From bc690ff309197dcc4bc6c086904cd15788c47595 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 27 Jul 2026 14:43:48 -0400 Subject: [PATCH] feat(homecore-api): serve bounded recorder history --- v2/Cargo.lock | 1 + v2/crates/homecore-api/Cargo.toml | 1 + v2/crates/homecore-api/src/app.rs | 5 + v2/crates/homecore-api/src/error.rs | 7 +- v2/crates/homecore-api/src/rest.rs | 139 +++++++++++++++++- v2/crates/homecore-api/src/state.rs | 18 +++ .../tests/compatibility_surface.rs | 39 +++++ v2/crates/homecore-server/src/main.rs | 5 +- v2/docs/homecore-capabilities.md | 6 +- 9 files changed, 214 insertions(+), 7 deletions(-) diff --git a/v2/Cargo.lock b/v2/Cargo.lock index cb875c09..5b44a110 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -3553,6 +3553,7 @@ dependencies = [ "futures-util", "homecore", "homecore-automation", + "homecore-recorder", "http-body-util", "hyper 1.8.1", "serde", diff --git a/v2/crates/homecore-api/Cargo.toml b/v2/crates/homecore-api/Cargo.toml index 6e111c56..ccaafc11 100644 --- a/v2/crates/homecore-api/Cargo.toml +++ b/v2/crates/homecore-api/Cargo.toml @@ -18,6 +18,7 @@ path = "src/bin/server.rs" [dependencies] homecore = { path = "../homecore", version = "0.1.0-alpha.0" } homecore-automation = { path = "../homecore-automation", version = "0.1.0-alpha.0" } +homecore-recorder = { path = "../homecore-recorder", version = "0.1.0-alpha.0" } axum = { version = "0.7", features = ["ws", "json", "macros"] } tokio = { version = "1", features = ["full"] } diff --git a/v2/crates/homecore-api/src/app.rs b/v2/crates/homecore-api/src/app.rs index e7d094e7..d090d50e 100644 --- a/v2/crates/homecore-api/src/app.rs +++ b/v2/crates/homecore-api/src/app.rs @@ -42,6 +42,11 @@ pub fn router(state: SharedState) -> Router { .route("/api/template", post(rest::render_template)) .route("/api/config/core/check_config", post(rest::check_config)) .route("/api/error_log", get(rest::error_log)) + .route("/api/history/period", get(rest::get_history)) + .route( + "/api/history/period/:start_time", + get(rest::get_history_period), + ) .route("/api/homecore/compatibility", get(rest::compatibility)) .route("/api/websocket", get(ws::websocket_handler)) .layer(cors) diff --git a/v2/crates/homecore-api/src/error.rs b/v2/crates/homecore-api/src/error.rs index 9dfc027b..01a08950 100644 --- a/v2/crates/homecore-api/src/error.rs +++ b/v2/crates/homecore-api/src/error.rs @@ -14,6 +14,8 @@ pub enum ApiError { Unauthorized, #[error("service not registered: {domain}.{service}")] ServiceNotRegistered { domain: String, service: String }, + #[error("service unavailable: {0}")] + Unavailable(String), #[error("internal error: {0}")] Internal(String), } @@ -21,7 +23,9 @@ pub enum ApiError { pub type ApiResult = Result; #[derive(Serialize)] -struct ErrorPayload { message: String } +struct ErrorPayload { + message: String, +} impl IntoResponse for ApiError { fn into_response(self) -> Response { @@ -30,6 +34,7 @@ impl IntoResponse for ApiError { Self::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), Self::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), Self::ServiceNotRegistered { .. } => (StatusCode::BAD_REQUEST, self.to_string()), + Self::Unavailable(_) => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()), Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), }; (status, Json(ErrorPayload { message })).into_response() diff --git a/v2/crates/homecore-api/src/rest.rs b/v2/crates/homecore-api/src/rest.rs index e576415e..3a3e5bd5 100644 --- a/v2/crates/homecore-api/src/rest.rs +++ b/v2/crates/homecore-api/src/rest.rs @@ -1,4 +1,4 @@ -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::IntoResponse; use axum::Json; @@ -92,6 +92,141 @@ pub struct StateView { pub context: ContextView, } +#[derive(Debug, Deserialize)] +pub struct HistoryQuery { + filter_entity_id: Option, + end_time: Option, + #[serde(default)] + minimal_response: bool, + #[serde(default)] + no_attributes: bool, + #[serde(default)] + significant_changes_only: bool, +} + +const MAX_HISTORY_ENTITIES: usize = 32; + +pub async fn get_history( + headers: HeaderMap, + State(s): State, + Query(query): Query, +) -> ApiResult>>> { + history_response(headers, s, None, query).await +} + +pub async fn get_history_period( + headers: HeaderMap, + State(s): State, + Path(start_time): Path, + Query(query): Query, +) -> ApiResult>>> { + history_response(headers, s, Some(start_time), query).await +} + +async fn history_response( + headers: HeaderMap, + state: SharedState, + start_time: Option, + query: HistoryQuery, +) -> ApiResult>>> { + let _ = BearerAuth::from_headers(&headers, state.tokens()).await?; + let recorder = state + .recorder() + .ok_or_else(|| ApiError::Unavailable("recorder is disabled".into()))?; + let now = chrono::Utc::now(); + let start = match start_time { + Some(value) => parse_history_time(&value)?, + None => now - chrono::Duration::days(1), + }; + let end = match query.end_time.as_deref() { + Some(value) => parse_history_time(value)?, + None => now, + }; + if end < start { + return Err(ApiError::BadRequest( + "end_time must not precede start_time".into(), + )); + } + + let entity_ids = match query.filter_entity_id.as_deref() { + Some(raw) => raw + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + EntityId::parse(value) + .map_err(|error| ApiError::BadRequest(format!("invalid entity_id: {error}"))) + }) + .collect::>>()?, + None => state + .homecore() + .states() + .all() + .into_iter() + .map(|snapshot| snapshot.entity_id.clone()) + .collect(), + }; + if entity_ids.len() > MAX_HISTORY_ENTITIES { + return Err(ApiError::BadRequest(format!( + "history queries are limited to {MAX_HISTORY_ENTITIES} entities" + ))); + } + + let mut result = Vec::with_capacity(entity_ids.len()); + for entity_id in entity_ids { + let rows = recorder + .get_state_history(&entity_id, start, end) + .await + .map_err(|error| ApiError::Internal(format!("history query failed: {error}")))?; + let mut previous_state: Option = None; + let states = rows + .into_iter() + .filter_map(|row| { + if query.significant_changes_only + && previous_state.as_deref() == Some(row.state.as_str()) + { + return None; + } + previous_state = Some(row.state.clone()); + let changed = history_timestamp(row.last_changed_ts); + let updated = history_timestamp(row.last_updated_ts); + Some(StateView { + entity_id: row.entity_id.as_str().to_owned(), + state: row.state, + attributes: if query.no_attributes || query.minimal_response { + serde_json::json!({}) + } else { + row.attributes + }, + last_changed: changed, + last_updated: updated, + context: ContextView { + id: row.context_id.unwrap_or_default(), + user_id: None, + parent_id: None, + }, + }) + }) + .collect(); + result.push(states); + } + Ok(Json(result)) +} + +fn parse_history_time(value: &str) -> ApiResult> { + chrono::DateTime::parse_from_rfc3339(value) + .map(|value| value.with_timezone(&chrono::Utc)) + .map_err(|_| ApiError::BadRequest("history timestamps must be RFC 3339".into())) +} + +fn history_timestamp(seconds: f64) -> String { + let whole = seconds.floor() as i64; + let nanos = ((seconds - seconds.floor()) * 1_000_000_000.0).round() as u32; + chrono::DateTime::::from_timestamp(whole, nanos.min(999_999_999)) + .unwrap_or(chrono::DateTime::::UNIX_EPOCH) + .to_rfc3339() +} + #[derive(Serialize)] pub struct ContextView { pub id: String, @@ -373,7 +508,7 @@ pub async fn compatibility( "template": "implemented", "check_config": "implemented", "error_log": "implemented", - "history": "requires_recorder", + "history": "implemented_when_recorder_enabled", "camera_calendar_media": "integration_dependent" }, "websocket": { diff --git a/v2/crates/homecore-api/src/state.rs b/v2/crates/homecore-api/src/state.rs index 0ec6b041..1a80d982 100644 --- a/v2/crates/homecore-api/src/state.rs +++ b/v2/crates/homecore-api/src/state.rs @@ -1,4 +1,5 @@ use homecore::HomeCore; +use homecore_recorder::Recorder; use std::sync::Arc; use crate::tokens::LongLivedTokenStore; @@ -13,6 +14,7 @@ struct SharedStateInner { pub homecore_version: String, pub location_name: String, pub tokens: LongLivedTokenStore, + pub recorder: Option, } impl SharedState { @@ -50,6 +52,19 @@ impl SharedState { homecore_version: homecore_version.into(), location_name: location_name.into(), tokens, + recorder: None, + }), + } + } + + pub fn with_recorder(self, recorder: Option) -> Self { + Self { + inner: Arc::new(SharedStateInner { + homecore: self.inner.homecore.clone(), + homecore_version: self.inner.homecore_version.clone(), + location_name: self.inner.location_name.clone(), + tokens: self.inner.tokens.clone(), + recorder, }), } } @@ -66,4 +81,7 @@ impl SharedState { pub fn tokens(&self) -> &LongLivedTokenStore { &self.inner.tokens } + pub fn recorder(&self) -> Option<&Recorder> { + self.inner.recorder.as_ref() + } } diff --git a/v2/crates/homecore-api/tests/compatibility_surface.rs b/v2/crates/homecore-api/tests/compatibility_surface.rs index b4001b5d..66ec8df4 100644 --- a/v2/crates/homecore-api/tests/compatibility_surface.rs +++ b/v2/crates/homecore-api/tests/compatibility_surface.rs @@ -63,3 +63,42 @@ async fn compatibility_matrix_is_authenticated() { .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + +#[tokio::test] +async fn history_reads_real_recorder_rows() { + use homecore::{Context, EntityId}; + use homecore_recorder::Recorder; + + let homecore = HomeCore::new(); + let recorder = Recorder::open("sqlite::memory:").await.unwrap(); + let mut changes = homecore.states().subscribe(); + homecore.states().set( + EntityId::parse("light.history_probe").unwrap(), + "on", + serde_json::json!({"brightness": 123}), + Context::new(), + ); + let change = changes.recv().await.unwrap(); + recorder.record_state(&change).await.unwrap(); + + let tokens = LongLivedTokenStore::empty(); + tokens.register("test-token").await; + let state = + SharedState::with_tokens(homecore, "Test", "test", tokens).with_recorder(Some(recorder)); + let response = router(state) + .oneshot( + Request::builder() + .uri("/api/history/period?filter_entity_id=light.history_probe") + .header("authorization", "Bearer test-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body[0][0]["entity_id"], "light.history_probe"); + assert_eq!(body[0][0]["state"], "on"); + assert_eq!(body[0][0]["attributes"]["brightness"], 123); +} diff --git a/v2/crates/homecore-server/src/main.rs b/v2/crates/homecore-server/src/main.rs index f5037fec..d6072aab 100644 --- a/v2/crates/homecore-server/src/main.rs +++ b/v2/crates/homecore-server/src/main.rs @@ -251,7 +251,7 @@ async fn main() -> Result<()> { } // ── 2. Recorder (optional) ────────────────────────────────────── - if let Some(recorder) = recorder { + if let Some(recorder) = recorder.clone() { let _recorder_task = RecorderListener::new(hc.states(), recorder).spawn(); info!( "Recorder open at {} — state_changed events being persisted", @@ -310,7 +310,8 @@ async fn main() -> Result<()> { 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. diff --git a/v2/docs/homecore-capabilities.md b/v2/docs/homecore-capabilities.md index ae6daa4e..b68f5b74 100644 --- a/v2/docs/homecore-capabilities.md +++ b/v2/docs/homecore-capabilities.md @@ -35,6 +35,7 @@ integration or every Apple Home controller. - `POST /api/template` - `POST /api/config/core/check_config` - `GET /api/error_log` +- `GET /api/history/period[/]` - `GET /api/websocket` - `POST /api/intent/handle` - `GET /api/homecore/compatibility` @@ -47,8 +48,9 @@ subscribers resynchronize. `GET /api/homecore/compatibility` is the machine-readable support matrix. HOMECORE implements the core contract above, not every endpoint contributed by -Home Assistant integrations. History needs an attached recorder query surface; -camera, calendar, media, and Lovelace behavior remain integration-dependent. +Home Assistant integrations. History is available when the recorder is +enabled; camera, calendar, media, and Lovelace behavior remain +integration-dependent. Registry mutations require a persistent registry mutation backend. The area registry currently returns a valid empty list.