mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
feat(homecore-api): serve bounded recorder history
This commit is contained in:
Generated
+1
@@ -3553,6 +3553,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"homecore",
|
||||
"homecore-automation",
|
||||
"homecore-recorder",
|
||||
"http-body-util",
|
||||
"hyper 1.8.1",
|
||||
"serde",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<T> = Result<T, ApiError>;
|
||||
|
||||
#[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()
|
||||
|
||||
@@ -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<String>,
|
||||
end_time: Option<String>,
|
||||
#[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<SharedState>,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
) -> ApiResult<Json<Vec<Vec<StateView>>>> {
|
||||
history_response(headers, s, None, query).await
|
||||
}
|
||||
|
||||
pub async fn get_history_period(
|
||||
headers: HeaderMap,
|
||||
State(s): State<SharedState>,
|
||||
Path(start_time): Path<String>,
|
||||
Query(query): Query<HistoryQuery>,
|
||||
) -> ApiResult<Json<Vec<Vec<StateView>>>> {
|
||||
history_response(headers, s, Some(start_time), query).await
|
||||
}
|
||||
|
||||
async fn history_response(
|
||||
headers: HeaderMap,
|
||||
state: SharedState,
|
||||
start_time: Option<String>,
|
||||
query: HistoryQuery,
|
||||
) -> ApiResult<Json<Vec<Vec<StateView>>>> {
|
||||
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::<ApiResult<Vec<_>>>()?,
|
||||
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<String> = 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<chrono::Utc>> {
|
||||
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::<chrono::Utc>::from_timestamp(whole, nanos.min(999_999_999))
|
||||
.unwrap_or(chrono::DateTime::<chrono::Utc>::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": {
|
||||
|
||||
@@ -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<Recorder>,
|
||||
}
|
||||
|
||||
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<Recorder>) -> 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[/<start_time>]`
|
||||
- `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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user