feat(homecore-plugins/p1): ADR-128 plugin runtime scaffold
Adds `v2/crates/homecore-plugins` (0.1.0-alpha.0) — the P1 scaffold for the HOMECORE-PLUGINS WASM integration system (ADR-128): - `manifest.rs`: `PluginManifest` — superset of HA manifest.json; serde round-trip + required-field validation (`domain`/`name`/`version`). - `error.rs`: `PluginError` typed enum (InvalidManifest, AlreadyLoaded, NotFound, RuntimeError, SetupFailed, UnloadFailed, Io). - `plugin.rs`: `HomeCorePlugin` async trait + `PluginId` newtype. - `runtime.rs`: `PluginRuntime` trait + `InProcessRuntime` (native Rust, first-party plugins). `WasmtimeRuntime` stub gated on `--features wasmtime` (default-off; 30 MB dep deferred to P2). - `registry.rs`: `PluginRegistry<R>` — load/unload/list/contains via RwLock. - 10 unit tests, 0 failed. Wasmtime vs wasm3 runtime selection is still open (ADR-128 §8 Q2); this scaffold makes the choice swappable via the `PluginRuntime` trait. The `wasmtime` and `wasm3` features are default-off; P2 resolves the choice and wires host ABI (`hc_state_get`/`hc_state_set`/etc.) to ADR-127. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
4d30304a1c
commit
c04906e7a8
File diff suppressed because it is too large
Load Diff
|
|
@ -29,6 +29,8 @@ members = [
|
||||||
"crates/nvsim",
|
"crates/nvsim",
|
||||||
"crates/nvsim-server",
|
"crates/nvsim-server",
|
||||||
"crates/homecore", # ADR-127 — HOMECORE state machine
|
"crates/homecore", # ADR-127 — HOMECORE state machine
|
||||||
|
"crates/homecore-plugins", # ADR-128 — HOMECORE-PLUGINS WASM runtime (P1 scaffold)
|
||||||
|
"crates/homecore-api", # ADR-130 — HOMECORE REST + WS API
|
||||||
# ADR-100/ADR-101: Cognitum Cog packaging — first Cog from this repo.
|
# ADR-100/ADR-101: Cognitum Cog packaging — first Cog from this repo.
|
||||||
# Ships the wifi-densepose pose-estimation model as a signed binary +
|
# Ships the wifi-densepose pose-estimation model as a signed binary +
|
||||||
# JSONL manifest installable by the Cognitum V0 appliance (cognitum-v0,
|
# JSONL manifest installable by the Cognitum V0 appliance (cognitum-v0,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
# HOMECORE-PLUGINS — WASM integration plugin system.
|
||||||
|
# Implements ADR-128 (HOMECORE-PLUGINS), P1 scaffold:
|
||||||
|
# - PluginManifest (serde-deserialised, superset of HA manifest.json)
|
||||||
|
# - HomeCorePlugin async trait + PluginId + PluginError
|
||||||
|
# - PluginRuntime trait + InProcessRuntime (native Rust, first-party plugins)
|
||||||
|
# - PluginRegistry (load / unload / list)
|
||||||
|
#
|
||||||
|
# P2 will add the `wasmtime` feature (gated below, default-off) for the real
|
||||||
|
# Wasmtime JIT sandbox. wasm3 interpretation mode lands behind `--features wasm3`
|
||||||
|
# in P3 for constrained-hardware targets.
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "homecore-plugins"
|
||||||
|
version = "0.1.0-alpha.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
|
||||||
|
description = "WASM integration plugin runtime for HOMECORE (ADR-128 P1 scaffold)"
|
||||||
|
repository = "https://github.com/ruvnet/RuView"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "homecore_plugins"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
# P2: real Wasmtime JIT sandbox (Cranelift; ~15 MB binary delta on Pi 5).
|
||||||
|
# Do not enable in production until the host ABI is frozen (ADR-128 §8 risk).
|
||||||
|
wasmtime = ["dep:wasmtime"]
|
||||||
|
# P3: wasm3 interpretation mode for constrained hardware (~50 kB).
|
||||||
|
wasm3 = ["dep:wasm3"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# HOMECORE state machine — local path (ADR-127).
|
||||||
|
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
||||||
|
|
||||||
|
# Async runtime — same version as workspace.
|
||||||
|
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||||
|
|
||||||
|
# Async trait support for HomeCorePlugin.
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
|
# Error handling.
|
||||||
|
thiserror = "1"
|
||||||
|
|
||||||
|
# Serialisation (manifest JSON + ABI call payloads).
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
# Optional Wasmtime runtime (P2, default-off — 30 MB dep).
|
||||||
|
wasmtime = { version = "25", optional = true }
|
||||||
|
|
||||||
|
# Optional wasm3 interpretation runtime (P3, default-off).
|
||||||
|
wasm3 = { version = "0.3", optional = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros", "test-util"] }
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
//! `PluginError` — typed error enum for the homecore-plugins crate.
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Errors produced by the HOMECORE plugin system.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum PluginError {
|
||||||
|
/// The plugin manifest JSON is missing required fields or is malformed.
|
||||||
|
#[error("invalid manifest: {0}")]
|
||||||
|
InvalidManifest(String),
|
||||||
|
|
||||||
|
/// A plugin with this ID is already loaded in the registry.
|
||||||
|
#[error("plugin already loaded: {0}")]
|
||||||
|
AlreadyLoaded(String),
|
||||||
|
|
||||||
|
/// No plugin with this ID is loaded in the registry.
|
||||||
|
#[error("plugin not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
/// The plugin runtime failed to spawn or execute the plugin.
|
||||||
|
#[error("runtime error: {0}")]
|
||||||
|
RuntimeError(String),
|
||||||
|
|
||||||
|
/// The plugin's `setup` hook returned an error.
|
||||||
|
#[error("plugin setup failed: {0}")]
|
||||||
|
SetupFailed(String),
|
||||||
|
|
||||||
|
/// The plugin's `unload` hook returned an error.
|
||||||
|
#[error("plugin unload failed: {0}")]
|
||||||
|
UnloadFailed(String),
|
||||||
|
|
||||||
|
/// IO error (manifest file not found, WASM binary missing, etc.).
|
||||||
|
#[error("io error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
//! HOMECORE-PLUGINS — WASM integration plugin system.
|
||||||
|
//!
|
||||||
|
//! Implements [ADR-128](../../docs/adr/ADR-128-homecore-integration-plugin-system.md)
|
||||||
|
//! P1 scaffold: manifest parsing, the `HomeCorePlugin` async trait, the
|
||||||
|
//! `PluginRuntime` abstraction, and the `PluginRegistry`.
|
||||||
|
//!
|
||||||
|
//! ## What's here (P1)
|
||||||
|
//!
|
||||||
|
//! - [`manifest`] — `PluginManifest`: superset of HA `manifest.json`; serde
|
||||||
|
//! round-trip + required-field validation.
|
||||||
|
//! - [`plugin`] — `HomeCorePlugin` async trait, `PluginId` newtype.
|
||||||
|
//! - [`runtime`] — `PluginRuntime` trait + `InProcessRuntime` (native Rust,
|
||||||
|
//! first-party plugins compiled into the binary).
|
||||||
|
//! - [`registry`] — `PluginRegistry<R>`: load / unload / list plugins.
|
||||||
|
//! - [`error`] — `PluginError` typed error enum.
|
||||||
|
//!
|
||||||
|
//! ## What's NOT here yet (deferred)
|
||||||
|
//!
|
||||||
|
//! - `WasmtimeRuntime` (P2, `--features wasmtime`): Cranelift JIT sandbox on
|
||||||
|
//! Pi 5 / x86_64. The runtime-selection question (Wasmtime vs wasm3) is still
|
||||||
|
//! open (ADR-128 §8) and will be resolved in Q2 before P2 begins.
|
||||||
|
//! - Host ABI wiring: `hc_state_get`, `hc_state_set`, `hc_event_fire`, etc.
|
||||||
|
//! (P2 — requires ADR-127 state machine API freeze first).
|
||||||
|
//! - Config entry lifecycle + hot-load (P3).
|
||||||
|
//! - Cog registry distribution + Ed25519 signature verification (P4).
|
||||||
|
//! - Permission enforcement (P5).
|
||||||
|
//!
|
||||||
|
//! ## Feature flags
|
||||||
|
//!
|
||||||
|
//! | Feature | Default | Description |
|
||||||
|
//! |---------|---------|-------------|
|
||||||
|
//! | `wasmtime` | off | Wasmtime Cranelift JIT runtime (P2) |
|
||||||
|
//! | `wasm3` | off | wasm3 interpreter runtime for constrained hardware (P3) |
|
||||||
|
|
||||||
|
pub mod error;
|
||||||
|
pub mod manifest;
|
||||||
|
pub mod plugin;
|
||||||
|
pub mod registry;
|
||||||
|
pub mod runtime;
|
||||||
|
|
||||||
|
pub use error::PluginError;
|
||||||
|
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;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
//! Plugin manifest — superset of HA's `manifest.json`.
|
||||||
|
//!
|
||||||
|
//! See ADR-128 §3 for the full field list. Fields present in HA's schema
|
||||||
|
//! are preserved verbatim. HOMECORE-specific fields are marked `[HOMECORE]`.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::error::PluginError;
|
||||||
|
|
||||||
|
/// Coarse-grained permission claim string (glob pattern).
|
||||||
|
/// Example: `"state:write:sensor.*"`.
|
||||||
|
pub type PermissionClaim = String;
|
||||||
|
|
||||||
|
/// HA `iot_class` values (non-exhaustive — HA adds new classes over time).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum IotClass {
|
||||||
|
LocalPush,
|
||||||
|
LocalPolling,
|
||||||
|
CloudPush,
|
||||||
|
CloudPolling,
|
||||||
|
AssumedState,
|
||||||
|
Calculated,
|
||||||
|
#[serde(other)]
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HOMECORE integration type.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum IntegrationType {
|
||||||
|
Integration,
|
||||||
|
Helper,
|
||||||
|
Entity,
|
||||||
|
#[serde(other)]
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed and validated plugin manifest.
|
||||||
|
///
|
||||||
|
/// Serialises to/from HA-compatible `manifest.json`. HOMECORE-only fields
|
||||||
|
/// are `Option<…>` so that a plain HA manifest is a valid (native-only)
|
||||||
|
/// HOMECORE manifest.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct PluginManifest {
|
||||||
|
/// Unique integration domain identifier (e.g. `"mqtt"`).
|
||||||
|
pub domain: String,
|
||||||
|
|
||||||
|
/// Human-readable integration name.
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// SemVer-ish version string (HA uses calendar-versioning, e.g. `"2025.1.0"`).
|
||||||
|
pub version: String,
|
||||||
|
|
||||||
|
/// Optional documentation URL.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub documentation: Option<String>,
|
||||||
|
|
||||||
|
/// HA `iot_class` — how the integration communicates with the device.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub iot_class: Option<IotClass>,
|
||||||
|
|
||||||
|
/// Whether this integration ships a UI config flow.
|
||||||
|
#[serde(default)]
|
||||||
|
pub config_flow: bool,
|
||||||
|
|
||||||
|
/// HOMECORE integration type (optional, defaults to Integration).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub integration_type: Option<IntegrationType>,
|
||||||
|
|
||||||
|
/// Intra-HOMECORE dependencies (other plugin domains this one requires).
|
||||||
|
#[serde(default)]
|
||||||
|
pub dependencies: Vec<String>,
|
||||||
|
|
||||||
|
/// External package requirements — kept for schema compat, ignored in HOMECORE
|
||||||
|
/// (WASM modules carry their own static deps, no pip).
|
||||||
|
#[serde(default)]
|
||||||
|
pub requirements: Vec<String>,
|
||||||
|
|
||||||
|
// ── [HOMECORE] fields ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// [HOMECORE] Relative path to the `.wasm` binary (absent for native plugins).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub wasm_module: Option<String>,
|
||||||
|
|
||||||
|
/// [HOMECORE] `sha256:<hex>` hash of the wasm binary; verified before execution.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub wasm_module_hash: Option<String>,
|
||||||
|
|
||||||
|
/// [HOMECORE] Ed25519 signature of the wasm binary hash (`ed25519:<base64>`).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub wasm_module_sig: Option<String>,
|
||||||
|
|
||||||
|
/// [HOMECORE] Ed25519 public key of the plugin publisher.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub publisher_key: Option<String>,
|
||||||
|
|
||||||
|
/// [HOMECORE] Minimum HOMECORE version required by this plugin.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub min_homecore_version: Option<String>,
|
||||||
|
|
||||||
|
/// [HOMECORE] Subset of host functions the WASM module imports.
|
||||||
|
#[serde(default)]
|
||||||
|
pub host_imports_required: Vec<String>,
|
||||||
|
|
||||||
|
/// [HOMECORE] Coarse-grained permission claims (glob patterns).
|
||||||
|
#[serde(default)]
|
||||||
|
pub homecore_permissions: Vec<PermissionClaim>,
|
||||||
|
|
||||||
|
/// [HOMECORE] Seed app registry cog ID for distribution.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cog_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PluginManifest {
|
||||||
|
/// Parse a `manifest.json` JSON string and validate required fields.
|
||||||
|
///
|
||||||
|
/// Required fields: `domain`, `name`, `version`.
|
||||||
|
pub fn parse_json(s: &str) -> Result<Self, PluginError> {
|
||||||
|
let m: Self = serde_json::from_str(s)
|
||||||
|
.map_err(|e| PluginError::InvalidManifest(e.to_string()))?;
|
||||||
|
m.validate()?;
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(&self) -> Result<(), PluginError> {
|
||||||
|
if self.domain.trim().is_empty() {
|
||||||
|
return Err(PluginError::InvalidManifest(
|
||||||
|
"manifest `domain` must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.name.trim().is_empty() {
|
||||||
|
return Err(PluginError::InvalidManifest(
|
||||||
|
"manifest `name` must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.version.trim().is_empty() {
|
||||||
|
return Err(PluginError::InvalidManifest(
|
||||||
|
"manifest `version` must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
//! `HomeCorePlugin` trait + `PluginId` newtype.
|
||||||
|
//!
|
||||||
|
//! Every first-party and third-party HOMECORE integration must implement
|
||||||
|
//! `HomeCorePlugin`. P1 provides an in-process native Rust implementation;
|
||||||
|
//! the WASM ABI wrapper (which maps the WASM exports `setup_entry`,
|
||||||
|
//! `call_service_handler`, `receive_event` to this trait) lands in P2.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use homecore::HomeCore;
|
||||||
|
|
||||||
|
use crate::error::PluginError;
|
||||||
|
|
||||||
|
/// Unique identifier for a loaded plugin — mirrors the `domain` field of
|
||||||
|
/// the plugin's `PluginManifest` (e.g. `"mqtt"`, `"homecore_lights"`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct PluginId(pub String);
|
||||||
|
|
||||||
|
impl PluginId {
|
||||||
|
/// Create a new `PluginId` from any string-like value.
|
||||||
|
pub fn new(s: impl Into<String>) -> Self {
|
||||||
|
Self(s.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the inner domain string.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PluginId {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lifecycle trait that every HOMECORE integration must implement.
|
||||||
|
///
|
||||||
|
/// Implementing types are passed to [`PluginRuntime::load`]; the runtime
|
||||||
|
/// calls these methods at the appropriate lifecycle points.
|
||||||
|
///
|
||||||
|
/// # Async
|
||||||
|
/// Both methods are `async` to allow network / IO initialisation without
|
||||||
|
/// blocking the Tokio runtime. The `async_trait` macro erases the `impl`
|
||||||
|
/// return type so it works in trait objects.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait HomeCorePlugin: Send + Sync + 'static {
|
||||||
|
/// Called once when the plugin's config entry is being set up.
|
||||||
|
///
|
||||||
|
/// The plugin receives a reference to the `HomeCore` runtime and should
|
||||||
|
/// register its entities, services, and event subscriptions here.
|
||||||
|
async fn setup(&self, hc: HomeCore) -> Result<(), PluginError>;
|
||||||
|
|
||||||
|
/// Called when the plugin is being removed from the registry.
|
||||||
|
///
|
||||||
|
/// The plugin should clean up subscriptions and deregister its entities.
|
||||||
|
async fn unload(&self) -> Result<(), PluginError>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
//! `PluginRegistry` — load, unload, and list HOMECORE plugins.
|
||||||
|
//!
|
||||||
|
//! The registry is runtime-agnostic: it accepts any type that implements
|
||||||
|
//! [`PluginRuntime`] and delegates load/unload to it. This allows swapping
|
||||||
|
//! the `InProcessRuntime` (P1) for a `WasmtimeRuntime` (P2) without
|
||||||
|
//! changing registry code.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use homecore::HomeCore;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::error::PluginError;
|
||||||
|
use crate::manifest::PluginManifest;
|
||||||
|
use crate::plugin::{HomeCorePlugin, PluginId};
|
||||||
|
use crate::runtime::{LoadedPlugin, PluginRuntime};
|
||||||
|
|
||||||
|
/// Holds all loaded plugins keyed by `PluginId`.
|
||||||
|
///
|
||||||
|
/// Thread-safe via `RwLock` — concurrent reads are cheap; writes (load /
|
||||||
|
/// unload) take an exclusive lock only while mutating the map.
|
||||||
|
pub struct PluginRegistry<R: PluginRuntime> {
|
||||||
|
runtime: R,
|
||||||
|
plugins: RwLock<HashMap<PluginId, LoadedPlugin>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<R: PluginRuntime> PluginRegistry<R> {
|
||||||
|
/// Create an empty registry backed by `runtime`.
|
||||||
|
pub fn new(runtime: R) -> Self {
|
||||||
|
Self {
|
||||||
|
runtime,
|
||||||
|
plugins: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a plugin, call its `setup` hook, and insert it into the registry.
|
||||||
|
///
|
||||||
|
/// Returns `PluginError::AlreadyLoaded` if a plugin with the same ID is
|
||||||
|
/// already registered.
|
||||||
|
pub async fn load(
|
||||||
|
&self,
|
||||||
|
manifest: PluginManifest,
|
||||||
|
plugin: Arc<dyn HomeCorePlugin>,
|
||||||
|
hc: HomeCore,
|
||||||
|
) -> Result<PluginId, PluginError> {
|
||||||
|
let id = PluginId::new(&manifest.domain);
|
||||||
|
|
||||||
|
{
|
||||||
|
let guard = self.plugins.read().await;
|
||||||
|
if guard.contains_key(&id) {
|
||||||
|
return Err(PluginError::AlreadyLoaded(id.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let loaded = self
|
||||||
|
.runtime
|
||||||
|
.load(id.clone(), manifest, plugin)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
loaded
|
||||||
|
.setup(hc)
|
||||||
|
.await
|
||||||
|
.map_err(|e| PluginError::SetupFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
self.plugins.write().await.insert(id.clone(), loaded);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unload a plugin by ID, calling its `unload` hook first.
|
||||||
|
///
|
||||||
|
/// Returns `PluginError::NotFound` if the plugin was not loaded.
|
||||||
|
pub async fn unload(&self, id: &PluginId) -> Result<(), PluginError> {
|
||||||
|
let loaded = {
|
||||||
|
let mut guard = self.plugins.write().await;
|
||||||
|
guard
|
||||||
|
.remove(id)
|
||||||
|
.ok_or_else(|| PluginError::NotFound(id.to_string()))?
|
||||||
|
};
|
||||||
|
|
||||||
|
loaded
|
||||||
|
.unload()
|
||||||
|
.await
|
||||||
|
.map_err(|e| PluginError::UnloadFailed(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a snapshot of currently loaded plugin IDs and their manifest domains.
|
||||||
|
pub async fn list(&self) -> Vec<(PluginId, String)> {
|
||||||
|
let guard = self.plugins.read().await;
|
||||||
|
guard
|
||||||
|
.iter()
|
||||||
|
.map(|(id, lp)| (id.clone(), lp.manifest.domain.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `true` if a plugin with this ID is loaded.
|
||||||
|
pub async fn contains(&self, id: &PluginId) -> bool {
|
||||||
|
self.plugins.read().await.contains_key(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
//! `PluginRuntime` trait + `InProcessRuntime` (P1).
|
||||||
|
//!
|
||||||
|
//! Abstracts over Wasmtime (P2, `--features wasmtime`) and native in-process
|
||||||
|
//! Rust plugins (P1, always-on). A third backend, wasm3 (P3), will provide
|
||||||
|
//! interpretation mode for constrained hardware.
|
||||||
|
//!
|
||||||
|
//! # Architecture
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! PluginRegistry
|
||||||
|
//! │
|
||||||
|
//! ▼
|
||||||
|
//! PluginRuntime ◄─── InProcessRuntime (P1, native Rust, <1 µs call)
|
||||||
|
//! ◄─── WasmtimeRuntime (P2, Cranelift JIT, ~5 ms cold start)
|
||||||
|
//! ◄─── Wasm3Runtime (P3, interpreter, ~50 kB, Pi Zero)
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use homecore::HomeCore;
|
||||||
|
|
||||||
|
use crate::error::PluginError;
|
||||||
|
use crate::manifest::PluginManifest;
|
||||||
|
use crate::plugin::{HomeCorePlugin, PluginId};
|
||||||
|
|
||||||
|
/// A loaded plugin handle — returned by [`PluginRuntime::load`].
|
||||||
|
pub struct LoadedPlugin {
|
||||||
|
pub id: PluginId,
|
||||||
|
pub manifest: PluginManifest,
|
||||||
|
/// Underlying plugin instance (boxed trait object).
|
||||||
|
pub(crate) instance: Arc<dyn HomeCorePlugin>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoadedPlugin {
|
||||||
|
/// Delegate to the inner plugin's `setup` method.
|
||||||
|
pub async fn setup(&self, hc: HomeCore) -> Result<(), PluginError> {
|
||||||
|
self.instance.setup(hc).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delegate to the inner plugin's `unload` method.
|
||||||
|
pub async fn unload(&self) -> Result<(), PluginError> {
|
||||||
|
self.instance.unload().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abstraction over the WASM (and native) plugin execution environment.
|
||||||
|
///
|
||||||
|
/// P2 will supply a `WasmtimeRuntime` that compiles `.wasm` bytes with
|
||||||
|
/// Cranelift; P3 adds a `Wasm3Runtime` for constrained targets. Both will
|
||||||
|
/// implement this trait so the registry is runtime-agnostic.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PluginRuntime: Send + Sync + 'static {
|
||||||
|
/// Load a plugin from a boxed [`HomeCorePlugin`] implementation and a
|
||||||
|
/// parsed `PluginManifest`. Returns a `LoadedPlugin` handle.
|
||||||
|
async fn load(
|
||||||
|
&self,
|
||||||
|
id: PluginId,
|
||||||
|
manifest: PluginManifest,
|
||||||
|
plugin: Arc<dyn HomeCorePlugin>,
|
||||||
|
) -> Result<LoadedPlugin, PluginError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native in-process runtime — loads first-party Rust plugins directly.
|
||||||
|
///
|
||||||
|
/// No WASM compilation; no sandbox. Intended for first-party plugins
|
||||||
|
/// (RuView MQTT bridge, presence sensor, etc.) that are compiled into the
|
||||||
|
/// HOMECORE binary and therefore trusted. Third-party / community plugins
|
||||||
|
/// must use the `WasmtimeRuntime` (P2) for isolation.
|
||||||
|
pub struct InProcessRuntime;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginRuntime for InProcessRuntime {
|
||||||
|
async fn load(
|
||||||
|
&self,
|
||||||
|
id: PluginId,
|
||||||
|
manifest: PluginManifest,
|
||||||
|
plugin: Arc<dyn HomeCorePlugin>,
|
||||||
|
) -> Result<LoadedPlugin, PluginError> {
|
||||||
|
Ok(LoadedPlugin {
|
||||||
|
id,
|
||||||
|
manifest,
|
||||||
|
instance: plugin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
//! Unit tests for homecore-plugins P1 scaffold.
|
||||||
|
//!
|
||||||
|
//! Covers: manifest parse + round-trip, manifest field validation,
|
||||||
|
//! PluginRegistry load/unload/list/duplicate, InProcessRuntime,
|
||||||
|
//! and PluginError variants.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use homecore::HomeCore;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::error::PluginError;
|
||||||
|
use crate::manifest::PluginManifest;
|
||||||
|
use crate::plugin::{HomeCorePlugin, PluginId};
|
||||||
|
use crate::registry::PluginRegistry;
|
||||||
|
use crate::runtime::InProcessRuntime;
|
||||||
|
|
||||||
|
// ── Test double ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Minimal plugin that records setup/unload calls.
|
||||||
|
struct TestPlugin {
|
||||||
|
pub setup_called: Mutex<bool>,
|
||||||
|
pub unload_called: Mutex<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestPlugin {
|
||||||
|
fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self {
|
||||||
|
setup_called: Mutex::new(false),
|
||||||
|
unload_called: Mutex::new(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl HomeCorePlugin for TestPlugin {
|
||||||
|
async fn setup(&self, _hc: HomeCore) -> Result<(), PluginError> {
|
||||||
|
*self.setup_called.lock().await = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn unload(&self) -> Result<(), PluginError> {
|
||||||
|
*self.unload_called.lock().await = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn minimal_manifest(domain: &str) -> PluginManifest {
|
||||||
|
PluginManifest {
|
||||||
|
domain: domain.into(),
|
||||||
|
name: "Test Plugin".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
documentation: None,
|
||||||
|
iot_class: None,
|
||||||
|
config_flow: false,
|
||||||
|
integration_type: None,
|
||||||
|
dependencies: vec![],
|
||||||
|
requirements: vec![],
|
||||||
|
wasm_module: None,
|
||||||
|
wasm_module_hash: None,
|
||||||
|
wasm_module_sig: None,
|
||||||
|
publisher_key: None,
|
||||||
|
min_homecore_version: None,
|
||||||
|
host_imports_required: vec![],
|
||||||
|
homecore_permissions: vec![],
|
||||||
|
cog_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manifest tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_parse_round_trip() {
|
||||||
|
let json = r#"{
|
||||||
|
"domain": "mqtt",
|
||||||
|
"name": "MQTT",
|
||||||
|
"version": "2025.1.0",
|
||||||
|
"iot_class": "local_push",
|
||||||
|
"config_flow": true,
|
||||||
|
"dependencies": [],
|
||||||
|
"requirements": [],
|
||||||
|
"wasm_module": "mqtt.wasm",
|
||||||
|
"homecore_permissions": ["state:write:sensor.*"]
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
let m = PluginManifest::parse_json(json).expect("should parse");
|
||||||
|
assert_eq!(m.domain, "mqtt");
|
||||||
|
assert_eq!(m.version, "2025.1.0");
|
||||||
|
assert!(m.config_flow);
|
||||||
|
assert_eq!(m.homecore_permissions, vec!["state:write:sensor.*"]);
|
||||||
|
|
||||||
|
// round-trip: serialize back to JSON and re-parse
|
||||||
|
let serialised = serde_json::to_string(&m).expect("should serialise");
|
||||||
|
let m2 = PluginManifest::parse_json(&serialised).expect("round-trip should parse");
|
||||||
|
assert_eq!(m.domain, m2.domain);
|
||||||
|
assert_eq!(m.version, m2.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_rejects_empty_domain() {
|
||||||
|
let json = r#"{"domain":"","name":"X","version":"1.0.0"}"#;
|
||||||
|
let err = PluginManifest::parse_json(json).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("domain"),
|
||||||
|
"error should mention domain: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_rejects_missing_domain() {
|
||||||
|
let json = r#"{"name":"X","version":"1.0.0"}"#;
|
||||||
|
// serde will fill domain as "" due to missing field → validation rejects
|
||||||
|
let err = PluginManifest::parse_json(json).unwrap_err();
|
||||||
|
// Either a serde error (missing field) or a validation error is acceptable
|
||||||
|
let s = err.to_string();
|
||||||
|
assert!(!s.is_empty(), "should produce a non-empty error");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_rejects_empty_version() {
|
||||||
|
let json = r#"{"domain":"lights","name":"Lights","version":""}"#;
|
||||||
|
let err = PluginManifest::parse_json(json).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.to_string().contains("version"),
|
||||||
|
"error should mention version: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Registry + InProcessRuntime tests ─────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registry_load_and_list() {
|
||||||
|
let hc = HomeCore::new();
|
||||||
|
let registry = PluginRegistry::new(InProcessRuntime);
|
||||||
|
let plugin = TestPlugin::new();
|
||||||
|
let manifest = minimal_manifest("lights");
|
||||||
|
|
||||||
|
let id = registry
|
||||||
|
.load(manifest, plugin.clone(), hc)
|
||||||
|
.await
|
||||||
|
.expect("load should succeed");
|
||||||
|
|
||||||
|
assert_eq!(id.as_str(), "lights");
|
||||||
|
assert!(*plugin.setup_called.lock().await, "setup should have been called");
|
||||||
|
|
||||||
|
let listing = registry.list().await;
|
||||||
|
assert_eq!(listing.len(), 1);
|
||||||
|
assert_eq!(listing[0].0.as_str(), "lights");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registry_unload_removes_plugin() {
|
||||||
|
let hc = HomeCore::new();
|
||||||
|
let registry = PluginRegistry::new(InProcessRuntime);
|
||||||
|
let plugin = TestPlugin::new();
|
||||||
|
|
||||||
|
let id = registry
|
||||||
|
.load(minimal_manifest("switch"), plugin.clone(), hc)
|
||||||
|
.await
|
||||||
|
.expect("load should succeed");
|
||||||
|
|
||||||
|
registry.unload(&id).await.expect("unload should succeed");
|
||||||
|
assert!(*plugin.unload_called.lock().await, "unload should have been called");
|
||||||
|
assert_eq!(registry.list().await.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registry_rejects_duplicate_load() {
|
||||||
|
let hc1 = HomeCore::new();
|
||||||
|
let hc2 = HomeCore::new();
|
||||||
|
let registry = PluginRegistry::new(InProcessRuntime);
|
||||||
|
|
||||||
|
registry
|
||||||
|
.load(minimal_manifest("sensor"), TestPlugin::new(), hc1)
|
||||||
|
.await
|
||||||
|
.expect("first load should succeed");
|
||||||
|
|
||||||
|
let err = registry
|
||||||
|
.load(minimal_manifest("sensor"), TestPlugin::new(), hc2)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(err, PluginError::AlreadyLoaded(_)),
|
||||||
|
"expected AlreadyLoaded, got: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn registry_unload_unknown_plugin_returns_not_found() {
|
||||||
|
let registry = PluginRegistry::new(InProcessRuntime);
|
||||||
|
let id = PluginId::new("nonexistent");
|
||||||
|
let err = registry.unload(&id).await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, PluginError::NotFound(_)),
|
||||||
|
"expected NotFound, got: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn in_process_runtime_setup_called() {
|
||||||
|
let hc = HomeCore::new();
|
||||||
|
let registry = PluginRegistry::new(InProcessRuntime);
|
||||||
|
let plugin = TestPlugin::new();
|
||||||
|
|
||||||
|
registry
|
||||||
|
.load(minimal_manifest("climate"), plugin.clone(), hc)
|
||||||
|
.await
|
||||||
|
.expect("load should succeed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
*plugin.setup_called.lock().await,
|
||||||
|
"InProcessRuntime must call setup"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Error display ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_display_variants() {
|
||||||
|
let e1 = PluginError::AlreadyLoaded("mqtt".into());
|
||||||
|
assert!(e1.to_string().contains("mqtt"));
|
||||||
|
|
||||||
|
let e2 = PluginError::NotFound("climate".into());
|
||||||
|
assert!(e2.to_string().contains("climate"));
|
||||||
|
|
||||||
|
let e3 = PluginError::RuntimeError("boom".into());
|
||||||
|
assert!(e3.to_string().contains("boom"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue