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:
parent
761b2248cb
commit
424721fa16
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue