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-server",
|
||||
"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.
|
||||
# Ships the wifi-densepose pose-estimation model as a signed binary +
|
||||
# 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