feat(homecore-plugins/p2): Wasmtime runtime + example WASM plugin (resolves ADR-128 Q2)

- 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<PluginStoreData> 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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-25 19:04:31 -04:00
parent 761b2248cb
commit 424721fa16
13 changed files with 1399 additions and 33 deletions

4
v2/Cargo.lock generated
View File

@ -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",

View File

@ -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]

View File

@ -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"

View File

@ -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 <ruv@ruv.net>", "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"

View File

@ -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.

View File

@ -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);
}
}

View File

@ -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::<f64>() {
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<String> {
let needle = format!("\"{}\":\"", key);
let start = json.find(&needle)? + needle.len();
let rest = &json[start..];
let end = rest.find('"')?;
Some(rest[..end].to_owned())
}

View File

@ -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 }

View File

@ -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<String>,
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",
}
}
}

View File

@ -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;

View File

@ -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<dyn HomeCorePlugin>,
) -> Result<LoadedPlugin, PluginError> {
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.

View File

@ -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<T>` 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<String>,
}
// ── 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<Self, PluginError> {
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<WasmPlugin, PluginError> {
let module = Module::new(&self.engine, wasm_bytes)
.map_err(|e| PluginError::RuntimeError(format!("WASM compile: {e}")))?;
let mut linker: Linker<PluginStoreData> = 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<PluginStoreData>,
) -> 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<PluginStoreData>,
) -> 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<u8> = {
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<PluginStoreData>,
) -> 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<PluginStoreData>,
) -> 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<PluginStoreData>,
) -> 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<Mutex<_>>` allows the handle to be `Clone` + `Send` while
/// maintaining exclusive access for calls into the WASM module.
pub struct WasmPlugin {
pub inner: Arc<Mutex<(Store<PluginStoreData>, wasmtime::Instance)>>,
}
impl WasmPlugin {
/// Return a snapshot of the entity IDs this plugin has subscribed to.
pub fn subscriptions(&self) -> Vec<String> {
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<i32, PluginError> {
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<i32, PluginError> {
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<PluginStoreData>,
instance: &wasmtime::Instance,
export_fn: &str,
payload: &str,
) -> Result<i32, PluginError> {
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::<i32, i32>(&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'"
);
}
}

View File

@ -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);
}
}