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:
parent
c04906e7a8
commit
02742f2f47
|
|
@ -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"
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -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<()>;
|
||||||
Loading…
Reference in New Issue