//! `homecore-server` — the HOMECORE integration binary. //! //! Boots one process that exposes the full HA-compat surface: //! //! - HomeCore runtime (state machine + event bus + service registry) //! - SQLite recorder writing every state_changed event //! - REST + WebSocket API on :8123 (HA wire-compat) //! - Automation engine subscribed to the state machine //! - Assist pipeline (intent recognizer + handler set) //! //! Run with: //! //! cargo run -p homecore-server --bin homecore-server -- --bind 0.0.0.0:8123 //! //! All-feature build with ruvector + wasmtime: //! //! cargo run -p homecore-server --features ruvector,wasmtime -- ... use std::net::SocketAddr; use anyhow::Result; use clap::Parser; use tracing::{info, warn}; use homecore::service::FnHandler; use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName}; use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState}; use homecore_assist::{ AssistPipeline, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn, RegexIntentRecognizer, }; use homecore_automation::AutomationEngine; use homecore_recorder::{Recorder, RecorderListener}; use axum::Router; use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; mod gateway; mod hap; mod plugins; mod restore; use gateway::{GatewayConfig, GatewayState}; /// Compile-time default location of the HOMECORE-UI assets (ADR-131). /// Works in dev/CI; the appliance overrides with `--ui-dir` / /// `HOMECORE_UI_DIR`. const DEFAULT_UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ui"); #[derive(Parser, Debug, Clone)] #[command(name = "homecore-server", version)] struct Cli { /// Bind address for the HA-compat REST + WS API. #[arg(long, env = "HOMECORE_BIND", default_value = "0.0.0.0:8123")] bind: SocketAddr, /// Directory of the HOMECORE-UI dashboard assets, served at /// `/homecore` (ADR-131). Empty string disables the UI mount. #[arg(long, env = "HOMECORE_UI_DIR", default_value = DEFAULT_UI_DIR)] ui_dir: String, /// Base URL of the calibration service (`wifi-densepose calibrate-serve`), /// reverse-proxied by the BFF gateway at `/api/cal/*` (ADR-131 §11). /// Unset → calibration/room endpoints return a typed 503. #[arg(long, env = "HOMECORE_CALIBRATION_URL")] calibration_url: Option, /// Bearer token for the calibration service (held server-side only, /// never exposed to the browser — ADR-131 §11.10). #[arg(long, env = "HOMECORE_CALIBRATION_TOKEN")] calibration_token: Option, /// COG install directory the gateway's supervisor reads (ADR-131 §11.6). #[arg( long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps" )] apps_dir: String, /// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1). #[arg(long, env = "HOMECORE_GATEWAY_TIMEOUT_MS", default_value_t = 2000)] gateway_timeout_ms: u64, /// SQLite recorder DB path. Use `:memory:` for an ephemeral run. #[arg(long, env = "HOMECORE_DB", default_value = "sqlite://homecore.db")] db: String, /// HOMECORE registry storage directory restored at startup. #[arg( long, env = "HOMECORE_STORAGE_DIR", default_value = ".homecore/storage" )] storage_dir: std::path::PathBuf, /// Maximum registry rows and latest entity states restored at startup. #[arg(long, env = "HOMECORE_RESTORE_LIMIT", default_value_t = 100_000)] restore_limit: usize, /// Friendly location name surfaced via `/api/config`. #[arg(long, env = "HOMECORE_LOCATION", default_value = "Home")] location_name: String, /// Disable the SQLite recorder for low-resource deployments. #[arg(long)] no_recorder: bool, /// Explicitly allow any non-empty bearer token. Development only. #[arg(long, env = "HOMECORE_INSECURE_DEV_AUTH", default_value_t = false)] insecure_dev_auth: bool, /// Seed synthetic demo entities. Disabled by default so simulated /// biometric readings are never mistaken for live sensor data. #[arg(long)] seed_demo_entities: bool, /// Optional Home Assistant-style automations YAML file to load at boot. #[arg(long, env = "HOMECORE_AUTOMATIONS")] automations: Option, /// Explicit directories containing packaged WebAssembly plugins. #[arg( long = "plugin-dir", env = "HOMECORE_PLUGIN_DIRS", value_delimiter = ',' )] plugin_dirs: Vec, /// Base64 Ed25519 publisher keys trusted to sign WebAssembly packages. #[arg( long = "plugin-trusted-publisher", env = "HOMECORE_PLUGIN_TRUSTED_PUBLISHERS", value_delimiter = ',' )] plugin_trusted_publishers: Vec, /// Permit unsigned WebAssembly plugins. Unsafe; development only. #[arg(long, env = "HOMECORE_PLUGIN_ALLOW_UNSIGNED", default_value_t = false)] plugin_allow_unsigned: bool, /// Bind address for the optional HomeKit Accessory Protocol server. /// The server remains disabled unless this option is supplied. #[arg(long, env = "HOMECORE_HAP_BIND")] hap_bind: Option, /// Stable six-octet HAP accessory identifier (for example /// `AA:BB:CC:DD:EE:FF`). Required when HAP is enabled. #[arg(long, env = "HOMECORE_HAP_DEVICE_ID")] hap_device_id: Option, /// HAP setup code in `XXX-XX-XXX` form. Required only when creating a /// pairing store for the first time and never persisted in plaintext. #[arg(long, env = "HOMECORE_HAP_SETUP_CODE", hide_env_values = true)] hap_setup_code: Option, /// LAN address published in the HAP mDNS record. Required when HAP is enabled. #[arg(long, env = "HOMECORE_HAP_ADVERTISE_ADDR")] hap_advertise_addr: Option, /// DNS hostname published by mDNS for the HAP bridge. #[arg(long, env = "HOMECORE_HAP_HOSTNAME", default_value = "homecore")] hap_hostname: String, /// HAP discovery instance shown to controller applications. #[arg( long, env = "HOMECORE_HAP_INSTANCE_NAME", default_value = "HOMECORE Bridge" )] hap_instance_name: String, /// Durable controller pairing database. #[arg( long, env = "HOMECORE_HAP_PAIRING_STORE", default_value = ".homecore/hap/pairings.json" )] hap_pairing_store: std::path::PathBuf, } #[tokio::main] async fn main() -> Result<()> { init_tracing(); let mut cli = Cli::parse(); let has_tokens = std::env::var("HOMECORE_TOKENS") .map(|value| !value.trim().is_empty()) .unwrap_or(false); if !has_tokens && !cli.insecure_dev_auth { anyhow::bail!( "HOMECORE_TOKENS is required; use --insecure-dev-auth only for isolated development" ); } let tokens = if has_tokens { let store = LongLivedTokenStore::from_env(); info!( "Provisioned {} bearer token(s) from HOMECORE_TOKENS", store.len().await ); store } else { warn!( "Insecure development authentication enabled: any non-empty bearer token is accepted" ); LongLivedTokenStore::allow_any_non_empty() }; info!( "HOMECORE booting — bind={}, db={}, location={:?}", cli.bind, cli.db, cli.location_name ); // ── 1. HomeCore runtime ───────────────────────────────────────── let hc = HomeCore::new(); info!("HomeCore state machine + event bus + service registry online"); let recorder = if cli.no_recorder { None } else { match open_recorder(&cli.db).await { Ok(recorder) => Some(recorder), Err(error) => { warn!("Recorder failed to open ({error}) — continuing without persistence"); None } } }; let restored = restore::restore_startup(&hc, recorder.as_ref(), &cli.storage_dir, cli.restore_limit).await; info!( entities = restored.entity_entries, devices = restored.device_entries, states = restored.states, truncated = restored.truncated, "Startup restoration complete" ); for warning in restored.warnings { warn!("{warning}"); } // Seed a representative set of built-in services so the web UI // and HA-wire-compat clients see a populated /api/services on // first boot. These are no-op handlers (they just echo back the // call as JSON for observability) — integrations override them // by registering the same ServiceName later. register_builtin_services(&hc).await; // Seed 10 representative entities so the web UI's Dashboard + // States pages have content out of the box. Operators registering // real integrations / plugins overwrite these by writing the same // entity_id with new values. Opt out with `--no-seed-entities`. if cli.seed_demo_entities { seed_default_entities(&hc); } else { info!("Synthetic demo entities disabled (use --seed-demo-entities to opt in)"); } // ── 2. Recorder (optional) ────────────────────────────────────── if let Some(recorder) = recorder.clone() { let _recorder_task = RecorderListener::new(hc.states(), recorder).spawn(); info!( "Recorder open at {} — state_changed events being persisted", cli.db ); } else { info!("Recorder unavailable or disabled"); } // ── 3. Plugin runtime ─────────────────────────────────────────── let server_plugins = plugins::ServerPlugins::start( hc.clone(), plugins::PluginConfig { directories: cli.plugin_dirs.clone(), trusted_publishers: cli.plugin_trusted_publishers.clone(), allow_unsigned: cli.plugin_allow_unsigned, limits: homecore_plugins::DiscoveryLimits::default(), }, ) .await?; // ── 4. Automation engine ──────────────────────────────────────── // Construct AND start the engine (HC-WS-03, ADR-161). `start()` // spawns the state-change event loop + the 1 Hz wall-clock timer // task so state/numeric/event AND time triggers all fire. The // engine is kept alive for the process lifetime (it is moved into a // long-lived binding); its background tasks run until the HomeCore // broadcast channel closes at shutdown. No automations are loaded at // boot yet (YAML loader is P-next); integrations register via // `engine.register(..)`. let automation_engine = AutomationEngine::new(hc.clone()); if let Some(path) = &cli.automations { let raw = tokio::fs::read_to_string(path).await?; let automations: Vec = serde_yaml::from_str(&raw) .map_err(|e| anyhow::anyhow!("invalid automations file {}: {e}", path.display()))?; for automation in automations { automation_engine.register(automation); } } let _automation_task = automation_engine.start(); info!( "Automation engine started ({} automations registered) — \ state/numeric/event + time triggers active", automation_engine.len() ); // ── 5. Assist pipeline ────────────────────────────────────────── // ── 6. HAP bridge surface ─────────────────────────────────────── // ── 7. REST + WS API ──────────────────────────────────────────── // Token provisioning closes audit findings HC-01/HC-02. If // HOMECORE_TOKENS is set in the env, populate the store from // its comma-separated list. Otherwise fall back to DEV mode // (warn-on-each-request) so existing smoke tests still work. let api_state = SharedState::with_tokens( hc.clone(), cli.location_name, env!("CARGO_PKG_VERSION"), tokens, ) .with_recorder(recorder); // BFF gateway (ADR-131 §11): single-origin aggregation of the // calibration API + SEED/appliance tiers. Shares the same token store // for auth; upstream credentials stay server-side. let assist = build_assist_pipeline().await?; info!( "Assist intent endpoint ready with {} handlers", assist.handler_count() ); let hap_runtime = hap::start( &hc, hap::HapRuntimeConfig { bind_addr: cli.hap_bind, device_id: cli.hap_device_id.clone(), setup_code: cli.hap_setup_code.take(), advertise_addr: cli.hap_advertise_addr, hostname: cli.hap_hostname.clone(), instance_name: cli.hap_instance_name.clone(), pairing_store: cli.hap_pairing_store.clone(), }, ) .await?; let gw = GatewayState::with_assist( api_state.clone(), GatewayConfig { calibration_url: cli.calibration_url.clone(), calibration_token: cli.calibration_token.clone(), apps_dir: std::path::PathBuf::from(&cli.apps_dir), timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms), }, assist, ); // Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the // audited CORS allowlist + request tracing to the WHOLE surface. The // gateway routes (`/api/homecore/*`, `/api/cal/*`) are merged in outside // `router()`'s own layers, so without this outer layer they would have NO // CORS coverage and would not be traced (ADR-131 §11 review). Applying CORS // again to the homecore-api routes is idempotent. let app = build_app(api_state, &cli.ui_dir) .merge(gateway::gateway_router(gw)) .layer(build_cors_layer()) .layer(TraceLayer::new_for_http()); let listener = tokio::net::TcpListener::bind(cli.bind).await?; info!( "HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind ); info!( "HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})", cli.calibration_url ); if !cli.ui_dir.trim().is_empty() { info!( "HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir ); } else { info!("HOMECORE-UI mount disabled (--ui-dir empty)"); } let shutdown_hc = hc.clone(); axum::serve(listener, app) .with_graceful_shutdown(async move { if let Err(error) = tokio::signal::ctrl_c().await { warn!("failed to install Ctrl-C handler: {error}"); } shutdown_hc .bus() .fire_system(homecore::SystemEvent::HomeCoreStop); info!("Shutdown requested; draining active HTTP connections"); }) .await?; hap_runtime.shutdown().await?; server_plugins.shutdown().await; Ok(()) } /// Assemble the full HTTP surface: the HA-compat REST + WS router /// (ADR-130) plus the HOMECORE-UI static mount at `/homecore` (ADR-131). /// Split out from `main` so it is exercised by the integration tests. fn build_app(api_state: SharedState, ui_dir: &str) -> Router { let app = router(api_state); if ui_dir.trim().is_empty() { return app; } // ServeDir serves index.html for the directory root, so /homecore/ // returns the dashboard and /homecore/js/... /homecore/css/... map // straight onto the asset tree the relative /