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
+7
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"
@@ -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())
}