294 lines
12 KiB
Rust
294 lines
12 KiB
Rust
//! `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)
|
|
//! - Plugin runtime (InProcessRuntime by default; Wasmtime with --features wasmtime)
|
|
//! - Automation engine subscribed to the state machine
|
|
//! - Assist pipeline (intent recognizer + handler set)
|
|
//! - HAP bridge surface (accessories registered via the API)
|
|
//!
|
|
//! 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 std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use clap::Parser;
|
|
use tracing::{info, warn};
|
|
|
|
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
|
|
use homecore::service::FnHandler;
|
|
use homecore_api::{router, LongLivedTokenStore, SharedState};
|
|
use homecore_assist::pipeline::default_pipeline;
|
|
use homecore_assist::RegexIntentRecognizer;
|
|
use homecore_automation::AutomationEngine;
|
|
use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
|
|
use homecore_plugins::{InProcessRuntime, PluginRegistry};
|
|
use homecore_recorder::Recorder;
|
|
|
|
#[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,
|
|
|
|
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
|
|
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
|
|
db: String,
|
|
|
|
/// 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,
|
|
|
|
/// Skip the boot-time entity seeding (10 demo entities including
|
|
/// 4 RuView-derived sensors). Use this when wiring real
|
|
/// integrations that will populate the state machine themselves.
|
|
#[arg(long)]
|
|
no_seed_entities: bool,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
init_tracing();
|
|
let cli = Cli::parse();
|
|
|
|
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");
|
|
|
|
// 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.
|
|
seed_default_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.no_seed_entities {
|
|
seed_default_entities(&hc);
|
|
} else {
|
|
info!("Entity seeding disabled by --no-seed-entities");
|
|
}
|
|
|
|
// ── 2. Recorder (optional) ──────────────────────────────────────
|
|
if !cli.no_recorder {
|
|
match Recorder::open(&cli.db).await {
|
|
Ok(recorder) => {
|
|
let recorder = Arc::new(recorder);
|
|
let rec_clone = Arc::clone(&recorder);
|
|
let mut state_rx = hc.states().subscribe();
|
|
tokio::spawn(async move {
|
|
while let Ok(event) = state_rx.recv().await {
|
|
if let Err(e) = rec_clone.record_state(&event).await {
|
|
warn!("recorder write failed: {e}");
|
|
}
|
|
}
|
|
});
|
|
info!("Recorder open at {} — state_changed events being persisted", cli.db);
|
|
}
|
|
Err(e) => {
|
|
warn!("Recorder failed to open ({e}) — continuing without persistence");
|
|
}
|
|
}
|
|
} else {
|
|
info!("Recorder disabled by --no-recorder");
|
|
}
|
|
|
|
// ── 3. Plugin runtime ───────────────────────────────────────────
|
|
let plugin_runtime = InProcessRuntime;
|
|
let plugin_registry: PluginRegistry<InProcessRuntime> = PluginRegistry::new(plugin_runtime);
|
|
info!("Plugin registry ready (runtime: InProcess; Wasmtime available with --features wasmtime)");
|
|
let _ = plugin_registry; // wired-but-empty at boot; integrations register here
|
|
|
|
// ── 4. Automation engine ────────────────────────────────────────
|
|
let _automation_engine = AutomationEngine::new(hc.clone());
|
|
info!("Automation engine ready (no automations loaded yet)");
|
|
|
|
// ── 5. Assist pipeline ──────────────────────────────────────────
|
|
let recognizer = RegexIntentRecognizer::new();
|
|
let pipeline = default_pipeline(recognizer);
|
|
info!("Assist pipeline ready (5 built-in intent handlers via default_pipeline)");
|
|
let _ = pipeline; // wired-but-idle at boot; voice WS plugs in here
|
|
|
|
// ── 6. HAP bridge surface ───────────────────────────────────────
|
|
let hap_record = HapServiceRecord {
|
|
instance_name: "HOMECORE".to_string(),
|
|
port: 51826,
|
|
setup_code: "123-45-678".to_string(),
|
|
device_id: "AA:BB:CC:DD:EE:FF".to_string(),
|
|
};
|
|
let hap_bridge = HapBridge::new(hap_record);
|
|
info!("HAP bridge surface ready ({} accessories registered)", hap_bridge.running_accessories().len());
|
|
let _ = hap_bridge;
|
|
|
|
// ── 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 tokens = if std::env::var("HOMECORE_TOKENS").map(|v| !v.trim().is_empty()).unwrap_or(false) {
|
|
let s = LongLivedTokenStore::from_env();
|
|
let n = s.len().await;
|
|
info!("LongLivedTokenStore provisioned with {} bearer token(s) from HOMECORE_TOKENS", n);
|
|
s
|
|
} else {
|
|
warn!("HOMECORE_TOKENS not set — token store in DEV mode (any non-empty bearer accepted). Provision real tokens before exposing to the network.");
|
|
LongLivedTokenStore::allow_any_non_empty()
|
|
};
|
|
let api_state = SharedState::with_tokens(
|
|
hc.clone(),
|
|
cli.location_name,
|
|
env!("CARGO_PKG_VERSION"),
|
|
tokens,
|
|
);
|
|
let app = router(api_state);
|
|
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
|
|
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
|
|
|
|
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|
|
|
|
fn init_tracing() {
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
.unwrap_or_else(|_| "info,homecore=debug,homecore_server=debug,tower_http=info".into()),
|
|
)
|
|
.init();
|
|
}
|
|
|
|
/// Register a representative set of built-in services so `/api/services`
|
|
/// is non-empty on first boot. Each handler simply echoes the call back
|
|
/// as a JSON acknowledgement — integrations override these by
|
|
/// re-registering the same `ServiceName` with a real handler later.
|
|
///
|
|
/// The set covers the HA wire-compat "starter pack" (homeassistant /
|
|
/// light / switch / scene / automation domains) plus a `homecore.*`
|
|
/// domain so operators can see HOMECORE-native services distinguished
|
|
/// from the HA-compat ones.
|
|
async fn seed_default_services(hc: &HomeCore) {
|
|
let echo = || FnHandler(|call: ServiceCall| async move {
|
|
Ok(serde_json::json!({
|
|
"called": format!("{}.{}", call.name.domain, call.name.service),
|
|
"service_data": call.data,
|
|
"acknowledged": true,
|
|
}))
|
|
});
|
|
|
|
let svcs = [
|
|
// Conventional HA wire-compat services
|
|
("homeassistant", "restart"),
|
|
("homeassistant", "stop"),
|
|
("homeassistant", "reload_core_config"),
|
|
("light", "turn_on"),
|
|
("light", "turn_off"),
|
|
("light", "toggle"),
|
|
("switch", "turn_on"),
|
|
("switch", "turn_off"),
|
|
("switch", "toggle"),
|
|
("scene", "apply"),
|
|
("automation", "trigger"),
|
|
// HOMECORE-native services
|
|
("homecore", "ping"),
|
|
("homecore", "snapshot_state"),
|
|
];
|
|
|
|
for (domain, service) in svcs {
|
|
hc.services()
|
|
.register(ServiceName::new(domain, service), echo())
|
|
.await;
|
|
}
|
|
|
|
let count = hc.services().registered_services().await.len();
|
|
let _ = ServiceError::NotRegistered { domain: String::new(), service: String::new() };
|
|
info!("Service registry seeded with {} default service(s)", count);
|
|
}
|
|
|
|
/// Register 10 representative entities so a fresh `--db :memory:`
|
|
/// boot has content for the web UI. Mirrors `scripts/homecore-seed.sh`
|
|
/// — when both are run the script just overwrites these values, so
|
|
/// they stay in sync.
|
|
fn seed_default_entities(hc: &HomeCore) {
|
|
let entities: Vec<(&str, &str, serde_json::Value)> = vec![
|
|
("sensor.living_room_presence", "false", serde_json::json!({
|
|
"friendly_name": "Living Room Presence", "device_class": "occupancy",
|
|
"source": "RuView ESP32-C6 BFLD"
|
|
})),
|
|
("sensor.living_room_motion_score", "0.0", serde_json::json!({
|
|
"friendly_name": "Living Room Motion Score", "unit_of_measurement": "score",
|
|
"icon": "mdi:motion-sensor"
|
|
})),
|
|
("sensor.bedroom_breathing_rate", "14.5", serde_json::json!({
|
|
"friendly_name": "Bedroom Breathing Rate", "unit_of_measurement": "BPM",
|
|
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
|
|
})),
|
|
("sensor.bedroom_heart_rate", "68.0", serde_json::json!({
|
|
"friendly_name": "Bedroom Heart Rate", "unit_of_measurement": "BPM",
|
|
"device_class": "frequency", "source": "Seeed MR60BHA2 mmWave"
|
|
})),
|
|
("light.kitchen_ceiling", "on", serde_json::json!({
|
|
"friendly_name": "Kitchen Ceiling", "brightness": 230,
|
|
"color_temp_kelvin": 4000, "supported_color_modes": ["color_temp"]
|
|
})),
|
|
("light.living_room_lamp", "off", serde_json::json!({
|
|
"friendly_name": "Living Room Lamp", "brightness": 0,
|
|
"supported_color_modes": ["brightness"]
|
|
})),
|
|
("switch.coffee_maker", "off", serde_json::json!({
|
|
"friendly_name": "Coffee Maker", "device_class": "outlet"
|
|
})),
|
|
("binary_sensor.front_door", "off", serde_json::json!({
|
|
"friendly_name": "Front Door", "device_class": "door"
|
|
})),
|
|
("climate.thermostat", "heat", serde_json::json!({
|
|
"friendly_name": "Thermostat", "current_temperature": 21.5,
|
|
"temperature": 22.0, "hvac_modes": ["off", "heat", "cool", "auto"],
|
|
"supported_features": 387
|
|
})),
|
|
("sensor.air_quality_index", "42", serde_json::json!({
|
|
"friendly_name": "Air Quality Index", "unit_of_measurement": "AQI",
|
|
"device_class": "aqi"
|
|
})),
|
|
];
|
|
|
|
for (id, state, attrs) in entities {
|
|
match EntityId::parse(id) {
|
|
Ok(eid) => {
|
|
hc.states().set(eid, state, attrs, Context::new());
|
|
}
|
|
Err(e) => warn!("seed_default_entities: bad entity_id {id}: {e}"),
|
|
}
|
|
}
|
|
|
|
let _ = ServiceCall {
|
|
name: ServiceName::new("homecore", "noop"),
|
|
data: serde_json::json!({}),
|
|
context: Context::new(),
|
|
};
|
|
let total = hc.states().all().len();
|
|
info!("State machine seeded with {} default entit{}", total,
|
|
if total == 1 { "y" } else { "ies" });
|
|
}
|