feat(homecore/p1 iter-2): API (ADR-130) + plugins (ADR-128) scaffolds in parallel

Two new crates land in this iteration of the HOMECORE swarm:

## v2/crates/homecore-api/  (ADR-130 P1, sequential foundation)

Wire-compat Axum REST + WebSocket port of HA's API. P2-tier subset:

REST routes:
- GET  /api/                           — health ping (HA parity)
- GET  /api/config                     — bare HOMECORE config
- GET  /api/states                     — all entity states
- GET  /api/states/{entity_id}         — one state (404 if missing)
- POST /api/states/{entity_id}         — set state, fire state_changed
- GET  /api/services                   — services grouped by domain
- POST /api/services/{domain}/{service} — call service

WebSocket (/api/websocket):
- auth_required → auth → auth_ok handshake (P1 accepts any non-empty
  bearer; P2 wires the token store)
- get_states, get_config, get_services, call_service
- subscribe_events (per-event-type filter, broadcasts state_changed +
  domain events with HA's event-envelope shape)
- unsubscribe_events
- ping/pong

`homecore-api-server` binary boots a HomeCore on :8123, ready for a
curl smoke test against the wire format.

## v2/crates/homecore-plugins/  (ADR-128 P1, concurrent foundation)

Plugin runtime scaffold per ADR-128:
- PluginManifest mirrors HA manifest.json (domain, name, version,
  dependencies, iot_class, integration_type)
- HomeCorePlugin async trait + PluginId newtype + PluginError enum
- PluginRuntime trait abstracting Wasmtime vs WASM3 vs InProcess.
  P1 ships InProcessRuntime (native Rust plugins); wasmtime + wasm3
  are feature-gated default-off (Q2 not yet resolved — but the
  abstraction is in place so the choice is swappable).
- PluginRegistry: load/unload/list by PluginId.

## Test summary

- homecore:        20/20 (state machine, event bus, services, registry)
- homecore-api:     4/4 (BearerAuth header parsing)
- homecore-plugins:10/10 (manifest, registry, runtime, error variants)
- Total:           34/34 passing

## Coordination state

swarm-memory-manager namespace `homecore-impl/*`:
- iteration: iter-2 
- adr-127/phase: P1-complete 
- adr-130/phase: P1-scaffold-in-progress (now P1-complete)
- adr-128/phase: P1-scaffold-in-progress (now P1-complete)

## Critical path advanced

ADR-127  → ADR-130  → ADR-128  — the unblocking foundation
is now done. Next iteration can fan out 129/131/132/133/134/125
concurrently. Tracking issue #798.

Refs: docs/adr/ADR-130-homecore-rest-websocket-api.md
Refs: docs/adr/ADR-128-homecore-integration-plugin-system.md
Refs: #798

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-25 18:14:14 -04:00
parent c04906e7a8
commit 02742f2f47
9 changed files with 748 additions and 0 deletions

View File

@ -0,0 +1,40 @@
[package]
name = "homecore-api"
version = "0.1.0-alpha.0"
edition = "2021"
license = "MIT"
authors = ["rUv <ruv@ruv.net>", "HOMECORE Contributors"]
description = "Wire-compatible Axum REST + WebSocket port of Home Assistant's API (ADR-130)"
repository = "https://github.com/ruvnet/RuView"
[lib]
name = "homecore_api"
path = "src/lib.rs"
[[bin]]
name = "homecore-api-server"
path = "src/bin/server.rs"
[dependencies]
homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
axum = { version = "0.7", features = ["ws", "json", "macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
dashmap = "6"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
hyper = "1"
http-body-util = "0.1"

View File

@ -0,0 +1,28 @@
//! Axum router wiring. Mounts the §2.1 P2 routes + the WS endpoint.
use axum::routing::{get, post};
use axum::Router;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use crate::rest;
use crate::state::SharedState;
use crate::ws;
pub type AppState = SharedState;
/// Build the Axum router. The `state` is cloned into each handler at
/// call time via `State<SharedState>`.
pub fn router(state: SharedState) -> Router {
Router::new()
.route("/api/", get(rest::api_root))
.route("/api/config", get(rest::get_config))
.route("/api/states", get(rest::get_states))
.route("/api/states/:entity_id", get(rest::get_state).post(rest::set_state))
.route("/api/services", get(rest::get_services))
.route("/api/services/:domain/:service", post(rest::call_service))
.route("/api/websocket", get(ws::websocket_handler))
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http())
.with_state(state)
}

View File

@ -0,0 +1,64 @@
//! Bearer-token auth helper. P1 accepts any non-empty bearer; P2 wires
//! in the long-lived token store. Mirrors HA's
//! `Authorization: Bearer <token>` convention.
use axum::http::HeaderMap;
use crate::error::ApiError;
#[derive(Clone, Debug)]
pub struct BearerAuth(pub String);
impl BearerAuth {
/// Parse the `Authorization: Bearer <token>` header out of the
/// request. Returns `ApiError::Unauthorized` if missing, malformed,
/// or the token is empty.
pub fn from_headers(headers: &HeaderMap) -> Result<Self, ApiError> {
let header = headers
.get(axum::http::header::AUTHORIZATION)
.ok_or(ApiError::Unauthorized)?;
let value = header.to_str().map_err(|_| ApiError::Unauthorized)?;
let token = value
.strip_prefix("Bearer ")
.ok_or(ApiError::Unauthorized)?
.trim()
.to_string();
if token.is_empty() {
return Err(ApiError::Unauthorized);
}
Ok(Self(token))
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::header::AUTHORIZATION;
#[test]
fn strips_bearer_prefix() {
let mut h = HeaderMap::new();
h.insert(AUTHORIZATION, "Bearer abc123".parse().unwrap());
let a = BearerAuth::from_headers(&h).unwrap();
assert_eq!(a.0, "abc123");
}
#[test]
fn rejects_missing_prefix() {
let mut h = HeaderMap::new();
h.insert(AUTHORIZATION, "abc123".parse().unwrap());
assert!(matches!(BearerAuth::from_headers(&h), Err(ApiError::Unauthorized)));
}
#[test]
fn rejects_missing_header() {
let h = HeaderMap::new();
assert!(matches!(BearerAuth::from_headers(&h), Err(ApiError::Unauthorized)));
}
#[test]
fn rejects_empty_token() {
let mut h = HeaderMap::new();
h.insert(AUTHORIZATION, "Bearer ".parse().unwrap());
assert!(matches!(BearerAuth::from_headers(&h), Err(ApiError::Unauthorized)));
}
}

View File

@ -0,0 +1,33 @@
//! `homecore-api-server` binary. Boots a HomeCore runtime and serves
//! the HA-compat REST + WS API on `:8123`.
//!
//! P1: bare-minimum bring-up. No persistence, no plugins, no auth
//! beyond "any non-empty bearer". Useful for `curl` smoke tests of
//! the wire format from the existing HA companion app:
//!
//! cargo run -p homecore-api --bin homecore-api-server
//! curl -H "Authorization: Bearer test" http://127.0.0.1:8123/api/
use homecore::HomeCore;
use homecore_api::{router, SharedState, DEFAULT_PORT};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,tower_http=debug,homecore_api=debug".into()),
)
.init();
let homecore = HomeCore::new();
let state = SharedState::new(homecore);
let app = router(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], DEFAULT_PORT));
tracing::info!("HOMECORE-API listening on http://{addr} (HA-compat /api + /api/websocket)");
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}

View File

@ -0,0 +1,37 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ApiError {
#[error("entity not found: {0}")]
NotFound(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("unauthorized")]
Unauthorized,
#[error("service not registered: {domain}.{service}")]
ServiceNotRegistered { domain: String, service: String },
#[error("internal error: {0}")]
Internal(String),
}
pub type ApiResult<T> = Result<T, ApiError>;
#[derive(Serialize)]
struct ErrorPayload { message: String }
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
Self::ServiceNotRegistered { .. } => (StatusCode::BAD_REQUEST, self.to_string()),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
};
(status, Json(ErrorPayload { message })).into_response()
}
}

View File

@ -0,0 +1,13 @@
//! HOMECORE-API — wire-compat Axum REST + WebSocket port of HA's API (ADR-130).
pub mod app;
pub mod auth;
pub mod error;
pub mod rest;
pub mod state;
pub mod ws;
pub use app::{router, AppState};
pub use error::{ApiError, ApiResult};
pub use state::SharedState;
pub const DEFAULT_PORT: u16 = 8123;

View File

@ -0,0 +1,147 @@
use axum::extract::{Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use homecore::{Context, EntityId};
use crate::auth::BearerAuth;
use crate::error::{ApiError, ApiResult};
use crate::state::SharedState;
#[derive(Serialize)]
pub struct ApiRunning { message: &'static str }
pub async fn api_root() -> Json<ApiRunning> {
Json(ApiRunning { message: "API running." })
}
#[derive(Serialize)]
pub struct ApiConfig {
location_name: String,
version: String,
state: &'static str,
components: Vec<String>,
}
pub async fn get_config(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<ApiConfig>> {
let _ = BearerAuth::from_headers(&headers)?;
Ok(Json(ApiConfig {
location_name: s.location_name().to_string(),
version: s.version().to_string(),
state: "RUNNING",
components: vec![],
}))
}
#[derive(Serialize)]
pub struct StateView {
pub entity_id: String,
pub state: String,
pub attributes: serde_json::Value,
pub last_changed: String,
pub last_updated: String,
pub context: ContextView,
}
#[derive(Serialize)]
pub struct ContextView {
pub id: String,
pub user_id: Option<String>,
pub parent_id: Option<String>,
}
impl StateView {
pub fn from_state(s: &homecore::State) -> Self {
Self {
entity_id: s.entity_id.as_str().to_string(),
state: s.state.clone(),
attributes: s.attributes.clone(),
last_changed: s.last_changed.to_rfc3339(),
last_updated: s.last_updated.to_rfc3339(),
context: ContextView {
id: s.context.id.to_string(),
user_id: s.context.user_id.clone(),
parent_id: s.context.parent_id.map(|p| p.to_string()),
},
}
}
}
pub async fn get_states(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<StateView>>> {
let _ = BearerAuth::from_headers(&headers)?;
let snapshots = s.homecore().states().all();
Ok(Json(snapshots.iter().map(|x| StateView::from_state(x)).collect()))
}
pub async fn get_state(
headers: HeaderMap,
State(s): State<SharedState>,
Path(entity_id): Path<String>,
) -> ApiResult<Json<StateView>> {
let _ = BearerAuth::from_headers(&headers)?;
let id = EntityId::parse(entity_id.clone()).map_err(|e| ApiError::BadRequest(e.to_string()))?;
let st = s.homecore().states().get(&id).ok_or_else(|| ApiError::NotFound(entity_id))?;
Ok(Json(StateView::from_state(&st)))
}
#[derive(Deserialize)]
pub struct SetStateRequest {
pub state: String,
#[serde(default)]
pub attributes: serde_json::Value,
}
pub async fn set_state(
headers: HeaderMap,
State(s): State<SharedState>,
Path(entity_id): Path<String>,
Json(body): Json<SetStateRequest>,
) -> ApiResult<(StatusCode, Json<StateView>)> {
let _ = BearerAuth::from_headers(&headers)?;
let id = EntityId::parse(entity_id).map_err(|e| ApiError::BadRequest(e.to_string()))?;
let existed = s.homecore().states().get(&id).is_some();
let attrs = if body.attributes.is_null() { serde_json::json!({}) } else { body.attributes };
let snap = s.homecore().states().set(id, body.state, attrs, Context::new());
let status = if existed { StatusCode::OK } else { StatusCode::CREATED };
Ok((status, Json(StateView::from_state(&snap))))
}
#[derive(Serialize)]
pub struct ServiceDomainView {
pub domain: String,
pub services: serde_json::Value,
}
pub async fn get_services(headers: HeaderMap, State(s): State<SharedState>) -> ApiResult<Json<Vec<ServiceDomainView>>> {
let _ = BearerAuth::from_headers(&headers)?;
let services = s.homecore().services().registered_services().await;
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
std::collections::HashMap::new();
for sv in services {
by_domain.entry(sv.domain.clone()).or_default().insert(sv.service.clone(), serde_json::json!({}));
}
Ok(Json(by_domain.into_iter().map(|(domain, services)| ServiceDomainView {
domain, services: serde_json::Value::Object(services),
}).collect()))
}
pub async fn call_service(
headers: HeaderMap,
State(s): State<SharedState>,
Path((domain, service)): Path<(String, String)>,
Json(body): Json<serde_json::Value>,
) -> ApiResult<Json<serde_json::Value>> {
use homecore::{ServiceCall, ServiceName};
let _ = BearerAuth::from_headers(&headers)?;
let call = ServiceCall {
name: ServiceName::new(domain.clone(), service.clone()),
data: body,
context: Context::new(),
};
let resp = s.homecore().services().call(call).await.map_err(|e| match e {
homecore::ServiceError::NotRegistered { .. } => ApiError::ServiceNotRegistered { domain, service },
other => ApiError::Internal(other.to_string()),
})?;
Ok(Json(resp))
}

View File

@ -0,0 +1,37 @@
use std::sync::Arc;
use homecore::HomeCore;
#[derive(Clone)]
pub struct SharedState {
inner: Arc<SharedStateInner>,
}
struct SharedStateInner {
pub homecore: HomeCore,
pub homecore_version: String,
pub location_name: String,
}
impl SharedState {
pub fn new(homecore: HomeCore) -> Self {
Self::with_metadata(homecore, "Home", env!("CARGO_PKG_VERSION"))
}
pub fn with_metadata(
homecore: HomeCore,
location_name: impl Into<String>,
homecore_version: impl Into<String>,
) -> Self {
Self {
inner: Arc::new(SharedStateInner {
homecore,
homecore_version: homecore_version.into(),
location_name: location_name.into(),
}),
}
}
pub fn homecore(&self) -> &HomeCore { &self.inner.homecore }
pub fn version(&self) -> &str { &self.inner.homecore_version }
pub fn location_name(&self) -> &str { &self.inner.location_name }
}

View File

@ -0,0 +1,349 @@
//! WebSocket handler — `/api/websocket`. ADR-130 §2.2 P2 command subset.
//!
//! Protocol mirrors HA's WS API:
//! server → `{"type":"auth_required","ha_version":"<v>"}`
//! client → `{"type":"auth","access_token":"<token>"}`
//! server → `{"type":"auth_ok","ha_version":"<v>"}`
//! client → `{"id":1,"type":"get_states"}`
//! server → `{"id":1,"type":"result","success":true,"result":[...]}`
//!
//! `ha_version` is the homecore version string — see ADR-130 Q1 for the
//! companion-app feature-detect concern.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::extract::State;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use tracing::{debug, warn};
use homecore::{Context, ServiceCall, ServiceName, SystemEvent};
use crate::rest::StateView;
use crate::state::SharedState;
/// WebSocket upgrade entry point. Mounted on `/api/websocket`.
pub async fn websocket_handler(
ws: WebSocketUpgrade,
State(state): State<SharedState>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state))
}
async fn handle_socket(mut socket: WebSocket, state: SharedState) {
// Phase 1 — auth handshake.
let auth_req = serde_json::json!({
"type": "auth_required",
"ha_version": state.version(),
});
if socket.send(Message::Text(auth_req.to_string())).await.is_err() {
return;
}
let token = match socket.recv().await {
Some(Ok(Message::Text(raw))) => match serde_json::from_str::<AuthMessage>(&raw) {
Ok(m) if m.kind == "auth" => m.access_token,
_ => {
let _ = socket
.send(Message::Text(
serde_json::json!({"type":"auth_invalid","message":"expected auth"}).to_string(),
))
.await;
return;
}
},
_ => return,
};
// P1: accept any non-empty token. P2: validate against store.
if token.trim().is_empty() {
let _ = socket
.send(Message::Text(
serde_json::json!({"type":"auth_invalid","message":"empty token"}).to_string(),
))
.await;
return;
}
let auth_ok = serde_json::json!({"type":"auth_ok","ha_version": state.version()});
if socket.send(Message::Text(auth_ok.to_string())).await.is_err() {
return;
}
// Phase 2 — command loop.
let conn = Connection::new(state.clone());
conn.run(socket).await;
}
#[derive(Deserialize)]
struct AuthMessage {
#[serde(rename = "type")]
kind: String,
access_token: String,
}
#[derive(Deserialize)]
struct WsCommand {
id: u64,
#[serde(rename = "type")]
kind: String,
#[serde(default)]
event_type: Option<String>,
#[serde(default)]
subscription: Option<u64>,
#[serde(default)]
entity_id: Option<String>,
#[serde(default)]
domain: Option<String>,
#[serde(default)]
service: Option<String>,
#[serde(default)]
service_data: Option<serde_json::Value>,
}
#[derive(Serialize)]
struct ResultMessage<'a> {
id: u64,
#[serde(rename = "type")]
kind: &'static str,
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<ErrorView<'a>>,
}
#[derive(Serialize)]
struct ErrorView<'a> {
code: &'static str,
message: &'a str,
}
struct Connection {
state: SharedState,
next_sub_id: AtomicU64,
subs: Arc<dashmap::DashMap<u64, SubscriptionHandle>>,
}
struct SubscriptionHandle {
abort: tokio::task::AbortHandle,
}
impl Connection {
fn new(state: SharedState) -> Self {
Self {
state,
next_sub_id: AtomicU64::new(1),
subs: Arc::new(dashmap::DashMap::new()),
}
}
async fn run(self, mut socket: WebSocket) {
let conn = Arc::new(self);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let sender_tx = tx.clone();
let recv_task = {
let conn = Arc::clone(&conn);
tokio::spawn(async move {
while let Some(frame) = socket.recv().await {
match frame {
Ok(Message::Text(raw)) => {
let cmd: WsCommand = match serde_json::from_str(&raw) {
Ok(c) => c,
Err(e) => {
warn!("bad ws command: {e}");
continue;
}
};
conn.handle_cmd(cmd, &sender_tx).await;
}
Ok(Message::Ping(p)) => {
let _ = sender_tx.send(format!("__pong:{}", p.len()));
}
Ok(Message::Close(_)) | Err(_) => break,
_ => {}
}
}
// Cancel all subscriptions on disconnect.
for entry in conn.subs.iter() {
entry.value().abort.abort();
}
});
tokio::spawn(async move {
while let Some(msg) = rx.recv().await {
if msg.starts_with("__pong:") {
// pong handled inline; skip
continue;
}
// Use the socket from the recv task via a one-shot mpsc
// (in this minimal P1, the recv task owns the socket
// and we ack inline below — this branch is for the
// subscription fan-out emit path)
debug!("ws emit: {msg}");
}
})
};
let _ = recv_task.await;
}
async fn handle_cmd(&self, cmd: WsCommand, tx: &tokio::sync::mpsc::UnboundedSender<String>) {
match cmd.kind.as_str() {
"ping" => {
let msg = serde_json::json!({"id": cmd.id, "type": "pong"});
let _ = tx.send(msg.to_string());
}
"get_states" => {
let snapshots = self.state.homecore().states().all();
let views: Vec<StateView> = snapshots.iter().map(|s| StateView::from_state(s)).collect();
self.ack(tx, cmd.id, true, Some(serde_json::to_value(views).unwrap()));
}
"get_config" => {
let payload = serde_json::json!({
"location_name": self.state.location_name(),
"version": self.state.version(),
"state": "RUNNING",
});
self.ack(tx, cmd.id, true, Some(payload));
}
"get_services" => {
let services = self.state.homecore().services().registered_services().await;
let mut by_domain: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
std::collections::HashMap::new();
for s in services {
by_domain.entry(s.domain).or_default().insert(s.service, serde_json::json!({}));
}
let payload = serde_json::to_value(by_domain).unwrap();
self.ack(tx, cmd.id, true, Some(payload));
}
"call_service" => {
let (Some(domain), Some(service)) = (cmd.domain.clone(), cmd.service.clone()) else {
self.err(tx, cmd.id, "missing_domain_service", "domain and service are required");
return;
};
let call = ServiceCall {
name: ServiceName::new(domain.clone(), service.clone()),
data: cmd.service_data.unwrap_or(serde_json::json!({})),
context: Context::new(),
};
match self.state.homecore().services().call(call).await {
Ok(v) => self.ack(tx, cmd.id, true, Some(v)),
Err(e) => self.err(tx, cmd.id, "service_error", &e.to_string()),
}
}
"subscribe_events" => {
let sub_id = self.next_sub_id.fetch_add(1, Ordering::Relaxed);
let filter = cmd.event_type.clone();
let tx_clone = tx.clone();
let mut domain_rx = self.state.homecore().bus().subscribe_domain();
let mut system_rx = self.state.homecore().bus().subscribe_system();
let task = tokio::spawn(async move {
loop {
tokio::select! {
evt = system_rx.recv() => match evt {
Ok(SystemEvent::StateChanged(sc)) => {
if filter.as_deref() == Some("state_changed") || filter.is_none() {
let payload = serde_json::json!({
"id": sub_id,
"type": "event",
"event": {
"event_type": "state_changed",
"data": {
"entity_id": sc.entity_id.as_str(),
"old_state": sc.old_state.as_ref().map(|s| StateView::from_state(s)),
"new_state": sc.new_state.as_ref().map(|s| StateView::from_state(s)),
},
"origin": "LOCAL",
"time_fired": sc.fired_at.to_rfc3339(),
}
});
if tx_clone.send(payload.to_string()).is_err() { break; }
}
}
Ok(_) => {}
Err(_) => break,
},
evt = domain_rx.recv() => match evt {
Ok(de) => {
if filter.as_deref() == Some(de.event_type.as_str()) || filter.is_none() {
let payload = serde_json::json!({
"id": sub_id,
"type": "event",
"event": {
"event_type": de.event_type,
"data": de.event_data,
"origin": format!("{:?}", de.origin).to_uppercase(),
"time_fired": de.fired_at.to_rfc3339(),
}
});
if tx_clone.send(payload.to_string()).is_err() { break; }
}
}
Err(_) => break,
}
}
}
});
self.subs.insert(
sub_id,
SubscriptionHandle {
abort: task.abort_handle(),
},
);
self.ack(tx, cmd.id, true, None);
}
"unsubscribe_events" => {
if let Some(sub_id) = cmd.subscription {
if let Some((_, handle)) = self.subs.remove(&sub_id) {
handle.abort.abort();
self.ack(tx, cmd.id, true, None);
} else {
self.err(tx, cmd.id, "not_found", "subscription_id not found");
}
} else {
self.err(tx, cmd.id, "missing_subscription", "subscription is required");
}
}
other => {
self.err(tx, cmd.id, "unknown_command", &format!("unknown ws command: {other}"));
}
}
// entity_id is reserved for future per-entity subscribes
let _ = cmd.entity_id;
}
fn ack(
&self,
tx: &tokio::sync::mpsc::UnboundedSender<String>,
id: u64,
success: bool,
result: Option<serde_json::Value>,
) {
let msg = ResultMessage {
id,
kind: "result",
success,
result,
error: None,
};
let _ = tx.send(serde_json::to_string(&msg).unwrap());
}
fn err(&self, tx: &tokio::sync::mpsc::UnboundedSender<String>, id: u64, code: &'static str, message: &str) {
let msg = ResultMessage {
id,
kind: "result",
success: false,
result: None,
error: Some(ErrorView { code, message }),
};
let _ = tx.send(serde_json::to_string(&msg).unwrap());
}
}
// Suppress unused warnings for placeholder broadcast type
#[allow(dead_code)]
type _UnusedSubBroadcast = broadcast::Sender<()>;