From e47d40c5c4ed3310f3a8fddd4b10871bc71de17a Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 27 Jul 2026 14:45:35 -0400 Subject: [PATCH] feat(homecore-api): add logbook and integration REST routes --- v2/crates/homecore-api/src/app.rs | 5 + v2/crates/homecore-api/src/rest.rs | 175 ++++++++++++++++++++++++++++- v2/docs/homecore-capabilities.md | 9 +- 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/v2/crates/homecore-api/src/app.rs b/v2/crates/homecore-api/src/app.rs index d090d50e..fc895d31 100644 --- a/v2/crates/homecore-api/src/app.rs +++ b/v2/crates/homecore-api/src/app.rs @@ -47,6 +47,11 @@ pub fn router(state: SharedState) -> Router { "/api/history/period/:start_time", get(rest::get_history_period), ) + .route("/api/logbook", get(rest::get_logbook)) + .route("/api/logbook/:start_time", get(rest::get_logbook_period)) + .route("/api/calendars", get(rest::get_calendars)) + .route("/api/calendars/:entity_id", get(rest::get_calendar_events)) + .route("/api/camera_proxy/:entity_id", get(rest::get_camera_proxy)) .route("/api/homecore/compatibility", get(rest::compatibility)) .route("/api/websocket", get(ws::websocket_handler)) .layer(cors) diff --git a/v2/crates/homecore-api/src/rest.rs b/v2/crates/homecore-api/src/rest.rs index 3a3e5bd5..87a791ef 100644 --- a/v2/crates/homecore-api/src/rest.rs +++ b/v2/crates/homecore-api/src/rest.rs @@ -227,6 +227,176 @@ fn history_timestamp(seconds: f64) -> String { .to_rfc3339() } +#[derive(Debug, Deserialize)] +pub struct LogbookQuery { + end_time: Option, + entity: Option, +} + +pub async fn get_logbook( + headers: HeaderMap, + State(s): State, + Query(query): Query, +) -> ApiResult>> { + logbook_response(headers, s, None, query).await +} + +pub async fn get_logbook_period( + headers: HeaderMap, + State(s): State, + Path(start_time): Path, + Query(query): Query, +) -> ApiResult>> { + logbook_response(headers, s, Some(start_time), query).await +} + +async fn logbook_response( + headers: HeaderMap, + state: SharedState, + start_time: Option, + query: LogbookQuery, +) -> 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.entity.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!( + "logbook queries are limited to {MAX_HISTORY_ENTITIES} entities" + ))); + } + let mut entries = Vec::new(); + for entity_id in entity_ids { + let rows = recorder + .get_state_history(&entity_id, start, end) + .await + .map_err(|error| ApiError::Internal(format!("logbook query failed: {error}")))?; + for row in rows { + entries.push(serde_json::json!({ + "when": history_timestamp(row.last_updated_ts), + "name": row.entity_id.as_str(), + "state": row.state, + "entity_id": row.entity_id.as_str(), + "context_id": row.context_id + })); + } + } + entries.sort_by(|left, right| { + left["when"] + .as_str() + .cmp(&right["when"].as_str()) + .then_with(|| left["entity_id"].as_str().cmp(&right["entity_id"].as_str())) + }); + Ok(Json(entries)) +} + +#[derive(Serialize)] +pub struct CalendarView { + entity_id: String, + name: String, +} + +pub async fn get_calendars( + headers: HeaderMap, + State(s): State, +) -> ApiResult>> { + let _ = BearerAuth::from_headers(&headers, s.tokens()).await?; + let calendars = s + .homecore() + .states() + .all_by_domain("calendar") + .into_iter() + .map(|state| CalendarView { + entity_id: state.entity_id.as_str().to_owned(), + name: state + .attributes + .get("friendly_name") + .and_then(serde_json::Value::as_str) + .unwrap_or(state.entity_id.as_str()) + .to_owned(), + }) + .collect(); + Ok(Json(calendars)) +} + +#[derive(Debug, Deserialize)] +pub struct CalendarQuery { + start: String, + end: String, +} + +pub async fn get_calendar_events( + headers: HeaderMap, + State(s): State, + Path(entity_id): Path, + Query(query): Query, +) -> ApiResult>> { + let _ = BearerAuth::from_headers(&headers, s.tokens()).await?; + let id = + EntityId::parse(&entity_id).map_err(|error| ApiError::BadRequest(error.to_string()))?; + if id.domain() != "calendar" || s.homecore().states().get(&id).is_none() { + return Err(ApiError::NotFound(entity_id)); + } + let start = parse_history_time(&query.start)?; + let end = parse_history_time(&query.end)?; + if end < start { + return Err(ApiError::BadRequest( + "end must not precede start".to_owned(), + )); + } + // Calendar integrations may expose their current entity without an event + // provider. An empty list is the valid response for that interval. + Ok(Json(Vec::new())) +} + +pub async fn get_camera_proxy( + headers: HeaderMap, + State(s): State, + Path(entity_id): Path, +) -> ApiResult { + let _ = BearerAuth::from_headers(&headers, s.tokens()).await?; + let id = + EntityId::parse(&entity_id).map_err(|error| ApiError::BadRequest(error.to_string()))?; + if id.domain() != "camera" || s.homecore().states().get(&id).is_none() { + return Err(ApiError::NotFound(entity_id)); + } + Err(ApiError::Unavailable( + "camera integration has no image provider".into(), + )) +} + #[derive(Serialize)] pub struct ContextView { pub id: String, @@ -509,7 +679,10 @@ pub async fn compatibility( "check_config": "implemented", "error_log": "implemented", "history": "implemented_when_recorder_enabled", - "camera_calendar_media": "integration_dependent" + "logbook": "implemented_when_recorder_enabled", + "calendar": "implemented_with_integration_supplied_events", + "camera": "implemented_with_integration_supplied_images", + "media": "integration_dependent" }, "websocket": { "auth": "implemented", diff --git a/v2/docs/homecore-capabilities.md b/v2/docs/homecore-capabilities.md index b68f5b74..84abde27 100644 --- a/v2/docs/homecore-capabilities.md +++ b/v2/docs/homecore-capabilities.md @@ -36,6 +36,9 @@ integration or every Apple Home controller. - `POST /api/config/core/check_config` - `GET /api/error_log` - `GET /api/history/period[/]` +- `GET /api/logbook[/]` +- `GET /api/calendars[/:entity_id]` +- `GET /api/camera_proxy/:entity_id` - `GET /api/websocket` - `POST /api/intent/handle` - `GET /api/homecore/compatibility` @@ -49,8 +52,10 @@ 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 is available when the recorder is -enabled; camera, calendar, media, and Lovelace behavior remain -integration-dependent. +enabled. Calendar discovery and interval queries are implemented, with events +supplied only by calendar integrations. Camera routing is implemented and +returns a typed unavailable response when the entity has no image provider. +Media and Lovelace behavior remain integration-dependent. Registry mutations require a persistent registry mutation backend. The area registry currently returns a valid empty list.