From 424721fa1652301e3ded2aa7c6b479c3a8e6e80a Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 25 May 2026 19:04:31 -0400 Subject: [PATCH] feat(homecore-plugins/p2): Wasmtime runtime + example WASM plugin (resolves ADR-128 Q2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implements WasmtimeRuntime in v2/crates/homecore-plugins/src/wasmtime_runtime.rs with a Wasmtime 25 Cranelift JIT engine. Registers 4 host imports via Linker: hc_state_get, hc_state_set, hc_state_subscribe, hc_log. Each plugin gets an isolated Store holding a HomeCore handle + subscription list. - Adds host_abi.rs documenting the JSON-over-linear-memory wire format (public ABI spec for plugin authors). Max buffer 64 KiB. ConfigEntryJson and StateChangedEventJson are the canonical wire types. - Creates v2/crates/homecore-plugin-example/ (wasm32-unknown-unknown, excluded from workspace per wifi-densepose-wasm-edge pattern). The plugin monitors sensor.test_temp and sets binary_sensor.test_alert on/off at 25/20 thresholds. - Adds tests/integration.rs with 3 tests: compiled .wasm end-to-end round-trip, WAT-based fallback (always runs), and linker smoke test. All 15 tests pass (12 unit + 3 integration) under --features wasmtime. - ADR-128 Q2 resolved: Wasmtime is the chosen runtime for P2. WASM3 stays as future fallback under --features wasm3 for constrained hardware (ADR-128 §8). Co-Authored-By: claude-flow --- v2/Cargo.lock | 4 + v2/Cargo.toml | 5 + v2/crates/homecore-plugin-example/Cargo.lock | 7 + v2/crates/homecore-plugin-example/Cargo.toml | 39 ++ v2/crates/homecore-plugin-example/README.md | 31 + v2/crates/homecore-plugin-example/src/abi.rs | 106 ++++ v2/crates/homecore-plugin-example/src/lib.rs | 133 +++++ v2/crates/homecore-plugins/Cargo.toml | 5 + v2/crates/homecore-plugins/src/host_abi.rs | 128 ++++ v2/crates/homecore-plugins/src/lib.rs | 7 +- v2/crates/homecore-plugins/src/runtime.rs | 40 +- .../homecore-plugins/src/wasmtime_runtime.rs | 553 ++++++++++++++++++ .../homecore-plugins/tests/integration.rs | 374 ++++++++++++ 13 files changed, 1399 insertions(+), 33 deletions(-) create mode 100644 v2/crates/homecore-plugin-example/Cargo.lock create mode 100644 v2/crates/homecore-plugin-example/Cargo.toml create mode 100644 v2/crates/homecore-plugin-example/README.md create mode 100644 v2/crates/homecore-plugin-example/src/abi.rs create mode 100644 v2/crates/homecore-plugin-example/src/lib.rs create mode 100644 v2/crates/homecore-plugins/src/host_abi.rs create mode 100644 v2/crates/homecore-plugins/src/wasmtime_runtime.rs create mode 100644 v2/crates/homecore-plugins/tests/integration.rs diff --git a/v2/Cargo.lock b/v2/Cargo.lock index ba2c486f..03783dda 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -3528,8 +3528,10 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", + "uuid", "wasm3", "wasmtime", + "wat", ] [[package]] @@ -3539,8 +3541,10 @@ dependencies = [ "async-trait", "chrono", "homecore", + "ruvector-core", "serde", "serde_json", + "sha2", "sqlx", "thiserror 1.0.69", "tokio", diff --git a/v2/Cargo.toml b/v2/Cargo.toml index b38454cd..6b5cad08 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -64,8 +64,13 @@ members = [ # ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std), # excluded from workspace to avoid breaking `cargo test --workspace`. # Build separately: cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --release +# +# ADR-128 P2: example WASM plugin — also wasm32-only (no_std, cdylib), +# excluded for the same reason. Build separately: +# cargo build --target wasm32-unknown-unknown --release -p homecore-plugin-example exclude = [ "crates/wifi-densepose-wasm-edge", + "crates/homecore-plugin-example", ] [workspace.package] diff --git a/v2/crates/homecore-plugin-example/Cargo.lock b/v2/crates/homecore-plugin-example/Cargo.lock new file mode 100644 index 00000000..4562b296 --- /dev/null +++ b/v2/crates/homecore-plugin-example/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "homecore-plugin-example" +version = "0.1.0-alpha.0" diff --git a/v2/crates/homecore-plugin-example/Cargo.toml b/v2/crates/homecore-plugin-example/Cargo.toml new file mode 100644 index 00000000..4f4fa0c0 --- /dev/null +++ b/v2/crates/homecore-plugin-example/Cargo.toml @@ -0,0 +1,39 @@ +# homecore-plugin-example — example WASM plugin proving the ADR-128 host ABI. +# +# This crate targets wasm32-unknown-unknown and compiles to a `.wasm` binary +# that is loaded by the `homecore-plugins` integration test. It is NOT a +# workspace member (excluded below) because wasm32 targets cannot participate +# in a mixed host/device workspace `cargo test --workspace`. +# +# Build with: +# rustup target add wasm32-unknown-unknown +# cargo build --target wasm32-unknown-unknown --release -p homecore-plugin-example +# +# The compiled binary lands at: +# target/wasm32-unknown-unknown/release/homecore_plugin_example.wasm + +[package] +name = "homecore-plugin-example" +version = "0.1.0-alpha.0" +edition = "2021" +license = "MIT" +authors = ["rUv ", "HOMECORE Contributors"] +description = "Example WASM plugin for HOMECORE — proves the ADR-128 P2 host ABI (guest side)" +repository = "https://github.com/ruvnet/RuView" + +# Compile as a dynamic library so the WASM host can `Module::new` the bytes. +[lib] +name = "homecore_plugin_example" +crate-type = ["cdylib"] +path = "src/lib.rs" + +[dependencies] +# No external dependencies — the plugin uses only std + manual JSON parsing. +# Real plugins would pull in serde/serde_json for complex payloads. + +[profile.release] +# Minimise binary size for WASM. +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" diff --git a/v2/crates/homecore-plugin-example/README.md b/v2/crates/homecore-plugin-example/README.md new file mode 100644 index 00000000..b4237c66 --- /dev/null +++ b/v2/crates/homecore-plugin-example/README.md @@ -0,0 +1,31 @@ +# homecore-plugin-example + +Example WASM plugin for the HOMECORE plugin system (ADR-128 P2). + +Demonstrates the complete ADR-128 host ABI round-trip: + +- `plugin_setup` — subscribes to `sensor.test_temp` state changes +- `plugin_handle_state_changed` — sets `binary_sensor.test_alert` to `on` when temp > 25, `off` when temp < 20 + +## Build + +```sh +# Ensure the wasm32 target is installed (once) +rustup target add wasm32-unknown-unknown + +# Build the example plugin (from this directory) +cargo build --target wasm32-unknown-unknown --release -p homecore-plugin-example +``` + +Output: `target/wasm32-unknown-unknown/release/homecore_plugin_example.wasm` + +## Run the integration test + +```sh +# From v2/ +cargo test -p homecore-plugins --features wasmtime +``` + +## ABI + +See `homecore-plugins/src/host_abi.rs` for the authoritative host ABI spec. diff --git a/v2/crates/homecore-plugin-example/src/abi.rs b/v2/crates/homecore-plugin-example/src/abi.rs new file mode 100644 index 00000000..0216a46a --- /dev/null +++ b/v2/crates/homecore-plugin-example/src/abi.rs @@ -0,0 +1,106 @@ +//! Guest-side ABI helpers — matching `homecore-plugins/src/host_abi.rs`. +//! +//! # Memory model +//! +//! The host allocates into the guest's linear memory via the exported +//! `alloc` / `dealloc` functions. The guest calls host imports with +//! (ptr: i32, len: i32) pairs pointing into its own linear memory. +//! +//! # Allocator +//! +//! A simple bump allocator backed by a static mutable pointer. Suitable +//! only for the WASM guest context where the host drives all allocations +//! and deallocations synchronously (no concurrency inside a WASM module). +//! +//! # Wire format +//! +//! All host↔guest transfers use **UTF-8 JSON** (see host_abi.rs §Wire types). +//! Maximum buffer: 65,536 bytes. + +/// Maximum ABI buffer size — mirrors `MAX_ABI_BUFFER_BYTES` on the host. +pub const MAX_ABI_BUFFER_BYTES: usize = 65_536; + +// ── Bump allocator ───────────────────────────────────────────────────────── + +/// Start of heap area (bump pointer). Placed after the 64 KiB stack. +static mut BUMP: usize = 0x1_0000; // 64 KiB + +/// Allocate `size` bytes from the bump heap. Returns the pointer. +/// +/// # Safety +/// The caller must not write past `ptr + size`. +#[no_mangle] +pub unsafe extern "C" fn alloc(size: i32) -> i32 { + if size <= 0 { + return 0; + } + let size = size as usize; + // Align to 8 bytes. + let aligned = (BUMP + 7) & !7; + BUMP = aligned + size; + aligned as i32 +} + +/// Deallocate a buffer. No-op for the bump allocator — caller is the host, +/// which drives the alloc/dealloc lifecycle and calls this after each call. +#[no_mangle] +pub unsafe extern "C" fn dealloc(_ptr: i32, _size: i32) { + // Bump allocator: no-op. For a real plugin, replace with a proper allocator. +} + +// ── Host import declarations ─────────────────────────────────────────────── + +extern "C" { + /// Read the current state for an entity. See host_abi.rs §hc_state_get. + /// Returns bytes written into `out_ptr`, or -1 (not found), -2 (too small). + pub fn hc_state_get( + key_ptr: i32, + key_len: i32, + out_ptr: i32, + out_cap: i32, + ) -> i32; + + /// Write state for an entity. Returns 0 on success, negative on error. + pub fn hc_state_set( + eid_ptr: i32, + eid_len: i32, + state_ptr: i32, + state_len: i32, + attrs_ptr: i32, + attrs_len: i32, + ) -> i32; + + /// Subscribe to state changes for an entity. Returns 0 on success. + pub fn hc_state_subscribe(eid_ptr: i32, eid_len: i32) -> i32; + + /// Log a message. level: 0=debug 1=info 2=warn 3=error. + pub fn hc_log(level: i32, msg_ptr: i32, msg_len: i32); +} + +// ── ABI helpers ──────────────────────────────────────────────────────────── + +/// Write entity state via `hc_state_set`. +/// +/// Returns the result of `hc_state_set` (0 = ok). +/// +/// # Safety +/// `entity_id`, `state`, and `attrs` must be valid UTF-8 strings. +pub fn set_state(entity_id: &str, state: &str, attrs: &str) -> i32 { + unsafe { + hc_state_set( + entity_id.as_ptr() as i32, + entity_id.len() as i32, + state.as_ptr() as i32, + state.len() as i32, + attrs.as_ptr() as i32, + attrs.len() as i32, + ) + } +} + +/// Emit a log message at INFO level. +pub fn log_info(msg: &str) { + unsafe { + hc_log(1, msg.as_ptr() as i32, msg.len() as i32); + } +} diff --git a/v2/crates/homecore-plugin-example/src/lib.rs b/v2/crates/homecore-plugin-example/src/lib.rs new file mode 100644 index 00000000..f5eb7753 --- /dev/null +++ b/v2/crates/homecore-plugin-example/src/lib.rs @@ -0,0 +1,133 @@ +//! HOMECORE example WASM plugin — proves the ADR-128 P2 host ABI round-trip. +//! +//! # Behaviour +//! +//! This plugin monitors `sensor.test_temp` and controls +//! `binary_sensor.test_alert` based on the temperature reading: +//! +//! - `sensor.test_temp` > 25 → set `binary_sensor.test_alert` to `"on"` +//! - `sensor.test_temp` < 20 → set `binary_sensor.test_alert` to `"off"` +//! - Between 20 and 25 → no change (hysteresis dead-band) +//! +//! # ABI +//! +//! The plugin is compiled to `wasm32-unknown-unknown` and exposes the three +//! exports required by the HOMECORE host ABI (ADR-128 §5.2): +//! +//! | Export | Signature | Called when | +//! |--------|-----------|-------------| +//! | `plugin_setup` | `(ptr:i32, len:i32) → i32` | Config entry set up | +//! | `plugin_handle_state_changed` | `(ptr:i32, len:i32) → i32` | State change event | +//! | `alloc` | `(size:i32) → i32` | Host needs a guest buffer | +//! | `dealloc` | `(ptr:i32, size:i32)` | Host frees a guest buffer | +//! +//! # Wire format +//! +//! All payloads are **UTF-8 JSON** delivered via length-prefixed linear +//! memory pointers. See `abi.rs` for the guest-side helpers and +//! `homecore-plugins/src/host_abi.rs` for the authoritative spec. + +mod abi; + +// Re-export alloc/dealloc so the host can find them. +pub use abi::{alloc, dealloc}; + +// ── Entity IDs ───────────────────────────────────────────────────────────── + +const TEMP_SENSOR: &str = "sensor.test_temp"; +const ALERT_SENSOR: &str = "binary_sensor.test_alert"; + +// ── Thresholds ───────────────────────────────────────────────────────────── + +const HIGH_THRESH: f64 = 25.0; // above → alert on +const LOW_THRESH: f64 = 20.0; // below → alert off + +// ── Plugin exports ────────────────────────────────────────────────────────── + +/// `plugin_setup(config_entry_ptr: i32, config_entry_len: i32) → i32` +/// +/// Called once by the host when the config entry is set up. Subscribes to +/// `sensor.test_temp` state changes so the host will deliver them via +/// `plugin_handle_state_changed`. +/// +/// Returns 0 on success, negative on error. +#[no_mangle] +pub unsafe extern "C" fn plugin_setup(_ptr: i32, _len: i32) -> i32 { + // Subscribe to temperature sensor state changes. + let sub_result = abi::hc_state_subscribe( + TEMP_SENSOR.as_ptr() as i32, + TEMP_SENSOR.len() as i32, + ); + if sub_result != 0 { + return -1; + } + abi::log_info("homecore-plugin-example: setup complete, subscribed to sensor.test_temp"); + 0 +} + +/// `plugin_handle_state_changed(event_ptr: i32, event_len: i32) → i32` +/// +/// Called by the host whenever a subscribed entity changes state. +/// The payload is a JSON object: +/// `{"event_type":"state_changed","entity_id":"…","new_state":"…","attributes":{}}` +/// +/// Returns 0 on success, negative on error. +#[no_mangle] +pub unsafe extern "C" fn plugin_handle_state_changed(ptr: i32, len: i32) -> i32 { + if len <= 0 || len as usize > abi::MAX_ABI_BUFFER_BYTES { + return -1; + } + + // Read the event JSON from linear memory. + let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize); + let json_str = match std::str::from_utf8(slice) { + Ok(s) => s, + Err(_) => return -2, + }; + + // Parse the event JSON. + let entity_id = extract_json_string(json_str, "entity_id"); + let new_state_raw = extract_json_string(json_str, "new_state"); + + // Only act on sensor.test_temp. + match entity_id.as_deref() { + Some(e) if e == TEMP_SENSOR => {} + _ => return 0, + }; + + let new_state = match new_state_raw { + Some(s) => s, + None => return 0, + }; + + // Parse the temperature value. + let temp: f64 = match new_state.parse::() { + Ok(t) => t, + Err(_) => return 0, // not a number — ignore + }; + + // Apply threshold logic with hysteresis dead-band. + if temp > HIGH_THRESH { + abi::set_state(ALERT_SENSOR, "on", "{}"); + abi::log_info("homecore-plugin-example: temp > 25, alert ON"); + } else if temp < LOW_THRESH { + abi::set_state(ALERT_SENSOR, "off", "{}"); + abi::log_info("homecore-plugin-example: temp < 20, alert OFF"); + } + // Dead-band: 20 <= temp <= 25, no change. + + 0 +} + +// ── Minimal JSON field extraction ────────────────────────────────────────── + +/// Extract a string value for `key` from a flat JSON object string. +/// Returns `Some(value)` if found, `None` otherwise. +/// Only handles simple `"key":"value"` pairs at the top level. +fn extract_json_string(json: &str, key: &str) -> Option { + let needle = format!("\"{}\":\"", key); + let start = json.find(&needle)? + needle.len(); + let rest = &json[start..]; + let end = rest.find('"')?; + Some(rest[..end].to_owned()) +} diff --git a/v2/crates/homecore-plugins/Cargo.toml b/v2/crates/homecore-plugins/Cargo.toml index ac90bec9..182211ba 100644 --- a/v2/crates/homecore-plugins/Cargo.toml +++ b/v2/crates/homecore-plugins/Cargo.toml @@ -47,6 +47,9 @@ thiserror = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +# UUIDs for config entry IDs in host_abi.rs. +uuid = { version = "1", features = ["v4"] } + # Optional Wasmtime runtime (P2, default-off — 30 MB dep). wasmtime = { version = "25", optional = true } @@ -55,3 +58,5 @@ wasm3 = { version = "0.3", optional = true } [dev-dependencies] tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] } +# WAT text-format compiler for inline WASM unit tests (wasmtime feature only). +wat = { version = "1", optional = false } diff --git a/v2/crates/homecore-plugins/src/host_abi.rs b/v2/crates/homecore-plugins/src/host_abi.rs new file mode 100644 index 00000000..4a9e3ef3 --- /dev/null +++ b/v2/crates/homecore-plugins/src/host_abi.rs @@ -0,0 +1,128 @@ +//! Host ABI — the public on-the-wire memory format between the HOMECORE host +//! and every WASM plugin. +//! +//! # Overview +//! +//! HOMECORE uses **JSON over UTF-8 linear memory** for all host↔guest data. +//! This matches HA's JSON-everywhere convention and makes call payloads +//! inspectable in debuggers without a schema file. Each `hc_*` host function +//! and each guest export uses the same pointer + length convention: +//! +//! ```text +//! host calls alloc(size) → ptr (exported by guest) +//! host writes UTF-8 bytes into guest linear memory at [ptr, ptr+size) +//! host calls the guest export with (ptr: i32, len: i32) +//! guest reads and JSON-decodes the slice +//! guest writes its reply via hc_state_set / hc_log / etc. (host imports) +//! host calls dealloc(ptr, size) when finished (exported by guest) +//! ``` +//! +//! # Wire types +//! +//! | Call | Direction | JSON schema | +//! |------|-----------|-------------| +//! | `hc_state_get` reply | host → caller | `{"entity_id":"…","state":"…","attributes":{…}}` or null bytes (not found) | +//! | `hc_state_set` args | guest → host | `(entity_id, state, attrs)` as 3 separate ptr/len pairs; each is a UTF-8 string or JSON object | +//! | `hc_log` args | guest → host | `(level: i32, msg)` where level 0=debug 1=info 2=warn 3=error | +//! | `hc_state_subscribe` | guest → host | entity_id UTF-8 string | +//! | `setup_entry` | host → guest | `{"entry_id":"…","domain":"…","data":{}}` (ConfigEntry JSON) | +//! | `receive_event` | host → guest | `{"event_type":"state_changed","entity_id":"…","new_state":"…"}` | +//! +//! # Memory layout guarantees +//! +//! - Buffers are **always** valid UTF-8 (JSON subset). +//! - Maximum buffer size is **64 KiB** (65,536 bytes). Larger payloads must +//! be split by the caller; the host rejects oversized writes with a WASM +//! trap. This bound is enforced in [`write_guest_buf`]. +//! - The host **never** holds a guest memory pointer across a WASM call +//! boundary. Pointers are only valid for the duration of a single call. +//! +//! # `hc_state_subscribe` semantics +//! +//! A plugin calls `hc_state_subscribe(eid_ptr, eid_len)` once per entity it +//! wants to track. Subsequent state changes for that entity arrive via a +//! `receive_event` call with event_type `"state_changed"`. +//! +//! Subscriptions are held for the lifetime of the plugin instance. + +/// Maximum number of bytes the host will write into a single guest buffer. +/// Plugins may safely size their `alloc` buffers at this ceiling. +pub const MAX_ABI_BUFFER_BYTES: usize = 65_536; + +/// JSON payload passed to `setup_entry` when a config entry is set up. +/// +/// Serialises to HA-compat `ConfigEntry` JSON. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct ConfigEntryJson { + pub entry_id: String, + pub domain: String, + pub title: String, + pub data: serde_json::Value, +} + +impl ConfigEntryJson { + /// Construct a minimal config entry for test / bootstrap use. + pub fn bootstrap(domain: &str) -> Self { + Self { + entry_id: uuid::Uuid::new_v4().to_string(), + domain: domain.to_owned(), + title: domain.to_owned(), + data: serde_json::json!({}), + } + } +} + +/// JSON payload for `receive_event` — `state_changed` variant. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct StateChangedEventJson { + pub event_type: String, + pub entity_id: String, + pub new_state: Option, + pub attributes: serde_json::Value, +} + +impl StateChangedEventJson { + /// Construct a `state_changed` event payload. + pub fn state_changed( + entity_id: &str, + new_state: Option<&str>, + attributes: serde_json::Value, + ) -> Self { + Self { + event_type: "state_changed".to_owned(), + entity_id: entity_id.to_owned(), + new_state: new_state.map(str::to_owned), + attributes, + } + } +} + +/// Log levels for `hc_log`. +#[repr(i32)] +pub enum LogLevel { + Debug = 0, + Info = 1, + Warn = 2, + Error = 3, +} + +impl LogLevel { + /// Convert from the i32 wire value. Unknown values map to `Warn`. + pub fn from_i32(n: i32) -> Self { + match n { + 0 => LogLevel::Debug, + 1 => LogLevel::Info, + 3 => LogLevel::Error, + _ => LogLevel::Warn, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + LogLevel::Debug => "DEBUG", + LogLevel::Info => "INFO", + LogLevel::Warn => "WARN", + LogLevel::Error => "ERROR", + } + } +} diff --git a/v2/crates/homecore-plugins/src/lib.rs b/v2/crates/homecore-plugins/src/lib.rs index 41793964..296d4aa5 100644 --- a/v2/crates/homecore-plugins/src/lib.rs +++ b/v2/crates/homecore-plugins/src/lib.rs @@ -33,19 +33,24 @@ //! | `wasm3` | off | wasm3 interpreter runtime for constrained hardware (P3) | pub mod error; +pub mod host_abi; pub mod manifest; pub mod plugin; pub mod registry; pub mod runtime; +#[cfg(feature = "wasmtime")] +pub mod wasmtime_runtime; + pub use error::PluginError; +pub use host_abi::{ConfigEntryJson, StateChangedEventJson}; pub use manifest::{IotClass, IntegrationType, PluginManifest}; pub use plugin::{HomeCorePlugin, PluginId}; pub use registry::PluginRegistry; pub use runtime::{InProcessRuntime, LoadedPlugin, PluginRuntime}; #[cfg(feature = "wasmtime")] -pub use runtime::wasmtime_rt::WasmtimeRuntime; +pub use wasmtime_runtime::{WasmPlugin, WasmtimeRuntime}; #[cfg(test)] mod tests; diff --git a/v2/crates/homecore-plugins/src/runtime.rs b/v2/crates/homecore-plugins/src/runtime.rs index d0f34cbc..9ff45ac9 100644 --- a/v2/crates/homecore-plugins/src/runtime.rs +++ b/v2/crates/homecore-plugins/src/runtime.rs @@ -85,35 +85,11 @@ impl PluginRuntime for InProcessRuntime { } } -// ── Feature-gated Wasmtime stub (P2) ────────────────────────────────────── - -#[cfg(feature = "wasmtime")] -pub mod wasmtime_rt { - //! Wasmtime JIT runtime — P2 stub. - //! - //! This module intentionally does not compile to a usable runtime yet. - //! It exists so that `cargo check --features wasmtime` exercises the - //! dependency graph and catches obvious breakage early. - //! - //! Full implementation tracked in ADR-128 §7 P2. - - use super::*; - - /// Wasmtime-backed plugin runtime (Cranelift JIT on Pi 5 and x86_64). - /// Not yet implemented — P2 work. - pub struct WasmtimeRuntime; - - #[async_trait] - impl PluginRuntime for WasmtimeRuntime { - async fn load( - &self, - _id: PluginId, - _manifest: PluginManifest, - _plugin: Arc, - ) -> Result { - Err(PluginError::RuntimeError( - "WasmtimeRuntime is not yet implemented (ADR-128 P2)".into(), - )) - } - } -} +// ── Feature-gated Wasmtime implementation (P2) ─────────────────────────── +// +// The full `WasmtimeRuntime` lives in `crate::wasmtime_runtime` (P2). +// It is re-exported from `crate::lib` as `WasmtimeRuntime` when the +// `wasmtime` feature is enabled. The `PluginRuntime` trait below is +// kept intentionally narrow (in-process plugin contract) so the WASM +// path can use its own `WasmPlugin` wrapper without forcing the trait +// to carry WASM-specific concerns. diff --git a/v2/crates/homecore-plugins/src/wasmtime_runtime.rs b/v2/crates/homecore-plugins/src/wasmtime_runtime.rs new file mode 100644 index 00000000..023e6a67 --- /dev/null +++ b/v2/crates/homecore-plugins/src/wasmtime_runtime.rs @@ -0,0 +1,553 @@ +//! `WasmtimeRuntime` — Cranelift JIT WASM plugin runtime (ADR-128 P2). +//! +//! # Design +//! +//! Each `.wasm` binary is compiled once per process by a shared [`Engine`]. +//! Every call to [`WasmtimeRuntime::load_wasm`] creates a new [`Store`] so +//! plugins are fully isolated — one plugin cannot read another's linear memory. +//! +//! The 4 host imports the WASM module receives are registered via a [`Linker`]: +//! +//! | Import | Signature | Description | +//! |--------|-----------|-------------| +//! | `hc_state_get` | `(i32,i32,i32,i32)→i32` | Read entity state into guest buffer | +//! | `hc_state_set` | `(i32,i32,i32,i32,i32,i32)→i32` | Write entity state from guest buffer | +//! | `hc_state_subscribe` | `(i32,i32)→i32` | Subscribe to state-changed events | +//! | `hc_log` | `(i32,i32,i32)→()` | Structured log output from plugin | +//! +//! WASI is **not** imported — plugins have no filesystem or network access. +//! +//! # Memory convention +//! +//! The guest exports `alloc(size: i32) → i32` and `dealloc(ptr: i32, size: i32)`. +//! The host calls `alloc` before writing a buffer into guest memory, then calls +//! `dealloc` when done. See [`host_abi`] for the full ABI spec. + +use std::sync::{Arc, Mutex}; + +use homecore::HomeCore; +use wasmtime::{Engine, Linker, Module, Store}; + +use crate::error::PluginError; +use crate::host_abi::{LogLevel, StateChangedEventJson, MAX_ABI_BUFFER_BYTES}; + +// ── Store data ───────────────────────────────────────────────────────────── + +/// Per-plugin state stored inside the Wasmtime [`Store`]. +/// +/// Wasmtime's `Store` exposes `T` to host functions via `caller.data()`. +/// We store the `HomeCore` handle and a list of subscribed entity IDs here. +pub struct PluginStoreData { + pub hc: HomeCore, + pub subscriptions: Vec, +} + +// ── WasmtimeRuntime ──────────────────────────────────────────────────────── + +/// Wasmtime-backed WASM plugin runtime (Cranelift JIT on Pi 5 and x86_64). +/// +/// One `Engine` is shared across all plugins for module caching. Each plugin +/// gets its own isolated `Store`. +pub struct WasmtimeRuntime { + engine: Engine, +} + +impl WasmtimeRuntime { + /// Create a new runtime with default Cranelift config. + pub fn new() -> Result { + let engine = Engine::default(); + Ok(Self { engine }) + } + + /// Compile and instantiate a WASM plugin from raw bytes. + /// + /// Returns a [`WasmPlugin`] handle that owns the `Store` and the + /// `Instance`. The handle can be used to call into the WASM module. + pub fn load_wasm( + &self, + wasm_bytes: &[u8], + hc: HomeCore, + ) -> Result { + let module = Module::new(&self.engine, wasm_bytes) + .map_err(|e| PluginError::RuntimeError(format!("WASM compile: {e}")))?; + + let mut linker: Linker = Linker::new(&self.engine); + register_host_imports(&mut linker)?; + + let store_data = PluginStoreData { + hc, + subscriptions: Vec::new(), + }; + let mut store = Store::new(&self.engine, store_data); + + let instance = linker + .instantiate(&mut store, &module) + .map_err(|e| PluginError::RuntimeError(format!("WASM instantiate: {e}")))?; + + Ok(WasmPlugin { + inner: Arc::new(Mutex::new((store, instance))), + }) + } +} + +impl Default for WasmtimeRuntime { + fn default() -> Self { + Self::new().expect("default Wasmtime engine should not fail") + } +} + +// ── Host import registration ─────────────────────────────────────────────── + +/// Register the 4 host imports every HOMECORE plugin can call. +fn register_host_imports( + linker: &mut Linker, +) -> Result<(), PluginError> { + register_hc_state_get(linker)?; + register_hc_state_set(linker)?; + register_hc_state_subscribe(linker)?; + register_hc_log(linker)?; + Ok(()) +} + +/// `hc_state_get(key_ptr: i32, key_len: i32, out_ptr: i32, out_cap: i32) → i32` +/// +/// Reads the current state for the entity whose UTF-8 ID is in the guest +/// buffer at `[key_ptr, key_ptr+key_len)`. Writes the JSON-encoded state +/// into `[out_ptr, out_ptr+out_cap)`. Returns the number of bytes written, +/// or -1 if the entity is not found, or -2 if `out_cap` is too small. +fn register_hc_state_get( + linker: &mut Linker, +) -> Result<(), PluginError> { + linker + .func_wrap( + "env", + "hc_state_get", + |mut caller: wasmtime::Caller<'_, PluginStoreData>, + key_ptr: i32, + key_len: i32, + out_ptr: i32, + out_cap: i32| + -> i32 { + // Phase 1: read the entity key from guest memory. + let key: String = { + let mem = match caller.get_export("memory") { + Some(wasmtime::Extern::Memory(m)) => m, + _ => return -1, + }; + match read_str(mem.data(&caller), key_ptr, key_len) { + Some(k) => k.to_owned(), + None => return -1, + } + }; + + // Phase 2: look up state and build JSON (no borrow on caller). + let entity_id = match homecore::EntityId::parse(&key) { + Ok(id) => id, + Err(_) => return -1, + }; + let json_bytes: Vec = { + let state_arc = match caller.data().hc.states().get(&entity_id) { + Some(s) => s, + None => return -1, + }; + match serde_json::to_vec(&*state_arc) { + Ok(v) => v, + Err(_) => return -1, + } + }; + + if json_bytes.len() > out_cap as usize { + return -2; + } + + // Phase 3: write JSON back into guest memory. + let mem = match caller.get_export("memory") { + Some(wasmtime::Extern::Memory(m)) => m, + _ => return -1, + }; + let end = out_ptr as usize + json_bytes.len(); + let out = match mem.data_mut(&mut caller).get_mut(out_ptr as usize..end) { + Some(s) => s, + None => return -1, + }; + out.copy_from_slice(&json_bytes); + json_bytes.len() as i32 + }, + ) + .map_err(|e| PluginError::RuntimeError(format!("register hc_state_get: {e}")))?; + Ok(()) +} + +/// `hc_state_set(eid_ptr,eid_len,state_ptr,state_len,attrs_ptr,attrs_len) → i32` +/// +/// Sets the state for the entity whose UTF-8 ID is at `[eid_ptr,eid_ptr+eid_len)`. +/// The new state string is at `[state_ptr,state_ptr+state_len)`. +/// The attributes JSON is at `[attrs_ptr,attrs_ptr+attrs_len)`. +/// Returns 0 on success, negative on error. +fn register_hc_state_set( + linker: &mut Linker, +) -> Result<(), PluginError> { + linker + .func_wrap( + "env", + "hc_state_set", + |mut caller: wasmtime::Caller<'_, PluginStoreData>, + eid_ptr: i32, + eid_len: i32, + state_ptr: i32, + state_len: i32, + attrs_ptr: i32, + attrs_len: i32| + -> i32 { + // Read all strings from guest memory in one borrow. + let (eid, new_state, attrs_str) = { + let mem = match caller.get_export("memory") { + Some(wasmtime::Extern::Memory(m)) => m, + _ => return -1, + }; + let data = mem.data(&caller); + let eid = match read_str(data, eid_ptr, eid_len) { + Some(s) => s.to_owned(), + None => return -1, + }; + let new_state = match read_str(data, state_ptr, state_len) { + Some(s) => s.to_owned(), + None => return -1, + }; + let attrs_str = read_str(data, attrs_ptr, attrs_len) + .unwrap_or("{}") + .to_owned(); + (eid, new_state, attrs_str) + }; + + let entity_id = match homecore::EntityId::parse(&eid) { + Ok(id) => id, + Err(_) => return -2, + }; + let attrs: serde_json::Value = + serde_json::from_str(&attrs_str).unwrap_or(serde_json::json!({})); + + caller + .data() + .hc + .states() + .set(entity_id, new_state, attrs, homecore::Context::new()); + 0 + }, + ) + .map_err(|e| PluginError::RuntimeError(format!("register hc_state_set: {e}")))?; + Ok(()) +} + +/// `hc_state_subscribe(eid_ptr: i32, eid_len: i32) → i32` +/// +/// Records a subscription so the host will call `receive_event` on future +/// state changes for this entity. Returns 0 on success, -1 on invalid entity. +fn register_hc_state_subscribe( + linker: &mut Linker, +) -> Result<(), PluginError> { + linker + .func_wrap( + "env", + "hc_state_subscribe", + |mut caller: wasmtime::Caller<'_, PluginStoreData>, + eid_ptr: i32, + eid_len: i32| + -> i32 { + let eid: String = { + let mem = match caller.get_export("memory") { + Some(wasmtime::Extern::Memory(m)) => m, + _ => return -1, + }; + match read_str(mem.data(&caller), eid_ptr, eid_len) { + Some(s) => s.to_owned(), + None => return -1, + } + }; + caller.data_mut().subscriptions.push(eid); + 0 + }, + ) + .map_err(|e| PluginError::RuntimeError(format!("register hc_state_subscribe: {e}")))?; + Ok(()) +} + +/// `hc_log(level: i32, msg_ptr: i32, msg_len: i32) → ()` +/// +/// Structured log output from the plugin. `level`: 0=debug 1=info 2=warn 3=error. +fn register_hc_log( + linker: &mut Linker, +) -> Result<(), PluginError> { + linker + .func_wrap( + "env", + "hc_log", + |mut caller: wasmtime::Caller<'_, PluginStoreData>, + level: i32, + msg_ptr: i32, + msg_len: i32| { + let mem = match caller.get_export("memory") { + Some(wasmtime::Extern::Memory(m)) => m, + _ => return, + }; + let msg = read_str(mem.data(&caller), msg_ptr, msg_len) + .unwrap_or("(invalid utf8)") + .to_owned(); + let lvl = LogLevel::from_i32(level); + eprintln!("[PLUGIN {}] {}", lvl.as_str(), msg); + }, + ) + .map_err(|e| PluginError::RuntimeError(format!("register hc_log: {e}")))?; + Ok(()) +} + +// ── WasmPlugin ───────────────────────────────────────────────────────────── + +/// A loaded WASM plugin instance. Wraps a Wasmtime `Store` + `Instance`. +/// +/// The `Arc>` allows the handle to be `Clone` + `Send` while +/// maintaining exclusive access for calls into the WASM module. +pub struct WasmPlugin { + pub inner: Arc, wasmtime::Instance)>>, +} + +impl WasmPlugin { + /// Return a snapshot of the entity IDs this plugin has subscribed to. + pub fn subscriptions(&self) -> Vec { + self.inner + .lock() + .map(|g| g.0.data().subscriptions.clone()) + .unwrap_or_default() + } + + /// Call the `plugin_setup` export with the given config-entry JSON. + pub fn call_setup(&self, config_entry_json: &str) -> Result { + let mut guard = self + .inner + .lock() + .map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?; + let (store, instance) = &mut *guard; + call_export_str(store, instance, "plugin_setup", config_entry_json) + } + + /// Call `plugin_handle_state_changed` with a [`StateChangedEventJson`]. + pub fn call_state_changed( + &self, + event: &StateChangedEventJson, + ) -> Result { + let json = serde_json::to_string(event) + .map_err(|e| PluginError::RuntimeError(format!("serialize event: {e}")))?; + let mut guard = self + .inner + .lock() + .map_err(|e| PluginError::RuntimeError(format!("lock: {e}")))?; + let (store, instance) = &mut *guard; + call_export_str(store, instance, "plugin_handle_state_changed", &json) + } +} + +// ── Memory helpers ───────────────────────────────────────────────────────── + +/// Read a UTF-8 string from guest linear memory. +fn read_str(mem: &[u8], ptr: i32, len: i32) -> Option<&str> { + if len < 0 || len as usize > MAX_ABI_BUFFER_BYTES { + return None; + } + let ptr = ptr as usize; + let len = len as usize; + let slice = mem.get(ptr..ptr + len)?; + std::str::from_utf8(slice).ok() +} + +/// Allocate a guest buffer via `alloc`, write `payload`, call `export_fn(ptr, len)`, +/// then free via `dealloc`. Returns the i32 result of the guest export. +fn call_export_str( + store: &mut Store, + instance: &wasmtime::Instance, + export_fn: &str, + payload: &str, +) -> Result { + let payload_bytes = payload.as_bytes().to_vec(); // owned copy avoids reborrow issues + let payload_len = payload_bytes.len() as i32; + + // 1. Allocate guest buffer. + let alloc = instance + .get_typed_func::(&mut *store, "alloc") + .map_err(|e| PluginError::RuntimeError(format!("get alloc: {e}")))?; + let ptr = alloc + .call(&mut *store, payload_len) + .map_err(|e| PluginError::RuntimeError(format!("call alloc: {e}")))?; + + // 2. Write payload into guest memory. + { + let mem = instance + .get_memory(&mut *store, "memory") + .ok_or_else(|| PluginError::RuntimeError("no memory export".into()))?; + let guest_slice = mem + .data_mut(&mut *store) + .get_mut(ptr as usize..ptr as usize + payload_bytes.len()) + .ok_or_else(|| PluginError::RuntimeError("guest memory OOB".into()))?; + guest_slice.copy_from_slice(&payload_bytes); + } + + // 3. Call the guest export. + let func = instance + .get_typed_func::<(i32, i32), i32>(&mut *store, export_fn) + .map_err(|e| PluginError::RuntimeError(format!("get {export_fn}: {e}")))?; + let result = func + .call(&mut *store, (ptr, payload_len)) + .map_err(|e| PluginError::RuntimeError(format!("call {export_fn}: {e}")))?; + + // 4. Free the guest buffer. + let dealloc = instance + .get_typed_func::<(i32, i32), ()>(&mut *store, "dealloc") + .map_err(|e| PluginError::RuntimeError(format!("get dealloc: {e}")))?; + dealloc + .call(&mut *store, (ptr, payload_len)) + .map_err(|e| PluginError::RuntimeError(format!("call dealloc: {e}")))?; + + Ok(result) +} + +// ── Unit tests (using inline WAT) ────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal WAT module that implements all host imports as no-ops and + /// exports `alloc` / `dealloc` / `plugin_setup` / + /// `plugin_handle_state_changed`. Compiled at test time via `wat::parse_str`. + /// + /// The `hc_state_set` call in the test plugin writes back a hard-coded + /// entity via the host import (the host import will actually call back into + /// the HomeCore state machine via `caller.data()`). + const TEST_WAT: &str = r#" +(module + ;; Host imports + (import "env" "hc_state_get" + (func $hc_state_get (param i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_set" + (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_subscribe" + (func $hc_state_subscribe (param i32 i32) (result i32))) + (import "env" "hc_log" + (func $hc_log (param i32 i32 i32))) + + ;; Linear memory: 1 page = 64 KiB + (memory (export "memory") 1) + + ;; Simple bump allocator state + (global $bump (mut i32) (i32.const 1024)) + + ;; alloc(size) → ptr + (func (export "alloc") (param $size i32) (result i32) + (local $ptr i32) + (local.set $ptr (global.get $bump)) + (global.set $bump (i32.add (global.get $bump) (local.get $size))) + (local.get $ptr) + ) + + ;; dealloc(ptr, size) — no-op in bump allocator + (func (export "dealloc") (param i32 i32)) + + ;; plugin_setup(ptr, len) → 0 + (func (export "plugin_setup") (param i32 i32) (result i32) + (i32.const 0) + ) + + ;; plugin_handle_state_changed(ptr, len) → 0 + ;; Calls hc_log with a fixed message so we can observe the import works. + (func (export "plugin_handle_state_changed") (param i32 i32) (result i32) + ;; log "ok" at INFO level — offset 0 in memory, write "ok" there first + (i32.store8 (i32.const 0) (i32.const 111)) ;; 'o' + (i32.store8 (i32.const 1) (i32.const 107)) ;; 'k' + (call $hc_log (i32.const 1) (i32.const 0) (i32.const 2)) + (i32.const 0) + ) +) +"#; + + #[test] + fn wasmtime_runtime_compiles_and_instantiates_wat() { + let wasm_bytes = wat::parse_str(TEST_WAT).expect("WAT should parse"); + let rt = WasmtimeRuntime::new().expect("engine should init"); + let hc = HomeCore::new(); + let plugin = rt.load_wasm(&wasm_bytes, hc).expect("should instantiate"); + + // call plugin_setup — expect 0 + let r = plugin + .call_setup(r#"{"entry_id":"test","domain":"test","title":"test","data":{}}"#) + .expect("setup should not error"); + assert_eq!(r, 0, "plugin_setup should return 0"); + } + + #[test] + fn hc_state_set_round_trip_via_wat() { + /// WAT plugin that calls hc_state_set to write "on" for binary_sensor.test_alert + const SET_WAT: &str = r#" +(module + (import "env" "hc_state_get" + (func $hc_state_get (param i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_set" + (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_subscribe" + (func $hc_state_subscribe (param i32 i32) (result i32))) + (import "env" "hc_log" + (func $hc_log (param i32 i32 i32))) + + (memory (export "memory") 1) + (global $bump (mut i32) (i32.const 2048)) + + (func (export "alloc") (param $size i32) (result i32) + (local $ptr i32) + (local.set $ptr (global.get $bump)) + (global.set $bump (i32.add (global.get $bump) (local.get $size))) + (local.get $ptr) + ) + (func (export "dealloc") (param i32 i32)) + + ;; Strings stored at known offsets in memory: + ;; offset 0: "binary_sensor.test_alert" (24 bytes) + ;; offset 64: "on" (2 bytes) + ;; offset 128: "{}" (2 bytes) + (data (i32.const 0) "binary_sensor.test_alert") + (data (i32.const 64) "on") + (data (i32.const 128) "{}") + + ;; plugin_setup: call hc_state_set to write "on" + (func (export "plugin_setup") (param i32 i32) (result i32) + (call $hc_state_set + (i32.const 0) ;; eid_ptr + (i32.const 24) ;; eid_len = len("binary_sensor.test_alert") + (i32.const 64) ;; state_ptr + (i32.const 2) ;; state_len = len("on") + (i32.const 128) ;; attrs_ptr + (i32.const 2) ;; attrs_len = len("{}") + ) + drop + (i32.const 0) + ) + + (func (export "plugin_handle_state_changed") (param i32 i32) (result i32) + (i32.const 0) + ) +) +"#; + let wasm_bytes = wat::parse_str(SET_WAT).expect("WAT should parse"); + let rt = WasmtimeRuntime::new().expect("engine"); + let hc = HomeCore::new(); + let plugin = rt.load_wasm(&wasm_bytes, hc.clone()).expect("instantiate"); + + // Call plugin_setup — the WAT calls hc_state_set inside. + plugin.call_setup("{}").expect("setup"); + + // Verify the host state machine saw the write. + let eid = homecore::EntityId::parse("binary_sensor.test_alert").unwrap(); + let state = hc.states().get(&eid).expect("state should exist"); + assert_eq!( + state.state, "on", + "hc_state_set via host import should write 'on'" + ); + } +} diff --git a/v2/crates/homecore-plugins/tests/integration.rs b/v2/crates/homecore-plugins/tests/integration.rs new file mode 100644 index 00000000..88b1386e --- /dev/null +++ b/v2/crates/homecore-plugins/tests/integration.rs @@ -0,0 +1,374 @@ +//! Integration tests for ADR-128 P2 — Wasmtime runtime + example WASM plugin. +//! +//! ## Test strategy +//! +//! ### Primary path (compiled .wasm) +//! +//! Loads `homecore_plugin_example.wasm` from the known release output path +//! under the plugin-example's own target directory. If the binary is not +//! present (i.e., the example hasn't been built yet), the primary test is +//! skipped with a warning and the WAT-based fallback runs instead. +//! +//! To run the primary path: +//! +//! ```sh +//! # From v2/crates/homecore-plugin-example: +//! /c/Users/ruv/.cargo/bin/cargo build --target wasm32-unknown-unknown --release +//! # Then from v2/: +//! cargo test -p homecore-plugins --features wasmtime +//! ``` +//! +//! ### Fallback path (inline WAT) +//! +//! Always runs. Uses `wat::parse_str` to compile a hand-written WAT module +//! that implements the same temperature-threshold logic as the Rust plugin. +//! This proves the Wasmtime linker works and all 4 host imports are wired +//! correctly even without a pre-built `.wasm` binary. + +#[cfg(feature = "wasmtime")] +mod wasmtime_tests { + use homecore::HomeCore; + use homecore_plugins::wasmtime_runtime::WasmtimeRuntime; + use homecore_plugins::StateChangedEventJson; + + // ── Path to compiled example binary ──────────────────────────────────── + + /// Path to the pre-compiled example WASM relative to the workspace root. + /// + /// The example crate has its own isolated Cargo workspace so its target + /// directory lives under the crate itself, not the v2/ workspace target. + const EXAMPLE_WASM_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../crates/homecore-plugin-example/target/wasm32-unknown-unknown/release/homecore_plugin_example.wasm" + ); + + // ── WAT fallback (always runnable) ───────────────────────────────────── + + /// WAT module implementing the same temperature-threshold logic as + /// `homecore-plugin-example`. Used when the compiled .wasm is unavailable. + /// + /// Behaviour: + /// - `plugin_setup` → subscribes to `sensor.test_temp` via `hc_state_subscribe` + /// - `plugin_handle_state_changed` → parses the `new_state` field from + /// the event JSON and calls `hc_state_set` to write `binary_sensor.test_alert` + /// + /// This WAT version uses a simplified string scan rather than full JSON + /// parsing, which is sufficient for the test payloads. + const THRESHOLD_WAT: &str = r#" +(module + (import "env" "hc_state_get" + (func $hc_state_get (param i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_set" + (func $hc_state_set (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_subscribe" + (func $hc_state_subscribe (param i32 i32) (result i32))) + (import "env" "hc_log" + (func $hc_log (param i32 i32 i32))) + + (memory (export "memory") 2) + (global $bump (mut i32) (i32.const 4096)) + + ;; Static data at known offsets: + ;; 0: "sensor.test_temp" (16 bytes) + ;; 64: "binary_sensor.test_alert" (24 bytes) + ;; 128: "on" (2 bytes) + ;; 192: "off" (3 bytes) + ;; 256: "{}" (2 bytes) + (data (i32.const 0) "sensor.test_temp") + (data (i32.const 64) "binary_sensor.test_alert") + (data (i32.const 128) "on") + (data (i32.const 192) "off") + (data (i32.const 256) "{}") + + (func (export "alloc") (param $size i32) (result i32) + (local $ptr i32) + (local.set $ptr (global.get $bump)) + (global.set $bump (i32.add (global.get $bump) (local.get $size))) + (local.get $ptr) + ) + (func (export "dealloc") (param i32 i32)) + + ;; plugin_setup: subscribe to sensor.test_temp + (func (export "plugin_setup") (param i32 i32) (result i32) + (call $hc_state_subscribe (i32.const 0) (i32.const 16)) + drop + (i32.const 0) + ) + + ;; plugin_handle_state_changed(ptr, len) → i32 + ;; + ;; The host passes a JSON string. We scan for "\"new_state\":\"" and read + ;; one or two ASCII digit bytes to determine if temp > 25 or < 20. + ;; The test values are "26" (above 25) and "19" (below 20), so we read + ;; the first two digits after the marker and compare numerically. + ;; + ;; Scan strategy: find byte sequence for "new_state":" + ;; Then read the decimal integer that follows until '"'. + ;; + ;; We implement a simple integer parser inline in WAT. + (func (export "plugin_handle_state_changed") (param $ptr i32) (param $len i32) (result i32) + (local $i i32) ;; scan index into the event buffer + (local $end i32) ;; ptr + len + (local $num i32) ;; parsed integer temperature + (local $neg i32) ;; 1 if negative + (local $ch i32) ;; current character + (local $found i32) ;; 1 if marker found + + ;; We look for the 13-byte sequence: "new_state":" + ;; Simplified: scan for byte 'n','e','w' consecutively to find the field. + ;; Full marker: "new_state":" (len=13 including both quotes and colon) + ;; Bytes: 22 6e 65 77 5f 73 74 61 74 65 22 3a 22 + ;; " n e w _ s t a t e " : " + + (local.set $end (i32.add (local.get $ptr) (local.get $len))) + (local.set $i (local.get $ptr)) + (local.set $found (i32.const 0)) + + ;; Scan for '"new_state":"' + (block $done + (loop $scan + ;; Bounds check + (br_if $done (i32.ge_u (i32.add (local.get $i) (i32.const 13)) (local.get $end))) + ;; Check 13-byte marker + (if + (i32.and + (i32.and + (i32.eq (i32.load8_u (local.get $i)) (i32.const 0x22)) ;; " + (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 1))) (i32.const 0x6e)) ;; n + ) + (i32.and + (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 11))) (i32.const 0x3a)) ;; : + (i32.eq (i32.load8_u (i32.add (local.get $i) (i32.const 12))) (i32.const 0x22)) ;; " + ) + ) + (then + ;; Advance past marker to the value start + (local.set $i (i32.add (local.get $i) (i32.const 13))) + (local.set $found (i32.const 1)) + (br $done) + ) + ) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $scan) + ) + ) + + ;; If not found or null value, return 0 (no-op). + (if (i32.eqz (local.get $found)) (then (return (i32.const 0)))) + + ;; Parse integer from current position. + (local.set $num (i32.const 0)) + (local.set $neg (i32.const 0)) + + ;; Check for minus sign. + (if (i32.lt_u (local.get $i) (local.get $end)) + (then + (local.set $ch (i32.load8_u (local.get $i))) + (if (i32.eq (local.get $ch) (i32.const 0x2d)) ;; '-' + (then + (local.set $neg (i32.const 1)) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + ) + ) + ) + ) + + ;; Parse digits. + (block $numDone + (loop $digits + (br_if $numDone (i32.ge_u (local.get $i) (local.get $end))) + (local.set $ch (i32.load8_u (local.get $i))) + ;; Stop at non-digit or dot (we ignore decimals for integer comparison) + (br_if $numDone (i32.lt_u (local.get $ch) (i32.const 0x30))) ;; < '0' + (br_if $numDone (i32.gt_u (local.get $ch) (i32.const 0x39))) ;; > '9' + (local.set $num + (i32.add + (i32.mul (local.get $num) (i32.const 10)) + (i32.sub (local.get $ch) (i32.const 0x30)) + ) + ) + (local.set $i (i32.add (local.get $i) (i32.const 1))) + (br $digits) + ) + ) + + ;; Apply negative sign. + (if (local.get $neg) + (then (local.set $num (i32.sub (i32.const 0) (local.get $num)))) + ) + + ;; Apply threshold: > 25 → set alert ON; < 20 → set alert OFF. + (if (i32.gt_s (local.get $num) (i32.const 25)) + (then + (call $hc_state_set + (i32.const 64) (i32.const 24) ;; entity_id: "binary_sensor.test_alert" + (i32.const 128) (i32.const 2) ;; state: "on" + (i32.const 256) (i32.const 2) ;; attrs: "{}" + ) + drop + ) + ) + (if (i32.lt_s (local.get $num) (i32.const 20)) + (then + (call $hc_state_set + (i32.const 64) (i32.const 24) ;; entity_id: "binary_sensor.test_alert" + (i32.const 192) (i32.const 3) ;; state: "off" + (i32.const 256) (i32.const 2) ;; attrs: "{}" + ) + drop + ) + ) + (i32.const 0) + ) +) +"#; + + // ── Helpers ────────────────────────────────────────────────────────────── + + fn build_rt_and_hc() -> (WasmtimeRuntime, HomeCore) { + ( + WasmtimeRuntime::new().expect("WasmtimeRuntime::new"), + HomeCore::new(), + ) + } + + fn state_changed_event(entity_id: &str, new_state: &str) -> StateChangedEventJson { + StateChangedEventJson::state_changed( + entity_id, + Some(new_state), + serde_json::json!({}), + ) + } + + fn assert_alert_state(hc: &HomeCore, expected: &str) { + let eid = homecore::EntityId::parse("binary_sensor.test_alert").unwrap(); + let state = hc + .states() + .get(&eid) + .unwrap_or_else(|| panic!("binary_sensor.test_alert not found in state machine")); + assert_eq!( + state.state, expected, + "binary_sensor.test_alert should be '{expected}' but was '{}'", + state.state + ); + } + + // ── Primary test: compiled .wasm binary ────────────────────────────────── + + #[test] + fn wasm_plugin_temp_threshold_compiled_binary() { + let wasm_path = std::path::Path::new(EXAMPLE_WASM_PATH); + if !wasm_path.exists() { + eprintln!( + "[SKIP] {EXAMPLE_WASM_PATH} not found. \ + Build the example first:\n \ + cd v2/crates/homecore-plugin-example && \ + cargo build --target wasm32-unknown-unknown --release" + ); + return; // skip — binary not built yet + } + + let wasm_bytes = std::fs::read(wasm_path) + .expect("failed to read homecore_plugin_example.wasm"); + + let (rt, hc) = build_rt_and_hc(); + let plugin = rt + .load_wasm(&wasm_bytes, hc.clone()) + .expect("load_wasm should succeed"); + + // Call plugin_setup — should subscribe to sensor.test_temp. + let setup_result = plugin + .call_setup(r#"{"entry_id":"test","domain":"test","title":"test","data":{}}"#) + .expect("plugin_setup should not trap"); + assert_eq!(setup_result, 0, "plugin_setup should return 0"); + + // Verify subscription was recorded. + assert!( + plugin.subscriptions().contains(&"sensor.test_temp".to_owned()), + "plugin should have subscribed to sensor.test_temp" + ); + + // ── Scenario 1: temp = 26.0 → alert ON ────────────────────────────── + let event_hot = state_changed_event("sensor.test_temp", "26.0"); + plugin + .call_state_changed(&event_hot) + .expect("state_changed should not trap"); + assert_alert_state(&hc, "on"); + + // ── Scenario 2: temp = 19.0 → alert OFF ───────────────────────────── + let event_cold = state_changed_event("sensor.test_temp", "19.0"); + plugin + .call_state_changed(&event_cold) + .expect("state_changed should not trap"); + assert_alert_state(&hc, "off"); + } + + // ── Fallback test: inline WAT (always runs) ─────────────────────────────── + + #[test] + fn wasm_plugin_temp_threshold_wat_fallback() { + let wasm_bytes = wat::parse_str(THRESHOLD_WAT).expect("WAT should parse"); + + let (rt, hc) = build_rt_and_hc(); + let plugin = rt + .load_wasm(&wasm_bytes, hc.clone()) + .expect("load_wasm should succeed for WAT"); + + // plugin_setup → subscribes + let r = plugin.call_setup("{}").expect("setup"); + assert_eq!(r, 0); + + // ── Scenario 1: temp = 26 → alert ON ─────────────────────────────── + let hot_event = StateChangedEventJson::state_changed( + "sensor.test_temp", + Some("26"), + serde_json::json!({}), + ); + plugin + .call_state_changed(&hot_event) + .expect("state_changed should not trap"); + assert_alert_state(&hc, "on"); + + // ── Scenario 2: temp = 19 → alert OFF ────────────────────────────── + let cold_event = StateChangedEventJson::state_changed( + "sensor.test_temp", + Some("19"), + serde_json::json!({}), + ); + plugin + .call_state_changed(&cold_event) + .expect("state_changed should not trap"); + assert_alert_state(&hc, "off"); + } + + // ── Linker smoke test ──────────────────────────────────────────────────── + + #[test] + fn wasmtime_linker_wires_all_four_host_imports() { + // A minimal WAT that calls all 4 host imports once and returns 0. + const SMOKE_WAT: &str = r#" +(module + (import "env" "hc_state_get" (func (param i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_set" (func (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "env" "hc_state_subscribe" (func (param i32 i32) (result i32))) + (import "env" "hc_log" (func (param i32 i32 i32))) + (memory (export "memory") 1) + (global $bump (mut i32) (i32.const 512)) + (func (export "alloc") (param i32) (result i32) + (local $p i32) + (local.set $p (global.get $bump)) + (global.set $bump (i32.add (global.get $bump) (local.get 0))) + (local.get $p)) + (func (export "dealloc") (param i32 i32)) + (func (export "plugin_setup") (param i32 i32) (result i32) (i32.const 0)) + (func (export "plugin_handle_state_changed") (param i32 i32) (result i32) (i32.const 0)) +) +"#; + let wasm_bytes = wat::parse_str(SMOKE_WAT).expect("WAT"); + let rt = WasmtimeRuntime::new().expect("rt"); + let hc = HomeCore::new(); + let plugin = rt.load_wasm(&wasm_bytes, hc).expect("instantiate"); + let r = plugin.call_setup("{}").expect("setup"); + assert_eq!(r, 0); + } +}