This commit is contained in:
Mateusz Gruszczyński
2026-08-24 14:05:43 +02:00
parent eb02f66056
commit 04fc91b9f4
30 changed files with 2257 additions and 175 deletions
+224 -11
View File
@@ -1,4 +1,4 @@
use std::{net::IpAddr, time::Duration};
use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
use axum::{
body::Body,
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
@@ -21,7 +21,8 @@ use crate::{
engine,
error::AppError,
home_assistant,
models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
influxdb,
models::{ApiTokenInfo, Automation, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
protocol::merge_discovered,
state::AppState,
};
@@ -56,8 +57,12 @@ pub fn router(state: AppState) -> Router {
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
.route("/api/readings", get(readings))
.route("/api/history", get(history))
.route("/api/control-plan", get(control_plan))
.route("/api/events", get(events))
.route("/api/settings", get(get_settings).put(update_settings))
.route("/api/settings/export", get(export_settings))
.route("/api/settings/import", post(import_settings))
.route("/api/debug", get(get_debug).put(update_debug))
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
@@ -66,6 +71,8 @@ pub fn router(state: AppState) -> Router {
let home_assistant_api = Router::new()
.route("/api/integrations/home-assistant/devices", get(list_devices))
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
.route("/api/integrations/home-assistant/zones/:id/control", post(update_zone_control))
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
Router::new()
@@ -86,9 +93,27 @@ pub fn router(state: AppState) -> Router {
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http())
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
.with_state(state)
}
async fn debug_api_requests(State(state): State<AppState>, request: Request, next: Next) -> Response {
if !state.settings.read().await.debug.overlay_enabled {
return next.run(request).await;
}
let method = request.method().clone();
let path = request.uri().path().to_string();
let started = Instant::now();
let response = next.run(request).await;
state.broadcast("api.request", json!({
"method": method.as_str(),
"path": path,
"status": response.status().as_u16(),
"duration_ms": started.elapsed().as_millis(),
}));
response
}
async fn auth(State(state): State<AppState>, request: Request, next: Next) -> Result<Response, AppError> {
let expected = state.config.app_token.trim();
if expected.is_empty() {
@@ -735,7 +760,7 @@ async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>
#[derive(Debug, Deserialize)]
struct ReadingsQuery { device_id: Option<String>, hours: Option<i64>, limit: Option<u32> }
async fn readings(State(state): State<AppState>, Query(query): Query<ReadingsQuery>) -> Result<Json<Value>, AppError> {
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31);
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
let values = state.db.list_readings(query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500))?;
Ok(Json(json!({"readings": values})))
}
@@ -755,7 +780,10 @@ fn history_bucket_seconds(hours: i64) -> i64 {
1..=6 => 30,
7..=24 => 120,
25..=168 => 600,
_ => 1800,
169..=720 => 1800,
721..=2160 => 7200,
2161..=8760 => 21600,
_ => 86400,
}
}
@@ -858,8 +886,110 @@ fn sensor_history_with_fallback(
Ok(values)
}
async fn combined_device_history(
state: &AppState,
device_id: Option<&str>,
since: chrono::DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<(Vec<Reading>, String, Option<String>), AppError> {
let influx = state.settings.read().await.influxdb.clone();
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
if !influx.enabled || since >= cutoff {
return Ok((state.db.list_device_history(device_id, since, bucket_seconds, limit)?, "sqlite".into(), None));
}
let mut warning = None;
let mut values = match influxdb::query_devices(&state.http, &influx, device_id, since, cutoff, bucket_seconds, limit).await {
Ok(rows) => rows,
Err(err) => {
warning = Some(err.to_string());
state.log("warn", "influx.query_error", "InfluxDB device history query failed", json!({"error": err.to_string()}));
state.db.list_device_history(device_id, since, bucket_seconds, limit)?
}
};
if warning.is_none() {
values.extend(state.db.list_device_history(device_id, cutoff, bucket_seconds, limit)?);
}
values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
Ok((values, source.into(), warning))
}
async fn combined_zone_history(
state: &AppState,
zone_id: Option<&str>,
since: chrono::DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
) -> Result<(Vec<ZoneReading>, String, Option<String>), AppError> {
let influx = state.settings.read().await.influxdb.clone();
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
if !influx.enabled || since >= cutoff {
return Ok((zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?, "sqlite".into(), None));
}
let mut warning = None;
let mut values = match influxdb::query_zones(&state.http, &influx, zone_id, since, cutoff, bucket_seconds, limit).await {
Ok(rows) => rows,
Err(err) => {
warning = Some(err.to_string());
state.log("warn", "influx.query_error", "InfluxDB zone history query failed", json!({"error": err.to_string()}));
zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?
}
};
if warning.is_none() {
values.extend(zone_history_with_fallback(state, zone_id, cutoff, bucket_seconds, limit)?);
}
values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
Ok((values, source.into(), warning))
}
async fn combined_sensor_history(
state: &AppState,
entity_id: Option<&str>,
since: chrono::DateTime<Utc>,
bucket_seconds: i64,
limit: u32,
outdoor_entity: &str,
) -> Result<(Vec<HaReading>, String, Option<String>), AppError> {
let influx = state.settings.read().await.influxdb.clone();
let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64);
let local = |start| -> Result<Vec<HaReading>, AppError> {
if entity_id.is_some() { Ok(state.db.list_ha_history(entity_id, start, bucket_seconds, limit)?) }
else { sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity) }
};
if !influx.enabled || since >= cutoff {
return Ok((local(since)?, "sqlite".into(), None));
}
let mut warning = None;
let mut values = match influxdb::query_ha(&state.http, &influx, entity_id, since, cutoff, bucket_seconds, limit).await {
Ok(rows) => rows,
Err(err) => {
warning = Some(err.to_string());
state.log("warn", "influx.query_error", "InfluxDB HA history query failed", json!({"error": err.to_string()}));
local(since)?
}
};
if warning.is_none() {
values.extend(local(cutoff)?);
}
values.sort_by_key(|row| row.timestamp);
trim_history(&mut values, limit);
let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" };
Ok((values, source.into(), warning))
}
fn trim_history<T>(values: &mut Vec<T>, limit: u32) {
if values.len() > limit as usize {
let keep_from = values.len() - limit as usize;
values.drain(0..keep_from);
}
}
async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery>) -> Result<Json<Value>, AppError> {
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31);
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 3650);
let since = Utc::now() - ChronoDuration::hours(hours);
let bucket_seconds = history_bucket_seconds(hours);
let limit = query.limit.unwrap_or(12_000).clamp(1, 20_000);
@@ -870,27 +1000,31 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
match scope {
"devices" => {
let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
let readings = state.db.list_device_history(device_id, since.clone(), bucket_seconds, limit)?;
let (readings, storage, warning) = combined_device_history(&state, device_id, since, bucket_seconds, limit).await?;
Ok(Json(json!({
"scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds,
"storage": storage, "storage_warning": warning,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
})))
}
"sensors" => {
let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all");
let readings = if entity_id.is_some() { state.db.list_ha_history(entity_id, since.clone(), bucket_seconds, limit)? } else { sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)? };
let (readings, storage, warning) = combined_sensor_history(&state, entity_id, since, bucket_seconds, limit, &outdoor_entity).await?;
Ok(Json(json!({
"scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds,
"storage": storage, "storage_warning": warning,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
})))
}
"overview" => {
let zones = zone_history_with_fallback(&state, None, since.clone(), bucket_seconds, limit)?;
let devices = state.db.list_device_history(None, since.clone(), bucket_seconds, limit)?;
let sensors = sensor_history_with_fallback(&state, since.clone(), bucket_seconds, limit, &outdoor_entity)?;
let (zones, zone_storage, zone_warning) = combined_zone_history(&state, None, since, bucket_seconds, limit).await?;
let (devices, device_storage, device_warning) = combined_device_history(&state, None, since, bucket_seconds, limit).await?;
let (sensors, sensor_storage, sensor_warning) = combined_sensor_history(&state, None, since, bucket_seconds, limit, &outdoor_entity).await?;
Ok(Json(json!({
"scope": "overview", "bucket_seconds": bucket_seconds,
"zones": zones, "devices": devices, "sensors": sensors,
"storage": {"zones": zone_storage, "devices": device_storage, "sensors": sensor_storage},
"storage_warning": [zone_warning, device_warning, sensor_warning].into_iter().flatten().collect::<Vec<_>>(),
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
})))
}
@@ -901,9 +1035,10 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
return Err(AppError::NotFound(format!("zone {zone_id}")));
}
}
let readings = zone_history_with_fallback(&state, zone_id, since.clone(), bucket_seconds, limit)?;
let (readings, storage, warning) = combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?;
Ok(Json(json!({
"scope": "zones", "readings": readings, "bucket_seconds": bucket_seconds,
"storage": storage, "storage_warning": warning,
"counts": {"devices": device_count, "zones": zone_count, "ha": ha_count}
})))
}
@@ -911,6 +1046,10 @@ async fn history(State(state): State<AppState>, Query(query): Query<HistoryQuery
}
}
async fn control_plan(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?))
}
#[derive(Debug, Deserialize)]
struct EventsQuery { limit: Option<u32> }
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> {
@@ -936,17 +1075,73 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
}
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; }
influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
if !input.home_assistant.url.trim().is_empty() {
let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
}
state.db.save_runtime_settings(&input)?;
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = input.clone();
state.log("info", "settings.updated", "Settings updated", json!({}));
state.broadcast("settings.updated", public_settings(&input));
Ok(Json(public_settings(&input)))
}
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
let settings = state.settings.read().await.clone();
Ok(Json(state.db.export_configuration(settings)?))
}
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
}
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
}
if export.automations.iter().any(|item| !devices.contains(item.action_device_id.as_str())) {
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
}
if export.automations.iter().any(|item| item.trigger_device_id.as_deref().is_some_and(|id| !devices.contains(id))) {
return Err(AppError::BadRequest("import contains an automation trigger referencing a missing device".into()));
}
Ok(())
}
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
validate_configuration_export(&export)?;
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
state.db.replace_configuration(&export)?;
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = export.settings.clone();
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
Ok(Json(json!({"ok": true})))
}
async fn get_debug(State(state): State<AppState>) -> Json<DebugSettings> {
Json(state.settings.read().await.debug.clone())
}
async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> {
let mut settings = state.settings.write().await;
settings.debug = input.clone();
state.db.save_runtime_settings(&settings)?;
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
state.broadcast("debug.settings", serde_json::to_value(&input)?);
Ok(Json(input))
}
#[derive(Debug, Deserialize)]
struct CreateAccessTokenRequest {
name: Option<String>,
@@ -1015,6 +1210,24 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"house_mode": settings.house_mode,
"control_strategy": settings.control_strategy,
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
"history_retention_days": settings.history_retention_days,
"history_compaction_enabled": settings.history_compaction_enabled,
"suppress_device_beep": settings.suppress_device_beep,
"debug": settings.debug,
"influxdb": {
"enabled": settings.influxdb.enabled,
"version": settings.influxdb.version,
"url": settings.influxdb.url,
"database": settings.influxdb.database,
"username": settings.influxdb.username,
"password": "",
"password_configured": !settings.influxdb.password.trim().is_empty(),
"org": settings.influxdb.org,
"bucket": settings.influxdb.bucket,
"token": "",
"token_configured": !settings.influxdb.token.trim().is_empty(),
"history_threshold_days": settings.influxdb.history_threshold_days,
},
"home_assistant": {
"url": settings.home_assistant.url,
"token": "",