mirror of
https://github.com/ruvnet/RuView
synced 2026-08-07 20:01:43 +00:00
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:
+7
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user