diff --git a/build.rs b/build.rs index c36c299..e8a4490 100644 --- a/build.rs +++ b/build.rs @@ -226,7 +226,10 @@ fn main() { .file_stem() .and_then(|value| value.to_str()) .expect("UTF-8 preset id"); - if !stem.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') { + if !stem + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { panic!("{filename}: filename may only contain ASCII letters, digits, '-' and '_'"); } let source = fs::read_to_string(&path) @@ -260,7 +263,9 @@ fn main() { .get("flow") .and_then(Value::as_object) .unwrap_or_else(|| panic!("{filename}: flow must be an object")); - if !flow.get("nodes").is_some_and(Value::is_array) || !flow.get("edges").is_some_and(Value::is_array) { + if !flow.get("nodes").is_some_and(Value::is_array) + || !flow.get("edges").is_some_and(Value::is_array) + { panic!("{filename}: flow.nodes and flow.edges must be arrays"); } preset_manifest.push(json!({ diff --git a/home-assistant/generated/gree_controller_entities.example.json b/home-assistant/generated/gree_controller_entities.example.json index ad2b638..70cfa50 100644 --- a/home-assistant/generated/gree_controller_entities.example.json +++ b/home-assistant/generated/gree_controller_entities.example.json @@ -6,4 +6,4 @@ "device_id": "gree-aabbccddeeff" } ] -} +} \ No newline at end of file diff --git a/lang/en.json b/lang/en.json index 21772b8..6633469 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1168,4 +1168,4 @@ "schedules.enabledState": "Enabled", "schedules.disabledState": "Disabled" } -} +} \ No newline at end of file diff --git a/lang/pl.json b/lang/pl.json index 23f1fd6..c2806c9 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -1168,4 +1168,4 @@ "schedules.enabledState": "Włączony", "schedules.disabledState": "Wyłączony" } -} +} \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index d03d246..5280d8c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,7 +1,27 @@ -use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}}; +use crate::{ + engine, + error::AppError, + home_assistant, influxdb, + models::{ + ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, + DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GreeSettings, + GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, + HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, + InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings, + NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, Reading, + RuntimeSettings, Schedule, TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, + ZoneControlPatch, ZoneReading, + }, + notifications, + protocol::merge_discovered, + state::AppState, +}; use axum::{ body::Body, - extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}}, + extract::{ + ws::{Message, WebSocket}, + Path, Query, Request, State, WebSocketUpgrade, + }, http::{header, HeaderMap, HeaderValue, StatusCode}, middleware::{self, Next}, response::{Redirect, Response}, @@ -13,20 +33,15 @@ use chrono::{Duration as ChronoDuration, NaiveTime, Utc}; use futures_util::StreamExt; use rand::{rngs::OsRng, RngCore}; use serde::Deserialize; -use sha2::{Digest, Sha256}; use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::{ + net::IpAddr, + sync::atomic::Ordering, + time::{Duration, Instant}, +}; use tower_http::{compression::CompressionLayer, trace::TraceLayer}; use uuid::Uuid; -use crate::{ - engine, - error::AppError, - home_assistant, - influxdb, - notifications, - models::{ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GreeSettings, GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings, NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch, ZoneReading}, - protocol::merge_discovered, - state::AppState, -}; mod openapi; @@ -67,65 +82,168 @@ pub fn router(state: AppState) -> Router { .route("/api/system/info", get(system_info)) .route("/api/discovery", post(discover)) .route("/api/devices", get(list_devices).post(add_device)) - .route("/api/devices/:id", get(get_device).patch(patch_device).delete(delete_device)) + .route( + "/api/devices/:id", + get(get_device).patch(patch_device).delete(delete_device), + ) .route("/api/devices/:id/bind", post(bind_device)) .route("/api/devices/:id/poll", post(poll_device)) .route("/api/devices/:id/probe", post(probe_device)) .route("/api/devices/:id/command", post(command_device)) .route("/api/zones", get(list_zones).post(create_zone)) - .route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone)) + .route( + "/api/zones/:id", + get(get_zone).put(update_zone).delete(delete_zone), + ) .route("/api/zones/:id/control", post(update_zone_control)) - .route("/api/zones/:id/compressor-queue/cancel", post(cancel_zone_compressor_queue)) - .route("/api/compressor-queue/cancel-all", post(cancel_all_compressor_queues)) - .route("/api/zones/:id/schedule-template", post(apply_schedule_template)) + .route( + "/api/zones/:id/compressor-queue/cancel", + post(cancel_zone_compressor_queue), + ) + .route( + "/api/compressor-queue/cancel-all", + post(cancel_all_compressor_queues), + ) + .route( + "/api/zones/:id/schedule-template", + post(apply_schedule_template), + ) .route("/api/groups", get(list_groups).post(create_group)) - .route("/api/groups/:id", get(get_group).put(update_group).delete(delete_group)) + .route( + "/api/groups/:id", + get(get_group).put(update_group).delete(delete_group), + ) .route("/api/groups/:id/control", post(update_group_control)) .route("/api/house/control", post(update_house_control)) .route("/api/house/power", post(update_house_power)) .route("/api/house/preset", post(update_house_preset)) .route("/api/schedules", get(list_schedules).post(create_schedule)) - .route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule)) - .route("/api/automations", get(list_automations).post(create_automation)) - .route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation)) + .route( + "/api/schedules/:id", + get(get_schedule) + .put(update_schedule) + .delete(delete_schedule), + ) + .route( + "/api/automations", + get(list_automations).post(create_automation), + ) + .route( + "/api/automations/:id", + get(get_automation) + .put(update_automation) + .delete(delete_automation), + ) .route("/api/flows", get(list_flows).post(create_flow)) .route("/api/flows/import", post(import_flow)) .route("/api/flows/simulate", post(simulate_flow)) .route("/api/flows/:id/export", get(export_flow)) .route("/api/flows/:id/logs", get(flow_logs)) - .route("/api/flows/:id", get(get_flow).put(update_flow).delete(delete_flow)) + .route( + "/api/flows/:id", + get(get_flow).put(update_flow).delete(delete_flow), + ) .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/application", get(get_application_settings).put(update_application_settings)) - .route("/api/settings/gree", get(get_gree_settings).put(update_gree_settings)) - .route("/api/settings/history", get(get_history_settings).put(update_history_settings)) - .route("/api/settings/influxdb", get(get_influxdb_settings).put(update_influxdb_settings)) - .route("/api/settings/notifications", get(get_notification_settings).put(update_notification_settings)) - .route("/api/settings/night", get(get_night_settings).put(update_night_settings)) - .route("/api/settings/home-assistant", get(get_home_assistant_settings).put(update_home_assistant_settings)) - .route("/api/settings/debug", get(get_debug_settings).put(update_debug_settings)) + .route( + "/api/settings/application", + get(get_application_settings).put(update_application_settings), + ) + .route( + "/api/settings/gree", + get(get_gree_settings).put(update_gree_settings), + ) + .route( + "/api/settings/history", + get(get_history_settings).put(update_history_settings), + ) + .route( + "/api/settings/influxdb", + get(get_influxdb_settings).put(update_influxdb_settings), + ) + .route( + "/api/settings/notifications", + get(get_notification_settings).put(update_notification_settings), + ) + .route( + "/api/settings/night", + get(get_night_settings).put(update_night_settings), + ) + .route( + "/api/settings/home-assistant", + get(get_home_assistant_settings).put(update_home_assistant_settings), + ) + .route( + "/api/settings/debug", + get(get_debug_settings).put(update_debug_settings), + ) .route("/api/configuration/export", get(export_configuration)) .route("/api/configuration/import", post(import_configuration)) - .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)) - .route("/api/integrations/home-assistant/entity", post(inspect_home_assistant_entity)) - .route("/api/integrations/notifications/test", post(test_notifications)) + .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), + ) + .route( + "/api/integrations/home-assistant/entity", + post(inspect_home_assistant_entity), + ) + .route( + "/api/integrations/notifications/test", + post(test_notifications), + ) .route_layer(middleware::from_fn_with_state(state.clone(), auth)); 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_home_assistant_device)) - .route("/api/integrations/home-assistant/control-plan", get(control_plan)) - .route("/api/integrations/home-assistant/groups", get(list_home_assistant_groups)) - .route("/api/integrations/home-assistant/groups/:id/control", post(update_home_assistant_group_control)) - .route("/api/integrations/home-assistant/house/control", post(update_house_control)) - .route("/api/integrations/home-assistant/house/preset", post(update_house_preset)) - .route("/api/integrations/home-assistant/house/power", post(update_house_power)) - .route("/api/integrations/home-assistant/zones/:id/control", post(update_home_assistant_zone_control)) - .route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); + .route( + "/api/integrations/home-assistant/devices", + get(list_devices), + ) + .route( + "/api/integrations/home-assistant/devices/:id/command", + post(command_home_assistant_device), + ) + .route( + "/api/integrations/home-assistant/control-plan", + get(control_plan), + ) + .route( + "/api/integrations/home-assistant/groups", + get(list_home_assistant_groups), + ) + .route( + "/api/integrations/home-assistant/groups/:id/control", + post(update_home_assistant_group_control), + ) + .route( + "/api/integrations/home-assistant/house/control", + post(update_house_control), + ) + .route( + "/api/integrations/home-assistant/house/preset", + post(update_house_preset), + ) + .route( + "/api/integrations/home-assistant/house/power", + post(update_house_power), + ) + .route( + "/api/integrations/home-assistant/zones/:id/control", + post(update_home_assistant_zone_control), + ) + .route_layer(middleware::from_fn_with_state( + state.clone(), + home_assistant_auth, + )); let mut app = Router::new() .route("/api/health", get(health)) @@ -161,23 +279,27 @@ pub fn router(state: AppState) -> Router { let base = state.config.base_path.clone(); let redirect_to = format!("{base}/"); Router::new() - .route(&base, get(move || { - let redirect_to = redirect_to.clone(); - async move { Redirect::permanent(&redirect_to) } - })) + .route( + &base, + get(move || { + let redirect_to = redirect_to.clone(); + async move { Redirect::permanent(&redirect_to) } + }), + ) .nest(&base, app) .fallback(not_found) }; - app - .layer(CompressionLayer::new()) + app.layer(CompressionLayer::new()) .layer(TraceLayer::new_for_http()) .layer(middleware::from_fn(security_headers)) - .layer(middleware::from_fn_with_state(state.clone(), debug_api_requests)) + .layer(middleware::from_fn_with_state( + state.clone(), + debug_api_requests, + )) .with_state(state) } - // Functional source split intentionally keeps items in the existing module namespace. include!("api/auth.rs"); include!("api/system.rs"); diff --git a/src/api/assets.rs b/src/api/assets.rs index d91be69..82db2e8 100644 --- a/src/api/assets.rs +++ b/src/api/assets.rs @@ -1,15 +1,31 @@ async fn not_found(State(state): State, headers: HeaderMap) -> Response { let base = request_base_path(&state, &headers); - let home = if base.is_empty() { "/".to_owned() } else { format!("{base}/") }; + let home = if base.is_empty() { + "/".to_owned() + } else { + format!("{base}/") + }; let body = NOT_FOUND_HTML .replace("__GREE_BASE_PATH__", &base) .replace("__GREE_HOME_PATH__", &home) - .replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}")) - .replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}")); + .replace( + "__GREE_THEME_INIT_ASSET__", + &format!("{base}{THEME_INIT_ASSET_PATH}"), + ) + .replace( + "__GREE_STYLES_ASSET__", + &format!("{base}{STYLES_CSS_ASSET_PATH}"), + ); let mut response = Response::new(Body::from(body)); *response.status_mut() = StatusCode::NOT_FOUND; - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); - response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate")); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store, no-cache, must-revalidate"), + ); response } @@ -18,11 +34,23 @@ async fn index(State(state): State, headers: HeaderMap) -> Response { let body = INDEX_HTML .replace("__GREE_BASE_PATH__", &base) .replace("__GREE_APP_ASSET__", &format!("{base}{APP_JS_ASSET_PATH}")) - .replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}")) - .replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}")); + .replace( + "__GREE_THEME_INIT_ASSET__", + &format!("{base}{THEME_INIT_ASSET_PATH}"), + ) + .replace( + "__GREE_STYLES_ASSET__", + &format!("{base}{STYLES_CSS_ASSET_PATH}"), + ); let mut response = Response::new(Body::from(body)); - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")); - response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate")); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store, no-cache, must-revalidate"), + ); response } @@ -35,18 +63,65 @@ fn request_base_path(state: &AppState, headers: &HeaderMap) -> String { } fn forwarded_prefix(headers: &HeaderMap) -> Option { - let raw = headers.get("x-forwarded-prefix")?.to_str().ok()?.split(',').next()?.trim(); - if raw.is_empty() || raw == "/" { return Some(String::new()); } - if raw.contains('?') || raw.contains('#') || raw.split('/').any(|part| matches!(part, "." | "..")) { return None; } + let raw = headers + .get("x-forwarded-prefix")? + .to_str() + .ok()? + .split(',') + .next()? + .trim(); + if raw.is_empty() || raw == "/" { + return Some(String::new()); + } + if raw.contains('?') + || raw.contains('#') + || raw.split('/').any(|part| matches!(part, "." | "..")) + { + return None; + } Some(format!("/{}", raw.trim_matches('/'))) } -async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") } -async fn app_js_legacy() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") } -async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") } -async fn theme_init_js_legacy() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "no-cache") } -async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "public, max-age=31536000, immutable") } -async fn styles_css_legacy() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") } -async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") } +async fn app_js() -> Response { + static_response( + APP_JS, + "application/javascript; charset=utf-8", + "public, max-age=31536000, immutable", + ) +} +async fn app_js_legacy() -> Response { + static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") +} +async fn theme_init_js() -> Response { + static_response( + THEME_INIT_JS, + "application/javascript; charset=utf-8", + "public, max-age=31536000, immutable", + ) +} +async fn theme_init_js_legacy() -> Response { + static_response( + THEME_INIT_JS, + "application/javascript; charset=utf-8", + "no-cache", + ) +} +async fn styles_css() -> Response { + static_response( + STYLES_CSS, + "text/css; charset=utf-8", + "public, max-age=31536000, immutable", + ) +} +async fn styles_css_legacy() -> Response { + static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") +} +async fn manifest() -> Response { + static_response( + MANIFEST, + "application/manifest+json", + "public, max-age=3600", + ) +} async fn service_worker() -> Response { let body = SERVICE_WORKER .replace("__GREE_ASSET_CACHE__", ASSET_BUILD_ID) @@ -55,22 +130,38 @@ async fn service_worker() -> Response { .replace("__GREE_STYLES_ASSET__", STYLES_CSS_ASSET_PATH); owned_response(body, "application/javascript; charset=utf-8", "no-cache") } -async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") } +async fn favicon() -> Response { + static_response(FAVICON, "image/svg+xml", "public, max-age=86400") +} async fn language_index() -> Response { - static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache") + static_response( + LANGUAGE_MANIFEST_JSON, + "application/json; charset=utf-8", + "no-cache", + ) } async fn language_file(Path(file): Path) -> Response { let code = file.strip_suffix(".json").unwrap_or(&file); - if let Some((_, body)) = LANGUAGE_ASSETS.iter().find(|(language, _)| *language == code) { + if let Some((_, body)) = LANGUAGE_ASSETS + .iter() + .find(|(language, _)| *language == code) + { return static_response(*body, "application/json; charset=utf-8", "no-cache"); } let mut response = Response::new(Body::from("Language not found")); *response.status_mut() = StatusCode::NOT_FOUND; - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8")); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); response } async fn preset_index() -> Response { - static_response(PRESET_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache") + static_response( + PRESET_MANIFEST_JSON, + "application/json; charset=utf-8", + "no-cache", + ) } async fn preset_file(Path(file): Path) -> Response { if let Some((_, body)) = PRESET_ASSETS.iter().find(|(filename, _)| *filename == file) { @@ -78,19 +169,34 @@ async fn preset_file(Path(file): Path) -> Response { } let mut response = Response::new(Body::from("Preset not found")); *response.status_mut() = StatusCode::NOT_FOUND; - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8")); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); response } -fn static_response(body: &'static str, content_type: &'static str, cache: &'static str) -> Response { +fn static_response( + body: &'static str, + content_type: &'static str, + cache: &'static str, +) -> Response { let mut response = Response::new(Body::from(body)); - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); - response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); response } fn owned_response(body: String, content_type: &'static str, cache: &'static str) -> Response { let mut response = Response::new(Body::from(body)); - response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); - response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); response } diff --git a/src/api/auth.rs b/src/api/auth.rs index 02493e6..2550f45 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -1,4 +1,8 @@ -async fn debug_api_requests(State(state): State, request: Request, next: Next) -> Response { +async fn debug_api_requests( + State(state): State, + request: Request, + next: Next, +) -> Response { if !state.settings.read().await.debug.overlay_enabled { return next.run(request).await; } @@ -6,16 +10,23 @@ async fn debug_api_requests(State(state): State, request: Request, nex 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(), - })); + 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, request: Request, next: Next) -> Result { +async fn auth( + State(state): State, + request: Request, + next: Next, +) -> Result { let expected = state.config.app_token.trim(); if expected.is_empty() { return Ok(next.run(request).await); @@ -44,10 +55,17 @@ async fn home_assistant_auth( } fn request_token(request: &Request) -> Option { - request.headers().get(header::AUTHORIZATION) + request + .headers() + .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")) - .or_else(|| request.headers().get("x-api-token").and_then(|value| value.to_str().ok())) + .or_else(|| { + request + .headers() + .get("x-api-token") + .and_then(|value| value.to_str().ok()) + }) .map(str::to_owned) } @@ -61,4 +79,3 @@ fn generate_access_token() -> String { rng.fill_bytes(&mut bytes); format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes)) } - diff --git a/src/api/automations.rs b/src/api/automations.rs index 766f499..e2975ff 100644 --- a/src/api/automations.rs +++ b/src/api/automations.rs @@ -21,38 +21,75 @@ struct AutomationInput { #[serde(default = "automation_cooldown")] cooldown_seconds: u64, } -fn automation_cooldown() -> u64 { 300 } +fn automation_cooldown() -> u64 { + 300 +} impl AutomationInput { fn validate(&self) -> Result<(), AppError> { - if self.name.trim().is_empty() { return Err(AppError::BadRequest("automation name is required".into())); } + if self.name.trim().is_empty() { + return Err(AppError::BadRequest("automation name is required".into())); + } match self.trigger_kind.as_str() { "temperature_above" | "temperature_below" => { - if self.trigger_device_id.as_deref().unwrap_or_default().is_empty() || self.threshold.is_none() { - return Err(AppError::BadRequest("temperature trigger needs device and threshold".into())); + if self + .trigger_device_id + .as_deref() + .unwrap_or_default() + .is_empty() + || self.threshold.is_none() + { + return Err(AppError::BadRequest( + "temperature trigger needs device and threshold".into(), + )); } } "time" => { - let at = self.at_time.as_deref().ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?; - chrono::NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("invalid automation time".into()))?; + let at = self + .at_time + .as_deref() + .ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?; + chrono::NaiveTime::parse_from_str(at, "%H:%M") + .map_err(|_| AppError::BadRequest("invalid automation time".into()))?; + } + _ => { + return Err(AppError::BadRequest( + "unsupported automation trigger".into(), + )) } - _ => return Err(AppError::BadRequest("unsupported automation trigger".into())), } - let action_group_id = self.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()); + let action_group_id = self + .action_group_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); if action_group_id.is_none() && self.action_device_id.trim().is_empty() { - return Err(AppError::BadRequest("automation action needs a device or group".into())); + return Err(AppError::BadRequest( + "automation action needs a device or group".into(), + )); } - if let Some(preset) = self.action_preset.as_deref().map(str::trim).filter(|value| !value.is_empty()) { + if let Some(preset) = self + .action_preset + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { if action_group_id.is_none() { - return Err(AppError::BadRequest("automation preset actions require a group target".into())); + return Err(AppError::BadRequest( + "automation preset actions require a group target".into(), + )); } if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { - return Err(AppError::BadRequest("unsupported group automation preset".into())); + return Err(AppError::BadRequest( + "unsupported group automation preset".into(), + )); } } if action_group_id.is_some() { if let Some(mode) = self.action.mode.as_deref() { if !matches!(mode, "auto" | "house" | "cool" | "heat") { - return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into())); + return Err(AppError::BadRequest( + "group automation mode must be house, cool or heat".into(), + )); } } if self.action.target_temperature.is_some() @@ -67,48 +104,121 @@ impl AutomationInput { || self.action.health.is_some() || self.action.sleep.is_some() { - return Err(AppError::BadRequest("group automations support only power, heat/cool/house mode and a group preset".into())); + return Err(AppError::BadRequest( + "group automations support only power, heat/cool/house mode and a group preset" + .into(), + )); } - if self.action.power.is_none() && self.action.mode.is_none() && self.action_preset.as_deref().map(str::trim).filter(|v| !v.is_empty()).is_none() { - return Err(AppError::BadRequest("group automation action cannot be empty".into())); + if self.action.power.is_none() + && self.action.mode.is_none() + && self + .action_preset + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .is_none() + { + return Err(AppError::BadRequest( + "group automation action cannot be empty".into(), + )); } } else { engine::validate_command(&self.action)?; if self.action.is_empty() { - return Err(AppError::BadRequest("automation action cannot be empty".into())); + return Err(AppError::BadRequest( + "automation action cannot be empty".into(), + )); } } Ok(()) } - fn into_automation(self, id: String, created_at: chrono::DateTime, last_fired_at: Option>) -> Automation { - Automation { id, name: self.name.trim().into(), enabled: self.enabled, - trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), - threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(), - action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), - action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()), - action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at, - action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(), - created_at, updated_at: Utc::now() } - } -} -fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Result<(), AppError> { - if matches!(input.trigger_kind.as_str(), "temperature_above" | "temperature_below") { - let trigger_id = input.trigger_device_id.as_deref().map(str::trim).unwrap_or_default(); - if state.db.get_device(trigger_id)?.is_none() { - return Err(AppError::BadRequest("automation trigger device does not exist".into())); + fn into_automation( + self, + id: String, + created_at: chrono::DateTime, + last_fired_at: Option>, + ) -> Automation { + Automation { + id, + name: self.name.trim().into(), + enabled: self.enabled, + trigger_kind: self.trigger_kind, + trigger_device_id: self + .trigger_device_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + threshold: self.threshold, + at_time: self.at_time, + action_device_id: self.action_device_id.trim().to_string(), + action_group_id: self + .action_group_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + action_preset: self + .action_preset + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + action: self.action, + cooldown_seconds: self.cooldown_seconds.max(30), + last_fired_at, + action_zone_id: None, + action_zone_preset: None, + action_ha_domain: None, + action_ha_service: None, + action_ha_entity_id: None, + action_ha_data: Value::Null, + flow_conditions: vec![], + flow_id: None, + flow_node_id: None, + flow_runtime: Default::default(), + created_at, + updated_at: Utc::now(), } } - if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) { +} +fn validate_automation_references( + state: &AppState, + input: &AutomationInput, +) -> Result<(), AppError> { + if matches!( + input.trigger_kind.as_str(), + "temperature_above" | "temperature_below" + ) { + let trigger_id = input + .trigger_device_id + .as_deref() + .map(str::trim) + .unwrap_or_default(); + if state.db.get_device(trigger_id)?.is_none() { + return Err(AppError::BadRequest( + "automation trigger device does not exist".into(), + )); + } + } + if let Some(group_id) = input + .action_group_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { if state.db.get_group(group_id)?.is_none() { - return Err(AppError::BadRequest("automation action group does not exist".into())); + return Err(AppError::BadRequest( + "automation action group does not exist".into(), + )); } } else { let device_id = input.action_device_id.trim(); if state.db.get_device(device_id)?.is_none() { - return Err(AppError::BadRequest("automation action device does not exist".into())); + return Err(AppError::BadRequest( + "automation action device does not exist".into(), + )); } if engine::automation_action_conflicts_with_thermostat(&input.action) - && state.db.list_zones()?.iter().any(|zone| zone.enabled && zone.device_id == device_id) + && state + .db + .list_zones()? + .iter() + .any(|zone| zone.enabled && zone.device_id == device_id) { return Err(AppError::BadRequest( "direct fan/quiet/sleep automation conflicts with an enabled thermostat zone; use thermostat/group policy instead".into(), @@ -118,47 +228,97 @@ fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Ok(()) } -async fn list_automations(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_automations()?)) } -async fn get_automation(State(state): State, Path(id): Path) -> Result, AppError> { - state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}"))) +async fn list_automations( + State(state): State, +) -> Result>, AppError> { + Ok(Json(state.db.list_automations()?)) } -async fn create_automation(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { +async fn get_automation( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + state + .db + .get_automation(&id)? + .map(Json) + .ok_or_else(|| AppError::NotFound(format!("automation {id}"))) +} +async fn create_automation( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; input.validate()?; - let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string); + let action_group_id = input + .action_group_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); let _group_guard = if let Some(group_id) = action_group_id.as_deref() { Some(state.lock_group_operation(group_id).await) - } else { None }; + } else { + None + }; validate_automation_references(&state, &input)?; let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None); state.db.save_automation(&item)?; state.broadcast("automation.created", serde_json::to_value(&item)?); Ok((StatusCode::CREATED, Json(item))) } -async fn update_automation(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { +async fn update_automation( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; input.validate()?; - let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; - if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; edit it in the Flow editor".into())); } - let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string); + let existing = state + .db + .get_automation(&id)? + .ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; + if existing.flow_id.is_some() { + return Err(AppError::BadRequest( + "this automation is generated by Flow; edit it in the Flow editor".into(), + )); + } + let action_group_id = input + .action_group_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); let _group_guard = if let Some(group_id) = action_group_id.as_deref() { Some(state.lock_group_operation(group_id).await) - } else { None }; + } else { + None + }; validate_automation_references(&state, &input)?; let item = input.into_automation(id, existing.created_at, existing.last_fired_at); state.db.save_automation(&item)?; state.broadcast("automation.updated", serde_json::to_value(&item)?); Ok(Json(item)) } -async fn delete_automation(State(state): State, Path(id): Path) -> Result { +async fn delete_automation( + State(state): State, + Path(id): Path, +) -> Result { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; - let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; - if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; delete it from the Flow editor".into())); } - if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); } + let existing = state + .db + .get_automation(&id)? + .ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; + if existing.flow_id.is_some() { + return Err(AppError::BadRequest( + "this automation is generated by Flow; delete it from the Flow editor".into(), + )); + } + if !state.db.delete_automation(&id)? { + return Err(AppError::NotFound(format!("automation {id}"))); + } state.broadcast("automation.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } - diff --git a/src/api/configuration.rs b/src/api/configuration.rs index f71318e..7bfd1c1 100644 --- a/src/api/configuration.rs +++ b/src/api/configuration.rs @@ -11,7 +11,9 @@ struct ConfigurationResourceGuards { _devices: Vec>, } -async fn export_configuration(State(state): State) -> Result, AppError> { +async fn export_configuration( + State(state): State, +) -> Result, AppError> { let settings = state.settings.read().await.clone(); let mut export = state.db.export_configuration(settings)?; sanitize_configuration_runtime(&mut export); @@ -23,21 +25,36 @@ fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), App return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.12.x".into())); } if export.settings.control_strategy != "setpoint" { - return Err(AppError::BadRequest("import contains an unsupported control strategy".into())); + return Err(AppError::BadRequest( + "import contains an unsupported control strategy".into(), + )); } - influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; + influxdb::validate(&export.settings.influxdb) + .map_err(|err| AppError::BadRequest(err.to_string()))?; if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") { - return Err(AppError::BadRequest("import contains an invalid house mode".into())); + return Err(AppError::BadRequest( + "import contains an invalid house mode".into(), + )); } Ok(()) } -fn collect_configuration_ids(export: &ConfigurationExport) -> Result, AppError> { +fn collect_configuration_ids( + export: &ConfigurationExport, +) -> Result, AppError> { let ids = ConfigurationIds { devices: export.devices.iter().map(|item| item.id.as_str()).collect(), zones: export.zones.iter().map(|item| item.id.as_str()).collect(), - schedules: export.schedules.iter().map(|item| item.id.as_str()).collect(), - automations: export.automations.iter().map(|item| item.id.as_str()).collect(), + schedules: export + .schedules + .iter() + .map(|item| item.id.as_str()) + .collect(), + automations: export + .automations + .iter() + .map(|item| item.id.as_str()) + .collect(), flows: export.flows.iter().map(|item| item.id.as_str()).collect(), }; let duplicate_or_empty = ids.devices.len() != export.devices.len() @@ -51,22 +68,38 @@ fn collect_configuration_ids(export: &ConfigurationExport) -> Result Result<(), AppError> { - let draft_flows: std::collections::HashSet<&str> = export.flows.iter() + let draft_flows: std::collections::HashSet<&str> = export + .flows + .iter() .filter(|item| item.draft) .map(|item| item.id.as_str()) .collect(); - let executable_draft = export.flows.iter().any(|item| item.draft - && (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty())) - || export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id))) - || export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id))); + let executable_draft = export.flows.iter().any(|item| { + item.draft + && (item.enabled + || !item.compiled_schedule_ids.is_empty() + || !item.compiled_automation_ids.is_empty()) + }) || export.schedules.iter().any(|item| { + item.flow_id + .as_deref() + .is_some_and(|id| draft_flows.contains(id)) + }) || export.automations.iter().any(|item| { + item.flow_id + .as_deref() + .is_some_and(|id| draft_flows.contains(id)) + }); if executable_draft { - return Err(AppError::BadRequest("import contains an executable Flow draft".into())); + return Err(AppError::BadRequest( + "import contains an executable Flow draft".into(), + )); } Ok(()) } @@ -75,23 +108,44 @@ fn validate_configuration_devices_and_zones( export: &ConfigurationExport, ids: &ConfigurationIds<'_>, ) -> Result<(), AppError> { - let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect(); + let device_macs: std::collections::HashSet<&str> = export + .devices + .iter() + .map(|item| item.mac.as_str()) + .collect(); if device_macs.len() != export.devices.len() { - return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into())); + return Err(AppError::BadRequest( + "import contains duplicate device MAC addresses".into(), + )); } - if export.zones.iter().any(|item| !ids.devices.contains(item.device_id.as_str())) { - return Err(AppError::BadRequest("import contains a zone referencing a missing device".into())); + if export + .zones + .iter() + .any(|item| !ids.devices.contains(item.device_id.as_str())) + { + return Err(AppError::BadRequest( + "import contains a zone referencing a missing device".into(), + )); } let mut zone_devices = std::collections::HashSet::new(); for zone in &export.zones { if !zone_devices.insert(zone.device_id.as_str()) { - return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into())); + return Err(AppError::BadRequest( + "import assigns one device to more than one thermostat zone".into(), + )); } if !matches!(zone.mode.as_str(), "cool" | "heat") { - return Err(AppError::BadRequest("import contains an invalid zone mode".into())); + return Err(AppError::BadRequest( + "import contains an invalid zone mode".into(), + )); } - if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") { - return Err(AppError::BadRequest("import contains an invalid zone sensor source".into())); + if !matches!( + zone.sensor_source.as_str(), + "device" | "home_assistant" | "combined" + ) { + return Err(AppError::BadRequest( + "import contains an invalid zone sensor source".into(), + )); } } Ok(()) @@ -101,25 +155,48 @@ fn validate_configuration_schedules( export: &ConfigurationExport, ids: &ConfigurationIds<'_>, ) -> Result<(), AppError> { - if export.schedules.iter().any(|item| !ids.zones.contains(item.zone_id.as_str())) { - return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into())); + if export + .schedules + .iter() + .any(|item| !ids.zones.contains(item.zone_id.as_str())) + { + return Err(AppError::BadRequest( + "import contains a schedule referencing a missing zone".into(), + )); } for item in &export.schedules { - if item.flow_id.as_deref().is_some_and(|flow_id| !ids.flows.contains(flow_id)) { - return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into())); + if item + .flow_id + .as_deref() + .is_some_and(|flow_id| !ids.flows.contains(flow_id)) + { + return Err(AppError::BadRequest( + "import contains a Flow-generated schedule referencing a missing Flow".into(), + )); } if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) { - return Err(AppError::BadRequest("import contains invalid schedule weekdays".into())); + return Err(AppError::BadRequest( + "import contains invalid schedule weekdays".into(), + )); } - NaiveTime::parse_from_str(&item.start_time, "%H:%M") - .map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?; - NaiveTime::parse_from_str(&item.end_time, "%H:%M") - .map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?; - if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { - return Err(AppError::BadRequest("import contains an invalid schedule preset".into())); + NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| { + AppError::BadRequest("import contains an invalid schedule start time".into()) + })?; + NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| { + AppError::BadRequest("import contains an invalid schedule end time".into()) + })?; + if !matches!( + item.preset.as_str(), + "comfort" | "sleep" | "away" | "custom" + ) { + return Err(AppError::BadRequest( + "import contains an invalid schedule preset".into(), + )); } if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) { - return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into())); + return Err(AppError::BadRequest( + "import contains an invalid schedule setpoint".into(), + )); } } validate_schedule_set(&export.schedules)?; @@ -131,17 +208,27 @@ fn validate_configuration_groups<'a>( ids: &ConfigurationIds<'_>, ) -> Result, AppError> { if export.groups.iter().any(|group| { - let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect(); + let members: std::collections::HashSet<&str> = + group.zone_ids.iter().map(String::as_str).collect(); group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len() - || group.zone_ids.iter().any(|zone_id| !ids.zones.contains(zone_id.as_str())) + || group + .zone_ids + .iter() + .any(|zone_id| !ids.zones.contains(zone_id.as_str())) }) { - return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into())); + return Err(AppError::BadRequest( + "import contains an invalid group, duplicate members or a missing zone reference" + .into(), + )); } - let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect(); + let groups: std::collections::HashSet<&str> = + export.groups.iter().map(|item| item.id.as_str()).collect(); if groups.len() != export.groups.len() { - return Err(AppError::BadRequest("import contains duplicate group IDs".into())); + return Err(AppError::BadRequest( + "import contains duplicate group IDs".into(), + )); } Ok(groups) } @@ -153,42 +240,78 @@ fn validate_configuration_automation_trigger( match item.trigger_kind.as_str() { "temperature_above" | "temperature_below" => { let Some(trigger_id) = item.trigger_device_id.as_deref() else { - return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into())); + return Err(AppError::BadRequest( + "import contains a temperature automation without a trigger device".into(), + )); }; if !ids.devices.contains(trigger_id) || item.threshold.is_none() { - return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into())); + return Err(AppError::BadRequest( + "import contains an invalid temperature automation trigger".into(), + )); } } "time" => { - let at = item.at_time.as_deref() - .ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?; - NaiveTime::parse_from_str(at, "%H:%M") - .map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?; + let at = item.at_time.as_deref().ok_or_else(|| { + AppError::BadRequest("import contains a time automation without at_time".into()) + })?; + NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| { + AppError::BadRequest("import contains an invalid automation time".into()) + })?; } "flow" => { - if item.flow_id.as_deref().filter(|id| ids.flows.contains(*id)).is_none() || item.flow_conditions.is_empty() { - return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into())); + if item + .flow_id + .as_deref() + .filter(|id| ids.flows.contains(*id)) + .is_none() + || item.flow_conditions.is_empty() + { + return Err(AppError::BadRequest( + "import contains an invalid Flow-generated automation".into(), + )); } } - _ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())), + _ => { + return Err(AppError::BadRequest( + "import contains an unsupported automation trigger".into(), + )) + } } Ok(()) } -fn validate_configuration_zone_automation(item: &Automation, ids: &ConfigurationIds<'_>) -> Result<(), AppError> { - let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); }; +fn validate_configuration_zone_automation( + item: &Automation, + ids: &ConfigurationIds<'_>, +) -> Result<(), AppError> { + let Some(zone_id) = item + .action_zone_id + .as_deref() + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; if !ids.zones.contains(zone_id) { - return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into())); + return Err(AppError::BadRequest( + "import contains a Flow automation referencing a missing zone".into(), + )); } if let Some(preset) = item.action_zone_preset.as_deref() { if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") { - return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into())); + return Err(AppError::BadRequest( + "import contains an invalid Flow thermostat preset".into(), + )); } } if item.action_zone_preset.as_deref() == Some("custom") - && item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value)) + && item + .action + .target_temperature + .is_some_and(|value| !(8.0..=30.0).contains(&value)) { - return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into())); + return Err(AppError::BadRequest( + "import contains an invalid Flow thermostat target".into(), + )); } Ok(()) } @@ -197,30 +320,51 @@ fn validate_configuration_group_automation( item: &Automation, groups: &std::collections::HashSet<&str>, ) -> Result<(), AppError> { - let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); }; + let Some(group_id) = item + .action_group_id + .as_deref() + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; if !groups.contains(group_id) { - return Err(AppError::BadRequest("import contains an automation referencing a missing group".into())); + return Err(AppError::BadRequest( + "import contains an automation referencing a missing group".into(), + )); } if let Some(mode) = item.action.mode.as_deref() { if !matches!(mode, "auto" | "house" | "cool" | "heat") { - return Err(AppError::BadRequest("import contains an invalid group automation mode".into())); + return Err(AppError::BadRequest( + "import contains an invalid group automation mode".into(), + )); } } - let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom"); + let flow_custom_group = + item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom"); if let Some(preset) = item.action_preset.as_deref() { - if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") { - return Err(AppError::BadRequest("import contains an invalid group automation preset".into())); + if !matches!(preset, "auto" | "comfort" | "sleep" | "away") + && !(flow_custom_group && preset == "custom") + { + return Err(AppError::BadRequest( + "import contains an invalid group automation preset".into(), + )); } } if flow_custom_group { let Some(target) = item.action.target_temperature else { - return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into())); + return Err(AppError::BadRequest( + "import contains a Flow custom group preset without a target".into(), + )); }; if !(8.0..=30.0).contains(&target) { - return Err(AppError::BadRequest("import contains an invalid Flow group target".into())); + return Err(AppError::BadRequest( + "import contains an invalid Flow group target".into(), + )); } } else if item.action.target_temperature.is_some() { - return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into())); + return Err(AppError::BadRequest( + "import contains unsupported target temperature in a group automation".into(), + )); } if item.action.fan_speed.is_some() || item.action.swing_vertical.is_some() @@ -233,13 +377,21 @@ fn validate_configuration_group_automation( || item.action.health.is_some() || item.action.sleep.is_some() { - return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into())); + return Err(AppError::BadRequest( + "import contains unsupported device fields in a group automation".into(), + )); } if item.action.power.is_none() && item.action.mode.is_none() - && item.action_preset.as_deref().filter(|value| !value.is_empty()).is_none() + && item + .action_preset + .as_deref() + .filter(|value| !value.is_empty()) + .is_none() { - return Err(AppError::BadRequest("import contains an empty group automation action".into())); + return Err(AppError::BadRequest( + "import contains an empty group automation action".into(), + )); } Ok(()) } @@ -250,14 +402,18 @@ fn validate_configuration_shared_inputs( groups: &std::collections::HashSet<&str>, ) -> Result<(), AppError> { for item in &export.settings.home_assistant.flow_inputs { - let Some(reference) = shared_input_resource_reference(&item.kind, &item.config)? else { continue; }; + let Some(reference) = shared_input_resource_reference(&item.kind, &item.config)? else { + continue; + }; let exists = match reference { SharedInputResourceReference::Device(id) => ids.devices.contains(id.as_str()), SharedInputResourceReference::Zone(id) => ids.zones.contains(id.as_str()), SharedInputResourceReference::Group(id) => groups.contains(id.as_str()), }; if !exists { - return Err(AppError::BadRequest("import contains a shared Flow input referencing a missing resource".into())); + return Err(AppError::BadRequest( + "import contains a shared Flow input referencing a missing resource".into(), + )); } } Ok(()) @@ -270,17 +426,29 @@ fn validate_configuration_automations( ) -> Result<(), AppError> { for item in &export.automations { validate_configuration_automation_trigger(item, ids)?; - if item.action_zone_id.as_deref().is_some_and(|value| !value.is_empty()) { + if item + .action_zone_id + .as_deref() + .is_some_and(|value| !value.is_empty()) + { validate_configuration_zone_automation(item, ids)?; - } else if item.action_group_id.as_deref().is_some_and(|value| !value.is_empty()) { + } else if item + .action_group_id + .as_deref() + .is_some_and(|value| !value.is_empty()) + { validate_configuration_group_automation(item, groups)?; } else { if !ids.devices.contains(item.action_device_id.as_str()) { - return Err(AppError::BadRequest("import contains an automation referencing a missing device".into())); + return Err(AppError::BadRequest( + "import contains an automation referencing a missing device".into(), + )); } engine::validate_command(&item.action)?; if item.action.is_empty() { - return Err(AppError::BadRequest("import contains an empty automation action".into())); + return Err(AppError::BadRequest( + "import contains an empty automation action".into(), + )); } } } @@ -366,8 +534,12 @@ fn sanitize_imported_zone(zone: &mut Zone, now: chrono::DateTime) { fn sanitize_configuration_runtime(export: &mut ConfigurationExport) { let now = Utc::now(); - for device in &mut export.devices { sanitize_imported_device(device, now.clone()); } - for zone in &mut export.zones { sanitize_imported_zone(zone, now.clone()); } + for device in &mut export.devices { + sanitize_imported_device(device, now.clone()); + } + for zone in &mut export.zones { + sanitize_imported_zone(zone, now.clone()); + } for automation in &mut export.automations { automation.last_fired_at = None; automation.updated_at = now.clone(); @@ -387,25 +559,32 @@ fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result settings.history_retention_days = settings.history_retention_days.clamp(1, 3650); settings.event_log_retention_days = settings.event_log_retention_days.clamp(1, 3650); - settings.influxdb.history_threshold_days = settings.influxdb.history_threshold_days.clamp(1, 3650); + settings.influxdb.history_threshold_days = + settings.influxdb.history_threshold_days.clamp(1, 3650); influxdb::validate(&settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; let current_notifications = settings.notifications.clone(); - settings.notifications = apply_notification_update(¤t_notifications, NotificationSettingsUpdate { - enabled: current_notifications.enabled, - mode: current_notifications.mode.clone(), - provider: current_notifications.provider.clone(), - pushover_app_token: Some(current_notifications.pushover_app_token.clone()), - pushover_user_key: Some(current_notifications.pushover_user_key.clone()), - slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()), - discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()), - cooldown_seconds: current_notifications.cooldown_seconds, - communication_failure_threshold: current_notifications.communication_failure_threshold, - target_timeout_minutes: current_notifications.target_timeout_minutes, - alert_types: current_notifications.alert_types.clone(), - })?; + settings.notifications = apply_notification_update( + ¤t_notifications, + NotificationSettingsUpdate { + enabled: current_notifications.enabled, + mode: current_notifications.mode.clone(), + provider: current_notifications.provider.clone(), + pushover_app_token: Some(current_notifications.pushover_app_token.clone()), + pushover_user_key: Some(current_notifications.pushover_user_key.clone()), + slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()), + discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()), + cooldown_seconds: current_notifications.cooldown_seconds, + communication_failure_threshold: current_notifications.communication_failure_threshold, + target_timeout_minutes: current_notifications.target_timeout_minutes, + alert_types: current_notifications.alert_types.clone(), + }, + )?; - settings.home_assistant.sensor_stale_after_seconds = settings.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400); + settings.home_assistant.sensor_stale_after_seconds = settings + .home_assistant + .sensor_stale_after_seconds + .clamp(30, 86_400); normalize_sensor_aliases(&mut settings.home_assistant); normalize_flow_shared_inputs(&mut settings.home_assistant)?; canonicalize_home_assistant_entities(&mut settings.home_assistant); @@ -428,23 +607,34 @@ async fn lock_configuration_resources( current_devices: &[Device], export: &ConfigurationExport, ) -> ConfigurationResourceGuards { - let mut zone_ids: Vec = current_zones.iter().map(|zone| zone.id.clone()) + let mut zone_ids: Vec = current_zones + .iter() + .map(|zone| zone.id.clone()) .chain(export.zones.iter().map(|zone| zone.id.clone())) .collect(); zone_ids.sort(); zone_ids.dedup(); let mut zone_guards = Vec::with_capacity(zone_ids.len()); - for zone_id in &zone_ids { zone_guards.push(state.lock_zone_operation(zone_id).await); } + for zone_id in &zone_ids { + zone_guards.push(state.lock_zone_operation(zone_id).await); + } - let mut device_ids: Vec = current_devices.iter().map(|device| device.id.clone()) + let mut device_ids: Vec = current_devices + .iter() + .map(|device| device.id.clone()) .chain(export.devices.iter().map(|device| device.id.clone())) .collect(); device_ids.sort(); device_ids.dedup(); let mut device_guards = Vec::with_capacity(device_ids.len()); - for device_id in &device_ids { device_guards.push(state.lock_device_operation(device_id).await); } + for device_id in &device_ids { + device_guards.push(state.lock_device_operation(device_id).await); + } - ConfigurationResourceGuards { _zones: zone_guards, _devices: device_guards } + ConfigurationResourceGuards { + _zones: zone_guards, + _devices: device_guards, + } } async fn power_off_detached_devices( @@ -452,37 +642,71 @@ async fn power_off_detached_devices( current_zones: &[Zone], export: &ConfigurationExport, ) -> Result<(), AppError> { - let imported_zone_map: std::collections::HashMap = export.zones.iter() + let imported_zone_map: std::collections::HashMap = export + .zones + .iter() .map(|zone| (zone.id.clone(), zone.device_id.clone())) .collect(); - let detach_devices: std::collections::HashSet = current_zones.iter() - .filter(|current| imported_zone_map.get(¤t.id).map(String::as_str) != Some(current.device_id.as_str())) + let detach_devices: std::collections::HashSet = current_zones + .iter() + .filter(|current| { + imported_zone_map.get(¤t.id).map(String::as_str) + != Some(current.device_id.as_str()) + }) .map(|current| current.device_id.clone()) .collect(); for device_id in detach_devices { - let Some(device) = state.db.get_device(&device_id)? else { continue; }; + let Some(device) = state.db.get_device(&device_id)? else { + continue; + }; if !device.enabled { return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into())); } engine::force_power_off_device_locked(state, &device_id).await?; - state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({ - "device_id": device.id, "source": "configuration.import" - })); + state.log( + "info", + "zone.detach_power_off", + &format!( + "Powered off {} before detaching thermostat ownership", + device.name + ), + json!({ + "device_id": device.id, "source": "configuration.import" + }), + ); } Ok(()) } -async fn reconcile_imported_devices(state: &AppState, export: &ConfigurationExport) -> Result<(), AppError> { - let controllable_devices: std::collections::HashSet = export.zones.iter() +async fn reconcile_imported_devices( + state: &AppState, + export: &ConfigurationExport, +) -> Result<(), AppError> { + let controllable_devices: std::collections::HashSet = export + .zones + .iter() .filter(|zone| { - let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() }; + let effective_mode = if zone.inherit_house_mode { + export.settings.house_mode.as_str() + } else { + zone.mode.as_str() + }; zone.enabled && effective_mode != "off" }) .map(|zone| zone.device_id.clone()) .collect(); - for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) { + for device in export + .devices + .iter() + .filter(|device| device.enabled && !controllable_devices.contains(&device.id)) + { if let Err(err) = engine::force_power_off_device_locked(state, &device.id).await { - state.log("error", "configuration.import_reconcile_error", &err.to_string(), json!({"device_id": device.id})); + state.log( + "error", + "configuration.import_reconcile_error", + &err.to_string(), + json!({"device_id": device.id}), + ); return Err(err); } } @@ -504,18 +728,30 @@ async fn import_configuration( let _cycle_guard = state.lock_zone_control_cycle().await; let current_zones = state.db.list_zones()?; let current_devices = state.db.list_devices()?; - let _resource_guards = lock_configuration_resources(&state, ¤t_zones, ¤t_devices, &export).await; + let _resource_guards = + lock_configuration_resources(&state, ¤t_zones, ¤t_devices, &export).await; power_off_detached_devices(&state, ¤t_zones, &export).await?; sanitize_configuration_runtime(&mut export); - state.initial_device_sync_complete.store(false, Ordering::Release); + state + .initial_device_sync_complete + .store(false, Ordering::Release); state.db.replace_configuration(&export)?; - state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed); + state + .debug_gree_frames + .store(export.settings.debug.gree_frames, Ordering::Relaxed); *state.settings.write().await = export.settings.clone(); reconcile_imported_devices(&state, &export).await?; - state.initial_device_sync_complete.store(true, Ordering::Release); + state + .initial_device_sync_complete + .store(true, Ordering::Release); state.wake_zone_control(); - state.log("info", "configuration.imported", "Application configuration imported", json!({"format_version": export.format_version})); + state.log( + "info", + "configuration.imported", + "Application configuration imported", + json!({"format_version": export.format_version}), + ); state.broadcast("configuration.imported", json!({"at": Utc::now()})); Ok(Json(json!({"ok": true}))) } diff --git a/src/api/debug_tokens.rs b/src/api/debug_tokens.rs index bc1ab52..5fe73ed 100644 --- a/src/api/debug_tokens.rs +++ b/src/api/debug_tokens.rs @@ -3,7 +3,9 @@ struct CreateAccessTokenRequest { name: Option, } -async fn list_access_tokens(State(state): State) -> Result>, AppError> { +async fn list_access_tokens( + State(state): State, +) -> Result>, AppError> { Ok(Json(state.db.list_api_tokens()?)) } @@ -11,9 +13,15 @@ async fn create_access_token( State(state): State, Json(input): Json, ) -> Result<(StatusCode, Json), AppError> { - let name = input.name.unwrap_or_else(|| "Home Assistant".into()).trim().to_string(); + let name = input + .name + .unwrap_or_else(|| "Home Assistant".into()) + .trim() + .to_string(); if name.is_empty() || name.len() > 80 { - return Err(AppError::BadRequest("token name must contain 1 to 80 characters".into())); + return Err(AppError::BadRequest( + "token name must contain 1 to 80 characters".into(), + )); } let secret = generate_access_token(); @@ -30,10 +38,16 @@ async fn create_access_token( "Created a Home Assistant access token", json!({"token_id": item.id.clone(), "name": item.name.clone()}), ); - Ok((StatusCode::CREATED, Json(json!({"token": secret, "item": item})))) + Ok(( + StatusCode::CREATED, + Json(json!({"token": secret, "item": item})), + )) } -async fn delete_access_token(State(state): State, Path(id): Path) -> Result { +async fn delete_access_token( + State(state): State, + Path(id): Path, +) -> Result { if !state.db.delete_api_token(&id)? { return Err(AppError::NotFound(format!("access token {id}"))); } @@ -45,4 +59,3 @@ async fn delete_access_token(State(state): State, Path(id): Path, Json(request): Json) -> Result, AppError> { +async fn discover( + State(state): State, + Json(request): Json, +) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let settings = state.settings.read().await.clone(); - let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000); + let timeout_ms = request + .timeout_ms + .unwrap_or(settings.discovery_timeout_ms) + .clamp(500, 30_000); let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast); let protocol_version = request.protocol_version.unwrap_or(0).min(2); let passes = request.passes.unwrap_or(3).clamp(1, 10); - let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms), protocol_version, passes).await + let discovered = state + .gree + .discover( + &broadcast, + Duration::from_millis(timeout_ms), + protocol_version, + passes, + ) + .await .map_err(|e| AppError::Device(e.to_string()))?; let mut saved = Vec::new(); let mut new_device_ids = Vec::new(); @@ -33,31 +47,48 @@ async fn discover(State(state): State, Json(request): Json { merged.last_error = Some(format!("discovered, bind pending: {err}")); - state.log("warn", "device.bind_after_discovery", &format!("{}: {err}", merged.name), json!({"device_id": merged.id})); + state.log( + "warn", + "device.bind_after_discovery", + &format!("{}: {err}", merged.name), + json!({"device_id": merged.id}), + ); } } } state.db.save_device(&merged)?; - if is_new { new_device_ids.push(merged.id.clone()); } + if is_new { + new_device_ids.push(merged.id.clone()); + } saved.push(merged); } state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()})); state.broadcast("devices.discovered", json!({"devices": saved})); - Ok(Json(json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}))) + Ok(Json( + json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}), + )) } async fn list_devices(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_devices()?)) } -async fn add_device(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { +async fn add_device( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AppError> { let _configuration_guard = state.lock_configuration_operation().await; if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() { return Err(AppError::BadRequest("name, mac and ip are required".into())); } - input.ip.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; + input + .ip + .parse::() + .map_err(|_| AppError::BadRequest("invalid IP address".into()))?; if state.db.get_device_by_mac(&input.mac)?.is_some() { - return Err(AppError::BadRequest("a device with this MAC already exists".into())); + return Err(AppError::BadRequest( + "a device with this MAC already exists".into(), + )); } let now = Utc::now(); let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase(); @@ -106,25 +137,54 @@ async fn add_device(State(state): State, Json(input): Json, Path(id): Path) -> Result, AppError> { - state.db.get_device(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device {id}"))) +async fn get_device( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + state + .db + .get_device(&id)? + .map(Json) + .ok_or_else(|| AppError::NotFound(format!("device {id}"))) } -async fn patch_device(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { +async fn patch_device( + State(state): State, + Path(id): Path, + Json(patch): Json, +) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; if patch.enabled == Some(false) { engine::disable_device_safely(&state, &id).await?; } let _device_guard = state.lock_device_operation(&id).await; - let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; - if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } } - if let Some(v) = patch.ip { v.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; } - if let Some(v) = patch.port { device.port = v; } + let mut device = state + .db + .get_device(&id)? + .ok_or_else(|| AppError::NotFound(format!("device {id}")))?; + if let Some(v) = patch.name { + if !v.trim().is_empty() { + device.name = v.trim().to_string(); + } + } + if let Some(v) = patch.ip { + v.parse::() + .map_err(|_| AppError::BadRequest("invalid IP address".into()))?; + device.ip = v; + } + if let Some(v) = patch.port { + device.port = v; + } if let Some(v) = patch.protocol_version { let v = v.min(2); if device.protocol_version != v { @@ -139,8 +199,12 @@ async fn patch_device(State(state): State, Path(id): Path, Jso device.supports_sleep = None; } } - if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); } - if let Some(v) = patch.enabled { device.enabled = v; } + if let Some(v) = patch.key { + device.key = v.filter(|x| !x.trim().is_empty()); + } + if let Some(v) = patch.enabled { + device.enabled = v; + } device.updated_at = Utc::now(); state.db.save_device(&device)?; state.broadcast("device.updated", serde_json::to_value(&device)?); @@ -151,7 +215,10 @@ async fn patch_device(State(state): State, Path(id): Path, Jso Ok(Json(device)) } -async fn delete_device(State(state): State, Path(id): Path) -> Result { +async fn delete_device( + State(state): State, + Path(id): Path, +) -> Result { let _configuration_guard = state.lock_configuration_operation().await; // Keep reference validation and the destructive DB operation in one serialized window. // Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> cycle -> zones -> device. @@ -159,14 +226,21 @@ async fn delete_device(State(state): State, Path(id): Path) -> let _house_guard = state.lock_house_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; - if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); } + if state.db.get_device(&id)?.is_none() { + return Err(AppError::NotFound(format!("device {id}"))); + } if state.db.list_automations()?.iter().any(|item| { item.trigger_device_id.as_deref() == Some(id.as_str()) || (item.action_group_id.is_none() && item.action_device_id == id) }) { - return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into())); + return Err(AppError::BadRequest( + "device is used by an automation; remove or retarget that automation first".into(), + )); } - let removed_zone_ids: std::collections::HashSet = state.db.list_zones()?.into_iter() + let removed_zone_ids: std::collections::HashSet = state + .db + .list_zones()? + .into_iter() .filter(|zone| zone.device_id == id) .map(|zone| zone.id) .collect(); @@ -178,20 +252,39 @@ async fn delete_device(State(state): State, Path(id): Path) -> } ensure_zone_removal_safe(&state, &removed_zone_ids)?; ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?; - if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } + if !state.db.delete_device(&id)? { + return Err(AppError::NotFound(format!("device {id}"))); + } drop(zone_guards); remove_zone_ids_from_groups_locked(&state, &removed_zone_ids).await?; - state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); + state.log( + "info", + "device.deleted", + "Device deleted", + json!({"device_id": id}), + ); state.broadcast("device.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } -async fn bind_device(State(state): State, Path(id): Path) -> Result, AppError> { +async fn bind_device( + State(state): State, + Path(id): Path, +) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _device_guard = state.lock_device_operation(&id).await; - let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; - if device.simulated { return Ok(Json(device)); } - let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?; + let mut device = state + .db + .get_device(&id)? + .ok_or_else(|| AppError::NotFound(format!("device {id}")))?; + if device.simulated { + return Ok(Json(device)); + } + let bound = state + .gree + .bind(&device) + .await + .map_err(|e| AppError::Device(e.to_string()))?; device.key = Some(bound.key); device.protocol_version = bound.protocol_version; device.communication_failures = 0; @@ -200,17 +293,35 @@ async fn bind_device(State(state): State, Path(id): Path) -> R device.last_error = None; device.updated_at = Utc::now(); state.db.save_device(&device)?; - state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": id})); + state.log( + "info", + "device.bound", + &format!("Bound {}", device.name), + json!({"device_id": id}), + ); Ok(Json(device)) } -async fn poll_device(State(state): State, Path(id): Path) -> Result, AppError> { +async fn poll_device( + State(state): State, + Path(id): Path, +) -> Result, AppError> { Ok(Json(engine::poll_one(&state, &id).await?)) } -async fn probe_device(State(state): State, Path(id): Path) -> Result, AppError> { - let device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; - let response_time_ms = state.gree.probe(&device).await.map_err(|err| AppError::Device(err.to_string()))?; +async fn probe_device( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let device = state + .db + .get_device(&id)? + .ok_or_else(|| AppError::NotFound(format!("device {id}")))?; + let response_time_ms = state + .gree + .probe(&device) + .await + .map_err(|err| AppError::Device(err.to_string()))?; Ok(Json(json!({ "device_id": device.id, "response_time_ms": response_time_ms, @@ -226,23 +337,36 @@ struct ManualDeviceCommandRequest { manual_override: bool, } -async fn command_device(State(state): State, Path(id): Path, Json(request): Json) -> Result, AppError> { - Ok(Json(engine::send_manual_command( - &state, - &id, - request.command, - "device.manual_control", - request.manual_override, - ).await?)) +async fn command_device( + State(state): State, + Path(id): Path, + Json(request): Json, +) -> Result, AppError> { + Ok(Json( + engine::send_manual_command( + &state, + &id, + request.command, + "device.manual_control", + request.manual_override, + ) + .await?, + )) } -async fn command_home_assistant_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { - Ok(Json(engine::send_manual_command( - &state, - &id, - command, - "home_assistant.device_manual_control", - false, - ).await?)) +async fn command_home_assistant_device( + State(state): State, + Path(id): Path, + Json(command): Json, +) -> Result, AppError> { + Ok(Json( + engine::send_manual_command( + &state, + &id, + command, + "home_assistant.device_manual_control", + false, + ) + .await?, + )) } - diff --git a/src/api/events.rs b/src/api/events.rs index e58d583..b873f39 100644 --- a/src/api/events.rs +++ b/src/api/events.rs @@ -1,5 +1,12 @@ #[derive(Debug, Deserialize)] -struct EventsQuery { limit: Option } -async fn events(State(state): State, Query(query): Query) -> Result, AppError> { - Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?}))) +struct EventsQuery { + limit: Option, +} +async fn events( + State(state): State, + Query(query): Query, +) -> Result, AppError> { + Ok(Json( + json!({"events": state.db.list_events(query.limit.unwrap_or(100))?}), + )) } diff --git a/src/api/flows.rs b/src/api/flows.rs index b56042f..cb2d7b4 100644 --- a/src/api/flows.rs +++ b/src/api/flows.rs @@ -29,14 +29,30 @@ struct FlowSimulationInput { } #[derive(Debug, Deserialize)] -struct FlowLogsQuery { limit: Option } +struct FlowLogsQuery { + limit: Option, +} fn flow_string(config: &Value, key: &str) -> Option { - config.get(key).and_then(Value::as_str).map(str::trim).filter(|v| !v.is_empty()).map(str::to_string) + config + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} +fn flow_f64(config: &Value, key: &str) -> Option { + config.get(key).and_then(Value::as_f64) +} +fn flow_u8(config: &Value, key: &str) -> Option { + config + .get(key) + .and_then(Value::as_u64) + .and_then(|value| u8::try_from(value).ok()) +} +fn flow_bool(config: &Value, key: &str) -> Option { + config.get(key).and_then(Value::as_bool) } -fn flow_f64(config: &Value, key: &str) -> Option { config.get(key).and_then(Value::as_f64) } -fn flow_u8(config: &Value, key: &str) -> Option { config.get(key).and_then(Value::as_u64).and_then(|value| u8::try_from(value).ok()) } -fn flow_bool(config: &Value, key: &str) -> Option { config.get(key).and_then(Value::as_bool) } fn generated_flow_name(flow_id: &str, action_node_id: &str) -> String { let digest = Sha256::digest(format!("{flow_id}:{action_node_id}").as_bytes()); @@ -44,20 +60,58 @@ fn generated_flow_name(flow_id: &str, action_node_id: &str) -> String { } fn flow_condition_kind(kind: &str) -> bool { - matches!(kind, - "weekday" | "time_range" | "date_range" | "cron_trigger" | "stable_for" | "delay" | "state_duration" | "on_change" | "rate_limit" | "rolling_stat" | "oscillates" | "outdoor_temperature" | "device_temperature" | - "zone_temperature" | "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "house_mode" | - "device_state" | "zone_state" | "group_state" | "night_mode" | "constant" | "shared_input" + matches!( + kind, + "weekday" + | "time_range" + | "date_range" + | "cron_trigger" + | "stable_for" + | "delay" + | "state_duration" + | "on_change" + | "rate_limit" + | "rolling_stat" + | "oscillates" + | "outdoor_temperature" + | "device_temperature" + | "zone_temperature" + | "ha_state" + | "ha_numeric" + | "ha_attribute" + | "ha_available" + | "house_mode" + | "device_state" + | "zone_state" + | "group_state" + | "night_mode" + | "constant" + | "shared_input" + ) +} +fn flow_logic_kind(kind: &str) -> bool { + matches!(kind, "logic_and" | "logic_or" | "logic_not") +} +fn flow_action_kind(kind: &str) -> bool { + matches!( + kind, + "zone_thermostat" | "device_action" | "group_action" | "ha_service_action" ) } -fn flow_logic_kind(kind: &str) -> bool { matches!(kind, "logic_and" | "logic_or" | "logic_not") } -fn flow_action_kind(kind: &str) -> bool { matches!(kind, "zone_thermostat" | "device_action" | "group_action" | "ha_service_action") } fn shared_input_comparison_kind(kind: &str) -> bool { - matches!(kind, - "outdoor_temperature" | "device_temperature" | "zone_temperature" | - "ha_state" | "ha_numeric" | "ha_attribute" | "house_mode" | - "device_state" | "zone_state" | "group_state" + matches!( + kind, + "outdoor_temperature" + | "device_temperature" + | "zone_temperature" + | "ha_state" + | "ha_numeric" + | "ha_attribute" + | "house_mode" + | "device_state" + | "zone_state" + | "group_state" ) } @@ -67,100 +121,179 @@ enum SharedInputResourceReference { Group(String), } -fn shared_input_resource_reference(kind: &str, config: &Value) -> Result, AppError> { +fn shared_input_resource_reference( + kind: &str, + config: &Value, +) -> Result, AppError> { let reference = match kind { "outdoor_temperature" | "house_mode" | "night_mode" => None, "device_temperature" => { - let id = flow_string(config, "device_id") - .ok_or_else(|| AppError::BadRequest("shared device temperature input needs device".into()))?; + let id = flow_string(config, "device_id").ok_or_else(|| { + AppError::BadRequest("shared device temperature input needs device".into()) + })?; Some(SharedInputResourceReference::Device(id)) } "zone_temperature" => { - let id = flow_string(config, "zone_id") - .ok_or_else(|| AppError::BadRequest("shared zone temperature input needs zone".into()))?; + let id = flow_string(config, "zone_id").ok_or_else(|| { + AppError::BadRequest("shared zone temperature input needs zone".into()) + })?; Some(SharedInputResourceReference::Zone(id)) } "ha_state" | "ha_numeric" | "ha_available" => { if flow_string(config, "entity_id").is_none() { - return Err(AppError::BadRequest("shared Home Assistant input needs entity_id".into())); + return Err(AppError::BadRequest( + "shared Home Assistant input needs entity_id".into(), + )); } None } "ha_attribute" => { - if flow_string(config, "entity_id").is_none() || flow_string(config, "attribute").is_none() { - return Err(AppError::BadRequest("shared Home Assistant attribute input needs entity_id and attribute".into())); + if flow_string(config, "entity_id").is_none() + || flow_string(config, "attribute").is_none() + { + return Err(AppError::BadRequest( + "shared Home Assistant attribute input needs entity_id and attribute".into(), + )); } None } "device_state" => { - let id = flow_string(config, "device_id") - .ok_or_else(|| AppError::BadRequest("shared device state input needs device".into()))?; - let field = flow_string(config, "field") - .ok_or_else(|| AppError::BadRequest("shared device state input needs a field".into()))?; - if !matches!(field.as_str(), "enabled" | "online" | "power" | "mode" | "fan_speed" | "swing_vertical" | "swing_horizontal" | "quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep") { - return Err(AppError::BadRequest("unsupported shared device state field".into())); + let id = flow_string(config, "device_id").ok_or_else(|| { + AppError::BadRequest("shared device state input needs device".into()) + })?; + let field = flow_string(config, "field").ok_or_else(|| { + AppError::BadRequest("shared device state input needs a field".into()) + })?; + if !matches!( + field.as_str(), + "enabled" + | "online" + | "power" + | "mode" + | "fan_speed" + | "swing_vertical" + | "swing_horizontal" + | "quiet" + | "turbo" + | "light" + | "air" + | "xfan" + | "health" + | "sleep" + ) { + return Err(AppError::BadRequest( + "unsupported shared device state field".into(), + )); } Some(SharedInputResourceReference::Device(id)) } "zone_state" => { let id = flow_string(config, "zone_id") .ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?; - let field = flow_string(config, "field") - .ok_or_else(|| AppError::BadRequest("shared zone state input needs a field".into()))?; - if !matches!(field.as_str(), "enabled" | "mode" | "active_preset" | "demand" | "control_owner" | "device_manual_override" | "local_thermostat_power") { - return Err(AppError::BadRequest("unsupported shared zone state field".into())); + let field = flow_string(config, "field").ok_or_else(|| { + AppError::BadRequest("shared zone state input needs a field".into()) + })?; + if !matches!( + field.as_str(), + "enabled" + | "mode" + | "active_preset" + | "demand" + | "control_owner" + | "device_manual_override" + | "local_thermostat_power" + ) { + return Err(AppError::BadRequest( + "unsupported shared zone state field".into(), + )); } Some(SharedInputResourceReference::Zone(id)) } "group_state" => { - let id = flow_string(config, "group_id") - .ok_or_else(|| AppError::BadRequest("shared group state input needs group".into()))?; + let id = flow_string(config, "group_id").ok_or_else(|| { + AppError::BadRequest("shared group state input needs group".into()) + })?; if flow_string(config, "field").as_deref() != Some("power_enabled") { - return Err(AppError::BadRequest("unsupported shared group state field".into())); + return Err(AppError::BadRequest( + "unsupported shared group state field".into(), + )); } Some(SharedInputResourceReference::Group(id)) } "constant" => { if config.get("value").and_then(Value::as_bool).is_none() { - return Err(AppError::BadRequest("shared constant input needs a boolean value".into())); + return Err(AppError::BadRequest( + "shared constant input needs a boolean value".into(), + )); } None } - _ => return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {kind}"))), + _ => { + return Err(AppError::BadRequest(format!( + "unsupported shared Flow input kind: {kind}" + ))) + } }; Ok(reference) } -fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) -> Result<(), AppError> { - let Some(reference) = shared_input_resource_reference(kind, config)? else { return Ok(()); }; +fn validate_shared_input_source( + kind: &str, + config: &Value, + state: &AppState, +) -> Result<(), AppError> { + let Some(reference) = shared_input_resource_reference(kind, config)? else { + return Ok(()); + }; let exists = match reference { SharedInputResourceReference::Device(id) => state.db.get_device(&id)?.is_some(), SharedInputResourceReference::Zone(id) => state.db.get_zone(&id)?.is_some(), SharedInputResourceReference::Group(id) => state.db.get_group(&id)?.is_some(), }; if !exists { - return Err(AppError::BadRequest("shared Flow input references a missing resource".into())); + return Err(AppError::BadRequest( + "shared Flow input references a missing resource".into(), + )); } Ok(()) } fn validate_flow_draft_graph(input: &FlowInput) -> Result<(), AppError> { - if input.name.trim().is_empty() { return Err(AppError::BadRequest("flow name is required".into())); } + if input.name.trim().is_empty() { + return Err(AppError::BadRequest("flow name is required".into())); + } let mut ids = std::collections::HashSet::::new(); for node in &input.nodes { - if node.id.trim().is_empty() || !ids.insert(node.id.clone()) { return Err(AppError::BadRequest("flow contains duplicate or empty block IDs".into())); } - if !(flow_condition_kind(&node.kind) || flow_action_kind(&node.kind) || flow_logic_kind(&node.kind)) { - return Err(AppError::BadRequest(format!("unsupported flow block: {}", node.kind))); + if node.id.trim().is_empty() || !ids.insert(node.id.clone()) { + return Err(AppError::BadRequest( + "flow contains duplicate or empty block IDs".into(), + )); + } + if !(flow_condition_kind(&node.kind) + || flow_action_kind(&node.kind) + || flow_logic_kind(&node.kind)) + { + return Err(AppError::BadRequest(format!( + "unsupported flow block: {}", + node.kind + ))); } } let mut edge_ids = std::collections::HashSet::::new(); let mut connections = std::collections::HashSet::<(String, String)>::new(); for edge in &input.edges { - if edge.id.trim().is_empty() || !edge_ids.insert(edge.id.clone()) || !connections.insert((edge.from.clone(), edge.to.clone())) { - return Err(AppError::BadRequest("flow contains duplicate or empty connection IDs".into())); + if edge.id.trim().is_empty() + || !edge_ids.insert(edge.id.clone()) + || !connections.insert((edge.from.clone(), edge.to.clone())) + { + return Err(AppError::BadRequest( + "flow contains duplicate or empty connection IDs".into(), + )); } if !ids.contains(edge.from.as_str()) || !ids.contains(edge.to.as_str()) { - return Err(AppError::BadRequest("flow contains a connection to a missing block".into())); + return Err(AppError::BadRequest( + "flow contains a connection to a missing block".into(), + )); } } Ok(()) @@ -168,60 +301,160 @@ fn validate_flow_draft_graph(input: &FlowInput) -> Result<(), AppError> { fn validate_flow_graph(input: &FlowInput) -> Result<(), AppError> { validate_flow_draft_graph(input)?; - if input.nodes.is_empty() { return Err(AppError::BadRequest("flow needs at least one block".into())); } - let ids = input.nodes.iter().map(|node| node.id.clone()).collect::>(); - if !input.nodes.iter().any(|node| flow_action_kind(&node.kind)) { return Err(AppError::BadRequest("flow needs at least one action block".into())); } + if input.nodes.is_empty() { + return Err(AppError::BadRequest("flow needs at least one block".into())); + } + let ids = input + .nodes + .iter() + .map(|node| node.id.clone()) + .collect::>(); + if !input.nodes.iter().any(|node| flow_action_kind(&node.kind)) { + return Err(AppError::BadRequest( + "flow needs at least one action block".into(), + )); + } for edge in &input.edges { if edge.from == edge.to { - return Err(AppError::BadRequest("flow contains an invalid connection".into())); + return Err(AppError::BadRequest( + "flow contains an invalid connection".into(), + )); } - let from = input.nodes.iter().find(|node| node.id == edge.from).expect("validated Flow source"); - let to = input.nodes.iter().find(|node| node.id == edge.to).expect("validated Flow target"); + let from = input + .nodes + .iter() + .find(|node| node.id == edge.from) + .expect("validated Flow source"); + let to = input + .nodes + .iter() + .find(|node| node.id == edge.to) + .expect("validated Flow target"); if flow_action_kind(&from.kind) { - return Err(AppError::BadRequest("Flow action blocks must be terminal and cannot feed another block".into())); + return Err(AppError::BadRequest( + "Flow action blocks must be terminal and cannot feed another block".into(), + )); } - if !(flow_condition_kind(&to.kind) || flow_logic_kind(&to.kind) || flow_action_kind(&to.kind)) { - return Err(AppError::BadRequest("Flow connection has an unsupported target".into())); + if !(flow_condition_kind(&to.kind) + || flow_logic_kind(&to.kind) + || flow_action_kind(&to.kind)) + { + return Err(AppError::BadRequest( + "Flow connection has an unsupported target".into(), + )); } } for node in input.nodes.iter().filter(|node| node.kind == "rate_limit") { - let targets = input.edges.iter().filter(|edge| edge.from == node.id) + let targets = input + .edges + .iter() + .filter(|edge| edge.from == node.id) .filter_map(|edge| input.nodes.iter().find(|target| target.id == edge.to)) .collect::>(); if targets.is_empty() || targets.iter().any(|target| !flow_action_kind(&target.kind)) { - return Err(AppError::BadRequest("rate-limit block must be placed directly before an action".into())); + return Err(AppError::BadRequest( + "rate-limit block must be placed directly before an action".into(), + )); } } - for node in input.nodes.iter().filter(|node| node.kind == "on_change" && flow_string(&node.config, "mode").as_deref() == Some("value")) { - let sources = input.edges.iter().filter(|edge| edge.to == node.id) + for node in input.nodes.iter().filter(|node| { + node.kind == "on_change" && flow_string(&node.config, "mode").as_deref() == Some("value") + }) { + let sources = input + .edges + .iter() + .filter(|edge| edge.to == node.id) .filter_map(|edge| input.nodes.iter().find(|source| source.id == edge.from)) .collect::>(); - if sources.len() != 1 || sources.iter().any(|source| flow_logic_kind(&source.kind) || matches!(source.kind.as_str(), "stable_for" | "delay" | "state_duration" | "on_change" | "rate_limit" | "rolling_stat" | "oscillates")) { - return Err(AppError::BadRequest("on-change value mode needs one direct source/condition input".into())); + if sources.len() != 1 + || sources.iter().any(|source| { + flow_logic_kind(&source.kind) + || matches!( + source.kind.as_str(), + "stable_for" + | "delay" + | "state_duration" + | "on_change" + | "rate_limit" + | "rolling_stat" + | "oscillates" + ) + }) + { + return Err(AppError::BadRequest( + "on-change value mode needs one direct source/condition input".into(), + )); } } // Reject cycles for executable Flows. Drafts intentionally allow unfinished wiring and are never compiled. let mut outgoing = std::collections::HashMap::>::new(); - for edge in &input.edges { outgoing.entry(edge.from.clone()).or_default().push(edge.to.clone()); } - fn visit(id: &str, outgoing: &std::collections::HashMap>, temp: &mut std::collections::HashSet, done: &mut std::collections::HashSet) -> bool { - if done.contains(id) { return false; } - if !temp.insert(id.to_string()) { return true; } - if outgoing.get(id).into_iter().flatten().any(|next| visit(next, outgoing, temp, done)) { return true; } - temp.remove(id); done.insert(id.to_string()); false + for edge in &input.edges { + outgoing + .entry(edge.from.clone()) + .or_default() + .push(edge.to.clone()); + } + fn visit( + id: &str, + outgoing: &std::collections::HashMap>, + temp: &mut std::collections::HashSet, + done: &mut std::collections::HashSet, + ) -> bool { + if done.contains(id) { + return false; + } + if !temp.insert(id.to_string()) { + return true; + } + if outgoing + .get(id) + .into_iter() + .flatten() + .any(|next| visit(next, outgoing, temp, done)) + { + return true; + } + temp.remove(id); + done.insert(id.to_string()); + false + } + let mut temp = std::collections::HashSet::new(); + let mut done = std::collections::HashSet::new(); + for id in &ids { + if visit(id, &outgoing, &mut temp, &mut done) { + return Err(AppError::BadRequest( + "flow connections cannot contain a cycle".into(), + )); + } } - let mut temp = std::collections::HashSet::new(); let mut done = std::collections::HashSet::new(); - for id in &ids { if visit(id, &outgoing, &mut temp, &mut done) { return Err(AppError::BadRequest("flow connections cannot contain a cycle".into())); } } Ok(()) } -fn compile_flow_program(action_id: &str, nodes: &[crate::models::FlowNode], edges: &[crate::models::FlowEdge]) -> Result, AppError> { - let by_id: std::collections::HashMap = nodes.iter().cloned().map(|node| (node.id.clone(), node)).collect(); +fn compile_flow_program( + action_id: &str, + nodes: &[crate::models::FlowNode], + edges: &[crate::models::FlowEdge], +) -> Result, AppError> { + let by_id: std::collections::HashMap = nodes + .iter() + .cloned() + .map(|node| (node.id.clone(), node)) + .collect(); let mut incoming = std::collections::HashMap::>::new(); - for edge in edges { incoming.entry(edge.to.clone()).or_default().push(edge.from.clone()); } + for edge in edges { + incoming + .entry(edge.to.clone()) + .or_default() + .push(edge.from.clone()); + } let action_inputs = incoming.get(action_id).cloned().unwrap_or_default(); - if action_inputs.is_empty() { return Err(AppError::BadRequest(format!("action '{action_id}' needs at least one connected condition"))); } + if action_inputs.is_empty() { + return Err(AppError::BadRequest(format!( + "action '{action_id}' needs at least one connected condition" + ))); + } fn visit( id: &str, @@ -230,25 +463,49 @@ fn compile_flow_program(action_id: &str, nodes: &[crate::models::FlowNode], edge seen: &mut std::collections::HashSet, out: &mut Vec, ) -> Result<(), AppError> { - if !seen.insert(id.to_string()) { return Ok(()); } - let node = by_id.get(id).ok_or_else(|| AppError::BadRequest("Flow references a missing block".into()))?; + if !seen.insert(id.to_string()) { + return Ok(()); + } + let node = by_id + .get(id) + .ok_or_else(|| AppError::BadRequest("Flow references a missing block".into()))?; if !(flow_condition_kind(&node.kind) || flow_logic_kind(&node.kind)) { - return Err(AppError::BadRequest("only condition or logic blocks can feed a Flow action".into())); + return Err(AppError::BadRequest( + "only condition or logic blocks can feed a Flow action".into(), + )); } let inputs = incoming.get(id).cloned().unwrap_or_default(); - for input in &inputs { visit(input, by_id, incoming, seen, out)?; } + for input in &inputs { + visit(input, by_id, incoming, seen, out)?; + } match node.kind.as_str() { - "logic_not" if inputs.len() != 1 => return Err(AppError::BadRequest("NOT block needs exactly one input".into())), - "logic_and" | "logic_or" if inputs.is_empty() => return Err(AppError::BadRequest(format!("{} block needs at least one input", node.kind))), + "logic_not" if inputs.len() != 1 => { + return Err(AppError::BadRequest( + "NOT block needs exactly one input".into(), + )) + } + "logic_and" | "logic_or" if inputs.is_empty() => { + return Err(AppError::BadRequest(format!( + "{} block needs at least one input", + node.kind + ))) + } _ => {} } - out.push(crate::models::FlowCondition { id: node.id.clone(), kind: node.kind.clone(), config: node.config.clone(), inputs }); + out.push(crate::models::FlowCondition { + id: node.id.clone(), + kind: node.kind.clone(), + config: node.config.clone(), + inputs, + }); Ok(()) } let mut out = Vec::new(); let mut seen = std::collections::HashSet::new(); - for input in &action_inputs { visit(input, &by_id, &incoming, &mut seen, &mut out)?; } + for input in &action_inputs { + visit(input, &by_id, &incoming, &mut seen, &mut out)?; + } out.push(crate::models::FlowCondition { id: format!("__flow_action__:{action_id}"), kind: "logic_and".into(), @@ -260,170 +517,496 @@ fn compile_flow_program(action_id: &str, nodes: &[crate::models::FlowNode], edge fn validate_text_comparison(config: &Value) -> Result<(), AppError> { let op = flow_string(config, "operator").unwrap_or_else(|| "eq".into()); - if !matches!(op.as_str(), "eq" | "neq") { return Err(AppError::BadRequest("state operator must be eq or neq".into())); } - if config.get("value").is_none() { return Err(AppError::BadRequest("state block needs a value".into())); } + if !matches!(op.as_str(), "eq" | "neq") { + return Err(AppError::BadRequest( + "state operator must be eq or neq".into(), + )); + } + if config.get("value").is_none() { + return Err(AppError::BadRequest("state block needs a value".into())); + } Ok(()) } -fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState) -> Result<(), AppError> { +fn validate_condition( + condition: &crate::models::FlowCondition, + state: &AppState, +) -> Result<(), AppError> { match condition.kind.as_str() { "weekday" => { - let days = condition.config.get("days").and_then(Value::as_array).ok_or_else(|| AppError::BadRequest("weekday block needs days".into()))?; - if days.is_empty() || days.iter().any(|v| v.as_u64().map(|d| !(1..=7).contains(&d)).unwrap_or(true)) { return Err(AppError::BadRequest("weekday block contains invalid days".into())); } + let days = condition + .config + .get("days") + .and_then(Value::as_array) + .ok_or_else(|| AppError::BadRequest("weekday block needs days".into()))?; + if days.is_empty() + || days + .iter() + .any(|v| v.as_u64().map(|d| !(1..=7).contains(&d)).unwrap_or(true)) + { + return Err(AppError::BadRequest( + "weekday block contains invalid days".into(), + )); + } } "time_range" => { - for key in ["start", "end"] { let value = flow_string(&condition.config, key).ok_or_else(|| AppError::BadRequest(format!("time range needs {key}")))?; chrono::NaiveTime::parse_from_str(&value, "%H:%M").map_err(|_| AppError::BadRequest("invalid time range".into()))?; } + for key in ["start", "end"] { + let value = flow_string(&condition.config, key) + .ok_or_else(|| AppError::BadRequest(format!("time range needs {key}")))?; + chrono::NaiveTime::parse_from_str(&value, "%H:%M") + .map_err(|_| AppError::BadRequest("invalid time range".into()))?; + } } "date_range" => { - let start = flow_string(&condition.config, "start").ok_or_else(|| AppError::BadRequest("date range needs start".into()))?; - let end = flow_string(&condition.config, "end").ok_or_else(|| AppError::BadRequest("date range needs end".into()))?; - let start = chrono::NaiveDate::parse_from_str(&start, "%Y-%m-%d").map_err(|_| AppError::BadRequest("invalid date range".into()))?; - let end = chrono::NaiveDate::parse_from_str(&end, "%Y-%m-%d").map_err(|_| AppError::BadRequest("invalid date range".into()))?; - if start > end { return Err(AppError::BadRequest("date range start must not be after end".into())); } + let start = flow_string(&condition.config, "start") + .ok_or_else(|| AppError::BadRequest("date range needs start".into()))?; + let end = flow_string(&condition.config, "end") + .ok_or_else(|| AppError::BadRequest("date range needs end".into()))?; + let start = chrono::NaiveDate::parse_from_str(&start, "%Y-%m-%d") + .map_err(|_| AppError::BadRequest("invalid date range".into()))?; + let end = chrono::NaiveDate::parse_from_str(&end, "%Y-%m-%d") + .map_err(|_| AppError::BadRequest("invalid date range".into()))?; + if start > end { + return Err(AppError::BadRequest( + "date range start must not be after end".into(), + )); + } } "cron_trigger" => { - let expr = flow_string(&condition.config, "expression").ok_or_else(|| AppError::BadRequest("cron block needs an expression".into()))?; - if !engine::cron_expression_valid(&expr) { return Err(AppError::BadRequest("invalid cron expression; expected 5 fields with *, */N, ranges or lists".into())); } + let expr = flow_string(&condition.config, "expression") + .ok_or_else(|| AppError::BadRequest("cron block needs an expression".into()))?; + if !engine::cron_expression_valid(&expr) { + return Err(AppError::BadRequest( + "invalid cron expression; expected 5 fields with *, */N, ranges or lists" + .into(), + )); + } } "stable_for" => { - let seconds = condition.config.get("seconds").and_then(Value::as_u64).unwrap_or(0); - if seconds == 0 || seconds > 604800 { return Err(AppError::BadRequest("stable-for duration must be between 1 second and 7 days".into())); } - if condition.inputs.len() != 1 { return Err(AppError::BadRequest("stable-for block needs exactly one input".into())); } + let seconds = condition + .config + .get("seconds") + .and_then(Value::as_u64) + .unwrap_or(0); + if seconds == 0 || seconds > 604800 { + return Err(AppError::BadRequest( + "stable-for duration must be between 1 second and 7 days".into(), + )); + } + if condition.inputs.len() != 1 { + return Err(AppError::BadRequest( + "stable-for block needs exactly one input".into(), + )); + } } "delay" => { - let seconds = condition.config.get("seconds").and_then(Value::as_u64).unwrap_or(0); - if seconds == 0 || seconds > 604800 { return Err(AppError::BadRequest("delay must be between 1 second and 7 days".into())); } - if condition.inputs.len() != 1 { return Err(AppError::BadRequest("delay block needs exactly one input".into())); } + let seconds = condition + .config + .get("seconds") + .and_then(Value::as_u64) + .unwrap_or(0); + if seconds == 0 || seconds > 604800 { + return Err(AppError::BadRequest( + "delay must be between 1 second and 7 days".into(), + )); + } + if condition.inputs.len() != 1 { + return Err(AppError::BadRequest( + "delay block needs exactly one input".into(), + )); + } } "state_duration" => { - let min_seconds = condition.config.get("min_seconds").and_then(Value::as_u64).unwrap_or(0); + let min_seconds = condition + .config + .get("min_seconds") + .and_then(Value::as_u64) + .unwrap_or(0); let max_seconds = condition.config.get("max_seconds").and_then(Value::as_u64); if min_seconds > 604800 || max_seconds.is_some_and(|value| value > 604800) { - return Err(AppError::BadRequest("state duration must be between 0 seconds and 7 days".into())); + return Err(AppError::BadRequest( + "state duration must be between 0 seconds and 7 days".into(), + )); } if max_seconds.is_some_and(|value| value < min_seconds) { - return Err(AppError::BadRequest("state duration maximum must be greater than or equal to minimum".into())); + return Err(AppError::BadRequest( + "state duration maximum must be greater than or equal to minimum".into(), + )); } if min_seconds == 0 && max_seconds.is_none() { - return Err(AppError::BadRequest("state duration needs a minimum or maximum duration".into())); + return Err(AppError::BadRequest( + "state duration needs a minimum or maximum duration".into(), + )); + } + if condition.inputs.len() != 1 { + return Err(AppError::BadRequest( + "state duration block needs exactly one input".into(), + )); } - if condition.inputs.len() != 1 { return Err(AppError::BadRequest("state duration block needs exactly one input".into())); } } "on_change" => { let mode = flow_string(&condition.config, "mode").unwrap_or_else(|| "result".into()); - if !matches!(mode.as_str(), "result" | "value") { return Err(AppError::BadRequest("on-change mode must be result or value".into())); } - if condition.inputs.len() != 1 { return Err(AppError::BadRequest("on-change block needs exactly one input".into())); } + if !matches!(mode.as_str(), "result" | "value") { + return Err(AppError::BadRequest( + "on-change mode must be result or value".into(), + )); + } + if condition.inputs.len() != 1 { + return Err(AppError::BadRequest( + "on-change block needs exactly one input".into(), + )); + } } "rate_limit" => { - let max_count = condition.config.get("max_count").and_then(Value::as_u64).unwrap_or(0); - let period_seconds = condition.config.get("period_seconds").and_then(Value::as_u64).unwrap_or(0); - if !(1..=1000).contains(&max_count) { return Err(AppError::BadRequest("rate limit max_count must be between 1 and 1000".into())); } - if !(1..=2678400).contains(&period_seconds) { return Err(AppError::BadRequest("rate limit period must be between 1 second and 31 days".into())); } - if condition.inputs.len() != 1 { return Err(AppError::BadRequest("rate-limit block needs exactly one input".into())); } + let max_count = condition + .config + .get("max_count") + .and_then(Value::as_u64) + .unwrap_or(0); + let period_seconds = condition + .config + .get("period_seconds") + .and_then(Value::as_u64) + .unwrap_or(0); + if !(1..=1000).contains(&max_count) { + return Err(AppError::BadRequest( + "rate limit max_count must be between 1 and 1000".into(), + )); + } + if !(1..=2678400).contains(&period_seconds) { + return Err(AppError::BadRequest( + "rate limit period must be between 1 second and 31 days".into(), + )); + } + if condition.inputs.len() != 1 { + return Err(AppError::BadRequest( + "rate-limit block needs exactly one input".into(), + )); + } } "rolling_stat" => { let source = flow_string(&condition.config, "source").unwrap_or_default(); - if !matches!(source.as_str(), "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_numeric") { return Err(AppError::BadRequest("rolling statistic has an unsupported source".into())); } - if source == "device_temperature" { let id=flow_string(&condition.config,"device_id").unwrap_or_default(); if state.db.get_device(&id)?.is_none(){return Err(AppError::BadRequest("rolling statistic references a missing device".into()));} } - if source == "zone_temperature" { let id=flow_string(&condition.config,"zone_id").unwrap_or_default(); if state.db.get_zone(&id)?.is_none(){return Err(AppError::BadRequest("rolling statistic references a missing zone".into()));} } - if source == "ha_numeric" && flow_string(&condition.config,"entity_id").is_none() { return Err(AppError::BadRequest("rolling HA statistic needs entity_id".into())); } - let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(0); - if window < 10 || window > 604800 { return Err(AppError::BadRequest("rolling statistic window must be between 10 seconds and 7 days".into())); } - if !matches!(flow_string(&condition.config,"statistic").as_deref(), Some("mean") | Some("median")) { return Err(AppError::BadRequest("rolling statistic must be mean or median".into())); } + if !matches!( + source.as_str(), + "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_numeric" + ) { + return Err(AppError::BadRequest( + "rolling statistic has an unsupported source".into(), + )); + } + if source == "device_temperature" { + let id = flow_string(&condition.config, "device_id").unwrap_or_default(); + if state.db.get_device(&id)?.is_none() { + return Err(AppError::BadRequest( + "rolling statistic references a missing device".into(), + )); + } + } + if source == "zone_temperature" { + let id = flow_string(&condition.config, "zone_id").unwrap_or_default(); + if state.db.get_zone(&id)?.is_none() { + return Err(AppError::BadRequest( + "rolling statistic references a missing zone".into(), + )); + } + } + if source == "ha_numeric" && flow_string(&condition.config, "entity_id").is_none() { + return Err(AppError::BadRequest( + "rolling HA statistic needs entity_id".into(), + )); + } + let window = condition + .config + .get("window_seconds") + .and_then(Value::as_u64) + .unwrap_or(0); + if window < 10 || window > 604800 { + return Err(AppError::BadRequest( + "rolling statistic window must be between 10 seconds and 7 days".into(), + )); + } + if !matches!( + flow_string(&condition.config, "statistic").as_deref(), + Some("mean") | Some("median") + ) { + return Err(AppError::BadRequest( + "rolling statistic must be mean or median".into(), + )); + } validate_flow_comparison(&condition.config)?; } "oscillates" => { let source = flow_string(&condition.config, "source").unwrap_or_default(); - if !matches!(source.as_str(), "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_numeric") { return Err(AppError::BadRequest("oscillation block has an unsupported source".into())); } - if source == "device_temperature" { let id=flow_string(&condition.config,"device_id").unwrap_or_default(); if state.db.get_device(&id)?.is_none(){return Err(AppError::BadRequest("oscillation block references a missing device".into()));} } - if source == "zone_temperature" { let id=flow_string(&condition.config,"zone_id").unwrap_or_default(); if state.db.get_zone(&id)?.is_none(){return Err(AppError::BadRequest("oscillation block references a missing zone".into()));} } - if source == "ha_numeric" && flow_string(&condition.config,"entity_id").is_none() { return Err(AppError::BadRequest("oscillation HA source needs entity_id".into())); } - let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(0); - if window < 10 || window > 604800 { return Err(AppError::BadRequest("oscillation window must be between 10 seconds and 7 days".into())); } + if !matches!( + source.as_str(), + "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_numeric" + ) { + return Err(AppError::BadRequest( + "oscillation block has an unsupported source".into(), + )); + } + if source == "device_temperature" { + let id = flow_string(&condition.config, "device_id").unwrap_or_default(); + if state.db.get_device(&id)?.is_none() { + return Err(AppError::BadRequest( + "oscillation block references a missing device".into(), + )); + } + } + if source == "zone_temperature" { + let id = flow_string(&condition.config, "zone_id").unwrap_or_default(); + if state.db.get_zone(&id)?.is_none() { + return Err(AppError::BadRequest( + "oscillation block references a missing zone".into(), + )); + } + } + if source == "ha_numeric" && flow_string(&condition.config, "entity_id").is_none() { + return Err(AppError::BadRequest( + "oscillation HA source needs entity_id".into(), + )); + } + let window = condition + .config + .get("window_seconds") + .and_then(Value::as_u64) + .unwrap_or(0); + if window < 10 || window > 604800 { + return Err(AppError::BadRequest( + "oscillation window must be between 10 seconds and 7 days".into(), + )); + } let min_span = flow_f64(&condition.config, "min_span").unwrap_or(0.0); - if !min_span.is_finite() || min_span <= 0.0 { return Err(AppError::BadRequest("oscillation minimum span must be greater than zero".into())); } - let min_changes = condition.config.get("min_direction_changes").and_then(Value::as_u64).unwrap_or(0); - if min_changes == 0 || min_changes > 1000 { return Err(AppError::BadRequest("oscillation direction changes must be between 1 and 1000".into())); } + if !min_span.is_finite() || min_span <= 0.0 { + return Err(AppError::BadRequest( + "oscillation minimum span must be greater than zero".into(), + )); + } + let min_changes = condition + .config + .get("min_direction_changes") + .and_then(Value::as_u64) + .unwrap_or(0); + if min_changes == 0 || min_changes > 1000 { + return Err(AppError::BadRequest( + "oscillation direction changes must be between 1 and 1000".into(), + )); + } + } + "outdoor_temperature" => { + validate_flow_comparison(&condition.config)?; } - "outdoor_temperature" => { validate_flow_comparison(&condition.config)?; } "device_temperature" => { validate_flow_comparison(&condition.config)?; - let id = flow_string(&condition.config, "device_id").ok_or_else(|| AppError::BadRequest("device temperature block needs device".into()))?; - if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing device".into())); } + let id = flow_string(&condition.config, "device_id").ok_or_else(|| { + AppError::BadRequest("device temperature block needs device".into()) + })?; + if state.db.get_device(&id)?.is_none() { + return Err(AppError::BadRequest( + "flow references a missing device".into(), + )); + } } "zone_temperature" => { validate_flow_comparison(&condition.config)?; - let id = flow_string(&condition.config, "zone_id").ok_or_else(|| AppError::BadRequest("zone temperature block needs zone".into()))?; - if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing zone".into())); } + let id = flow_string(&condition.config, "zone_id") + .ok_or_else(|| AppError::BadRequest("zone temperature block needs zone".into()))?; + if state.db.get_zone(&id)?.is_none() { + return Err(AppError::BadRequest( + "flow references a missing zone".into(), + )); + } } "ha_state" => { - if flow_string(&condition.config, "entity_id").is_none() { return Err(AppError::BadRequest("Home Assistant state block needs entity_id".into())); } + if flow_string(&condition.config, "entity_id").is_none() { + return Err(AppError::BadRequest( + "Home Assistant state block needs entity_id".into(), + )); + } validate_text_comparison(&condition.config)?; } "ha_numeric" => { - if flow_string(&condition.config, "entity_id").is_none() { return Err(AppError::BadRequest("Home Assistant numeric block needs entity_id".into())); } + if flow_string(&condition.config, "entity_id").is_none() { + return Err(AppError::BadRequest( + "Home Assistant numeric block needs entity_id".into(), + )); + } validate_flow_comparison(&condition.config)?; } "ha_attribute" => { - if flow_string(&condition.config, "entity_id").is_none() || flow_string(&condition.config, "attribute").is_none() { - return Err(AppError::BadRequest("Home Assistant attribute block needs entity_id and attribute".into())); + if flow_string(&condition.config, "entity_id").is_none() + || flow_string(&condition.config, "attribute").is_none() + { + return Err(AppError::BadRequest( + "Home Assistant attribute block needs entity_id and attribute".into(), + )); } let op = flow_string(&condition.config, "operator").unwrap_or_else(|| "eq".into()); - if !matches!(op.as_str(), "lt" | "lte" | "gt" | "gte" | "eq" | "neq") { return Err(AppError::BadRequest("unsupported Home Assistant attribute operator".into())); } - if condition.config.get("value").is_none() { return Err(AppError::BadRequest("Home Assistant attribute block needs a value".into())); } + if !matches!(op.as_str(), "lt" | "lte" | "gt" | "gte" | "eq" | "neq") { + return Err(AppError::BadRequest( + "unsupported Home Assistant attribute operator".into(), + )); + } + if condition.config.get("value").is_none() { + return Err(AppError::BadRequest( + "Home Assistant attribute block needs a value".into(), + )); + } } "ha_available" => { - if flow_string(&condition.config, "entity_id").is_none() { return Err(AppError::BadRequest("Home Assistant availability block needs entity_id".into())); } + if flow_string(&condition.config, "entity_id").is_none() { + return Err(AppError::BadRequest( + "Home Assistant availability block needs entity_id".into(), + )); + } } "house_mode" => { - let value = flow_string(&condition.config, "value").ok_or_else(|| AppError::BadRequest("house mode block needs a mode".into()))?; - if !matches!(value.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); } + let value = flow_string(&condition.config, "value") + .ok_or_else(|| AppError::BadRequest("house mode block needs a mode".into()))?; + if !matches!(value.as_str(), "cool" | "heat" | "off") { + return Err(AppError::BadRequest( + "house mode must be cool, heat or off".into(), + )); + } validate_text_comparison(&condition.config)?; } "device_state" => { - let id = flow_string(&condition.config, "device_id").ok_or_else(|| AppError::BadRequest("device state block needs device".into()))?; - if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing device".into())); } - let field = flow_string(&condition.config, "field").ok_or_else(|| AppError::BadRequest("device state block needs a field".into()))?; - if !matches!(field.as_str(), "enabled" | "online" | "power" | "mode" | "fan_speed" | "swing_vertical" | "swing_horizontal" | "quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep") { return Err(AppError::BadRequest("unsupported device state field".into())); } + let id = flow_string(&condition.config, "device_id") + .ok_or_else(|| AppError::BadRequest("device state block needs device".into()))?; + if state.db.get_device(&id)?.is_none() { + return Err(AppError::BadRequest( + "flow references a missing device".into(), + )); + } + let field = flow_string(&condition.config, "field") + .ok_or_else(|| AppError::BadRequest("device state block needs a field".into()))?; + if !matches!( + field.as_str(), + "enabled" + | "online" + | "power" + | "mode" + | "fan_speed" + | "swing_vertical" + | "swing_horizontal" + | "quiet" + | "turbo" + | "light" + | "air" + | "xfan" + | "health" + | "sleep" + ) { + return Err(AppError::BadRequest( + "unsupported device state field".into(), + )); + } validate_text_comparison(&condition.config)?; } "zone_state" => { - let id = flow_string(&condition.config, "zone_id").ok_or_else(|| AppError::BadRequest("zone state block needs zone".into()))?; - if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing zone".into())); } - let field = flow_string(&condition.config, "field").ok_or_else(|| AppError::BadRequest("zone state block needs a field".into()))?; - if !matches!(field.as_str(), "enabled" | "mode" | "active_preset" | "demand" | "control_owner" | "device_manual_override" | "local_thermostat_power") { return Err(AppError::BadRequest("unsupported zone state field".into())); } + let id = flow_string(&condition.config, "zone_id") + .ok_or_else(|| AppError::BadRequest("zone state block needs zone".into()))?; + if state.db.get_zone(&id)?.is_none() { + return Err(AppError::BadRequest( + "flow references a missing zone".into(), + )); + } + let field = flow_string(&condition.config, "field") + .ok_or_else(|| AppError::BadRequest("zone state block needs a field".into()))?; + if !matches!( + field.as_str(), + "enabled" + | "mode" + | "active_preset" + | "demand" + | "control_owner" + | "device_manual_override" + | "local_thermostat_power" + ) { + return Err(AppError::BadRequest("unsupported zone state field".into())); + } validate_text_comparison(&condition.config)?; } "group_state" => { - let id = flow_string(&condition.config, "group_id").ok_or_else(|| AppError::BadRequest("group state block needs group".into()))?; - if state.db.get_group(&id)?.is_none() { return Err(AppError::BadRequest("flow references a missing group".into())); } - let field = flow_string(&condition.config, "field").ok_or_else(|| AppError::BadRequest("group state block needs a field".into()))?; - if field != "power_enabled" { return Err(AppError::BadRequest("unsupported group state field".into())); } + let id = flow_string(&condition.config, "group_id") + .ok_or_else(|| AppError::BadRequest("group state block needs group".into()))?; + if state.db.get_group(&id)?.is_none() { + return Err(AppError::BadRequest( + "flow references a missing group".into(), + )); + } + let field = flow_string(&condition.config, "field") + .ok_or_else(|| AppError::BadRequest("group state block needs a field".into()))?; + if field != "power_enabled" { + return Err(AppError::BadRequest("unsupported group state field".into())); + } validate_text_comparison(&condition.config)?; } "night_mode" => {} "constant" => { - if condition.config.get("value").and_then(Value::as_bool).is_none() { return Err(AppError::BadRequest("constant block needs a boolean value".into())); } + if condition + .config + .get("value") + .and_then(Value::as_bool) + .is_none() + { + return Err(AppError::BadRequest( + "constant block needs a boolean value".into(), + )); + } } "shared_input" => { - let input_id = flow_string(&condition.config, "input_id").ok_or_else(|| AppError::BadRequest("shared Flow input block needs input_id".into()))?; - let settings = state.db.load_runtime_settings()?.ok_or_else(|| AppError::BadRequest("runtime settings are unavailable".into()))?; - let shared = settings.home_assistant.flow_inputs.iter().find(|item| item.id == input_id) - .ok_or_else(|| AppError::BadRequest("shared Flow input block references a missing input".into()))?; + let input_id = flow_string(&condition.config, "input_id").ok_or_else(|| { + AppError::BadRequest("shared Flow input block needs input_id".into()) + })?; + let settings = state + .db + .load_runtime_settings()? + .ok_or_else(|| AppError::BadRequest("runtime settings are unavailable".into()))?; + let shared = settings + .home_assistant + .flow_inputs + .iter() + .find(|item| item.id == input_id) + .ok_or_else(|| { + AppError::BadRequest( + "shared Flow input block references a missing input".into(), + ) + })?; validate_shared_input_source(&shared.kind, &shared.config, state)?; if shared_input_comparison_kind(&shared.kind) { - let operator = flow_string(&condition.config, "operator") - .ok_or_else(|| AppError::BadRequest("shared Flow value needs an operator in the Flow block".into()))?; + let operator = flow_string(&condition.config, "operator").ok_or_else(|| { + AppError::BadRequest( + "shared Flow value needs an operator in the Flow block".into(), + ) + })?; let mut config = shared.config.clone(); - let map = config.as_object_mut().ok_or_else(|| AppError::BadRequest("shared Flow input config must be an object".into()))?; + let map = config.as_object_mut().ok_or_else(|| { + AppError::BadRequest("shared Flow input config must be an object".into()) + })?; map.insert("operator".into(), Value::String(operator)); - map.insert("value".into(), condition.config.get("value").cloned().unwrap_or(Value::Null)); + map.insert( + "value".into(), + condition + .config + .get("value") + .cloned() + .unwrap_or(Value::Null), + ); let resolved = crate::models::FlowCondition { - id: condition.id.clone(), kind: shared.kind.clone(), config, inputs: Vec::new(), + id: condition.id.clone(), + kind: shared.kind.clone(), + config, + inputs: Vec::new(), }; validate_condition(&resolved, state)?; - } else if flow_string(&condition.config, "operator").is_some() || condition.config.get("value").is_some() { - return Err(AppError::BadRequest("this shared Flow input is already boolean and does not accept a comparison".into())); + } else if flow_string(&condition.config, "operator").is_some() + || condition.config.get("value").is_some() + { + return Err(AppError::BadRequest( + "this shared Flow input is already boolean and does not accept a comparison" + .into(), + )); } } _ => {} @@ -432,108 +1015,290 @@ fn validate_condition(condition: &crate::models::FlowCondition, state: &AppState } fn validate_flow_comparison(config: &Value) -> Result<(), AppError> { let op = flow_string(config, "operator").unwrap_or_else(|| "lt".into()); - if !matches!(op.as_str(), "lt" | "lte" | "gt" | "gte" | "eq" | "neq") { return Err(AppError::BadRequest("unsupported comparison operator".into())); } - if flow_f64(config, "value").is_none() { return Err(AppError::BadRequest("comparison block needs a numeric value".into())); } + if !matches!(op.as_str(), "lt" | "lte" | "gt" | "gte" | "eq" | "neq") { + return Err(AppError::BadRequest( + "unsupported comparison operator".into(), + )); + } + if flow_f64(config, "value").is_none() { + return Err(AppError::BadRequest( + "comparison block needs a numeric value".into(), + )); + } Ok(()) } -fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crate::models::Flow, Vec, Vec), AppError> { +fn compile_flow( + state: &AppState, + mut flow: crate::models::Flow, +) -> Result<(crate::models::Flow, Vec, Vec), AppError> { let mut schedules = Vec::new(); let mut automations = Vec::new(); let now = Utc::now(); let zones = state.db.list_zones()?; let groups = state.db.list_groups()?; let devices = state.db.list_devices()?; - let previous_schedules: std::collections::HashMap = state.db.list_schedules()?.into_iter() - .filter(|item| item.flow_id.as_deref() == Some(flow.id.as_str())).map(|item| (item.id.clone(), item)).collect(); - let previous_automations: std::collections::HashMap = state.db.list_automations()?.into_iter() - .filter(|item| item.flow_id.as_deref() == Some(flow.id.as_str())).map(|item| (item.id.clone(), item)).collect(); - let actions: Vec<_> = flow.nodes.iter().filter(|node| flow_action_kind(&node.kind)).cloned().collect(); + let previous_schedules: std::collections::HashMap = state + .db + .list_schedules()? + .into_iter() + .filter(|item| item.flow_id.as_deref() == Some(flow.id.as_str())) + .map(|item| (item.id.clone(), item)) + .collect(); + let previous_automations: std::collections::HashMap = state + .db + .list_automations()? + .into_iter() + .filter(|item| item.flow_id.as_deref() == Some(flow.id.as_str())) + .map(|item| (item.id.clone(), item)) + .collect(); + let actions: Vec<_> = flow + .nodes + .iter() + .filter(|node| flow_action_kind(&node.kind)) + .cloned() + .collect(); for action_node in actions { let conditions = compile_flow_program(&action_node.id, &flow.nodes, &flow.edges)?; - for condition in conditions.iter().filter(|condition| flow_condition_kind(&condition.kind)) { validate_condition(condition, state)?; } - let schedule_leaves: Vec<_> = conditions.iter().filter(|condition| flow_condition_kind(&condition.kind)).collect(); - let preset_for_schedule = flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into()); - let mode_for_schedule = flow_string(&action_node.config, "mode").unwrap_or_else(|| "auto".into()); - let native_time_range = schedule_leaves.iter().find(|condition| condition.kind == "time_range") - .and_then(|condition| flow_string(&condition.config, "start").zip(flow_string(&condition.config, "end"))) - .and_then(|(start, end)| chrono::NaiveTime::parse_from_str(&start, "%H:%M").ok().zip(chrono::NaiveTime::parse_from_str(&end, "%H:%M").ok())) + for condition in conditions + .iter() + .filter(|condition| flow_condition_kind(&condition.kind)) + { + validate_condition(condition, state)?; + } + let schedule_leaves: Vec<_> = conditions + .iter() + .filter(|condition| flow_condition_kind(&condition.kind)) + .collect(); + let preset_for_schedule = + flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into()); + let mode_for_schedule = + flow_string(&action_node.config, "mode").unwrap_or_else(|| "auto".into()); + let native_time_range = schedule_leaves + .iter() + .find(|condition| condition.kind == "time_range") + .and_then(|condition| { + flow_string(&condition.config, "start").zip(flow_string(&condition.config, "end")) + }) + .and_then(|(start, end)| { + chrono::NaiveTime::parse_from_str(&start, "%H:%M") + .ok() + .zip(chrono::NaiveTime::parse_from_str(&end, "%H:%M").ok()) + }) .map(|(start, end)| start < end) .unwrap_or(false); let schedule_only = action_node.kind == "zone_thermostat" - && !conditions.iter().any(|condition| matches!(condition.kind.as_str(), "logic_or" | "logic_not")) - && schedule_leaves.iter().all(|condition| matches!(condition.kind.as_str(), "weekday" | "time_range")) - && schedule_leaves.iter().filter(|condition| condition.kind == "weekday").count() == 1 - && schedule_leaves.iter().filter(|condition| condition.kind == "time_range").count() == 1 + && !conditions + .iter() + .any(|condition| matches!(condition.kind.as_str(), "logic_or" | "logic_not")) + && schedule_leaves + .iter() + .all(|condition| matches!(condition.kind.as_str(), "weekday" | "time_range")) + && schedule_leaves + .iter() + .filter(|condition| condition.kind == "weekday") + .count() + == 1 + && schedule_leaves + .iter() + .filter(|condition| condition.kind == "time_range") + .count() + == 1 && native_time_range && preset_for_schedule != "auto" && mode_for_schedule == "auto" && flow_bool(&action_node.config, "power").is_none(); if schedule_only { - let zone_id = flow_string(&action_node.config, "zone_id").ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?; - let zone = zones.iter().find(|z| z.id == zone_id).ok_or_else(|| AppError::BadRequest("thermostat block references a missing zone".into()))?; - let preset = flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into()); - if !matches!(preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported thermostat preset".into())); } + let zone_id = flow_string(&action_node.config, "zone_id") + .ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?; + let zone = zones.iter().find(|z| z.id == zone_id).ok_or_else(|| { + AppError::BadRequest("thermostat block references a missing zone".into()) + })?; + let preset = + flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into()); + if !matches!(preset.as_str(), "comfort" | "sleep" | "away" | "custom") { + return Err(AppError::BadRequest("unsupported thermostat preset".into())); + } let setpoint = flow_f64(&action_node.config, "setpoint").unwrap_or(zone.setpoint); - if preset == "custom" && !(8.0..=30.0).contains(&setpoint) { return Err(AppError::BadRequest("custom thermostat target must be between 8 and 30 C".into())); } - let weekday = schedule_leaves.iter().find(|c| c.kind == "weekday").copied().unwrap(); - let time = schedule_leaves.iter().find(|c| c.kind == "time_range").copied().unwrap(); - let weekdays = weekday.config.get("days").and_then(Value::as_array).unwrap().iter().filter_map(Value::as_u64).map(|v| v as u32).collect(); + if preset == "custom" && !(8.0..=30.0).contains(&setpoint) { + return Err(AppError::BadRequest( + "custom thermostat target must be between 8 and 30 C".into(), + )); + } + let weekday = schedule_leaves + .iter() + .find(|c| c.kind == "weekday") + .copied() + .unwrap(); + let time = schedule_leaves + .iter() + .find(|c| c.kind == "time_range") + .copied() + .unwrap(); + let weekdays = weekday + .config + .get("days") + .and_then(Value::as_array) + .unwrap() + .iter() + .filter_map(Value::as_u64) + .map(|v| v as u32) + .collect(); let id = format!("flow:{}:schedule:{}", flow.id, action_node.id); - let created_at = previous_schedules.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone()); + let created_at = previous_schedules + .get(&id) + .map(|item| item.created_at.clone()) + .unwrap_or_else(|| now.clone()); let schedule = Schedule { - id, zone_id, name: generated_flow_name(&flow.id, &action_node.id), enabled: flow.enabled, - weekdays, start_time: flow_string(&time.config, "start").unwrap(), end_time: flow_string(&time.config, "end").unwrap(), preset, setpoint, - created_at, updated_at: now.clone(), flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), + id, + zone_id, + name: generated_flow_name(&flow.id, &action_node.id), + enabled: flow.enabled, + weekdays, + start_time: flow_string(&time.config, "start").unwrap(), + end_time: flow_string(&time.config, "end").unwrap(), + preset, + setpoint, + created_at, + updated_at: now.clone(), + flow_id: Some(flow.id.clone()), + flow_node_id: Some(action_node.id.clone()), }; schedules.push(schedule); continue; } drop(schedule_leaves); let id = format!("flow:{}:automation:{}", flow.id, action_node.id); - let created_at = previous_automations.get(&id).map(|item| item.created_at.clone()).unwrap_or_else(|| now.clone()); - let last_fired_at = previous_automations.get(&id).and_then(|item| item.last_fired_at.clone()); - let flow_runtime = previous_automations.get(&id).map(|item| { - let previous_conditions = item.flow_conditions.iter().map(|condition| (condition.id.as_str(), condition)).collect::>(); - let current_conditions = conditions.iter().map(|condition| (condition.id.as_str(), condition)).collect::>(); - let mut runtime = item.flow_runtime.clone(); - runtime.retain(|node_id, _| { - let Some(previous) = previous_conditions.get(node_id.as_str()) else { return false; }; - let Some(current) = current_conditions.get(node_id.as_str()) else { return false; }; - matches!(current.kind.as_str(), "stable_for" | "delay" | "state_duration" | "on_change" | "rate_limit" | "rolling_stat" | "oscillates") - && previous.kind == current.kind - && previous.config == current.config - && previous.inputs == current.inputs - }); - runtime - }).unwrap_or_default(); + let created_at = previous_automations + .get(&id) + .map(|item| item.created_at.clone()) + .unwrap_or_else(|| now.clone()); + let last_fired_at = previous_automations + .get(&id) + .and_then(|item| item.last_fired_at.clone()); + let flow_runtime = previous_automations + .get(&id) + .map(|item| { + let previous_conditions = item + .flow_conditions + .iter() + .map(|condition| (condition.id.as_str(), condition)) + .collect::>(); + let current_conditions = conditions + .iter() + .map(|condition| (condition.id.as_str(), condition)) + .collect::>(); + let mut runtime = item.flow_runtime.clone(); + runtime.retain(|node_id, _| { + let Some(previous) = previous_conditions.get(node_id.as_str()) else { + return false; + }; + let Some(current) = current_conditions.get(node_id.as_str()) else { + return false; + }; + matches!( + current.kind.as_str(), + "stable_for" + | "delay" + | "state_duration" + | "on_change" + | "rate_limit" + | "rolling_stat" + | "oscillates" + ) && previous.kind == current.kind + && previous.config == current.config + && previous.inputs == current.inputs + }); + runtime + }) + .unwrap_or_default(); let mut item = Automation { - id, name: generated_flow_name(&flow.id, &action_node.id), enabled: flow.enabled, - trigger_kind: "flow".into(), trigger_device_id: None, threshold: None, at_time: None, - action_device_id: String::new(), action_group_id: None, action_preset: None, action: DeviceCommand::default(), cooldown_seconds: 60, - last_fired_at, action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: conditions, - flow_id: Some(flow.id.clone()), flow_node_id: Some(action_node.id.clone()), flow_runtime, created_at, updated_at: now.clone(), + id, + name: generated_flow_name(&flow.id, &action_node.id), + enabled: flow.enabled, + trigger_kind: "flow".into(), + trigger_device_id: None, + threshold: None, + at_time: None, + action_device_id: String::new(), + action_group_id: None, + action_preset: None, + action: DeviceCommand::default(), + cooldown_seconds: 60, + last_fired_at, + action_zone_id: None, + action_zone_preset: None, + action_ha_domain: None, + action_ha_service: None, + action_ha_entity_id: None, + action_ha_data: Value::Null, + flow_conditions: conditions, + flow_id: Some(flow.id.clone()), + flow_node_id: Some(action_node.id.clone()), + flow_runtime, + created_at, + updated_at: now.clone(), }; - if let Some(cooldown) = action_node.config.get("cooldown_seconds").and_then(Value::as_u64) { item.cooldown_seconds = cooldown.max(30); } + if let Some(cooldown) = action_node + .config + .get("cooldown_seconds") + .and_then(Value::as_u64) + { + item.cooldown_seconds = cooldown.max(30); + } match action_node.kind.as_str() { "zone_thermostat" => { - let zone_id = flow_string(&action_node.config, "zone_id").ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?; - if !zones.iter().any(|z| z.id == zone_id) { return Err(AppError::BadRequest("thermostat block references a missing zone".into())); } + let zone_id = flow_string(&action_node.config, "zone_id") + .ok_or_else(|| AppError::BadRequest("thermostat block needs a zone".into()))?; + if !zones.iter().any(|z| z.id == zone_id) { + return Err(AppError::BadRequest( + "thermostat block references a missing zone".into(), + )); + } item.action_zone_id = Some(zone_id); - let preset = flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into()); - if !matches!(preset.as_str(), "comfort" | "sleep" | "away" | "custom" | "auto") { return Err(AppError::BadRequest("unsupported thermostat preset".into())); } + let preset = + flow_string(&action_node.config, "preset").unwrap_or_else(|| "comfort".into()); + if !matches!( + preset.as_str(), + "comfort" | "sleep" | "away" | "custom" | "auto" + ) { + return Err(AppError::BadRequest("unsupported thermostat preset".into())); + } item.action_zone_preset = Some(preset.clone()); - if preset == "custom" { let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| AppError::BadRequest("custom thermostat block needs a target".into()))?; if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("custom thermostat target must be between 8 and 30 C".into())); } item.action.target_temperature = Some(target); } - if let Some(power) = flow_bool(&action_node.config, "power") { item.action.power = Some(power); } - if let Some(mode) = flow_string(&action_node.config, "mode") { if !matches!(mode.as_str(), "auto" | "heat" | "cool") { return Err(AppError::BadRequest("unsupported thermostat mode".into())); } item.action.mode = Some(mode); } + if preset == "custom" { + let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| { + AppError::BadRequest("custom thermostat block needs a target".into()) + })?; + if !(8.0..=30.0).contains(&target) { + return Err(AppError::BadRequest( + "custom thermostat target must be between 8 and 30 C".into(), + )); + } + item.action.target_temperature = Some(target); + } + if let Some(power) = flow_bool(&action_node.config, "power") { + item.action.power = Some(power); + } + if let Some(mode) = flow_string(&action_node.config, "mode") { + if !matches!(mode.as_str(), "auto" | "heat" | "cool") { + return Err(AppError::BadRequest("unsupported thermostat mode".into())); + } + item.action.mode = Some(mode); + } } "device_action" => { - let id = flow_string(&action_node.config, "device_id").ok_or_else(|| AppError::BadRequest("device action needs a device".into()))?; - if !devices.iter().any(|d| d.id == id) { return Err(AppError::BadRequest("device action references a missing device".into())); } + let id = flow_string(&action_node.config, "device_id") + .ok_or_else(|| AppError::BadRequest("device action needs a device".into()))?; + if !devices.iter().any(|d| d.id == id) { + return Err(AppError::BadRequest( + "device action references a missing device".into(), + )); + } item.action_device_id = id; item.action.power = flow_bool(&action_node.config, "power"); item.action.mode = flow_string(&action_node.config, "mode"); - item.action.target_temperature = flow_f64(&action_node.config, "target_temperature"); + item.action.target_temperature = + flow_f64(&action_node.config, "target_temperature"); item.action.fan_speed = flow_u8(&action_node.config, "fan_speed"); item.action.swing_vertical = flow_bool(&action_node.config, "swing_vertical"); item.action.swing_horizontal = flow_bool(&action_node.config, "swing_horizontal"); @@ -544,56 +1309,130 @@ fn compile_flow(state: &AppState, mut flow: crate::models::Flow) -> Result<(crat item.action.xfan = flow_bool(&action_node.config, "xfan"); item.action.health = flow_bool(&action_node.config, "health"); item.action.sleep = flow_bool(&action_node.config, "sleep"); - engine::validate_command(&item.action)?; if item.action.is_empty() { return Err(AppError::BadRequest("device action cannot be empty".into())); } + engine::validate_command(&item.action)?; + if item.action.is_empty() { + return Err(AppError::BadRequest("device action cannot be empty".into())); + } } "ha_service_action" => { - let domain = flow_string(&action_node.config, "domain").ok_or_else(|| AppError::BadRequest("Home Assistant action needs a domain".into()))?; - let service = flow_string(&action_node.config, "service").ok_or_else(|| AppError::BadRequest("Home Assistant action needs a service".into()))?; + let domain = flow_string(&action_node.config, "domain").ok_or_else(|| { + AppError::BadRequest("Home Assistant action needs a domain".into()) + })?; + let service = flow_string(&action_node.config, "service").ok_or_else(|| { + AppError::BadRequest("Home Assistant action needs a service".into()) + })?; item.action_ha_domain = Some(domain); item.action_ha_service = Some(service); item.action_ha_entity_id = flow_string(&action_node.config, "entity_id"); - item.action_ha_data = action_node.config.get("data").cloned().unwrap_or_else(|| json!({})); - if !item.action_ha_data.is_object() { return Err(AppError::BadRequest("Home Assistant service data must be a JSON object".into())); } + item.action_ha_data = action_node + .config + .get("data") + .cloned() + .unwrap_or_else(|| json!({})); + if !item.action_ha_data.is_object() { + return Err(AppError::BadRequest( + "Home Assistant service data must be a JSON object".into(), + )); + } } "group_action" => { - let id = flow_string(&action_node.config, "group_id").ok_or_else(|| AppError::BadRequest("group action needs a group".into()))?; - if !groups.iter().any(|g| g.id == id) { return Err(AppError::BadRequest("group action references a missing group".into())); } - item.action_group_id = Some(id); item.action.power = flow_bool(&action_node.config, "power"); item.action.mode = flow_string(&action_node.config, "mode"); item.action_preset = flow_string(&action_node.config, "preset"); + let id = flow_string(&action_node.config, "group_id") + .ok_or_else(|| AppError::BadRequest("group action needs a group".into()))?; + if !groups.iter().any(|g| g.id == id) { + return Err(AppError::BadRequest( + "group action references a missing group".into(), + )); + } + item.action_group_id = Some(id); + item.action.power = flow_bool(&action_node.config, "power"); + item.action.mode = flow_string(&action_node.config, "mode"); + item.action_preset = flow_string(&action_node.config, "preset"); if let Some(mode) = item.action.mode.as_deref() { - if !matches!(mode, "auto" | "house" | "cool" | "heat") { return Err(AppError::BadRequest("unsupported Flow group mode".into())); } + if !matches!(mode, "auto" | "house" | "cool" | "heat") { + return Err(AppError::BadRequest("unsupported Flow group mode".into())); + } } if let Some(preset) = item.action_preset.as_deref() { - if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported Flow group preset".into())); } + if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") { + return Err(AppError::BadRequest("unsupported Flow group preset".into())); + } if preset == "custom" { - let target = flow_f64(&action_node.config, "setpoint").ok_or_else(|| AppError::BadRequest("custom Flow group preset needs a target".into()))?; - if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("custom Flow group target must be between 8 and 30 C".into())); } + let target = + flow_f64(&action_node.config, "setpoint").ok_or_else(|| { + AppError::BadRequest( + "custom Flow group preset needs a target".into(), + ) + })?; + if !(8.0..=30.0).contains(&target) { + return Err(AppError::BadRequest( + "custom Flow group target must be between 8 and 30 C".into(), + )); + } item.action.target_temperature = Some(target); } } - if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.is_none() { return Err(AppError::BadRequest("group action cannot be empty".into())); } + if item.action.power.is_none() + && item.action.mode.is_none() + && item.action_preset.is_none() + { + return Err(AppError::BadRequest("group action cannot be empty".into())); + } } _ => unreachable!(), } automations.push(item); } - let existing: Vec<_> = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() != Some(flow.id.as_str())).collect(); + let existing: Vec<_> = state + .db + .list_schedules()? + .into_iter() + .filter(|s| s.flow_id.as_deref() != Some(flow.id.as_str())) + .collect(); for candidate in &schedules { - for other in existing.iter().chain(schedules.iter().filter(|s| s.id != candidate.id)) { - if engine::schedules_overlap(candidate, other) { return Err(AppError::BadRequest(format!("flow schedule '{}' overlaps with '{}'", candidate.name, other.name))); } + for other in existing + .iter() + .chain(schedules.iter().filter(|s| s.id != candidate.id)) + { + if engine::schedules_overlap(candidate, other) { + return Err(AppError::BadRequest(format!( + "flow schedule '{}' overlaps with '{}'", + candidate.name, other.name + ))); + } } } flow.compiled_schedule_ids = schedules.iter().map(|s| s.id.clone()).collect(); flow.compiled_automation_ids = automations.iter().map(|a| a.id.clone()).collect(); - flow.summary = format!("{} bloków · {} harmonogramów · {} automatyzacji", flow.nodes.len(), schedules.len(), automations.len()); + flow.summary = format!( + "{} bloków · {} harmonogramów · {} automatyzacji", + flow.nodes.len(), + schedules.len(), + automations.len() + ); Ok((flow, schedules, automations)) } -fn flow_from_input(id: String, input: FlowInput, created_at: chrono::DateTime, revision: u64) -> crate::models::Flow { +fn flow_from_input( + id: String, + input: FlowInput, + created_at: chrono::DateTime, + revision: u64, +) -> crate::models::Flow { let draft = input.draft; crate::models::Flow { - id, name: input.name.trim().into(), enabled: if draft { false } else { input.enabled }, draft, - description: input.description.trim().into(), nodes: input.nodes, edges: input.edges, - summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision, created_at, updated_at: Utc::now(), + id, + name: input.name.trim().into(), + enabled: if draft { false } else { input.enabled }, + draft, + description: input.description.trim().into(), + nodes: input.nodes, + edges: input.edges, + summary: String::new(), + compiled_schedule_ids: vec![], + compiled_automation_ids: vec![], + revision, + created_at, + updated_at: Utc::now(), } } @@ -606,61 +1445,156 @@ fn prepare_draft_flow(mut flow: crate::models::Flow) -> crate::models::Flow { flow } -async fn list_flows(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_flows()?)) } -async fn get_flow(State(state): State, Path(id): Path) -> Result, AppError> { state.db.get_flow(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("flow {id}"))) } +async fn list_flows( + State(state): State, +) -> Result>, AppError> { + Ok(Json(state.db.list_flows()?)) +} +async fn get_flow( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + state + .db + .get_flow(&id)? + .map(Json) + .ok_or_else(|| AppError::NotFound(format!("flow {id}"))) +} -async fn create_flow(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { - if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; } +async fn create_flow( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AppError> { + if input.draft { + validate_flow_draft_graph(&input)?; + } else { + validate_flow_graph(&input)?; + } let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1); - let (flow, schedules, automations) = if flow.draft { (prepare_draft_flow(flow), vec![], vec![]) } else { compile_flow(&state, flow)? }; - state.db.replace_flow_outputs(&flow, &schedules, &automations)?; - for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::>() { refresh_zone_override_boundary(&state, zone_id).await?; } + let (flow, schedules, automations) = if flow.draft { + (prepare_draft_flow(flow), vec![], vec![]) + } else { + compile_flow(&state, flow)? + }; + state + .db + .replace_flow_outputs(&flow, &schedules, &automations)?; + for zone_id in schedules + .iter() + .map(|s| s.zone_id.as_str()) + .collect::>() + { + refresh_zone_override_boundary(&state, zone_id).await?; + } state.log("info", "flow.created", &format!("Created Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision, "schedules": schedules.len(), "automations": automations.len()})); - state.broadcast("flow.created", serde_json::to_value(&flow)?); state.wake_zone_control(); + state.broadcast("flow.created", serde_json::to_value(&flow)?); + state.wake_zone_control(); Ok((StatusCode::CREATED, Json(flow))) } -async fn update_flow(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { - if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; } +async fn update_flow( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, AppError> { + if input.draft { + validate_flow_draft_graph(&input)?; + } else { + validate_flow_graph(&input)?; + } let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; - let existing = state.db.get_flow(&id)?.ok_or_else(|| AppError::NotFound(format!("flow {id}")))?; - let expected = input.expected_revision.ok_or_else(|| AppError::BadRequest("Flow update requires expected_revision".into()))?; - if expected != existing.revision { return Err(AppError::Conflict(format!("flow {id} changed; expected revision {expected}, current revision {}", existing.revision))); } - let old_zone_ids: std::collections::HashSet = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() == Some(id.as_str())).map(|s| s.zone_id).collect(); + let existing = state + .db + .get_flow(&id)? + .ok_or_else(|| AppError::NotFound(format!("flow {id}")))?; + let expected = input + .expected_revision + .ok_or_else(|| AppError::BadRequest("Flow update requires expected_revision".into()))?; + if expected != existing.revision { + return Err(AppError::Conflict(format!( + "flow {id} changed; expected revision {expected}, current revision {}", + existing.revision + ))); + } + let old_zone_ids: std::collections::HashSet = state + .db + .list_schedules()? + .into_iter() + .filter(|s| s.flow_id.as_deref() == Some(id.as_str())) + .map(|s| s.zone_id) + .collect(); let next_revision = existing.revision.saturating_add(1).max(1); let flow = flow_from_input(id, input, existing.created_at, next_revision); - let (flow, schedules, automations) = if flow.draft { (prepare_draft_flow(flow), vec![], vec![]) } else { compile_flow(&state, flow)? }; - state.db.replace_flow_outputs(&flow, &schedules, &automations)?; - let mut zone_ids = old_zone_ids; zone_ids.extend(schedules.iter().map(|s| s.zone_id.clone())); - for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; } + let (flow, schedules, automations) = if flow.draft { + (prepare_draft_flow(flow), vec![], vec![]) + } else { + compile_flow(&state, flow)? + }; + state + .db + .replace_flow_outputs(&flow, &schedules, &automations)?; + let mut zone_ids = old_zone_ids; + zone_ids.extend(schedules.iter().map(|s| s.zone_id.clone())); + for zone_id in zone_ids { + refresh_zone_override_boundary(&state, &zone_id).await?; + } state.log("info", "flow.updated", &format!("Updated Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision, "schedules": schedules.len(), "automations": automations.len()})); - state.broadcast("flow.updated", serde_json::to_value(&flow)?); state.wake_zone_control(); + state.broadcast("flow.updated", serde_json::to_value(&flow)?); + state.wake_zone_control(); Ok(Json(flow)) } -async fn delete_flow(State(state): State, Path(id): Path) -> Result { +async fn delete_flow( + State(state): State, + Path(id): Path, +) -> Result { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; - let zone_ids: std::collections::HashSet = state.db.list_schedules()?.into_iter().filter(|s| s.flow_id.as_deref() == Some(id.as_str())).map(|s| s.zone_id).collect(); - let existing = state.db.get_flow(&id)?.ok_or_else(|| AppError::NotFound(format!("flow {id}")))?; - if !state.db.delete_flow(&id)? { return Err(AppError::NotFound(format!("flow {id}"))); } - for zone_id in zone_ids { refresh_zone_override_boundary(&state, &zone_id).await?; } - state.log("info", "flow.deleted", &format!("Deleted Flow {}", existing.name), json!({"flow_id": id})); - state.broadcast("flow.deleted", json!({"id": id})); state.wake_zone_control(); + let zone_ids: std::collections::HashSet = state + .db + .list_schedules()? + .into_iter() + .filter(|s| s.flow_id.as_deref() == Some(id.as_str())) + .map(|s| s.zone_id) + .collect(); + let existing = state + .db + .get_flow(&id)? + .ok_or_else(|| AppError::NotFound(format!("flow {id}")))?; + if !state.db.delete_flow(&id)? { + return Err(AppError::NotFound(format!("flow {id}"))); + } + for zone_id in zone_ids { + refresh_zone_override_boundary(&state, &zone_id).await?; + } + state.log( + "info", + "flow.deleted", + &format!("Deleted Flow {}", existing.name), + json!({"flow_id": id}), + ); + state.broadcast("flow.deleted", json!({"id": id})); + state.wake_zone_control(); Ok(StatusCode::NO_CONTENT) } -async fn export_flow(State(state): State, Path(id): Path) -> Result, AppError> { - let flow = state.db.get_flow(&id)?.ok_or_else(|| AppError::NotFound(format!("flow {id}")))?; +async fn export_flow( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + let flow = state + .db + .get_flow(&id)? + .ok_or_else(|| AppError::NotFound(format!("flow {id}")))?; Ok(Json(json!({ "format": "gree-controller-flow", "version": 1, @@ -676,48 +1610,120 @@ async fn export_flow(State(state): State, Path(id): Path) -> R }))) } -async fn import_flow(State(state): State, Json(document): Json) -> Result<(StatusCode, Json), AppError> { +async fn import_flow( + State(state): State, + Json(document): Json, +) -> Result<(StatusCode, Json), AppError> { if let Some(format) = document.get("format").and_then(Value::as_str) { - if format != "gree-controller-flow" { return Err(AppError::BadRequest("unsupported Flow import format".into())); } + if format != "gree-controller-flow" { + return Err(AppError::BadRequest( + "unsupported Flow import format".into(), + )); + } } let payload = document.get("flow").cloned().unwrap_or(document); - let mut input: FlowInput = serde_json::from_value(payload).map_err(|err| AppError::BadRequest(format!("invalid Flow import: {err}")))?; + let mut input: FlowInput = serde_json::from_value(payload) + .map_err(|err| AppError::BadRequest(format!("invalid Flow import: {err}")))?; input.expected_revision = None; - if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; } + if input.draft { + validate_flow_draft_graph(&input)?; + } else { + validate_flow_graph(&input)?; + } let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let flow = flow_from_input(Uuid::new_v4().to_string(), input, Utc::now(), 1); - let (flow, schedules, automations) = if flow.draft { (prepare_draft_flow(flow), vec![], vec![]) } else { compile_flow(&state, flow)? }; - state.db.replace_flow_outputs(&flow, &schedules, &automations)?; - for zone_id in schedules.iter().map(|s| s.zone_id.as_str()).collect::>() { refresh_zone_override_boundary(&state, zone_id).await?; } - state.log("info", "flow.imported", &format!("Imported Flow {}", flow.name), json!({"flow_id": flow.id, "revision": flow.revision})); - state.broadcast("flow.created", serde_json::to_value(&flow)?); state.wake_zone_control(); + let (flow, schedules, automations) = if flow.draft { + (prepare_draft_flow(flow), vec![], vec![]) + } else { + compile_flow(&state, flow)? + }; + state + .db + .replace_flow_outputs(&flow, &schedules, &automations)?; + for zone_id in schedules + .iter() + .map(|s| s.zone_id.as_str()) + .collect::>() + { + refresh_zone_override_boundary(&state, zone_id).await?; + } + state.log( + "info", + "flow.imported", + &format!("Imported Flow {}", flow.name), + json!({"flow_id": flow.id, "revision": flow.revision}), + ); + state.broadcast("flow.created", serde_json::to_value(&flow)?); + state.wake_zone_control(); Ok((StatusCode::CREATED, Json(flow))) } -fn dry_run_block_reason(state: &AppState, action: &crate::models::FlowNode) -> Result, AppError> { +fn dry_run_block_reason( + state: &AppState, + action: &crate::models::FlowNode, +) -> Result, AppError> { match action.kind.as_str() { "zone_thermostat" => { - let Some(zone_id) = flow_string(&action.config, "zone_id") else { return Ok(Some("missing_zone".into())); }; - let Some(zone) = state.db.get_zone(&zone_id)? else { return Ok(Some("missing_zone".into())); }; - if state.db.get_device(&zone.device_id)?.is_none() { return Ok(Some("missing_device".into())); } - if zone.device_manual_override { return Ok(Some("device_manual_override".into())); } - if zone.local_thermostat_power.is_some() { return Ok(Some("local_thermostat_override".into())); } - if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { return Ok(Some("temporary_quick_thermostat".into())); } - if !zone.enabled && flow_bool(&action.config, "power") != Some(true) { return Ok(Some("zone_disabled".into())); } + let Some(zone_id) = flow_string(&action.config, "zone_id") else { + return Ok(Some("missing_zone".into())); + }; + let Some(zone) = state.db.get_zone(&zone_id)? else { + return Ok(Some("missing_zone".into())); + }; + if state.db.get_device(&zone.device_id)?.is_none() { + return Ok(Some("missing_device".into())); + } + if zone.device_manual_override { + return Ok(Some("device_manual_override".into())); + } + if zone.local_thermostat_power.is_some() { + return Ok(Some("local_thermostat_override".into())); + } + if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { + return Ok(Some("temporary_quick_thermostat".into())); + } + if !zone.enabled && flow_bool(&action.config, "power") != Some(true) { + return Ok(Some("zone_disabled".into())); + } Ok(None) } "device_action" => { - let Some(device_id) = flow_string(&action.config, "device_id") else { return Ok(Some("missing_device".into())); }; - let Some(device) = state.db.get_device(&device_id)? else { return Ok(Some("missing_device".into())); }; - if !device.enabled { return Ok(Some("device_disabled".into())); } + let Some(device_id) = flow_string(&action.config, "device_id") else { + return Ok(Some("missing_device".into())); + }; + let Some(device) = state.db.get_device(&device_id)? else { + return Ok(Some("missing_device".into())); + }; + if !device.enabled { + return Ok(Some("device_disabled".into())); + } let zones = state.db.list_zones()?; - if zones.iter().any(|z| z.device_id == device_id && z.device_manual_override) { return Ok(Some("device_manual_override".into())); } - if zones.iter().any(|z| z.device_id == device_id && z.local_thermostat_power.is_some()) { return Ok(Some("local_thermostat_override".into())); } - if zones.iter().any(|z| z.device_id == device_id && engine::temporary_quick_thermostat_is_active(z, Utc::now())) { return Ok(Some("temporary_quick_thermostat".into())); } - if zones.iter().any(|z| z.device_id == device_id && !z.enabled) && flow_bool(&action.config, "power") != Some(true) { return Ok(Some("zone_disabled".into())); } + if zones + .iter() + .any(|z| z.device_id == device_id && z.device_manual_override) + { + return Ok(Some("device_manual_override".into())); + } + if zones + .iter() + .any(|z| z.device_id == device_id && z.local_thermostat_power.is_some()) + { + return Ok(Some("local_thermostat_override".into())); + } + if zones.iter().any(|z| { + z.device_id == device_id + && engine::temporary_quick_thermostat_is_active(z, Utc::now()) + }) { + return Ok(Some("temporary_quick_thermostat".into())); + } + if zones.iter().any(|z| z.device_id == device_id && !z.enabled) + && flow_bool(&action.config, "power") != Some(true) + { + return Ok(Some("zone_disabled".into())); + } let mut command = DeviceCommand::default(); command.power = flow_bool(&action.config, "power"); command.mode = flow_string(&action.config, "mode"); @@ -732,17 +1738,27 @@ fn dry_run_block_reason(state: &AppState, action: &crate::models::FlowNode) -> R command.xfan = flow_bool(&action.config, "xfan"); command.health = flow_bool(&action.config, "health"); command.sleep = flow_bool(&action.config, "sleep"); - if zones.iter().any(|z| z.device_id == device_id) && engine::automation_action_conflicts_with_thermostat(&command) { + if zones.iter().any(|z| z.device_id == device_id) + && engine::automation_action_conflicts_with_thermostat(&command) + { return Ok(Some("thermostat_owner_conflict".into())); } Ok(None) } "group_action" => { - let Some(group_id) = flow_string(&action.config, "group_id") else { return Ok(Some("missing_group".into())); }; - let Some(group) = state.db.get_group(&group_id)? else { return Ok(Some("missing_group".into())); }; - let climate_change = flow_string(&action.config, "mode").is_some() || flow_string(&action.config, "preset").is_some(); - let resulting_enabled = flow_bool(&action.config, "power").unwrap_or(group.power_enabled); - if climate_change && !resulting_enabled { return Ok(Some("group_control_disabled".into())); } + let Some(group_id) = flow_string(&action.config, "group_id") else { + return Ok(Some("missing_group".into())); + }; + let Some(group) = state.db.get_group(&group_id)? else { + return Ok(Some("missing_group".into())); + }; + let climate_change = flow_string(&action.config, "mode").is_some() + || flow_string(&action.config, "preset").is_some(); + let resulting_enabled = + flow_bool(&action.config, "power").unwrap_or(group.power_enabled); + if climate_change && !resulting_enabled { + return Ok(Some("group_control_disabled".into())); + } Ok(None) } "ha_service_action" => Ok(None), @@ -750,28 +1766,69 @@ fn dry_run_block_reason(state: &AppState, action: &crate::models::FlowNode) -> R } } -async fn simulate_flow(State(state): State, Json(input): Json) -> Result, AppError> { +async fn simulate_flow( + State(state): State, + Json(input): Json, +) -> Result, AppError> { validate_flow_graph(&input.flow)?; let at = match input.at.as_deref().map(str::trim).filter(|v| !v.is_empty()) { - Some(value) => chrono::DateTime::parse_from_rfc3339(value).map_err(|_| AppError::BadRequest("simulation time must be RFC3339".into()))?.with_timezone(&chrono::Local), + Some(value) => chrono::DateTime::parse_from_rfc3339(value) + .map_err(|_| AppError::BadRequest("simulation time must be RFC3339".into()))? + .with_timezone(&chrono::Local), None => chrono::Local::now(), }; - let preview_id = input.flow_id.clone().filter(|v| !v.trim().is_empty()).unwrap_or_else(|| format!("dry-run-{}", Uuid::new_v4())); + let preview_id = input + .flow_id + .clone() + .filter(|v| !v.trim().is_empty()) + .unwrap_or_else(|| format!("dry-run-{}", Uuid::new_v4())); let preview = crate::models::Flow { - id: preview_id.clone(), name: input.flow.name.trim().into(), enabled: input.flow.enabled, draft: false, description: input.flow.description.trim().into(), - nodes: input.flow.nodes.clone(), edges: input.flow.edges.clone(), summary: String::new(), compiled_schedule_ids: vec![], compiled_automation_ids: vec![], revision: 0, - created_at: Utc::now(), updated_at: Utc::now(), + id: preview_id.clone(), + name: input.flow.name.trim().into(), + enabled: input.flow.enabled, + draft: false, + description: input.flow.description.trim().into(), + nodes: input.flow.nodes.clone(), + edges: input.flow.edges.clone(), + summary: String::new(), + compiled_schedule_ids: vec![], + compiled_automation_ids: vec![], + revision: 0, + created_at: Utc::now(), + updated_at: Utc::now(), }; let (compiled, schedules, automations) = compile_flow(&state, preview)?; let devices = state.db.list_devices()?; let mut actions = Vec::new(); - for action in input.flow.nodes.iter().filter(|node| flow_action_kind(&node.kind)) { + for action in input + .flow + .nodes + .iter() + .filter(|node| flow_action_kind(&node.kind)) + { let program = compile_flow_program(&action.id, &input.flow.nodes, &input.flow.edges)?; let automation_id = format!("flow:{preview_id}:automation:{}", action.id); - let mut runtime = state.db.get_automation(&automation_id)?.map(|item| item.flow_runtime).unwrap_or_default(); - let (matched, trace) = engine::evaluate_flow_conditions_trace(&state, &devices, &program, at.clone(), &input.overrides, Some(&mut runtime)).await?; - let blocked_reason = if matched && !input.flow.enabled { Some("flow_disabled".into()) } - else if matched { dry_run_block_reason(&state, action)? } else { None }; + let mut runtime = state + .db + .get_automation(&automation_id)? + .map(|item| item.flow_runtime) + .unwrap_or_default(); + let (matched, trace) = engine::evaluate_flow_conditions_trace( + &state, + &devices, + &program, + at.clone(), + &input.overrides, + Some(&mut runtime), + ) + .await?; + let blocked_reason = if matched && !input.flow.enabled { + Some("flow_disabled".into()) + } else if matched { + dry_run_block_reason(&state, action)? + } else { + None + }; actions.push(json!({ "node_id": action.id, "kind": action.kind, @@ -799,13 +1856,30 @@ async fn simulate_flow(State(state): State, Json(input): Json, Path(id): Path, Query(query): Query) -> Result, AppError> { - if state.db.get_flow(&id)?.is_none() { return Err(AppError::NotFound(format!("flow {id}"))); } +async fn flow_logs( + State(state): State, + Path(id): Path, + Query(query): Query, +) -> Result, AppError> { + if state.db.get_flow(&id)?.is_none() { + return Err(AppError::NotFound(format!("flow {id}"))); + } let limit = query.limit.unwrap_or(100).clamp(1, 250) as usize; - let events = state.db.list_events(1000)?.into_iter().filter(|event| { - event.metadata.get("flow_id").and_then(Value::as_str) == Some(id.as_str()) - || event.metadata.get("automation_id").and_then(Value::as_str).map(|value| value.starts_with(&format!("flow:{id}:"))).unwrap_or(false) - }).take(limit).collect::>(); + let events = state + .db + .list_events(1000)? + .into_iter() + .filter(|event| { + event.metadata.get("flow_id").and_then(Value::as_str) == Some(id.as_str()) + || event + .metadata + .get("automation_id") + .and_then(Value::as_str) + .map(|value| value.starts_with(&format!("flow:{id}:"))) + .unwrap_or(false) + }) + .take(limit) + .collect::>(); Ok(Json(json!({"events": events}))) } @@ -841,7 +1915,11 @@ mod flow_draft_tests { #[test] fn draft_graph_still_rejects_missing_edge_endpoints() { let mut input = unfinished_input(); - input.edges.push(crate::models::FlowEdge { id: "edge".into(), from: "condition".into(), to: "missing".into() }); + input.edges.push(crate::models::FlowEdge { + id: "edge".into(), + from: "condition".into(), + to: "missing".into(), + }); assert!(validate_flow_draft_graph(&input).is_err()); } diff --git a/src/api/groups.rs b/src/api/groups.rs index 465a61c..b3335c6 100644 --- a/src/api/groups.rs +++ b/src/api/groups.rs @@ -8,7 +8,8 @@ struct GroupInput { } fn normalize_group_zone_ids(zone_ids: Vec) -> Vec { - let mut values: Vec = zone_ids.into_iter() + let mut values: Vec = zone_ids + .into_iter() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .collect(); @@ -23,11 +24,15 @@ fn validate_group_input(state: &AppState, input: &GroupInput) -> Result) -> Result, Path(id): Path) -> Result, AppError> { - state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}"))) +async fn get_group( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + state + .db + .get_group(&id)? + .map(Json) + .ok_or_else(|| AppError::NotFound(format!("group {id}"))) } -async fn create_group(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { +async fn create_group( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _reference_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; @@ -62,7 +77,11 @@ async fn create_group(State(state): State, Json(input): Json, Path(id): Path, Json(input): Json) -> Result, AppError> { +async fn update_group( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; // Membership changes alter the target set of group automations, so serialize them with // automation execution/reference validation before taking the group lock. @@ -70,7 +89,10 @@ async fn update_group(State(state): State, Path(id): Path, Jso let _house_guard = state.lock_house_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let _group_guard = state.lock_group_operation(&id).await; - let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?; + let existing = state + .db + .get_group(&id)? + .ok_or_else(|| AppError::NotFound(format!("group {id}")))?; let zone_ids = validate_group_input(&state, &input)?; let group = ClimateGroup { id, @@ -86,46 +108,90 @@ async fn update_group(State(state): State, Path(id): Path, Jso Ok(Json(group)) } -async fn delete_group(State(state): State, Path(id): Path) -> Result { +async fn delete_group( + State(state): State, + Path(id): Path, +) -> Result { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let _group_guard = state.lock_group_operation(&id).await; - if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) { - return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into())); + if state + .db + .list_automations()? + .iter() + .any(|item| item.action_group_id.as_deref() == Some(id.as_str())) + { + return Err(AppError::BadRequest( + "group is used by an automation; remove or retarget that automation first".into(), + )); + } + if !state.db.delete_group(&id)? { + return Err(AppError::NotFound(format!("group {id}"))); } - if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); } state.broadcast("group.deleted", json!({"id": id})); state.wake_zone_control(); Ok(StatusCode::NO_CONTENT) } -fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashSet) -> Result<(), AppError> { - if zone_ids.is_empty() { return Ok(()); } - let automated_groups: std::collections::HashSet = state.db.list_automations()?.into_iter() +fn ensure_zone_removal_safe( + state: &AppState, + zone_ids: &std::collections::HashSet, +) -> Result<(), AppError> { + if zone_ids.is_empty() { + return Ok(()); + } + let automated_groups: std::collections::HashSet = state + .db + .list_automations()? + .into_iter() .filter_map(|item| item.action_group_id) .collect(); for group in state.db.list_groups()? { - let remaining = group.zone_ids.iter().filter(|zone_id| !zone_ids.contains(*zone_id)).count(); - if remaining == 0 && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id)) && automated_groups.contains(&group.id) { + let remaining = group + .zone_ids + .iter() + .filter(|zone_id| !zone_ids.contains(*zone_id)) + .count(); + if remaining == 0 + && group + .zone_ids + .iter() + .any(|zone_id| zone_ids.contains(zone_id)) + && automated_groups.contains(&group.id) + { return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name))); } } Ok(()) } -async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::collections::HashSet) -> Result<(), AppError> { - if zone_ids.is_empty() { return Ok(()); } - let mut group_ids: Vec = state.db.list_groups()?.into_iter().map(|group| group.id).collect(); +async fn remove_zone_ids_from_groups_locked( + state: &AppState, + zone_ids: &std::collections::HashSet, +) -> Result<(), AppError> { + if zone_ids.is_empty() { + return Ok(()); + } + let mut group_ids: Vec = state + .db + .list_groups()? + .into_iter() + .map(|group| group.id) + .collect(); group_ids.sort(); group_ids.dedup(); for group_id in group_ids { let _group_guard = state.lock_group_operation(&group_id).await; - let Some(mut group) = state.db.get_group(&group_id)? else { continue; }; + let Some(mut group) = state.db.get_group(&group_id)? else { + continue; + }; let before = group.zone_ids.len(); group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id)); - if group.zone_ids.len() == before { continue; } + if group.zone_ids.len() == before { + continue; + } if group.zone_ids.is_empty() { state.db.delete_group(&group.id)?; state.broadcast("group.deleted", json!({"id": group.id})); @@ -138,19 +204,31 @@ async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::co Ok(()) } -async fn update_group_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { - Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?)) +async fn update_group_control( + State(state): State, + Path(id): Path, + Json(patch): Json, +) -> Result, AppError> { + Ok(Json( + engine::control_group(&state, &id, patch, "group.quick_control").await?, + )) } fn home_assistant_group_mode(zones: &[&Zone]) -> String { let mut value: Option<&str> = None; for zone in zones { - let current = if zone.inherit_house_mode { "house" } else { zone.mode.as_str() }; + let current = if zone.inherit_house_mode { + "house" + } else { + zone.mode.as_str() + }; if !matches!(current, "house" | "cool" | "heat") { return "mixed".into(); } if let Some(previous) = value { - if previous != current { return "mixed".into(); } + if previous != current { + return "mixed".into(); + } } else { value = Some(current); } @@ -166,7 +244,9 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String { return "mixed".into(); } if let Some(previous) = value { - if previous != current { return "mixed".into(); } + if previous != current { + return "mixed".into(); + } } else { value = Some(current); } @@ -174,14 +254,17 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String { value.unwrap_or("mixed").to_string() } - fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option { let mut value: Option = None; for zone in zones { - if zone.manual_preset.as_deref() != Some("custom") { return None; } + if zone.manual_preset.as_deref() != Some("custom") { + return None; + } let current = zone.manual_setpoint.or(zone.effective_setpoint)?; if let Some(previous) = value { - if (previous - current).abs() > 0.05 { return None; } + if (previous - current).abs() > 0.05 { + return None; + } } else { value = Some(current); } @@ -189,7 +272,9 @@ fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option { value } -async fn list_home_assistant_groups(State(state): State) -> Result>, AppError> { +async fn list_home_assistant_groups( + State(state): State, +) -> Result>, AppError> { let groups = state.db.list_groups()?; let zones = state.db.list_zones()?; let devices = state.db.list_devices()?; @@ -198,19 +283,35 @@ async fn list_home_assistant_groups(State(state): State) -> Result>(); - let planned_members = plan.zones.iter() - .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id)) + let planned_members = plan + .zones + .iter() + .filter(|zone| { + group + .zone_ids + .iter() + .any(|zone_id| zone_id == &zone.zone_id) + }) .collect::>(); - let zone_names = members.iter().map(|zone| zone.name.clone()).collect::>(); - let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::>(); - let online_devices = devices.iter() + let zone_names = members + .iter() + .map(|zone| zone.name.clone()) + .collect::>(); + let member_device_ids = members + .iter() + .map(|zone| zone.device_id.as_str()) + .collect::>(); + let online_devices = devices + .iter() .filter(|device| member_device_ids.contains(device.id.as_str()) && device.online) .count(); - let current_temperatures = planned_members.iter() + let current_temperatures = planned_members + .iter() .filter_map(|zone| zone.current_temperature) .collect::>(); let current_temperature = if current_temperatures.is_empty() { @@ -228,27 +329,32 @@ async fn list_home_assistant_groups(State(state): State) -> Result>(); + let member_states = planned_members + .iter() + .map(|zone| { + json!({ + "zone_id": zone.zone_id, + "zone_name": zone.zone_name, + "device_id": zone.device_id, + "device_name": zone.device_name, + "enabled": zone.enabled, + "effective_enabled": zone.effective_enabled, + "mode": zone.mode, + "configured_mode": zone.configured_mode, + "inherit_house_mode": zone.inherit_house_mode, + "preset": zone.preset, + "current_temperature": zone.current_temperature, + "target_temperature": zone.target_temperature, + "demand": zone.demand, + "control_source": zone.control_source, + "current_schedule": zone.current_schedule_name, + "local_thermostat_power": zone.local_thermostat_power, + "local_thermostat_resume_at": zone.local_thermostat_resume_at, + "device_manual_override": zone.device_manual_override, + "device_manual_override_until": zone.device_manual_override_until, + }) + }) + .collect::>(); output.push(json!({ "id": group.id, @@ -281,6 +387,7 @@ async fn update_home_assistant_group_control( Path(id): Path, Json(patch): Json, ) -> Result, AppError> { - Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?)) + Ok(Json( + engine::control_group(&state, &id, patch, "home_assistant.group_control").await?, + )) } - diff --git a/src/api/history.rs b/src/api/history.rs index 2d63a82..66a4e3b 100644 --- a/src/api/history.rs +++ b/src/api/history.rs @@ -1,8 +1,19 @@ #[derive(Debug, Deserialize)] -struct ReadingsQuery { device_id: Option, hours: Option, limit: Option } -async fn readings(State(state): State, Query(query): Query) -> Result, AppError> { +struct ReadingsQuery { + device_id: Option, + hours: Option, + limit: Option, +} +async fn readings( + State(state): State, + Query(query): Query, +) -> Result, AppError> { 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))?; + 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}))) } @@ -29,24 +40,27 @@ fn history_bucket_seconds(hours: i64) -> i64 { } fn fallback_zone_rows(zone: &Zone, device: &Device, readings: Vec) -> Vec { - readings.into_iter().map(|reading| ZoneReading { - id: reading.id, - zone_id: zone.id.clone(), - device_id: zone.device_id.clone(), - timestamp: reading.timestamp, - gree_temperature: reading.indoor_temperature, - external_temperature: None, - control_temperature: reading.indoor_temperature, - target_temperature: Some(reading.target_temperature), - device_setpoint: Some(reading.target_temperature), - outdoor_temperature: reading.outdoor_temperature, - power: reading.power, - mode: device.mode.clone(), - fan_speed: device.fan_speed, - demand: false, - control_source: "gree_history_fallback".into(), - active_preset: "history".into(), - }).collect() + readings + .into_iter() + .map(|reading| ZoneReading { + id: reading.id, + zone_id: zone.id.clone(), + device_id: zone.device_id.clone(), + timestamp: reading.timestamp, + gree_temperature: reading.indoor_temperature, + external_temperature: None, + control_temperature: reading.indoor_temperature, + target_temperature: Some(reading.target_temperature), + device_setpoint: Some(reading.target_temperature), + outdoor_temperature: reading.outdoor_temperature, + power: reading.power, + mode: device.mode.clone(), + fan_speed: device.fan_speed, + demand: false, + control_source: "gree_history_fallback".into(), + active_preset: "history".into(), + }) + .collect() } fn zone_history_with_fallback( @@ -56,23 +70,43 @@ fn zone_history_with_fallback( bucket_seconds: i64, limit: u32, ) -> Result, AppError> { - let mut values = state.db.list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?; + let mut values = state + .db + .list_zone_history(zone_id, since.clone(), bucket_seconds, limit)?; if let Some(zone_id) = zone_id { if values.is_empty() { - let zone = state.db.get_zone(zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; + let zone = state + .db + .get_zone(zone_id)? + .ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; if let Some(device) = state.db.get_device(&zone.device_id)? { - let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?; + let rows = state.db.list_device_history( + Some(&zone.device_id), + since.clone(), + bucket_seconds, + limit, + )?; values = fallback_zone_rows(&zone, &device, rows); } } return Ok(values); } - let existing: std::collections::HashSet = values.iter().map(|row| row.zone_id.clone()).collect(); + let existing: std::collections::HashSet = + values.iter().map(|row| row.zone_id.clone()).collect(); for zone in state.db.list_zones()? { - if existing.contains(&zone.id) { continue; } - let Some(device) = state.db.get_device(&zone.device_id)? else { continue; }; - let rows = state.db.list_device_history(Some(&zone.device_id), since.clone(), bucket_seconds, limit)?; + if existing.contains(&zone.id) { + continue; + } + let Some(device) = state.db.get_device(&zone.device_id)? else { + continue; + }; + let rows = state.db.list_device_history( + Some(&zone.device_id), + since.clone(), + bucket_seconds, + limit, + )?; values.extend(fallback_zone_rows(&zone, &device, rows)); } values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); @@ -90,33 +124,68 @@ fn sensor_history_with_fallback( limit: u32, outdoor_entity: &str, ) -> Result, AppError> { - let mut values = state.db.list_ha_history(None, since.clone(), bucket_seconds, limit)?; - let mut existing: std::collections::HashSet = values.iter().map(|row| row.entity_id.clone()).collect(); + let mut values = state + .db + .list_ha_history(None, since.clone(), bucket_seconds, limit)?; + let mut existing: std::collections::HashSet = + values.iter().map(|row| row.entity_id.clone()).collect(); for zone in state.db.list_zones()? { - let Some(entity_id) = zone.ha_entity_id.as_deref().filter(|value| !value.trim().is_empty()) else { continue; }; - if existing.contains(entity_id) { continue; } - let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; + let Some(entity_id) = zone + .ha_entity_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + continue; + }; + if existing.contains(entity_id) { + continue; + } + let rows = + state + .db + .list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; let mut added = false; for row in rows { if let Some(temperature) = row.external_temperature { - values.push(HaReading { id: row.id, entity_id: entity_id.to_string(), zone_id: Some(zone.id.clone()), kind: "room".into(), timestamp: row.timestamp, temperature }); + values.push(HaReading { + id: row.id, + entity_id: entity_id.to_string(), + zone_id: Some(zone.id.clone()), + kind: "room".into(), + timestamp: row.timestamp, + temperature, + }); added = true; } } - if added { existing.insert(entity_id.to_string()); } + if added { + existing.insert(entity_id.to_string()); + } } let outdoor_entity = outdoor_entity.trim(); if !outdoor_entity.is_empty() && !existing.contains(outdoor_entity) { for zone in state.db.list_zones()? { - let rows = state.db.list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; + let rows = + state + .db + .list_zone_history(Some(&zone.id), since.clone(), bucket_seconds, limit)?; let mut added = false; for row in rows { if let Some(temperature) = row.outdoor_temperature { - values.push(HaReading { id: row.id, entity_id: outdoor_entity.to_string(), zone_id: None, kind: "outdoor".into(), timestamp: row.timestamp, temperature }); + values.push(HaReading { + id: row.id, + entity_id: outdoor_entity.to_string(), + zone_id: None, + kind: "outdoor".into(), + timestamp: row.timestamp, + temperature, + }); added = true; } } - if added { break; } + if added { + break; + } } } values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); @@ -137,23 +206,54 @@ async fn combined_device_history( 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)); + 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 { + 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)? + 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.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" }; + let source = if warning.is_some() { + "sqlite_fallback" + } else { + "influx+sqlite" + }; Ok((values, source.into(), warning)) } @@ -167,23 +267,52 @@ async fn combined_zone_history( 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)); + 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 { + 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()})); + 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.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" }; + let source = if warning.is_some() { + "sqlite_fallback" + } else { + "influx+sqlite" + }; Ok((values, source.into(), warning)) } @@ -198,18 +327,38 @@ async fn combined_sensor_history( 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, 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 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 { + 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()})); + state.log( + "warn", + "influx.query_error", + "InfluxDB HA history query failed", + json!({"error": err.to_string()}), + ); local(since)? } }; @@ -218,7 +367,11 @@ async fn combined_sensor_history( } values.sort_by_key(|row| row.timestamp); trim_history(&mut values, limit); - let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; + let source = if warning.is_some() { + "sqlite_fallback" + } else { + "influx+sqlite" + }; Ok((values, source.into(), warning)) } @@ -229,19 +382,32 @@ fn trim_history(values: &mut Vec, limit: u32) { } } -async fn history(State(state): State, Query(query): Query) -> Result, AppError> { +async fn history( + State(state): State, + Query(query): Query, +) -> Result, AppError> { 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); let scope = query.scope.as_deref().unwrap_or("zones"); - let outdoor_entity = state.settings.read().await.home_assistant.outdoor_entity_id.clone(); + let outdoor_entity = state + .settings + .read() + .await + .home_assistant + .outdoor_entity_id + .clone(); let (device_count, zone_count, ha_count) = state.db.history_counts()?; match scope { "devices" => { - let device_id = query.device_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); - let (readings, storage, warning) = combined_device_history(&state, device_id, since, bucket_seconds, limit).await?; + let device_id = query + .device_id + .as_deref() + .filter(|value| !value.is_empty() && *value != "all"); + 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, @@ -249,8 +415,19 @@ async fn history(State(state): State, Query(query): Query { - let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); - let (readings, storage, warning) = combined_sensor_history(&state, entity_id, since, bucket_seconds, limit, &outdoor_entity).await?; + let entity_id = query + .entity_id + .as_deref() + .filter(|value| !value.is_empty() && *value != "all"); + 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, @@ -258,9 +435,19 @@ async fn history(State(state): State, Query(query): Query { - 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?; + 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?; let storage_warning = [zone_warning, device_warning, sensor_warning] .into_iter() .flatten() @@ -274,24 +461,31 @@ async fn history(State(state): State, Query(query): Query { - let zone_id = query.zone_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); + let zone_id = query + .zone_id + .as_deref() + .filter(|value| !value.is_empty() && *value != "all"); if let Some(zone_id) = zone_id { if state.db.get_zone(zone_id)?.is_none() { return Err(AppError::NotFound(format!("zone {zone_id}"))); } } - let (readings, storage, warning) = combined_zone_history(&state, zone_id, since, bucket_seconds, limit).await?; + 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} }))) } - _ => Err(AppError::BadRequest("history scope must be overview, zones, devices or sensors".into())), + _ => Err(AppError::BadRequest( + "history scope must be overview, zones, devices or sensors".into(), + )), } } async fn control_plan(State(state): State) -> Result, AppError> { - Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?)) + Ok(Json(serde_json::to_value( + engine::build_control_plan(&state).await?, + )?)) } - diff --git a/src/api/house.rs b/src/api/house.rs index a9c53e1..a9f639e 100644 --- a/src/api/house.rs +++ b/src/api/house.rs @@ -1,21 +1,36 @@ #[derive(Debug, Deserialize)] -struct HouseControlPatch { mode: String } - +struct HouseControlPatch { + mode: String, +} async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> { - let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); + let mut zone_ids: Vec = state + .db + .list_zones()? + .into_iter() + .map(|zone| zone.id) + .collect(); zone_ids.sort(); zone_ids.dedup(); for zone_id in zone_ids { let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(mut zone) = state.db.get_zone(&zone_id)? else { + continue; + }; let scoped_manual = zone.device_manual_override || zone.local_thermostat_power.is_some() || zone.control_source.starts_with("group:") || engine::temporary_quick_thermostat_is_active(&zone, Utc::now()); - if scoped_manual { continue; } - if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none() - && zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; } + if scoped_manual { + continue; + } + if zone.compressor_pending_action.is_none() + && zone.compressor_cancelled_action.is_none() + && zone.lockout_until.is_none() + && zone.lockout_reason.is_none() + { + continue; + } engine::rearm_compressor_queue(&mut zone); zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); @@ -25,15 +40,24 @@ async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<() Ok(()) } async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result { - let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); + let mut zone_ids: Vec = state + .db + .list_zones()? + .into_iter() + .map(|zone| zone.id) + .collect(); zone_ids.sort(); zone_ids.dedup(); let mut changed = 0usize; for zone_id in zone_ids { let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(mut zone) = state.db.get_zone(&zone_id)? else { + continue; + }; engine::rearm_compressor_queue(&mut zone); - if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; } + if engine::set_house_bulk_thermostat_power(&mut zone, power) { + changed += 1; + } engine::refresh_control_ownership(&mut zone); zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); @@ -43,10 +67,16 @@ async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result Ok(changed) } -async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result, AppError> { +async fn command_all_enabled_devices_power( + state: &AppState, + power: bool, + source: &str, +) -> Result, AppError> { let mut failed = Vec::new(); for device in state.db.list_devices()? { - if !device.enabled { continue; } + if !device.enabled { + continue; + } // The per-zone thermostat power state is persisted before these physical commands. // OFF is immediate; ON still respects compressor protection. let result = if power { @@ -55,12 +85,17 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source engine::force_house_power_off_device(state, &device.id, source).await }; if let Err(err) = result { - state.log("error", "house.power_all_error", &err.to_string(), json!({ - "device_id": device.id, - "device_name": device.name, - "power": power, - "source": source, - })); + state.log( + "error", + "house.power_all_error", + &err.to_string(), + json!({ + "device_id": device.id, + "device_name": device.name, + "power": power, + "source": source, + }), + ); failed.push(json!({ "device_id": device.id, "device_name": device.name, @@ -71,14 +106,19 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source Ok(failed) } -async fn update_house_control(State(state): State, Json(input): Json) -> Result, AppError> { +async fn update_house_control( + State(state): State, + Json(input): Json, +) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; // Serialize the ownership/configuration transition against an already-running thermostat // cycle. Otherwise a cycle that captured the previous house mode could send one stale // climate command after this interactive change. let cycle_guard = state.lock_zone_control_cycle().await; if !matches!(input.mode.as_str(), "cool" | "heat" | "off") { - return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); + return Err(AppError::BadRequest( + "house mode must be cool, heat or off".into(), + )); } let mode = input.mode; let activate_all = mode != "off"; @@ -101,14 +141,24 @@ async fn update_house_control(State(state): State, Json(input): Json, Json(input): Json) -> Result, AppError> { +async fn update_house_power( + State(state): State, + Json(input): Json, +) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; @@ -125,17 +175,22 @@ async fn update_house_power(State(state): State, Json(input): Json, Json(input): Json, Json(input): Json) -> Result, AppError> { +async fn update_house_preset( + State(state): State, + Json(input): Json, +) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; let cycle_guard = state.lock_zone_control_cycle().await; if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") { - return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into())); + return Err(AppError::BadRequest( + "house preset must be auto, comfort, sleep or away".into(), + )); } // A house profile applies to free house-controlled zones. Explicit local/group/direct @@ -160,7 +222,12 @@ async fn update_house_preset(State(state): State, Json(input): Json = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); + let mut zone_ids: Vec = state + .db + .list_zones()? + .into_iter() + .map(|zone| zone.id) + .collect(); zone_ids.sort(); zone_ids.dedup(); let mut _zone_guards = Vec::with_capacity(zone_ids.len()); @@ -169,9 +236,13 @@ async fn update_house_preset(State(state): State, Json(input): Json, Json(input): Json, Json(input): Json = Vec::new(); if let Err(err) = engine::run_zone_control_now(&state).await { - state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_preset"})); + state.log( + "error", + "house.immediate_control_error", + &err.to_string(), + json!({"source":"house_preset"}), + ); failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()})); } let devices = state.db.list_devices()?; - state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({ - "preset": input.preset, - "failed": failed.len(), - })); + state.log( + "info", + "house.preset", + &format!("House preset set to {}", input.preset), + json!({ + "preset": input.preset, + "failed": failed.len(), + }), + ); Ok(Json(json!({ "preset": input.preset, "zones": zones, @@ -222,22 +304,41 @@ async fn update_house_preset(State(state): State, Json(input): Json, Path(id): Path, Json(input): Json) -> Result, AppError> { +async fn apply_schedule_template( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; - let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let zone = state + .db + .get_zone(&id)? + .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut items: Vec = Vec::new(); let mut add = |name: &str, days: Vec, start: &str, end: &str, preset: &str| { items.push(Schedule { - id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true, - weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(), - setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None, + id: Uuid::new_v4().to_string(), + zone_id: id.clone(), + name: name.into(), + enabled: true, + weekdays: days, + start_time: start.into(), + end_time: end.into(), + preset: preset.into(), + setpoint: zone.setpoint, + created_at: Utc::now(), + updated_at: Utc::now(), + flow_id: None, + flow_node_id: None, }); }; - let all = vec![1,2,3,4,5,6,7]; + let all = vec![1, 2, 3, 4, 5, 6, 7]; match input.template.as_str() { "family" => { add("Comfort", all.clone(), "06:30", "22:30", "comfort"); @@ -252,8 +353,8 @@ async fn apply_schedule_template(State(state): State, Path(id): Path { - let weekdays = vec![1,2,3,4,5]; - let weekend = vec![6,7]; + let weekdays = vec![1, 2, 3, 4, 5]; + let weekend = vec![6, 7]; add("Morning", weekdays.clone(), "06:30", "08:00", "comfort"); add("Away", weekdays.clone(), "08:00", "16:00", "away"); add("Evening", weekdays.clone(), "16:00", "22:30", "comfort"); @@ -270,28 +371,45 @@ async fn apply_schedule_template(State(state): State, Path(id): Path, Path(id): Path, Json(patch): Json) -> Result, AppError> { - Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?)) +async fn update_home_assistant_zone_control( + State(state): State, + Path(id): Path, + Json(patch): Json, +) -> Result, AppError> { + Ok(Json( + apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?, + )) } -async fn delete_zone(State(state): State, Path(id): Path) -> Result { +async fn delete_zone( + State(state): State, + Path(id): Path, +) -> Result { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let zone_guard = state.lock_zone_operation(&id).await; - let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let zone = state + .db + .get_zone(&id)? + .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut removed = std::collections::HashSet::new(); removed.insert(id.clone()); ensure_zone_removal_safe(&state, &removed)?; ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?; - if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } + if !state.db.delete_zone(&id)? { + return Err(AppError::NotFound(format!("zone {id}"))); + } // Group control locks group first and zone second. Release the zone lock before taking // group locks so deletion cannot form the inverse zone -> group lock order. drop(zone_guard); @@ -299,4 +417,3 @@ async fn delete_zone(State(state): State, Path(id): Path) -> R state.broadcast("zone.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } - diff --git a/src/api/integrations.rs b/src/api/integrations.rs index 2b31c58..0091922 100644 --- a/src/api/integrations.rs +++ b/src/api/integrations.rs @@ -1,25 +1,53 @@ #[derive(Debug, Deserialize)] -struct HaTestRequest { entity_id: Option } -async fn test_home_assistant(State(state): State, Json(input): Json) -> Result, AppError> { +struct HaTestRequest { + entity_id: Option, +} +async fn test_home_assistant( + State(state): State, + Json(input): Json, +) -> Result, AppError> { let settings = state.settings.read().await.clone(); - let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref()); - let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref(), Some(settings.home_assistant.sensor_stale_after_seconds)) - .await.map_err(|e| AppError::Device(e.to_string()))?; - Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id}))) + let resolved_entity_id = + home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref()); + let temperature = home_assistant::read_temperature( + &state.http, + &settings.home_assistant, + resolved_entity_id.as_deref(), + Some(settings.home_assistant.sensor_stale_after_seconds), + ) + .await + .map_err(|e| AppError::Device(e.to_string()))?; + Ok(Json( + json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id}), + )) } - #[derive(Debug, Deserialize)] -struct HaEntityRequest { entity_id: String } +struct HaEntityRequest { + entity_id: String, +} -async fn inspect_home_assistant_entity(State(state): State, Json(input): Json) -> Result, AppError> { +async fn inspect_home_assistant_entity( + State(state): State, + Json(input): Json, +) -> Result, AppError> { let settings = state.settings.read().await.clone(); - let entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str())) - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?; - let payload = home_assistant::read_entity(&state.http, &settings.home_assistant, Some(entity_id.as_str())) - .await.map_err(|e| AppError::Device(e.to_string()))?; - let raw_state = payload.get("state").and_then(Value::as_str).unwrap_or_default().to_string(); + let entity_id = + home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str())) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?; + let payload = home_assistant::read_entity( + &state.http, + &settings.home_assistant, + Some(entity_id.as_str()), + ) + .await + .map_err(|e| AppError::Device(e.to_string()))?; + let raw_state = payload + .get("state") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); let available = !matches!(raw_state.as_str(), "unknown" | "unavailable" | ""); Ok(Json(json!({ "ok": true, @@ -32,13 +60,25 @@ async fn inspect_home_assistant_entity(State(state): State, Json(input }))) } -async fn test_notifications(State(state): State, Json(mut input): Json) -> Result, AppError> { +async fn test_notifications( + State(state): State, + Json(mut input): Json, +) -> Result, AppError> { let old = state.settings.read().await.notifications.clone(); - if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; } - if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; } - if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; } - if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; } - notifications::test(&state, input).await.map_err(AppError::Device)?; + if input.pushover_app_token.trim().is_empty() { + input.pushover_app_token = old.pushover_app_token; + } + if input.pushover_user_key.trim().is_empty() { + input.pushover_user_key = old.pushover_user_key; + } + if input.slack_webhook_url.trim().is_empty() { + input.slack_webhook_url = old.slack_webhook_url; + } + if input.discord_webhook_url.trim().is_empty() { + input.discord_webhook_url = old.discord_webhook_url; + } + notifications::test(&state, input) + .await + .map_err(AppError::Device)?; Ok(Json(json!({"ok": true}))) } - diff --git a/src/api/middleware.rs b/src/api/middleware.rs index f533987..ce29a93 100644 --- a/src/api/middleware.rs +++ b/src/api/middleware.rs @@ -5,16 +5,33 @@ async fn security_headers(request: Request, next: Next) -> Response { .headers() .get(header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.split(';').next().is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/html"))); + .is_some_and(|value| { + value + .split(';') + .next() + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/html")) + }); let headers = response.headers_mut(); - headers.insert(header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff")); - headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin")); + headers.insert( + header::HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("same-origin"), + ); if is_html { - headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN")); + headers.insert( + header::HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("SAMEORIGIN"), + ); headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'")); - headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()")); + headers.insert( + header::HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), + ); } if is_api { diff --git a/src/api/public_settings.rs b/src/api/public_settings.rs index 266d85b..6930784 100644 --- a/src/api/public_settings.rs +++ b/src/api/public_settings.rs @@ -61,4 +61,3 @@ fn public_settings(settings: &RuntimeSettings) -> Value { } }) } - diff --git a/src/api/schedules.rs b/src/api/schedules.rs index 1d7d922..2c6199e 100644 --- a/src/api/schedules.rs +++ b/src/api/schedules.rs @@ -11,39 +11,82 @@ struct ScheduleInput { preset: String, setpoint: f64, } -fn schedule_preset() -> String { "custom".into() } +fn schedule_preset() -> String { + "custom".into() +} impl ScheduleInput { fn validate(&self) -> Result<(), AppError> { - if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); } - if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); } - chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?; - chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?; - if !matches!(self.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported schedule preset".into())); } - if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); } + if self.name.trim().is_empty() { + return Err(AppError::BadRequest("schedule name is required".into())); + } + if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { + return Err(AppError::BadRequest( + "weekdays must contain numbers 1..7".into(), + )); + } + chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M") + .map_err(|_| AppError::BadRequest("invalid start time".into()))?; + chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M") + .map_err(|_| AppError::BadRequest("invalid end time".into()))?; + if !matches!( + self.preset.as_str(), + "comfort" | "sleep" | "away" | "custom" + ) { + return Err(AppError::BadRequest("unsupported schedule preset".into())); + } + if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { + return Err(AppError::BadRequest( + "schedule setpoint must be between 8 and 30 C".into(), + )); + } Ok(()) } fn into_schedule(self, id: String, created_at: chrono::DateTime) -> Schedule { - Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled, - weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time, - preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now(), flow_id: None, flow_node_id: None } + Schedule { + id, + zone_id: self.zone_id, + name: self.name.trim().into(), + enabled: self.enabled, + weekdays: self.weekdays, + start_time: self.start_time, + end_time: self.end_time, + preset: self.preset, + setpoint: self.setpoint, + created_at, + updated_at: Utc::now(), + flow_id: None, + flow_node_id: None, + } } } fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> { for (index, item) in items.iter().enumerate() { for other in items.iter().skip(index + 1) { if engine::schedules_overlap(item, other) { - return Err(AppError::BadRequest(format!("schedule '{}' overlaps with '{}' for the same zone", item.name, other.name))); + return Err(AppError::BadRequest(format!( + "schedule '{}' overlaps with '{}' for the same zone", + item.name, other.name + ))); } } } Ok(()) } -fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Option<&str>) -> Result<(), AppError> { +fn validate_schedule_conflicts( + state: &AppState, + item: &Schedule, + exclude_id: Option<&str>, +) -> Result<(), AppError> { for existing in state.db.list_schedules()? { - if exclude_id == Some(existing.id.as_str()) { continue; } + if exclude_id == Some(existing.id.as_str()) { + continue; + } if engine::schedules_overlap(item, &existing) { - return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name))); + return Err(AppError::BadRequest(format!( + "schedule overlaps with '{}'", + existing.name + ))); } } Ok(()) @@ -57,11 +100,21 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu } else { None }; - let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); }; - let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref() + let Some(mut zone) = state.db.get_zone(zone_id)? else { + return Ok(()); + }; + let has_temporary_schedule_boundary = zone + .temporary_quick_thermostat + .as_ref() .map(|session| session.finish_kind == "schedule_boundary") .unwrap_or(false); - if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override && !has_temporary_schedule_boundary { return Ok(()); } + if zone.manual_preset.is_none() + && zone.manual_setpoint.is_none() + && !zone.device_manual_override + && !has_temporary_schedule_boundary + { + return Ok(()); + } let schedules = state.db.list_schedules()?; let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); // An active Temporary Quick Thermostat explicitly owns its target until its own finish @@ -79,11 +132,14 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu zone.control_resume_at = None; } if has_temporary_schedule_boundary { - let reference = zone.temporary_quick_thermostat.as_ref() + let reference = zone + .temporary_quick_thermostat + .as_ref() .filter(|session| session.activated_at.is_none()) .map(|session| session.started_at.with_timezone(&chrono::Local)) .unwrap_or_else(chrono::Local::now); - let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference).or(Some(Utc::now())); + let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference) + .or(Some(Utc::now())); if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.expires_at = refreshed; } @@ -95,16 +151,30 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu Ok(()) } -async fn list_schedules(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_schedules()?)) } -async fn get_schedule(State(state): State, Path(id): Path) -> Result, AppError> { - state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}"))) +async fn list_schedules(State(state): State) -> Result>, AppError> { + Ok(Json(state.db.list_schedules()?)) } -async fn create_schedule(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { +async fn get_schedule( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + state + .db + .get_schedule(&id)? + .map(Json) + .ok_or_else(|| AppError::NotFound(format!("schedule {id}"))) +} +async fn create_schedule( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; input.validate()?; - if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } + if state.db.get_zone(&input.zone_id)?.is_none() { + return Err(AppError::BadRequest("schedule zone does not exist".into())); + } let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now()); validate_schedule_conflicts(&state, &item, None)?; state.db.save_schedule(&item)?; @@ -113,34 +183,60 @@ async fn create_schedule(State(state): State, Json(input): Json, Path(id): Path, Json(input): Json) -> Result, AppError> { +async fn update_schedule( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; input.validate()?; - let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; - if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; edit it in the Flow editor".into())); } - if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } + let existing = state + .db + .get_schedule(&id)? + .ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; + if existing.flow_id.is_some() { + return Err(AppError::BadRequest( + "this schedule is generated by Flow; edit it in the Flow editor".into(), + )); + } + if state.db.get_zone(&input.zone_id)?.is_none() { + return Err(AppError::BadRequest("schedule zone does not exist".into())); + } let old_zone_id = existing.zone_id.clone(); let item = input.into_schedule(id.clone(), existing.created_at); validate_schedule_conflicts(&state, &item, Some(&id))?; state.db.save_schedule(&item)?; refresh_zone_override_boundary(&state, &old_zone_id).await?; - if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id).await?; } + if item.zone_id != old_zone_id { + refresh_zone_override_boundary(&state, &item.zone_id).await?; + } state.broadcast("schedule.updated", serde_json::to_value(&item)?); state.wake_zone_control(); Ok(Json(item)) } -async fn delete_schedule(State(state): State, Path(id): Path) -> Result { +async fn delete_schedule( + State(state): State, + Path(id): Path, +) -> Result { let _configuration_guard = state.lock_configuration_operation().await; let _schedule_guard = state.lock_schedule_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; - let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; - if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; delete it from the Flow editor".into())); } - if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); } + let existing = state + .db + .get_schedule(&id)? + .ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; + if existing.flow_id.is_some() { + return Err(AppError::BadRequest( + "this schedule is generated by Flow; delete it from the Flow editor".into(), + )); + } + if !state.db.delete_schedule(&id)? { + return Err(AppError::NotFound(format!("schedule {id}"))); + } refresh_zone_override_boundary(&state, &existing.zone_id).await?; state.broadcast("schedule.deleted", json!({"id": id})); state.wake_zone_control(); Ok(StatusCode::NO_CONTENT) } - diff --git a/src/api/settings.rs b/src/api/settings.rs index 39373e3..105db5a 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -1,5 +1,7 @@ fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings { - ApplicationSettings { simulator_enabled: settings.simulator_enabled } + ApplicationSettings { + simulator_enabled: settings.simulator_enabled, + } } fn gree_settings(settings: &RuntimeSettings) -> GreeSettings { @@ -83,8 +85,16 @@ async fn update_application_settings( state.db.save_runtime_settings(&settings)?; application_settings(&settings) }; - state.log("info", "settings.application.updated", "Application settings updated", json!({"simulator_enabled": payload.simulator_enabled})); - state.broadcast("settings.application.updated", serde_json::to_value(&payload)?); + state.log( + "info", + "settings.application.updated", + "Application settings updated", + json!({"simulator_enabled": payload.simulator_enabled}), + ); + state.broadcast( + "settings.application.updated", + serde_json::to_value(&payload)?, + ); Ok(Json(payload)) } @@ -102,21 +112,33 @@ fn normalize_gree_settings(mut input: GreeSettings) -> Result() + input + .discovery_broadcast + .parse::() .map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?; } Ok(input) } async fn clear_compressor_runtime_after_settings_change(state: &AppState) -> Result<(), AppError> { - let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); + let mut zone_ids: Vec = state + .db + .list_zones()? + .into_iter() + .map(|zone| zone.id) + .collect(); zone_ids.sort(); zone_ids.dedup(); for zone_id in zone_ids { let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(mut zone) = state.db.get_zone(&zone_id)? else { + continue; + }; if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none() && zone.lockout_until.is_none() @@ -143,7 +165,8 @@ async fn update_gree_settings( let _cycle_guard = state.lock_zone_control_cycle().await; let (payload, compressor_changed) = { let mut settings = state.settings.write().await; - let compressor_changed = settings.compressor_protection_enabled != input.compressor_protection_enabled + let compressor_changed = settings.compressor_protection_enabled + != input.compressor_protection_enabled || settings.compressor_protection_seconds != input.compressor_protection_seconds; settings.controller_id = input.controller_id; settings.poll_interval_seconds = input.poll_interval_seconds; @@ -159,10 +182,15 @@ async fn update_gree_settings( if compressor_changed { clear_compressor_runtime_after_settings_change(&state).await?; } - state.log("info", "settings.gree.updated", "GREE settings updated", json!({ - "compressor_protection_enabled": payload.compressor_protection_enabled, - "compressor_protection_seconds": payload.compressor_protection_seconds - })); + state.log( + "info", + "settings.gree.updated", + "GREE settings updated", + json!({ + "compressor_protection_enabled": payload.compressor_protection_enabled, + "compressor_protection_seconds": payload.compressor_protection_seconds + }), + ); state.broadcast("settings.gree.updated", serde_json::to_value(&payload)?); state.wake_zone_control(); Ok(Json(payload)) @@ -188,11 +216,16 @@ async fn update_history_settings( history_settings(&settings) }; let removed = state.db.prune_events(payload.event_retention_days as i64)?; - state.log("info", "settings.history.updated", "History settings updated", json!({ - "retention_days": payload.retention_days, - "event_retention_days": payload.event_retention_days, - "event_rows_removed": removed - })); + state.log( + "info", + "settings.history.updated", + "History settings updated", + json!({ + "retention_days": payload.retention_days, + "event_retention_days": payload.event_retention_days, + "event_rows_removed": removed + }), + ); state.broadcast("settings.history.updated", serde_json::to_value(&payload)?); Ok(Json(payload)) } @@ -201,7 +234,10 @@ async fn get_influxdb_settings(State(state): State) -> Json Result { +fn apply_influxdb_update( + current: &InfluxDbSettings, + input: InfluxDbSettingsUpdate, +) -> Result { let mut next = InfluxDbSettings { enabled: input.enabled, version: input.version, @@ -214,8 +250,12 @@ fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpda token: current.token.clone(), history_threshold_days: input.history_threshold_days.clamp(1, 3650), }; - if let Some(password) = input.password { next.password = password; } - if let Some(token) = input.token { next.token = token; } + if let Some(password) = input.password { + next.password = password; + } + if let Some(token) = input.token { + next.token = token; + } influxdb::validate(&next).map_err(|err| AppError::BadRequest(err.to_string()))?; Ok(next) } @@ -232,24 +272,38 @@ async fn update_influxdb_settings( state.db.save_runtime_settings(&settings)?; influxdb_settings(&settings) }; - state.log("info", "settings.influxdb.updated", "InfluxDB settings updated", json!({"enabled": payload.enabled, "version": payload.version})); + state.log( + "info", + "settings.influxdb.updated", + "InfluxDB settings updated", + json!({"enabled": payload.enabled, "version": payload.version}), + ); state.broadcast("settings.influxdb.updated", serde_json::to_value(&payload)?); Ok(Json(payload)) } -async fn get_notification_settings(State(state): State) -> Json { +async fn get_notification_settings( + State(state): State, +) -> Json { Json(notification_settings(&*state.settings.read().await)) } -fn apply_notification_update(current: &NotificationSettings, mut input: NotificationSettingsUpdate) -> Result { +fn apply_notification_update( + current: &NotificationSettings, + mut input: NotificationSettingsUpdate, +) -> Result { input.cooldown_seconds = input.cooldown_seconds.clamp(30, 86_400); input.communication_failure_threshold = input.communication_failure_threshold.clamp(2, 100); input.target_timeout_minutes = input.target_timeout_minutes.clamp(5, 24 * 60); if !matches!(input.mode.as_str(), "problems" | "important") { - return Err(AppError::BadRequest("notification mode must be problems or important".into())); + return Err(AppError::BadRequest( + "notification mode must be problems or important".into(), + )); } if !matches!(input.provider.as_str(), "pushover" | "slack" | "discord") { - return Err(AppError::BadRequest("unsupported notification provider".into())); + return Err(AppError::BadRequest( + "unsupported notification provider".into(), + )); } let mut next = NotificationSettings { enabled: input.enabled, @@ -264,10 +318,18 @@ fn apply_notification_update(current: &NotificationSettings, mut input: Notifica target_timeout_minutes: input.target_timeout_minutes, alert_types: input.alert_types, }; - if let Some(value) = input.pushover_app_token { next.pushover_app_token = value; } - if let Some(value) = input.pushover_user_key { next.pushover_user_key = value; } - if let Some(value) = input.slack_webhook_url { next.slack_webhook_url = value; } - if let Some(value) = input.discord_webhook_url { next.discord_webhook_url = value; } + if let Some(value) = input.pushover_app_token { + next.pushover_app_token = value; + } + if let Some(value) = input.pushover_user_key { + next.pushover_user_key = value; + } + if let Some(value) = input.slack_webhook_url { + next.slack_webhook_url = value; + } + if let Some(value) = input.discord_webhook_url { + next.discord_webhook_url = value; + } Ok(next) } @@ -283,8 +345,16 @@ async fn update_notification_settings( state.db.save_runtime_settings(&settings)?; notification_settings(&settings) }; - state.log("info", "settings.notifications.updated", "Notification settings updated", json!({"enabled": payload.enabled, "provider": payload.provider})); - state.broadcast("settings.notifications.updated", serde_json::to_value(&payload)?); + state.log( + "info", + "settings.notifications.updated", + "Notification settings updated", + json!({"enabled": payload.enabled, "provider": payload.provider}), + ); + state.broadcast( + "settings.notifications.updated", + serde_json::to_value(&payload)?, + ); Ok(Json(payload)) } @@ -313,24 +383,37 @@ async fn update_night_settings( settings.night_mode = input.clone(); state.db.save_runtime_settings(&settings)?; } - state.log("info", "settings.night.updated", "Night mode settings updated", json!({"enabled": input.enabled})); + state.log( + "info", + "settings.night.updated", + "Night mode settings updated", + json!({"enabled": input.enabled}), + ); state.broadcast("settings.night.updated", serde_json::to_value(&input)?); state.wake_zone_control(); Ok(Json(input)) } -async fn get_home_assistant_settings(State(state): State) -> Json { +async fn get_home_assistant_settings( + State(state): State, +) -> Json { Json(home_assistant_settings(&*state.settings.read().await)) } fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) { - settings.sensor_aliases = settings.sensor_aliases + settings.sensor_aliases = settings + .sensor_aliases .iter() .filter_map(|(entity, alias)| { let entity = entity.trim(); let alias = alias.trim(); - if entity.is_empty() || alias.is_empty() { return None; } - Some((entity.chars().take(160).collect::(), alias.chars().take(80).collect::())) + if entity.is_empty() || alias.is_empty() { + return None; + } + Some(( + entity.chars().take(160).collect::(), + alias.chars().take(80).collect::(), + )) }) .collect(); } @@ -338,39 +421,69 @@ fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) { fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> { let mut ids = std::collections::HashSet::new(); if settings.flow_inputs.len() > 128 { - return Err(AppError::BadRequest("too many shared Flow inputs (max 128)".into())); + return Err(AppError::BadRequest( + "too many shared Flow inputs (max 128)".into(), + )); } for item in &mut settings.flow_inputs { item.id = item.id.trim().chars().take(120).collect(); item.name = item.name.trim().chars().take(100).collect(); item.kind = item.kind.trim().to_string(); if item.id.is_empty() || item.name.is_empty() { - return Err(AppError::BadRequest("shared Flow input requires id and name".into())); + return Err(AppError::BadRequest( + "shared Flow input requires id and name".into(), + )); } if !ids.insert(item.id.clone()) { - return Err(AppError::BadRequest("shared Flow input IDs must be unique".into())); + return Err(AppError::BadRequest( + "shared Flow input IDs must be unique".into(), + )); } - if !matches!(item.kind.as_str(), - "outdoor_temperature" | "device_temperature" | "zone_temperature" | - "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | - "house_mode" | "device_state" | "zone_state" | "group_state" | - "night_mode" | "constant") { - return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {}", item.kind))); + if !matches!( + item.kind.as_str(), + "outdoor_temperature" + | "device_temperature" + | "zone_temperature" + | "ha_state" + | "ha_numeric" + | "ha_attribute" + | "ha_available" + | "house_mode" + | "device_state" + | "zone_state" + | "group_state" + | "night_mode" + | "constant" + ) { + return Err(AppError::BadRequest(format!( + "unsupported shared Flow input kind: {}", + item.kind + ))); } if !item.config.is_object() { - return Err(AppError::BadRequest("shared Flow input config must be an object".into())); + return Err(AppError::BadRequest( + "shared Flow input config must be an object".into(), + )); } if item.config.get("operator").is_some() { - return Err(AppError::BadRequest("shared Flow inputs are value sources; operator belongs to the Flow block".into())); + return Err(AppError::BadRequest( + "shared Flow inputs are value sources; operator belongs to the Flow block".into(), + )); } if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() { - return Err(AppError::BadRequest("shared Flow inputs are value sources; comparison value belongs to the Flow block".into())); + return Err(AppError::BadRequest( + "shared Flow inputs are value sources; comparison value belongs to the Flow block" + .into(), + )); } } Ok(()) } -fn validate_flow_shared_inputs(settings: &mut HomeAssistantSettings, state: &AppState) -> Result<(), AppError> { +fn validate_flow_shared_inputs( + settings: &mut HomeAssistantSettings, + state: &AppState, +) -> Result<(), AppError> { normalize_flow_shared_inputs(settings)?; for item in &settings.flow_inputs { validate_shared_input_source(&item.kind, &item.config, state)?; @@ -385,36 +498,55 @@ fn canonicalize_home_assistant_entities(settings: &mut HomeAssistantSettings) { } let outdoor_entity = settings.outdoor_entity_id.clone(); if !outdoor_entity.trim().is_empty() { - if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity)) { + if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity)) + { settings.outdoor_entity_id = entity_id; } } } fn validate_home_assistant_url(settings: &HomeAssistantSettings) -> Result<(), AppError> { - if settings.url.trim().is_empty() { return Ok(()); } + if settings.url.trim().is_empty() { + return Ok(()); + } let parsed = url::Url::parse(&settings.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())); + return Err(AppError::BadRequest( + "Home Assistant URL must use http or https".into(), + )); } Ok(()) } fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &HomeAssistantSettings) { - let Some(configured) = zone.ha_entity_id.clone() else { return; }; + let Some(configured) = zone.ha_entity_id.clone() else { + return; + }; zone.ha_entity_id = home_assistant::resolve_entity_id(settings, Some(&configured)); } -async fn canonicalize_saved_zone_entities(state: &AppState, settings: &HomeAssistantSettings) -> Result<(), AppError> { - let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); +async fn canonicalize_saved_zone_entities( + state: &AppState, + settings: &HomeAssistantSettings, +) -> Result<(), AppError> { + let mut zone_ids: Vec = state + .db + .list_zones()? + .into_iter() + .map(|zone| zone.id) + .collect(); zone_ids.sort(); zone_ids.dedup(); for zone_id in zone_ids { let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(snapshot) = state.db.get_zone(&zone_id)? else { + continue; + }; let _device_guard = state.lock_device_operation(&snapshot.device_id).await; - let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(mut zone) = state.db.get_zone(&zone_id)? else { + continue; + }; let previous = zone.ha_entity_id.clone(); canonicalize_zone_ha_entity(&mut zone, settings); if zone.ha_entity_id != previous { @@ -440,7 +572,9 @@ fn apply_home_assistant_update( sensor_aliases: input.sensor_aliases, flow_inputs: input.flow_inputs, }; - if let Some(token) = input.token { next.token = token; } + if let Some(token) = input.token { + next.token = token; + } next } @@ -464,11 +598,19 @@ async fn update_home_assistant_settings( }; let saved = state.settings.read().await.home_assistant.clone(); canonicalize_saved_zone_entities(&state, &saved).await?; - state.log("info", "settings.home_assistant.updated", "Home Assistant settings updated", json!({ - "configured": payload.token_configured, - "flow_inputs": payload.flow_inputs.len() - })); - state.broadcast("settings.home_assistant.updated", serde_json::to_value(&payload)?); + state.log( + "info", + "settings.home_assistant.updated", + "Home Assistant settings updated", + json!({ + "configured": payload.token_configured, + "flow_inputs": payload.flow_inputs.len() + }), + ); + state.broadcast( + "settings.home_assistant.updated", + serde_json::to_value(&payload)?, + ); state.wake_zone_control(); Ok(Json(payload)) } @@ -487,7 +629,9 @@ async fn update_debug_settings( settings.debug = input.clone(); state.db.save_runtime_settings(&settings)?; } - state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed); + state + .debug_gree_frames + .store(input.gree_frames, Ordering::Relaxed); state.broadcast("settings.debug.updated", serde_json::to_value(&input)?); Ok(Json(input)) } diff --git a/src/api/system.rs b/src/api/system.rs index e67cc3f..3b50504 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -67,4 +67,3 @@ async fn system_info(State(state): State) -> Result, AppEr "gree_received_frames_by_device": received_frames_by_device, }))) } - diff --git a/src/api/websocket.rs b/src/api/websocket.rs index ad561ad..05a45a2 100644 --- a/src/api/websocket.rs +++ b/src/api/websocket.rs @@ -1,17 +1,33 @@ #[derive(Debug, Deserialize)] -struct WsQuery { token: Option } -async fn websocket(State(state): State, Query(query): Query, ws: WebSocketUpgrade) -> Result { +struct WsQuery { + token: Option, +} +async fn websocket( + State(state): State, + Query(query): Query, + ws: WebSocketUpgrade, +) -> Result { let expected = state.config.app_token.trim(); - if !expected.is_empty() && query.token.as_deref() != Some(expected) { return Err(AppError::Unauthorized); } + if !expected.is_empty() && query.token.as_deref() != Some(expected) { + return Err(AppError::Unauthorized); + } Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket))) } async fn websocket_loop(state: AppState, mut socket: WebSocket) { let initial = match build_bootstrap(&state).await { Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}), - Err(err) => json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}), + Err(err) => { + json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}) + } }; - if socket.send(Message::Text(initial.to_string())).await.is_err() { return; } + if socket + .send(Message::Text(initial.to_string())) + .await + .is_err() + { + return; + } let mut receiver = state.events.subscribe(); loop { tokio::select! { @@ -37,4 +53,3 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) { } } } - diff --git a/src/api/zones.rs b/src/api/zones.rs index 73e9b2f..1eb87f3 100644 --- a/src/api/zones.rs +++ b/src/api/zones.rs @@ -53,86 +53,246 @@ struct ZoneInput { #[serde(default)] revision: Option, } -fn yes() -> bool { true } -fn cool() -> String { "cool".into() } -fn setpoint() -> f64 { 24.0 } -fn cool_comfort() -> f64 { 23.0 } -fn cool_sleep() -> f64 { 24.5 } -fn cool_away() -> f64 { 27.0 } -fn heat_comfort() -> f64 { 21.0 } -fn heat_sleep() -> f64 { 19.0 } -fn heat_away() -> f64 { 17.0 } -fn hysteresis() -> f64 { 0.6 } -fn cycle() -> u64 { 180 } -fn min_adjust() -> u64 { 120 } -fn standby_offset() -> f64 { 2.0 } -fn external_sensor_weight() -> f64 { 0.4 } -fn max_sensor_difference() -> f64 { 3.0 } -fn sensor_stale_after() -> u64 { 300 } -fn device_source() -> String { "device".into() } +fn yes() -> bool { + true +} +fn cool() -> String { + "cool".into() +} +fn setpoint() -> f64 { + 24.0 +} +fn cool_comfort() -> f64 { + 23.0 +} +fn cool_sleep() -> f64 { + 24.5 +} +fn cool_away() -> f64 { + 27.0 +} +fn heat_comfort() -> f64 { + 21.0 +} +fn heat_sleep() -> f64 { + 19.0 +} +fn heat_away() -> f64 { + 17.0 +} +fn hysteresis() -> f64 { + 0.6 +} +fn cycle() -> u64 { + 180 +} +fn min_adjust() -> u64 { + 120 +} +fn standby_offset() -> f64 { + 2.0 +} +fn external_sensor_weight() -> f64 { + 0.4 +} +fn max_sensor_difference() -> f64 { + 3.0 +} +fn sensor_stale_after() -> u64 { + 300 +} +fn device_source() -> String { + "device".into() +} impl ZoneInput { fn validate(&self) -> Result<(), AppError> { - if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); } - for value in [self.setpoint, self.cool_comfort_setpoint, self.cool_sleep_setpoint, self.cool_away_setpoint, - self.heat_comfort_setpoint, self.heat_sleep_setpoint, self.heat_away_setpoint] { - if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone temperatures must be between 8 and 30 C".into())); } + if self.name.trim().is_empty() { + return Err(AppError::BadRequest("zone name is required".into())); } - if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); } - if !(0.1..=5.0).contains(&self.cool_hysteresis) { return Err(AppError::BadRequest("cooling hysteresis must be between 0.1 and 5 C".into())); } - if !(0.1..=5.0).contains(&self.heat_hysteresis) { return Err(AppError::BadRequest("heating hysteresis must be between 0.1 and 5 C".into())); } - if !(0.5..=8.0).contains(&self.standby_offset_c) { return Err(AppError::BadRequest("standby offset must be between 0.5 and 8 C".into())); } - if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); } - if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); } - if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); } - if !(0.1..=20.0).contains(&self.max_sensor_difference) { return Err(AppError::BadRequest("maximum sensor difference must be between 0.1 and 20 C".into())); } - if matches!(self.sensor_source.as_str(), "home_assistant" | "combined") && self.ha_entity_id.as_deref().map(|value| value.trim()).unwrap_or("").is_empty() { + for value in [ + self.setpoint, + self.cool_comfort_setpoint, + self.cool_sleep_setpoint, + self.cool_away_setpoint, + self.heat_comfort_setpoint, + self.heat_sleep_setpoint, + self.heat_away_setpoint, + ] { + if !(8.0..=30.0).contains(&value) { + return Err(AppError::BadRequest( + "zone temperatures must be between 8 and 30 C".into(), + )); + } + } + if !(0.1..=5.0).contains(&self.hysteresis) { + return Err(AppError::BadRequest( + "hysteresis must be between 0.1 and 5 C".into(), + )); + } + if !(0.1..=5.0).contains(&self.cool_hysteresis) { + return Err(AppError::BadRequest( + "cooling hysteresis must be between 0.1 and 5 C".into(), + )); + } + if !(0.1..=5.0).contains(&self.heat_hysteresis) { + return Err(AppError::BadRequest( + "heating hysteresis must be between 0.1 and 5 C".into(), + )); + } + if !(0.5..=8.0).contains(&self.standby_offset_c) { + return Err(AppError::BadRequest( + "standby offset must be between 0.5 and 8 C".into(), + )); + } + if !matches!(self.mode.as_str(), "cool" | "heat") { + return Err(AppError::BadRequest( + "zone mode must be cool or heat".into(), + )); + } + if !matches!( + self.sensor_source.as_str(), + "device" | "home_assistant" | "combined" + ) { + return Err(AppError::BadRequest("unsupported sensor source".into())); + } + if !(0.0..=1.0).contains(&self.external_sensor_weight) { + return Err(AppError::BadRequest( + "external sensor weight must be between 0 and 1".into(), + )); + } + if !(0.1..=20.0).contains(&self.max_sensor_difference) { + return Err(AppError::BadRequest( + "maximum sensor difference must be between 0.1 and 20 C".into(), + )); + } + if matches!(self.sensor_source.as_str(), "home_assistant" | "combined") + && self + .ha_entity_id + .as_deref() + .map(|value| value.trim()) + .unwrap_or("") + .is_empty() + { return Err(AppError::BadRequest("a per-zone Home Assistant entity_id is required for external or combined temperature control".into())); } Ok(()) } fn into_zone(self, id: String, created_at: chrono::DateTime) -> Zone { Zone { - id, name: self.name.trim().into(), device_id: self.device_id, enabled: self.enabled, - mode: self.mode, inherit_house_mode: self.inherit_house_mode, setpoint: self.setpoint, profile_version: 1, - cool_comfort_setpoint: self.cool_comfort_setpoint, cool_sleep_setpoint: self.cool_sleep_setpoint, - cool_away_setpoint: self.cool_away_setpoint, heat_comfort_setpoint: self.heat_comfort_setpoint, - heat_sleep_setpoint: self.heat_sleep_setpoint, heat_away_setpoint: self.heat_away_setpoint, - hysteresis: self.hysteresis, separate_hysteresis: self.separate_hysteresis, - cool_hysteresis: self.cool_hysteresis, heat_hysteresis: self.heat_hysteresis, - min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds, - min_adjust_seconds: self.min_adjust_seconds, standby_offset_c: self.standby_offset_c, smart_fan: self.smart_fan, - sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()), - external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds, - device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(), - active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None, - device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None, - revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(), - last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, - compressor_pending_action: None, compressor_pending_since: None, compressor_pending_until: None, compressor_cancelled_action: None, - effective_mode: String::new(), effective_setpoint: None, device_setpoint: None, - demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, - created_at, updated_at: Utc::now(), + id, + name: self.name.trim().into(), + device_id: self.device_id, + enabled: self.enabled, + mode: self.mode, + inherit_house_mode: self.inherit_house_mode, + setpoint: self.setpoint, + profile_version: 1, + cool_comfort_setpoint: self.cool_comfort_setpoint, + cool_sleep_setpoint: self.cool_sleep_setpoint, + cool_away_setpoint: self.cool_away_setpoint, + heat_comfort_setpoint: self.heat_comfort_setpoint, + heat_sleep_setpoint: self.heat_sleep_setpoint, + heat_away_setpoint: self.heat_away_setpoint, + hysteresis: self.hysteresis, + separate_hysteresis: self.separate_hysteresis, + cool_hysteresis: self.cool_hysteresis, + heat_hysteresis: self.heat_hysteresis, + min_on_seconds: self.min_on_seconds, + min_off_seconds: self.min_off_seconds, + min_adjust_seconds: self.min_adjust_seconds, + standby_offset_c: self.standby_offset_c, + smart_fan: self.smart_fan, + sensor_source: self.sensor_source, + ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()), + external_sensor_weight: self.external_sensor_weight, + max_sensor_difference: self.max_sensor_difference, + sensor_stale_after_seconds: self.sensor_stale_after_seconds, + device_temperature: None, + external_temperature: None, + current_temperature: None, + control_temperature_source: "device".into(), + active_preset: "comfort".into(), + manual_preset: None, + manual_setpoint: None, + manual_override_until: None, + local_thermostat_power: None, + local_thermostat_resume_at: None, + local_thermostat_restore_zone_enabled: None, + temporary_quick_thermostat: None, + device_manual_override: false, + device_manual_override_since: None, + device_manual_override_until: None, + device_manual_override_fields: Vec::new(), + device_manual_override_baseline: None, + revision: 1, + control_owner: "automation".into(), + control_source: "automation".into(), + control_since: Some(Utc::now()), + control_resume_at: None, + control_reason: "zone created".into(), + last_power_change_at: None, + last_mode_change_at: None, + lockout_until: None, + lockout_reason: None, + compressor_pending_action: None, + compressor_pending_since: None, + compressor_pending_until: None, + compressor_cancelled_action: None, + effective_mode: String::new(), + effective_setpoint: None, + device_setpoint: None, + demand: false, + demand_since: None, + target_alerted_at: None, + last_action_at: None, + created_at, + updated_at: Utc::now(), } } } -fn validate_zone_device_assignment(state: &AppState, device_id: &str, current_zone_id: Option<&str>) -> Result<(), AppError> { - if state.db.list_zones()?.iter().any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str())) { - return Err(AppError::BadRequest("a device can belong to only one thermostat zone".into())); +fn validate_zone_device_assignment( + state: &AppState, + device_id: &str, + current_zone_id: Option<&str>, +) -> Result<(), AppError> { + if state + .db + .list_zones()? + .iter() + .any(|zone| zone.device_id == device_id && current_zone_id != Some(zone.id.as_str())) + { + return Err(AppError::BadRequest( + "a device can belong to only one thermostat zone".into(), + )); } Ok(()) } -async fn list_zones(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_zones()?)) } -async fn get_zone(State(state): State, Path(id): Path) -> Result, AppError> { - state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}"))) +async fn list_zones(State(state): State) -> Result>, AppError> { + Ok(Json(state.db.list_zones()?)) } -async fn create_zone(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { +async fn get_zone( + State(state): State, + Path(id): Path, +) -> Result, AppError> { + state + .db + .get_zone(&id)? + .map(Json) + .ok_or_else(|| AppError::NotFound(format!("zone {id}"))) +} +async fn create_zone( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _reference_guard = state.lock_automation_operation().await; input.validate()?; - if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } + if state.db.get_device(&input.device_id)?.is_none() { + return Err(AppError::BadRequest("zone device does not exist".into())); + } validate_zone_device_assignment(&state, &input.device_id, None)?; // Creating thermostat ownership must not overlap a poll of the device. Otherwise a poll // that started before the zone existed could apply its old physical-control snapshot to @@ -146,18 +306,30 @@ async fn create_zone(State(state): State, Json(input): Json state.wake_zone_control(); Ok((StatusCode::CREATED, Json(zone))) } -async fn update_zone(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { +async fn update_zone( + State(state): State, + Path(id): Path, + Json(input): Json, +) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _reference_guard = state.lock_automation_operation().await; input.validate()?; let _zone_guard = state.lock_zone_operation(&id).await; - let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let mut existing = state + .db + .get_zone(&id)? + .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; if let Some(expected) = input.revision { if expected != existing.revision { - return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); + return Err(AppError::Conflict(format!( + "zone {id} changed; expected revision {expected}, current revision {}", + existing.revision + ))); } } - if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } + if state.db.get_device(&input.device_id)?.is_none() { + return Err(AppError::BadRequest("zone device does not exist".into())); + } validate_zone_device_assignment(&state, &input.device_id, Some(&id))?; let device_changed = existing.device_id != input.device_id; // Keep the zone lock while taking all involved device locks in stable order. This makes a @@ -173,10 +345,16 @@ async fn update_zone(State(state): State, Path(id): Path, Json if !device_changed { // Polling may have updated takeover/runtime state while we were waiting for the // device lock. Re-read under both locks before building the replacement Zone. - existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + existing = state + .db + .get_zone(&id)? + .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; if let Some(expected) = input.revision { if expected != existing.revision { - return Err(AppError::Conflict(format!("zone {id} changed; expected revision {expected}, current revision {}", existing.revision))); + return Err(AppError::Conflict(format!( + "zone {id} changed; expected revision {expected}, current revision {}", + existing.revision + ))); } } } @@ -223,7 +401,12 @@ async fn update_zone(State(state): State, Path(id): Path, Json } else { // A new physical unit starts with a clean ownership/runtime state. Never transfer // demand, sensor cache or remote-control takeover from the previous device. - ensure_device_stopped_for_detach_locked(&state, &existing.device_id, "zone.device_reassigned").await?; + ensure_device_stopped_for_detach_locked( + &state, + &existing.device_id, + "zone.device_reassigned", + ) + .await?; zone.revision = existing.revision.saturating_add(1); } let settings = state.settings.read().await.clone(); @@ -233,7 +416,11 @@ async fn update_zone(State(state): State, Path(id): Path, Json // Full configuration PUT and quick-control disable use the same ownership cleanup. // No local/temporary/manual takeover survives a disabled thermostat zone (H6). if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - engine::finish_temporary_quick_thermostat(&mut zone, &state.db.list_schedules()?, &settings.house_mode); + engine::finish_temporary_quick_thermostat( + &mut zone, + &state.db.list_schedules()?, + &settings.house_mode, + ); } else { zone.temporary_quick_thermostat = None; } @@ -282,7 +469,9 @@ fn resolve_temporary_start( let existing = zone.temporary_quick_thermostat.as_ref(); let editing_active = engine::temporary_quick_thermostat_is_active(zone, now.clone()); if !matches!(request.start_kind.as_str(), "now" | "delay" | "at") { - return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into())); + return Err(AppError::BadRequest( + "unsupported temporary thermostat start kind".into(), + )); } if editing_active { let existing = existing.expect("active temporary session must exist"); @@ -296,16 +485,30 @@ fn resolve_temporary_start( let started_at = match request.start_kind.as_str() { "now" => now, "delay" => { - let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?; + let minutes = request.start_delay_minutes.ok_or_else(|| { + AppError::BadRequest("temporary thermostat start delay is required".into()) + })?; if !(1..=43_200).contains(&minutes) { - return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into())); + return Err(AppError::BadRequest( + "temporary thermostat start delay must be between 1 minute and 30 days".into(), + )); } now + ChronoDuration::minutes(minutes as i64) } "at" => { - let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?; - if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); } - if at > now + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); } + let at = request.start_at.clone().ok_or_else(|| { + AppError::BadRequest("temporary thermostat start time is required".into()) + })?; + if at <= now { + return Err(AppError::BadRequest( + "temporary thermostat start time must be in the future".into(), + )); + } + if at > now + ChronoDuration::days(30) { + return Err(AppError::BadRequest( + "temporary thermostat start time cannot be more than 30 days away".into(), + )); + } at } _ => unreachable!(), @@ -323,29 +526,57 @@ fn resolve_temporary_target( request: &TemporaryQuickThermostatRequest, ) -> Result { let finish_kind = request.finish_kind.as_str(); - if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") { - return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into())); + if !matches!( + finish_kind, + "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary" + ) { + return Err(AppError::BadRequest( + "unsupported temporary thermostat finish kind".into(), + )); } - let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint))); + let target = request.target_temperature.unwrap_or( + zone.manual_setpoint + .unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)), + ); if !(8.0..=30.0).contains(&target) { - return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into())); + return Err(AppError::BadRequest( + "temporary thermostat target must be between 8 and 30 C".into(), + )); } let target = (target * 2.0).round() / 2.0; - let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() }; + let tolerance_mode = if zone.effective_mode.is_empty() { + zone.mode.as_str() + } else { + zone.effective_mode.as_str() + }; let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0); let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3)); if !(0.1..=3.0).contains(&requested_tolerance) { - return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into())); + return Err(AppError::BadRequest( + "temporary thermostat tolerance must be between 0.1 and 3 C".into(), + )); } let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within"); - if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") { - return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into())); + if !matches!( + temperature_operator, + "within" | "at_or_below" | "at_or_above" + ) { + return Err(AppError::BadRequest( + "unsupported temporary thermostat temperature operator".into(), + )); } Ok(TemporaryTargetSettings { target, - tolerance: if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance }, + tolerance: if finish_kind == "temperature_stable" { + requested_tolerance.max(min_stable_tolerance) + } else { + requested_tolerance + }, temperature_operator: temperature_operator.to_string(), - is_temperature_condition: matches!(finish_kind, "temperature_reached" | "temperature_stable"), + is_temperature_condition: matches!( + finish_kind, + "temperature_reached" | "temperature_stable" + ), }) } @@ -359,44 +590,100 @@ fn resolve_temporary_finish( ) -> Result { let finish_kind = request.finish_kind.as_str(); let duration_seconds = if finish_kind == "duration" { - let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?; - if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); } + let minutes = request.duration_minutes.ok_or_else(|| { + AppError::BadRequest("temporary thermostat duration is required".into()) + })?; + if !(1..=14_400).contains(&minutes) { + return Err(AppError::BadRequest( + "temporary thermostat duration must be between 1 minute and 10 days".into(), + )); + } Some(minutes.saturating_mul(60)) - } else { None }; + } else { + None + }; let hold_seconds = if finish_kind == "temperature_stable" { - let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?; - if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); } + let minutes = request + .hold_minutes + .ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?; + if !(1..=1_440).contains(&minutes) { + return Err(AppError::BadRequest( + "temperature hold time must be between 1 minute and 24 hours".into(), + )); + } minutes.saturating_mul(60) - } else { 0 }; + } else { + 0 + }; let safety_duration_seconds = if is_temperature_condition { - request.max_duration_minutes.map(|minutes| { - if !(1..=14_400).contains(&minutes) { - return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into())); - } - Ok(minutes.saturating_mul(60)) - }).transpose()? - } else { None }; + request + .max_duration_minutes + .map(|minutes| { + if !(1..=14_400).contains(&minutes) { + return Err(AppError::BadRequest( + "temporary thermostat safety limit must be between 1 minute and 10 days" + .into(), + )); + } + Ok(minutes.saturating_mul(60)) + }) + .transpose()? + } else { + None + }; let active_base = start.activated_at.clone().unwrap_or(now.clone()); let expires_at = match finish_kind { - "duration" if start.editing_active => duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64)), + "duration" if start.editing_active => { + duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64)) + } "until" => { - let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?; - let comparison_start = if start.editing_active { now.clone() } else { start.started_at.clone() }; - if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); } - if until > comparison_start + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); } + let until = request.until.clone().ok_or_else(|| { + AppError::BadRequest("temporary thermostat end time is required".into()) + })?; + let comparison_start = if start.editing_active { + now.clone() + } else { + start.started_at.clone() + }; + if until <= comparison_start { + return Err(AppError::BadRequest( + "temporary thermostat end time must be in the future and after its start" + .into(), + )); + } + if until > comparison_start + ChronoDuration::days(30) { + return Err(AppError::BadRequest( + "temporary thermostat end time cannot be more than 30 days after start".into(), + )); + } Some(until) } "schedule_boundary" => { - let reference = if start.editing_active { chrono::Local::now() } else { start.started_at.clone().with_timezone(&chrono::Local) }; - Some(engine::next_schedule_boundary_utc(&zone.id, schedules, reference) - .ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?) + let reference = if start.editing_active { + chrono::Local::now() + } else { + start.started_at.clone().with_timezone(&chrono::Local) + }; + Some( + engine::next_schedule_boundary_utc(&zone.id, schedules, reference).ok_or_else( + || AppError::BadRequest("this zone has no future schedule transition".into()), + )?, + ) } _ => None, }; let safety_expires_at = if start.editing_active { safety_duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64)) - } else { None }; - Ok(TemporaryFinishTiming { duration_seconds, hold_seconds, safety_duration_seconds, expires_at, safety_expires_at }) + } else { + None + }; + Ok(TemporaryFinishTiming { + duration_seconds, + hold_seconds, + safety_duration_seconds, + expires_at, + safety_expires_at, + }) } async fn apply_temporary_quick_thermostat_request( @@ -410,17 +697,44 @@ async fn apply_temporary_quick_thermostat_request( let existing_session = zone.temporary_quick_thermostat.clone(); let start = resolve_temporary_start(zone, request, now.clone())?; let target = resolve_temporary_target(zone, request)?; - let finish = resolve_temporary_finish(zone, schedules, request, &start, now.clone(), target.is_temperature_condition)?; + let finish = resolve_temporary_finish( + zone, + schedules, + request, + &start, + now.clone(), + target.is_temperature_condition, + )?; let starts_now = start.start_kind == "now"; let immediate_activation = !start.editing_active && starts_now && !zone.device_manual_override; let restore_zone_enabled = if start.editing_active { - existing_session.as_ref().and_then(|session| session.restore_zone_enabled) - } else if immediate_activation { Some(zone.enabled) } else { None }; - let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() }; - let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; + existing_session + .as_ref() + .and_then(|session| session.restore_zone_enabled) + } else if immediate_activation { + Some(zone.enabled) + } else { + None + }; + let configured_mode = if zone.inherit_house_mode { + runtime.house_mode.as_str() + } else { + zone.mode.as_str() + }; + let captured_mode = if configured_mode == "off" { + zone.mode.clone() + } else { + configured_mode.to_string() + }; let active_mode = if start.editing_active { - existing_session.as_ref().and_then(|session| session.active_mode.clone()) - } else if immediate_activation { Some(captured_mode.clone()) } else { None }; + existing_session + .as_ref() + .and_then(|session| session.active_mode.clone()) + } else if immediate_activation { + Some(captured_mode.clone()) + } else { + None + }; let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str()); if target.is_temperature_condition && ((condition_mode == "heat" && target.temperature_operator == "at_or_below") @@ -429,8 +743,16 @@ async fn apply_temporary_quick_thermostat_request( return Err(AppError::BadRequest("temporary thermostat temperature condition conflicts with the active heating/cooling direction".into())); } let state_value = if start.editing_active { - if zone.device_manual_override { "paused_manual" } else { "active" } - } else if starts_now && zone.device_manual_override { "paused_manual" } else { "scheduled" }; + if zone.device_manual_override { + "paused_manual" + } else { + "active" + } + } else if starts_now && zone.device_manual_override { + "paused_manual" + } else { + "scheduled" + }; let underlying_local_power = zone.local_thermostat_power; let underlying_local_resume_at = zone.local_thermostat_resume_at; @@ -456,37 +778,119 @@ async fn apply_temporary_quick_thermostat_request( start_kind: start.start_kind, finish_kind: request.finish_kind.clone(), started_at: start.started_at.clone(), - activated_at: if immediate_activation { Some(now.clone()) } else { start.activated_at.clone() }, + activated_at: if immediate_activation { + Some(now.clone()) + } else { + start.activated_at.clone() + }, state: state_value.into(), active_mode, restore_zone_enabled, - restore_local_thermostat_power: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power) } else if immediate_activation { underlying_local_power } else { None }, - restore_local_thermostat_resume_at: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at) } else if immediate_activation { underlying_local_resume_at } else { None }, - restore_local_thermostat_zone_enabled: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled) } else if immediate_activation { underlying_local_zone_enabled } else { None }, - restore_manual_preset: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone()) } else if immediate_activation { underlying_manual_preset } else { None }, - restore_manual_setpoint: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_setpoint) } else if immediate_activation { underlying_manual_setpoint } else { None }, - restore_manual_override_until: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_override_until) } else if immediate_activation { underlying_manual_override_until } else { None }, - expires_at: if immediate_activation && request.finish_kind == "duration" { finish.duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.expires_at }, + restore_local_thermostat_power: if start.editing_active { + existing_session + .as_ref() + .and_then(|session| session.restore_local_thermostat_power) + } else if immediate_activation { + underlying_local_power + } else { + None + }, + restore_local_thermostat_resume_at: if start.editing_active { + existing_session + .as_ref() + .and_then(|session| session.restore_local_thermostat_resume_at) + } else if immediate_activation { + underlying_local_resume_at + } else { + None + }, + restore_local_thermostat_zone_enabled: if start.editing_active { + existing_session + .as_ref() + .and_then(|session| session.restore_local_thermostat_zone_enabled) + } else if immediate_activation { + underlying_local_zone_enabled + } else { + None + }, + restore_manual_preset: if start.editing_active { + existing_session + .as_ref() + .and_then(|session| session.restore_manual_preset.clone()) + } else if immediate_activation { + underlying_manual_preset + } else { + None + }, + restore_manual_setpoint: if start.editing_active { + existing_session + .as_ref() + .and_then(|session| session.restore_manual_setpoint) + } else if immediate_activation { + underlying_manual_setpoint + } else { + None + }, + restore_manual_override_until: if start.editing_active { + existing_session + .as_ref() + .and_then(|session| session.restore_manual_override_until) + } else if immediate_activation { + underlying_manual_override_until + } else { + None + }, + expires_at: if immediate_activation && request.finish_kind == "duration" { + finish + .duration_seconds + .map(|seconds| now + ChronoDuration::seconds(seconds as i64)) + } else { + finish.expires_at + }, duration_seconds: finish.duration_seconds, safety_duration_seconds: finish.safety_duration_seconds, temperature_target: Some(target.target), - temperature_operator: target.is_temperature_condition.then(|| target.temperature_operator.clone()), + temperature_operator: target + .is_temperature_condition + .then(|| target.temperature_operator.clone()), tolerance_c: target.tolerance, hold_seconds: finish.hold_seconds, condition_started_at: None, condition_last_observed_at: None, paused_at: if zone.device_manual_override && (start.editing_active || starts_now) { - existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone())) - } else { None }, - deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()), - deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()), - deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint), - safety_expires_at: if immediate_activation && target.is_temperature_condition { finish.safety_duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.safety_expires_at }, + existing_session + .as_ref() + .and_then(|session| session.paused_at.clone()) + .or(Some(now.clone())) + } else { + None + }, + deferred_mode: existing_session + .as_ref() + .and_then(|session| session.deferred_mode.clone()), + deferred_preset: existing_session + .as_ref() + .and_then(|session| session.deferred_preset.clone()), + deferred_setpoint: existing_session + .as_ref() + .and_then(|session| session.deferred_setpoint), + safety_expires_at: if immediate_activation && target.is_temperature_condition { + finish + .safety_duration_seconds + .map(|seconds| now + ChronoDuration::seconds(seconds as i64)) + } else { + finish.safety_expires_at + }, }); Ok(()) } -async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result { +async fn apply_zone_control_patch( + state: &AppState, + id: &str, + patch: ZoneControlPatch, + source: &str, +) -> Result { // A quick preset/setpoint derives its resume boundary from schedules. Take the schedule // lock before the per-zone lock so a concurrent schedule edit cannot leave an override // pointing at an obsolete boundary (and so lock order stays schedule -> zone -> device). @@ -501,34 +905,58 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl // thermostat action could save an older zone snapshot afterwards and resurrect a false // "physical/pilot" takeover. let _zone_guard = state.lock_zone_operation(id).await; - let device_id = state.db.get_zone(id)? + let device_id = state + .db + .get_zone(id)? .ok_or_else(|| AppError::NotFound(format!("zone {id}")))? .device_id; let device_guard = state.lock_device_operation(&device_id).await; - let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let mut zone = state + .db + .get_zone(id)? + .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let was_enabled = zone.enabled; let schedules = state.db.list_schedules()?; - let rearm_compressor = patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some() - || patch.enabled.is_some() || patch.preset.is_some() || patch.clear_override.unwrap_or(false) - || patch.clear_device_manual_override.unwrap_or(false) || patch.clear_local_thermostat_override.unwrap_or(false) - || patch.temporary_quick_thermostat.is_some() || patch.clear_temporary_quick_thermostat.unwrap_or(false); + let rearm_compressor = patch.power.is_some() + || patch.setpoint.is_some() + || patch.mode.is_some() + || patch.enabled.is_some() + || patch.preset.is_some() + || patch.clear_override.unwrap_or(false) + || patch.clear_device_manual_override.unwrap_or(false) + || patch.clear_local_thermostat_override.unwrap_or(false) + || patch.temporary_quick_thermostat.is_some() + || patch.clear_temporary_quick_thermostat.unwrap_or(false); if rearm_compressor { engine::rearm_compressor_queue(&mut zone); } let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false); let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false); let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false); - if patch.temporary_quick_thermostat.is_some() && (patch.power.is_some() || stop_temporary_quick_thermostat || resume_local_thermostat) { + if patch.temporary_quick_thermostat.is_some() + && (patch.power.is_some() || stop_temporary_quick_thermostat || resume_local_thermostat) + { return Err(AppError::BadRequest("temporary thermostat cannot be combined with local power/clear operations in one request".into())); } // Direct/manual device takeover is higher priority than a temporary thermostat. Creating // or editing a temporary session therefore never clears an active pilot/Devices takeover; // the session waits/pauses instead. Other explicit thermostat actions still resume control. let resume_device_automation = resume_device_takeover - || patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some() - || patch.preset.is_some() || patch.enabled.is_some(); - if resume_device_automation || resume_local_thermostat || stop_temporary_quick_thermostat || patch.temporary_quick_thermostat.is_some() { - zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() }; + || patch.power.is_some() + || patch.setpoint.is_some() + || patch.mode.is_some() + || patch.preset.is_some() + || patch.enabled.is_some(); + if resume_device_automation + || resume_local_thermostat + || stop_temporary_quick_thermostat + || patch.temporary_quick_thermostat.is_some() + { + zone.control_source = if source.contains("home_assistant") { + "home_assistant_thermostat".into() + } else { + "web_thermostat".into() + }; } if resume_local_thermostat { @@ -536,7 +964,11 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl } if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() { if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); + engine::finish_temporary_quick_thermostat( + &mut zone, + &schedules, + &state.settings.read().await.house_mode, + ); } else { // Cancelling a delayed session before it starts must not erase unrelated // quick preset/setpoint state that automation may be using in the meantime. @@ -551,13 +983,19 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl // same temporary-session cleanup/restore semantics before local ownership changes. if zone.temporary_quick_thermostat.is_some() { if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); + engine::finish_temporary_quick_thermostat( + &mut zone, + &schedules, + &state.settings.read().await.house_mode, + ); } else { zone.temporary_quick_thermostat = None; } } engine::set_local_thermostat_power(&mut zone, power, Utc::now()); - if power { zone.enabled = true; } + if power { + zone.enabled = true; + } // A manually started local thermostat keeps an already selected target/profile until // it is switched off and the delayed hand-back completes, Auto is selected, or the // user explicitly resumes automation. @@ -566,7 +1004,11 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl } } if let Some(value) = patch.setpoint { - if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); } + if !(8.0..=30.0).contains(&value) { + return Err(AppError::BadRequest( + "zone setpoint must be between 8 and 30 C".into(), + )); + } let value = (value * 10.0).round() / 10.0; zone.setpoint = value; zone.manual_setpoint = Some(value); @@ -576,7 +1018,10 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl // +/- always edits the live temporary target, including duration/until // sessions, so the modal and regulator cannot diverge (M14). session.temperature_target = Some(value); - if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { + if matches!( + session.finish_kind.as_str(), + "temperature_reached" | "temperature_stable" + ) { session.condition_started_at = None; session.condition_last_observed_at = None; } @@ -590,7 +1035,9 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl } if let Some(value) = patch.mode.as_deref() { if !matches!(value, "house" | "auto" | "cool" | "heat") { - return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())); + return Err(AppError::BadRequest( + "zone mode must be house, cool or heat".into(), + )); } if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { @@ -614,7 +1061,9 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { session.deferred_preset = Some(value.to_string()); - if value != "custom" { session.deferred_setpoint = None; } + if value != "custom" { + session.deferred_setpoint = None; + } } } else { match value { @@ -629,7 +1078,11 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl zone.manual_override_until = if zone.local_thermostat_power == Some(true) { None } else { - engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) + engine::next_schedule_boundary_utc( + &zone.id, + &schedules, + chrono::Local::now(), + ) }; } _ => unreachable!(), @@ -651,7 +1104,11 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl if let Some(value) = patch.enabled { if !value { if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { - engine::finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); + engine::finish_temporary_quick_thermostat( + &mut zone, + &schedules, + &state.settings.read().await.house_mode, + ); } else { zone.temporary_quick_thermostat = None; } @@ -662,7 +1119,11 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl zone.enabled = true; } } - let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false }; + let device_override_cleared = if resume_device_automation { + engine::reset_device_manual_override(&mut zone) + } else { + false + }; let runtime = state.settings.read().await.clone(); let house_mode = runtime.house_mode.clone(); engine::refresh_control_ownership(&mut zone); @@ -681,9 +1142,14 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl // unit survive a thermostat OFF click. The cycle lock held by this handler keeps // automatic arbitration out until the OFF marker and physical command are complete. if let Err(err) = engine::force_power_off_device(state, &zone.device_id).await { - state.log("error", "zone.local_power_error", &err.to_string(), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "power": false - })); + state.log( + "error", + "zone.local_power_error", + &err.to_string(), + json!({ + "zone_id": zone.id, "device_id": zone.device_id, "power": false + }), + ); } state.wake_zone_control(); } else { @@ -701,52 +1167,87 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl Ok(zone) } -async fn update_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { - Ok(Json(apply_zone_control_patch(&state, &id, patch, "web.zone_thermostat").await?)) +async fn update_zone_control( + State(state): State, + Path(id): Path, + Json(patch): Json, +) -> Result, AppError> { + Ok(Json( + apply_zone_control_patch(&state, &id, patch, "web.zone_thermostat").await?, + )) } - - -async fn ensure_device_stopped_for_detach_locked(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { - let Some(device) = state.db.get_device(device_id)? else { return Ok(()); }; +async fn ensure_device_stopped_for_detach_locked( + state: &AppState, + device_id: &str, + source: &str, +) -> Result<(), AppError> { + let Some(device) = state.db.get_device(device_id)? else { + return Ok(()); + }; if !device.enabled { return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into())); } // Force one OFF transition even when the cached state already says OFF. A remote change // may not have been polled yet and detaching must not leave a running unit without owner. engine::force_power_off_device_locked(state, device_id).await?; - state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({ - "device_id": device.id, "source": source - })); + state.log( + "info", + "zone.detach_power_off", + &format!( + "Powered off {} before detaching thermostat ownership", + device.name + ), + json!({ + "device_id": device.id, "source": source + }), + ); Ok(()) } -async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { +async fn ensure_device_stopped_for_detach( + state: &AppState, + device_id: &str, + source: &str, +) -> Result<(), AppError> { let _device_guard = state.lock_device_operation(device_id).await; ensure_device_stopped_for_detach_locked(state, device_id, source).await } async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) { - let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; }; - if !device.enabled { return; } + let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { + return; + }; + if !device.enabled { + return; + } if let Err(err) = engine::force_power_off_device(state, &device.id).await { - state.log("error", "zone.disable_power_error", &err.to_string(), json!({ - "zone_id": zone.id, - "device_id": device.id, - "device_name": device.name, - "source": source, - })); + state.log( + "error", + "zone.disable_power_error", + &err.to_string(), + json!({ + "zone_id": zone.id, + "device_id": device.id, + "device_name": device.name, + "source": source, + }), + ); } } - - -async fn cancel_zone_compressor_queue(State(state): State, Path(id): Path) -> Result, AppError> { +async fn cancel_zone_compressor_queue( + State(state): State, + Path(id): Path, +) -> Result, AppError> { // Serialize cancellation with the thermostat cycle. If cancellation wins this lock, the // pending action cannot expire and execute between the UI click and the persisted marker. let _cycle_guard = state.lock_zone_control_cycle().await; let _zone_guard = state.lock_zone_operation(&id).await; - let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + let mut zone = state + .db + .get_zone(&id)? + .ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let cancelled = zone.compressor_pending_action.clone(); if let Some(action) = cancelled.clone() { zone.compressor_cancelled_action = Some(action); @@ -759,29 +1260,51 @@ async fn cancel_zone_compressor_queue(State(state): State, Path(id): P zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); - state.log("info", "zone.compressor_queue_cancelled", &format!("Cancelled compressor-protection task for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "action": cancelled.clone() - })); + state.log( + "info", + "zone.compressor_queue_cancelled", + &format!("Cancelled compressor-protection task for {}", zone.name), + json!({ + "zone_id": zone.id, "device_id": zone.device_id, "action": cancelled.clone() + }), + ); } - if cancelled.is_some() { state.wake_zone_control(); } - Ok(Json(json!({"cancelled": cancelled.is_some(), "action": cancelled, "zone": zone}))) + if cancelled.is_some() { + state.wake_zone_control(); + } + Ok(Json( + json!({"cancelled": cancelled.is_some(), "action": cancelled, "zone": zone}), + )) } -async fn cancel_all_compressor_queues(State(state): State) -> Result, AppError> { +async fn cancel_all_compressor_queues( + State(state): State, +) -> Result, AppError> { // Stop the thermostat arbiter while taking all zone locks, so a task cannot expire and // execute between discovering it and persisting the cancellation marker. let _cycle_guard = state.lock_zone_control_cycle().await; - let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); + let mut zone_ids: Vec = state + .db + .list_zones()? + .into_iter() + .map(|zone| zone.id) + .collect(); zone_ids.sort(); zone_ids.dedup(); let mut guards = Vec::with_capacity(zone_ids.len()); - for zone_id in &zone_ids { guards.push(state.lock_zone_operation(zone_id).await); } + for zone_id in &zone_ids { + guards.push(state.lock_zone_operation(zone_id).await); + } let mut cancelled = 0usize; let mut zones = Vec::new(); for zone_id in &zone_ids { - let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; - let Some(action) = zone.compressor_pending_action.clone() else { continue; }; + let Some(mut zone) = state.db.get_zone(zone_id)? else { + continue; + }; + let Some(action) = zone.compressor_pending_action.clone() else { + continue; + }; zone.compressor_cancelled_action = Some(action.clone()); zone.compressor_pending_action = None; zone.compressor_pending_since = None; @@ -798,6 +1321,8 @@ async fn cancel_all_compressor_queues(State(state): State) -> Result 0 { state.wake_zone_control(); } + if cancelled > 0 { + state.wake_zone_control(); + } Ok(Json(json!({"cancelled": cancelled, "zones": zones}))) } diff --git a/src/config.rs b/src/config.rs index af37f46..8a17a56 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,14 +1,21 @@ -use std::{env, net::SocketAddr, path::PathBuf}; +use crate::models::{ + DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, + NotificationSettings, RuntimeSettings, +}; use anyhow::{Context, Result}; use clap::Parser; -use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, NotificationSettings, RuntimeSettings}; +use std::{env, net::SocketAddr, path::PathBuf}; #[derive(Debug, Clone, Parser)] #[command(author, version, about)] pub struct Config { #[arg(long, env = "GREE_CONTROLLER_BIND", default_value = "0.0.0.0:8787")] pub bind: SocketAddr, - #[arg(long, env = "GREE_CONTROLLER_DATABASE", default_value = "./data/gree-controller.db")] + #[arg( + long, + env = "GREE_CONTROLLER_DATABASE", + default_value = "./data/gree-controller.db" + )] pub database: PathBuf, #[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")] pub app_token: String, @@ -18,13 +25,29 @@ pub struct Config { pub simulate: bool, #[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)] pub auto_seed: bool, - #[arg(long, env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", default_value_t = 15)] + #[arg( + long, + env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", + default_value_t = 15 + )] pub poll_interval_seconds: u64, - #[arg(long, env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS", default_value_t = 5)] + #[arg( + long, + env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS", + default_value_t = 5 + )] pub zone_interval_seconds: u64, - #[arg(long, env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS", default_value_t = 3000)] + #[arg( + long, + env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS", + default_value_t = 3000 + )] pub discovery_timeout_ms: u64, - #[arg(long, env = "GREE_CONTROLLER_DISCOVERY_BROADCAST", default_value = "255.255.255.255:7000")] + #[arg( + long, + env = "GREE_CONTROLLER_DISCOVERY_BROADCAST", + default_value = "255.255.255.255:7000" + )] pub discovery_broadcast: String, #[arg(long, env = "GREE_CONTROLLER_GREE_INTERFACE", default_value = "")] pub gree_interface: String, @@ -38,8 +61,9 @@ impl Config { let mut config = Self::parse(); config.base_path = normalize_base_path(&config.base_path)?; if let Some(parent) = config.database.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("cannot create database directory {}", parent.display()))?; + std::fs::create_dir_all(parent).with_context(|| { + format!("cannot create database directory {}", parent.display()) + })?; } Ok(config) } @@ -54,13 +78,24 @@ impl Config { discovery_broadcast: self.discovery_broadcast.clone(), house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()), control_strategy: "setpoint".into(), - outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true), - history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650), - history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true), - event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650), + outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED") + .unwrap_or(true), + history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS") + .unwrap_or(30) + .clamp(1, 3650), + history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") + .unwrap_or(true), + event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS") + .unwrap_or(30) + .clamp(1, 3650), suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false), - compressor_protection_enabled: env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED").unwrap_or(true), - compressor_protection_seconds: env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS").unwrap_or(180).clamp(30, 1800), + compressor_protection_enabled: env_bool( + "GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED", + ) + .unwrap_or(true), + compressor_protection_seconds: env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") + .unwrap_or(180) + .clamp(30, 1800), influxdb: influx_settings_from_env(), debug: DebugSettings { overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false), @@ -69,18 +104,25 @@ impl Config { notifications: NotificationSettings::default(), night_mode: NightModeSettings { enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false), - start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()), - end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END").unwrap_or_else(|_| "06:00".into()), - max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED").unwrap_or(1).clamp(1, 5), + start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START") + .unwrap_or_else(|_| "22:00".into()), + end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END") + .unwrap_or_else(|_| "06:00".into()), + max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") + .unwrap_or(1) + .clamp(1, 5), force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true), - use_native_sleep: env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP").unwrap_or(true), + use_native_sleep: env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") + .unwrap_or(true), }, home_assistant: HomeAssistantSettings { url: env::var("HA_URL").unwrap_or_default(), token: env::var("HA_TOKEN").unwrap_or_default(), default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(), outdoor_entity_id: env::var("HA_OUTDOOR_ENTITY_ID").unwrap_or_default(), - sensor_stale_after_seconds: env_u64("HA_SENSOR_STALE_AFTER_SECONDS").unwrap_or(300).clamp(30, 86_400), + sensor_stale_after_seconds: env_u64("HA_SENSOR_STALE_AFTER_SECONDS") + .unwrap_or(300) + .clamp(30, 86_400), allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS") .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) .unwrap_or(false), @@ -93,32 +135,80 @@ impl Config { /// Environment values explicitly supplied by the service override persisted runtime values. pub fn apply_runtime_env_overrides(&self, settings: &mut RuntimeSettings) { if env::var_os("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").is_some() { - settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(settings.history_retention_days).clamp(1, 3650); + settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS") + .unwrap_or(settings.history_retention_days) + .clamp(1, 3650); + } + if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { + settings.history_compaction_enabled = value; } - if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; } if env::var_os("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").is_some() { - settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(settings.event_log_retention_days).clamp(1, 3650); + settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS") + .unwrap_or(settings.event_log_retention_days) + .clamp(1, 3650); + } + if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { + settings.suppress_device_beep = value; + } + if let Some(value) = env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED") { + settings.compressor_protection_enabled = value; + } + if let Some(value) = env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") { + settings.compressor_protection_seconds = value.clamp(30, 1800); + } + if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { + settings.debug.overlay_enabled = value; + } + if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { + settings.debug.gree_frames = value; + } + if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { + settings.night_mode.enabled = value; + } + if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") { + if !value.trim().is_empty() { + settings.night_mode.start_time = value; + } + } + if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { + if !value.trim().is_empty() { + settings.night_mode.end_time = value; + } + } + if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { + settings.night_mode.max_fan_speed = value.clamp(1, 5); + } + if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { + settings.night_mode.force_quiet = value; + } + if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") { + settings.night_mode.use_native_sleep = value; + } + if let Some(value) = env_u64("HA_SENSOR_STALE_AFTER_SECONDS") { + settings.home_assistant.sensor_stale_after_seconds = value.clamp(30, 86_400); } - if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; } - if let Some(value) = env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED") { settings.compressor_protection_enabled = value; } - if let Some(value) = env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") { settings.compressor_protection_seconds = value.clamp(30, 1800); } - if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; } - if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; } - if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { settings.night_mode.enabled = value; } - if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") { if !value.trim().is_empty() { settings.night_mode.start_time = value; } } - if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { if !value.trim().is_empty() { settings.night_mode.end_time = value; } } - if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); } - if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; } - if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") { settings.night_mode.use_native_sleep = value; } - if let Some(value) = env_u64("HA_SENSOR_STALE_AFTER_SECONDS") { settings.home_assistant.sensor_stale_after_seconds = value.clamp(30, 86_400); } let influx_env_present = [ - "GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL", - "GREE_CONTROLLER_INFLUX_DATABASE", "GREE_CONTROLLER_INFLUX_USERNAME", "GREE_CONTROLLER_INFLUX_PASSWORD", - "GREE_CONTROLLER_INFLUX_ORG", "GREE_CONTROLLER_INFLUX_BUCKET", "GREE_CONTROLLER_INFLUX_TOKEN", - "GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS", "INFLUXDB_URL", "INFLUXDB_DATABASE", "INFLUXDB_USERNAME", - "INFLUXDB_PASSWORD", "INFLUXDB_TOKEN", "INFLUXDB_ORG", "INFLUXDB_BUCKET", - ].iter().any(|name| env::var_os(name).is_some()); + "GREE_CONTROLLER_INFLUX_ENABLED", + "GREE_CONTROLLER_INFLUX_VERSION", + "GREE_CONTROLLER_INFLUX_URL", + "GREE_CONTROLLER_INFLUX_DATABASE", + "GREE_CONTROLLER_INFLUX_USERNAME", + "GREE_CONTROLLER_INFLUX_PASSWORD", + "GREE_CONTROLLER_INFLUX_ORG", + "GREE_CONTROLLER_INFLUX_BUCKET", + "GREE_CONTROLLER_INFLUX_TOKEN", + "GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS", + "INFLUXDB_URL", + "INFLUXDB_DATABASE", + "INFLUXDB_USERNAME", + "INFLUXDB_PASSWORD", + "INFLUXDB_TOKEN", + "INFLUXDB_ORG", + "INFLUXDB_BUCKET", + ] + .iter() + .any(|name| env::var_os(name).is_some()); if influx_env_present { let env_settings = influx_settings_from_env(); if env::var_os("GREE_CONTROLLER_INFLUX_ENABLED").is_some() { @@ -126,35 +216,66 @@ impl Config { } else if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { settings.influxdb.enabled = true; } - if first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).is_some() { settings.influxdb.version = env_settings.version; } - if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { settings.influxdb.url = env_settings.url; } - if first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).is_some() { settings.influxdb.database = env_settings.database; } - if first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).is_some() { settings.influxdb.username = env_settings.username; } - if first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).is_some() { settings.influxdb.password = env_settings.password; } - if first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).is_some() { settings.influxdb.org = env_settings.org; } - if first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).is_some() { settings.influxdb.bucket = env_settings.bucket; } - if first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).is_some() { settings.influxdb.token = env_settings.token; } - if env::var_os("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").is_some() { settings.influxdb.history_threshold_days = env_settings.history_threshold_days; } + if first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).is_some() { + settings.influxdb.version = env_settings.version; + } + if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { + settings.influxdb.url = env_settings.url; + } + if first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).is_some() { + settings.influxdb.database = env_settings.database; + } + if first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).is_some() { + settings.influxdb.username = env_settings.username; + } + if first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).is_some() { + settings.influxdb.password = env_settings.password; + } + if first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).is_some() { + settings.influxdb.org = env_settings.org; + } + if first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).is_some() { + settings.influxdb.bucket = env_settings.bucket; + } + if first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).is_some() { + settings.influxdb.token = env_settings.token; + } + if env::var_os("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").is_some() { + settings.influxdb.history_threshold_days = env_settings.history_threshold_days; + } } } } fn normalize_base_path(value: &str) -> Result { let value = value.trim(); - if value.is_empty() || value == "/" { return Ok(String::new()); } - if value.contains('?') || value.contains('#') || value.split('/').any(|part| matches!(part, "." | "..")) { + if value.is_empty() || value == "/" { + return Ok(String::new()); + } + if value.contains('?') + || value.contains('#') + || value.split('/').any(|part| matches!(part, "." | "..")) + { anyhow::bail!("GREE_CONTROLLER_BASE_PATH must be a simple URL path without '.', '..', query or fragment"); } Ok(format!("/{}", value.trim_matches('/'))) } fn env_bool(name: &str) -> Option { - env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) + env::var(name) + .ok() + .map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on")) } -fn env_u32(name: &str) -> Option { env::var(name).ok()?.parse().ok() } -fn env_u64(name: &str) -> Option { env::var(name).ok()?.parse().ok() } -fn env_u8(name: &str) -> Option { env::var(name).ok()?.parse().ok() } +fn env_u32(name: &str) -> Option { + env::var(name).ok()?.parse().ok() +} +fn env_u64(name: &str) -> Option { + env::var(name).ok()?.parse().ok() +} +fn env_u8(name: &str) -> Option { + env::var(name).ok()?.parse().ok() +} fn first_env(names: &[&str]) -> Option { names.iter().find_map(|name| { @@ -167,13 +288,21 @@ fn influx_settings_from_env() -> InfluxDbSettings { let mut settings = InfluxDbSettings::default(); settings.version = first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).unwrap_or_else(|| "2".into()); settings.url = first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).unwrap_or_default(); - settings.enabled = env_bool("GREE_CONTROLLER_INFLUX_ENABLED").unwrap_or(!settings.url.is_empty()); - settings.database = first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).unwrap_or_else(|| "gree_controller".into()); - settings.username = first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).unwrap_or_default(); - settings.password = first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).unwrap_or_default(); + settings.enabled = + env_bool("GREE_CONTROLLER_INFLUX_ENABLED").unwrap_or(!settings.url.is_empty()); + settings.database = first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]) + .unwrap_or_else(|| "gree_controller".into()); + settings.username = + first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).unwrap_or_default(); + settings.password = + first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).unwrap_or_default(); settings.org = first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).unwrap_or_default(); - settings.bucket = first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).unwrap_or_else(|| "gree_controller".into()); - settings.token = first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).unwrap_or_default(); - settings.history_threshold_days = env_u32("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").unwrap_or(30).clamp(1, 3650); + settings.bucket = first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]) + .unwrap_or_else(|| "gree_controller".into()); + settings.token = + first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).unwrap_or_default(); + settings.history_threshold_days = env_u32("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS") + .unwrap_or(30) + .clamp(1, 3650); settings } diff --git a/src/db.rs b/src/db.rs index 38a36d6..38fe608 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,12 +1,18 @@ -use std::{path::Path, sync::{Arc, Mutex}}; +use crate::{ + models::{ + ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, Flow, + HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading, + }, + queries, +}; use anyhow::{Context, Result}; use chrono::{DateTime, Duration, Utc}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{de::DeserializeOwned, Serialize}; use serde_json::Value; -use crate::{ - models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, Flow, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading}, - queries, +use std::{ + path::Path, + sync::{Arc, Mutex}, }; #[derive(Clone)] @@ -14,7 +20,6 @@ pub struct Db { conn: Arc>, } - // Functional source split intentionally keeps items in the existing module namespace. include!("db/core_devices.rs"); include!("db/climate.rs"); diff --git a/src/db/climate.rs b/src/db/climate.rs index 02b36c1..5ff9196 100644 --- a/src/db/climate.rs +++ b/src/db/climate.rs @@ -48,7 +48,6 @@ impl Db { pub fn delete_group(&self, id: &str) -> Result { self.delete_by_id("groups", id) } - } impl Db { @@ -62,8 +61,12 @@ impl Db { mode_changed: bool, at: DateTime, ) -> Result> { - if !power_changed && !mode_changed { return Ok(Vec::new()); } - let zone_ids: Vec = self.list_zones()?.into_iter() + if !power_changed && !mode_changed { + return Ok(Vec::new()); + } + let zone_ids: Vec = self + .list_zones()? + .into_iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id) .collect(); @@ -71,10 +74,16 @@ impl Db { for zone_id in zone_ids { let mut saved = false; for _ in 0..8 { - let Some(mut zone) = self.get_zone(&zone_id)? else { break; }; + let Some(mut zone) = self.get_zone(&zone_id)? else { + break; + }; let expected_updated_at = zone.updated_at.to_rfc3339(); - if power_changed { zone.last_power_change_at = Some(at); } - if mode_changed { zone.last_mode_change_at = Some(at); } + if power_changed { + zone.last_power_change_at = Some(at); + } + if mode_changed { + zone.last_mode_change_at = Some(at); + } let payload = Self::to_json(&zone)?; let conn = self.lock()?; let changed = conn.execute( @@ -89,7 +98,9 @@ impl Db { } } if !saved && self.get_zone(&zone_id)?.is_some() { - return Err(anyhow::anyhow!("zone {zone_id} kept changing while device transition timestamps were merged")); + return Err(anyhow::anyhow!( + "zone {zone_id} kept changing while device transition timestamps were merged" + )); } } Ok(updated) diff --git a/src/db/configuration.rs b/src/db/configuration.rs index f873a5a..d0b1752 100644 --- a/src/db/configuration.rs +++ b/src/db/configuration.rs @@ -19,37 +19,73 @@ impl Db { tx.execute_batch(queries::CLEAR_CONFIGURATION)?; for device in &export.devices { let payload = Self::to_json(device)?; - tx.execute(queries::UPSERT_DEVICE, params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()])?; + tx.execute( + queries::UPSERT_DEVICE, + params![ + device.id, + device.mac, + device.name, + device.ip, + device.simulated as i64, + payload, + device.updated_at.to_rfc3339() + ], + )?; } for zone in &export.zones { let payload = Self::to_json(zone)?; - tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.updated_at.to_rfc3339()])?; + tx.execute( + queries::UPSERT_ZONE, + params![zone.id, payload, zone.updated_at.to_rfc3339()], + )?; } for group in &export.groups { let payload = Self::to_json(group)?; - tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.updated_at.to_rfc3339()])?; + tx.execute( + queries::UPSERT_GROUP, + params![group.id, payload, group.updated_at.to_rfc3339()], + )?; } for schedule in &export.schedules { let payload = Self::to_json(schedule)?; - tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?; + tx.execute( + queries::UPSERT_SCHEDULE, + params![ + schedule.id, + schedule.zone_id, + payload, + schedule.updated_at.to_rfc3339() + ], + )?; } for item in &export.automations { let payload = Self::to_json(item)?; - tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?; + tx.execute( + queries::UPSERT_AUTOMATION, + params![item.id, payload, item.updated_at.to_rfc3339()], + )?; } for flow in &export.flows { let payload = Self::to_json(flow)?; - tx.execute(queries::UPSERT_FLOW, params![flow.id, payload, flow.updated_at.to_rfc3339()])?; + tx.execute( + queries::UPSERT_FLOW, + params![flow.id, payload, flow.updated_at.to_rfc3339()], + )?; } let settings_json = Self::to_json(&export.settings)?; - tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?; + tx.execute( + queries::UPSERT_RUNTIME_SETTINGS, + params![settings_json, Utc::now().to_rfc3339()], + )?; tx.commit()?; Ok(()) } pub fn load_runtime_settings(&self) -> Result> { let conn = self.lock()?; - let value: Option = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?; + let value: Option = conn + .query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)) + .optional()?; value.map(Self::from_json).transpose() } diff --git a/src/db/core_devices.rs b/src/db/core_devices.rs index 2ed914b..3106a19 100644 --- a/src/db/core_devices.rs +++ b/src/db/core_devices.rs @@ -4,11 +4,15 @@ impl Db { .with_context(|| format!("cannot open SQLite database {}", path.display()))?; conn.busy_timeout(std::time::Duration::from_secs(5))?; conn.execute_batch(queries::INIT_SCHEMA)?; - Ok(Self { conn: Arc::new(Mutex::new(conn)) }) + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) } fn lock(&self) -> Result> { - self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned")) + self.conn + .lock() + .map_err(|_| anyhow::anyhow!("database mutex poisoned")) } fn from_json(payload: String) -> Result { @@ -30,7 +34,15 @@ impl Db { let conn = self.lock()?; conn.execute( queries::UPSERT_DEVICE, - params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()], + params![ + device.id, + device.mac, + device.name, + device.ip, + device.simulated as i64, + payload, + device.updated_at.to_rfc3339() + ], )?; Ok(()) } @@ -38,20 +50,25 @@ impl Db { pub fn list_devices(&self) -> Result> { let conn = self.lock()?; let mut stmt = conn.prepare(queries::LIST_DEVICES)?; - let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? + let payloads = stmt + .query_map([], |row| row.get::<_, String>(0))? .collect::, _>>()?; payloads.into_iter().map(Self::from_json).collect() } pub fn get_device(&self, id: &str) -> Result> { let conn = self.lock()?; - let payload: Option = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?; + let payload: Option = conn + .query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)) + .optional()?; payload.map(Self::from_json).transpose() } pub fn get_device_by_mac(&self, mac: &str) -> Result> { let conn = self.lock()?; - let payload: Option = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?; + let payload: Option = conn + .query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)) + .optional()?; payload.map(Self::from_json).transpose() } @@ -66,5 +83,4 @@ impl Db { tx.commit()?; Ok(changed) } - } diff --git a/src/db/device_history.rs b/src/db/device_history.rs index 384ff56..eb93cb0 100644 --- a/src/db/device_history.rs +++ b/src/db/device_history.rs @@ -3,41 +3,76 @@ impl Db { let conn = self.lock()?; conn.execute( queries::INSERT_READING, - params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature, - reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source], + params![ + reading.device_id, + reading.timestamp.to_rfc3339(), + reading.indoor_temperature, + reading.outdoor_temperature, + reading.target_temperature, + reading.power as i64, + reading.source + ], )?; Ok(conn.last_insert_rowid()) } - pub fn list_readings(&self, device_id: Option<&str>, since: DateTime, limit: u32) -> Result> { + pub fn list_readings( + &self, + device_id: Option<&str>, + since: DateTime, + limit: u32, + ) -> Result> { let conn = self.lock()?; let limit = limit.clamp(1, 5000) as i64; let mut rows_out = Vec::new(); if let Some(device_id) = device_id { let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?; - let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?; - for row in rows { rows_out.push(row?); } + let rows = stmt.query_map( + params![device_id, since.to_rfc3339(), limit], + Self::map_reading, + )?; + for row in rows { + rows_out.push(row?); + } } else { let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?; let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?; - for row in rows { rows_out.push(row?); } + for row in rows { + rows_out.push(row?); + } } Ok(rows_out) } - pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime, bucket_seconds: i64, limit: u32) -> Result> { + pub fn list_device_history( + &self, + device_id: Option<&str>, + since: DateTime, + bucket_seconds: i64, + limit: u32, + ) -> Result> { let conn = self.lock()?; let bucket_seconds = bucket_seconds.max(1); let limit = limit.clamp(1, 20_000) as i64; let mut rows_out = Vec::new(); if let Some(device_id) = device_id { let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?; - let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?; - for row in rows { rows_out.push(row?); } + let rows = stmt.query_map( + params![device_id, since.to_rfc3339(), bucket_seconds, limit], + Self::map_reading, + )?; + for row in rows { + rows_out.push(row?); + } } else { let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?; - let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?; - for row in rows { rows_out.push(row?); } + let rows = stmt.query_map( + params![since.to_rfc3339(), bucket_seconds, limit], + Self::map_reading, + )?; + for row in rows { + rows_out.push(row?); + } } Ok(rows_out) } @@ -58,7 +93,11 @@ impl Db { }) } - pub fn history_before(&self, before: DateTime, limit_per_family: u32) -> Result<(Vec, Vec, Vec)> { + pub fn history_before( + &self, + before: DateTime, + limit_per_family: u32, + ) -> Result<(Vec, Vec, Vec)> { let conn = self.lock()?; let limit = limit_per_family.clamp(1, 5_000) as i64; let before = before.to_rfc3339(); @@ -81,13 +120,24 @@ impl Db { Ok((devices, zones, ha)) } - pub fn delete_history_batch(&self, devices: &[Reading], zones: &[ZoneReading], ha: &[HaReading]) -> Result { + pub fn delete_history_batch( + &self, + devices: &[Reading], + zones: &[ZoneReading], + ha: &[HaReading], + ) -> Result { let mut conn = self.lock()?; let tx = conn.transaction()?; let mut changed = 0_u64; - for row in devices { changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64; } - for row in zones { changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64; } - for row in ha { changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64; } + for row in devices { + changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64; + } + for row in zones { + changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64; + } + for row in ha { + changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64; + } tx.commit()?; Ok(changed) } @@ -114,7 +164,9 @@ impl Db { (600_i64, one_day, seven_days), (1800_i64, seven_days, retention), ] { - if older_than <= newer_than { continue; } + if older_than <= newer_than { + continue; + } let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; changed += conn.execute(queries::COMPACT_DEVICE_HISTORY, args)? as u64; let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()]; @@ -125,5 +177,4 @@ impl Db { conn.execute_batch("PRAGMA optimize;")?; Ok(changed) } - } diff --git a/src/db/events_tokens.rs b/src/db/events_tokens.rs index ecffcae..cc8b8c9 100644 --- a/src/db/events_tokens.rs +++ b/src/db/events_tokens.rs @@ -1,15 +1,30 @@ impl Db { pub fn history_counts(&self) -> Result<(u64, u64, u64)> { let conn = self.lock()?; - let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?; + let (device, zone, ha): (i64, i64, i64) = + conn.query_row(queries::HISTORY_COUNTS, [], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) + })?; Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64)) } - pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result { + pub fn log_event( + &self, + level: &str, + kind: &str, + message: &str, + metadata: &Value, + ) -> Result { let conn = self.lock()?; conn.execute( queries::INSERT_EVENT, - params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?], + params![ + Utc::now().to_rfc3339(), + level, + kind, + message, + serde_json::to_string(metadata)? + ], )?; Ok(conn.last_insert_rowid()) } @@ -22,14 +37,17 @@ impl Db { let metadata: String = row.get(5)?; Ok(EventLog { id: row.get(0)?, - timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()), + timestamp: DateTime::parse_from_rfc3339(&ts) + .map(|v| v.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), level: row.get(2)?, kind: row.get(3)?, message: row.get(4)?, metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null), }) })?; - rows.collect::, _>>().map_err(Into::into) + rows.collect::, _>>() + .map_err(Into::into) } pub fn prune_events(&self, retention_days: i64) -> Result { @@ -52,25 +70,30 @@ impl Db { .unwrap_or_else(|_| Utc::now()), }) })?; - rows.collect::, _>>().map_err(Into::into) + rows.collect::, _>>() + .map_err(Into::into) } pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> { let conn = self.lock()?; conn.execute( queries::INSERT_API_TOKEN, - params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()], + params![ + token.id, + token.name, + token_hash, + token.token_prefix, + token.created_at.to_rfc3339() + ], )?; Ok(()) } pub fn api_token_exists(&self, token_hash: &str) -> Result { let conn = self.lock()?; - let found: Option = conn.query_row( - queries::API_TOKEN_EXISTS, - [token_hash], - |row| row.get(0), - ).optional()?; + let found: Option = conn + .query_row(queries::API_TOKEN_EXISTS, [token_hash], |row| row.get(0)) + .optional()?; Ok(found.is_some()) } @@ -78,5 +101,4 @@ impl Db { let conn = self.lock()?; Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0) } - } diff --git a/src/db/flows.rs b/src/db/flows.rs index da671fd..b7471d1 100644 --- a/src/db/flows.rs +++ b/src/db/flows.rs @@ -7,21 +7,40 @@ impl Db { self.get_payload(queries::GET_FLOW, id) } - pub fn replace_flow_outputs(&self, flow: &Flow, schedules: &[Schedule], automations: &[Automation]) -> Result<()> { + pub fn replace_flow_outputs( + &self, + flow: &Flow, + schedules: &[Schedule], + automations: &[Automation], + ) -> Result<()> { let mut conn = self.lock()?; let tx = conn.transaction()?; tx.execute(queries::DELETE_SCHEDULES_BY_FLOW_ID, [flow.id.as_str()])?; tx.execute(queries::DELETE_AUTOMATIONS_BY_FLOW_ID, [flow.id.as_str()])?; for schedule in schedules { let payload = Self::to_json(schedule)?; - tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?; + tx.execute( + queries::UPSERT_SCHEDULE, + params![ + schedule.id, + schedule.zone_id, + payload, + schedule.updated_at.to_rfc3339() + ], + )?; } for item in automations { let payload = Self::to_json(item)?; - tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?; + tx.execute( + queries::UPSERT_AUTOMATION, + params![item.id, payload, item.updated_at.to_rfc3339()], + )?; } let payload = Self::to_json(flow)?; - tx.execute(queries::UPSERT_FLOW, params![flow.id, payload, flow.updated_at.to_rfc3339()])?; + tx.execute( + queries::UPSERT_FLOW, + params![flow.id, payload, flow.updated_at.to_rfc3339()], + )?; tx.commit()?; Ok(()) } diff --git a/src/db/ha_history.rs b/src/db/ha_history.rs index 0051f52..b2bdf80 100644 --- a/src/db/ha_history.rs +++ b/src/db/ha_history.rs @@ -1,5 +1,9 @@ impl Db { - pub fn add_ha_reading_if_due(&self, reading: &HaReading, min_interval_seconds: i64) -> Result { + pub fn add_ha_reading_if_due( + &self, + reading: &HaReading, + min_interval_seconds: i64, + ) -> Result { let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1)); let conn = self.lock()?; let changed = conn.execute( @@ -16,19 +20,35 @@ impl Db { Ok(changed > 0) } - pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime, bucket_seconds: i64, limit: u32) -> Result> { + pub fn list_ha_history( + &self, + entity_id: Option<&str>, + since: DateTime, + bucket_seconds: i64, + limit: u32, + ) -> Result> { let conn = self.lock()?; let bucket_seconds = bucket_seconds.max(1); let limit = limit.clamp(1, 20_000) as i64; let mut rows_out = Vec::new(); if let Some(entity_id) = entity_id { let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?; - let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?; - for row in rows { rows_out.push(row?); } + let rows = stmt.query_map( + params![entity_id, since.to_rfc3339(), bucket_seconds, limit], + Self::map_ha_reading, + )?; + for row in rows { + rows_out.push(row?); + } } else { let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?; - let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?; - for row in rows { rows_out.push(row?); } + let rows = stmt.query_map( + params![since.to_rfc3339(), bucket_seconds, limit], + Self::map_ha_reading, + )?; + for row in rows { + rows_out.push(row?); + } } Ok(rows_out) } @@ -46,5 +66,4 @@ impl Db { temperature: row.get(5)?, }) } - } diff --git a/src/db/schedules_automations.rs b/src/db/schedules_automations.rs index fb477d6..ba7e7b2 100644 --- a/src/db/schedules_automations.rs +++ b/src/db/schedules_automations.rs @@ -4,7 +4,12 @@ impl Db { let conn = self.lock()?; conn.execute( queries::UPSERT_SCHEDULE, - params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()], + params![ + schedule.id, + schedule.zone_id, + payload, + schedule.updated_at.to_rfc3339() + ], )?; Ok(()) } @@ -29,7 +34,12 @@ impl Db { let payload = Self::to_json(schedule)?; tx.execute( queries::UPSERT_SCHEDULE, - params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()], + params![ + schedule.id, + schedule.zone_id, + payload, + schedule.updated_at.to_rfc3339() + ], )?; } tx.commit()?; @@ -61,7 +71,8 @@ impl Db { fn list_payloads(&self, sql: &str) -> Result> { let conn = self.lock()?; let mut stmt = conn.prepare(sql)?; - let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? + let payloads = stmt + .query_map([], |row| row.get::<_, String>(0))? .collect::, _>>()?; payloads.into_iter().map(Self::from_json).collect() } @@ -82,5 +93,4 @@ impl Db { let conn = self.lock()?; Ok(conn.execute(sql, [id])? > 0) } - } diff --git a/src/db/tests.rs b/src/db/tests.rs index 82222c0..cc4cf05 100644 --- a/src/db/tests.rs +++ b/src/db/tests.rs @@ -13,10 +13,16 @@ mod tests { let base = DateTime::::from_timestamp(seconds, 0).unwrap(); for offset in [10_i64, 20_i64] { db.add_reading(&Reading { - id: 0, device_id: device.id.clone(), timestamp: base + Duration::seconds(offset), - indoor_temperature: Some(22.0), outdoor_temperature: None, target_temperature: 23.0, - power: true, source: "gree".into(), - }).unwrap(); + id: 0, + device_id: device.id.clone(), + timestamp: base + Duration::seconds(offset), + indoor_temperature: Some(22.0), + outdoor_temperature: None, + target_temperature: 23.0, + power: true, + source: "gree".into(), + }) + .unwrap(); } assert_eq!(db.history_counts().unwrap().0, 2); assert_eq!(db.compact_history(30).unwrap(), 1); @@ -32,11 +38,22 @@ mod tests { let loaded = db.get_device(&device.id).unwrap().unwrap(); assert_eq!(loaded.mac, device.mac); assert_eq!(db.list_devices().unwrap().len(), 1); - db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap(); + db.log_event("info", "test", "ok", &serde_json::json!({"a":1})) + .unwrap(); assert_eq!(db.list_events(10).unwrap().len(), 1); { let conn = db.lock().unwrap(); - conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap(); + conn.execute( + queries::INSERT_EVENT, + rusqlite::params![ + (Utc::now() - Duration::days(40)).to_rfc3339(), + "info", + "old", + "old", + "{}" + ], + ) + .unwrap(); } assert_eq!(db.prune_events(30).unwrap(), 1); assert_eq!(db.list_events(10).unwrap().len(), 1); @@ -55,16 +72,51 @@ mod tests { let now = Utc::now(); db.add_reading(&Reading { - id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5), - outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(), - }).unwrap(); - assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1); + id: 0, + device_id: device.id.clone(), + timestamp: now.clone(), + indoor_temperature: Some(22.5), + outdoor_temperature: Some(31.0), + target_temperature: 23.0, + power: true, + source: "gree".into(), + }) + .unwrap(); + assert_eq!( + db.list_device_history( + Some(&device.id), + now.clone() - Duration::minutes(1), + 30, + 100 + ) + .unwrap() + .len(), + 1 + ); - db.add_ha_reading_if_due(&HaReading { - id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(), - timestamp: now.clone(), temperature: 22.1, - }, 15).unwrap(); - assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1); + db.add_ha_reading_if_due( + &HaReading { + id: 0, + entity_id: "sensor.room".into(), + zone_id: Some("zone-room".into()), + kind: "room".into(), + timestamp: now.clone(), + temperature: 22.1, + }, + 15, + ) + .unwrap(); + assert_eq!( + db.list_ha_history( + Some("sensor.room"), + now.clone() - Duration::minutes(1), + 30, + 100 + ) + .unwrap() + .len(), + 1 + ); assert_eq!(db.history_counts().unwrap(), (1, 0, 1)); } } diff --git a/src/db/zone_history.rs b/src/db/zone_history.rs index 0fcbe20..1b38293 100644 --- a/src/db/zone_history.rs +++ b/src/db/zone_history.rs @@ -1,5 +1,9 @@ impl Db { - pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result { + pub fn add_zone_reading_if_due( + &self, + reading: &ZoneReading, + min_interval_seconds: i64, + ) -> Result { let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1)); let conn = self.lock()?; let changed = conn.execute( @@ -26,19 +30,35 @@ impl Db { Ok(changed > 0) } - pub fn list_zone_history(&self, zone_id: Option<&str>, since: DateTime, bucket_seconds: i64, limit: u32) -> Result> { + pub fn list_zone_history( + &self, + zone_id: Option<&str>, + since: DateTime, + bucket_seconds: i64, + limit: u32, + ) -> Result> { let conn = self.lock()?; let bucket_seconds = bucket_seconds.max(1); let limit = limit.clamp(1, 20_000) as i64; let mut rows_out = Vec::new(); if let Some(zone_id) = zone_id { let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?; - let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?; - for row in rows { rows_out.push(row?); } + let rows = stmt.query_map( + params![zone_id, since.to_rfc3339(), bucket_seconds, limit], + Self::map_zone_reading, + )?; + for row in rows { + rows_out.push(row?); + } } else { let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?; - let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?; - for row in rows { rows_out.push(row?); } + let rows = stmt.query_map( + params![since.to_rfc3339(), bucket_seconds, limit], + Self::map_zone_reading, + )?; + for row in rows { + rows_out.push(row?); + } } Ok(rows_out) } @@ -66,5 +86,4 @@ impl Db { active_preset: row.get(15)?, }) } - } diff --git a/src/engine.rs b/src/engine.rs index a787dbb..3e719ca 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,16 +1,22 @@ -use std::{collections::HashMap, sync::atomic::Ordering, time::{Duration, Instant}}; +use crate::{ + error::AppError, + home_assistant, influxdb, + models::{ + Automation, AutomationPlanRule, ClimateGroup, ControlPlan, ControlPlanEvent, Device, + DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings, + Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading, + }, + state::{AppState, PendingControllerCommand}, +}; use anyhow::Result; use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday}; use serde_json::{json, Value}; -use tokio::time::sleep; -use crate::{ - error::AppError, - home_assistant, - influxdb, - models::{Automation, AutomationPlanRule, ClimateGroup, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading}, - state::{AppState, PendingControllerCommand}, +use std::{ + collections::HashMap, + sync::atomic::Ordering, + time::{Duration, Instant}, }; - +use tokio::time::sleep; // Functional source split intentionally keeps items in the existing module namespace. include!("engine/runtime.rs"); diff --git a/src/engine/commands.rs b/src/engine/commands.rs index a7b8e30..0358899 100644 --- a/src/engine/commands.rs +++ b/src/engine/commands.rs @@ -1,8 +1,16 @@ -async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { +async fn send_command_locked( + state: &AppState, + device_id: &str, + command: DeviceCommand, +) -> Result { send_command_locked_inner(state, device_id, command, true, true).await } -async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { +async fn send_command_locked_forced( + state: &AppState, + device_id: &str, + command: DeviceCommand, +) -> Result { send_command_locked_inner(state, device_id, command, false, true).await } @@ -14,14 +22,24 @@ async fn send_command_locked_inner( track_controller_command: bool, ) -> Result { validate_command(&command)?; - let mut device = state.db.get_device(device_id)? + let mut device = state + .db + .get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; - if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } + if !device.enabled { + return Err(AppError::BadRequest("device is disabled".into())); + } // Routine control avoids redundant frames. Explicit safety transitions (global/group OFF, // detach) may bypass cache de-duplication so stale state cannot leave a unit powered. - let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command }; - if command.is_empty() { return Ok(device); } + let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { + command.changed_from(&device) + } else { + command + }; + if command.is_empty() { + return Ok(device); + } let controller_command_baseline = device.clone(); let suppress_beep = state.settings.read().await.suppress_device_beep; let response_started = Instant::now(); @@ -70,20 +88,21 @@ async fn send_command_locked_inner( Ok(()) => { device = observed; let remaining = command.changed_from(&device); - if remaining.is_empty() { Ok(command.clone()) } - else { state.gree.command(&device, &remaining, suppress_beep).await } - } - Err(_) => { - match state.gree.bind(&device).await { - Ok(bound) => { - device.key = Some(bound.key); - device.protocol_version = bound.protocol_version; - state.db.save_device(&device)?; - state.gree.command(&device, &command, suppress_beep).await - } - Err(_) => Err(first_err), + if remaining.is_empty() { + Ok(command.clone()) + } else { + state.gree.command(&device, &remaining, suppress_beep).await } } + Err(_) => match state.gree.bind(&device).await { + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + state.db.save_device(&device)?; + state.gree.command(&device, &command, suppress_beep).await + } + Err(_) => Err(first_err), + }, }; match retry_result { Ok(result) => applied_command = result, @@ -94,8 +113,12 @@ async fn send_command_locked_inner( } } } - if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); } - if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); } + if command.quiet.is_some() && applied_command.quiet.is_none() { + device.supports_quiet = Some(false); + } + if command.sleep.is_some() && applied_command.sleep.is_none() { + device.supports_sleep = Some(false); + } // A command ACK confirms transport/acceptance, but several GREE firmwares keep // returning the pre-command status for a short settling window. Publishing that first @@ -105,7 +128,9 @@ async fn send_command_locked_inner( let verification_delays_ms = [0_u64, 150, 350, 650]; let mut last_verification_error: Option = None; for delay_ms in verification_delays_ms { - if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; } + if delay_ms > 0 { + sleep(Duration::from_millis(delay_ms)).await; + } let mut observed = device.clone(); match state.gree.poll(&mut observed).await { Ok(()) => { @@ -114,7 +139,9 @@ async fn send_command_locked_inner( confirmed_state = true; confirmed_requested_state = requested_matches; last_verification_error = None; - if requested_matches { break; } + if requested_matches { + break; + } } Err(err) => { last_verification_error = Some(err.to_string()); @@ -125,23 +152,49 @@ async fn send_command_locked_inner( if confirmed_state && !confirmed_requested_state { tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window"); } else if !confirmed_state { - let error = last_verification_error.unwrap_or_else(|| "status verification failed".into()); - record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}")); - state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({ - "device_id": device.id, "error": error - })); + let error = + last_verification_error.unwrap_or_else(|| "status verification failed".into()); + record_poll_failure( + &mut device, + &format!("command accepted but status verification failed: {error}"), + ); + state.log( + "warn", + "device.command_unconfirmed", + &format!( + "Command accepted by {}, but resulting state could not be verified", + device.name + ), + json!({ + "device_id": device.id, "error": error + }), + ); } } if confirmed_state { - device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); + device.response_time_ms = + Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64); } state.db.save_device(&device)?; if !dedupe_against_cache && confirmed_state && !confirmed_requested_state { - if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() { - remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; + if track_controller_command + && !command_manual_control_fields(&applied_command).is_empty() + { + remember_controller_command( + state, + device_id, + &applied_command, + &controller_command_baseline, + ) + .await; } - state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); - return Err(AppError::Device("device did not confirm the requested forced state change".into())); + state.broadcast( + "device.updated", + serde_json::to_value(&device).unwrap_or_default(), + ); + return Err(AppError::Device( + "device did not confirm the requested forced state change".into(), + )); } } @@ -150,32 +203,54 @@ async fn send_command_locked_inner( // observed once. Several GREE modules can briefly publish an older snapshot again // and then return to the controller-requested state. Without this guard that normal // firmware bounce can be misclassified as a physical/pilot takeover. - remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await; + remember_controller_command( + state, + device_id, + &applied_command, + &controller_command_baseline, + ) + .await; } record_device_transition_timestamps(state, &controller_command_baseline, &device)?; - state.log("info", "device.command", &format!("Updated {}", device.name), json!({ - "device_id": device.id, - "command": applied_command, - "confirmed": confirmed_state, - })); - state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); + state.log( + "info", + "device.command", + &format!("Updated {}", device.name), + json!({ + "device_id": device.id, + "command": applied_command, + "confirmed": confirmed_state, + }), + ); + state.broadcast( + "device.updated", + serde_json::to_value(&device).unwrap_or_default(), + ); Ok(device) } -fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { +fn record_device_transition_timestamps( + state: &AppState, + before: &Device, + after: &Device, +) -> Result<(), AppError> { let power_changed = before.power != after.power; let mode_changed = before.mode != after.mode; - if !power_changed && !mode_changed { return Ok(()); } + if !power_changed && !mode_changed { + return Ok(()); + } // This function is often called while the device lock is held, so acquiring a zone lock // here would invert the global zone -> device order. Merge only these timestamp fields // with a DB compare-and-swap instead of saving a stale whole-zone snapshot. for zone in state.db.merge_zone_device_transition_timestamps( - &after.id, power_changed, mode_changed, Utc::now(), + &after.id, + power_changed, + mode_changed, + Utc::now(), )? { state.broadcast("zone.updated", serde_json::to_value(&zone)?); } Ok(()) } - diff --git a/src/engine/control_plan.rs b/src/engine/control_plan.rs index 6b4957e..138b393 100644 --- a/src/engine/control_plan.rs +++ b/src/engine/control_plan.rs @@ -4,10 +4,15 @@ pub async fn build_control_plan(state: &AppState) -> Result = zones.iter().map(|zone| (zone.id.clone(), zone.name.clone())).collect(); + let zone_names: HashMap = zones + .iter() + .map(|zone| (zone.id.clone(), zone.name.clone())) + .collect(); let house_preset = zones.first().and_then(|first| { let first_preset = first.manual_preset.as_deref().unwrap_or("auto"); - zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset) + zones + .iter() + .all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset) .then(|| first_preset.to_string()) }); let house_power = true; @@ -21,9 +26,14 @@ pub async fn build_control_plan(state: &AppState) -> Result Result Result Result Result Result Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else if automation_idle { Some("no_active_thermostat_intent".into()) } else { None }, + blocked_reason: if zone.device_manual_override { + Some("manual_override".into()) + } else if zone + .lockout_until + .map(|until| until > Utc::now()) + .unwrap_or(false) + { + Some( + zone.lockout_reason + .clone() + .unwrap_or_else(|| "lockout".into()), + ) + } else if !zone.enabled { + Some("zone_disabled".into()) + } else if device + .map(|d| !d.online || d.communication_failures > 0) + .unwrap_or(true) + { + Some("offline".into()) + } else if automation_idle { + Some("no_active_thermostat_intent".into()) + } else { + None + }, lockout_until: zone.lockout_until, current_schedule_id: active.map(|item| item.id.clone()), current_schedule_name: active.map(|item| item.name.clone()), @@ -112,19 +171,33 @@ pub async fn build_control_plan(state: &AppState) -> Result Result, limit: usize) -> Vec { - if !settings.enabled || limit == 0 { return Vec::new(); } - let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); }; - let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); }; +fn next_night_mode_events( + settings: &NightModeSettings, + now: DateTime, + limit: usize, +) -> Vec { + if !settings.enabled || limit == 0 { + return Vec::new(); + } + let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { + return Vec::new(); + }; + let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { + return Vec::new(); + }; let mut events = Vec::new(); let base = minute_floor(now); for minute in 1..=(48 * 60) { @@ -182,7 +264,14 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime, li let time = candidate.time(); let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() { let quiet = if settings.force_quiet { " + Quiet" } else { "" }; - ("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet)) + ( + "night_mode_start", + format!( + "Night mode -> fan max {}{}", + settings.max_fan_speed.clamp(1, 5), + quiet + ), + ) } else if time.hour() == end.hour() && time.minute() == end.minute() { ("night_mode_end", "Night mode ends".to_string()) } else { @@ -195,12 +284,18 @@ fn next_night_mode_events(settings: &NightModeSettings, now: DateTime, li preset: None, target_temperature: None, }); - if events.len() >= limit { break; } + if events.len() >= limit { + break; + } } events } -fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime) -> Option { +fn next_time_automation_event( + item: &Automation, + action_name: &str, + now: DateTime, +) -> Option { let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; let base = minute_floor(now.clone()); if time_automation_due(item, now) { @@ -228,8 +323,16 @@ fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTim None } -fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: DateTime, limit: usize) -> Vec { - if mode == "off" { return Vec::new(); } +fn next_schedule_events( + zone: &Zone, + schedules: &[Schedule], + mode: &str, + now: DateTime, + limit: usize, +) -> Vec { + if mode == "off" { + return Vec::new(); + } let mut events = Vec::new(); let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str()); let base = minute_floor(now); @@ -237,14 +340,28 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da let candidate = base + chrono::Duration::minutes(minute); let next = active_schedule_for_zone(zone, schedules, candidate); let next_id = next.map(|item| item.id.as_str()); - if next_id == current { continue; } + if next_id == current { + continue; + } current = next_id; let (preset, target, label) = if let Some(item) = next { - let target = if item.preset == "custom" { item.setpoint } else { profile_setpoint(zone, &item.preset, mode) }; - (Some(item.preset.clone()), Some(target), format!("{} -> {} {:.1} C", item.name, item.preset, target)) + let target = if item.preset == "custom" { + item.setpoint + } else { + profile_setpoint(zone, &item.preset, mode) + }; + ( + Some(item.preset.clone()), + Some(target), + format!("{} -> {} {:.1} C", item.name, item.preset, target), + ) } else { let target = profile_setpoint(zone, "comfort", mode); - (Some("comfort".into()), Some(target), format!("comfort {:.1} C", target)) + ( + Some("comfort".into()), + Some(target), + format!("comfort {:.1} C", target), + ) }; events.push(ControlPlanEvent { at: candidate.with_timezone(&Utc), @@ -253,8 +370,9 @@ fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: Da preset, target_temperature: target, }); - if events.len() >= limit { break; } + if events.len() >= limit { + break; + } } events } - diff --git a/src/engine/deadlines.rs b/src/engine/deadlines.rs index 109d2c8..c2c88fe 100644 --- a/src/engine/deadlines.rs +++ b/src/engine/deadlines.rs @@ -1,10 +1,14 @@ fn next_time_automation_utc(item: &Automation, now: DateTime) -> Option> { - if !item.enabled || item.trigger_kind != "time" { return None; } + if !item.enabled || item.trigger_kind != "time" { + return None; + } let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?; let minute_floor = now.with_second(0)?.with_nanosecond(0)?; for offset in 0..=(24 * 60) { let candidate = minute_floor + chrono::Duration::minutes(offset); - if candidate <= now { continue; } + if candidate <= now { + continue; + } if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() { return Some(candidate.with_timezone(&Utc)); } @@ -22,25 +26,39 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result for zone in &zones { if local_thermostat_handback_is_active(zone) { - if let Some(at) = zone.local_thermostat_resume_at.clone() { deadlines.push(at); } + if let Some(at) = zone.local_thermostat_resume_at.clone() { + deadlines.push(at); + } } if zone.compressor_pending_action.is_some() { - if let Some(at) = zone.compressor_pending_until.clone().or(zone.lockout_until.clone()) { deadlines.push(at); } + if let Some(at) = zone + .compressor_pending_until + .clone() + .or(zone.lockout_until.clone()) + { + deadlines.push(at); + } + } + if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { + deadlines.push(at); + } + if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { + deadlines.push(at); } - if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { deadlines.push(at); } - if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { deadlines.push(at); } } for automation in &automations { - if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { deadlines.push(at); } + if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { + deadlines.push(at); + } } // Ignore already-expired deadlines here. The control cycle that just ran had the // opportunity to consume them; if another prerequisite (offline sensor/device, manual // ownership, etc.) prevents execution, the normal thermostat interval should retry // instead of creating a zero-delay busy loop. - Ok(deadlines.into_iter() + Ok(deadlines + .into_iter() .filter(|at| at > &now) .filter_map(|at| (at - now.clone()).to_std().ok()) .min()) } - diff --git a/src/engine/groups.rs b/src/engine/groups.rs index 3a911d4..9453f03 100644 --- a/src/engine/groups.rs +++ b/src/engine/groups.rs @@ -1,36 +1,58 @@ fn validate_group_control_patch(patch: &GroupControlPatch) -> Result, AppError> { if let Some(mode) = patch.mode.as_deref() { if !matches!(mode, "house" | "auto" | "cool" | "heat") { - return Err(AppError::BadRequest("group mode must be house, cool or heat".into())); + return Err(AppError::BadRequest( + "group mode must be house, cool or heat".into(), + )); } } if let Some(preset) = patch.preset.as_deref() { if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") { - return Err(AppError::BadRequest("group preset must be auto, comfort, sleep, away or custom".into())); + return Err(AppError::BadRequest( + "group preset must be auto, comfort, sleep, away or custom".into(), + )); } } if patch.preset.as_deref() == Some("custom") && patch.setpoint.is_none() { - return Err(AppError::BadRequest("group custom preset requires a setpoint".into())); + return Err(AppError::BadRequest( + "group custom preset requires a setpoint".into(), + )); } if let Some(setpoint) = patch.setpoint { if !(8.0..=30.0).contains(&setpoint) { - return Err(AppError::BadRequest("group setpoint must be between 8 and 30 C".into())); + return Err(AppError::BadRequest( + "group setpoint must be between 8 and 30 C".into(), + )); } if patch.preset.as_deref() != Some("custom") { - return Err(AppError::BadRequest("group setpoint requires preset=custom".into())); + return Err(AppError::BadRequest( + "group setpoint requires preset=custom".into(), + )); } } Ok(patch.setpoint.map(|value| (value * 10.0).round() / 10.0)) } -fn defer_group_climate_change(zone: &mut Zone, patch: &GroupControlPatch, custom_setpoint: Option) { - let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return; }; - if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); } +fn defer_group_climate_change( + zone: &mut Zone, + patch: &GroupControlPatch, + custom_setpoint: Option, +) { + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { + return; + }; + if let Some(mode) = patch.mode.as_deref() { + session.deferred_mode = Some(mode.to_string()); + } if let Some(preset) = patch.preset.as_deref() { session.deferred_preset = Some(preset.to_string()); - if preset != "custom" { session.deferred_setpoint = None; } + if preset != "custom" { + session.deferred_setpoint = None; + } + } + if let Some(setpoint) = custom_setpoint { + session.deferred_setpoint = Some(setpoint); } - if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); } } fn apply_group_climate_change( @@ -57,7 +79,9 @@ fn apply_group_climate_change( zone.manual_override_until = None; } else { zone.manual_preset = Some(preset.to_string()); - if preset != "custom" { zone.manual_setpoint = None; } + if preset != "custom" { + zone.manual_setpoint = None; + } zone.manual_override_until = if manual_group_control { None } else { @@ -85,9 +109,13 @@ fn apply_group_member_power( temporary_owns_zone: bool, group_handback_at: Option>, ) -> Result<(Option, bool), Value> { - let Some(power) = requested_power else { return Ok((None, false)); }; + let Some(power) = requested_power else { + return Ok((None, false)); + }; let automatic_power_blocked = source == "automation.group" - && (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone); + && (zone.device_manual_override + || zone.local_thermostat_power.is_some() + || temporary_owns_zone); if automatic_power_blocked { return Err(json!({ "scope": "ownership", @@ -119,16 +147,24 @@ fn update_group_member_ownership( applied_group_power: Option, group_handback_at: Option>, ) { - if temporary_owns_zone || zone.device_manual_override { return; } + if temporary_owns_zone || zone.device_manual_override { + return; + } if applied_group_power == Some(false) { zone.control_owner = "local_thermostat".into(); zone.control_source = "local_thermostat".into(); zone.control_since = Some(Utc::now()); zone.control_resume_at = group_handback_at.clone(); zone.control_reason = if group_handback_at.is_some() { - format!("Group {} powered the thermostat off; automation resumes after {} minutes", group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES) + format!( + "Group {} powered the thermostat off; automation resumes after {} minutes", + group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES + ) } else { - format!("Group {} powered the thermostat off; group ownership released", group.name) + format!( + "Group {} powered the thermostat off; group ownership released", + group.name + ) }; } else if group.power_enabled && zone.local_thermostat_power.is_none() { zone.control_owner = "automation".into(); @@ -141,34 +177,55 @@ fn update_group_member_ownership( zone.control_source = "automation".into(); zone.control_since = Some(Utc::now()); zone.control_resume_at = zone.manual_override_until; - zone.control_reason = format!("Group {} ownership released; zone automation resumed", group.name); + zone.control_reason = format!( + "Group {} ownership released; zone automation resumed", + group.name + ); } } -pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result { +pub async fn control_group( + state: &AppState, + group_id: &str, + patch: GroupControlPatch, + source: &str, +) -> Result { let custom_setpoint = validate_group_control_patch(&patch)?; - let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some(); + let climate_change = + patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some(); // House/group actions share one ordering domain. This prevents a concurrent group ON // from racing with whole-house OFF. Lock order stays house -> cycle -> group -> zone -> device. let _house_guard = state.lock_house_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let _group_guard = state.lock_group_operation(group_id).await; - let mut group = state.db.get_group(group_id)? + let mut group = state + .db + .get_group(group_id)? .ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?; let mut locked_zone_ids = group.zone_ids.clone(); locked_zone_ids.sort(); locked_zone_ids.dedup(); let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len()); - for zone_id in &locked_zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } + for zone_id in &locked_zone_ids { + _zone_guards.push(state.lock_zone_operation(zone_id).await); + } let schedules = state.db.list_schedules()?; let resulting_control_enabled = patch.power.unwrap_or(group.power_enabled); if climate_change && !resulting_control_enabled { if source == "automation.group" { - state.log("info", "automation.group_control_disabled", &format!("Group automation suppressed because group control is disabled for {}", group.name), json!({ - "group_id": group.id, "source": source - })); + state.log( + "info", + "automation.group_control_disabled", + &format!( + "Group automation suppressed because group control is disabled for {}", + group.name + ), + json!({ + "group_id": group.id, "source": source + }), + ); return Ok(json!({ "group": group, "zones": [], @@ -177,17 +234,24 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl "suppressed": true, })); } - return Err(AppError::BadRequest("enable group control before changing group mode, preset or setpoint".into())); + return Err(AppError::BadRequest( + "enable group control before changing group mode, preset or setpoint".into(), + )); } - if let Some(power) = patch.power { group.power_enabled = power; } + if let Some(power) = patch.power { + group.power_enabled = power; + } group.updated_at = Utc::now(); state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); let manual_group_control = source != "automation.group"; let group_handback_at = if manual_group_control && patch.power == Some(false) { - Some(group.updated_at.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)) + Some( + group.updated_at.clone() + + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES), + ) } else { None }; @@ -200,22 +264,34 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl let mut failed = Vec::new(); let mut forced_off_devices = std::collections::HashSet::new(); for zone_id in &group.zone_ids { - let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; + let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { + continue; + }; let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; - let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; - if patch.power.is_some() || climate_change { rearm_compressor_queue(&mut zone); } + let Some(mut zone) = state.db.get_zone(zone_id)? else { + continue; + }; + if patch.power.is_some() || climate_change { + rearm_compressor_queue(&mut zone); + } let mut temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now()); if explicit_group_takeover { if zone.temporary_quick_thermostat.is_some() { if temporary_owns_zone { - finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); + finish_temporary_quick_thermostat( + &mut zone, + &schedules, + &state.settings.read().await.house_mode, + ); } else { zone.temporary_quick_thermostat = None; } temporary_owns_zone = false; } - if zone.device_manual_override { reset_device_manual_override(&mut zone); } + if zone.device_manual_override { + reset_device_manual_override(&mut zone); + } } if temporary_owns_zone { @@ -226,16 +302,35 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl })); } } else { - apply_group_climate_change(&mut zone, &patch, custom_setpoint, manual_group_control, &schedules); + apply_group_climate_change( + &mut zone, + &patch, + custom_setpoint, + manual_group_control, + &schedules, + ); } let (applied_group_power, should_force_off) = match apply_group_member_power( - &mut zone, requested_member_power, source, temporary_owns_zone, group_handback_at.clone(), + &mut zone, + requested_member_power, + source, + temporary_owns_zone, + group_handback_at.clone(), ) { Ok(result) => result, - Err(error) => { failed.push(error); (None, false) } + Err(error) => { + failed.push(error); + (None, false) + } }; - update_group_member_ownership(&mut zone, &group, temporary_owns_zone, applied_group_power, group_handback_at); + update_group_member_ownership( + &mut zone, + &group, + temporary_owns_zone, + applied_group_power, + group_handback_at, + ); zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); @@ -261,12 +356,21 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl drop(_cycle_guard); drop(_house_guard); if let Err(err) = run_zone_control_now(state).await { - state.log("error", "group.immediate_control_error", &err.to_string(), json!({ - "group_id": group.id, "source": source, - })); + state.log( + "error", + "group.immediate_control_error", + &err.to_string(), + json!({ + "group_id": group.id, "source": source, + }), + ); failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()})); } - group.zone_ids.iter().filter_map(|zone_id| state.db.get_zone(zone_id).ok().flatten()).collect::>() + group + .zone_ids + .iter() + .filter_map(|zone_id| state.db.get_zone(zone_id).ok().flatten()) + .collect::>() } else { zones }; diff --git a/src/engine/history.rs b/src/engine/history.rs index e941464..eb3a75f 100644 --- a/src/engine/history.rs +++ b/src/engine/history.rs @@ -1,4 +1,9 @@ -fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option, poll_interval_seconds: u64) { +fn record_zone_history( + state: &AppState, + zone: &Zone, + outdoor_temperature: Option, + poll_interval_seconds: u64, +) { let device = match state.db.get_device(&zone.device_id) { Ok(Some(device)) => device, Ok(None) => return, @@ -14,12 +19,22 @@ fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Optio timestamp: Utc::now(), gree_temperature: zone.device_temperature.or(device.current_temperature), external_temperature: zone.external_temperature, - control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature), - target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)), + control_temperature: zone + .current_temperature + .or(zone.device_temperature) + .or(device.current_temperature), + target_temperature: zone + .effective_setpoint + .or(zone.manual_setpoint) + .or(Some(zone.setpoint)), device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)), outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature), power: device.power, - mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() }, + mode: if zone.effective_mode.is_empty() { + device.mode.clone() + } else { + zone.effective_mode.clone() + }, fan_speed: device.fan_speed, demand: zone.demand, control_source: zone.control_temperature_source.clone(), @@ -53,7 +68,9 @@ fn record_ha_history( match state.db.add_ha_reading_if_due(&reading, interval) { Ok(true) => queue_influx_ha(state, reading), Ok(false) => {} - Err(err) => tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"), + Err(err) => { + tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample") + } } } @@ -61,7 +78,9 @@ fn queue_influx_device(state: &AppState, reading: Reading) { let state = state.clone(); tokio::spawn(async move { let settings = state.settings.read().await.influxdb.clone(); - if !settings.enabled { return; } + if !settings.enabled { + return; + } if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await { tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB"); } @@ -72,7 +91,9 @@ fn queue_influx_zone(state: &AppState, reading: ZoneReading) { let state = state.clone(); tokio::spawn(async move { let settings = state.settings.read().await.influxdb.clone(); - if !settings.enabled { return; } + if !settings.enabled { + return; + } if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await { tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB"); } @@ -83,7 +104,9 @@ fn queue_influx_ha(state: &AppState, reading: HaReading) { let state = state.clone(); tokio::spawn(async move { let settings = state.settings.read().await.influxdb.clone(); - if !settings.enabled { return; } + if !settings.enabled { + return; + } if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await { tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB"); } @@ -91,12 +114,15 @@ fn queue_influx_ha(state: &AppState, reading: HaReading) { } fn gree_outdoor_temperature(devices: &[Device]) -> Option { - let mut values: Vec = devices.iter() + let mut values: Vec = devices + .iter() .filter(|device| device.enabled && device.online && device.communication_failures == 0) .filter_map(|device| device.outdoor_temperature) .filter(|value| value.is_finite() && (-60.0..=70.0).contains(value)) .collect(); - if values.is_empty() { return None; } + if values.is_empty() { + return None; + } values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let middle = values.len() / 2; let value = if values.len() % 2 == 0 { @@ -106,4 +132,3 @@ fn gree_outdoor_temperature(devices: &[Device]) -> Option { }; Some((value * 10.0).round() / 10.0) } - diff --git a/src/engine/local_thermostat.rs b/src/engine/local_thermostat.rs index df9b966..09744ce 100644 --- a/src/engine/local_thermostat.rs +++ b/src/engine/local_thermostat.rs @@ -48,7 +48,10 @@ pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime) -> bool { - if zone.local_thermostat_power != Some(false) || zone.local_thermostat_resume_at.is_none() { return false; } + if zone.local_thermostat_power != Some(false) || zone.local_thermostat_resume_at.is_none() { + return false; + } set_local_thermostat_power(zone, false, now) } @@ -82,11 +87,15 @@ fn local_thermostat_handback_is_active(zone: &Zone) -> bool { /// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is /// deliberately untouched; the two ownership mechanisms have independent cleanup paths. pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool { - let temporary_active = zone.temporary_quick_thermostat.as_ref() + let temporary_active = zone + .temporary_quick_thermostat + .as_ref() .map(|session| session.activated_at.is_some()) .unwrap_or(false); if temporary_active { - let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return false; }; + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { + return false; + }; let changed = session.restore_local_thermostat_power.is_some() || session.restore_local_thermostat_resume_at.is_some() || session.restore_local_thermostat_zone_enabled.is_some() @@ -120,4 +129,3 @@ pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool { } changed } - diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index 40a4459..708523a 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -5,13 +5,20 @@ pub fn refresh_control_ownership(zone: &mut Zone) { "home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(), _ => "external".into(), }; - ("direct_manual", source, zone.device_manual_override_until, "Direct/manual device control has priority".to_string()) + ( + "direct_manual", + source, + zone.device_manual_override_until, + "Direct/manual device control has priority".to_string(), + ) } else if zone.local_thermostat_power.is_some() { let source = match zone.control_source.as_str() { "home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(), _ => "local_thermostat".into(), }; - let resume_at = zone.temporary_quick_thermostat.as_ref() + let resume_at = zone + .temporary_quick_thermostat + .as_ref() .and_then(temporary_quick_thermostat_next_deadline) .or(zone.local_thermostat_resume_at.clone()); let reason = if zone.local_thermostat_power == Some(false) { @@ -24,13 +31,20 @@ pub fn refresh_control_ownership(zone: &mut Zone) { ("local_thermostat", source, resume_at, reason) } else { let source = if zone.control_source.starts_with("group:") - || matches!(zone.control_source.as_str(), "automation.device" | "house_power") - { + || matches!( + zone.control_source.as_str(), + "automation.device" | "house_power" + ) { zone.control_source.clone() } else { "automation".into() }; - ("automation", source, zone.manual_override_until, "Automatic thermostat/schedule control".to_string()) + ( + "automation", + source, + zone.manual_override_until, + "Automatic thermostat/schedule control".to_string(), + ) }; if zone.control_owner != owner || zone.control_source != source { zone.control_since = Some(now); @@ -44,9 +58,13 @@ pub fn refresh_control_ownership(zone: &mut Zone) { } fn normalized_direct_source(source: &str) -> &'static str { - if source.contains("home_assistant") { "home_assistant_direct" } - else if source == "device.manual_control" { "web_direct" } - else { "external" } + if source.contains("home_assistant") { + "home_assistant_direct" + } else if source == "device.manual_control" { + "web_direct" + } else { + "external" + } } pub fn reset_device_manual_override(zone: &mut Zone) -> bool { @@ -62,7 +80,9 @@ pub fn reset_device_manual_override(zone: &mut Zone) -> bool { zone.device_manual_override_fields.clear(); zone.device_manual_override_baseline = None; if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - let pause = session.paused_at.take() + let pause = session + .paused_at + .take() .map(|paused_at| now.signed_duration_since(paused_at)) .filter(|pause| *pause > chrono::Duration::zero()); if session.activated_at.is_some() { @@ -70,7 +90,10 @@ pub fn reset_device_manual_override(zone: &mut Zone) -> bool { if matches!(session.finish_kind.as_str(), "duration" | "until") { session.expires_at = session.expires_at.map(|at| at + pause); } - if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { + if matches!( + session.finish_kind.as_str(), + "temperature_reached" | "temperature_stable" + ) { session.safety_expires_at = session.safety_expires_at.map(|at| at + pause); } } @@ -100,46 +123,85 @@ pub fn reset_device_manual_override(zone: &mut Zone) -> bool { #[cfg(test)] fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool { - let Some(baseline) = zone.device_manual_override_baseline.as_ref() else { return false; }; - if zone.device_manual_override_fields.is_empty() { return false; } + let Some(baseline) = zone.device_manual_override_baseline.as_ref() else { + return false; + }; + if zone.device_manual_override_fields.is_empty() { + return false; + } // If the unit was OFF before takeover, returning it to OFF is operationally the same // controller state even if the remote retained a different mode/target internally. // Those dormant values will be set explicitly if automation later powers the unit. - if !baseline.power { return !device.power; } - zone.device_manual_override_fields.iter().all(|field| match field.as_str() { - "power" => device.power == baseline.power, - "mode" => device.mode == baseline.mode, - "target_temperature" => device.target_temperature.round() == baseline.target_temperature.round(), - "fan_speed" => device.fan_speed == baseline.fan_speed, - "quiet" => device.quiet == baseline.quiet, - "sleep" => device.sleep == baseline.sleep, - _ => false, - }) + if !baseline.power { + return !device.power; + } + zone.device_manual_override_fields + .iter() + .all(|field| match field.as_str() { + "power" => device.power == baseline.power, + "mode" => device.mode == baseline.mode, + "target_temperature" => { + device.target_temperature.round() == baseline.target_temperature.round() + } + "fan_speed" => device.fan_speed == baseline.fan_speed, + "quiet" => device.quiet == baseline.quiet, + "sleep" => device.sleep == baseline.sleep, + _ => false, + }) } -fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str, restored: bool) -> Result { - if !reset_device_manual_override(zone) { return Ok(false); } +fn persist_manual_override_clear( + state: &AppState, + zone: &mut Zone, + source: &str, + restored: bool, +) -> Result { + if !reset_device_manual_override(zone) { + return Ok(false); + } let now = Utc::now(); let local_resume_rearmed = restored && rearm_local_thermostat_resume(zone, now.clone()); zone.updated_at = now; state.db.save_zone(zone)?; state.broadcast("zone.updated", serde_json::to_value(&*zone)?); let (kind, message) = if restored { - ("zone.device_manual_override_restored", format!("Manual device control returned {} to its previous state", zone.name)) + ( + "zone.device_manual_override_restored", + format!( + "Manual device control returned {} to its previous state", + zone.name + ), + ) } else { - ("zone.device_manual_override_cleared", format!("Manual device control ended for {}", zone.name)) + ( + "zone.device_manual_override_cleared", + format!("Manual device control ended for {}", zone.name), + ) }; - state.log("info", kind, &message, json!({ - "zone_id": zone.id, "device_id": zone.device_id, "source": source, - "local_thermostat_resume_rearmed": local_resume_rearmed, - "local_thermostat_resume_at": zone.local_thermostat_resume_at, - })); + state.log( + "info", + kind, + &message, + json!({ + "zone_id": zone.id, "device_id": zone.device_id, "source": source, + "local_thermostat_resume_rearmed": local_resume_rearmed, + "local_thermostat_resume_at": zone.local_thermostat_resume_at, + }), + ); state.wake_zone_control(); Ok(true) } -fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec, source: &str, baseline: &Device) -> Result<(), AppError> { - if fields.is_empty() { return Ok(()); } +fn set_device_manual_override( + state: &AppState, + zone: &mut Zone, + fields: Vec, + source: &str, + baseline: &Device, +) -> Result<(), AppError> { + if fields.is_empty() { + return Ok(()); + } let now = Utc::now(); if !zone.device_manual_override { zone.device_manual_override_since = Some(now); @@ -151,7 +213,9 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec Result<(), AppError> { - if before.id != after.id { return Ok(()); } - for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { +async fn detect_external_device_control( + state: &AppState, + before: &Device, + after: &Device, +) -> Result<(), AppError> { + if before.id != after.id { + return Ok(()); + } + for mut zone in state + .db + .list_zones()? + .into_iter() + .filter(|zone| zone.device_id == after.id) + { let raw_fields = externally_changed_control_fields(before, after, &zone); let controller_settling = if raw_fields.is_empty() { json!({ "active": false, "reason": "no_changed_control_fields" }) } else { controller_settling_diagnostics(state, &after.id).await }; - let fields = suppress_expected_controller_changes( - state, - after, - raw_fields.clone(), - ).await; + let fields = suppress_expected_controller_changes(state, after, raw_fields.clone()).await; // Once direct/pilot ownership has been detected, merely returning the device to a // previous physical state must not silently hand control back to schedules. Only the // explicit Resume automation action ends manual ownership. - if fields.is_empty() { continue; } + if fields.is_empty() { + continue; + } // A disabled zone is outside controller ownership. When its manually operated unit is // switched off there is no takeover left to display or remember. if !zone.enabled && !after.power { persist_manual_override_clear(state, &mut zone, "gree_poll", false)?; continue; } - state.log("info", "device.remote_control_detected", &format!("External/pilot control detected for {}", zone.name), json!({ - "zone_id": zone.id.clone(), - "device_id": zone.device_id.clone(), - "raw_fields": raw_fields, - "detected_fields": fields.clone(), - "before": device_control_snapshot(before), - "after": device_control_snapshot(after), - "controller_settling": controller_settling, - "source": "gree_poll", - "zone_state": { - "enabled": zone.enabled, - "control_owner": zone.control_owner.clone(), - "control_source": zone.control_source.clone(), - "demand": zone.demand, - "local_thermostat_power": zone.local_thermostat_power, - "temporary_quick_thermostat": zone.temporary_quick_thermostat.clone(), - }, - })); + state.log( + "info", + "device.remote_control_detected", + &format!("External/pilot control detected for {}", zone.name), + json!({ + "zone_id": zone.id.clone(), + "device_id": zone.device_id.clone(), + "raw_fields": raw_fields, + "detected_fields": fields.clone(), + "before": device_control_snapshot(before), + "after": device_control_snapshot(after), + "controller_settling": controller_settling, + "source": "gree_poll", + "zone_state": { + "enabled": zone.enabled, + "control_owner": zone.control_owner.clone(), + "control_source": zone.control_source.clone(), + "demand": zone.demand, + "local_thermostat_power": zone.local_thermostat_power, + "temporary_quick_thermostat": zone.temporary_quick_thermostat.clone(), + }, + }), + ); set_device_manual_override(state, &mut zone, fields, "gree_poll", before)?; } Ok(()) } -pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str, allow_disabled_zone: bool) -> Result { +pub async fn send_manual_command( + state: &AppState, + device_id: &str, + command: DeviceCommand, + source: &str, + allow_disabled_zone: bool, +) -> Result { // Stabilize device <-> zone membership while validating the manual-control safety gate. // Lock order remains configuration -> zone(s) -> device. let _configuration_guard = state.lock_configuration_operation().await; - let zones: Vec = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).collect(); + let zones: Vec = state + .db + .list_zones()? + .into_iter() + .filter(|zone| zone.device_id == device_id) + .collect(); let mut zone_ids: Vec = zones.iter().map(|zone| zone.id.clone()).collect(); zone_ids.sort(); zone_ids.dedup(); let mut _zone_guards = Vec::new(); - for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } + for zone_id in &zone_ids { + _zone_guards.push(state.lock_zone_operation(zone_id).await); + } if !allow_disabled_zone { // Re-read after taking the zone lock(s): a quick thermostat action may have changed // enabled state while this request was waiting, even though membership is stable. - if let Some(zone) = state.db.list_zones()?.into_iter().find(|zone| zone.device_id == device_id && !zone.enabled) { + if let Some(zone) = state + .db + .list_zones()? + .into_iter() + .find(|zone| zone.device_id == device_id && !zone.enabled) + { return Err(AppError::BadRequest(format!( "device belongs to disabled thermostat zone '{}'; explicit manual_override=true is required for direct control", zone.name @@ -259,7 +364,9 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev // Keep the device lock until the zone takeover marker is persisted. Otherwise a poll // could observe our own just-sent command before the controller records manual ownership. let _device_guard = state.lock_device_operation(device_id).await; - let before = state.db.get_device(device_id)? + let before = state + .db + .get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; // An explicit direct-control request is an ownership action even when the requested // value already matches the cached device state. Derive takeover fields from the user's @@ -268,7 +375,12 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev let fields = command_manual_control_fields(&command); let updated = send_command_locked_inner(state, device_id, command, true, false).await?; if !fields.is_empty() { - for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { + for mut zone in state + .db + .list_zones()? + .into_iter() + .filter(|zone| zone.device_id == device_id) + { if !zone.enabled && !updated.power { persist_manual_override_clear(state, &mut zone, source, false)?; continue; @@ -279,10 +391,17 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev Ok(updated) } -pub async fn force_house_power_off_device(state: &AppState, device_id: &str, _source: &str) -> Result { +pub async fn force_house_power_off_device( + state: &AppState, + device_id: &str, + _source: &str, +) -> Result { // Whole-house OFF physically forces the unit down after the API has persisted per-zone local OFF. // Keep zone -> device ordering so a concurrent local/manual action cannot race the frame. - let mut zone_ids: Vec = state.db.list_zones()?.into_iter() + let mut zone_ids: Vec = state + .db + .list_zones()? + .into_iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id) .collect(); @@ -296,11 +415,17 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, _so force_power_off_device_locked(state, device_id).await } -pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -> Result { +pub async fn one_shot_house_power_on_device( + state: &AppState, + device_id: &str, +) -> Result { // Global ON releases per-zone OFF state in the API and must not create a local-ON ownership marker. For thermostat-managed // units it still respects compressor protection; a protected start is stored as a visible // queue item and executed at the protection deadline unless a newer intent cancels/replaces it. - let mut zone_ids: Vec = state.db.list_zones()?.into_iter() + let mut zone_ids: Vec = state + .db + .list_zones()? + .into_iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id) .collect(); @@ -311,9 +436,13 @@ pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) - _zone_guards.push(state.lock_zone_operation(zone_id).await); } let _device_guard = state.lock_device_operation(device_id).await; - let device = state.db.get_device(device_id)? + let device = state + .db + .get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; - if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } + if !device.enabled { + return Err(AppError::BadRequest("device is disabled".into())); + } if device.power { return Ok(device); } @@ -323,22 +452,36 @@ pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) - if let Some(zone_id) = zone_ids.first() { if let Some(mut zone) = state.db.get_zone(zone_id)? { let now = Utc::now(); - let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64); + let protection = + chrono::Duration::seconds(settings.compressor_protection_seconds as i64); if let Some(last_change) = zone.last_power_change_at { let until = last_change + protection; if until > now { rearm_compressor_queue(&mut zone); - queue_compressor_action(&mut zone, "global_power_on".into(), until, "minimum_off_before_global_start"); + queue_compressor_action( + &mut zone, + "global_power_on".into(), + until, + "minimum_off_before_global_start", + ); zone.revision = zone.revision.saturating_add(1); zone.updated_at = now; state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); - state.log("info", "zone.compressor_queue_queued", &format!("Queued global ON for {} behind compressor protection", zone.name), json!({ - "zone_id": zone.id, - "device_id": zone.device_id, - "action": "global_power_on", - "resume_at": zone.compressor_pending_until, - })); + state.log( + "info", + "zone.compressor_queue_queued", + &format!( + "Queued global ON for {} behind compressor protection", + zone.name + ), + json!({ + "zone_id": zone.id, + "device_id": zone.device_id, + "action": "global_power_on", + "resume_at": zone.compressor_pending_until, + }), + ); state.wake_zone_control(); return Ok(device); } @@ -348,32 +491,67 @@ pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) - } // The device lock is already held. This is physical global power only: no manual marker. - send_command_locked(state, device_id, DeviceCommand { power: Some(true), ..Default::default() }).await + send_command_locked( + state, + device_id, + DeviceCommand { + power: Some(true), + ..Default::default() + }, + ) + .await } pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result { let _device_guard = state.lock_device_operation(device_id).await; - send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await + send_command_locked_forced( + state, + device_id, + DeviceCommand { + power: Some(false), + ..Default::default() + }, + ) + .await } /// Same safety transition for callers that already hold the per-device operation lock. /// Keeping this separate avoids recursive lock acquisition during atomic configuration import. -pub async fn force_power_off_device_locked(state: &AppState, device_id: &str) -> Result { - send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await +pub async fn force_power_off_device_locked( + state: &AppState, + device_id: &str, +) -> Result { + send_command_locked_forced( + state, + device_id, + DeviceCommand { + power: Some(false), + ..Default::default() + }, + ) + .await } /// Technical device disable is a safety transition, not just a database flag. The unit is /// explicitly powered off while it is still commandable, then removed from controller polling. pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result { let _device_guard = state.lock_device_operation(device_id).await; - let mut device = state.db.get_device(device_id)? + let mut device = state + .db + .get_device(device_id)? .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; - if !device.enabled { return Ok(device); } + if !device.enabled { + return Ok(device); + } device = send_command_locked_forced( state, device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - ).await?; + DeviceCommand { + power: Some(false), + ..Default::default() + }, + ) + .await?; device.enabled = false; device.updated_at = Utc::now(); state.db.save_device(&device)?; @@ -381,4 +559,3 @@ pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result< state.wake_zone_control(); Ok(device) } - diff --git a/src/engine/runtime.rs b/src/engine/runtime.rs index 8fa229a..db58144 100644 --- a/src/engine/runtime.rs +++ b/src/engine/runtime.rs @@ -1,8 +1,14 @@ -fn reset_temporary_condition_observations_after_restart(state: &AppState) -> Result { +fn reset_temporary_condition_observations_after_restart( + state: &AppState, +) -> Result { let mut changed = 0usize; for mut zone in state.db.list_zones()? { - let Some(session) = zone.temporary_quick_thermostat.as_mut() else { continue; }; - if session.condition_started_at.is_none() && session.condition_last_observed_at.is_none() { continue; } + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { + continue; + }; + if session.condition_started_at.is_none() && session.condition_last_observed_at.is_none() { + continue; + } session.condition_started_at = None; session.condition_last_observed_at = None; zone.updated_at = Utc::now(); @@ -24,13 +30,23 @@ pub fn start(state: AppState) { loop { match poll_all(&poll_state).await { Ok(()) => { - if !poll_state.initial_device_sync_complete.swap(true, Ordering::AcqRel) { - tracing::info!("initial device state synchronized; thermostat control enabled"); + if !poll_state + .initial_device_sync_complete + .swap(true, Ordering::AcqRel) + { + tracing::info!( + "initial device state synchronized; thermostat control enabled" + ); } } Err(err) => tracing::error!(error=?err, "device poll cycle failed"), } - let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2); + let seconds = poll_state + .settings + .read() + .await + .poll_interval_seconds + .max(2); sleep(Duration::from_secs(seconds)).await; } }); @@ -42,7 +58,10 @@ pub fn start(state: AppState) { // A restart must never make decisions from the persisted, potentially stale // device snapshot. Wait for one full live poll before thermostat/schedule/automation // ownership can emit commands. Manual API/remote control remains available. - if !control_state.initial_device_sync_complete.load(Ordering::Acquire) { + if !control_state + .initial_device_sync_complete + .load(Ordering::Acquire) + { sleep(Duration::from_millis(250)).await; continue; } @@ -52,7 +71,12 @@ pub fn start(state: AppState) { if let Err(err) = run_automations(&control_state).await { tracing::error!(error=?err, "automation cycle failed"); } - let seconds = control_state.settings.read().await.zone_interval_seconds.max(2); + let seconds = control_state + .settings + .read() + .await + .zone_interval_seconds + .max(2); let normal_delay = Duration::from_secs(seconds); let resume_delay = match next_zone_control_deadline_delay(&control_state) { Ok(value) => value, @@ -61,7 +85,9 @@ pub fn start(state: AppState) { None } }; - let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay); + let sleep_for = resume_delay + .map(|delay| delay.min(normal_delay)) + .unwrap_or(normal_delay); tokio::select! { _ = sleep(sleep_for) => {}, _ = control_state.zone_control_wakeup.notified() => {}, @@ -76,7 +102,11 @@ pub fn start(state: AppState) { let settings = maintenance_state.settings.read().await.clone(); // When InfluxDB is enabled, compact all locally retained legacy history before // transferring old buckets. Without Influx, compact only the configured retention window. - let compaction_days = if settings.influxdb.enabled { 3650 } else { settings.history_retention_days.max(1) } as i64; + let compaction_days = if settings.influxdb.enabled { + 3650 + } else { + settings.history_retention_days.max(1) + } as i64; if settings.history_compaction_enabled { match maintenance_state.db.compact_history(compaction_days) { Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"), @@ -85,22 +115,36 @@ pub fn start(state: AppState) { } } if settings.influxdb.enabled { - match archive_old_history(&maintenance_state, settings.influxdb.history_threshold_days.max(1)).await { - Ok(count) if count > 0 => tracing::info!(count, "old local readings archived to InfluxDB and removed from SQLite"), + match archive_old_history( + &maintenance_state, + settings.influxdb.history_threshold_days.max(1), + ) + .await + { + Ok(count) if count > 0 => tracing::info!( + count, + "old local readings archived to InfluxDB and removed from SQLite" + ), Ok(_) => {} - Err(err) => tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept"), + Err(err) => { + tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept") + } } } else { let retention_days = settings.history_retention_days.max(1) as i64; match maintenance_state.db.prune_readings(retention_days) { - Ok(count) if count > 0 => tracing::info!(count, retention_days, "old local readings pruned"), + Ok(count) if count > 0 => { + tracing::info!(count, retention_days, "old local readings pruned") + } Ok(_) => {} Err(err) => tracing::warn!(error=?err, "cannot prune readings"), } } let event_retention_days = settings.event_log_retention_days.max(1) as i64; match maintenance_state.db.prune_events(event_retention_days) { - Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"), + Ok(count) if count > 0 => { + tracing::info!(count, event_retention_days, "old event log rows pruned") + } Ok(_) => {} Err(err) => tracing::warn!(error=?err, "cannot prune event log"), } @@ -117,12 +161,15 @@ async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result(zone: &Zone, schedules: &'a [Schedule], now: DateTime) -> Option<&'a Schedule> { - schedules.iter() +fn active_schedule_for_zone<'a>( + zone: &Zone, + schedules: &'a [Schedule], + now: DateTime, +) -> Option<&'a Schedule> { + schedules + .iter() .filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now)) // Overlaps are rejected by the API, but imported/legacy data may still contain one. // Prefer the most recently edited entry instead of depending on database/name order. @@ -7,11 +12,18 @@ fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: Dat } fn minute_floor(now: DateTime) -> DateTime { - now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now) + now.with_second(0) + .and_then(|value| value.with_nanosecond(0)) + .unwrap_or(now) } -pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime) -> Option> { - let current = schedules.iter() +pub fn next_schedule_boundary_utc( + zone_id: &str, + schedules: &[Schedule], + now: DateTime, +) -> Option> { + let current = schedules + .iter() .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now)) .max_by_key(|item| item.updated_at) .map(|item| item.id.as_str()); @@ -19,8 +31,11 @@ pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: Da // Eight days cover a complete weekly schedule plus the next transition. for minute in 1..=(8 * 24 * 60) { let candidate = base + chrono::Duration::minutes(minute); - let next = schedules.iter() - .filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate)) + let next = schedules + .iter() + .filter(|item| { + item.enabled && item.zone_id == zone_id && schedule_active(item, candidate) + }) .max_by_key(|item| item.updated_at) .map(|item| item.id.as_str()); if next != current { @@ -32,8 +47,12 @@ pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: Da } fn schedule_active(item: &Schedule, now: DateTime) -> bool { - let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; }; - let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; }; + let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { + return false; + }; + let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { + return false; + }; let time = now.time(); let today = now.weekday().number_from_monday(); if start == end { @@ -63,11 +82,15 @@ fn schedule_week_mask(item: &Schedule) -> Option> { let end_minute = (end.hour() * 60 + end.minute()) as usize; let mut mask = vec![false; 7 * 24 * 60]; for weekday in &item.weekdays { - if !(1..=7).contains(weekday) { return None; } + if !(1..=7).contains(weekday) { + return None; + } let day = (*weekday as usize) - 1; let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| { let base = (day % 7) * 24 * 60; - for minute in from..to { mask[base + minute] = true; } + for minute in from..to { + mask[base + minute] = true; + } }; if start_minute == end_minute { mark(&mut mask, day, start_minute, 24 * 60); @@ -83,16 +106,23 @@ fn schedule_week_mask(item: &Schedule) -> Option> { } pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool { - if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; } - let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; }; + if !a.enabled || !b.enabled || a.zone_id != b.zone_id { + return false; + } + let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { + return false; + }; left.iter().zip(right.iter()).any(|(a, b)| *a && *b) } fn previous_weekday(day: Weekday) -> Weekday { match day { - Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue, - Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri, + Weekday::Mon => Weekday::Sun, + Weekday::Tue => Weekday::Mon, + Weekday::Wed => Weekday::Tue, + Weekday::Thu => Weekday::Wed, + Weekday::Fri => Weekday::Thu, + Weekday::Sat => Weekday::Fri, Weekday::Sun => Weekday::Sat, } } - diff --git a/src/engine/targets.rs b/src/engine/targets.rs index 109b011..5b78589 100644 --- a/src/engine/targets.rs +++ b/src/engine/targets.rs @@ -1,5 +1,7 @@ fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 { - if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; } + if zone.profile_version == 0 && preset == "comfort" { + return zone.setpoint; + } match (mode, preset) { ("heat", "sleep") => zone.heat_sleep_setpoint, ("heat", "away") => zone.heat_away_setpoint, @@ -21,7 +23,10 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> if item.preset == "custom" { ("custom".into(), item.setpoint) } else { - (item.preset.clone(), profile_setpoint(zone, &item.preset, mode)) + ( + item.preset.clone(), + profile_setpoint(zone, &item.preset, mode), + ) } } else { // Comfort is only a fallback target for an explicit controller (local/group/etc.). @@ -36,20 +41,31 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> /// Return whether some thermostat source currently asks this zone to participate in climate /// control. Heat/Cool configuration plus the implicit Comfort fallback is intentionally not a /// source on its own. -fn zone_has_thermostat_intent_source(zone: &Zone, schedule: Option<&Schedule>, now: DateTime) -> bool { +fn zone_has_thermostat_intent_source( + zone: &Zone, + schedule: Option<&Schedule>, + now: DateTime, +) -> bool { zone.local_thermostat_power == Some(true) || temporary_quick_thermostat_is_active(zone, now) || zone.manual_preset.is_some() || zone.manual_setpoint.is_some() || zone.control_source.starts_with("group:") - || matches!(zone.control_source.as_str(), "automation.device" | "house_power") + || matches!( + zone.control_source.as_str(), + "automation.device" | "house_power" + ) || schedule.is_some() } /// Automatic thermostat arbitration may run only when an explicit source exists and no higher /// priority OFF/manual gate blocks it. This keeps a resumed/global/group-OFF zone physically OFF /// while it waits for the next schedule window or another explicit thermostat request. -fn zone_has_active_thermostat_intent(zone: &Zone, schedule: Option<&Schedule>, now: DateTime) -> bool { +fn zone_has_active_thermostat_intent( + zone: &Zone, + schedule: Option<&Schedule>, + now: DateTime, +) -> bool { zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false) @@ -58,7 +74,11 @@ fn zone_has_active_thermostat_intent(zone: &Zone, schedule: Option<&Schedule>, n pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) { let configured_mode = effective_zone_mode(zone, house_mode); - let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() }; + let target_mode = if configured_mode == "off" { + zone.mode.as_str() + } else { + configured_mode.as_str() + }; let schedule = active_schedule_for_zone(zone, schedules, Local::now()); let has_intent_source = zone_has_thermostat_intent_source(zone, schedule, Utc::now()); @@ -95,12 +115,23 @@ pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], hous fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String { if temporary_quick_thermostat_is_active(zone, Utc::now()) { - if let Some(mode) = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.active_mode.as_deref()) { - if matches!(mode, "cool" | "heat") { return mode.to_string(); } + if let Some(mode) = zone + .temporary_quick_thermostat + .as_ref() + .and_then(|session| session.active_mode.as_deref()) + { + if matches!(mode, "cool" | "heat") { + return mode.to_string(); + } } } - let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; - let scoped_manual = zone.local_thermostat_power == Some(true) || zone.control_source.starts_with("group:"); + let configured = if zone.inherit_house_mode { + house_mode + } else { + zone.mode.as_str() + }; + let scoped_manual = + zone.local_thermostat_power == Some(true) || zone.control_source.starts_with("group:"); if scoped_manual && configured == "off" { // Explicit local/group control is independent from house "Do not control". Reuse the // zone's last concrete heat/cool mode instead of turning a manual action into a no-op. @@ -109,4 +140,3 @@ fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String { configured.to_string() } } - diff --git a/src/engine/temperature.rs b/src/engine/temperature.rs index 61e0113..5c87095 100644 --- a/src/engine/temperature.rs +++ b/src/engine/temperature.rs @@ -1,4 +1,8 @@ -fn select_zone_temperature(zone: &Zone, device_temperature: Option, external_temperature: Option) -> (Option, String, bool) { +fn select_zone_temperature( + zone: &Zone, + device_temperature: Option, + external_temperature: Option, +) -> (Option, String, bool) { match zone.sensor_source.as_str() { "home_assistant" => match (external_temperature, device_temperature) { (Some(value), _) => (Some(value), "external".into(), false), @@ -12,7 +16,11 @@ fn select_zone_temperature(zone: &Zone, device_temperature: Option, externa } else { let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0); let value = device * (1.0 - external_weight) + external * external_weight; - (Some((value * 10.0).round() / 10.0), "combined".into(), false) + ( + Some((value * 10.0).round() / 10.0), + "combined".into(), + false, + ) } } (Some(value), None) => (Some(value), "device_fallback".into(), false), @@ -27,19 +35,29 @@ fn select_zone_temperature(zone: &Zone, device_temperature: Option, externa } fn adjustment_allowed(zone: &Zone) -> bool { - let Some(last) = zone.last_action_at else { return true; }; + let Some(last) = zone.last_action_at else { + return true; + }; (Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15) } fn external_room_sensor_cooling_assist(mode: &str, control_source: &str) -> f64 { - if mode == "cool" && matches!(control_source, "external" | "combined") { 1.0 } else { 0.0 } + if mode == "cool" && matches!(control_source, "external" | "combined") { + 1.0 + } else { + 0.0 + } } fn effective_sensor_stale_after_seconds(zone_value: u64, global_value: u64) -> u64 { let global = global_value.clamp(30, 86_400); // 0 and the historical hidden default (300 s) mean "inherit the HA setting". // A non-default value supplied through the existing zone API remains a per-zone override. - if zone_value == 0 || zone_value == 300 { global } else { zone_value.clamp(30, 86_400) } + if zone_value == 0 || zone_value == 300 { + global + } else { + zone_value.clamp(30, 86_400) + } } fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 { @@ -53,7 +71,9 @@ fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 { } fn outdoor_assist_offset(mode: &str, outdoor: Option, room: f64, target: f64) -> f64 { - let Some(outdoor) = outdoor else { return 0.0; }; + let Some(outdoor) = outdoor else { + return 0.0; + }; let room_error = (room - target).abs(); let weather = match mode { "heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0), @@ -72,7 +92,9 @@ fn smart_quiet_command( night_active: bool, night_force_quiet: bool, ) -> Option { - if !quiet_supported { return None; } + if !quiet_supported { + return None; + } if night_enabled && night_force_quiet && night_active { return if device_quiet { None } else { Some(true) }; } @@ -84,13 +106,19 @@ fn smart_quiet_command( // Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a // satisfied room remains in standby: some units report Quiet=false again even after // accepting the command, which otherwise produces a beep every adjustment interval. - if previous_demand && !demand && !device_quiet { return Some(true); } - if !previous_demand && demand && device_quiet { return Some(false); } + if previous_demand && !demand && !device_quiet { + return Some(true); + } + if !previous_demand && demand && device_quiet { + return Some(false); + } return None; } // Without Smart Fan, Quiet can only have been requested by scheduled night mode, // so release it after the night window ends. - if night_enabled && night_force_quiet && device_quiet { return Some(false); } + if night_enabled && night_force_quiet && device_quiet { + return Some(false); + } None } @@ -101,40 +129,67 @@ fn native_sleep_command( sleep_supported: bool, device_sleep: bool, ) -> Option { - if !sleep_supported { return None; } + if !sleep_supported { + return None; + } if night_enabled && use_native_sleep && night_active { return if device_sleep { None } else { Some(true) }; } // If night mode ended or native Sleep was disabled in settings, remove a previously // active device Sleep flag instead of leaving it latched indefinitely. - if device_sleep { return Some(false); } + if device_sleep { + return Some(false); + } None } fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 { let max_fan = max_fan.clamp(1, 5); - if requested == 0 { 1 } else { requested.min(max_fan) } + if requested == 0 { + 1 + } else { + requested.min(max_fan) + } } pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool { - if !settings.enabled { return false; } - let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; }; - let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; }; - if start == end { return true; } - if start < end { time >= start && time < end } else { time >= start || time < end } + if !settings.enabled { + return false; + } + let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { + return false; + }; + let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { + return false; + }; + if start == end { + return true; + } + if start < end { + time >= start && time < end + } else { + time >= start || time < end + } } fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option, demand: bool) -> u8 { // When the thermostat is satisfied, keep airflow quiet instead of leaving the // unit in Auto. The caller sends this together with the standby setpoint in // the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating. - if !demand { return 1; } + if !demand { + return 1; + } let error = (room - target).abs(); let extreme_weather = match (mode, outdoor) { ("heat", Some(value)) => value <= 0.0, (_, Some(value)) => value >= 32.0, _ => false, }; - if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 } + if error >= 2.0 || extreme_weather { + 3 + } else if error >= 1.0 { + 2 + } else { + 0 + } } - diff --git a/src/engine/temporary_thermostat.rs b/src/engine/temporary_thermostat.rs index a75e451..217a8bc 100644 --- a/src/engine/temporary_thermostat.rs +++ b/src/engine/temporary_thermostat.rs @@ -1,12 +1,17 @@ pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime) -> bool { - zone.temporary_quick_thermostat.as_ref() + zone.temporary_quick_thermostat + .as_ref() .and_then(|session| session.activated_at.as_ref()) .map(|activated_at| activated_at <= &now) .unwrap_or(false) } -fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option> { - if session.state == "paused_manual" { return None; } +fn temporary_quick_thermostat_hard_deadline( + session: &TemporaryQuickThermostat, +) -> Option> { + if session.state == "paused_manual" { + return None; + } match (session.expires_at, session.safety_expires_at) { (Some(a), Some(b)) => Some(a.min(b)), (Some(a), None) => Some(a), @@ -15,10 +20,14 @@ fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) } } -fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) -> Option> { +fn temporary_quick_thermostat_next_deadline( + session: &TemporaryQuickThermostat, +) -> Option> { let hard = temporary_quick_thermostat_hard_deadline(session); let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 { - session.condition_started_at.map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64)) + session + .condition_started_at + .map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64)) } else { None }; @@ -42,11 +51,19 @@ fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime) -> Opti /// Finish an active temporary session and apply climate changes that were deferred while /// it owned the zone. Pending-session cancellation should simply remove the session instead. -pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) -> bool { +pub fn finish_temporary_quick_thermostat( + zone: &mut Zone, + schedules: &[Schedule], + house_mode: &str, +) -> bool { let now = Utc::now(); let was_active = temporary_quick_thermostat_is_active(zone, now); - let Some(session) = zone.temporary_quick_thermostat.take() else { return false; }; - if !was_active { return false; } + let Some(session) = zone.temporary_quick_thermostat.take() else { + return false; + }; + if !was_active { + return false; + } zone.local_thermostat_power = session.restore_local_thermostat_power; zone.local_thermostat_resume_at = session.restore_local_thermostat_resume_at; @@ -75,8 +92,11 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule] zone.manual_override_until = None; } else if matches!(preset, "comfort" | "sleep" | "away" | "custom") { zone.manual_preset = Some(preset.to_string()); - if preset != "custom" { zone.manual_setpoint = None; } - zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); + if preset != "custom" { + zone.manual_setpoint = None; + } + zone.manual_override_until = + next_schedule_boundary_utc(&zone.id, schedules, Local::now()); } } // A deferred custom temperature is valid only while the final deferred preset is custom. @@ -87,7 +107,8 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule] zone.setpoint = setpoint; zone.manual_preset = Some("custom".into()); zone.manual_setpoint = Some(setpoint); - zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); + zone.manual_override_until = + next_schedule_boundary_utc(&zone.id, schedules, Local::now()); } } refresh_zone_runtime_target(zone, schedules, house_mode); @@ -97,27 +118,53 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule] true } -async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result, AppError> { +async fn expire_temporary_quick_thermostats( + state: &AppState, + zones: &mut [Zone], + schedules: &[Schedule], + house_mode: &str, +) -> Result, AppError> { let now = Utc::now(); let mut restored_disabled_zones = Vec::new(); for zone in zones.iter_mut() { let zone_id = zone.id.clone(); let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(snapshot) = state.db.get_zone(&zone_id)? else { + continue; + }; let _device_guard = state.lock_device_operation(&snapshot.device_id).await; - let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(latest) = state.db.get_zone(&zone_id)? else { + continue; + }; *zone = latest; let active_under_manual = zone.device_manual_override - && zone.temporary_quick_thermostat.as_ref().and_then(|session| session.activated_at).is_some(); + && zone + .temporary_quick_thermostat + .as_ref() + .and_then(|session| session.activated_at) + .is_some(); if active_under_manual { set_temporary_wait_state(state, zone, "paused_manual", now)?; continue; } - let Some((deadline, finish_kind, restore_zone_enabled)) = zone.temporary_quick_thermostat.as_ref() - .and_then(|session| temporary_quick_thermostat_hard_deadline(session) - .map(|deadline| (deadline, session.finish_kind.clone(), session.restore_zone_enabled))) - else { continue; }; - if deadline > now { continue; } + let Some((deadline, finish_kind, restore_zone_enabled)) = zone + .temporary_quick_thermostat + .as_ref() + .and_then(|session| { + temporary_quick_thermostat_hard_deadline(session).map(|deadline| { + ( + deadline, + session.finish_kind.clone(), + session.restore_zone_enabled, + ) + }) + }) + else { + continue; + }; + if deadline > now { + continue; + } let was_activated = temporary_quick_thermostat_is_active(zone, now); let restores_disabled = was_activated && restore_zone_enabled == Some(false); if was_activated { @@ -130,17 +177,31 @@ async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone] zone.updated_at = now; state.db.save_zone(zone)?; state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, - "reason": if was_activated { "deadline" } else { "expired_before_activation" } - })); - if restores_disabled { restored_disabled_zones.push(zone.id.clone()); } + state.log( + "info", + "zone.temporary_quick_thermostat_finished", + &format!("Temporary Quick Thermostat finished for {}", zone.name), + json!({ + "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, + "reason": if was_activated { "deadline" } else { "expired_before_activation" } + }), + ); + if restores_disabled { + restored_disabled_zones.push(zone.id.clone()); + } } Ok(restored_disabled_zones) } -fn set_temporary_wait_state(state: &AppState, zone: &mut Zone, value: &str, now: DateTime) -> Result<(), AppError> { - let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return Ok(()); }; +fn set_temporary_wait_state( + state: &AppState, + zone: &mut Zone, + value: &str, + now: DateTime, +) -> Result<(), AppError> { + let Some(session) = zone.temporary_quick_thermostat.as_mut() else { + return Ok(()); + }; let mut changed = false; if session.state != value { session.state = value.to_string(); @@ -150,7 +211,9 @@ fn set_temporary_wait_state(state: &AppState, zone: &mut Zone, value: &str, now: session.paused_at = Some(now); changed = true; } - if !changed { return Ok(()); } + if !changed { + return Ok(()); + } session.condition_started_at = None; session.condition_last_observed_at = None; zone.updated_at = now; @@ -169,28 +232,46 @@ async fn activate_due_temporary_quick_thermostats( for zone in zones.iter_mut() { let zone_id = zone.id.clone(); let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(snapshot) = state.db.get_zone(&zone_id)? else { + continue; + }; let _device_guard = state.lock_device_operation(&snapshot.device_id).await; - let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(latest) = state.db.get_zone(&zone_id)? else { + continue; + }; *zone = latest; - let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref() + let Some((session_state, started_at)) = zone + .temporary_quick_thermostat + .as_ref() .map(|session| (session.state.clone(), session.started_at)) - else { continue; }; + else { + continue; + }; if temporary_quick_thermostat_is_active(zone, now) { if session_state != "active" && !zone.device_manual_override { set_temporary_wait_state(state, zone, "active", now)?; } continue; } - if started_at > now { continue; } + if started_at > now { + continue; + } if zone.device_manual_override { set_temporary_wait_state(state, zone, "paused_manual", now)?; continue; } let (temperature_target, duration_seconds, safety_duration_seconds, finish_kind) = { - let session = zone.temporary_quick_thermostat.as_ref().expect("temporary session checked above"); - (session.temperature_target, session.duration_seconds, session.safety_duration_seconds, session.finish_kind.clone()) + let session = zone + .temporary_quick_thermostat + .as_ref() + .expect("temporary session checked above"); + ( + session.temperature_target, + session.duration_seconds, + session.safety_duration_seconds, + session.finish_kind.clone(), + ) }; let target = temperature_target .or(zone.manual_setpoint) @@ -203,11 +284,21 @@ async fn activate_due_temporary_quick_thermostats( let restore_manual_preset = zone.manual_preset.clone(); let restore_manual_setpoint = zone.manual_setpoint; let restore_manual_override_until = zone.manual_override_until; - let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() }; - let active_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() }; + let configured_mode = if zone.inherit_house_mode { + house_mode + } else { + zone.mode.as_str() + }; + let active_mode = if configured_mode == "off" { + zone.mode.clone() + } else { + configured_mode.to_string() + }; let schedule_boundary = if finish_kind == "schedule_boundary" { next_schedule_boundary_utc(&zone.id, schedules, Local::now()) - } else { None }; + } else { + None + }; if finish_kind == "schedule_boundary" && schedule_boundary.is_none() { zone.temporary_quick_thermostat = None; zone.updated_at = now; @@ -247,12 +338,17 @@ async fn activate_due_temporary_quick_thermostats( session.condition_last_observed_at = None; session.paused_at = None; if session.finish_kind == "duration" { - session.expires_at = duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); + session.expires_at = + duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); } else if session.finish_kind == "schedule_boundary" { session.expires_at = schedule_boundary; } - if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") { - session.safety_expires_at = safety_duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64)); + if matches!( + session.finish_kind.as_str(), + "temperature_reached" | "temperature_stable" + ) { + session.safety_expires_at = safety_duration_seconds + .map(|seconds| now + chrono::Duration::seconds(seconds as i64)); } } zone.updated_at = now; @@ -265,25 +361,62 @@ async fn activate_due_temporary_quick_thermostats( Ok(()) } -async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) { - if zone.enabled || zone.device_manual_override || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; } +async fn ensure_device_off_after_temporary_disabled_restore( + state: &AppState, + zone: &Zone, + device: &Device, +) { + if zone.enabled + || zone.device_manual_override + || !device.enabled + || !device.online + || device.communication_failures > 0 + || !device.power + { + return; + } let _device_guard = state.lock_device_operation(&zone.device_id).await; - let should_stop = state.db.get_zone(&zone.id).ok().flatten() - .map(|latest| !latest.enabled - && !latest.device_manual_override - && latest.temporary_quick_thermostat.is_none() - && latest.local_thermostat_power.is_none()) + let should_stop = state + .db + .get_zone(&zone.id) + .ok() + .flatten() + .map(|latest| { + !latest.enabled + && !latest.device_manual_override + && latest.temporary_quick_thermostat.is_none() + && latest.local_thermostat_power.is_none() + }) .unwrap_or(false); - if !should_stop { return; } - if let Err(err) = send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await { - state.log("error", "zone.temporary_quick_thermostat_poweroff_error", &err.to_string(), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })); + if !should_stop { + return; + } + if let Err(err) = send_command_locked( + state, + &zone.device_id, + DeviceCommand { + power: Some(false), + ..Default::default() + }, + ) + .await + { + state.log( + "error", + "zone.temporary_quick_thermostat_poweroff_error", + &err.to_string(), + json!({ + "zone_id": zone.id, "device_id": zone.device_id + }), + ); } } fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool { - let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) else { return false; }; + let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) + else { + return false; + }; let tolerance = session.tolerance_c.max(0.0); match session.temperature_operator.as_deref().unwrap_or("within") { "at_or_below" => current <= target + tolerance, @@ -300,11 +433,22 @@ fn evaluate_temporary_quick_thermostat_condition( sample_at: Option>, max_gap_seconds: u64, ) -> Option { - if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override { return None; } - let is_condition = zone.temporary_quick_thermostat.as_ref() - .map(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable")) + if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override { + return None; + } + let is_condition = zone + .temporary_quick_thermostat + .as_ref() + .map(|session| { + matches!( + session.finish_kind.as_str(), + "temperature_reached" | "temperature_stable" + ) + }) .unwrap_or(false); - if !is_condition { return None; } + if !is_condition { + return None; + } let Some(sample_at) = sample_at else { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { @@ -313,20 +457,33 @@ fn evaluate_temporary_quick_thermostat_condition( } return None; }; - let last_observed = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.condition_last_observed_at); - if last_observed.map(|last| sample_at <= last).unwrap_or(false) { return None; } + let last_observed = zone + .temporary_quick_thermostat + .as_ref() + .and_then(|session| session.condition_last_observed_at); + if last_observed.map(|last| sample_at <= last).unwrap_or(false) { + return None; + } let gap_broken = last_observed - .map(|last| sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64) + .map(|last| { + sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64 + }) .unwrap_or(false); - let met = zone.temporary_quick_thermostat.as_ref() + let met = zone + .temporary_quick_thermostat + .as_ref() .map(|session| temporary_temperature_condition_met(zone, session))?; let session = zone.temporary_quick_thermostat.as_mut()?; session.condition_last_observed_at = Some(sample_at); - if gap_broken { session.condition_started_at = None; } + if gap_broken { + session.condition_started_at = None; + } match session.finish_kind.as_str() { "temperature_reached" => { - if met { return Some("temperature_reached".into()); } + if met { + return Some("temperature_reached".into()); + } session.condition_started_at = None; } "temperature_stable" => { @@ -335,7 +492,10 @@ fn evaluate_temporary_quick_thermostat_condition( return None; } let started = session.condition_started_at.get_or_insert(sample_at); - if session.hold_seconds == 0 || sample_at.signed_duration_since(*started).num_seconds() >= session.hold_seconds as i64 { + if session.hold_seconds == 0 + || sample_at.signed_duration_since(*started).num_seconds() + >= session.hold_seconds as i64 + { return Some("temperature_stable".into()); } } @@ -344,14 +504,23 @@ fn evaluate_temporary_quick_thermostat_condition( None } -async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> { +async fn expire_local_thermostat_overrides( + state: &AppState, + zones: &mut [Zone], + schedules: &[Schedule], + house_mode: &str, +) -> Result<(), AppError> { let now = Utc::now(); for zone in zones.iter_mut() { let zone_id = zone.id.clone(); let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(snapshot) = state.db.get_zone(&zone_id)? else { + continue; + }; let _device_guard = state.lock_device_operation(&snapshot.device_id).await; - let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; + let Some(latest) = state.db.get_zone(&zone_id)? else { + continue; + }; *zone = latest; // A direct device/pilot takeover has higher priority than the local-OFF hand-back. // Do not let the old timer expire underneath someone who is actively controlling @@ -361,9 +530,17 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], // local_thermostat_power=false with resume_at=None is a deliberate indefinite OFF // (notably the state produced by group OFF) and must stay off until the user/group // explicitly turns it back on or resumes automation. - if !local_thermostat_handback_is_active(zone) { continue; } - let expired = zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false); - if !expired { continue; } + if !local_thermostat_handback_is_active(zone) { + continue; + } + let expired = zone + .local_thermostat_resume_at + .as_ref() + .map(|at| at <= &now) + .unwrap_or(false); + if !expired { + continue; + } reset_local_thermostat_override(zone); refresh_zone_runtime_target(zone, schedules, house_mode); zone.updated_at = now.clone(); @@ -375,4 +552,3 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], } Ok(()) } - diff --git a/src/engine/zone_actions.rs b/src/engine/zone_actions.rs index 8cfd1b5..77325ac 100644 --- a/src/engine/zone_actions.rs +++ b/src/engine/zone_actions.rs @@ -1,5 +1,11 @@ -fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateTime) -> Result { - let Some(mut latest) = state.db.get_zone(&computed.id)? else { return Ok(computed.clone()); }; +fn persist_zone_cycle( + state: &AppState, + computed: &Zone, + cycle_started_at: DateTime, +) -> Result { + let Some(mut latest) = state.db.get_zone(&computed.id)? else { + return Ok(computed.clone()); + }; if latest.updated_at <= cycle_started_at { state.db.save_zone(computed)?; return Ok(computed.clone()); @@ -18,9 +24,21 @@ fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateT Ok(latest) } -async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result { - let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); }; - if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); } +async fn thermostat_ownership_is_current( + state: &AppState, + zone_id: &str, + device_id: &str, +) -> Result { + let Some(zone) = state.db.get_zone(zone_id)? else { + return Ok(false); + }; + if zone.device_id != device_id + || !zone.enabled + || zone.device_manual_override + || zone.local_thermostat_power == Some(false) + { + return Ok(false); + } Ok(true) } @@ -35,8 +53,12 @@ async fn send_zone_command_if_owned( // and a stale thermostat decision would still be sent immediately afterwards. let _device_guard = state.lock_device_operation(device_id).await; let owned = thermostat_ownership_is_current(state, zone_id, device_id).await?; - if !owned { return Ok(None); } - send_command_locked(state, device_id, command).await.map(Some) + if !owned { + return Ok(None); + } + send_command_locked(state, device_id, command) + .await + .map(Some) } async fn send_automatic_device_command_if_owned( @@ -53,7 +75,9 @@ async fn send_automatic_device_command_if_owned( { return Ok(None); } - send_command_locked(state, device_id, command).await.map(Some) + send_command_locked(state, device_id, command) + .await + .map(Some) } async fn apply_automatic_device_action( @@ -68,19 +92,31 @@ async fn apply_automatic_device_action( // the thermostat cycle so it cannot act on a snapshot taken before this automation. let _cycle_guard = state.lock_zone_control_cycle().await; let zones = state.db.list_zones()?; - let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else { + let Some(zone_id) = zones + .iter() + .find(|zone| zone.device_id == device_id) + .map(|zone| zone.id.clone()) + else { return send_automatic_device_command_if_owned(state, device_id, command).await; }; let _zone_guard = state.lock_zone_operation(&zone_id).await; let _device_guard = state.lock_device_operation(device_id).await; - let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; - if zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_quick_thermostat_is_active(&zone, Utc::now()) { + let mut zone = state + .db + .get_zone(&zone_id)? + .ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; + if zone.device_manual_override + || zone.local_thermostat_power.is_some() + || temporary_quick_thermostat_is_active(&zone, Utc::now()) + { return Ok(None); } // A power-on automation is an explicit domain transition and may re-enable a zone that // a previous power automation disabled. Other actions still respect a disabled zone gate. - if !zone.enabled && command.power != Some(true) { return Ok(None); } + if !zone.enabled && command.power != Some(true) { + return Ok(None); + } let mut domain_changed = false; if let Some(power) = command.power { @@ -105,7 +141,8 @@ async fn apply_automatic_device_action( } if let Some(target) = command.target_temperature { zone.manual_setpoint = Some((target.clamp(8.0, 30.0) * 2.0).round() / 2.0); - zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()); + zone.manual_override_until = + next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now()); domain_changed = true; } @@ -128,8 +165,13 @@ async fn apply_automatic_device_action( return send_command_locked_forced( state, device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - ).await.map(Some); + DeviceCommand { + power: Some(false), + ..Default::default() + }, + ) + .await + .map(Some); } // Climate fields above are durable zone state. Only non-climate device capabilities remain @@ -150,9 +192,12 @@ async fn apply_automatic_device_action( sleep: command.sleep, }; if residual.is_empty() { - return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}"))); + return state + .db + .get_device(device_id)? + .map(Some) + .ok_or_else(|| AppError::NotFound(format!("device {device_id}"))); } drop(_device_guard); send_automatic_device_command_if_owned(state, device_id, residual).await } - diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index 01691b4..237db71 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -8,10 +8,17 @@ pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) { zone.compressor_pending_action = None; zone.compressor_pending_since = None; zone.compressor_pending_until = None; - if clear_cancelled { zone.compressor_cancelled_action = None; } + if clear_cancelled { + zone.compressor_cancelled_action = None; + } } -pub(crate) fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime, reason: &str) { +pub(crate) fn queue_compressor_action( + zone: &mut Zone, + action: String, + until: DateTime, + reason: &str, +) { let now = Utc::now(); if zone.compressor_pending_action.as_deref() != Some(action.as_str()) { zone.compressor_pending_since = Some(now); @@ -30,7 +37,6 @@ pub(crate) fn rearm_compressor_queue(zone: &mut Zone) { clear_compressor_pending(zone, true); } - async fn resolve_cycle_outdoor_temperature( state: &AppState, settings: &RuntimeSettings, @@ -48,9 +54,18 @@ async fn resolve_cycle_outdoor_temperature( &settings.home_assistant, Some(entity_id), Some(settings.home_assistant.sensor_stale_after_seconds), - ).await { + ) + .await + { Ok(value) => { - record_ha_history(state, entity_id, None, "outdoor", value, settings.poll_interval_seconds); + record_ha_history( + state, + entity_id, + None, + "outdoor", + value, + settings.poll_interval_seconds, + ); Some(value) } Err(err) => { @@ -76,9 +91,14 @@ async fn read_cycle_room_sensors( zones: &[Zone], ) -> HashMap, Result)> { futures_util::future::join_all(zones.iter().filter_map(|zone| { - if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; } + if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { + return None; + } let zone_id = zone.id.clone(); - let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref()); + let resolved_entity = home_assistant::resolve_entity_id( + &settings.home_assistant, + zone.ha_entity_id.as_deref(), + ); let http = &state.http; let ha_settings = &settings.home_assistant; let stale_after_seconds = effective_sensor_stale_after_seconds( @@ -86,8 +106,14 @@ async fn read_cycle_room_sensors( ha_settings.sensor_stale_after_seconds, ); Some(async move { - let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await - .map_err(|err| err.to_string()); + let result = home_assistant::read_temperature( + http, + ha_settings, + resolved_entity.as_deref(), + Some(stale_after_seconds), + ) + .await + .map_err(|err| err.to_string()); (zone_id, resolved_entity, result) }) })) @@ -105,36 +131,58 @@ fn refresh_zone_temperature( room_sensor_results: &mut HashMap, Result)>, ) -> (String, bool) { let previous_source = zone.control_temperature_source.clone(); - let device_temperature = if device.enabled && device.online && device.communication_failures == 0 { - device.current_temperature - } else { - None - }; - let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { - match room_sensor_results.remove(&zone.id) { - Some((resolved_entity, Ok(value))) => { - if let Some(entity_id) = resolved_entity.as_deref() { - record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds); + let device_temperature = + if device.enabled && device.online && device.communication_failures == 0 { + device.current_temperature + } else { + None + }; + let external_temperature = + if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { + match room_sensor_results.remove(&zone.id) { + Some((resolved_entity, Ok(value))) => { + if let Some(entity_id) = resolved_entity.as_deref() { + record_ha_history( + state, + entity_id, + Some(&zone.id), + "room", + value, + settings.poll_interval_seconds, + ); + } + Some(value) } - Some(value) - } - Some((resolved_entity, Err(err))) => { - if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") { - let kind = if err.contains("Home Assistant sensor is stale:") { "ha.sensor_stale" } else { "ha.sensor_error" }; - state.log("warn", kind, &err, json!({ - "zone_id": zone.id, - "configured_entity_id": zone.ha_entity_id.as_deref(), - "resolved_entity_id": resolved_entity, - })); + Some((resolved_entity, Err(err))) => { + if !matches!( + previous_source.as_str(), + "device_fallback" | "device_discrepancy_fallback" + ) { + let kind = if err.contains("Home Assistant sensor is stale:") { + "ha.sensor_stale" + } else { + "ha.sensor_error" + }; + state.log( + "warn", + kind, + &err, + json!({ + "zone_id": zone.id, + "configured_entity_id": zone.ha_entity_id.as_deref(), + "resolved_entity_id": resolved_entity, + }), + ); + } + None } - None + None => None, } - None => None, - } - } else { - None - }; - let (temperature, source, discrepancy) = select_zone_temperature(zone, device_temperature, external_temperature); + } else { + None + }; + let (temperature, source, discrepancy) = + select_zone_temperature(zone, device_temperature, external_temperature); zone.device_temperature = device_temperature; zone.external_temperature = external_temperature; zone.current_temperature = temperature; @@ -173,45 +221,92 @@ async fn handle_zone_pre_control_state( if device.power { clear_compressor_pending(zone, true); zone.updated_at = now; - persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + persist_zone_cycle_with_history( + state, + zone, + cycle_started_at, + outdoor_temperature, + settings.poll_interval_seconds, + )?; return Ok(true); } let due = !settings.compressor_protection_enabled - || zone.compressor_pending_until.as_ref().map(|until| until <= &now).unwrap_or(true); + || zone + .compressor_pending_until + .as_ref() + .map(|until| until <= &now) + .unwrap_or(true); if due { let _device_guard = state.lock_device_operation(&zone.device_id).await; - match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await { + match send_command_locked( + state, + &zone.device_id, + DeviceCommand { + power: Some(true), + ..Default::default() + }, + ) + .await + { Ok(updated_device) => { - if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); } + if !device.power && updated_device.power { + zone.last_power_change_at = Some(Utc::now()); + } clear_compressor_pending(zone, true); zone.last_action_at = Some(Utc::now()); - state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })); + state.log( + "info", + "house.power_one_shot_executed", + &format!("Executed queued global ON for {}", zone.name), + json!({ + "zone_id": zone.id, "device_id": zone.device_id + }), + ); } Err(err) => { - zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10)); + zone.compressor_pending_until = + Some(Utc::now() + chrono::Duration::seconds(10)); zone.lockout_until = zone.compressor_pending_until.clone(); zone.lockout_reason = Some("global_start_retry".into()); - state.log("error", "house.power_one_shot_error", &err.to_string(), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })); + state.log( + "error", + "house.power_one_shot_error", + &err.to_string(), + json!({ + "zone_id": zone.id, "device_id": zone.device_id + }), + ); } } } zone.updated_at = Utc::now(); - persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + persist_zone_cycle_with_history( + state, + zone, + cycle_started_at, + outdoor_temperature, + settings.poll_interval_seconds, + )?; return Ok(true); } if !zone.enabled { - if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) { + if temporary_restored_disabled + .iter() + .any(|zone_id| zone_id == &zone.id) + { ensure_device_off_after_temporary_disabled_restore(state, zone, device).await; } clear_compressor_pending(zone, true); zone.demand = false; zone.demand_since = None; - persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + persist_zone_cycle_with_history( + state, + zone, + cycle_started_at, + outdoor_temperature, + settings.poll_interval_seconds, + )?; return Ok(true); } @@ -220,7 +315,13 @@ async fn handle_zone_pre_control_state( zone.demand = false; zone.demand_since = None; zone.device_setpoint = None; - persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + persist_zone_cycle_with_history( + state, + zone, + cycle_started_at, + outdoor_temperature, + settings.poll_interval_seconds, + )?; return Ok(true); } @@ -230,23 +331,43 @@ async fn handle_zone_pre_control_state( let pause_started_at = zone.updated_at.clone(); if temporary_active { if let Some(session) = zone.temporary_quick_thermostat.as_mut() { - if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); } + if session.paused_at.is_none() { + session.paused_at = Some(pause_started_at); + } session.state = "paused_manual".into(); session.condition_started_at = None; session.condition_last_observed_at = None; } } - let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode }; + let target_mode = if effective_mode == "off" { + zone.mode.as_str() + } else { + effective_mode + }; let active_schedule = active_schedule_for_zone(zone, schedules, Local::now()); let (preset, target) = resolve_zone_target(zone, active_schedule, target_mode); zone.active_preset = preset; zone.effective_setpoint = Some(target); - zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() }; - zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; + zone.effective_mode = if device.power { + device.mode.clone() + } else { + "off".into() + }; + zone.device_setpoint = if device.power { + Some(device.target_temperature) + } else { + None + }; zone.demand = false; zone.demand_since = None; zone.target_alerted_at = None; - persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + persist_zone_cycle_with_history( + state, + zone, + cycle_started_at, + outdoor_temperature, + settings.poll_interval_seconds, + )?; return Ok(true); } @@ -254,7 +375,8 @@ async fn handle_zone_pre_control_state( "home_assistant" | "combined" => Some(zone.updated_at.clone()), _ => device.last_seen.clone(), }; - let max_condition_gap_seconds = settings.poll_interval_seconds + let max_condition_gap_seconds = settings + .poll_interval_seconds .max(settings.zone_interval_seconds) .saturating_mul(2) .saturating_add(5); @@ -265,9 +387,19 @@ async fn handle_zone_pre_control_state( condition_sample_at, max_condition_gap_seconds, ) { - let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default(); + let finish_kind = zone + .temporary_quick_thermostat + .as_ref() + .map(|item| item.finish_kind.clone()) + .unwrap_or_default(); finish_temporary_quick_thermostat(zone, schedules, &settings.house_mode); - let persisted = persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + let persisted = persist_zone_cycle_with_history( + state, + zone, + cycle_started_at, + outdoor_temperature, + settings.poll_interval_seconds, + )?; ensure_device_off_after_temporary_disabled_restore(state, &persisted, device).await; state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason @@ -284,17 +416,39 @@ async fn handle_zone_pre_control_state( if device.online && device.communication_failures == 0 && device.power { let _device_guard = state.lock_device_operation(&zone.device_id).await; let latest = state.db.get_zone(&zone.id)?; - if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) { + if latest + .as_ref() + .map(|item| { + item.local_thermostat_power == Some(false) && !item.device_manual_override + }) + .unwrap_or(false) + { if let Err(err) = send_command_locked( state, &zone.device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - ).await { - state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})); + DeviceCommand { + power: Some(false), + ..Default::default() + }, + ) + .await + { + state.log( + "error", + "zone.local_power_error", + &err.to_string(), + json!({"zone_id": zone.id, "device_id": zone.device_id}), + ); } } } - persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?; + persist_zone_cycle_with_history( + state, + zone, + cycle_started_at, + outdoor_temperature, + settings.poll_interval_seconds, + )?; return Ok(true); } @@ -307,13 +461,31 @@ async fn control_zones(state: &AppState) -> Result<()> { let settings = state.settings.read().await.clone(); let mut zone_snapshot = state.db.list_zones()?; // Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently. - expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; - let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; - activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; + expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode) + .await?; + let temporary_restored_disabled = expire_temporary_quick_thermostats( + state, + &mut zone_snapshot, + &schedules, + &settings.house_mode, + ) + .await?; + activate_due_temporary_quick_thermostats( + state, + &mut zone_snapshot, + &schedules, + &settings.house_mode, + ) + .await?; let device_snapshot = state.db.list_devices()?; - let outdoor_temperature = resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await; - let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None }; + let outdoor_temperature = + resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await; + let outdoor_assist_temperature = if settings.outdoor_assist_enabled { + outdoor_temperature + } else { + None + }; let night_active = night_mode_active(&settings.night_mode, Local::now().time()); let mut room_sensor_results = read_cycle_room_sensors(state, &settings, &zone_snapshot).await; @@ -322,9 +494,15 @@ async fn control_zones(state: &AppState) -> Result<()> { // Web/HA/manual control and polling. Re-read after taking the zone lock so an // interactive change cannot be evaluated from a stale snapshot. let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await; - let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; }; + let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { + continue; + }; let cycle_started_at = zone.updated_at.clone(); - if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { + if zone + .manual_override_until + .map(|until| until <= Utc::now()) + .unwrap_or(false) + { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; @@ -338,13 +516,26 @@ async fn control_zones(state: &AppState) -> Result<()> { if zone.device_manual_override && zone.device_manual_override_until.is_some() { zone.device_manual_override_until = None; zone.control_resume_at = None; - state.log("info", "zone.device_manual_override_migrated", &format!("Manual device control remains active for {} until explicit resume", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })); + state.log( + "info", + "zone.device_manual_override_migrated", + &format!( + "Manual device control remains active for {} until explicit resume", + zone.name + ), + json!({ + "zone_id": zone.id, "device_id": zone.device_id + }), + ); } let Some(device) = state.db.get_device(&zone.device_id)? else { - state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id})); + state.log( + "error", + "zone.device_missing", + &format!("Zone {} has no device", zone.name), + json!({"zone_id": zone.id}), + ); continue; }; @@ -360,7 +551,11 @@ async fn control_zones(state: &AppState) -> Result<()> { let effective_mode = effective_mode_owned.as_str(); let (previous_source, discrepancy) = refresh_zone_temperature( - state, &settings, &mut zone, &device, &mut room_sensor_results, + state, + &settings, + &mut zone, + &device, + &mut room_sensor_results, ); if handle_zone_pre_control_state( @@ -373,18 +568,28 @@ async fn control_zones(state: &AppState) -> Result<()> { effective_mode, cycle_started_at.clone(), outdoor_temperature, - ).await? { + ) + .await? + { continue; } if discrepancy && previous_source != "device_discrepancy_fallback" { - state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({ - "zone_id": zone.id, - "device_temperature": zone.device_temperature, - "external_temperature": zone.external_temperature, - "max_difference": zone.max_sensor_difference, - "entity_id": zone.ha_entity_id.as_deref(), - })); + state.log( + "warn", + "zone.sensor_discrepancy", + &format!( + "Zone {} sensors differ by more than {:.1} C; using GREE sensor", + zone.name, zone.max_sensor_difference + ), + json!({ + "zone_id": zone.id, + "device_temperature": zone.device_temperature, + "external_temperature": zone.external_temperature, + "max_difference": zone.max_sensor_difference, + "entity_id": zone.ha_entity_id.as_deref(), + }), + ); } // House "off" is a no-control state, not a power-off command. Keep polling and @@ -396,7 +601,12 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.demand = false; zone.demand_since = None; zone.target_alerted_at = None; - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; @@ -418,7 +628,8 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.demand_since = None; zone.target_alerted_at = None; if zone.control_owner == "automation" { - zone.control_reason = "Automation idle: no active schedule or explicit thermostat request".into(); + zone.control_reason = + "Automation idle: no active schedule or explicit thermostat request".into(); zone.control_resume_at = None; } @@ -427,31 +638,54 @@ async fn control_zones(state: &AppState) -> Result<()> { state, &zone.id, &zone.device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - ).await { + DeviceCommand { + power: Some(false), + ..Default::default() + }, + ) + .await + { Ok(Some(updated_device)) => { let transition_at = Utc::now(); if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); } zone.last_action_at = Some(transition_at); - state.log("info", "zone.automation_idle_off", &format!("Zone {} remains OFF: no active thermostat intent", zone.name), json!({ - "zone_id": zone.id, - "device_id": zone.device_id, - "active_schedule": false, - "manual_preset": zone.manual_preset, - "manual_setpoint": zone.manual_setpoint, - "control_source": zone.control_source, - })); + state.log( + "info", + "zone.automation_idle_off", + &format!( + "Zone {} remains OFF: no active thermostat intent", + zone.name + ), + json!({ + "zone_id": zone.id, + "device_id": zone.device_id, + "active_schedule": false, + "manual_preset": zone.manual_preset, + "manual_setpoint": zone.manual_setpoint, + "control_source": zone.control_source, + }), + ); } Ok(None) => {} - Err(err) => state.log("error", "zone.automation_idle_off_error", &err.to_string(), json!({ - "zone_id": zone.id, "device_id": zone.device_id - })), + Err(err) => state.log( + "error", + "zone.automation_idle_off_error", + &err.to_string(), + json!({ + "zone_id": zone.id, "device_id": zone.device_id + }), + ), } } - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; @@ -462,7 +696,12 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.effective_setpoint = Some(target); let Some(temp) = zone.current_temperature else { - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; @@ -472,14 +711,22 @@ async fn control_zones(state: &AppState) -> Result<()> { let previous_demand = zone.demand; zone.demand = match effective_mode { "heat" => { - if temp <= target - half { true } - else if temp >= target + half { false } - else { zone.demand } + if temp <= target - half { + true + } else if temp >= target + half { + false + } else { + zone.demand + } } _ => { - if temp >= target + half { true } - else if temp <= target - half { false } - else { zone.demand } + if temp >= target + half { + true + } else if temp <= target - half { + false + } else { + zone.demand + } } }; if zone.demand && !previous_demand { @@ -503,13 +750,15 @@ async fn control_zones(state: &AppState) -> Result<()> { // Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor // stop naturally when we move the target to the satisfied side of room temperature. - let outdoor_assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target); + let outdoor_assist = + outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target); // When an independent room sensor is actually driving cooling, the indoor unit's // own sensor can satisfy too early. Apply a full-degree pre-rounding bias: because // GREE setpoints are sent as whole degrees, this keeps the unit at least one full // degree below the room target, including half-degree thermostat setpoints. Do not // stack it with outdoor assist or use it during device/fallback control. - let room_sensor_assist = external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source); + let room_sensor_assist = + external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source); let demand_assist = outdoor_assist.max(room_sensor_assist); let active_target = match effective_mode { "heat" => target + outdoor_assist, @@ -519,17 +768,35 @@ async fn control_zones(state: &AppState) -> Result<()> { "heat" => target - zone.standby_offset_c.max(0.5), _ => target + zone.standby_offset_c.max(0.5), }; - let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target }); + let desired_device_target = round_device_setpoint( + effective_mode, + zone.demand, + if zone.demand { + active_target + } else { + standby_target + }, + ); // Report only the last confirmed device state here. The desired target belongs to // effective_setpoint/command planning until a device command succeeds. - zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None }; + zone.device_setpoint = if device.power { + Some(device.target_temperature) + } else { + None + }; let demand_changed = previous_demand != zone.demand; let desired_fan = if night_active { let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5); if zone.smart_fan { Some(night_limited_fan_speed( - smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand), + smart_fan_speed( + effective_mode, + temp, + target, + outdoor_assist_temperature, + zone.demand, + ), max_fan, )) } else if device.fan_speed == 0 || device.fan_speed > max_fan { @@ -538,7 +805,13 @@ async fn control_zones(state: &AppState) -> Result<()> { Some(device.fan_speed) } } else if zone.smart_fan { - Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand)) + Some(smart_fan_speed( + effective_mode, + temp, + target, + outdoor_assist_temperature, + zone.demand, + )) } else { None }; @@ -572,7 +845,11 @@ async fn control_zones(state: &AppState) -> Result<()> { let now = Utc::now(); if !settings.compressor_protection_enabled { clear_compressor_pending(&mut zone, true); - } else if zone.lockout_until.map(|until| until <= now).unwrap_or(false) { + } else if zone + .lockout_until + .map(|until| until <= now) + .unwrap_or(false) + { zone.lockout_until = None; zone.lockout_reason = None; zone.compressor_pending_until = None; @@ -583,41 +860,90 @@ async fn control_zones(state: &AppState) -> Result<()> { let action = compressor_action_id("mode_change", effective_mode, desired_device_target); if compressor_action_is_cancelled(&zone, &action) { clear_compressor_pending(&mut zone, false); - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } if let Some(last_change) = zone.last_power_change_at { if now.signed_duration_since(last_change) < protection { - queue_compressor_action(&mut zone, action, last_change + protection, "minimum_on_before_mode_change"); - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + queue_compressor_action( + &mut zone, + action, + last_change + protection, + "minimum_on_before_mode_change", + ); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } } - match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await { + match send_zone_command_if_owned( + state, + &zone.id, + &zone.device_id, + DeviceCommand { + power: Some(false), + ..Default::default() + }, + ) + .await + { Ok(Some(_)) => { zone.last_power_change_at = Some(now); - queue_compressor_action(&mut zone, action, now + protection, "mode_change_off_delay"); + queue_compressor_action( + &mut zone, + action, + now + protection, + "mode_change_off_delay", + ); state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until, "compressor_protection_enabled": settings.compressor_protection_enabled})); } Ok(None) => {} - Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})), + Err(err) => state.log( + "error", + "zone.mode_change_off_error", + &err.to_string(), + json!({"zone_id": zone.id}), + ), } - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } if !device.power { - let action = zone.compressor_pending_action.clone() + let action = zone + .compressor_pending_action + .clone() .filter(|value| value.starts_with("mode_change:")) - .unwrap_or_else(|| compressor_action_id("power_on", effective_mode, desired_device_target)); + .unwrap_or_else(|| { + compressor_action_id("power_on", effective_mode, desired_device_target) + }); if compressor_action_is_cancelled(&zone, &action) { clear_compressor_pending(&mut zone, false); - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; @@ -625,8 +951,18 @@ async fn control_zones(state: &AppState) -> Result<()> { if settings.compressor_protection_enabled { if let Some(last_change) = zone.last_power_change_at { if now.signed_duration_since(last_change) < protection { - queue_compressor_action(&mut zone, action, last_change + protection, "minimum_off_before_start"); - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + queue_compressor_action( + &mut zone, + action, + last_change + protection, + "minimum_off_before_start", + ); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; @@ -658,8 +994,12 @@ async fn control_zones(state: &AppState) -> Result<()> { && (zone.demand || demand_changed || core_needs_command || night_active); let needs_command = core_needs_command || fan_needs_command - || desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false) - || desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false); + || desired_quiet + .map(|quiet| quiet != device.quiet) + .unwrap_or(false) + || desired_sleep + .map(|sleep| sleep != device.sleep) + .unwrap_or(false); let urgent_start = !device.power; if needs_command && (urgent_start || adjustment_allowed(&zone)) { @@ -675,9 +1015,17 @@ async fn control_zones(state: &AppState) -> Result<()> { match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command).await { Ok(Some(updated_device)) => { let transition_at = Utc::now(); - if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); } - if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); } - zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None }; + if device.power != updated_device.power { + zone.last_power_change_at = Some(transition_at); + } + if device.mode != updated_device.mode { + zone.last_mode_change_at = Some(transition_at); + } + zone.device_setpoint = if updated_device.power { + Some(updated_device.target_temperature) + } else { + None + }; zone.last_action_at = Some(transition_at); clear_compressor_pending(&mut zone, true); state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({ @@ -698,16 +1046,31 @@ async fn control_zones(state: &AppState) -> Result<()> { })); } Ok(None) => { - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); continue; } - Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})), + Err(err) => state.log( + "error", + "zone.action_error", + &err.to_string(), + json!({"zone_id": zone.id}), + ), } } - record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds); + record_zone_history( + state, + &zone, + outdoor_temperature, + settings.poll_interval_seconds, + ); let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?; state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?); } @@ -715,7 +1078,6 @@ async fn control_zones(state: &AppState) -> Result<()> { Ok(()) } - /// Run one thermostat arbitration cycle immediately and wait for all currently eligible zones. /// The cycle lock prevents overlap with the background regulator. pub async fn run_zone_control_now(state: &AppState) -> Result<(), AppError> { diff --git a/src/error.rs b/src/error.rs index 716e5ad..931cfc1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,4 +1,8 @@ -use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; use serde_json::json; use thiserror::Error; @@ -28,7 +32,10 @@ impl IntoResponse for AppError { Self::Device(v) => (StatusCode::BAD_GATEWAY, v.clone()), Self::Internal(v) => { tracing::error!(error = ?v, "internal error"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into()) + ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".into(), + ) } }; (status, Json(json!({"error": message}))).into_response() @@ -36,9 +43,13 @@ impl IntoResponse for AppError { } impl From for AppError { - fn from(value: rusqlite::Error) -> Self { Self::Internal(value.into()) } + fn from(value: rusqlite::Error) -> Self { + Self::Internal(value.into()) + } } impl From for AppError { - fn from(value: serde_json::Error) -> Self { Self::Internal(value.into()) } + fn from(value: serde_json::Error) -> Self { + Self::Internal(value.into()) + } } diff --git a/src/home_assistant.rs b/src/home_assistant.rs index acc2ba0..ba169ee 100644 --- a/src/home_assistant.rs +++ b/src/home_assistant.rs @@ -1,10 +1,13 @@ -use std::time::Duration; +use crate::models::HomeAssistantSettings; use anyhow::{anyhow, bail, Context, Result}; use serde_json::Value; +use std::time::Duration; use url::Url; -use crate::models::HomeAssistantSettings; -fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSettings) -> Result { +fn request_client( + default_client: &reqwest::Client, + settings: &HomeAssistantSettings, +) -> Result { if !settings.allow_invalid_tls { return Ok(default_client.clone()); } @@ -17,11 +20,17 @@ fn request_client(default_client: &reqwest::Client, settings: &HomeAssistantSett .context("cannot build Home Assistant HTTPS client") } -pub fn resolve_entity_id(settings: &HomeAssistantSettings, entity_override: Option<&str>) -> Option { - let requested = entity_override.filter(|value| !value.trim().is_empty()) +pub fn resolve_entity_id( + settings: &HomeAssistantSettings, + entity_override: Option<&str>, +) -> Option { + let requested = entity_override + .filter(|value| !value.trim().is_empty()) .map(str::trim) .unwrap_or_else(|| settings.default_entity_id.trim()); - if requested.is_empty() { return None; } + if requested.is_empty() { + return None; + } // Aliases are presentation-only. Accepting an alias here is a defensive // compatibility path for settings saved by older UI revisions or manual edits; @@ -29,8 +38,11 @@ pub fn resolve_entity_id(settings: &HomeAssistantSettings, entity_override: Opti if settings.sensor_aliases.contains_key(requested) { return Some(requested.to_string()); } - if let Some((entity_id, _)) = settings.sensor_aliases.iter() - .find(|(_, alias)| alias.trim().eq_ignore_ascii_case(requested)) { + if let Some((entity_id, _)) = settings + .sensor_aliases + .iter() + .find(|(_, alias)| alias.trim().eq_ignore_ascii_case(requested)) + { return Some(entity_id.clone()); } Some(requested.to_string()) @@ -42,38 +54,71 @@ pub async fn read_temperature( entity_override: Option<&str>, stale_after_seconds: Option, ) -> Result { - if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } - if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } + if settings.url.trim().is_empty() { + bail!("Home Assistant URL is not configured") + } + if settings.token.trim().is_empty() { + bail!("Home Assistant token is not configured") + } let entity = resolve_entity_id(settings, entity_override) .ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?; let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; - if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } + if !matches!(base.scheme(), "http" | "https") { + bail!("Home Assistant URL must use http or https") + } let path = format!("api/states/{}", entity.trim_start_matches('/')); - base = base.join(&path).context("cannot build Home Assistant API URL")?; + base = base + .join(&path) + .context("cannot build Home Assistant API URL")?; let client = request_client(default_client, settings)?; - let response = client.get(base) + let response = client + .get(base) .bearer_auth(settings.token.trim()) .header("Accept", "application/json") - .send().await.context("Home Assistant request failed")?; + .send() + .await + .context("Home Assistant request failed")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::()) + bail!( + "Home Assistant returned {status}: {}", + body.chars().take(200).collect::() + ) } - let payload: Value = response.json().await.context("invalid Home Assistant JSON")?; + let payload: Value = response + .json() + .await + .context("invalid Home Assistant JSON")?; if let Some(limit) = stale_after_seconds.filter(|value| *value > 0) { - let updated = payload.get("last_updated").and_then(Value::as_str) + let updated = payload + .get("last_updated") + .and_then(Value::as_str) .ok_or_else(|| anyhow!("Home Assistant last_updated is missing"))?; - let updated = chrono::DateTime::parse_from_rfc3339(updated).context("invalid Home Assistant last_updated")?.with_timezone(&chrono::Utc); - let age = chrono::Utc::now().signed_duration_since(updated).num_seconds().max(0) as u64; - if age > limit { bail!("Home Assistant sensor is stale: {age}s old (limit {limit}s)") } + let updated = chrono::DateTime::parse_from_rfc3339(updated) + .context("invalid Home Assistant last_updated")? + .with_timezone(&chrono::Utc); + let age = chrono::Utc::now() + .signed_duration_since(updated) + .num_seconds() + .max(0) as u64; + if age > limit { + bail!("Home Assistant sensor is stale: {age}s old (limit {limit}s)") + } } - let state = payload.get("state").and_then(Value::as_str) + let state = payload + .get("state") + .and_then(Value::as_str) .ok_or_else(|| anyhow!("Home Assistant state is missing"))?; - let mut temperature: f64 = state.parse().context("Home Assistant state is not a number")?; - let unit = payload.pointer("/attributes/unit_of_measurement").and_then(Value::as_str).unwrap_or("°C"); + let mut temperature: f64 = state + .parse() + .context("Home Assistant state is not a number")?; + let unit = payload + .pointer("/attributes/unit_of_measurement") + .and_then(Value::as_str) + .unwrap_or("°C"); if unit.eq_ignore_ascii_case("°F") || unit.eq_ignore_ascii_case("F") { temperature = (temperature - 32.0) * 5.0 / 9.0; } @@ -103,9 +148,18 @@ mod tests { #[test] fn aliases_never_replace_real_home_assistant_entity_ids() { let settings = settings(); - assert_eq!(resolve_entity_id(&settings, Some("sensor.gabinet_temperature")).as_deref(), Some("sensor.gabinet_temperature")); - assert_eq!(resolve_entity_id(&settings, Some("Gabinet")).as_deref(), Some("sensor.gabinet_temperature")); - assert_eq!(resolve_entity_id(&settings, None).as_deref(), Some("sensor.salon_temperature")); + assert_eq!( + resolve_entity_id(&settings, Some("sensor.gabinet_temperature")).as_deref(), + Some("sensor.gabinet_temperature") + ); + assert_eq!( + resolve_entity_id(&settings, Some("Gabinet")).as_deref(), + Some("sensor.gabinet_temperature") + ); + assert_eq!( + resolve_entity_id(&settings, None).as_deref(), + Some("sensor.salon_temperature") + ); } } @@ -116,20 +170,36 @@ pub async fn read_entity( settings: &HomeAssistantSettings, entity_override: Option<&str>, ) -> Result { - if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } - if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } + if settings.url.trim().is_empty() { + bail!("Home Assistant URL is not configured") + } + if settings.token.trim().is_empty() { + bail!("Home Assistant token is not configured") + } let entity = resolve_entity_id(settings, entity_override) .ok_or_else(|| anyhow!("Home Assistant entity_id is not configured"))?; let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; - if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } - base = base.join(&format!("api/states/{}", entity.trim_start_matches('/'))).context("cannot build Home Assistant API URL")?; + if !matches!(base.scheme(), "http" | "https") { + bail!("Home Assistant URL must use http or https") + } + base = base + .join(&format!("api/states/{}", entity.trim_start_matches('/'))) + .context("cannot build Home Assistant API URL")?; let client = request_client(default_client, settings)?; - let response = client.get(base).bearer_auth(settings.token.trim()).header("Accept", "application/json") - .send().await.context("Home Assistant request failed")?; + let response = client + .get(base) + .bearer_auth(settings.token.trim()) + .header("Accept", "application/json") + .send() + .await + .context("Home Assistant request failed")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::()) + bail!( + "Home Assistant returned {status}: {}", + body.chars().take(200).collect::() + ) } response.json().await.context("invalid Home Assistant JSON") } @@ -143,10 +213,13 @@ pub async fn read_state( entity_override: Option<&str>, ) -> Result { let payload = read_entity(default_client, settings, entity_override).await?; - payload.get("state").and_then(Value::as_str).map(str::to_string).ok_or_else(|| anyhow!("Home Assistant state is missing")) + payload + .get("state") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| anyhow!("Home Assistant state is missing")) } - /// Call a Home Assistant service from a Flow action. The domain/service pair is explicit /// and the payload is sent as JSON. Entity targeting is normalized through entity_id. pub async fn call_service( @@ -157,23 +230,46 @@ pub async fn call_service( entity_id: Option<&str>, data: &Value, ) -> Result { - if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } - if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } - if domain.trim().is_empty() || service.trim().is_empty() { bail!("Home Assistant domain/service is required") } + if settings.url.trim().is_empty() { + bail!("Home Assistant URL is not configured") + } + if settings.token.trim().is_empty() { + bail!("Home Assistant token is not configured") + } + if domain.trim().is_empty() || service.trim().is_empty() { + bail!("Home Assistant domain/service is required") + } let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; - if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } - base = base.join(&format!("api/services/{}/{}", domain.trim(), service.trim())).context("cannot build Home Assistant service URL")?; + if !matches!(base.scheme(), "http" | "https") { + bail!("Home Assistant URL must use http or https") + } + base = base + .join(&format!( + "api/services/{}/{}", + domain.trim(), + service.trim() + )) + .context("cannot build Home Assistant service URL")?; let mut payload = data.as_object().cloned().unwrap_or_default(); if let Some(entity) = entity_id.map(str::trim).filter(|v| !v.is_empty()) { payload.insert("entity_id".into(), Value::String(entity.to_string())); } let client = request_client(default_client, settings)?; - let response = client.post(base).bearer_auth(settings.token.trim()).header("Accept", "application/json") - .json(&Value::Object(payload)).send().await.context("Home Assistant service request failed")?; + let response = client + .post(base) + .bearer_auth(settings.token.trim()) + .header("Accept", "application/json") + .json(&Value::Object(payload)) + .send() + .await + .context("Home Assistant service request failed")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::()) + bail!( + "Home Assistant returned {status}: {}", + body.chars().take(200).collect::() + ) } match response.json::().await { Ok(value) => Ok(value), diff --git a/src/influxdb.rs b/src/influxdb.rs index 0fbe675..bf42fe9 100644 --- a/src/influxdb.rs +++ b/src/influxdb.rs @@ -10,7 +10,6 @@ const DEVICE_MEASUREMENT: &str = "gree_device"; const ZONE_MEASUREMENT: &str = "gree_zone"; const HA_MEASUREMENT: &str = "gree_ha"; - // Functional source split intentionally keeps items in the existing module namespace. include!("influxdb/write.rs"); include!("influxdb/query.rs"); diff --git a/src/influxdb/codec.rs b/src/influxdb/codec.rs index 499722c..9ca433d 100644 --- a/src/influxdb/codec.rs +++ b/src/influxdb/codec.rs @@ -20,30 +20,100 @@ fn parse_csv_line(line: &str) -> Vec { out } -fn flux_query(settings: &InfluxDbSettings, measurement: &str, extra_filters: &str, group_tags: &[&str], start: DateTime, stop: DateTime, bucket_seconds: i64) -> String { - let tags = group_tags.iter().map(|tag| format!("\"{tag}\"")).collect::>().join(","); +fn flux_query( + settings: &InfluxDbSettings, + measurement: &str, + extra_filters: &str, + group_tags: &[&str], + start: DateTime, + stop: DateTime, + bucket_seconds: i64, +) -> String { + let tags = group_tags + .iter() + .map(|tag| format!("\"{tag}\"")) + .collect::>() + .join(","); format!( "from(bucket: {}) |> range(start: time(v: {}), stop: time(v: {})) |> filter(fn: (r) => r._measurement == {}){} |> aggregateWindow(every: {}s, fn: mean, createEmpty: false) |> group(columns: [{}]) |> pivot(rowKey:[\"_time\"], columnKey:[\"_field\"], valueColumn:\"_value\") |> sort(columns:[\"_time\"])", flux_string(&settings.bucket), flux_string(&start.to_rfc3339()), flux_string(&stop.to_rfc3339()), flux_string(measurement), extra_filters, bucket_seconds.max(1), tags ) } -fn line_protocol(measurement: &str, tags: &[(&str, &str)], fields: Vec, timestamp: DateTime) -> Result { - if fields.is_empty() { bail!("InfluxDB measurement has no fields"); } - let tags = tags.iter().filter(|(_, value)| !value.is_empty()).map(|(key,value)| format!(",{}={}", escape_tag(key), escape_tag(value))).collect::(); - let nanos = timestamp.timestamp_nanos_opt().ok_or_else(|| anyhow!("timestamp outside nanosecond range"))?; - Ok(format!("{}{} {} {}", escape_measurement(measurement), tags, fields.join(","), nanos)) +fn line_protocol( + measurement: &str, + tags: &[(&str, &str)], + fields: Vec, + timestamp: DateTime, +) -> Result { + if fields.is_empty() { + bail!("InfluxDB measurement has no fields"); + } + let tags = tags + .iter() + .filter(|(_, value)| !value.is_empty()) + .map(|(key, value)| format!(",{}={}", escape_tag(key), escape_tag(value))) + .collect::(); + let nanos = timestamp + .timestamp_nanos_opt() + .ok_or_else(|| anyhow!("timestamp outside nanosecond range"))?; + Ok(format!( + "{}{} {} {}", + escape_measurement(measurement), + tags, + fields.join(","), + nanos + )) } -fn push_float(fields: &mut Vec, key: &str, value: Option) { if let Some(value) = value.filter(|v| v.is_finite()) { fields.push(format!("{}={value}", escape_field_key(key))); } } -fn push_int(fields: &mut Vec, key: &str, value: i64) { fields.push(format!("{}={value}i", escape_field_key(key))); } -fn escape_measurement(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace(' ', "\\ ") } -fn escape_tag(value: &str) -> String { value.replace('\\', "\\\\").replace(',', "\\,").replace('=', "\\=").replace(' ', "\\ ") } -fn escape_field_key(value: &str) -> String { escape_tag(value) } -fn influxql_string(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") } -fn flux_string(value: &str) -> String { format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) } -fn truncate(value: &str, max: usize) -> String { value.chars().take(max).collect() } -fn row_f64(row: &HashMap, key: &str) -> Option { row.get(key)?.parse().ok() } -fn parse_flux_time(row: &HashMap) -> Option> { DateTime::parse_from_rfc3339(row.get("_time")?).ok().map(|v| v.with_timezone(&Utc)) } -fn row_num(row: &HashMap, key: &str) -> Option { row.get(key)?.as_f64().or_else(|| row.get(key)?.as_i64().map(|v|v as f64)) } -fn row_time(row: &HashMap) -> Option> { DateTime::parse_from_rfc3339(row.get("time")?.as_str()?).ok().map(|v|v.with_timezone(&Utc)) } +fn push_float(fields: &mut Vec, key: &str, value: Option) { + if let Some(value) = value.filter(|v| v.is_finite()) { + fields.push(format!("{}={value}", escape_field_key(key))); + } +} +fn push_int(fields: &mut Vec, key: &str, value: i64) { + fields.push(format!("{}={value}i", escape_field_key(key))); +} +fn escape_measurement(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace(',', "\\,") + .replace(' ', "\\ ") +} +fn escape_tag(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace(',', "\\,") + .replace('=', "\\=") + .replace(' ', "\\ ") +} +fn escape_field_key(value: &str) -> String { + escape_tag(value) +} +fn influxql_string(value: &str) -> String { + value.replace('\\', "\\\\").replace('\'', "\\'") +} +fn flux_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} +fn truncate(value: &str, max: usize) -> String { + value.chars().take(max).collect() +} +fn row_f64(row: &HashMap, key: &str) -> Option { + row.get(key)?.parse().ok() +} +fn parse_flux_time(row: &HashMap) -> Option> { + DateTime::parse_from_rfc3339(row.get("_time")?) + .ok() + .map(|v| v.with_timezone(&Utc)) +} +fn row_num(row: &HashMap, key: &str) -> Option { + row.get(key)? + .as_f64() + .or_else(|| row.get(key)?.as_i64().map(|v| v as f64)) +} +fn row_time(row: &HashMap) -> Option> { + DateTime::parse_from_rfc3339(row.get("time")?.as_str()?) + .ok() + .map(|v| v.with_timezone(&Utc)) +} diff --git a/src/influxdb/query.rs b/src/influxdb/query.rs index 28f330f..bd02cdc 100644 --- a/src/influxdb/query.rs +++ b/src/influxdb/query.rs @@ -8,15 +8,43 @@ pub async fn query_devices( limit: u32, ) -> Result> { if settings.version == "1" { - query_devices_v1(client, settings, device_id, start, stop, bucket_seconds, limit).await + query_devices_v1( + client, + settings, + device_id, + start, + stop, + bucket_seconds, + limit, + ) + .await } else { - let tags = if let Some(value) = device_id { format!(" |> filter(fn: (r) => r.device_id == {})", flux_string(value)) } else { String::new() }; - let query = flux_query(settings, DEVICE_MEASUREMENT, &tags, &["device_id"], start, stop, bucket_seconds); + let tags = if let Some(value) = device_id { + format!( + " |> filter(fn: (r) => r.device_id == {})", + flux_string(value) + ) + } else { + String::new() + }; + let query = flux_query( + settings, + DEVICE_MEASUREMENT, + &tags, + &["device_id"], + start, + stop, + bucket_seconds, + ); let rows = query_v2(client, settings, &query).await?; let mut out = Vec::new(); for row in rows.into_iter().take(limit as usize) { - let Some(timestamp) = parse_flux_time(&row) else { continue; }; - let Some(id) = row.get("device_id").filter(|v| !v.is_empty()) else { continue; }; + let Some(timestamp) = parse_flux_time(&row) else { + continue; + }; + let Some(id) = row.get("device_id").filter(|v| !v.is_empty()) else { + continue; + }; out.push(Reading { id: 0, device_id: id.clone(), @@ -43,15 +71,40 @@ pub async fn query_zones( limit: u32, ) -> Result> { if settings.version == "1" { - query_zones_v1(client, settings, zone_id, start, stop, bucket_seconds, limit).await + query_zones_v1( + client, + settings, + zone_id, + start, + stop, + bucket_seconds, + limit, + ) + .await } else { - let tags = if let Some(value) = zone_id { format!(" |> filter(fn: (r) => r.zone_id == {})", flux_string(value)) } else { String::new() }; - let query = flux_query(settings, ZONE_MEASUREMENT, &tags, &["zone_id", "device_id"], start, stop, bucket_seconds); + let tags = if let Some(value) = zone_id { + format!(" |> filter(fn: (r) => r.zone_id == {})", flux_string(value)) + } else { + String::new() + }; + let query = flux_query( + settings, + ZONE_MEASUREMENT, + &tags, + &["zone_id", "device_id"], + start, + stop, + bucket_seconds, + ); let rows = query_v2(client, settings, &query).await?; let mut out = Vec::new(); for row in rows.into_iter().take(limit as usize) { - let Some(timestamp) = parse_flux_time(&row) else { continue; }; - let Some(zone) = row.get("zone_id").filter(|v| !v.is_empty()) else { continue; }; + let Some(timestamp) = parse_flux_time(&row) else { + continue; + }; + let Some(zone) = row.get("zone_id").filter(|v| !v.is_empty()) else { + continue; + }; out.push(ZoneReading { id: 0, zone_id: zone.clone(), @@ -65,7 +118,10 @@ pub async fn query_zones( outdoor_temperature: row_f64(&row, "outdoor_temperature"), power: row_f64(&row, "power").unwrap_or(0.0) >= 0.5, mode: "history".into(), - fan_speed: row_f64(&row, "fan_speed").unwrap_or(0.0).round().clamp(0.0, 5.0) as u8, + fan_speed: row_f64(&row, "fan_speed") + .unwrap_or(0.0) + .round() + .clamp(0.0, 5.0) as u8, demand: row_f64(&row, "demand").unwrap_or(0.0) >= 0.5, control_source: "influx".into(), active_preset: "history".into(), @@ -86,16 +142,46 @@ pub async fn query_ha( limit: u32, ) -> Result> { if settings.version == "1" { - query_ha_v1(client, settings, entity_id, start, stop, bucket_seconds, limit).await + query_ha_v1( + client, + settings, + entity_id, + start, + stop, + bucket_seconds, + limit, + ) + .await } else { - let tags = if let Some(value) = entity_id { format!(" |> filter(fn: (r) => r.entity_id == {})", flux_string(value)) } else { String::new() }; - let query = flux_query(settings, HA_MEASUREMENT, &tags, &["entity_id", "zone_id", "kind"], start, stop, bucket_seconds); + let tags = if let Some(value) = entity_id { + format!( + " |> filter(fn: (r) => r.entity_id == {})", + flux_string(value) + ) + } else { + String::new() + }; + let query = flux_query( + settings, + HA_MEASUREMENT, + &tags, + &["entity_id", "zone_id", "kind"], + start, + stop, + bucket_seconds, + ); let rows = query_v2(client, settings, &query).await?; let mut out = Vec::new(); for row in rows.into_iter().take(limit as usize) { - let Some(timestamp) = parse_flux_time(&row) else { continue; }; - let Some(entity) = row.get("entity_id").filter(|v| !v.is_empty()) else { continue; }; - let Some(temperature) = row_f64(&row, "temperature") else { continue; }; + let Some(timestamp) = parse_flux_time(&row) else { + continue; + }; + let Some(entity) = row.get("entity_id").filter(|v| !v.is_empty()) else { + continue; + }; + let Some(temperature) = row_f64(&row, "temperature") else { + continue; + }; out.push(HaReading { id: 0, entity_id: entity.clone(), @@ -110,23 +196,56 @@ pub async fn query_ha( } } -async fn query_devices_v1(client: &Client, settings: &InfluxDbSettings, device_id: Option<&str>, start: DateTime, stop: DateTime, bucket: i64, limit: u32) -> Result> { - let filter = device_id.map(|id| format!(" AND \"device_id\"='{}'", influxql_string(id))).unwrap_or_default(); +async fn query_devices_v1( + client: &Client, + settings: &InfluxDbSettings, + device_id: Option<&str>, + start: DateTime, + stop: DateTime, + bucket: i64, + limit: u32, +) -> Result> { + let filter = device_id + .map(|id| format!(" AND \"device_id\"='{}'", influxql_string(id))) + .unwrap_or_default(); let q = format!("SELECT mean(\"indoor_temperature\") AS \"indoor_temperature\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",mean(\"target_temperature\") AS \"target_temperature\",max(\"power\") AS \"power\" FROM \"{DEVICE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); let series = query_v1(client, settings, &q).await?; let mut out = Vec::new(); for item in series { let device = item.tags.get("device_id").cloned().unwrap_or_default(); for row in item.rows { - let Some(timestamp) = row_time(&row) else { continue; }; - out.push(Reading { id:0, device_id:device.clone(), timestamp, indoor_temperature:row_num(&row,"indoor_temperature"), outdoor_temperature:row_num(&row,"outdoor_temperature"), target_temperature:row_num(&row,"target_temperature").unwrap_or(0.0), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, source:"influx".into() }); + let Some(timestamp) = row_time(&row) else { + continue; + }; + out.push(Reading { + id: 0, + device_id: device.clone(), + timestamp, + indoor_temperature: row_num(&row, "indoor_temperature"), + outdoor_temperature: row_num(&row, "outdoor_temperature"), + target_temperature: row_num(&row, "target_temperature").unwrap_or(0.0), + power: row_num(&row, "power").unwrap_or(0.0) >= 0.5, + source: "influx".into(), + }); } } - out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) + out.sort_by_key(|row| row.timestamp); + out.truncate(limit as usize); + Ok(out) } -async fn query_zones_v1(client: &Client, settings: &InfluxDbSettings, zone_id: Option<&str>, start: DateTime, stop: DateTime, bucket: i64, limit: u32) -> Result> { - let filter = zone_id.map(|id| format!(" AND \"zone_id\"='{}'", influxql_string(id))).unwrap_or_default(); +async fn query_zones_v1( + client: &Client, + settings: &InfluxDbSettings, + zone_id: Option<&str>, + start: DateTime, + stop: DateTime, + bucket: i64, + limit: u32, +) -> Result> { + let filter = zone_id + .map(|id| format!(" AND \"zone_id\"='{}'", influxql_string(id))) + .unwrap_or_default(); let q = format!("SELECT mean(\"gree_temperature\") AS \"gree_temperature\",mean(\"external_temperature\") AS \"external_temperature\",mean(\"control_temperature\") AS \"control_temperature\",mean(\"target_temperature\") AS \"target_temperature\",mean(\"device_setpoint\") AS \"device_setpoint\",mean(\"outdoor_temperature\") AS \"outdoor_temperature\",max(\"power\") AS \"power\",mean(\"fan_speed\") AS \"fan_speed\",max(\"demand\") AS \"demand\" FROM \"{ZONE_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"zone_id\",\"device_id\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); let series = query_v1(client, settings, &q).await?; let mut out = Vec::new(); @@ -134,81 +253,210 @@ async fn query_zones_v1(client: &Client, settings: &InfluxDbSettings, zone_id: O let zone = item.tags.get("zone_id").cloned().unwrap_or_default(); let device = item.tags.get("device_id").cloned().unwrap_or_default(); for row in item.rows { - let Some(timestamp) = row_time(&row) else { continue; }; - out.push(ZoneReading { id:0, zone_id:zone.clone(), device_id:device.clone(), timestamp, gree_temperature:row_num(&row,"gree_temperature"), external_temperature:row_num(&row,"external_temperature"), control_temperature:row_num(&row,"control_temperature"), target_temperature:row_num(&row,"target_temperature"), device_setpoint:row_num(&row,"device_setpoint"), outdoor_temperature:row_num(&row,"outdoor_temperature"), power:row_num(&row,"power").unwrap_or(0.0)>=0.5, mode:"history".into(), fan_speed:row_num(&row,"fan_speed").unwrap_or(0.0).round().clamp(0.0,5.0) as u8, demand:row_num(&row,"demand").unwrap_or(0.0)>=0.5, control_source:"influx".into(), active_preset:"history".into() }); + let Some(timestamp) = row_time(&row) else { + continue; + }; + out.push(ZoneReading { + id: 0, + zone_id: zone.clone(), + device_id: device.clone(), + timestamp, + gree_temperature: row_num(&row, "gree_temperature"), + external_temperature: row_num(&row, "external_temperature"), + control_temperature: row_num(&row, "control_temperature"), + target_temperature: row_num(&row, "target_temperature"), + device_setpoint: row_num(&row, "device_setpoint"), + outdoor_temperature: row_num(&row, "outdoor_temperature"), + power: row_num(&row, "power").unwrap_or(0.0) >= 0.5, + mode: "history".into(), + fan_speed: row_num(&row, "fan_speed") + .unwrap_or(0.0) + .round() + .clamp(0.0, 5.0) as u8, + demand: row_num(&row, "demand").unwrap_or(0.0) >= 0.5, + control_source: "influx".into(), + active_preset: "history".into(), + }); } } - out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) + out.sort_by_key(|row| row.timestamp); + out.truncate(limit as usize); + Ok(out) } -async fn query_ha_v1(client: &Client, settings: &InfluxDbSettings, entity_id: Option<&str>, start: DateTime, stop: DateTime, bucket: i64, limit: u32) -> Result> { - let filter = entity_id.map(|id| format!(" AND \"entity_id\"='{}'", influxql_string(id))).unwrap_or_default(); +async fn query_ha_v1( + client: &Client, + settings: &InfluxDbSettings, + entity_id: Option<&str>, + start: DateTime, + stop: DateTime, + bucket: i64, + limit: u32, +) -> Result> { + let filter = entity_id + .map(|id| format!(" AND \"entity_id\"='{}'", influxql_string(id))) + .unwrap_or_default(); let q = format!("SELECT mean(\"temperature\") AS \"temperature\" FROM \"{HA_MEASUREMENT}\" WHERE time >= '{}' AND time < '{}'{} GROUP BY time({}s),\"entity_id\",\"zone_id\",\"kind\" fill(none) LIMIT {}", start.to_rfc3339(), stop.to_rfc3339(), filter, bucket.max(1), limit); let series = query_v1(client, settings, &q).await?; let mut out = Vec::new(); for item in series { let entity = item.tags.get("entity_id").cloned().unwrap_or_default(); let zone = item.tags.get("zone_id").filter(|v| !v.is_empty()).cloned(); - let kind = item.tags.get("kind").cloned().unwrap_or_else(|| "room".into()); + let kind = item + .tags + .get("kind") + .cloned() + .unwrap_or_else(|| "room".into()); for row in item.rows { - let Some(timestamp) = row_time(&row) else { continue; }; - let Some(temperature) = row_num(&row,"temperature") else { continue; }; - out.push(HaReading { id:0, entity_id:entity.clone(), zone_id:zone.clone(), kind:kind.clone(), timestamp, temperature }); + let Some(timestamp) = row_time(&row) else { + continue; + }; + let Some(temperature) = row_num(&row, "temperature") else { + continue; + }; + out.push(HaReading { + id: 0, + entity_id: entity.clone(), + zone_id: zone.clone(), + kind: kind.clone(), + timestamp, + temperature, + }); } } - out.sort_by_key(|row| row.timestamp); out.truncate(limit as usize); Ok(out) + out.sort_by_key(|row| row.timestamp); + out.truncate(limit as usize); + Ok(out) } -struct V1Series { tags: HashMap, rows: Vec> } +struct V1Series { + tags: HashMap, + rows: Vec>, +} async fn query_v1(client: &Client, settings: &InfluxDbSettings, q: &str) -> Result> { validate(settings)?; let base = settings.url.trim_end_matches('/'); - let request = client.get(format!("{base}/query")).query(&[("db", settings.database.as_str()), ("q", q)]); - let request = if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) }; + let request = client + .get(format!("{base}/query")) + .query(&[("db", settings.database.as_str()), ("q", q)]); + let request = if settings.username.trim().is_empty() { + request + } else { + request.basic_auth(&settings.username, Some(&settings.password)) + }; let response = request.send().await.context("InfluxDB 1.x query failed")?; let status = response.status(); - let body: Value = response.json().await.context("invalid InfluxDB 1.x JSON response")?; - if !status.is_success() { bail!("InfluxDB 1.x query failed ({status}): {body}"); } - if let Some(error) = body.pointer("/results/0/error").and_then(Value::as_str) { bail!("InfluxDB 1.x query error: {error}"); } + let body: Value = response + .json() + .await + .context("invalid InfluxDB 1.x JSON response")?; + if !status.is_success() { + bail!("InfluxDB 1.x query failed ({status}): {body}"); + } + if let Some(error) = body.pointer("/results/0/error").and_then(Value::as_str) { + bail!("InfluxDB 1.x query error: {error}"); + } let mut out = Vec::new(); - for series in body.pointer("/results/0/series").and_then(Value::as_array).into_iter().flatten() { - let columns: Vec = series.get("columns").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); - let tags = series.get("tags").and_then(Value::as_object).map(|map| map.iter().map(|(k,v)|(k.clone(),v.as_str().unwrap_or_default().to_string())).collect()).unwrap_or_default(); + for series in body + .pointer("/results/0/series") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let columns: Vec = series + .get("columns") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + let tags = series + .get("tags") + .and_then(Value::as_object) + .map(|map| { + map.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or_default().to_string())) + .collect() + }) + .unwrap_or_default(); let mut rows = Vec::new(); - for values in series.get("values").and_then(Value::as_array).into_iter().flatten() { - let Some(values) = values.as_array() else { continue; }; - rows.push(columns.iter().cloned().zip(values.iter().cloned()).collect()); + for values in series + .get("values") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(values) = values.as_array() else { + continue; + }; + rows.push( + columns + .iter() + .cloned() + .zip(values.iter().cloned()) + .collect(), + ); } out.push(V1Series { tags, rows }); } Ok(out) } -async fn query_v2(client: &Client, settings: &InfluxDbSettings, query: &str) -> Result>> { +async fn query_v2( + client: &Client, + settings: &InfluxDbSettings, + query: &str, +) -> Result>> { validate(settings)?; let base = settings.url.trim_end_matches('/'); - let response = client.post(format!("{base}/api/v2/query")) + let response = client + .post(format!("{base}/api/v2/query")) .query(&[("org", settings.org.as_str())]) .bearer_auth(settings.token.trim()) .header(reqwest::header::ACCEPT, "application/csv") .header(reqwest::header::CONTENT_TYPE, "application/vnd.flux") .body(query.to_string()) - .send().await.context("InfluxDB 2.x query failed")?; + .send() + .await + .context("InfluxDB 2.x query failed")?; let status = response.status(); - let body = response.text().await.context("cannot read InfluxDB 2.x response")?; - if !status.is_success() { bail!("InfluxDB 2.x query failed ({status}): {}", truncate(&body, 500)); } + let body = response + .text() + .await + .context("cannot read InfluxDB 2.x response")?; + if !status.is_success() { + bail!( + "InfluxDB 2.x query failed ({status}): {}", + truncate(&body, 500) + ); + } let mut headers: Option> = None; let mut rows = Vec::new(); - for line in body.lines().filter(|line| !line.starts_with('#') && !line.trim().is_empty()) { + for line in body + .lines() + .filter(|line| !line.starts_with('#') && !line.trim().is_empty()) + { let record = parse_csv_line(line); if headers.is_none() { headers = Some(record); continue; } - let row: HashMap = headers.as_ref().unwrap().iter().cloned().zip(record.into_iter()).collect(); - if row.get("_time").map(|value| !value.is_empty()).unwrap_or(false) { rows.push(row); } + let row: HashMap = headers + .as_ref() + .unwrap() + .iter() + .cloned() + .zip(record.into_iter()) + .collect(); + if row + .get("_time") + .map(|value| !value.is_empty()) + .unwrap_or(false) + { + rows.push(row); + } } Ok(rows) } - diff --git a/src/influxdb/write.rs b/src/influxdb/write.rs index 75a0c46..cc102d0 100644 --- a/src/influxdb/write.rs +++ b/src/influxdb/write.rs @@ -1,57 +1,129 @@ pub fn validate(settings: &InfluxDbSettings) -> Result<()> { - if !settings.enabled { return Ok(()); } - if !matches!(settings.version.as_str(), "1" | "2") { bail!("InfluxDB version must be 1 or 2"); } + if !settings.enabled { + return Ok(()); + } + if !matches!(settings.version.as_str(), "1" | "2") { + bail!("InfluxDB version must be 1 or 2"); + } let parsed = url::Url::parse(settings.url.trim()).context("invalid InfluxDB URL")?; - if !matches!(parsed.scheme(), "http" | "https") { bail!("InfluxDB URL must use http or https"); } - if settings.version == "1" && settings.database.trim().is_empty() { bail!("InfluxDB 1.x database is required"); } + if !matches!(parsed.scheme(), "http" | "https") { + bail!("InfluxDB URL must use http or https"); + } + if settings.version == "1" && settings.database.trim().is_empty() { + bail!("InfluxDB 1.x database is required"); + } if settings.version == "2" { - if settings.org.trim().is_empty() { bail!("InfluxDB 2.x organization is required"); } - if settings.bucket.trim().is_empty() { bail!("InfluxDB 2.x bucket is required"); } - if settings.token.trim().is_empty() { bail!("InfluxDB 2.x token is required"); } + if settings.org.trim().is_empty() { + bail!("InfluxDB 2.x organization is required"); + } + if settings.bucket.trim().is_empty() { + bail!("InfluxDB 2.x bucket is required"); + } + if settings.token.trim().is_empty() { + bail!("InfluxDB 2.x token is required"); + } } Ok(()) } -pub async fn write_device(client: &Client, settings: &InfluxDbSettings, reading: &Reading) -> Result<()> { - if !settings.enabled { return Ok(()); } +pub async fn write_device( + client: &Client, + settings: &InfluxDbSettings, + reading: &Reading, +) -> Result<()> { + if !settings.enabled { + return Ok(()); + } let mut fields = Vec::new(); - push_float(&mut fields, "indoor_temperature", reading.indoor_temperature); - push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); - push_float(&mut fields, "target_temperature", Some(reading.target_temperature)); + push_float( + &mut fields, + "indoor_temperature", + reading.indoor_temperature, + ); + push_float( + &mut fields, + "outdoor_temperature", + reading.outdoor_temperature, + ); + push_float( + &mut fields, + "target_temperature", + Some(reading.target_temperature), + ); push_int(&mut fields, "power", reading.power as i64); - let line = line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?; - write_line(client, settings, line).await -} - -pub async fn write_zone(client: &Client, settings: &InfluxDbSettings, reading: &ZoneReading) -> Result<()> { - if !settings.enabled { return Ok(()); } - let mut fields = Vec::new(); - push_float(&mut fields, "gree_temperature", reading.gree_temperature); - push_float(&mut fields, "external_temperature", reading.external_temperature); - push_float(&mut fields, "control_temperature", reading.control_temperature); - push_float(&mut fields, "target_temperature", reading.target_temperature); - push_float(&mut fields, "device_setpoint", reading.device_setpoint); - push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); - push_int(&mut fields, "power", reading.power as i64); - push_int(&mut fields, "fan_speed", reading.fan_speed as i64); - push_int(&mut fields, "demand", reading.demand as i64); let line = line_protocol( - ZONE_MEASUREMENT, - &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], + DEVICE_MEASUREMENT, + &[("device_id", &reading.device_id)], fields, reading.timestamp, )?; write_line(client, settings, line).await } -pub async fn write_ha(client: &Client, settings: &InfluxDbSettings, reading: &HaReading) -> Result<()> { - if !settings.enabled { return Ok(()); } +pub async fn write_zone( + client: &Client, + settings: &InfluxDbSettings, + reading: &ZoneReading, +) -> Result<()> { + if !settings.enabled { + return Ok(()); + } + let mut fields = Vec::new(); + push_float(&mut fields, "gree_temperature", reading.gree_temperature); + push_float( + &mut fields, + "external_temperature", + reading.external_temperature, + ); + push_float( + &mut fields, + "control_temperature", + reading.control_temperature, + ); + push_float( + &mut fields, + "target_temperature", + reading.target_temperature, + ); + push_float(&mut fields, "device_setpoint", reading.device_setpoint); + push_float( + &mut fields, + "outdoor_temperature", + reading.outdoor_temperature, + ); + push_int(&mut fields, "power", reading.power as i64); + push_int(&mut fields, "fan_speed", reading.fan_speed as i64); + push_int(&mut fields, "demand", reading.demand as i64); + let line = line_protocol( + ZONE_MEASUREMENT, + &[ + ("zone_id", &reading.zone_id), + ("device_id", &reading.device_id), + ], + fields, + reading.timestamp, + )?; + write_line(client, settings, line).await +} + +pub async fn write_ha( + client: &Client, + settings: &InfluxDbSettings, + reading: &HaReading, +) -> Result<()> { + if !settings.enabled { + return Ok(()); + } let zone = reading.zone_id.as_deref().unwrap_or(""); let mut fields = Vec::new(); push_float(&mut fields, "temperature", Some(reading.temperature)); let line = line_protocol( HA_MEASUREMENT, - &[("entity_id", &reading.entity_id), ("zone_id", zone), ("kind", &reading.kind)], + &[ + ("entity_id", &reading.entity_id), + ("zone_id", zone), + ("kind", &reading.kind), + ], fields, reading.timestamp, )?; @@ -65,35 +137,89 @@ pub async fn write_batch( zones: &[ZoneReading], ha: &[HaReading], ) -> Result<()> { - if !settings.enabled { return Ok(()); } + if !settings.enabled { + return Ok(()); + } let mut lines = Vec::with_capacity(devices.len() + zones.len() + ha.len()); for reading in devices { let mut fields = Vec::new(); - push_float(&mut fields, "indoor_temperature", reading.indoor_temperature); - push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); - push_float(&mut fields, "target_temperature", Some(reading.target_temperature)); + push_float( + &mut fields, + "indoor_temperature", + reading.indoor_temperature, + ); + push_float( + &mut fields, + "outdoor_temperature", + reading.outdoor_temperature, + ); + push_float( + &mut fields, + "target_temperature", + Some(reading.target_temperature), + ); push_int(&mut fields, "power", reading.power as i64); - lines.push(line_protocol(DEVICE_MEASUREMENT, &[("device_id", &reading.device_id)], fields, reading.timestamp)?); + lines.push(line_protocol( + DEVICE_MEASUREMENT, + &[("device_id", &reading.device_id)], + fields, + reading.timestamp, + )?); } for reading in zones { let mut fields = Vec::new(); push_float(&mut fields, "gree_temperature", reading.gree_temperature); - push_float(&mut fields, "external_temperature", reading.external_temperature); - push_float(&mut fields, "control_temperature", reading.control_temperature); - push_float(&mut fields, "target_temperature", reading.target_temperature); + push_float( + &mut fields, + "external_temperature", + reading.external_temperature, + ); + push_float( + &mut fields, + "control_temperature", + reading.control_temperature, + ); + push_float( + &mut fields, + "target_temperature", + reading.target_temperature, + ); push_float(&mut fields, "device_setpoint", reading.device_setpoint); - push_float(&mut fields, "outdoor_temperature", reading.outdoor_temperature); + push_float( + &mut fields, + "outdoor_temperature", + reading.outdoor_temperature, + ); push_int(&mut fields, "power", reading.power as i64); push_int(&mut fields, "fan_speed", reading.fan_speed as i64); push_int(&mut fields, "demand", reading.demand as i64); - lines.push(line_protocol(ZONE_MEASUREMENT, &[("zone_id", &reading.zone_id), ("device_id", &reading.device_id)], fields, reading.timestamp)?); + lines.push(line_protocol( + ZONE_MEASUREMENT, + &[ + ("zone_id", &reading.zone_id), + ("device_id", &reading.device_id), + ], + fields, + reading.timestamp, + )?); } for reading in ha { let mut fields = Vec::new(); push_float(&mut fields, "temperature", Some(reading.temperature)); - lines.push(line_protocol(HA_MEASUREMENT, &[("entity_id", &reading.entity_id), ("zone_id", reading.zone_id.as_deref().unwrap_or("")), ("kind", &reading.kind)], fields, reading.timestamp)?); + lines.push(line_protocol( + HA_MEASUREMENT, + &[ + ("entity_id", &reading.entity_id), + ("zone_id", reading.zone_id.as_deref().unwrap_or("")), + ("kind", &reading.kind), + ], + fields, + reading.timestamp, + )?); + } + if lines.is_empty() { + return Ok(()); } - if lines.is_empty() { return Ok(()); } write_lines(client, settings, lines.join("\n")).await } @@ -105,19 +231,32 @@ async fn write_lines(client: &Client, settings: &InfluxDbSettings, body: String) validate(settings)?; let base = settings.url.trim_end_matches('/'); let request = if settings.version == "1" { - let request = client.post(format!("{base}/write")) + let request = client + .post(format!("{base}/write")) .query(&[("db", settings.database.as_str()), ("precision", "ns")]) .header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8") .body(body.clone()); - if settings.username.trim().is_empty() { request } else { request.basic_auth(&settings.username, Some(&settings.password)) } + if settings.username.trim().is_empty() { + request + } else { + request.basic_auth(&settings.username, Some(&settings.password)) + } } else { - client.post(format!("{base}/api/v2/write")) - .query(&[("org", settings.org.as_str()), ("bucket", settings.bucket.as_str()), ("precision", "ns")]) + client + .post(format!("{base}/api/v2/write")) + .query(&[ + ("org", settings.org.as_str()), + ("bucket", settings.bucket.as_str()), + ("precision", "ns"), + ]) .bearer_auth(settings.token.trim()) .header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8") .body(body) }; - let response = request.send().await.context("InfluxDB write request failed")?; + let response = request + .send() + .await + .context("InfluxDB write request failed")?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); @@ -125,4 +264,3 @@ async fn write_lines(client: &Client, settings: &InfluxDbSettings, body: String) } Ok(()) } - diff --git a/src/main.rs b/src/main.rs index 2e57cbf..d95c47e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,14 +11,21 @@ mod protocol; mod queries; mod state; -use std::{sync::{Arc, atomic::AtomicBool}, time::{Duration, Instant}}; use anyhow::{Context, Result}; use config::Config; use db::Db; use models::Device; use protocol::GreeClient; use state::AppState; -use tokio::{net::TcpListener, signal, sync::{broadcast, Notify, RwLock}}; +use std::{ + sync::{atomic::AtomicBool, Arc}, + time::{Duration, Instant}, +}; +use tokio::{ + net::TcpListener, + signal, + sync::{broadcast, Notify, RwLock}, +}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] @@ -27,7 +34,9 @@ async fn main() -> Result<()> { init_tracing(); let db = Db::open(&config.database)?; - let mut runtime_settings = db.load_runtime_settings()?.unwrap_or_else(|| config.runtime_defaults()); + let mut runtime_settings = db + .load_runtime_settings()? + .unwrap_or_else(|| config.runtime_defaults()); // Network deployment settings explicitly provided by the service environment are authoritative. // This makes /etc/gree-controller.env useful even after runtime settings were persisted in SQLite. if std::env::var_os("GREE_CONTROLLER_DISCOVERY_BROADCAST").is_some() { @@ -58,7 +67,8 @@ async fn main() -> Result<()> { config: Arc::new(config.clone()), gree: GreeClient::new( runtime_settings.controller_id.clone(), - (!config.gree_interface.trim().is_empty()).then(|| config.gree_interface.trim().to_string()), + (!config.gree_interface.trim().is_empty()) + .then(|| config.gree_interface.trim().to_string()), Some(events.clone()), debug_gree_frames.clone(), ), @@ -76,16 +86,23 @@ async fn main() -> Result<()> { house_operation_lock: Arc::new(tokio::sync::Mutex::new(())), configuration_operation_lock: Arc::new(tokio::sync::Mutex::new(())), zone_control_cycle_lock: Arc::new(tokio::sync::Mutex::new(())), - pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + pending_controller_commands: Arc::new(tokio::sync::Mutex::new( + std::collections::HashMap::new(), + )), started: Instant::now(), }; engine::start(state.clone()); let app = api::router(state.clone()); - let listener = TcpListener::bind(config.bind).await + let listener = TcpListener::bind(config.bind) + .await .with_context(|| format!("cannot bind HTTP server to {}", config.bind))?; - let gree_interface_log = if config.gree_interface.trim().is_empty() { "auto" } else { config.gree_interface.trim() }; + let gree_interface_log = if config.gree_interface.trim().is_empty() { + "auto" + } else { + config.gree_interface.trim() + }; tracing::info!( address = %config.bind, database = %config.database.display(), @@ -113,12 +130,17 @@ fn init_tracing() { } async fn shutdown_signal() { - let ctrl_c = async { signal::ctrl_c().await.expect("cannot install Ctrl+C handler"); }; + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("cannot install Ctrl+C handler"); + }; #[cfg(unix)] let terminate = async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("cannot install SIGTERM handler") - .recv().await; + .recv() + .await; }; #[cfg(not(unix))] let terminate = std::future::pending::<()>(); diff --git a/src/models.rs b/src/models.rs index bac7156..3316191 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,7 +1,7 @@ -use std::collections::BTreeMap; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::BTreeMap; // Functional source split intentionally keeps items in the existing module namespace. include!("models/defaults.rs"); diff --git a/src/models/automation.rs b/src/models/automation.rs index 4a41b75..05c225d 100644 --- a/src/models/automation.rs +++ b/src/models/automation.rs @@ -25,7 +25,6 @@ pub struct Schedule { pub flow_node_id: Option, } - #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] pub struct FlowRuntimeNodeState { #[serde(default)] @@ -98,4 +97,3 @@ pub struct Automation { pub created_at: DateTime, pub updated_at: DateTime, } - diff --git a/src/models/control_plan.rs b/src/models/control_plan.rs index 6702094..903eca4 100644 --- a/src/models/control_plan.rs +++ b/src/models/control_plan.rs @@ -109,4 +109,3 @@ pub struct ControlPlan { pub zones: Vec, pub rules: Vec, } - diff --git a/src/models/defaults.rs b/src/models/defaults.rs index 44dcd64..a0d1367 100644 --- a/src/models/defaults.rs +++ b/src/models/defaults.rs @@ -1,39 +1,114 @@ -fn default_true() -> bool { true } -fn default_port() -> u16 { 7000 } -fn default_protocol() -> u8 { 0 } -fn default_mode() -> String { "cool".into() } -fn default_fan() -> u8 { 0 } -fn default_target() -> f64 { 24.0 } -fn default_hysteresis() -> f64 { 0.6 } -fn default_external_sensor_weight() -> f64 { 0.4 } -fn default_max_sensor_difference() -> f64 { 3.0 } -fn default_control_temperature_source() -> String { "device".into() } -fn default_min_cycle() -> u64 { 180 } -fn default_compressor_protection_seconds() -> u64 { 180 } -fn default_sensor_stale_after() -> u64 { 300 } -fn default_cooldown() -> u64 { 300 } -fn default_house_mode() -> String { "cool".into() } -fn default_control_strategy() -> String { "setpoint".into() } -fn default_standby_offset() -> f64 { 2.0 } -fn default_min_adjust() -> u64 { 120 } -fn default_schedule_preset() -> String { "custom".into() } -fn default_active_preset() -> String { "comfort".into() } -fn default_cool_comfort() -> f64 { 23.0 } -fn default_cool_sleep() -> f64 { 24.5 } -fn default_cool_away() -> f64 { 27.0 } -fn default_heat_comfort() -> f64 { 21.0 } -fn default_heat_sleep() -> f64 { 19.0 } -fn default_heat_away() -> f64 { 17.0 } -fn default_history_retention_days() -> u32 { 30 } -fn default_event_log_retention_days() -> u32 { 30 } -fn default_influx_version() -> String { "2".into() } -fn default_influx_database() -> String { "gree_controller".into() } -fn default_influx_threshold_days() -> u32 { 30 } -fn default_night_start() -> String { "22:00".into() } -fn default_night_end() -> String { "06:00".into() } -fn default_night_max_fan_speed() -> u8 { 1 } -fn default_group_power_enabled() -> bool { true } -fn default_temporary_tolerance() -> f64 { 0.3 } -fn default_temporary_start_kind() -> String { "now".into() } -fn default_temporary_state() -> String { "scheduled".into() } - +fn default_true() -> bool { + true +} +fn default_port() -> u16 { + 7000 +} +fn default_protocol() -> u8 { + 0 +} +fn default_mode() -> String { + "cool".into() +} +fn default_fan() -> u8 { + 0 +} +fn default_target() -> f64 { + 24.0 +} +fn default_hysteresis() -> f64 { + 0.6 +} +fn default_external_sensor_weight() -> f64 { + 0.4 +} +fn default_max_sensor_difference() -> f64 { + 3.0 +} +fn default_control_temperature_source() -> String { + "device".into() +} +fn default_min_cycle() -> u64 { + 180 +} +fn default_compressor_protection_seconds() -> u64 { + 180 +} +fn default_sensor_stale_after() -> u64 { + 300 +} +fn default_cooldown() -> u64 { + 300 +} +fn default_house_mode() -> String { + "cool".into() +} +fn default_control_strategy() -> String { + "setpoint".into() +} +fn default_standby_offset() -> f64 { + 2.0 +} +fn default_min_adjust() -> u64 { + 120 +} +fn default_schedule_preset() -> String { + "custom".into() +} +fn default_active_preset() -> String { + "comfort".into() +} +fn default_cool_comfort() -> f64 { + 23.0 +} +fn default_cool_sleep() -> f64 { + 24.5 +} +fn default_cool_away() -> f64 { + 27.0 +} +fn default_heat_comfort() -> f64 { + 21.0 +} +fn default_heat_sleep() -> f64 { + 19.0 +} +fn default_heat_away() -> f64 { + 17.0 +} +fn default_history_retention_days() -> u32 { + 30 +} +fn default_event_log_retention_days() -> u32 { + 30 +} +fn default_influx_version() -> String { + "2".into() +} +fn default_influx_database() -> String { + "gree_controller".into() +} +fn default_influx_threshold_days() -> u32 { + 30 +} +fn default_night_start() -> String { + "22:00".into() +} +fn default_night_end() -> String { + "06:00".into() +} +fn default_night_max_fan_speed() -> u8 { + 1 +} +fn default_group_power_enabled() -> bool { + true +} +fn default_temporary_tolerance() -> f64 { + 0.3 +} +fn default_temporary_start_kind() -> String { + "now".into() +} +fn default_temporary_state() -> String { + "scheduled".into() +} diff --git a/src/models/device.rs b/src/models/device.rs index d7fa679..2053c09 100644 --- a/src/models/device.rs +++ b/src/models/device.rs @@ -162,21 +162,42 @@ pub struct DeviceCommand { impl DeviceCommand { pub fn is_empty(&self) -> bool { - self.power.is_none() && self.mode.is_none() && self.target_temperature.is_none() - && self.fan_speed.is_none() && self.swing_vertical.is_none() && self.swing_horizontal.is_none() - && self.quiet.is_none() && self.turbo.is_none() && self.light.is_none() - && self.air.is_none() && self.xfan.is_none() && self.health.is_none() && self.sleep.is_none() + self.power.is_none() + && self.mode.is_none() + && self.target_temperature.is_none() + && self.fan_speed.is_none() + && self.swing_vertical.is_none() + && self.swing_horizontal.is_none() + && self.quiet.is_none() + && self.turbo.is_none() + && self.light.is_none() + && self.air.is_none() + && self.xfan.is_none() + && self.health.is_none() + && self.sleep.is_none() } /// Return only fields that differ from the last known device state. pub fn changed_from(&self, device: &Device) -> Self { Self { power: self.power.filter(|value| *value != device.power), - mode: self.mode.as_ref().filter(|value| value.as_str() != device.mode.as_str()).cloned(), - target_temperature: self.target_temperature.filter(|value| value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round()), - fan_speed: self.fan_speed.filter(|value| (*value).min(5) != device.fan_speed), - swing_vertical: self.swing_vertical.filter(|value| *value != device.swing_vertical), - swing_horizontal: self.swing_horizontal.filter(|value| *value != device.swing_horizontal), + mode: self + .mode + .as_ref() + .filter(|value| value.as_str() != device.mode.as_str()) + .cloned(), + target_temperature: self.target_temperature.filter(|value| { + value.clamp(8.0, 30.0).round() != device.target_temperature.clamp(8.0, 30.0).round() + }), + fan_speed: self + .fan_speed + .filter(|value| (*value).min(5) != device.fan_speed), + swing_vertical: self + .swing_vertical + .filter(|value| *value != device.swing_vertical), + swing_horizontal: self + .swing_horizontal + .filter(|value| *value != device.swing_horizontal), quiet: self.quiet.filter(|value| *value != device.quiet), turbo: self.turbo.filter(|value| *value != device.turbo), light: self.light.filter(|value| *value != device.light), @@ -188,19 +209,45 @@ impl DeviceCommand { } pub fn apply(&self, device: &mut Device) { - if let Some(v) = self.power { device.power = v; } - if let Some(v) = &self.mode { device.mode = v.clone(); } - if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 30.0).round(); } - if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); } - if let Some(v) = self.swing_vertical { device.swing_vertical = v; } - if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; } - if let Some(v) = self.quiet { device.quiet = v; } - if let Some(v) = self.turbo { device.turbo = v; } - if let Some(v) = self.light { device.light = v; } - if let Some(v) = self.air { device.air = v; } - if let Some(v) = self.xfan { device.xfan = v; } - if let Some(v) = self.health { device.health = v; } - if let Some(v) = self.sleep { device.sleep = v; } + if let Some(v) = self.power { + device.power = v; + } + if let Some(v) = &self.mode { + device.mode = v.clone(); + } + if let Some(v) = self.target_temperature { + device.target_temperature = v.clamp(8.0, 30.0).round(); + } + if let Some(v) = self.fan_speed { + device.fan_speed = v.min(5); + } + if let Some(v) = self.swing_vertical { + device.swing_vertical = v; + } + if let Some(v) = self.swing_horizontal { + device.swing_horizontal = v; + } + if let Some(v) = self.quiet { + device.quiet = v; + } + if let Some(v) = self.turbo { + device.turbo = v; + } + if let Some(v) = self.light { + device.light = v; + } + if let Some(v) = self.air { + device.air = v; + } + if let Some(v) = self.xfan { + device.xfan = v; + } + if let Some(v) = self.health { + device.health = v; + } + if let Some(v) = self.sleep { + device.sleep = v; + } device.updated_at = Utc::now(); } } @@ -227,4 +274,3 @@ impl From<&Device> for ManualDeviceBaseline { } } } - diff --git a/src/models/history.rs b/src/models/history.rs index 06c0c40..8b49860 100644 --- a/src/models/history.rs +++ b/src/models/history.rs @@ -49,4 +49,3 @@ pub struct EventLog { pub message: String, pub metadata: Value, } - diff --git a/src/models/integrations.rs b/src/models/integrations.rs index 58842ce..db76cd6 100644 --- a/src/models/integrations.rs +++ b/src/models/integrations.rs @@ -78,7 +78,6 @@ impl Default for InfluxDbSettings { } } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NotificationAlertTypes { /// Home Assistant sensor exceeded the configured freshness window. @@ -110,8 +109,14 @@ pub struct NotificationAlertTypes { impl Default for NotificationAlertTypes { fn default() -> Self { Self { - stale_sensor: true, sensor_errors: true, communication: true, target_timeout: true, - automation: true, control_errors: true, important_events: true, other: true, + stale_sensor: true, + sensor_errors: true, + communication: true, + target_timeout: true, + automation: true, + control_errors: true, + important_events: true, + other: true, } } } @@ -145,18 +150,32 @@ pub struct NotificationSettings { pub alert_types: NotificationAlertTypes, } -fn default_notification_mode() -> String { "problems".into() } -fn default_notification_provider() -> String { "pushover".into() } -fn default_notification_cooldown() -> u64 { 300 } -fn default_notification_failure_threshold() -> u32 { 3 } -fn default_notification_target_timeout() -> u32 { 60 } +fn default_notification_mode() -> String { + "problems".into() +} +fn default_notification_provider() -> String { + "pushover".into() +} +fn default_notification_cooldown() -> u64 { + 300 +} +fn default_notification_failure_threshold() -> u32 { + 3 +} +fn default_notification_target_timeout() -> u32 { + 60 +} impl Default for NotificationSettings { fn default() -> Self { Self { - enabled: false, mode: default_notification_mode(), provider: default_notification_provider(), - pushover_app_token: String::new(), pushover_user_key: String::new(), - slack_webhook_url: String::new(), discord_webhook_url: String::new(), + enabled: false, + mode: default_notification_mode(), + provider: default_notification_provider(), + pushover_app_token: String::new(), + pushover_user_key: String::new(), + slack_webhook_url: String::new(), + discord_webhook_url: String::new(), cooldown_seconds: default_notification_cooldown(), communication_failure_threshold: default_notification_failure_threshold(), target_timeout_minutes: default_notification_target_timeout(), @@ -204,4 +223,3 @@ impl Default for NightModeSettings { } } } - diff --git a/src/models/temporary_thermostat.rs b/src/models/temporary_thermostat.rs index b51eb17..f765f5e 100644 --- a/src/models/temporary_thermostat.rs +++ b/src/models/temporary_thermostat.rs @@ -106,4 +106,3 @@ pub struct TemporaryQuickThermostatRequest { #[serde(default)] pub max_duration_minutes: Option, } - diff --git a/src/models/zone.rs b/src/models/zone.rs index c71229c..f51717c 100644 --- a/src/models/zone.rs +++ b/src/models/zone.rs @@ -175,7 +175,11 @@ pub struct Zone { impl Zone { pub fn hysteresis_for_mode(&self, mode: &str) -> f64 { let value = if self.separate_hysteresis { - if mode == "heat" { self.heat_hysteresis } else { self.cool_hysteresis } + if mode == "heat" { + self.heat_hysteresis + } else { + self.cool_hysteresis + } } else { self.hysteresis }; @@ -183,8 +187,9 @@ impl Zone { } } -fn default_sensor_source() -> String { "device".into() } - +fn default_sensor_source() -> String { + "device".into() +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClimateGroup { @@ -244,4 +249,3 @@ pub struct ZoneControlPatch { #[serde(default)] pub clear_temporary_quick_thermostat: Option, } - diff --git a/src/notifications.rs b/src/notifications.rs index 7c149f6..11b2a0c 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -1,43 +1,84 @@ -use std::{collections::HashMap, sync::{Mutex, OnceLock}}; +use crate::{models::NotificationSettings, state::AppState}; use chrono::Utc; use serde_json::{json, Value}; -use crate::{models::NotificationSettings, state::AppState}; +use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, +}; static LAST_SENT: OnceLock>> = OnceLock::new(); fn important_kind(kind: &str) -> bool { - matches!(kind, - "device.offline" | "device.recovered" | "automation.fired" | "automation.error" | - "zone.target_timeout" | "zone.action_error" | "house.mode" | "house.preset" | - "device.communication_error") + matches!( + kind, + "device.offline" + | "device.recovered" + | "automation.fired" + | "automation.error" + | "zone.target_timeout" + | "zone.action_error" + | "house.mode" + | "house.preset" + | "device.communication_error" + ) } fn alert_type_enabled(cfg: &NotificationSettings, kind: &str) -> bool { let types = &cfg.alert_types; - if kind == "ha.sensor_stale" { return types.stale_sensor; } - if kind.starts_with("ha.sensor_") { return types.sensor_errors; } - if matches!(kind, "device.offline" | "device.communication_error" | "device.command_unconfirmed") { return types.communication; } - if kind == "zone.target_timeout" { return types.target_timeout; } - if kind.starts_with("automation.") { return types.automation; } - if matches!(kind, - "zone.action_error" | "zone.mode_change_off_error" | "zone.local_power_error" | - "zone.device_missing" | "zone.sensor_discrepancy" | - "zone.temporary_quick_thermostat_cancelled" | "zone.temporary_quick_thermostat_poweroff_error" | - "group.power_error" - ) { return types.control_errors; } - if important_kind(kind) { return types.important_events; } + if kind == "ha.sensor_stale" { + return types.stale_sensor; + } + if kind.starts_with("ha.sensor_") { + return types.sensor_errors; + } + if matches!( + kind, + "device.offline" | "device.communication_error" | "device.command_unconfirmed" + ) { + return types.communication; + } + if kind == "zone.target_timeout" { + return types.target_timeout; + } + if kind.starts_with("automation.") { + return types.automation; + } + if matches!( + kind, + "zone.action_error" + | "zone.mode_change_off_error" + | "zone.local_power_error" + | "zone.device_missing" + | "zone.sensor_discrepancy" + | "zone.temporary_quick_thermostat_cancelled" + | "zone.temporary_quick_thermostat_poweroff_error" + | "group.power_error" + ) { + return types.control_errors; + } + if important_kind(kind) { + return types.important_events; + } types.other } fn should_send(cfg: &NotificationSettings, level: &str, kind: &str) -> bool { - if !cfg.enabled || !alert_type_enabled(cfg, kind) { return false; } - if level == "error" || level == "warn" { return true; } + if !cfg.enabled || !alert_type_enabled(cfg, kind) { + return false; + } + if level == "error" || level == "warn" { + return true; + } cfg.mode == "important" && important_kind(kind) } fn cooldown_key(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> String { - let entity = metadata.get("device_id").or_else(|| metadata.get("zone_id")).or_else(|| metadata.get("automation_id")) - .and_then(Value::as_str).unwrap_or("global"); + let entity = metadata + .get("device_id") + .or_else(|| metadata.get("zone_id")) + .or_else(|| metadata.get("automation_id")) + .and_then(Value::as_str) + .unwrap_or("global"); format!("{}:{}:{}", cfg.provider, kind, entity) } @@ -45,22 +86,55 @@ fn take_cooldown(cfg: &NotificationSettings, kind: &str, metadata: &Value) -> bo let now = Utc::now().timestamp(); let key = cooldown_key(cfg, kind, metadata); let map = LAST_SENT.get_or_init(|| Mutex::new(HashMap::new())); - let Ok(mut map) = map.lock() else { return false; }; + let Ok(mut map) = map.lock() else { + return false; + }; if let Some(last) = map.get(&key) { - if now - *last < cfg.cooldown_seconds.max(30) as i64 { return false; } + if now - *last < cfg.cooldown_seconds.max(30) as i64 { + return false; + } } map.insert(key, now); true } -pub async fn dispatch(state: AppState, level: String, kind: String, message: String, metadata: Value) { +pub async fn dispatch( + state: AppState, + level: String, + kind: String, + message: String, + metadata: Value, +) { let cfg = state.settings.read().await.notifications.clone(); - if !should_send(&cfg, &level, &kind) || !take_cooldown(&cfg, &kind, &metadata) { return; } - let title = format!("GREE Controller - {}", if level == "error" { "error" } else if level == "warn" { "warning" } else { "event" }); + if !should_send(&cfg, &level, &kind) || !take_cooldown(&cfg, &kind, &metadata) { + return; + } + let title = format!( + "GREE Controller - {}", + if level == "error" { + "error" + } else if level == "warn" { + "warning" + } else { + "event" + } + ); let result = match cfg.provider.as_str() { "pushover" => send_pushover(&state, &cfg, &title, &message).await, - "slack" => send_webhook(&cfg.slack_webhook_url, json!({"text": format!("*{}*\\n{}", title, message)})).await, - "discord" => send_webhook(&cfg.discord_webhook_url, json!({"content": format!("**{}**\\n{}", title, message)})).await, + "slack" => { + send_webhook( + &cfg.slack_webhook_url, + json!({"text": format!("*{}*\\n{}", title, message)}), + ) + .await + } + "discord" => { + send_webhook( + &cfg.discord_webhook_url, + json!({"content": format!("**{}**\\n{}", title, message)}), + ) + .await + } _ => Err("unsupported notification provider".into()), }; if let Err(err) = result { @@ -68,33 +142,79 @@ pub async fn dispatch(state: AppState, level: String, kind: String, message: Str } } -async fn send_pushover(state: &AppState, cfg: &NotificationSettings, title: &str, message: &str) -> Result<(), String> { - if cfg.pushover_app_token.trim().is_empty() || cfg.pushover_user_key.trim().is_empty() { return Err("Pushover credentials are incomplete".into()); } - let response = state.http.post("https://api.pushover.net/1/messages.json") - .form(&[("token", cfg.pushover_app_token.as_str()), ("user", cfg.pushover_user_key.as_str()), ("title", title), ("message", message)]) - .send().await.map_err(|e| e.to_string())?; - if response.status().is_success() { Ok(()) } else { Err(format!("Pushover HTTP {}", response.status())) } +async fn send_pushover( + state: &AppState, + cfg: &NotificationSettings, + title: &str, + message: &str, +) -> Result<(), String> { + if cfg.pushover_app_token.trim().is_empty() || cfg.pushover_user_key.trim().is_empty() { + return Err("Pushover credentials are incomplete".into()); + } + let response = state + .http + .post("https://api.pushover.net/1/messages.json") + .form(&[ + ("token", cfg.pushover_app_token.as_str()), + ("user", cfg.pushover_user_key.as_str()), + ("title", title), + ("message", message), + ]) + .send() + .await + .map_err(|e| e.to_string())?; + if response.status().is_success() { + Ok(()) + } else { + Err(format!("Pushover HTTP {}", response.status())) + } } async fn send_webhook(url: &str, body: Value) -> Result<(), String> { let parsed = url::Url::parse(url).map_err(|_| "invalid webhook URL".to_string())?; - if parsed.scheme() != "https" { return Err("webhook URL must use HTTPS".into()); } + if parsed.scheme() != "https" { + return Err("webhook URL must use HTTPS".into()); + } let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase(); let allowed = host == "hooks.slack.com" || host == "discord.com" || host == "discordapp.com"; - if !allowed { return Err("webhook host is not supported".into()); } + if !allowed { + return Err("webhook host is not supported".into()); + } let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(10)) - .build().map_err(|e| e.to_string())?; - let response = client.post(parsed).json(&body).send().await.map_err(|e| e.to_string())?; - if response.status().is_success() { Ok(()) } else { Err(format!("webhook HTTP {}", response.status())) } + .build() + .map_err(|e| e.to_string())?; + let response = client + .post(parsed) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())?; + if response.status().is_success() { + Ok(()) + } else { + Err(format!("webhook HTTP {}", response.status())) + } } pub async fn test(state: &AppState, cfg: NotificationSettings) -> Result<(), String> { match cfg.provider.as_str() { "pushover" => send_pushover(state, &cfg, "GREE Controller", "Test notification").await, - "slack" => send_webhook(&cfg.slack_webhook_url, json!({"text":"GREE Controller - test notification"})).await, - "discord" => send_webhook(&cfg.discord_webhook_url, json!({"content":"GREE Controller - test notification"})).await, + "slack" => { + send_webhook( + &cfg.slack_webhook_url, + json!({"text":"GREE Controller - test notification"}), + ) + .await + } + "discord" => { + send_webhook( + &cfg.discord_webhook_url, + json!({"content":"GREE Controller - test notification"}), + ) + .await + } _ => Err("unsupported notification provider".into()), } } diff --git a/src/protocol/crypto.rs b/src/protocol/crypto.rs index 74b2bb6..528ea3e 100644 --- a/src/protocol/crypto.rs +++ b/src/protocol/crypto.rs @@ -1,5 +1,11 @@ -use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}}; -use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}}; +use aes::{ + cipher::{generic_array::GenericArray, BlockDecrypt, BlockEncrypt, KeyInit}, + Aes128, +}; +use aes_gcm::{ + aead::{AeadInPlace, KeyInit as AeadKeyInit}, + Aes128Gcm, Nonce, +}; use anyhow::{anyhow, bail, Context, Result}; use base64::{ alphabet, @@ -15,7 +21,9 @@ pub const GENERIC_GREE_V1_KEY: &str = "a3K8Bx%2r8Y7#xDh"; /// Shared discovery/bind key used by AES-128-GCM capable Wi-Fi modules. pub const GENERIC_GREE_V2_KEY: &str = "{yxAHAY_Lm6pbC/<"; /// GREE protocol v2 uses a fixed nonce and AAD, matching the EWPE/GREE LAN protocol. -const GCM_NONCE: [u8; 12] = [0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13]; +const GCM_NONCE: [u8; 12] = [ + 0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13, +]; const GCM_AAD: &[u8] = b"qualcomm-test"; // Some GREE Wi-Fi modules emit technically non-canonical Base64: padding may @@ -62,7 +70,9 @@ pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result { pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result> { let key = normalize_key(key)?; let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?; - let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid base64 packet")?; + let mut data = GREE_BASE64_DECODE + .decode(ciphertext_b64) + .context("invalid base64 packet")?; if data.is_empty() || data.len() % 16 != 0 { bail!("invalid AES-ECB ciphertext length") } @@ -94,10 +104,12 @@ pub struct V2Encrypted { pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result { let key = normalize_key(key)?; - let cipher = ::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?; + let cipher = ::new_from_slice(&key) + .map_err(|_| anyhow!("invalid AES-GCM key"))?; let nonce = Nonce::from_slice(&GCM_NONCE); let mut buffer = plaintext.to_vec(); - let tag = cipher.encrypt_in_place_detached(nonce, GCM_AAD, &mut buffer) + let tag = cipher + .encrypt_in_place_detached(nonce, GCM_AAD, &mut buffer) .map_err(|_| anyhow!("AES-GCM encryption failed"))?; Ok(V2Encrypted { ciphertext: STANDARD.encode(buffer), @@ -107,13 +119,21 @@ pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result { pub fn decrypt_v2(key: &str, ciphertext_b64: &str, tag_b64: &str) -> Result> { let key = normalize_key(key)?; - let cipher = ::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?; - let tag_bytes = GREE_BASE64_DECODE.decode(tag_b64).context("invalid GCM tag")?; - if tag_bytes.len() != 16 { bail!("invalid GCM tag length: {} bytes", tag_bytes.len()) } - let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid GCM ciphertext")?; + let cipher = ::new_from_slice(&key) + .map_err(|_| anyhow!("invalid AES-GCM key"))?; + let tag_bytes = GREE_BASE64_DECODE + .decode(tag_b64) + .context("invalid GCM tag")?; + if tag_bytes.len() != 16 { + bail!("invalid GCM tag length: {} bytes", tag_bytes.len()) + } + let mut data = GREE_BASE64_DECODE + .decode(ciphertext_b64) + .context("invalid GCM ciphertext")?; let nonce = Nonce::from_slice(&GCM_NONCE); let tag = GenericArray::from_slice(&tag_bytes); - cipher.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag) + cipher + .decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag) .map_err(|_| anyhow!("AES-GCM authentication failed"))?; // A few modules append 0xff filler bytes to decrypted JSON. data.retain(|byte| *byte != 0xff); @@ -135,7 +155,10 @@ mod tests { fn v2_round_trip() { let value = b"gree-gcm-test"; let encrypted = encrypt_v2(GENERIC_GREE_V2_KEY, value).unwrap(); - assert_eq!(decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(), value); + assert_eq!( + decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(), + value + ); } #[test] @@ -143,7 +166,9 @@ mod tests { // 16 zero bytes canonically end with `A==`. `B==` carries the same // useful two bits but has non-zero unused trailing bits. Python's // base64.b64decode accepts it and real GREE modules emit this form. - let decoded = GREE_BASE64_DECODE.decode("AAAAAAAAAAAAAAAAAAAAAB==").unwrap(); + let decoded = GREE_BASE64_DECODE + .decode("AAAAAAAAAAAAAAAAAAAAAB==") + .unwrap(); assert_eq!(decoded, vec![0_u8; 16]); } } diff --git a/src/protocol/gree.rs b/src/protocol/gree.rs index ec14291..be047c9 100644 --- a/src/protocol/gree.rs +++ b/src/protocol/gree.rs @@ -1,14 +1,25 @@ -use std::{collections::{HashMap, HashSet}, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}}, time::Duration}; +use super::crypto::{ + decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY, +}; +use crate::models::{ApiEvent, Device, DeviceCommand}; use anyhow::{anyhow, bail, Context, Result}; use chrono::Utc; use serde_json::{json, Value}; -use tokio::{net::UdpSocket, sync::broadcast, time::{timeout, Instant}}; -use uuid::Uuid; -use crate::models::{ApiEvent, Device, DeviceCommand}; -use super::crypto::{ - decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, - GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY, +use std::{ + collections::{HashMap, HashSet}, + net::{Ipv4Addr, SocketAddr, SocketAddrV4}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, + time::Duration, }; +use tokio::{ + net::UdpSocket, + sync::broadcast, + time::{timeout, Instant}, +}; +use uuid::Uuid; #[derive(Debug, Clone)] pub struct BindResult { @@ -29,7 +40,6 @@ pub struct GreeClient { sleep_unsupported: Arc>>, } - // Functional source split intentionally keeps items in the existing module namespace. include!("gree/core.rs"); include!("gree/discovery.rs"); diff --git a/src/protocol/gree/binding.rs b/src/protocol/gree/binding.rs index d558738..fdc964b 100644 --- a/src/protocol/gree/binding.rs +++ b/src/protocol/gree/binding.rs @@ -7,7 +7,12 @@ impl GreeClient { let mut errors = Vec::new(); for &version in versions { match self.bind_attempt(device, version).await { - Ok(key) => return Ok(BindResult { key, protocol_version: version }), + Ok(key) => { + return Ok(BindResult { + key, + protocol_version: version, + }) + } Err(err) => { tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed"); errors.push(format!("V{version}: {err}")); @@ -28,7 +33,10 @@ impl GreeClient { async fn bind_attempt(&self, device: &Device, version: u8) -> Result { let target = self.device_target(device)?; - let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; + let target_hint = match target { + SocketAddr::V4(addr) => Some(*addr.ip()), + SocketAddr::V6(_) => None, + }; let socket = self.udp_socket(true, target_hint).await?; // Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the @@ -56,16 +64,28 @@ impl GreeClient { let wire_mac = Self::wire_mac(device); let inner = json!({"mac": wire_mac, "t": "bind", "uid": 0}); - let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY }; - let response = self.request_on_socket(device, &inner, generic_key, true, version, &socket).await?; - let kind = response.get("t").and_then(Value::as_str).unwrap_or_default(); + let generic_key = if version == 2 { + GENERIC_GREE_V2_KEY + } else { + GENERIC_GREE_V1_KEY + }; + let response = self + .request_on_socket(device, &inner, generic_key, true, version, &socket) + .await?; + let kind = response + .get("t") + .and_then(Value::as_str) + .unwrap_or_default(); if !kind.eq_ignore_ascii_case("bindok") { bail!("unexpected bind response type: {kind}") } - let key = response.get("key").and_then(Value::as_str) + let key = response + .get("key") + .and_then(Value::as_str) .ok_or_else(|| anyhow!("bind response does not contain device key"))?; - if key.is_empty() { bail!("device returned an empty key") } + if key.is_empty() { + bail!("device returned an empty key") + } Ok(key.to_string()) } - } diff --git a/src/protocol/gree/commands.rs b/src/protocol/gree/commands.rs index 6c66326..c818f4e 100644 --- a/src/protocol/gree/commands.rs +++ b/src/protocol/gree/commands.rs @@ -1,10 +1,16 @@ impl GreeClient { pub fn quiet_command_supported(&self, device_id: &str) -> bool { - self.quiet_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true) + self.quiet_unsupported + .lock() + .map(|items| !items.contains(device_id)) + .unwrap_or(true) } pub fn sleep_command_supported(&self, device_id: &str) -> bool { - self.sleep_unsupported.lock().map(|items| !items.contains(device_id)).unwrap_or(true) + self.sleep_unsupported + .lock() + .map(|items| !items.contains(device_id)) + .unwrap_or(true) } async fn request_command_with_buzzer_fallback( @@ -15,17 +21,29 @@ impl GreeClient { suppress_beep: bool, ) -> Result { let try_buzzer_suppression = suppress_beep - && self.buzzer_unsupported.lock().map(|items| !items.contains(&device.id)).unwrap_or(true); + && self + .buzzer_unsupported + .lock() + .map(|items| !items.contains(&device.id)) + .unwrap_or(true); let inner = Self::command_payload(command, try_buzzer_suppression)?; - match self.request(device, &inner, key, false, device.protocol_version).await { + match self + .request(device, &inner, key, false, device.protocol_version) + .await + { Ok(value) => Ok(value), Err(first_err) if try_buzzer_suppression => { // Some firmwares reject unknown buzzer properties instead of ignoring them. // Retry the exact state change without buzzer fields and remember the fallback. let fallback = Self::command_payload(command, false)?; - match self.request(device, &fallback, key, false, device.protocol_version).await { + match self + .request(device, &fallback, key, false, device.protocol_version) + .await + { Ok(value) => { - if let Ok(mut items) = self.buzzer_unsupported.lock() { items.insert(device.id.clone()); } + if let Ok(mut items) = self.buzzer_unsupported.lock() { + items.insert(device.id.clone()); + } tracing::warn!(device=%device.id, "GREE buzzer suppression is unsupported; using normal command frames for this device"); Ok(value) } @@ -36,8 +54,16 @@ impl GreeClient { } } - pub async fn command(&self, device: &Device, command: &DeviceCommand, suppress_beep: bool) -> Result { - let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?; + pub async fn command( + &self, + device: &Device, + command: &DeviceCommand, + suppress_beep: bool, + ) -> Result { + let key = device + .key + .as_deref() + .ok_or_else(|| anyhow!("device is not bound"))?; let mut effective = command.clone(); if effective.quiet.is_some() && !self.quiet_command_supported(&device.id) { effective.quiet = None; @@ -49,7 +75,10 @@ impl GreeClient { return Ok(effective); } - match self.request_command_with_buzzer_fallback(device, key, &effective, suppress_beep).await { + match self + .request_command_with_buzzer_fallback(device, key, &effective, suppress_beep) + .await + { Ok(_) => Ok(effective), Err(first_err) => { // Quiet and native Sleep are optional GREE features. A unit may report a @@ -59,8 +88,19 @@ impl GreeClient { let mut fallback = effective.clone(); fallback.sleep = None; if !fallback.is_empty() { - if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { - if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); } + if self + .request_command_with_buzzer_fallback( + device, + key, + &fallback, + suppress_beep, + ) + .await + .is_ok() + { + if let Ok(mut items) = self.sleep_unsupported.lock() { + items.insert(device.id.clone()); + } tracing::warn!(device=%device.id, "GREE native Sleep command is unsupported; night mode will continue without Sleep for this device"); return Ok(fallback); } @@ -70,8 +110,19 @@ impl GreeClient { let mut fallback = effective.clone(); fallback.quiet = None; if !fallback.is_empty() { - if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { - if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); } + if self + .request_command_with_buzzer_fallback( + device, + key, + &fallback, + suppress_beep, + ) + .await + .is_ok() + { + if let Ok(mut items) = self.quiet_unsupported.lock() { + items.insert(device.id.clone()); + } tracing::warn!(device=%device.id, "GREE Quiet command is unsupported; thermostat will continue without Quiet for this device"); return Ok(fallback); } @@ -82,9 +133,22 @@ impl GreeClient { fallback.sleep = None; fallback.quiet = None; if !fallback.is_empty() { - if self.request_command_with_buzzer_fallback(device, key, &fallback, suppress_beep).await.is_ok() { - if let Ok(mut items) = self.sleep_unsupported.lock() { items.insert(device.id.clone()); } - if let Ok(mut items) = self.quiet_unsupported.lock() { items.insert(device.id.clone()); } + if self + .request_command_with_buzzer_fallback( + device, + key, + &fallback, + suppress_beep, + ) + .await + .is_ok() + { + if let Ok(mut items) = self.sleep_unsupported.lock() { + items.insert(device.id.clone()); + } + if let Ok(mut items) = self.quiet_unsupported.lock() { + items.insert(device.id.clone()); + } tracing::warn!(device=%device.id, "GREE Quiet/Sleep optional command fields are unsupported; using the core thermostat command"); return Ok(fallback); } @@ -98,30 +162,70 @@ impl GreeClient { fn command_payload(command: &DeviceCommand, suppress_beep: bool) -> Result { let mut opt = Vec::<&str>::new(); let mut values = Vec::::new(); - if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); } + if let Some(v) = command.power { + opt.push("Pow"); + values.push(json!(if v { 1 } else { 0 })); + } + if let Some(v) = &command.mode { + opt.push("Mod"); + values.push(json!(mode_value(v)?)); + } if let Some(v) = command.target_temperature { // GREE's Celsius setpoint is whole-degree. TemRec is used by the // Fahrenheit conversion path and should not be abused as a 0.5 C bit. let whole = v.clamp(8.0, 30.0).round() as i64; - opt.push("SetTem"); values.push(json!(whole)); + opt.push("SetTem"); + values.push(json!(whole)); + } + if let Some(v) = command.fan_speed { + opt.push("WdSpd"); + values.push(json!(v.min(5))); + } + if let Some(v) = command.swing_vertical { + opt.push("SwUpDn"); + values.push(json!(if v { 1 } else { 0 })); + } + if let Some(v) = command.swing_horizontal { + opt.push("SwingLfRig"); + values.push(json!(if v { 1 } else { 0 })); + } + if let Some(v) = command.quiet { + opt.push("Quiet"); + values.push(json!(if v { 1 } else { 0 })); + } + if let Some(v) = command.turbo { + opt.push("Tur"); + values.push(json!(if v { 1 } else { 0 })); + } + if let Some(v) = command.light { + opt.push("Lig"); + values.push(json!(if v { 1 } else { 0 })); + } + if let Some(v) = command.air { + opt.push("Air"); + values.push(json!(if v { 1 } else { 0 })); + } + if let Some(v) = command.xfan { + opt.push("Blo"); + values.push(json!(if v { 1 } else { 0 })); + } + if let Some(v) = command.health { + opt.push("Health"); + values.push(json!(if v { 1 } else { 0 })); + } + if let Some(v) = command.sleep { + opt.push("SwhSlp"); + values.push(json!(if v { 1 } else { 0 })); + } + if opt.is_empty() { + bail!("empty device command") } - if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); } - if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.air { opt.push("Air"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.xfan { opt.push("Blo"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.health { opt.push("Health"); values.push(json!(if v { 1 } else { 0 })); } - if let Some(v) = command.sleep { opt.push("SwhSlp"); values.push(json!(if v { 1 } else { 0 })); } - if opt.is_empty() { bail!("empty device command") } if suppress_beep { - opt.push("Buzzer_ON_OFF"); values.push(json!(1)); - opt.push("BuzzerCtrl"); values.push(json!(0)); + opt.push("Buzzer_ON_OFF"); + values.push(json!(1)); + opt.push("BuzzerCtrl"); + values.push(json!(0)); } Ok(json!({"opt": opt, "p": values, "t": "cmd"})) } - } diff --git a/src/protocol/gree/core.rs b/src/protocol/gree/core.rs index 0a027fe..de9e8e1 100644 --- a/src/protocol/gree/core.rs +++ b/src/protocol/gree/core.rs @@ -20,19 +20,29 @@ impl GreeClient { pub fn received_frame_stats(&self) -> (u64, HashMap) { let total = self.received_frames_total.load(Ordering::Relaxed); - let by_device = self.received_frames_by_device.lock() + let by_device = self + .received_frames_by_device + .lock() .map(|counts| counts.clone()) .unwrap_or_default(); (total, by_device) } fn record_received_frame(&self, device: &Device) { - let total = self.received_frames_total.fetch_add(1, Ordering::Relaxed).saturating_add(1); - let device_count = self.received_frames_by_device.lock().ok().map(|mut counts| { - let count = counts.entry(device.id.clone()).or_insert(0); - *count = (*count).saturating_add(1); - *count - }).unwrap_or(0); + let total = self + .received_frames_total + .fetch_add(1, Ordering::Relaxed) + .saturating_add(1); + let device_count = self + .received_frames_by_device + .lock() + .ok() + .map(|mut counts| { + let count = counts.entry(device.id.clone()).or_insert(0); + *count = (*count).saturating_add(1); + *count + }) + .unwrap_or(0); if let Some(events) = &self.debug_events { let _ = events.send(ApiEvent { event: "gree.frame_received".into(), @@ -47,12 +57,25 @@ impl GreeClient { } } - fn debug_frame(&self, direction: &str, device: &Device, target: SocketAddr, protocol: u8, payload: &Value) { - if !self.debug_gree_frames.load(Ordering::Relaxed) { return; } - let Some(events) = &self.debug_events else { return; }; + fn debug_frame( + &self, + direction: &str, + device: &Device, + target: SocketAddr, + protocol: u8, + payload: &Value, + ) { + if !self.debug_gree_frames.load(Ordering::Relaxed) { + return; + } + let Some(events) = &self.debug_events else { + return; + }; let mut safe = payload.clone(); if let Some(object) = safe.as_object_mut() { - if object.contains_key("key") { object.insert("key".into(), json!("***")); } + if object.contains_key("key") { + object.insert("key".into(), json!("***")); + } } let _ = events.send(ApiEvent { event: "gree.frame".into(), @@ -68,11 +91,18 @@ impl GreeClient { }); } - async fn udp_socket(&self, broadcast: bool, target_hint: Option) -> Result { + async fn udp_socket( + &self, + broadcast: bool, + target_hint: Option, + ) -> Result { let socket = if let Some(interface) = self.interface.as_deref() { let ip = interface_ipv4(interface)?; - UdpSocket::bind(SocketAddrV4::new(ip, 0)).await - .with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))? + UdpSocket::bind(SocketAddrV4::new(ip, 0)) + .await + .with_context(|| { + format!("cannot bind GREE UDP socket to {ip} from interface {interface}") + })? } else if let Some(target) = target_hint { if let Some(config) = local_ipv4_config_for_target(target)? { tracing::debug!( @@ -81,8 +111,14 @@ impl GreeClient { local_ip = %config.ip, "Automatically selected local interface for GREE UDP" ); - UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await - .with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))? + UdpSocket::bind(SocketAddrV4::new(config.ip, 0)) + .await + .with_context(|| { + format!( + "cannot bind GREE UDP socket to {} on {}", + config.ip, config.interface + ) + })? } else { UdpSocket::bind("0.0.0.0:0").await? } @@ -94,7 +130,9 @@ impl GreeClient { } fn bind_scan_target(&self, target: SocketAddr) -> Result { - let SocketAddr::V4(target_v4) = target else { return Ok(target); }; + let SocketAddr::V4(target_v4) = target else { + return Ok(target); + }; let broadcast = if let Some(interface) = self.interface.as_deref() { let (_, broadcast) = interface_ipv4_config(interface)?; Some(broadcast) @@ -109,16 +147,20 @@ impl GreeClient { fn discovery_target(&self, configured: &str) -> Result { let value = configured.trim(); if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") { - let port = value.split_once(':') - .map(|(_, port)| port.parse::().context("invalid automatic discovery port")) + let port = value + .split_once(':') + .map(|(_, port)| { + port.parse::() + .context("invalid automatic discovery port") + }) .transpose()? .unwrap_or(7000); - let interface = self.interface.as_deref() - .ok_or_else(|| anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE"))?; + let interface = self.interface.as_deref().ok_or_else(|| { + anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE") + })?; let (_, broadcast) = interface_ipv4_config(interface)?; return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port))); } value.parse().context("invalid discovery broadcast address") } - } diff --git a/src/protocol/gree/discovery.rs b/src/protocol/gree/discovery.rs index 6b2efcf..9c8a2cd 100644 --- a/src/protocol/gree/discovery.rs +++ b/src/protocol/gree/discovery.rs @@ -1,8 +1,17 @@ impl GreeClient { /// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only. - pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result> { + pub async fn discover( + &self, + broadcast: &str, + duration: Duration, + protocol_filter: u8, + passes: u8, + ) -> Result> { let target = self.discovery_target(broadcast)?; - let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; + let target_hint = match target { + SocketAddr::V4(addr) => Some(*addr.ip()), + SocketAddr::V6(_) => None, + }; let socket = self.udp_socket(true, target_hint).await?; let local = socket.local_addr()?; let passes = passes.clamp(1, 10); @@ -17,7 +26,11 @@ impl GreeClient { ); let deadline = Instant::now() + duration; - let interval = if passes > 1 { duration / passes as u32 } else { duration }; + let interval = if passes > 1 { + duration / passes as u32 + } else { + duration + }; let mut next_scan = Instant::now(); let mut sent = 0_u8; let mut result = Vec::new(); @@ -36,10 +49,14 @@ impl GreeClient { let wait = remaining.min(Duration::from_millis(250)); match timeout(wait, socket.recv_from(&mut buffer)).await { Ok(Ok((size, source))) => { - let Ok(value) = serde_json::from_slice::(&buffer[..size]) else { continue; }; + let Ok(value) = serde_json::from_slice::(&buffer[..size]) else { + continue; + }; match self.parse_discovery(value, source) { Ok(Some(mut device)) => { - if protocol_filter != 0 && device.protocol_version != protocol_filter { continue; } + if protocol_filter != 0 && device.protocol_version != protocol_filter { + continue; + } let key = device.mac.to_ascii_lowercase(); if seen.insert(key) { device.last_seen = Some(Utc::now()); @@ -48,7 +65,9 @@ impl GreeClient { } } Ok(None) => {} - Err(err) => tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response"), + Err(err) => { + tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response") + } } } Ok(Err(err)) => return Err(err.into()), @@ -69,46 +88,90 @@ impl GreeClient { } else { decrypt_v1(GENERIC_GREE_V1_KEY, pack)? }; - value = serde_json::from_slice::(&clear).context("invalid decrypted discovery JSON")?; + value = serde_json::from_slice::(&clear) + .context("invalid decrypted discovery JSON")?; } else if pack_value.is_object() { value = pack_value.clone(); } } } - let kind = value.get("t").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase(); - if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() { + let kind = value + .get("t") + .and_then(Value::as_str) + .unwrap_or_default() + .to_ascii_lowercase(); + if kind != "dev" + && kind != "scan" + && value.get("mac").is_none() + && value.get("cid").is_none() + { return Ok(None); } - let mac = value.get("mac") + let mac = value + .get("mac") .or_else(|| value.get("cid")) .and_then(Value::as_str) .unwrap_or_default() - .replace([':', '-'], "").to_ascii_uppercase(); - if mac.is_empty() { return Ok(None); } + .replace([':', '-'], "") + .to_ascii_uppercase(); + if mac.is_empty() { + return Ok(None); + } - let raw_model = value.get("model").or_else(|| value.get("series")) - .and_then(Value::as_str).unwrap_or_default().trim().to_string(); - let model_type = value.get("ModelType") - .and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string()))) + let raw_model = value + .get("model") + .or_else(|| value.get("series")) + .and_then(Value::as_str) + .unwrap_or_default() + .trim() + .to_string(); + let model_type = value + .get("ModelType") + .and_then(|v| { + v.as_str() + .map(str::to_string) + .or_else(|| v.as_i64().map(|n| n.to_string())) + }) .unwrap_or_default(); - let model = if !model_type.is_empty() && (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree")) { + let model = if !model_type.is_empty() + && (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree")) + { format!("GREE {model_type}") } else if raw_model.is_empty() { "GREE".to_string() } else { raw_model }; - let ver = value.get("ver").and_then(Value::as_str).unwrap_or_default().trim(); - let hid = value.get("hid").and_then(Value::as_str).unwrap_or_default().trim(); + let ver = value + .get("ver") + .and_then(Value::as_str) + .unwrap_or_default() + .trim(); + let hid = value + .get("hid") + .and_then(Value::as_str) + .unwrap_or_default() + .trim(); let firmware = match (ver.is_empty(), hid.is_empty()) { (false, false) => format!("{ver} · {hid}"), (false, true) => ver.to_string(), (true, false) => hid.to_string(), (true, true) => String::new(), }; - let suffix = mac.chars().rev().take(4).collect::().chars().rev().collect::().to_ascii_uppercase(); - let name = value.get("name").and_then(Value::as_str) - .map(str::trim).filter(|v| !v.is_empty()) + let suffix = mac + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect::() + .to_ascii_uppercase(); + let name = value + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|v| !v.is_empty()) .map(str::to_string) .unwrap_or_else(|| format!("{model} {suffix}")); let now = Utc::now(); @@ -117,7 +180,11 @@ impl GreeClient { mac, name, ip: source.ip().to_string(), - port: if source.port() == 0 { 7000 } else { source.port() }, + port: if source.port() == 0 { + 7000 + } else { + source.port() + }, protocol_version: detected_protocol, model, firmware, @@ -157,5 +224,4 @@ impl GreeClient { updated_at: now, })) } - } diff --git a/src/protocol/gree/merge.rs b/src/protocol/gree/merge.rs index ea5c663..3dd71f3 100644 --- a/src/protocol/gree/merge.rs +++ b/src/protocol/gree/merge.rs @@ -2,9 +2,18 @@ pub fn merge_discovered(existing: Option, discovered: Device) -> Device if let Some(mut old) = existing { old.ip = discovered.ip; old.port = discovered.port; - if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" || old.name == "GREE air conditioner" { old.name = discovered.name; } - if !discovered.model.is_empty() { old.model = discovered.model; } - if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; } + if old.name.trim().is_empty() + || old.name == "Klimatyzator GREE" + || old.name == "GREE air conditioner" + { + old.name = discovered.name; + } + if !discovered.model.is_empty() { + old.model = discovered.model; + } + if !discovered.firmware.is_empty() { + old.firmware = discovered.firmware; + } if old.protocol_version != discovered.protocol_version { old.protocol_version = discovered.protocol_version; old.key = None; @@ -17,7 +26,9 @@ pub fn merge_discovered(existing: Option, discovered: Device) -> Device old } else { let mut new = discovered; - if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); } + if new.id.is_empty() { + new.id = Uuid::new_v4().to_string(); + } new } } diff --git a/src/protocol/gree/polling.rs b/src/protocol/gree/polling.rs index 24fa4cc..9c94370 100644 --- a/src/protocol/gree/polling.rs +++ b/src/protocol/gree/polling.rs @@ -2,8 +2,13 @@ impl GreeClient { /// Measure a minimal GREE round-trip without mutating persisted/live device state. /// Diagnostics must not alter online/error counters, ownership, readings or capabilities. pub async fn probe(&self, device: &Device) -> Result { - if device.simulated { return Ok(0); } - let key = device.key.as_deref().filter(|value| !value.is_empty()) + if device.simulated { + return Ok(0); + } + let key = device + .key + .as_deref() + .filter(|value| !value.is_empty()) .ok_or_else(|| anyhow!("device is not bound"))?; let started = Instant::now(); let response = self.status_request(device, key, &["Pow"]).await?; @@ -13,14 +18,52 @@ impl GreeClient { } pub async fn poll(&self, device: &mut Device) -> Result<()> { - let key = device.key.clone().ok_or_else(|| anyhow!("device is not bound"))?; + let key = device + .key + .clone() + .ok_or_else(|| anyhow!("device is not bound"))?; let full_cols = [ - "Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig", - "SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType", - "TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem" + "Pow", + "Mod", + "SetTem", + "WdSpd", + "Air", + "Blo", + "Health", + "SwhSlp", + "Lig", + "SwingLfRig", + "SwUpDn", + "Quiet", + "Tur", + "StHt", + "TemUn", + "HeatCoolType", + "TemRec", + "SvSt", + "TemSen", + "CoolSvTem", + "HeatSvTem", + "OutEnvTem", ]; - let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"]; - let (response, used_core_fallback) = match self.status_request(device, &key, &full_cols).await { + let core_cols = [ + "Pow", + "Mod", + "SetTem", + "TemRec", + "TemUn", + "TemSen", + "WdSpd", + "Lig", + "SwingLfRig", + "SwUpDn", + "Quiet", + "Tur", + ]; + let (response, used_core_fallback) = match self + .status_request(device, &key, &full_cols) + .await + { Ok(value) => (value, false), Err(first) => { tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties"); @@ -33,8 +76,12 @@ impl GreeClient { // their outdoor sensor to history without making the main poll fail. if used_core_fallback { match self.status_request(device, &key, &["OutEnvTem"]).await { - Ok(optional) => { let _ = self.apply_status(device, &optional); } - Err(err) => tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available"), + Ok(optional) => { + let _ = self.apply_status(device, &optional); + } + Err(err) => { + tracing::trace!(device=%device.id, error=?err, "GREE outdoor temperature is not available") + } } } // Capability discovery is deliberately lazy. Existing installations start with @@ -51,7 +98,8 @@ impl GreeClient { async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result { let inner = json!({"cols": cols, "mac": Self::wire_mac(device), "t": "status"}); - self.request(device, &inner, key, false, device.protocol_version).await + self.request(device, &inner, key, false, device.protocol_version) + .await } async fn probe_optional_features(&self, device: &mut Device, key: &str) { @@ -65,10 +113,14 @@ impl GreeClient { ("SwhSlp", device.supports_sleep.is_none()), ]; for (property, needed) in probes { - if !needed { continue; } + if !needed { + continue; + } match self.status_request(device, key, &[property]).await { Ok(value) => { - let returned = value.get("cols").and_then(Value::as_array) + let returned = value + .get("cols") + .and_then(Value::as_array) .map(|cols| cols.iter().any(|name| name.as_str() == Some(property))) .unwrap_or(false); if !returned || self.apply_status(device, &value).is_err() { @@ -98,9 +150,13 @@ impl GreeClient { } fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> { - let response_cols = response.get("cols").and_then(Value::as_array) + let response_cols = response + .get("cols") + .and_then(Value::as_array) .ok_or_else(|| anyhow!("status response has no cols"))?; - let data = response.get("dat").and_then(Value::as_array) + let data = response + .get("dat") + .and_then(Value::as_array) .ok_or_else(|| anyhow!("status response has no dat"))?; if data.len() < response_cols.len() { bail!("status response contains fewer values than columns") @@ -112,38 +168,69 @@ impl GreeClient { let mut next = device.clone(); let mut set_temp = None; for (name, value) in response_cols.iter().zip(data.iter()) { - let Some(name) = name.as_str() else { continue; }; + let Some(name) = name.as_str() else { + continue; + }; match name { "Pow" => next.power = status_flag(name, value)?, "Mod" => { let raw = status_i64(name, value)?; - next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into(); + next.mode = mode_name_checked(raw) + .ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))? + .into(); } "SetTem" => { let raw = status_f64(name, value)?; - if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") } + if !(8.0..=30.0).contains(&raw) { + bail!("invalid GREE setpoint for {name}: {raw}") + } set_temp = Some(raw.round()); } "WdSpd" => { let raw = status_i64(name, value)?; - if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") } + if !(0..=5).contains(&raw) { + bail!("invalid GREE fan value for {name}: {raw}") + } next.fan_speed = raw as u8; } "SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0, "SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0, - "Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); }, - "Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); }, - "Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); }, - "Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); }, - "Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); }, - "Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); }, - "SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); }, + "Quiet" => { + next.quiet = status_flag(name, value)?; + next.supports_quiet = Some(true); + } + "Tur" => { + next.turbo = status_flag(name, value)?; + next.supports_turbo = Some(true); + } + "Lig" => { + next.light = status_flag(name, value)?; + next.supports_light = Some(true); + } + "Air" => { + next.air = status_flag(name, value)?; + next.supports_air = Some(true); + } + "Blo" => { + next.xfan = status_flag(name, value)?; + next.supports_xfan = Some(true); + } + "Health" => { + next.health = status_flag(name, value)?; + next.supports_health = Some(true); + } + "SwhSlp" => { + next.sleep = status_flag(name, value)?; + next.supports_sleep = Some(true); + } "TemSen" => { let raw = status_f64(name, value)?; if raw != 0.0 { let offset = raw > 40.0; let temperature = if offset { raw - 40.0 } else { raw }; - if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") } + if !(-40.0..=80.0).contains(&temperature) { + bail!("invalid GREE indoor temperature: {temperature}") + } next.temperature_sensor_offset = Some(offset); next.current_temperature = Some(temperature); } @@ -153,16 +240,19 @@ impl GreeClient { if raw != 0.0 { let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0); let temperature = if offset { raw - 40.0 } else { raw }; - if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") } + if !(-60.0..=80.0).contains(&temperature) { + bail!("invalid GREE outdoor temperature: {temperature}") + } next.outdoor_temperature = Some(temperature); } } _ => {} } } - if let Some(base) = set_temp { next.target_temperature = base; } + if let Some(base) = set_temp { + next.target_temperature = base; + } *device = next; Ok(()) } - } diff --git a/src/protocol/gree/tests.rs b/src/protocol/gree/tests.rs index 264d426..725ec14 100644 --- a/src/protocol/gree/tests.rs +++ b/src/protocol/gree/tests.rs @@ -28,15 +28,25 @@ mod tests { #[test] fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() { - let payload = GreeClient::command_payload(&DeviceCommand { - target_temperature: Some(19.0), - fan_speed: Some(1), - quiet: Some(true), - sleep: Some(true), - ..DeviceCommand::default() - }, false).expect("thermostat command payload"); + let payload = GreeClient::command_payload( + &DeviceCommand { + target_temperature: Some(19.0), + fan_speed: Some(1), + quiet: Some(true), + sleep: Some(true), + ..DeviceCommand::default() + }, + false, + ) + .expect("thermostat command payload"); - assert_eq!(payload.get("opt").cloned(), Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"]))); - assert_eq!(payload.get("p").cloned(), Some(serde_json::json!([19, 1, 1, 1]))); + assert_eq!( + payload.get("opt").cloned(), + Some(serde_json::json!(["SetTem", "WdSpd", "Quiet", "SwhSlp"])) + ); + assert_eq!( + payload.get("p").cloned(), + Some(serde_json::json!([19, 1, 1, 1])) + ); } } diff --git a/src/protocol/gree/transport.rs b/src/protocol/gree/transport.rs index bd18e4d..15bc450 100644 --- a/src/protocol/gree/transport.rs +++ b/src/protocol/gree/transport.rs @@ -1,12 +1,31 @@ impl GreeClient { - async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result { + async fn request( + &self, + device: &Device, + inner: &Value, + key: &str, + binding: bool, + protocol_version: u8, + ) -> Result { let target = self.device_target(device)?; - let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None }; + let target_hint = match target { + SocketAddr::V4(addr) => Some(*addr.ip()), + SocketAddr::V6(_) => None, + }; let socket = self.udp_socket(false, target_hint).await?; - self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await + self.request_on_socket(device, inner, key, binding, protocol_version, &socket) + .await } - async fn request_on_socket(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8, socket: &UdpSocket) -> Result { + async fn request_on_socket( + &self, + device: &Device, + inner: &Value, + key: &str, + binding: bool, + protocol_version: u8, + socket: &UdpSocket, + ) -> Result { let target = self.device_target(device)?; let version = if protocol_version == 2 { 2 } else { 1 }; let inner_bytes = serde_json::to_vec(inner)?; @@ -41,26 +60,36 @@ impl GreeClient { Ok(Err(err)) => return Err(err.into()), Err(_) => break, }; - if source.ip() != target.ip() { continue; } + if source.ip() != target.ip() { + continue; + } self.record_received_frame(device); let response: Value = match serde_json::from_slice(&buffer[..size]) { Ok(value) => value, - Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; } + Err(err) => { + last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); + continue; + } }; if let Some(pack) = response.get("pack").and_then(Value::as_object) { let decoded = Value::Object(pack.clone()); if binding { - let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default(); + let response_type = + decoded.get("t").and_then(Value::as_str).unwrap_or_default(); if !response_type.eq_ignore_ascii_case("bindok") { tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response"); continue; } } - if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } + if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { + bail!("GREE device error: {err}") + } self.debug_frame("rx", device, target, version, &decoded); return Ok(decoded); } - let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; }; + let Some(pack) = response.get("pack").and_then(Value::as_str) else { + continue; + }; let clear = if version == 2 { let Some(tag) = response.get("tag").and_then(Value::as_str) else { last_decode_error = Some(anyhow!("AES-GCM response is missing tag")); @@ -68,31 +97,48 @@ impl GreeClient { }; match decrypt_v2(key, pack, tag) { Ok(v) => v, - Err(err) => { last_decode_error = Some(err); continue; } + Err(err) => { + last_decode_error = Some(err); + continue; + } } } else { match decrypt_v1(key, pack) { Ok(v) => v, - Err(err) => { last_decode_error = Some(err); continue; } + Err(err) => { + last_decode_error = Some(err); + continue; + } } }; let decoded: Value = match serde_json::from_slice(&clear) { Ok(value) => value, - Err(err) => { last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); continue; } + Err(err) => { + last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); + continue; + } }; if binding { let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default(); - if !response_type.eq_ignore_ascii_case("bindok") { continue; } + if !response_type.eq_ignore_ascii_case("bindok") { + continue; + } + } + if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { + bail!("GREE device error: {err}") } - if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } self.debug_frame("rx", device, target, version, &decoded); return Ok(decoded); } - if let Some(err) = last_decode_error { return Err(err); } + if let Some(err) = last_decode_error { + return Err(err); + } bail!("GREE response timeout after 4 seconds") } fn device_target(&self, device: &Device) -> Result { - format!("{}:{}", device.ip, device.port).parse().context("invalid device address") + format!("{}:{}", device.ip, device.port) + .parse() + .context("invalid device address") } } diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 1208d79..b6747a3 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -1,4 +1,4 @@ pub mod crypto; pub mod gree; -pub use gree::{GreeClient, merge_discovered}; +pub use gree::{merge_discovered, GreeClient}; diff --git a/src/state.rs b/src/state.rs index eee9544..6de300b 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,8 +1,17 @@ -use std::{collections::HashMap, sync::{Arc, atomic::AtomicBool}, time::Instant}; +use crate::{ + config::Config, + db::Db, + models::{ApiEvent, DeviceCommand, RuntimeSettings}, + protocol::GreeClient, +}; use chrono::Utc; use serde_json::Value; +use std::{ + collections::HashMap, + sync::{atomic::AtomicBool, Arc}, + time::Instant, +}; use tokio::sync::{broadcast, Mutex, Notify, OwnedMutexGuard, RwLock}; -use crate::{config::Config, db::Db, models::{ApiEvent, DeviceCommand, RuntimeSettings}, protocol::GreeClient}; #[derive(Debug, Clone)] pub(crate) struct PendingControllerCommand { @@ -49,7 +58,10 @@ impl AppState { pub async fn lock_device_operation(&self, device_id: &str) -> OwnedMutexGuard<()> { let lock = { let mut locks = self.device_operation_locks.lock().await; - locks.entry(device_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone() + locks + .entry(device_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() }; lock.lock_owned().await } @@ -57,7 +69,10 @@ impl AppState { pub async fn lock_zone_operation(&self, zone_id: &str) -> OwnedMutexGuard<()> { let lock = { let mut locks = self.zone_operation_locks.lock().await; - locks.entry(zone_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone() + locks + .entry(zone_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() }; lock.lock_owned().await } @@ -65,7 +80,10 @@ impl AppState { pub async fn lock_group_operation(&self, group_id: &str) -> OwnedMutexGuard<()> { let lock = { let mut locks = self.group_operation_locks.lock().await; - locks.entry(group_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone() + locks + .entry(group_id.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() }; lock.lock_owned().await } @@ -106,18 +124,23 @@ impl AppState { if let Err(err) = self.db.log_event(level, kind, message, &metadata) { tracing::warn!(error=?err, "cannot persist event log"); } - self.broadcast("log.created", serde_json::json!({ - "level": level, - "kind": kind, - "message": message, - "metadata": metadata, - })); + self.broadcast( + "log.created", + serde_json::json!({ + "level": level, + "kind": kind, + "message": message, + "metadata": metadata, + }), + ); if let Ok(handle) = tokio::runtime::Handle::try_current() { let state = self.clone(); let level = level.to_string(); let kind = kind.to_string(); let message = message.to_string(); - handle.spawn(async move { crate::notifications::dispatch(state, level, kind, message, metadata).await; }); + handle.spawn(async move { + crate::notifications::dispatch(state, level, kind, message, metadata).await; + }); } } } diff --git a/web/index.html b/web/index.html index 5fdaeac..7d52579 100644 --- a/web/index.html +++ b/web/index.html @@ -130,7 +130,8 @@
Devices

Manual control

-

Direct GREE control: power, temperature, mode, fan and supported unit functions.

+

Direct GREE control: power, temperature, mode, fan and + supported unit functions.

0
@@ -148,7 +149,8 @@ data-i18n="actions.discover">Discover -

Technical configuration and diagnostics only. Current operating settings are available in Manual control.

+

Technical configuration and diagnostics only. Current + operating settings are available in Manual control.

@@ -182,7 +184,10 @@
Visual logic

Flow

-
+
+
One place for schedules and automations -

Build the logic from blocks. GREE Controller translates Flow into generated schedules and automations. Generated entries are read-only outside this editor.

-
+

Build the logic from blocks. GREE Controller translates Flow into generated + schedules and automations. Generated entries are read-only outside this editor.

+ +
Flow = source of truth
@@ -390,46 +398,99 @@ tokens for the Home Assistant integration.

-

Połączenie z Home Assistant

Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.

+
+
+

Połączenie z Home Assistant

+

Adres serwera i dane dostępu używane przez wszystkie funkcje Home + Assistant.

+
+
- - - -

Use only for a trusted local Home Assistant server.

-
+ + + +

Use only for a trusted + local Home Assistant server.

+
-

Źródła temperatury sterowania

Te ustawienia należą do logiki termostatów i źródeł temperatury, nie do wspólnych wejść Flow.

+
+
+

Źródła temperatury sterowania

+

Te ustawienia należą do logiki termostatów i źródeł + temperatury, nie do wspólnych wejść Flow.

+
+
- -

This entity is used by the Home Assistant connection test when no other entity is specified. It is not automatically used as a zone temperature source.

- - -

If the HA sensor is not updated within this time, the reading is treated as stale.

- + +

This entity is used by the Home Assistant + connection test when no other entity is specified. It is not automatically used as a zone temperature + source.

+ + +

If the HA sensor is not updated + within this time, the reading is treated as stale.

+
-

Wspólne wejścia Flow

Zdefiniuj wspólne źródła wartości raz i używaj ich w wielu Flow. Czujniki HA dodane tylko tutaj nie są zapisywane do metryk.

-
+
+
+

Wspólne wejścia Flow

+

Zdefiniuj wspólne źródła wartości raz i używaj ich w wielu Flow. + Czujniki HA dodane tylko tutaj nie są zapisywane do metryk.

+
+
+ +
-

Sensor aliases

Badges show whether an entity is collected as metrics, used by Flow, or both.

+
+
+

Sensor aliases

+

Badges show whether an entity is collected as metrics, used by + Flow, or both.

+
+
-
+
-

Home Assistant integration access

Create a controller token and paste it into the GREE Controller integration in Home Assistant.

-
+
+
+

Home Assistant integration access

+

Create a controller token and paste it into the GREE Controller + integration in Home Assistant.

+
+
+
+
+
+
-
Save Home Assistant and sensor changes.
+
Save Home Assistant and sensor + changes.
@@ -710,58 +771,155 @@ -
Blocks

Add block

- +
+
Blocks +

Add block

+
+
+
-

Actions

+
+

Actions

+
@@ -770,43 +928,78 @@
-
Wspólne dane

Wspólne wejście Flow

+
+
Wspólne dane +

Wspólne wejście Flow

+
+
- - + +
-
+
-
Flow library

Templates

-

Ready-made layouts create an editable graph using existing zones, devices and groups.

-
+
+
Flow library +

Templates

+
+
+

Ready-made layouts create an editable graph using existing + zones, devices and groups.

+
-
+
- +
-
Flow diagnostics

Dry-run

+
+
Flow diagnostics +

Dry-run

+
+
-

Dry-run does not change thermostat, device, group, schedule or automation state.

- -
Sensor overrides

Leave blank to use the current application or Home Assistant state.

-
+

Dry-run does not change thermostat, device, group, schedule or + automation state.

+ +
Sensor overrides +

Leave blank to use the current application or + Home Assistant state.

+
+
+
@@ -819,13 +1012,17 @@ - - + + - - + + @@ -923,13 +1120,16 @@

Technical configuration

-
+
-

Changing protocol clears the saved device key. Save and check connection immediately performs the required bind and test.

+

Changing protocol clears the saved device key. Save + and check connection immediately performs the required bind and test.

-

Measures a minimal GREE round-trip without changing device state, error counters or automation ownership. The chart refreshes while monitoring is enabled.

+

Measures a minimal GREE round-trip without changing device + state, error counters or automation ownership. The chart refreshes while monitoring is enabled.

- - + +
diff --git a/web/js/charts.js b/web/js/charts.js index 39d1569..4f02d2e 100644 --- a/web/js/charts.js +++ b/web/js/charts.js @@ -191,7 +191,7 @@ function prepareCanvas(canvas, height) { canvas.style.width = `${width}px`; canvas.style.height = `${actualHeight}px`; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, actualHeight); - return { ctx, width, height:actualHeight }; + return { ctx, width, height: actualHeight }; } function drawEmptyChart(canvas, height = 340) { diff --git a/web/js/flows.js b/web/js/flows.js index 8bd2fcf..c0c2743 100644 --- a/web/js/flows.js +++ b/web/js/flows.js @@ -39,24 +39,24 @@ function newFlowId(prefix = 'node') { } function sharedFlowInputRequiresComparison(kind) { - return ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','house_mode','device_state','zone_state','group_state'].includes(kind); + return ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'house_mode', 'device_state', 'zone_state', 'group_state'].includes(kind); } function sharedFlowReferenceComparisonDefaults(item) { const kind = item?.kind || ''; - if (['outdoor_temperature','device_temperature','zone_temperature'].includes(kind)) return { operator:'lt', value:20 }; - if (kind === 'ha_numeric') return { operator:'lt', value:0 }; - if (kind === 'ha_state') return { operator:'eq', value:'on' }; - if (kind === 'ha_attribute') return { operator:'eq', value:'' }; - if (kind === 'house_mode') return { operator:'eq', value:'cool' }; - if (kind === 'device_state') return { operator:'eq', value:'true' }; - if (kind === 'zone_state') return { operator:'eq', value:'true' }; - if (kind === 'group_state') return { operator:'eq', value:'true' }; + if (['outdoor_temperature', 'device_temperature', 'zone_temperature'].includes(kind)) return { operator: 'lt', value: 20 }; + if (kind === 'ha_numeric') return { operator: 'lt', value: 0 }; + if (kind === 'ha_state') return { operator: 'eq', value: 'on' }; + if (kind === 'ha_attribute') return { operator: 'eq', value: '' }; + if (kind === 'house_mode') return { operator: 'eq', value: 'cool' }; + if (kind === 'device_state') return { operator: 'eq', value: 'true' }; + if (kind === 'zone_state') return { operator: 'eq', value: 'true' }; + if (kind === 'group_state') return { operator: 'eq', value: 'true' }; return {}; } function sharedFlowReferenceDefaultConfig(item) { - const config = { input_id:item?.id || '' }; + const config = { input_id: item?.id || '' }; if (item && sharedFlowInputRequiresComparison(item.kind)) Object.assign(config, sharedFlowReferenceComparisonDefaults(item)); return config; } @@ -180,38 +180,38 @@ function flowSharedInputValueSignature(item) { } function flowSharedInputLocalObservation(item) { - if (!item) return { hasValue:false, value:null }; + if (!item) return { hasValue: false, value: null }; const c = item.config || {}; - if (item.kind === 'outdoor_temperature') return { hasValue:app.outdoorTemperature !== null && app.outdoorTemperature !== undefined && Number.isFinite(Number(app.outdoorTemperature)), value:app.outdoorTemperature }; + if (item.kind === 'outdoor_temperature') return { hasValue: app.outdoorTemperature !== null && app.outdoorTemperature !== undefined && Number.isFinite(Number(app.outdoorTemperature)), value: app.outdoorTemperature }; if (item.kind === 'device_temperature') { const value = app.devices.find(device => device.id === c.device_id)?.current_temperature; - return { hasValue:value !== null && value !== undefined && Number.isFinite(Number(value)), value }; + return { hasValue: value !== null && value !== undefined && Number.isFinite(Number(value)), value }; } if (item.kind === 'zone_temperature') { const value = app.zones.find(zone => zone.id === c.zone_id)?.current_temperature; - return { hasValue:value !== null && value !== undefined && Number.isFinite(Number(value)), value }; + return { hasValue: value !== null && value !== undefined && Number.isFinite(Number(value)), value }; } if (item.kind === 'house_mode') { const value = app.settings?.house_mode; - return { hasValue:value !== null && value !== undefined && value !== '', value }; + return { hasValue: value !== null && value !== undefined && value !== '', value }; } if (item.kind === 'device_state') { const device = app.devices.find(value => value.id === c.device_id), value = device?.[c.field]; - return { hasValue:value !== undefined && value !== null, value }; + return { hasValue: value !== undefined && value !== null, value }; } if (item.kind === 'zone_state') { const zone = app.zones.find(value => value.id === c.zone_id), value = zone?.[c.field]; - return { hasValue:value !== undefined && value !== null, value }; + return { hasValue: value !== undefined && value !== null, value }; } if (item.kind === 'group_state') { const group = app.groups.find(value => value.id === c.group_id), value = group?.[c.field || 'power_enabled']; - return { hasValue:value !== undefined && value !== null, value }; + return { hasValue: value !== undefined && value !== null, value }; } if (item.kind === 'night_mode') { const value = app.controlPlan?.night_mode_active; - return { hasValue:typeof value === 'boolean', value }; + return { hasValue: typeof value === 'boolean', value }; } - if (item.kind === 'constant') return { hasValue:true, value:c.value }; + if (item.kind === 'constant') return { hasValue: true, value: c.value }; return null; } @@ -219,13 +219,13 @@ function flowSharedInputObservation(item) { const local = flowSharedInputLocalObservation(item); if (local) return local; const cached = app.flowSharedInputValueCache?.[item?.id]; - return cached?.signature === flowSharedInputValueSignature(item) ? cached : { hasValue:false, value:null }; + return cached?.signature === flowSharedInputValueSignature(item) ? cached : { hasValue: false, value: null }; } function flowSharedInputCurrentText(item, observation = flowSharedInputObservation(item)) { if (!observation?.hasValue) return '—'; const value = observation.value; - if (['outdoor_temperature','device_temperature','zone_temperature'].includes(item.kind)) return fmtTemp(value); + if (['outdoor_temperature', 'device_temperature', 'zone_temperature'].includes(item.kind)) return fmtTemp(value); if (item.kind === 'ha_numeric') { const numeric = Number(value); if (!Number.isFinite(numeric)) return '—'; @@ -249,7 +249,7 @@ function renderFlowSharedInputCurrentValues() { } async function loadFlowSharedInputHaValue(item) { - if (!item || !['ha_state','ha_numeric','ha_attribute','ha_available'].includes(item.kind)) return; + if (!item || !['ha_state', 'ha_numeric', 'ha_attribute', 'ha_available'].includes(item.kind)) return; const signature = flowSharedInputValueSignature(item), cached = app.flowSharedInputValueCache?.[item.id]; if (cached?.signature === signature && Date.now() - Number(cached.fetchedAt || 0) < 15000) return; if (app.flowSharedInputValueRequests?.[item.id] === signature) return; @@ -257,7 +257,7 @@ async function loadFlowSharedInputHaValue(item) { try { const entityId = String(item.config?.entity_id || '').trim(); if (!entityId) throw new Error('missing entity_id'); - const result = await api('/api/integrations/home-assistant/entity', { method:'POST', body:{ entity_id:entityId } }); + const result = await api('/api/integrations/home-assistant/entity', { method: 'POST', body: { entity_id: entityId } }); let value = result.state, hasValue = true; if (item.kind === 'ha_available') value = result.available === true; else if (item.kind === 'ha_attribute') { @@ -268,11 +268,11 @@ async function loadFlowSharedInputHaValue(item) { hasValue = Number.isFinite(value); } app.flowSharedInputValueCache[item.id] = { - signature, fetchedAt:Date.now(), hasValue, value, - unit:item.kind === 'ha_numeric' ? result.attributes?.unit_of_measurement : '', + signature, fetchedAt: Date.now(), hasValue, value, + unit: item.kind === 'ha_numeric' ? result.attributes?.unit_of_measurement : '', }; } catch (_) { - app.flowSharedInputValueCache[item.id] = { signature, fetchedAt:Date.now(), hasValue:false, value:null }; + app.flowSharedInputValueCache[item.id] = { signature, fetchedAt: Date.now(), hasValue: false, value: null }; } finally { if (app.flowSharedInputValueRequests?.[item.id] === signature) delete app.flowSharedInputValueRequests[item.id]; renderFlowSharedInputCurrentValues(); @@ -377,7 +377,7 @@ function setFlowZoom(value, { render = true } = {}) { function fitFlowToView({ maxZoom = 1 } = {}) { const workspace = $('#flowWorkspace'); const nodes = app.flowDraft?.nodes || []; - if (!workspace || !nodes.length) { setFlowZoom(1); if (workspace) workspace.scrollTo({ left:0, top:0, behavior:'smooth' }); return; } + if (!workspace || !nodes.length) { setFlowZoom(1); if (workspace) workspace.scrollTo({ left: 0, top: 0, behavior: 'smooth' }); return; } const width = 170, height = 96, pad = 56; const minX = Math.max(0, Math.min(...nodes.map(node => Number(node.x || 0))) - pad); const minY = Math.max(0, Math.min(...nodes.map(node => Number(node.y || 0))) - pad); @@ -387,7 +387,7 @@ function fitFlowToView({ maxZoom = 1 } = {}) { const availableW = Math.max(220, workspace.clientWidth - 24), availableH = Math.max(180, workspace.clientHeight - 24); const zoom = clamp(Math.min(availableW / contentW, availableH / contentH, maxZoom), .45, 1.35); setFlowZoom(zoom); - requestAnimationFrame(() => workspace.scrollTo({ left:Math.max(0, minX * app.flowZoom - 12), top:Math.max(0, minY * app.flowZoom - 12), behavior:'smooth' })); + requestAnimationFrame(() => workspace.scrollTo({ left: Math.max(0, minX * app.flowZoom - 12), top: Math.max(0, minY * app.flowZoom - 12), behavior: 'smooth' })); } function renderFlowBlockLibrary(filter = '') { @@ -423,8 +423,8 @@ function renderFlowEditor() { `; }).join(''); $('#flowEmptyHint').hidden = draft.nodes.length > 0; - const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count:(app.flowSelectedNodeIds || []).length }) : ''; - renderFlowNameMode(); renderFlowEnabledLabel(); setFlowZoom(app.flowZoom || 1, { render:false }); renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); renderFlowSaveStatus(); refreshFlowSharedInputCurrentValues(); + const selectionCount = $('#flowSelectionCount'); if (selectionCount) selectionCount.textContent = (app.flowSelectedNodeIds || []).length ? tr('flow.selectedCount', { count: (app.flowSelectedNodeIds || []).length }) : ''; + renderFlowNameMode(); renderFlowEnabledLabel(); setFlowZoom(app.flowZoom || 1, { render: false }); renderFlowEdges(); renderFlowInspector(); renderFlowInterpretation(); renderFlowRuntimeInfo(); renderFlowSaveStatus(); refreshFlowSharedInputCurrentValues(); const status = $('#flowCompileStatus'); status.textContent = draft.draft ? tr('flow.draftStatus') : tr('flow.compileCount', { schedules: draft.compiled_schedule_ids?.length || 0, automations: draft.compiled_automation_ids?.length || 0 }); status.classList.toggle('flow-draft-badge', draft.draft === true); @@ -447,19 +447,19 @@ function renderFlowEdges() { function flowSelectOptions(items, selected, nameFn = item => item.name) { return items.map(item => ``).join(''); } -function flowOperatorOptions(selected) { return [['lt','<'],['lte','≤'],['gt','>'],['gte','≥'],['eq','='],['neq','≠']].map(([v,l]) => ``).join(''); } +function flowOperatorOptions(selected) { return [['lt', '<'], ['lte', '≤'], ['gt', '>'], ['gte', '≥'], ['eq', '='], ['neq', '≠']].map(([v, l]) => ``).join(''); } function sharedFlowReferenceComparisonFields(item, config) { if (!item || !sharedFlowInputRequiresComparison(item.kind)) return ''; const defaults = sharedFlowReferenceComparisonDefaults(item); const selected = config.operator || ''; - const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(item.kind); + const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(item.kind); const fullOperators = numeric || item.kind === 'ha_attribute'; - const operatorOptions = `${!selected ? `` : ''}${fullOperators ? flowOperatorOptions(selected) : [['eq','='],['neq','≠']].map(([value,label]) => ``).join('')}`; + const operatorOptions = `${!selected ? `` : ''}${fullOperators ? flowOperatorOptions(selected) : [['eq', '='], ['neq', '≠']].map(([value, label]) => ``).join('')}`; const value = config.value ?? defaults.value ?? ''; let valueField = ``; if (numeric) valueField = ``; - else if (item.kind === 'house_mode') valueField = ``; + else if (item.kind === 'house_mode') valueField = ``; return `

${esc(tr('flow.sharedInputFlowComparisonHint'))}

`; } @@ -469,7 +469,7 @@ function renderFlowInspector() { if (!node) { host.classList.remove('is-expanded'); host.innerHTML = `
${esc(tr('flow.selectBlock'))}${esc(tr('flow.selectBlockHint'))}
`; return; } const c = node.config || {}, meta = FLOW_NODE_META[node.kind] || { title: node.kind }; let fields = ''; - if (node.kind === 'weekday') fields = `
${[1,2,3,4,5,6,7].map(day => ``).join('')}
`; + if (node.kind === 'weekday') fields = `
${[1, 2, 3, 4, 5, 6, 7].map(day => ``).join('')}
`; else if (node.kind === 'time_range') fields = `
`; else if (node.kind === 'date_range') fields = `
`; else if (node.kind === 'cron_trigger') fields = `

${esc(tr('flow.cronHint'))}

`; @@ -478,8 +478,8 @@ function renderFlowInspector() { else if (node.kind === 'on_change') fields = `

${esc(tr('flow.onChangeHint'))}

`; else if (node.kind === 'rate_limit') fields = `

${esc(tr('flow.rateLimitHint'))}

`; else if (node.kind === 'delay') fields = `

${esc(tr('flow.delayHint'))}

`; - else if (node.kind === 'rolling_stat') { const source=c.source || 'outdoor_temperature'; fields = `${source==='device_temperature'?``:''}${source==='zone_temperature'?``:''}${source==='ha_numeric'?``:''}
${flowComparisonFields(c,false)}`; } - else if (node.kind === 'oscillates') { const source=c.source || 'outdoor_temperature'; fields = `${source==='device_temperature'?``:''}${source==='zone_temperature'?``:''}${source==='ha_numeric'?``:''}

${esc(tr('flow.oscillatesHint'))}

`; } + else if (node.kind === 'rolling_stat') { const source = c.source || 'outdoor_temperature'; fields = `${source === 'device_temperature' ? `` : ''}${source === 'zone_temperature' ? `` : ''}${source === 'ha_numeric' ? `` : ''}
${flowComparisonFields(c, false)}`; } + else if (node.kind === 'oscillates') { const source = c.source || 'outdoor_temperature'; fields = `${source === 'device_temperature' ? `` : ''}${source === 'zone_temperature' ? `` : ''}${source === 'ha_numeric' ? `` : ''}

${esc(tr('flow.oscillatesHint'))}

`; } else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c); else if (node.kind === 'device_temperature') fields = `${flowComparisonFields(c)}`; else if (node.kind === 'zone_temperature') fields = `${flowComparisonFields(c)}`; @@ -487,9 +487,9 @@ function renderFlowInspector() { else if (node.kind === 'ha_numeric') fields = `${flowComparisonFields(c, false)}`; else if (node.kind === 'ha_attribute') fields = `${flowTextComparisonFields(c, true)}`; else if (node.kind === 'ha_available') fields = `

${esc(tr('flow.haAvailableHint'))}

`; - else if (node.kind === 'house_mode') fields = `
`; - else if (node.kind === 'device_state') fields = `${flowTextComparisonFields(c)}`; - else if (node.kind === 'zone_state') fields = `${flowTextComparisonFields(c)}`; + else if (node.kind === 'house_mode') fields = `
`; + else if (node.kind === 'device_state') fields = `${flowTextComparisonFields(c)}`; + else if (node.kind === 'zone_state') fields = `${flowTextComparisonFields(c)}`; else if (node.kind === 'group_state') fields = `${flowTextComparisonFields(c)}`; else if (node.kind === 'night_mode') fields = `

${esc(tr('flow.nightModeHint'))}

`; else if (node.kind === 'constant') fields = ``; @@ -502,26 +502,26 @@ function renderFlowInspector() { else if (node.kind === 'logic_and') fields = `

${esc(tr('flow.andHint'))}

`; else if (node.kind === 'logic_or') fields = `

${esc(tr('flow.orHint'))}

`; else if (node.kind === 'logic_not') fields = `

${esc(tr('flow.notHint'))}

`; - else if (node.kind === 'zone_thermostat') fields = `
`; + else if (node.kind === 'zone_thermostat') fields = `
`; else if (node.kind === 'device_action') fields = `${flowActionFields(c, false)}`; else if (node.kind === 'group_action') fields = `${flowActionFields(c, true)}`; else if (node.kind === 'ha_service_action') fields = `

${esc(tr('flow.serviceExample'))}

`; host.innerHTML = `
${esc(tr('flow.blockSettings'))}

${esc(flowNodeTitle(meta))}

${fields}
ID${esc(node.id)}
`; } -function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq','='],['neq','≠']].map(([v,l]) => ``).join(''); return `
`; } +function flowTextComparisonFields(c, numeric = false) { const options = numeric ? flowOperatorOptions(c.operator || 'eq') : [['eq', '='], ['neq', '≠']].map(([v, l]) => ``).join(''); return `
`; } function flowComparisonFields(c, temperature = true) { return `
`; } function flowOptionalBoolField(c, key) { return ``; } function flowActionFields(c, group) { - const modes = group ? ['auto','house','heat','cool'] : ['auto','cool','dry','fan','heat']; + const modes = group ? ['auto', 'house', 'heat', 'cool'] : ['auto', 'cool', 'dry', 'fan', 'heat']; const modeOptions = modes.map(v => ``).join(''); const base = `
`; const target = group - ? `` + ? `` : ``; - const deviceOptions = group ? '' : `
${esc(tr('flow.deviceOptions'))}
${flowOptionalBoolField(c,'swing_vertical')}${flowOptionalBoolField(c,'swing_horizontal')}${flowOptionalBoolField(c,'quiet')}${flowOptionalBoolField(c,'turbo')}${flowOptionalBoolField(c,'light')}${flowOptionalBoolField(c,'air')}${flowOptionalBoolField(c,'xfan')}${flowOptionalBoolField(c,'health')}${flowOptionalBoolField(c,'sleep')}

${esc(tr('flow.deviceOptionsHint'))}

`; + const deviceOptions = group ? '' : `
${esc(tr('flow.deviceOptions'))}
${flowOptionalBoolField(c, 'swing_vertical')}${flowOptionalBoolField(c, 'swing_horizontal')}${flowOptionalBoolField(c, 'quiet')}${flowOptionalBoolField(c, 'turbo')}${flowOptionalBoolField(c, 'light')}${flowOptionalBoolField(c, 'air')}${flowOptionalBoolField(c, 'xfan')}${flowOptionalBoolField(c, 'health')}${flowOptionalBoolField(c, 'sleep')}

${esc(tr('flow.deviceOptionsHint'))}

`; return `${base}${target}${deviceOptions}`; } @@ -546,7 +546,7 @@ function flowExpressionSummary(actionId) { function renderFlowInterpretation() { const target = $('#flowInterpretation'); if (!target || !app.flowDraft) return; - const actions = app.flowDraft.nodes.filter(node => ['action','haaction'].includes(FLOW_NODE_META[node.kind]?.category)); + const actions = app.flowDraft.nodes.filter(node => ['action', 'haaction'].includes(FLOW_NODE_META[node.kind]?.category)); if (!actions.length) { target.textContent = tr('flow.noActionsYet'); return; } target.textContent = actions.map(action => `${flowExpressionSummary(action.id)} → ${flowNodeTitle(FLOW_NODE_META[action.kind])}: ${flowNodeSummary(action)}`).join(' · '); } @@ -570,7 +570,7 @@ function renderFlowRuntimeInfo() { const container = $('#flowRuntimeInfo'); if (!target || !app.flowDraft) return; const seconds = Math.max(2, Number(app.settings?.zone_interval_seconds || 5)); - const actions = app.flowDraft.nodes.filter(node => ['action','haaction'].includes(FLOW_NODE_META[node.kind]?.category)); + const actions = app.flowDraft.nodes.filter(node => ['action', 'haaction'].includes(FLOW_NODE_META[node.kind]?.category)); if (!actions.length) { target.textContent = tr('flow.runtimeNoActions', { seconds }); if (container) container.title = tr('flow.runtimeHint'); @@ -610,7 +610,7 @@ function addFlowNode(kind) { const viewportY = workspace ? (workspace.scrollTop / (app.flowZoom || 1)) + 54 : 70; const node = { id: newFlowId('node'), kind, x: Math.max(36, viewportX + (count % 3) * 24), y: Math.max(36, viewportY + (count % 3) * 24), config: flowDefaultConfig(kind) }; app.flowDraft.nodes.push(node); app.flowSelectedNodeId = node.id; app.flowSelectedNodeIds = [node.id]; app.flowDirty = true; renderFlowEditor(); - requestAnimationFrame(() => $(`[data-flow-node="${CSS.escape(node.id)}"]`)?.scrollIntoView({ block:'center', inline:'center', behavior:'smooth' })); + requestAnimationFrame(() => $(`[data-flow-node="${CSS.escape(node.id)}"]`)?.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' })); } function removeFlowNode(id) { if (!app.flowDraft) return; @@ -668,7 +668,7 @@ async function importFlowFile(file) { finally { const input = $('#flowImportFile'); if (input) input.value = ''; } } -const FLOW_PRESET_CATEGORY_ORDER = ['comfort','energy','safety','night','reliability','home_assistant','advanced']; +const FLOW_PRESET_CATEGORY_ORDER = ['comfort', 'energy', 'safety', 'night', 'reliability', 'home_assistant', 'advanced']; const FLOW_PRESET_FAVORITES_KEY = 'gree_controller_flow_preset_favorites'; const FLOW_PRESET_RECENT_KEY = 'gree_controller_flow_preset_recent'; let flowPresetLoadPromise = null; @@ -684,7 +684,7 @@ function flowPresetStoredIds(key) { } function flowPresetFavoriteIds() { return new Set(flowPresetStoredIds(FLOW_PRESET_FAVORITES_KEY)); } function flowPresetRecentIds() { return flowPresetStoredIds(FLOW_PRESET_RECENT_KEY); } -function saveFlowPresetIds(key, ids) { try { localStorage.setItem(key, JSON.stringify(ids)); } catch (_) {} } +function saveFlowPresetIds(key, ids) { try { localStorage.setItem(key, JSON.stringify(ids)); } catch (_) { } } function toggleFlowPresetFavorite(id) { const ids = flowPresetFavoriteIds(); if (ids.has(id)) ids.delete(id); else ids.add(id); @@ -757,7 +757,7 @@ function materializeFlowPreset(preset) { zone_id: zone1, preset: node.config.preset || 'comfort', setpoint: Number(node.config.setpoint ?? 21), - mode: ['heat','cool'].includes(node.config.mode) ? node.config.mode : 'auto', + mode: ['heat', 'cool'].includes(node.config.mode) ? node.config.mode : 'auto', cooldown_seconds: Number(node.config.cooldown_seconds || 60), power: node.config.power ?? null, }; @@ -767,13 +767,13 @@ function materializeFlowPreset(preset) { }); const edges = sourceEdges .filter(edge => !skipped.has(edge.from) && !skipped.has(edge.to) && idMap.has(edge.from) && idMap.has(edge.to)) - .map(edge => ({ id:newFlowId('edge'), from:idMap.get(edge.from), to:idMap.get(edge.to) })); + .map(edge => ({ id: newFlowId('edge'), from: idMap.get(edge.from), to: idMap.get(edge.to) })); return { nodes, edges }; } function flowPresetRequirements(preset) { const nodes = preset?.flow?.nodes || []; - const placeholders = { zone:new Set(), device:new Set(), group:new Set(), shared:new Set() }; + const placeholders = { zone: new Set(), device: new Set(), group: new Set(), shared: new Set() }; const visit = value => { if (typeof value === 'string') { const match = /^\$(zone|device|group|shared)(\d+)$/.exec(value); @@ -784,14 +784,14 @@ function flowPresetRequirements(preset) { else if (value && typeof value === 'object') Object.values(value).forEach(visit); }; nodes.forEach(node => visit(node.config || {})); - const hasHa = nodes.some(node => ['ha_state','ha_numeric','ha_attribute','ha_available','ha_service_action'].includes(node.kind)); + const hasHa = nodes.some(node => ['ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'ha_service_action'].includes(node.kind)); const haEntities = [...new Set(nodes.map(node => node.config?.entity_id).filter(value => typeof value === 'string' && value && !value.startsWith('$')))]; const requirements = []; const missing = []; const addCount = (kind, count, available, key) => { if (!count) return; const label = tr(key, { count }); - requirements.push({ label, ok:available >= count }); + requirements.push({ label, ok: available >= count }); if (available < count) missing.push(label); }; addCount('zone', placeholders.zone.size, (app.zones || []).length, 'flow.templateRequiresZone'); @@ -800,10 +800,10 @@ function flowPresetRequirements(preset) { addCount('shared', placeholders.shared.size, (app.flowSharedInputs || []).length, 'flow.templateRequiresShared'); if (hasHa) { const ready = Boolean(app.settings?.home_assistant?.url && app.settings?.home_assistant?.token_configured); - requirements.push({ label:tr('flow.templateRequiresHa'), ok:ready }); + requirements.push({ label: tr('flow.templateRequiresHa'), ok: ready }); if (!ready) missing.push(tr('flow.templateRequiresHa')); } - if (haEntities.length) requirements.push({ label:`${tr('flow.templateRequiresEntity')}: ${haEntities.join(', ')}`, ok:true, info:true }); + if (haEntities.length) requirements.push({ label: `${tr('flow.templateRequiresEntity')}: ${haEntities.join(', ')}`, ok: true, info: true }); return { requirements, missing }; } @@ -814,14 +814,14 @@ function flowPresetPreviewGraph(preset) { const xs = nodes.map(node => Number(node.x || 0)), ys = nodes.map(node => Number(node.y || 0)); const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys); const spanX = Math.max(1, maxX - minX), spanY = Math.max(1, maxY - minY); - const point = node => ({ x:8 + ((Number(node.x || 0) - minX) / spanX) * 84, y:12 + ((Number(node.y || 0) - minY) / spanY) * 76 }); + const point = node => ({ x: 8 + ((Number(node.x || 0) - minX) / spanX) * 84, y: 12 + ((Number(node.y || 0) - minY) / spanY) * 76 }); const lines = edges.map(edge => { const from = byId.get(edge.from), to = byId.get(edge.to); if (!from || !to) return ''; const a = point(from), b = point(to); return ``; }).join(''); const blocks = nodes.map(node => { - const p = point(node), meta = FLOW_NODE_META[node.kind] || { title:node.kind, category:'logic' }; + const p = point(node), meta = FLOW_NODE_META[node.kind] || { title: node.kind, category: 'logic' }; return `
${esc(flowNodeTitle(meta))}
`; }).join(''); return `
${blocks}
`; @@ -836,13 +836,13 @@ function renderFlowPresetPreview(preset) { const req = flowPresetRequirements(preset); const favorite = flowPresetFavoriteIds().has(preset.id); const reqMarkup = req.requirements.length - ? req.requirements.map(item => `${item.ok ? '✓' : '!' } ${esc(item.label)}`).join('') + ? req.requirements.map(item => `${item.ok ? '✓' : '!'} ${esc(item.label)}`).join('') : `✓ ${esc(tr('flow.templateRequirementsReady'))}`; host.innerHTML = `
${esc(tr('flow.templatePreview'))}

${esc(flowPresetText(preset.name) || preset.id)}

${esc(flowPresetText(preset.description))}

-
${esc(tr('flow.templateNodesCount', { count:preset.flow.nodes.length }))}${esc(tr('flow.templateEdgesCount', { count:preset.flow.edges.length }))}
+
${esc(tr('flow.templateNodesCount', { count: preset.flow.nodes.length }))}${esc(tr('flow.templateEdgesCount', { count: preset.flow.edges.length }))}
${flowPresetPreviewGraph(preset)} -
${esc(tr('flow.templateRequirements'))}
${reqMarkup}
${req.missing.length ? `

${esc(tr('flow.templateRequirementsMissing', { items:req.missing.join(', ') }))}

` : ''}
+
${esc(tr('flow.templateRequirements'))}
${reqMarkup}
${req.missing.length ? `

${esc(tr('flow.templateRequirementsMissing', { items: req.missing.join(', ') }))}

` : ''}
`; } @@ -864,7 +864,7 @@ function renderFlowPresetBrowser(category = '') { byCategory.set('favorites', presets.filter(preset => favorites.has(preset.id))); byCategory.set('recent', recent.map(id => presets.find(preset => preset.id === id)).filter(Boolean)); const normalCategories = [...new Set([...FLOW_PRESET_CATEGORY_ORDER, ...presets.map(preset => preset.category || 'advanced')])].filter(key => (byCategory.get(key) || []).length); - const categories = ['favorites','recent', ...normalCategories]; + const categories = ['favorites', 'recent', ...normalCategories]; if (!presets.length) { tabs.innerHTML = ''; host.innerHTML = ''; renderFlowPresetPreview(null); return; } flowPresetActiveCategory = categories.includes(category) ? category : (categories.includes(flowPresetActiveCategory) ? flowPresetActiveCategory : normalCategories[0]); tabs.innerHTML = categories.map(key => ``).join(''); @@ -921,17 +921,17 @@ function applyFlowTemplate(key) { function localDateTimeInputValue(date = new Date()) { const pad = value => String(value).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth()+1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } function flowSimulationOverrideNodes() { - return (app.flowDraft?.nodes || []).filter(node => ['outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute','ha_available','house_mode','device_state','zone_state','group_state','night_mode','shared_input'].includes(node.kind)); + return (app.flowDraft?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind)); } function renderFlowSimulationOverrides() { const host = $('#flowSimulationOverrides'); if (!host) return; const nodes = flowSimulationOverrideNodes(); host.innerHTML = nodes.length ? nodes.map(node => { const effectiveKind = node.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node.kind; - const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind); + const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind); return ``; }).join('') : `

${esc(tr('flow.noSimulationOverrides'))}

`; } @@ -941,7 +941,7 @@ function collectFlowSimulationOverrides() { if (input.value.trim() === '') return; const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim(); const effectiveKind = node?.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node?.kind; - if (node && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(effectiveKind)) value = Number(value); + if (node && ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind)) value = Number(value); else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true'; result[input.dataset.flowSimNode] = value; }); @@ -955,43 +955,43 @@ function openFlowDryRun() { function renderFlowDryRunResult(result) { const host = $('#flowTestResults'); const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id; - host.innerHTML = `
${esc(result.summary || tr('flow.dryRun'))}${esc(tr('flow.compiledPreview', result.compiled || { schedules:0, automations:0 }))}
${(result.actions || []).map(action => `
${esc(actionName(action.node_id))}${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}
${action.blocked_reason ? `

${esc(tr('flow.blockReason'))}: ${esc(action.blocked_reason)}

` : ''}
${(action.trace || []).map(item => `
${item.matched ? '✓' : '×'} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))}${esc(JSON.stringify(item.actual))}
`).join('')}
`).join('')}`; + host.innerHTML = `
${esc(result.summary || tr('flow.dryRun'))}${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}
${(result.actions || []).map(action => `
${esc(actionName(action.node_id))}${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}
${action.blocked_reason ? `

${esc(tr('flow.blockReason'))}: ${esc(action.blocked_reason)}

` : ''}
${(action.trace || []).map(item => `
${item.matched ? '✓' : '×'} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}${esc(JSON.stringify(item.actual))}
`).join('')}
`).join('')}`; } async function runFlowDryRun() { if (!app.flowDraft) return; const input = $('#flowSimulationAt').value; const at = input ? new Date(input).toISOString() : new Date().toISOString(); $('#flowTestResults').innerHTML = `

${esc(tr('flow.runningSimulation'))}

`; try { - const result = await api('/api/flows/simulate', { method:'POST', body:{ flow:flowSourcePayload(), flow_id:app.flowDraft.id || null, at, overrides:collectFlowSimulationOverrides(), log:true } }); + const result = await api('/api/flows/simulate', { method: 'POST', body: { flow: flowSourcePayload(), flow_id: app.flowDraft.id || null, at, overrides: collectFlowSimulationOverrides(), log: true } }); renderFlowDryRunResult(result); } catch (error) { $('#flowTestResults').innerHTML = `
${esc(tr('flow.simulationFailed'))}${esc(error.message)}
`; } } function flowLogLevelLabel(level = '') { - const key = { info:'flow.logLevelInfo', warn:'flow.logLevelWarn', error:'flow.logLevelError' }[String(level).toLowerCase()]; + const key = { info: 'flow.logLevelInfo', warn: 'flow.logLevelWarn', error: 'flow.logLevelError' }[String(level).toLowerCase()]; return key ? tr(key) : level; } function flowLogKindLabel(kind = '') { const key = { - 'flow.created':'flow.logKindCreated', 'flow.updated':'flow.logKindUpdated', 'flow.deleted':'flow.logKindDeleted', - 'flow.imported':'flow.logKindImported', 'flow.dry_run':'flow.logKindDryRun', 'flow.condition_error':'flow.logKindConditionError', - 'flow.action_suppressed':'flow.logKindActionSuppressed', 'automation.fired':'flow.logKindActionFired', - 'automation.error':'flow.logKindActionError', 'automation.blocked_by_zone':'flow.logKindBlockedZone', - 'automation.blocked_by_manual_override':'flow.logKindBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logKindBlockedThermostat', - 'automation.blocked_by_temporary_thermostat':'flow.logKindBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logKindBlockedThermostatOwner', - 'automation.blocked_by_fresh_ownership':'flow.logKindBlockedOwnership', 'automation.conflict':'flow.logKindConflict', + 'flow.created': 'flow.logKindCreated', 'flow.updated': 'flow.logKindUpdated', 'flow.deleted': 'flow.logKindDeleted', + 'flow.imported': 'flow.logKindImported', 'flow.dry_run': 'flow.logKindDryRun', 'flow.condition_error': 'flow.logKindConditionError', + 'flow.action_suppressed': 'flow.logKindActionSuppressed', 'automation.fired': 'flow.logKindActionFired', + 'automation.error': 'flow.logKindActionError', 'automation.blocked_by_zone': 'flow.logKindBlockedZone', + 'automation.blocked_by_manual_override': 'flow.logKindBlockedManual', 'automation.blocked_by_local_thermostat': 'flow.logKindBlockedThermostat', + 'automation.blocked_by_temporary_thermostat': 'flow.logKindBlockedTemporary', 'automation.blocked_by_thermostat_owner': 'flow.logKindBlockedThermostatOwner', + 'automation.blocked_by_fresh_ownership': 'flow.logKindBlockedOwnership', 'automation.conflict': 'flow.logKindConflict', }[kind]; return key ? tr(key) : kind; } function flowLogMessage(event = {}) { const name = app.flowDraft?.name || tr('nav.flows'); const key = { - 'flow.created':'flow.logMessageCreated', 'flow.updated':'flow.logMessageUpdated', 'flow.deleted':'flow.logMessageDeleted', - 'flow.imported':'flow.logMessageImported', 'flow.dry_run':'flow.logMessageDryRun', 'flow.condition_error':'flow.logMessageConditionError', - 'flow.action_suppressed':'flow.logMessageActionSuppressed', 'automation.fired':'flow.logMessageActionFired', - 'automation.error':'flow.logMessageActionError', 'automation.blocked_by_zone':'flow.logMessageBlockedZone', - 'automation.blocked_by_manual_override':'flow.logMessageBlockedManual', 'automation.blocked_by_local_thermostat':'flow.logMessageBlockedThermostat', - 'automation.blocked_by_temporary_thermostat':'flow.logMessageBlockedTemporary', 'automation.blocked_by_thermostat_owner':'flow.logMessageBlockedThermostatOwner', - 'automation.blocked_by_fresh_ownership':'flow.logMessageBlockedOwnership', 'automation.conflict':'flow.logMessageConflict', + 'flow.created': 'flow.logMessageCreated', 'flow.updated': 'flow.logMessageUpdated', 'flow.deleted': 'flow.logMessageDeleted', + 'flow.imported': 'flow.logMessageImported', 'flow.dry_run': 'flow.logMessageDryRun', 'flow.condition_error': 'flow.logMessageConditionError', + 'flow.action_suppressed': 'flow.logMessageActionSuppressed', 'automation.fired': 'flow.logMessageActionFired', + 'automation.error': 'flow.logMessageActionError', 'automation.blocked_by_zone': 'flow.logMessageBlockedZone', + 'automation.blocked_by_manual_override': 'flow.logMessageBlockedManual', 'automation.blocked_by_local_thermostat': 'flow.logMessageBlockedThermostat', + 'automation.blocked_by_temporary_thermostat': 'flow.logMessageBlockedTemporary', 'automation.blocked_by_thermostat_owner': 'flow.logMessageBlockedThermostatOwner', + 'automation.blocked_by_fresh_ownership': 'flow.logMessageBlockedOwnership', 'automation.conflict': 'flow.logMessageConflict', }[event.kind]; return key ? tr(key, { name }) : (event.message || '—'); } @@ -1027,10 +1027,10 @@ async function saveFlow() { await applySavedFlow(saved, 'flow.saved'); } catch (error) { if (error.status !== 400) return toast(error.message, true); - const saveDraft = confirm(tr('flow.saveAsDraftConfirm', { reason:error.message })); + const saveDraft = confirm(tr('flow.saveAsDraftConfirm', { reason: error.message })); if (!saveDraft) return toast(error.message, true); try { - const saved = await persistFlowDraft({ ...body, enabled:false, draft:true }); + const saved = await persistFlowDraft({ ...body, enabled: false, draft: true }); await applySavedFlow(saved, 'flow.savedAsDraft'); } catch (draftError) { toast(draftError.message, true); } } @@ -1047,9 +1047,9 @@ async function toggleFlowEnabled(id, enabled) { if (flow.draft) return toast(tr('flow.draftCannotEnable'), true); const toggle = $(`[data-action="toggle-flow-enabled"][data-id="${CSS.escape(id)}"]`); if (toggle?.disabled) return; if (toggle) toggle.disabled = true; - const body = { name:flow.name, enabled, draft:false, description:flow.description || '', nodes:flow.nodes || [], edges:flow.edges || [], expected_revision:Number(flow.revision || 0) }; + const body = { name: flow.name, enabled, draft: false, description: flow.description || '', nodes: flow.nodes || [], edges: flow.edges || [], expected_revision: Number(flow.revision || 0) }; try { - const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method:'PUT', body }); + const saved = await api(`/api/flows/${encodeURIComponent(id)}`, { method: 'PUT', body }); const index = app.flows.findIndex(item => item.id === id); if (index >= 0) app.flows[index] = saved; renderFlows(); await loadBootstrap(); toast(enabled ? tr('flow.quickEnabled') : tr('flow.quickDisabled')); } catch (error) { renderFlows(); toast(error.message, true); } @@ -1097,22 +1097,22 @@ function updateFlowConfig(input) { } else if (node.kind === 'shared_input' && key === 'value') { const item = (app.flowSharedInputs || []).find(value => value.id === node.config.input_id); - const numeric = ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric'].includes(item?.kind); + const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(item?.kind); node.config.value = numeric ? (input.value === '' ? null : Number(input.value)) : input.value; } - else if (['power','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true'; + else if (['power', 'swing_vertical', 'swing_horizontal', 'quiet', 'turbo', 'light', 'air', 'xfan', 'health', 'sleep'].includes(key)) node.config[key] = input.value === '' ? null : input.value === 'true'; else if (key === 'value' && node.kind === 'constant') node.config[key] = input.value === 'true'; else if (key === 'data_json' && node.kind === 'ha_service_action') { try { node.config.data = JSON.parse(input.value || '{}'); } catch { toast(tr('flow.invalidJson'), true); return; } } - else if (key === 'seconds' && ['stable_for','delay'].includes(node.kind)) node.config.seconds = Math.max(1, Number(input.value || 1)); + else if (key === 'seconds' && ['stable_for', 'delay'].includes(node.kind)) node.config.seconds = Math.max(1, Number(input.value || 1)); else if (key === 'min_seconds' && node.kind === 'state_duration') node.config.min_seconds = Math.max(0, Number(input.value || 0)); else if (key === 'max_seconds' && node.kind === 'state_duration') node.config.max_seconds = input.value === '' ? null : Math.max(0, Number(input.value)); else if (key === 'max_count' && node.kind === 'rate_limit') node.config.max_count = Math.max(1, Math.floor(Number(input.value || 1))); else if (key === 'period_seconds' && node.kind === 'rate_limit') node.config.period_seconds = Math.max(1, Math.floor(Number(input.value || 1))); - else if (key === 'window_seconds' && ['rolling_stat','oscillates'].includes(node.kind)) node.config.window_seconds = Math.max(10, Number(input.value || 10)); + else if (key === 'window_seconds' && ['rolling_stat', 'oscillates'].includes(node.kind)) node.config.window_seconds = Math.max(10, Number(input.value || 10)); else if (key === 'value' && node.kind === 'rolling_stat') node.config.value = Number(input.value || 0); else if (key === 'min_span' && node.kind === 'oscillates') node.config.min_span = Math.max(0.001, Number(input.value || 0.001)); else if (key === 'min_direction_changes' && node.kind === 'oscillates') node.config.min_direction_changes = Math.max(1, Math.floor(Number(input.value || 1))); - else if (['value','setpoint','target_temperature','cooldown_seconds','fan_speed'].includes(key) && ['outdoor_temperature','device_temperature','zone_temperature','ha_numeric','zone_thermostat','device_action','group_action','ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value); + else if (['value', 'setpoint', 'target_temperature', 'cooldown_seconds', 'fan_speed'].includes(key) && ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'zone_thermostat', 'device_action', 'group_action', 'ha_service_action'].includes(node.kind)) node.config[key] = input.value === '' ? null : Number(input.value); else node.config[key] = input.value; app.flowDirty = true; renderFlowEditor(); } @@ -1134,8 +1134,8 @@ document.addEventListener('pointerdown', event => { if (!selected.includes(node.id)) { setFlowSelection(selected, selected[selected.length - 1] || null); event.preventDefault(); return; } } else if (!selected.includes(node.id)) selected = [node.id]; app.flowSelectedNodeIds = selected; app.flowSelectedNodeId = node.id; renderFlowEditor(); - const starts = selected.map(id => flowNodeById(id)).filter(Boolean).map(item => ({ id:item.id, left:Number(item.x || 0), top:Number(item.y || 0) })); - flowDrag = { x:event.clientX, y:event.clientY, starts }; + const starts = selected.map(id => flowNodeById(id)).filter(Boolean).map(item => ({ id: item.id, left: Number(item.x || 0), top: Number(item.y || 0) })); + flowDrag = { x: event.clientX, y: event.clientY, starts }; nodeEl.setPointerCapture?.(event.pointerId); event.preventDefault(); }); document.addEventListener('pointermove', event => { @@ -1208,7 +1208,7 @@ document.addEventListener('click', event => { requestAnimationFrame(() => { const target = $('#flowSharedInputsSettings'); if (!target) return; - target.scrollIntoView({ behavior:'smooth', block:'start' }); + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); target.classList.add('is-linked-target'); setTimeout(() => target.classList.remove('is-linked-target'), 1800); }); diff --git a/web/js/realtime.js b/web/js/realtime.js index 65e88fd..8e32cf3 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -67,6 +67,6 @@ function connectWebSocket() { ws.onerror = () => updateConnectionIndicator('connectionError'); let messageQueue = Promise.resolve(); ws.onmessage = event => { - messageQueue = messageQueue.then(() => handleWebSocketMessage(event)).catch(() => {}); + messageQueue = messageQueue.then(() => handleWebSocketMessage(event)).catch(() => { }); }; } diff --git a/web/js/settings-ui.js b/web/js/settings-ui.js index 31cf736..b25058a 100644 --- a/web/js/settings-ui.js +++ b/web/js/settings-ui.js @@ -76,10 +76,10 @@ function sharedFlowInputSourceSummary(item) { if (item.kind === 'device_state') return `${app.devices.find(value => value.id === c.device_id)?.name || tr('common.noDevice')} · ${c.field || 'state'}`; if (item.kind === 'zone_state') return `${app.zones.find(value => value.id === c.zone_id)?.name || tr('common.noZone')} · ${c.field || 'state'}`; if (item.kind === 'group_state') return `${app.groups.find(value => value.id === c.group_id)?.name || tr('groups.group')} · ${c.field || 'power_enabled'}`; - return flowNodeSummary({ kind:item.kind, config:c }); + return flowNodeSummary({ kind: item.kind, config: c }); } -function isHaSharedInputKind(kind) { return ['ha_state','ha_numeric','ha_attribute','ha_available'].includes(kind); } +function isHaSharedInputKind(kind) { return ['ha_state', 'ha_numeric', 'ha_attribute', 'ha_available'].includes(kind); } function flowSharedInputUsages(id) { return (app.flows || []).filter(flow => (flow.nodes || []).some(node => node.kind === 'shared_input' && node.config?.input_id === id)); @@ -91,10 +91,10 @@ function renderFlowSharedInputs() { host.innerHTML = items.length ? items.map(item => { const usages = flowSharedInputUsages(item.id); const usageMarkup = usages.length - ? `
${esc(tr('flow.sharedInputUsedBy', { count:usages.length }))}
${usages.slice(0, 4).map(flow => ``).join('')}${usages.length > 4 ? `+${usages.length - 4}` : ''}
` + ? `
${esc(tr('flow.sharedInputUsedBy', { count: usages.length }))}
${usages.slice(0, 4).map(flow => ``).join('')}${usages.length > 4 ? `+${usages.length - 4}` : ''}
` : `${esc(tr('flow.sharedInputUnused'))}`; return `
-
${esc(item.name)}${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title:item.kind }))} · ${esc(sharedFlowInputSourceSummary(item))}${esc(item.id)}${usageMarkup}
+
${esc(item.name)}${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))} · ${esc(sharedFlowInputSourceSummary(item))}${esc(item.id)}${usageMarkup}
`; }).join('') : `
${esc(tr('flow.sharedInputsEmpty'))}${esc(tr('flow.sharedInputsEmptyHint'))}
`; @@ -119,8 +119,8 @@ function renderFlowSharedInputFields(kind, config = {}) { else if (kind === 'device_temperature') fields = ``; else if (kind === 'zone_temperature') fields = ``; else if (kind === 'house_mode') fields = `

${esc(tr('flow.sharedInputSourceOnlyHint'))}

`; - else if (kind === 'device_state') fields = ``; - else if (kind === 'zone_state') fields = ``; + else if (kind === 'device_state') fields = ``; + else if (kind === 'zone_state') fields = ``; else if (kind === 'group_state') fields = ``; else if (kind === 'night_mode') fields = `

${esc(tr('flow.nightModeHint'))}

`; host.innerHTML = fields; @@ -133,7 +133,7 @@ function openFlowSharedInputEditor(id = '') { const form = $('#flowSharedInputForm'), dialog = $('#flowSharedInputDialog'); if (!form || !dialog) return; const item = id ? (app.flowSharedInputs || []).find(value => value.id === id) : null; const kindSelect = form.kind; - kindSelect.innerHTML = flowSharedInputKinds().map(([kind,key]) => ``).join(''); + kindSelect.innerHTML = flowSharedInputKinds().map(([kind, key]) => ``).join(''); form.id.value = item?.id || ''; form.name.value = item?.name || ''; form.kind.value = item?.kind || 'constant'; diff --git a/web/js/settings.js b/web/js/settings.js index 05200fc..cedd40e 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -356,16 +356,16 @@ $('#flowSharedInputKind')?.addEventListener('change', event => { renderFlowSharedInputFields(event.target.value, flowSharedInputDefaultConfig(event.target.value)); }); function evaluateFlowSharedHaTest(kind, config, result) { - if (kind === 'ha_available') return { actual:result.available === true, valid:true }; + if (kind === 'ha_available') return { actual: result.available === true, valid: true }; if (kind === 'ha_attribute') { const actual = result.attributes?.[config.attribute]; - return { actual, valid:actual !== undefined }; + return { actual, valid: actual !== undefined }; } if (kind === 'ha_numeric') { const actual = Number(result.state); - return { actual, valid:Number.isFinite(actual) }; + return { actual, valid: Number.isFinite(actual) }; } - return { actual:result.state, valid:true }; + return { actual: result.state, valid: true }; } $('#flowSharedInputTest')?.addEventListener('click', async event => { @@ -384,7 +384,7 @@ $('#flowSharedInputTest')?.addEventListener('click', async event => { button.disabled = true; button.textContent = tr('flow.sharedInputTesting'); resultHost.hidden = false; resultHost.innerHTML = `${esc(tr('flow.sharedInputTesting'))}`; try { - const result = await api('/api/integrations/home-assistant/entity', { method:'POST', body:{ entity_id:entityId } }); + const result = await api('/api/integrations/home-assistant/entity', { method: 'POST', body: { entity_id: entityId } }); const evaluation = evaluateFlowSharedHaTest(kind, config, result); const actual = evaluation.actual == null ? 'null' : (typeof evaluation.actual === 'object' ? JSON.stringify(evaluation.actual) : String(evaluation.actual)); const success = evaluation.valid; @@ -424,7 +424,7 @@ document.addEventListener('click', event => { const id = remove.dataset.flowSharedDelete; const item = app.flowSharedInputs.find(value => value.id === id); if (!item) return; const uses = app.flows.reduce((count, flow) => count + (flow.nodes || []).filter(node => node.kind === 'shared_input' && node.config?.input_id === id).length, 0); - const message = uses ? tr('flow.sharedInputDeleteUsed', { name:item.name, count:uses }) : tr('flow.sharedInputDeleteConfirm', { name:item.name }); + const message = uses ? tr('flow.sharedInputDeleteUsed', { name: item.name, count: uses }) : tr('flow.sharedInputDeleteConfirm', { name: item.name }); if (!confirm(message)) return; app.flowSharedInputs = app.flowSharedInputs.filter(value => value.id !== id); renderFlowSharedInputs(); diff --git a/web/styles.css b/web/styles.css index fab6e97..2850f64 100644 --- a/web/styles.css +++ b/web/styles.css @@ -36,6 +36,8 @@ --scroll-thumb: #444444; --scroll-thumb-hover: #575757; --radius: 16px; + --radius-small: 10px; + --radius-medium: 14px; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } @@ -1502,7 +1504,6 @@ legend { } } -/* Zone quick controls */ .zone-quick-control { display: grid; gap: 4px; @@ -1558,7 +1559,6 @@ legend { overflow-wrap: anywhere; } -/* Themed scrollbars: visible where scrolling is useful, but visually quiet. */ * { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--accent) 42%, var(--surface-2)) var(--surface-muted); @@ -2006,7 +2006,6 @@ legend { line-height: 1.5; } -/* Rich climate history (v0.4.4) using the classic rounded UI. */ .history-chart-card { margin-top: 14px; } @@ -2160,7 +2159,6 @@ legend { } } -/* History navigation, custom chart composer and linkable sub-pages. */ .history-tabs { display: flex; gap: 7px; @@ -2325,7 +2323,6 @@ legend { background: var(--surface-muted); } -/* Toast stack: quiet success/error feedback that matches light and dark themes. */ .toast-stack { position: fixed; z-index: 120; @@ -2896,7 +2893,6 @@ html[data-theme='light'] .simulation-rule-action { } } -/* Compact connection state: a colored dot replaces the Connected/Disconnected label. */ .brand-line { display: flex; align-items: center; @@ -2943,7 +2939,6 @@ html[data-theme='light'] .simulation-rule-action { } } -/* v0.5.3 usability refinements */ .history-toolbar-panel .chart-toolbar { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(180px, .55fr) auto; @@ -3471,7 +3466,6 @@ html[data-theme='light'] .simulation-rule-action { } } -/* v0.5.4: settings headers no longer use decorative icons. */ .settings-block-head { gap: 0; } @@ -3509,7 +3503,6 @@ html[data-theme='light'] .simulation-rule-action { color: var(--accent); } -/* compact, centered unit-feature controls */ .device-capability-panel { justify-items: center; text-align: center; @@ -3637,7 +3630,6 @@ html[data-theme='light'] .simulation-rule-action { } } -/* Group controls and standalone simulator */ [hidden] { display: none !important; } @@ -3883,7 +3875,6 @@ body.simulation-standalone [data-view="simulation"] { } } -/* Dashboard hierarchy: compact global controls -> wrapped groups -> on-demand details. */ [data-view="dashboard"] .hero { min-height: 164px; padding: 24px 28px; @@ -4027,7 +4018,6 @@ body.simulation-standalone [data-view="simulation"] { padding: 8px 11px; } -/* Groups wrap into rows. Horizontal scrolling is intentionally disabled on the dashboard. */ .dashboard-group-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); @@ -4183,7 +4173,6 @@ body.simulation-standalone [data-view="simulation"] { grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); } -/* Dashboard quick controls share one compact visual language. */ .quick-control-card { --quick-control-height: 28px; --quick-control-gap: 4px; @@ -4213,8 +4202,6 @@ body.simulation-standalone [data-view="simulation"] { grid-template-columns: repeat(5, minmax(0, 1fr)); } -/* Polish mode labels are noticeably wider than Auto/Nawiew/Grzanie. - Give Cool/Dry more room instead of shrinking or clipping their text. */ .quick-device-control .mode-row.quick-control-row-5 { grid-template-columns: minmax(0, .8fr) minmax(0, 1.35fr) minmax(0, 1.2fr) minmax(0, .85fr) minmax(0, .8fr); } @@ -4480,7 +4467,6 @@ body.simulation-standalone [data-view="simulation"] { } } -/* Dashboard sections: horizontal tabs, one vertically scrollable panel at a time. */ .dashboard-tabs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -4596,7 +4582,6 @@ body.simulation-standalone [data-view="simulation"] { } } -/* 0.6.9 dashboard density and fit fixes */ [data-view="dashboard"] .dashboard-summary-card { min-height: 0; align-items: center; @@ -4676,7 +4661,6 @@ body.simulation-standalone [data-view="simulation"] { text-align: right; } -/* Quick thermostat: target control gets its own row, readings no longer collide with it. */ #dashboardZones { grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); } @@ -4686,9 +4670,6 @@ body.simulation-standalone [data-view="simulation"] { align-items: stretch; } -/* Primary control/configuration cards need room for labels and actions. - Keep at most two cards per row and collapse naturally to one before - the card internals start clipping. */ #dashboardZones, #dashboardDevices, #deviceList, @@ -4697,10 +4678,10 @@ body.simulation-standalone [data-view="simulation"] { grid-auto-rows: 1fr; } -#dashboardZones > article, -#dashboardDevices > article, -#deviceList > article, -#zoneList > article { +#dashboardZones>article, +#dashboardDevices>article, +#deviceList>article, +#zoneList>article { height: 100%; } @@ -4803,7 +4784,6 @@ body.simulation-standalone [data-view="simulation"] { white-space: normal; } -/* Smaller Node-RED/simulator-like plan blocks with explicit group context. */ #controlPlan.automation-plan-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 210px), 1fr)); gap: 8px; @@ -4991,7 +4971,6 @@ body.simulation-standalone [data-view="simulation"] { margin-bottom: 10px; } -/* 0.7.2: Zones are configuration cards; live controls stay on the Dashboard. */ .zone-config-card { display: grid; gap: 12px; @@ -5145,9 +5124,6 @@ body.simulation-standalone [data-view="simulation"] { } } -/* 0.7.1+: quick thermostats and quick manual control use the shared quick-control classes above. */ - -/* 0.8.11 mobile toolbar and chart inspection UX */ .topbar { grid-template-columns: minmax(0, 1fr) auto auto; column-gap: 10px; @@ -5412,7 +5388,6 @@ button:focus-visible, min-width: max-content; } -/* 0.8.13 settings separation, simulation warning, system status and debug filters */ .toolbar-picker { position: relative; display: inline-flex; @@ -5787,7 +5762,6 @@ textarea[aria-invalid="true"] { box-shadow: 0 8px 30px rgba(0, 0, 0, .18), 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent); } -/* Standalone HTTP error pages */ .error-page { min-height: 100dvh; } @@ -5861,7 +5835,6 @@ textarea[aria-invalid="true"] { background: var(--accent-strong); } -/* Explicit control-source states */ .list-card.group-controlled { border-color: var(--group-control-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--group-control-color) 30%, transparent); @@ -5904,7 +5877,6 @@ textarea[aria-invalid="true"] { background-image: linear-gradient(color-mix(in srgb, var(--warning-soft) 42%, transparent), color-mix(in srgb, var(--warning-soft) 42%, transparent)); } -/* v0.8.20: group ownership and pending thermostat state clarity. */ .group-custom-temperature-row { grid-template-columns: minmax(0, 1fr) auto auto auto; } @@ -5946,7 +5918,6 @@ textarea[aria-invalid="true"] { } } -/* Idle groups keep the card neutral; only the state badge carries the group color. */ .list-card.group-card.group-linked, .list-card.group-card.group-off { border-style: solid; @@ -5965,14 +5936,12 @@ textarea[aria-invalid="true"] { border-style: dashed; } -/* Color the whole group card only while the group is the active control source. */ .list-card.group-card.group-linked.group-controlled { border-style: solid; border-color: var(--group-control-color); box-shadow: 0 0 0 1px color-mix(in srgb, var(--group-control-color) 30%, transparent); } -/* v0.9.0: visible/cancellable compressor-protection queue. */ .compressor-queue-panel { display: flex; align-items: center; @@ -6005,7 +5974,6 @@ textarea[aria-invalid="true"] { } -/* v0.9.1: global compressor queue overview. */ .queue-overview-button { display: inline-flex; align-items: center; @@ -6193,599 +6161,2824 @@ textarea[aria-invalid="true"] { justify-self: start; } } -/* Visual Flow editor */ -.flow-info-panel { display:flex; align-items:center; justify-content:space-between; gap:20px; } -.flow-info-panel p { margin:.35rem 0 0; color:var(--muted); max-width:780px; } -.flow-card .card-footer small { max-width:70%; } -body.flow-editor-open { overflow:hidden; } -.flow-editor { position:fixed; inset:0; z-index:80; background:var(--bg); display:flex; flex-direction:column; } -.flow-editor[hidden] { display:none !important; } -.flow-editor-bar { min-height:76px; display:flex; align-items:center; justify-content:space-between; gap:20px; padding:10px 18px; border-bottom:1px solid var(--line); background:var(--surface); } -.flow-editor-title { display:flex; align-items:center; gap:12px; min-width:0; } -.flow-editor-title > div { display:grid; gap:2px; min-width:0; } -.flow-name-wrap { min-width:280px; min-height:36px; display:flex; align-items:center; gap:8px; } -.flow-name-view { display:flex; align-items:center; gap:6px; min-width:0; } -.flow-name-view[hidden] { display:none !important; } -.flow-name-text { appearance:none; border:0; background:transparent; color:var(--text); font:inherit; font-weight:750; font-size:1.12rem; line-height:1.25; padding:4px 2px; min-width:0; max-width:min(52vw,620px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:text; text-align:left; } -.flow-name-text:hover { color:var(--accent); } -.flow-name-edit { appearance:none; border:0; background:transparent; color:var(--muted); width:28px; height:28px; border-radius:7px; padding:0; cursor:pointer; display:grid; place-items:center; opacity:.62; transition:opacity .15s ease,background .15s ease,color .15s ease; } -.flow-name-view:hover .flow-name-edit,.flow-name-edit:focus-visible { opacity:1; color:var(--accent); background:var(--accent-soft); outline:none; } -.flow-editor-title input { border:1px solid var(--line-strong); background:var(--input-bg); color:var(--text); font-weight:750; font-size:1.12rem; padding:7px 10px; min-width:280px; outline:none; border-radius:9px; box-shadow:inset 0 0 0 1px transparent; transition:border-color .15s ease,box-shadow .15s ease,background .15s ease; } -.flow-editor-title input[hidden] { display:none !important; } -.flow-editor-title input:hover { border-color:var(--accent-border); background:var(--surface-2); } -.flow-editor-title input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); background:var(--input-bg); } -.flow-draft-badge { border-color:var(--orange); background:color-mix(in srgb,var(--orange) 14%,var(--surface)); color:var(--text); } -.flow-card.is-draft { border-style:dashed; } -.flow-editor-actions { display:flex; align-items:center; gap:10px; } -.flow-enabled { display:flex; align-items:center; gap:8px; color:var(--muted); } -.flow-editor-body { flex:1; min-height:0; display:grid; grid-template-columns:220px minmax(420px,1fr) 300px; } -.flow-palette,.flow-inspector { background:var(--surface); overflow:auto; padding:16px; } -.flow-palette { border-right:1px solid var(--line); } -.flow-inspector { border-left:1px solid var(--line); } -.flow-palette-head { display:grid; gap:4px; margin-bottom:18px; } -.flow-palette-head small { color:var(--muted); line-height:1.35; } -.flow-palette-group { display:grid; gap:7px; margin:0 0 18px; } -.flow-palette-group > span { color:var(--muted); font-size:.72rem; font-weight:750; text-transform:uppercase; letter-spacing:.08em; margin-bottom:2px; } -.flow-palette-group button { text-align:left; border:1px solid var(--line); background:var(--surface-2); color:var(--text); border-radius:10px; padding:9px 10px; } -.flow-palette-group button:hover { border-color:var(--accent-border); background:var(--accent-soft); } -.flow-workspace-wrap { min-width:0; min-height:0; display:grid; grid-template-rows:auto minmax(0,1fr) auto; background:var(--canvas); } -.flow-workspace { position:relative; min-height:0; overflow:auto; outline:none; background-color:var(--canvas); background-image:linear-gradient(var(--grid) 1px,transparent 1px),linear-gradient(90deg,var(--grid) 1px,transparent 1px); background-size:24px 24px; } -.flow-nodes { position:relative; width:2400px; height:1500px; } -.flow-edges { position:absolute; inset:0; width:2400px; height:1500px; overflow:visible; pointer-events:none; z-index:1; } -.flow-edges path { fill:none; stroke:var(--muted-2); stroke-width:2.5; pointer-events:stroke; cursor:pointer; } -.flow-edges path:hover { stroke:var(--danger); stroke-width:4; } -.flow-editor .flow-node { position:absolute; z-index:2; display:block; align-content:normal; gap:0; width:170px; min-height:78px; padding:0; border:1px solid var(--line-strong); border-radius:12px; background:var(--surface); box-shadow:0 10px 28px rgba(0,0,0,.18); user-select:none; } -.flow-editor .flow-node.selected { border-color:var(--accent); box-shadow:0 0 0 2px var(--accent-soft),0 12px 30px rgba(0,0,0,.22); } -.flow-editor .flow-node-time { border-top:3px solid var(--blue); } -.flow-editor .flow-node-sensor { border-top:3px solid var(--teal); } -.flow-editor .flow-node-logic { border-top:3px solid var(--purple); } -.flow-editor .flow-node-action { border-top:3px solid var(--orange); } -.flow-editor .flow-node-head { display:flex; justify-content:space-between; gap:8px; align-items:center; padding:8px 9px 6px; font-size:.82rem; font-weight:750; cursor:grab; } -.flow-editor .flow-node-head button { border:0; background:transparent; color:var(--muted); padding:0 2px; font-size:1rem; } -.flow-editor .flow-node-body { padding:5px 10px 10px; color:var(--muted); font-size:.76rem; line-height:1.35; overflow-wrap:anywhere; } -.flow-editor .flow-port { position:absolute; top:50%; width:14px; height:14px; margin-top:-7px; border-radius:50%; border:2px solid var(--surface); background:var(--muted-2); padding:0; z-index:4; } -.flow-editor .flow-port-in { left:-8px; } -.flow-editor .flow-port-out { right:-8px; } -.flow-editor .flow-port:hover,.flow-editor .flow-port.armed { background:var(--accent); transform:scale(1.15); } -.flow-empty-hint { position:absolute; z-index:0; left:50%; top:42%; transform:translate(-50%,-50%); text-align:center; display:grid; gap:5px; color:var(--muted); pointer-events:none; } -.flow-natural-preview { display:flex; justify-content:space-between; gap:24px; align-items:center; min-height:58px; padding:10px 18px; border-top:1px solid var(--line); background:var(--surface); } -.flow-preview-item { display:grid; grid-template-columns:auto minmax(0,1fr); gap:10px; align-items:baseline; min-width:0; } -.flow-preview-interpretation { flex:1 1 auto; } -.flow-preview-runtime { flex:0 0 auto; justify-content:end; text-align:right; } -.flow-natural-preview span { color:var(--muted); font-size:.86rem; line-height:1.4; } -.flow-preview-runtime span { white-space:nowrap; } -.flow-inspector-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; margin-bottom:18px; } -.flow-inspector-head h3 { margin:2px 0 0; } -.flow-inspector label { display:grid; gap:6px; margin-bottom:13px; color:var(--muted); font-size:.82rem; } -.flow-inspector input,.flow-inspector select { width:100%; border:1px solid var(--line); border-radius:10px; background:var(--input-bg); color:var(--text); padding:9px 10px; } -.flow-inspector .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; } -.flow-day-grid { display:grid; grid-template-columns:1fr 1fr; gap:7px; } -.flow-day-grid label { display:flex; align-items:center; gap:7px; padding:8px; border:1px solid var(--line); border-radius:9px; margin:0; } -.flow-day-grid input { width:auto; } -.flow-inspector-meta { display:grid; gap:5px; margin-top:20px; padding-top:14px; border-top:1px solid var(--line); color:var(--muted); } -.flow-inspector-meta code { font-size:.72rem; overflow-wrap:anywhere; } + +.flow-info-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +.flow-info-panel p { + margin: .35rem 0 0; + color: var(--muted); + max-width: 780px; +} + +.flow-card .card-footer small { + max-width: 70%; +} + +body.flow-editor-open { + overflow: hidden; +} + +.flow-editor { + position: fixed; + inset: 0; + z-index: 80; + background: var(--bg); + display: flex; + flex-direction: column; +} + +.flow-editor[hidden] { + display: none !important; +} + +.flow-editor-bar { + min-height: 76px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 10px 18px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.flow-editor-title { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.flow-editor-title>div { + display: grid; + gap: 2px; + min-width: 0; +} + +.flow-name-wrap { + min-width: 280px; + min-height: 36px; + display: flex; + align-items: center; + gap: 8px; +} + +.flow-name-view { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.flow-name-view[hidden] { + display: none !important; +} + +.flow-name-text { + appearance: none; + border: 0; + background: transparent; + color: var(--text); + font: inherit; + font-weight: 750; + font-size: 1.12rem; + line-height: 1.25; + padding: 4px 2px; + min-width: 0; + max-width: min(52vw, 620px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; + text-align: left; +} + +.flow-name-text:hover { + color: var(--accent); +} + +.flow-name-edit { + appearance: none; + border: 0; + background: transparent; + color: var(--muted); + width: 28px; + height: 28px; + border-radius: 7px; + padding: 0; + cursor: pointer; + display: grid; + place-items: center; + opacity: .62; + transition: opacity .15s ease, background .15s ease, color .15s ease; +} + +.flow-name-view:hover .flow-name-edit, +.flow-name-edit:focus-visible { + opacity: 1; + color: var(--accent); + background: var(--accent-soft); + outline: none; +} + +.flow-editor-title input { + border: 1px solid var(--line-strong); + background: var(--input-bg); + color: var(--text); + font-weight: 750; + font-size: 1.12rem; + padding: 7px 10px; + min-width: 280px; + outline: none; + border-radius: 9px; + box-shadow: inset 0 0 0 1px transparent; + transition: border-color .15s ease, box-shadow .15s ease, background .15s ease; +} + +.flow-editor-title input[hidden] { + display: none !important; +} + +.flow-editor-title input:hover { + border-color: var(--accent-border); + background: var(--surface-2); +} + +.flow-editor-title input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); + background: var(--input-bg); +} + +.flow-draft-badge { + border-color: var(--orange); + background: color-mix(in srgb, var(--orange) 14%, var(--surface)); + color: var(--text); +} + +.flow-card.is-draft { + border-style: dashed; +} + +.flow-editor-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.flow-enabled { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); +} + +.flow-editor-body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 220px minmax(420px, 1fr) 300px; +} + +.flow-palette, +.flow-inspector { + background: var(--surface); + overflow: auto; + padding: 16px; +} + +.flow-palette { + border-right: 1px solid var(--line); +} + +.flow-inspector { + border-left: 1px solid var(--line); +} + +.flow-palette-head { + display: grid; + gap: 4px; + margin-bottom: 18px; +} + +.flow-palette-head small { + color: var(--muted); + line-height: 1.35; +} + +.flow-palette-group { + display: grid; + gap: 7px; + margin: 0 0 18px; +} + +.flow-palette-group>span { + color: var(--muted); + font-size: .72rem; + font-weight: 750; + text-transform: uppercase; + letter-spacing: .08em; + margin-bottom: 2px; +} + +.flow-palette-group button { + text-align: left; + border: 1px solid var(--line); + background: var(--surface-2); + color: var(--text); + border-radius: 10px; + padding: 9px 10px; +} + +.flow-palette-group button:hover { + border-color: var(--accent-border); + background: var(--accent-soft); +} + +.flow-workspace-wrap { + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + background: var(--canvas); +} + +.flow-workspace { + position: relative; + min-height: 0; + overflow: auto; + outline: none; + background-color: var(--canvas); + background-image: linear-gradient(var(--grid) 1px, transparent 1px), linear-gradient(90deg, var(--grid) 1px, transparent 1px); + background-size: 24px 24px; +} + +.flow-nodes { + position: relative; + width: 2400px; + height: 1500px; +} + +.flow-edges { + position: absolute; + inset: 0; + width: 2400px; + height: 1500px; + overflow: visible; + pointer-events: none; + z-index: 1; +} + +.flow-edges path { + fill: none; + stroke: var(--muted-2); + stroke-width: 2.5; + pointer-events: stroke; + cursor: pointer; +} + +.flow-edges path:hover { + stroke: var(--danger); + stroke-width: 4; +} + +.flow-editor .flow-node { + position: absolute; + z-index: 2; + display: block; + align-content: normal; + gap: 0; + width: 170px; + min-height: 78px; + padding: 0; + border: 1px solid var(--line-strong); + border-radius: 12px; + background: var(--surface); + box-shadow: 0 10px 28px rgba(0, 0, 0, .18); + user-select: none; +} + +.flow-editor .flow-node.selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-soft), 0 12px 30px rgba(0, 0, 0, .22); +} + +.flow-editor .flow-node-time { + border-top: 3px solid var(--blue); +} + +.flow-editor .flow-node-sensor { + border-top: 3px solid var(--teal); +} + +.flow-editor .flow-node-logic { + border-top: 3px solid var(--purple); +} + +.flow-editor .flow-node-action { + border-top: 3px solid var(--orange); +} + +.flow-editor .flow-node-head { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + padding: 8px 9px 6px; + font-size: .82rem; + font-weight: 750; + cursor: grab; +} + +.flow-editor .flow-node-head button { + border: 0; + background: transparent; + color: var(--muted); + padding: 0 2px; + font-size: 1rem; +} + +.flow-editor .flow-node-body { + padding: 5px 10px 10px; + color: var(--muted); + font-size: .76rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.flow-editor .flow-port { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + margin-top: -7px; + border-radius: 50%; + border: 2px solid var(--surface); + background: var(--muted-2); + padding: 0; + z-index: 4; +} + +.flow-editor .flow-port-in { + left: -8px; +} + +.flow-editor .flow-port-out { + right: -8px; +} + +.flow-editor .flow-port:hover, +.flow-editor .flow-port.armed { + background: var(--accent); + transform: scale(1.15); +} + +.flow-empty-hint { + position: absolute; + z-index: 0; + left: 50%; + top: 42%; + transform: translate(-50%, -50%); + text-align: center; + display: grid; + gap: 5px; + color: var(--muted); + pointer-events: none; +} + +.flow-natural-preview { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: center; + min-height: 58px; + padding: 10px 18px; + border-top: 1px solid var(--line); + background: var(--surface); +} + +.flow-preview-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: baseline; + min-width: 0; +} + +.flow-preview-interpretation { + flex: 1 1 auto; +} + +.flow-preview-runtime { + flex: 0 0 auto; + justify-content: end; + text-align: right; +} + +.flow-natural-preview span { + color: var(--muted); + font-size: .86rem; + line-height: 1.4; +} + +.flow-preview-runtime span { + white-space: nowrap; +} + +.flow-inspector-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 18px; +} + +.flow-inspector-head h3 { + margin: 2px 0 0; +} + +.flow-inspector label { + display: grid; + gap: 6px; + margin-bottom: 13px; + color: var(--muted); + font-size: .82rem; +} + +.flow-inspector input, +.flow-inspector select { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--input-bg); + color: var(--text); + padding: 9px 10px; +} + +.flow-inspector .two { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.flow-day-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 7px; +} + +.flow-day-grid label { + display: flex; + align-items: center; + gap: 7px; + padding: 8px; + border: 1px solid var(--line); + border-radius: 9px; + margin: 0; +} + +.flow-day-grid input { + width: auto; +} + +.flow-inspector-meta { + display: grid; + gap: 5px; + margin-top: 20px; + padding-top: 14px; + border-top: 1px solid var(--line); + color: var(--muted); +} + +.flow-inspector-meta code { + font-size: .72rem; + overflow-wrap: anywhere; +} @media (max-width: 980px) { - .flow-editor-body { grid-template-columns:180px minmax(360px,1fr) 260px; } - .flow-name-wrap,.flow-editor-title input { min-width:180px; } + .flow-editor-body { + grid-template-columns: 180px minmax(360px, 1fr) 260px; + } + + .flow-name-wrap, + .flow-editor-title input { + min-width: 180px; + } } + @media (max-width: 760px) { - .flow-editor-bar { align-items:flex-start; flex-direction:column; } - .flow-editor-actions { width:100%; justify-content:flex-end; } - .flow-editor-body { grid-template-columns:132px minmax(500px,1fr); } - .flow-inspector { position:absolute; right:0; top:138px; bottom:0; width:min(310px,86vw); z-index:8; box-shadow:-12px 0 30px rgba(0,0,0,.28); } - .flow-palette { padding:10px; } - .flow-palette-group button { font-size:.76rem; padding:8px; } - .flow-natural-preview { flex-direction:column; align-items:stretch; gap:6px; } - .flow-preview-item { grid-template-columns:1fr; gap:2px; } - .flow-preview-runtime { justify-content:start; text-align:left; } - .flow-preview-runtime span { white-space:normal; } -} - -/* Flow diagnostics, templates and portable Flow files */ -.flow-template-card { display:grid; gap:7px; text-align:left; align-content:start; min-height:118px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); color:var(--text); padding:14px; } -.flow-template-card:hover { border-color:var(--accent); } -.flow-template-card span { color:var(--muted); font-size:.86rem; line-height:1.4; } -.flow-simulation-controls { display:grid; gap:14px; padding:4px 0 16px; border-bottom:1px solid var(--line); } -.flow-simulation-overrides { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; margin-top:8px; } -.flow-test-results { display:grid; gap:10px; margin-top:16px; } -.flow-test-summary,.flow-test-action,.flow-log-row { border:1px solid var(--line); border-radius:12px; background:var(--surface-2); padding:12px; } -.flow-test-summary { display:flex; justify-content:space-between; gap:12px; align-items:center; } -.flow-test-action > div:first-child,.flow-log-row > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; } -.flow-test-action.pass { border-left:4px solid var(--teal); } -.flow-test-action.blocked { border-left:4px solid var(--line-strong); } -.flow-trace { display:grid; gap:5px; margin-top:10px; } -.flow-trace > div { display:flex; justify-content:space-between; gap:10px; padding-top:5px; border-top:1px solid var(--line); font-size:.82rem; } -.flow-trace code { color:var(--muted); max-width:48%; overflow-wrap:anywhere; text-align:right; } -.flow-log-row p { margin:8px 0 4px; } -.flow-log-row small { color:var(--muted); } -@media (max-width:760px) { .flow-test-summary { align-items:flex-start; flex-direction:column; } } -.flow-editor-actions { flex-wrap:wrap; justify-content:flex-end; } -.flow-editor-actions .secondary { padding-inline:10px; } -@media (max-width:1100px) { - .flow-editor-bar { align-items:flex-start; } - .flow-editor-actions { max-width:70%; } -} -@media (max-width:760px) { - .flow-editor-actions { max-width:none; justify-content:flex-start; } -} -.flow-device-options { margin:10px 0; padding:10px 12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); } -.flow-device-options summary { cursor:pointer; font-weight:650; } -.flow-device-options[open] summary { margin-bottom:10px; } - -/* Flow list, multi-select and reusable inputs */ -.flow-page-actions { display:flex; align-items:center; gap:10px; } -.flow-list-section { margin-top:22px; padding-top:20px; border-top:1px solid var(--line); } -.flow-list-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:14px; } -.flow-list-heading h2 { margin:3px 0 0; } -.flow-list-heading-count { justify-content:flex-end; } -.flow-card.is-disabled { opacity:.72; } -.flow-card.is-disabled:hover { opacity:.9; } -.flow-workspace-toolbar { min-height:44px; display:flex; align-items:center; gap:7px; padding:6px 10px; border-bottom:1px solid var(--line); background:var(--surface); } -.flow-workspace-toolbar .secondary { padding:7px 9px; font-size:.78rem; } -.flow-workspace-toolbar .badge { margin-left:2px; } -.flow-shared-input-list { display:grid; gap:10px; } -.flow-shared-input-row { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:12px 14px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); } -.flow-shared-input-row > div:first-child { min-width:0; display:grid; gap:4px; } -.flow-shared-input-row small,.flow-shared-input-row code { color:var(--muted); overflow-wrap:anywhere; } -.flow-shared-input-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; } -.flow-shared-input-fields { display:grid; gap:12px; } -.flow-shared-input-fields .two { display:grid; grid-template-columns:1fr 1fr; gap:10px; } -.flow-template-list { display:grid; grid-template-columns:1fr; gap:22px; } -.flow-template-category-head { display:grid; gap:3px; } -.flow-template-category-head span { color:var(--muted); font-size:.84rem; } -.flow-template-category-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(230px,1fr)); gap:12px; } -@media (max-width:760px) { - .flow-list-heading { align-items:flex-start; } - .flow-shared-input-row { align-items:flex-start; flex-direction:column; } - .flow-shared-input-actions { width:100%; } - .flow-shared-input-actions button { flex:1; } - .flow-shared-input-fields .two { grid-template-columns:1fr; } -} - -/* v0.9.9: Flow shared-input navigation and preset category tabs */ -.flow-shared-settings-head { justify-content:space-between; align-items:flex-start; } -.flow-shared-settings-head > div { min-width:0; } -.flow-shared-add-button { flex:0 0 auto; align-self:flex-start; margin-left:auto; } -.flow-shared-settings-link { margin-top:12px; width:max-content; max-width:100%; } -.flow-shared-settings.is-linked-target { outline:2px solid var(--accent); outline-offset:3px; } -.flow-template-tabs { display:flex; gap:8px; overflow-x:auto; padding:4px 0 10px; margin-top:14px; border-bottom:1px solid var(--line); scrollbar-width:thin; } -.flow-template-tab { flex:0 0 auto; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); color:var(--muted); padding:8px 12px; white-space:nowrap; } -.flow-template-tab.active { border-color:var(--accent-border); background:var(--accent-soft); color:var(--accent); } -.flow-template-list { margin-top:14px; } -.flow-template-category-single { display:grid; gap:12px; } -.flow-template-category-single .flow-template-category-head { padding-bottom:2px; } -@media (max-width:760px) { - .flow-shared-settings-head { gap:12px; } - .flow-shared-add-button { width:auto; } -} - -/* v0.9.10: Flow preset library preview, favorites/search and shared-input diagnostics */ -.flow-shared-usage { display:grid; gap:5px; margin-top:5px; } -.flow-shared-usage > div { display:flex; flex-wrap:wrap; align-items:center; gap:6px; } -.flow-shared-usage .link-button { border:0; padding:0; background:transparent; color:var(--accent); text-align:left; text-decoration:underline; text-underline-offset:2px; cursor:pointer; font:inherit; font-size:.78rem; } -.flow-shared-unused { margin-top:4px; } -.flow-shared-test-panel { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px 14px; align-items:start; padding:12px; border:1px solid var(--line); border-radius:12px; background:var(--surface-2); } -.flow-shared-test-panel .field-note { margin:3px 0 0; } -.flow-shared-test-result { grid-column:1 / -1; display:grid; gap:8px; padding:10px 12px; border:1px solid var(--line); border-radius:10px; background:var(--surface); } -.flow-shared-test-result.pass { border-left:4px solid var(--teal); } -.flow-shared-test-result.fail { border-left:4px solid var(--line-strong); } -.flow-shared-test-result > div:first-child { display:flex; justify-content:space-between; gap:10px; align-items:center; } -.flow-shared-test-result dl { display:grid; gap:5px; margin:0; } -.flow-shared-test-result dl > div { display:grid; grid-template-columns:120px minmax(0,1fr); gap:8px; } -.flow-shared-test-result dt { color:var(--muted); } -.flow-shared-test-result dd { margin:0; min-width:0; overflow-wrap:anywhere; } -.flow-shared-test-result > small { color:var(--muted); } - -#flowTemplateDialog { width:min(1180px,calc(100vw - 32px)); } -.flow-template-toolbar { margin-top:14px; } -.flow-template-toolbar label { display:grid; gap:6px; } -.flow-template-toolbar input { width:100%; border:1px solid var(--line); border-radius:10px; background:var(--input-bg); color:var(--text); padding:10px 12px; } -.flow-template-browser { display:grid; grid-template-columns:minmax(0,1.15fr) minmax(320px,.85fr); gap:18px; align-items:start; } -.flow-template-catalog { min-width:0; } -.flow-template-preview { position:sticky; top:0; display:grid; gap:12px; margin-top:14px; padding:14px; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); min-height:250px; } -.flow-template-preview-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; } -.flow-template-preview-head h3 { margin:3px 0 0; } -.flow-template-preview > p { margin:0; color:var(--muted); line-height:1.45; } -.flow-template-preview-stats { display:flex; flex-wrap:wrap; gap:7px; } -.flow-template-preview-stats span { padding:5px 8px; border:1px solid var(--line); border-radius:999px; color:var(--muted); font-size:.78rem; } -.flow-template-preview-canvas { position:relative; height:250px; overflow:hidden; border:1px solid var(--line); border-radius:12px; background:var(--surface); } -.flow-template-preview-canvas svg { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; } -.flow-template-preview-canvas line { stroke:var(--line-strong); stroke-width:.7; vector-effect:non-scaling-stroke; } -.flow-template-preview-node { position:absolute; transform:translate(-50%,-50%); width:104px; max-width:28%; padding:6px 7px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); box-shadow:0 3px 10px rgba(0,0,0,.12); font-size:.68rem; line-height:1.2; text-align:center; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } -.flow-template-preview-node-sensor { border-top:3px solid var(--blue, var(--accent)); } -.flow-template-preview-node-logic { border-top:3px solid var(--accent); } -.flow-template-preview-node-action { border-top:3px solid var(--teal); } -.flow-template-requirements { display:grid; gap:8px; } -.flow-template-requirements > div { display:flex; flex-wrap:wrap; gap:6px; } -.flow-template-requirement { padding:5px 8px; border:1px solid var(--line); border-radius:999px; font-size:.75rem; } -.flow-template-requirement.ok { background:var(--surface); } -.flow-template-requirement.missing { border-color:var(--danger, var(--line-strong)); } -.flow-template-requirement.info { color:var(--muted); } -.flow-template-card { position:relative; display:grid; min-height:0; padding:0; overflow:hidden; } -.flow-template-card.selected { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent-border); } -.flow-template-card-main { display:grid; gap:7px; width:100%; min-height:118px; border:0; background:transparent; color:var(--text); padding:14px 42px 14px 14px; text-align:left; align-content:start; cursor:pointer; } -.flow-template-card-main span { color:var(--muted); font-size:.86rem; line-height:1.4; } -.flow-template-favorite { width:26px; height:26px; display:grid; place-items:center; border:0; padding:0; background:transparent; color:var(--muted); cursor:pointer; font-size:1.22rem; line-height:1; opacity:.72; transition:color .15s ease, opacity .15s ease, transform .15s ease; } -.flow-template-card > .flow-template-favorite { position:absolute; right:8px; top:8px; } -.flow-template-favorite:hover { color:var(--text); background:transparent; opacity:1; transform:scale(1.08); } -.flow-template-favorite:focus-visible { outline:2px solid var(--accent); outline-offset:2px; opacity:1; } -.flow-template-favorite.active { color:var(--accent); background:transparent; opacity:1; } -.flow-template-favorite.active:hover { background:transparent; } -.flow-template-favorite:active { transform:scale(.92); } -.flow-template-preview .form-actions { margin-top:0; padding-top:10px; border-top:1px solid var(--line); } - -@media (max-width:900px) { - .flow-template-browser { grid-template-columns:1fr; } - .flow-template-preview { position:static; } -} -@media (max-width:760px) { - .flow-shared-test-panel { grid-template-columns:1fr; } - .flow-shared-test-panel > button { width:100%; } - .flow-shared-test-result { grid-column:1; } - .flow-shared-test-result dl > div { grid-template-columns:1fr; gap:2px; } - .flow-template-preview-canvas { height:220px; } -} - -/* Keep zone hysteresis/source controls aligned without stretching selects. */ -.zone-hysteresis-row { align-items:start; } -.flow-editor .flow-node-current { margin-top:6px; padding-top:6px; border-top:1px solid color-mix(in srgb, var(--border) 72%, transparent); display:flex; align-items:baseline; justify-content:space-between; gap:8px; } -.flow-editor .flow-node-current span { color:var(--muted); font-size:.68rem; white-space:nowrap; } -.flow-editor .flow-node-current strong { min-width:0; max-width:72px; color:var(--text); font-size:.76rem; font-weight:700; text-align:right; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } - -/* Compact, category-coded Flow palette */ -.flow-palette-group { padding-left:8px; border-left:3px solid var(--line-strong); } -.flow-palette-group button { min-height:30px; padding:6px 8px; border-radius:8px; font-size:.76rem; line-height:1.15; } -.flow-palette-trigger { border-left-color:var(--warning); } -.flow-palette-time { border-left-color:var(--blue); } -.flow-palette-timeop { border-left-color:var(--info); } -.flow-palette-sensor { border-left-color:var(--teal); } -.flow-palette-logic { border-left-color:var(--purple); } -.flow-palette-action { border-left-color:var(--orange); } -.flow-palette-haaction { border-left-color:var(--accent); } -.flow-editor .flow-node-trigger { border-top:3px solid var(--warning); } -.flow-editor .flow-node-timeop { border-top:3px solid var(--info); } -.flow-editor .flow-node-haaction { border-top:3px solid var(--accent); } -.flow-template-preview-node-trigger { border-top:3px solid var(--warning); } -.flow-template-preview-node-time, .flow-template-preview-node-timeop { border-top:3px solid var(--blue); } -.flow-template-preview-node-haaction { border-top:3px solid var(--accent); } - - -/* Compact Flow state control and mobile action entry point. */ -.flow-enabled { white-space:nowrap; } -.flow-enabled input { width:16px; min-height:16px; accent-color:var(--accent); } -.flow-mobile-actions-button { display:none; } -.flow-inspector-head-actions { display:flex; align-items:center; gap:7px; } -.flow-inspector-close { display:none; width:34px; height:34px; padding:0; font-size:1.1rem; } - -/* Clarify which HA aliases are metric sources and which are only Flow references. */ -.sensor-alias-entity { min-width:0; display:flex; align-items:center; gap:7px; } -.sensor-alias-entity > .mono { min-width:0; overflow:hidden; color:var(--muted); font-size:11px; text-overflow:ellipsis; white-space:nowrap; } -.sensor-source-badges { display:flex; flex:0 0 auto; gap:4px; } -.sensor-source-badge { display:inline-flex; align-items:center; min-height:20px; padding:2px 6px; border:1px solid var(--line); border-radius:999px; font-size:.64rem; font-weight:750; line-height:1; white-space:nowrap; } -.sensor-source-badge.metrics { border-color:var(--accent-border); background:var(--accent-soft); color:var(--accent); } -.sensor-source-badge.flow { background:var(--surface); color:var(--muted); } - -/* UX refresh: clearer hierarchy, safer destructive actions and touch-first layouts. */ -:root { --radius-small:10px; --radius-medium:14px; } - -.device-card,.panel,.list-card { border-radius:var(--radius); } -button { border-radius:11px; } -.metric { border-radius:14px; } -.lead { max-width:780px; line-height:1.55; } -.section-heading h1 { margin-bottom:0; } - -.automation-tabs { - display:flex; align-items:center; gap:5px; width:max-content; max-width:100%; - margin:-2px 0 14px; padding:5px; overflow-x:auto; border:1px solid var(--line); - border-radius:14px; background:var(--surface); scrollbar-width:none; -} -.automation-tabs::-webkit-scrollbar { display:none; } -.automation-tabs button { flex:0 0 auto; min-height:38px; padding:8px 12px; color:var(--muted); background:transparent; font-size:12px; font-weight:750; } -.automation-tabs button.active { color:var(--accent); background:var(--accent-soft); box-shadow:inset 0 0 0 1px var(--accent-border); } - -.mobile-nav-only { display:block; } -.desktop-nav-only { display:none; } -.nav-section-label,.menu-section-label { color:var(--muted); font-size:9px; font-weight:800; letter-spacing:.09em; text-transform:uppercase; } -.menu-section-label { display:block; padding:14px 9px 5px; } -.more-menu-list .menu-section-label:first-child { padding-top:5px; } - -.card-menu { align-items:center; } -.card-primary-action { font-weight:700; } -.card-overflow { position:relative; flex:0 0 auto; } -.card-overflow summary { - display:grid; place-items:center; width:38px; height:38px; border:1px solid var(--line); - border-radius:10px; color:var(--muted); background:var(--surface-muted); cursor:pointer; - list-style:none; font-size:16px; font-weight:800; letter-spacing:.08em; -} -.card-overflow summary::-webkit-details-marker { display:none; } -.card-overflow[open] summary { color:var(--text); border-color:var(--line-strong); background:var(--surface-2); } -.card-overflow-menu { - position:absolute; z-index:25; right:0; bottom:calc(100% + 7px); min-width:150px; padding:6px; - border:1px solid var(--line); border-radius:12px; background:var(--surface); box-shadow:0 14px 34px rgba(0,0,0,.28); -} -.card-overflow-menu button { width:100%; text-align:left; } -.card-overflow-menu .danger { background:var(--danger-soft); } - -.flow-save-status { display:inline-flex; align-items:center; min-height:24px; color:var(--muted); font-size:.72rem; font-weight:750; white-space:nowrap; } -.flow-save-status::before { content:""; width:6px; height:6px; margin-right:6px; border-radius:50%; background:var(--accent); } -.flow-save-status.is-dirty { color:var(--warning); } -.flow-save-status.is-dirty::before { background:var(--warning); } -.flow-canvas { position:relative; width:2400px; height:1500px; transform-origin:0 0; } -.flow-canvas .flow-nodes,.flow-canvas .flow-edges { width:2400px; height:1500px; } -.flow-workspace-toolbar { justify-content:space-between; } -.flow-workspace-primary-actions,.flow-zoom-controls { display:flex; align-items:center; gap:7px; min-width:0; } -.flow-add-block-button { display:none; align-items:center; justify-content:center; gap:5px; } -.flow-zoom-controls .icon-button { width:32px; height:32px; min-height:32px; padding:0; font-size:17px; } -.flow-zoom-controls > span { min-width:44px; color:var(--muted); font-size:.72rem; font-weight:800; text-align:center; font-variant-numeric:tabular-nums; } -.flow-fit-button { min-height:32px; padding:6px 9px; font-size:.75rem; } -.flow-inspector-expand { display:none; width:34px; height:34px; padding:0; } - -.flow-block-dialog { width:min(620px,calc(100% - 24px)); } -.flow-block-dialog .dialog-head { padding:16px 16px 8px; } -.flow-block-search { padding:4px 16px 10px; } -.flow-block-library { display:grid; gap:14px; padding:0 16px 18px; } -.flow-block-library-group { padding-left:10px; border-left:3px solid var(--line-strong); } -.flow-block-library-group.flow-palette-trigger { border-left-color:var(--warning); } -.flow-block-library-group.flow-palette-time { border-left-color:var(--blue); } -.flow-block-library-group.flow-palette-timeop { border-left-color:var(--info); } -.flow-block-library-group.flow-palette-sensor { border-left-color:var(--teal); } -.flow-block-library-group.flow-palette-logic { border-left-color:var(--purple); } -.flow-block-library-group.flow-palette-action { border-left-color:var(--orange); } -.flow-block-library-group.flow-palette-haaction { border-left-color:var(--accent); } -.flow-block-library-group h3 { margin:0 0 7px; color:var(--muted); font-size:.72rem; letter-spacing:.07em; text-transform:uppercase; } -.flow-block-library-group > div { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } -.flow-block-library-group button { min-height:42px; text-align:left; } - -/* Port stays visually small but gets a comfortable touch target. */ -.flow-editor .flow-port { width:16px; height:16px; margin-top:-8px; } -.flow-editor .flow-port-in { left:-9px; } -.flow-editor .flow-port-out { right:-9px; } -.flow-editor .flow-port::after { content:""; position:absolute; inset:-11px; border-radius:50%; } - -.flow-preview-toggle { display:none; } -.flow-preview-details { display:flex; justify-content:space-between; gap:24px; align-items:center; width:100%; min-width:0; } - -@media (min-width:900px) { - .mobile-nav-only { display:none !important; } - .desktop-nav-only { display:flex; } - .bottom-nav { - top:calc(var(--topbar-height,72px) + 14px); bottom:14px; left:max(14px,calc((100vw - 1500px)/2)); - display:flex; flex-direction:column; gap:2px; width:190px; max-height:calc(100dvh - var(--topbar-height,72px) - 28px); - padding:10px; overflow-y:auto; border-radius:16px; scrollbar-width:thin; - } - .bottom-nav .nav-section-label { padding:10px 9px 4px; } - .bottom-nav .nav-section-label:first-child { padding-top:3px; } - .bottom-nav button { display:flex; align-items:center; justify-content:flex-start; gap:10px; min-height:39px; padding:8px 10px; border-radius:10px; text-align:left; } - .bottom-nav button span { display:grid; place-items:center; flex:0 0 24px; width:24px; font-size:17px; } - .bottom-nav button b { min-width:0; overflow:hidden; font-size:11px; text-overflow:ellipsis; white-space:nowrap; } - .bottom-nav button.active { background:var(--accent-soft); } - main { width:min(1500px,100%); padding-left:222px; padding-right:24px; } -} - -@media (max-width:760px) { - main { padding:18px 12px calc(94px + env(safe-area-inset-bottom)); } - .desktop-nav-only { display:none !important; } - .mobile-nav-only { display:grid; } - .bottom-nav { grid-template-columns:repeat(5,minmax(0,1fr)); } - .bottom-nav button { min-height:48px; } - .bottom-nav button span { font-size:19px; } - - .section-heading { gap:12px; margin:4px 0 14px; } - .section-heading h1 { font-size:26px; } - .section-heading > button,.section-heading > .section-actions,.section-heading > .section-heading-actions { width:100%; } - .section-heading > button { min-height:44px; } - .flow-page-actions,.section-heading-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } - .flow-page-actions button,.section-heading-actions button { width:100%; min-height:44px; } - .automation-tabs { width:100%; margin-bottom:12px; scroll-snap-type:x proximity; } - .automation-tabs button { flex:1 0 auto; scroll-snap-align:start; } - - .device-card,.panel,.list-card { border-radius:14px; } - .device-card { padding:15px; } - .card-footer { gap:9px; } - .card-menu { width:100%; justify-content:flex-end; } - .card-menu > button { min-height:40px; } - .card-primary-action { flex:1 1 auto; } - .card-overflow summary { width:40px; height:40px; } - - dialog:not(.auth-dialog) { - inset:auto 0 0; width:100%; max-width:none; max-height:88dvh; margin:0; - border-right:0; border-bottom:0; border-left:0; border-radius:18px 18px 0 0; - overscroll-behavior:contain; - } - dialog:not(.auth-dialog) .dialog-head { position:sticky; top:0; z-index:5; padding-top:14px; background:var(--dialog-bg); } - dialog:not(.auth-dialog) .dialog-head::before { content:""; position:absolute; top:6px; left:50%; width:38px; height:4px; border-radius:999px; background:var(--line-strong); transform:translateX(-50%); } - .dialog-form { padding:14px 14px calc(14px + env(safe-area-inset-bottom)); } - .dialog-form .form-actions { position:sticky; bottom:0; z-index:4; margin:6px -14px -14px; padding:10px 14px calc(10px + env(safe-area-inset-bottom)); border-top:1px solid var(--line); background:color-mix(in srgb,var(--dialog-bg) 96%,transparent); backdrop-filter:blur(12px); } - .dialog-form .form-actions > button { flex:1 1 0; min-height:44px; } - - .history-tabs { margin-inline:-12px; padding-inline:12px; scroll-padding-inline:12px; scroll-snap-type:x proximity; } - .history-tabs button { min-height:40px; scroll-snap-align:start; } - .history-toolbar-panel { padding:12px; } - - .settings-mode-tabs { position:sticky; top:calc(var(--topbar-height,64px) + 6px); z-index:8; margin-inline:-4px; background:color-mix(in srgb,var(--bg) 94%,transparent); backdrop-filter:blur(12px); } - .settings-save-bar { bottom:calc(74px + env(safe-area-inset-bottom)); margin-inline:-4px; padding:10px; } - .settings-save-bar span { display:none; } - .settings-save-bar button { width:100%; min-height:44px; } - - /* Mobile Flow: compact header, canvas-first editor and bottom-sheet inspector. */ .flow-editor-bar { - display:grid; grid-template-columns:minmax(0,1fr) auto; grid-template-rows:auto auto; - align-items:center; width:100%; gap:6px 8px; padding:7px 8px; overflow:hidden; - } - .flow-editor-title { grid-column:1; grid-row:1; width:100%; min-width:0; gap:8px; } - .flow-editor-title > div { width:100%; min-width:0; } - .flow-editor-title .eyebrow { display:none; } - .flow-name-wrap { display:flex; width:100%; min-width:0; gap:6px; } - .flow-name-view { min-width:0; flex:1 1 auto; } - .flow-name-text { min-width:0; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .flow-editor-title input { width:100%; min-width:0; flex:1 1 auto; font-size:1rem; } - .flow-editor-bar > .flow-save-button { - grid-column:2; grid-row:1; align-self:stretch; min-width:0; min-height:38px; - padding-inline:11px; white-space:nowrap; + align-items: flex-start; + flex-direction: column; } + .flow-editor-actions { - grid-column:1 / -1; grid-row:2; display:flex; align-items:center; justify-content:space-between; - width:100%; min-width:0; max-width:none; gap:8px; overflow:hidden; - } - .flow-editor-actions .flow-desktop-action,.flow-save-status,.flow-compile-status { display:none; } - .flow-editor-actions .flow-enabled { - display:flex; flex:0 1 auto; width:auto; min-width:0; min-height:34px; - justify-content:flex-start; gap:7px; padding:0 4px; font-size:.78rem; - } - .flow-editor-actions .flow-enabled span { display:inline; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .flow-editor-actions .flow-mobile-actions-button { - display:inline-flex; flex:0 0 auto; width:auto; min-width:96px; min-height:34px; - margin-left:auto; padding:6px 12px; justify-content:center; + width: 100%; + justify-content: flex-end; } - .flow-editor-body { position:relative; display:flex; flex:1 1 auto; flex-direction:column; height:0; min-width:0; min-height:0; overflow:hidden; } - .flow-workspace-wrap { - order:1; display:grid; grid-template-rows:auto minmax(0,1fr) auto; flex:1 1 auto; - width:100%; height:100%; min-width:0; min-height:0; overflow:hidden; + .flow-editor-body { + grid-template-columns: 132px minmax(500px, 1fr); } - .flow-workspace-toolbar { display:flex; flex:0 0 auto; width:100%; min-width:0; min-height:48px; padding:6px 8px; gap:7px; justify-content:space-between; overflow:hidden; } - .flow-workspace-primary-actions { display:flex; flex:1 1 auto; min-width:0; overflow:hidden; } - .flow-add-block-button { display:inline-flex; min-width:0; max-width:128px; min-height:36px; padding:7px 10px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .flow-selection-action,#flowSelectionCount { display:none; } - .flow-zoom-controls { display:flex; flex:0 0 auto; margin-left:auto; gap:4px; } - .flow-zoom-controls .icon-button { width:34px; height:34px; min-height:34px; } - .flow-zoom-controls > span { display:none; } - .flow-fit-button { width:34px; min-width:34px; min-height:34px; padding:0; font-size:0; } - .flow-fit-button::before { content:"⊡"; font-size:17px; line-height:1; } - .flow-palette { display:none !important; } - .flow-workspace { height:100%; min-width:0; min-height:0 !important; max-height:none; overflow:auto; overscroll-behavior:contain; touch-action:pan-x pan-y; } - - .flow-natural-preview { display:block; flex:0 0 auto; min-height:0; max-height:none; padding:0; overflow:hidden; } - .flow-preview-toggle { - display:flex; align-items:center; justify-content:space-between; width:100%; min-height:38px; - padding:7px 10px; border:0; border-radius:0; background:var(--surface); color:var(--text); text-align:left; - } - .flow-preview-toggle strong { font-size:.78rem; } - .flow-preview-toggle-icon { color:var(--muted); font-size:1rem; transition:transform .15s ease; } - .flow-preview-details { display:none; flex-direction:column; align-items:stretch; gap:7px; max-height:32dvh; padding:8px 10px 10px; overflow:auto; } - .flow-natural-preview.is-expanded .flow-preview-details { display:flex; } - .flow-natural-preview.is-expanded .flow-preview-toggle { border-bottom:1px solid var(--line); } - .flow-natural-preview.is-expanded .flow-preview-toggle-icon { transform:rotate(180deg); } - .flow-preview-item { gap:1px; } - .flow-natural-preview strong { font-size:.78rem; } - .flow-natural-preview span { font-size:.76rem; } .flow-inspector { - display:none; position:fixed; inset:auto 0 0; width:100%; max-height:56dvh; - padding:22px 14px calc(14px + env(safe-area-inset-bottom)); overflow:auto; - border:0; border-top:1px solid var(--line-strong); border-radius:18px 18px 0 0; - z-index:95; box-shadow:0 -14px 34px rgba(0,0,0,.3); transition:max-height .18s ease; + position: absolute; + right: 0; + top: 138px; + bottom: 0; + width: min(310px, 86vw); + z-index: 8; + box-shadow: -12px 0 30px rgba(0, 0, 0, .28); } - .flow-inspector.has-selection { display:block; } - .flow-inspector::before { content:""; position:absolute; top:7px; left:50%; width:38px; height:4px; border-radius:999px; background:var(--line-strong); transform:translateX(-50%); } - .flow-inspector.is-expanded { max-height:90dvh; } - .flow-inspector-close,.flow-inspector-expand { display:inline-grid; place-items:center; } - .flow-inspector-head { position:sticky; top:-22px; z-index:4; margin:-8px -2px 14px; padding:10px 2px 8px; background:var(--surface); } - .flow-inspector-delete { padding-inline:10px; } - .flow-inspector .two { grid-template-columns:1fr; } - .flow-inspector label { font-size:.78rem; } - .flow-inspector input,.flow-inspector select,.flow-inspector textarea { min-height:44px; } - .flow-block-dialog { max-height:82dvh !important; } - .flow-block-library-group > div { grid-template-columns:1fr; } - .flow-block-library-group button { min-height:46px; } + .flow-palette { + padding: 10px; + } - .sensor-alias-row { grid-template-columns:minmax(0,1fr) 34px; } - .sensor-alias-entity { grid-column:1 / -1; } - .sensor-alias-row input { grid-column:1; } - .sensor-alias-clear { grid-column:2; } + .flow-palette-group button { + font-size: .76rem; + padding: 8px; + } - /* Do not let long settings/help copy dominate small screens. */ - .settings-block { gap:14px; } - .settings-block-head h3 { font-size:16px; } - .settings-block-head p,.field-note { line-height:1.45; } + .flow-natural-preview { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .flow-preview-item { + grid-template-columns: 1fr; + gap: 2px; + } + + .flow-preview-runtime { + justify-content: start; + text-align: left; + } + + .flow-preview-runtime span { + white-space: normal; + } +} + +.flow-template-card { + display: grid; + gap: 7px; + text-align: left; + align-content: start; + min-height: 118px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface-2); + color: var(--text); + padding: 14px; +} + +.flow-template-card:hover { + border-color: var(--accent); +} + +.flow-template-card span { + color: var(--muted); + font-size: .86rem; + line-height: 1.4; +} + +.flow-simulation-controls { + display: grid; + gap: 14px; + padding: 4px 0 16px; + border-bottom: 1px solid var(--line); +} + +.flow-simulation-overrides { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; + margin-top: 8px; +} + +.flow-test-results { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.flow-test-summary, +.flow-test-action, +.flow-log-row { + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-2); + padding: 12px; +} + +.flow-test-summary { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.flow-test-action>div:first-child, +.flow-log-row>div:first-child { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.flow-test-action.pass { + border-left: 4px solid var(--teal); +} + +.flow-test-action.blocked { + border-left: 4px solid var(--line-strong); +} + +.flow-trace { + display: grid; + gap: 5px; + margin-top: 10px; +} + +.flow-trace>div { + display: flex; + justify-content: space-between; + gap: 10px; + padding-top: 5px; + border-top: 1px solid var(--line); + font-size: .82rem; +} + +.flow-trace code { + color: var(--muted); + max-width: 48%; + overflow-wrap: anywhere; + text-align: right; +} + +.flow-log-row p { + margin: 8px 0 4px; +} + +.flow-log-row small { + color: var(--muted); +} + +@media (max-width:760px) { + .flow-test-summary { + align-items: flex-start; + flex-direction: column; + } +} + +.flow-editor-actions { + flex-wrap: wrap; + justify-content: flex-end; +} + +.flow-editor-actions .secondary { + padding-inline: 10px; +} + +@media (max-width:1100px) { + .flow-editor-bar { + align-items: flex-start; + } + + .flow-editor-actions { + max-width: 70%; + } +} + +@media (max-width:760px) { + .flow-editor-actions { + max-width: none; + justify-content: flex-start; + } +} + +.flow-device-options { + margin: 10px 0; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-2); +} + +.flow-device-options summary { + cursor: pointer; + font-weight: 650; +} + +.flow-device-options[open] summary { + margin-bottom: 10px; +} + +.flow-page-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.flow-list-section { + margin-top: 22px; + padding-top: 20px; + border-top: 1px solid var(--line); +} + +.flow-list-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} + +.flow-list-heading h2 { + margin: 3px 0 0; +} + +.flow-list-heading-count { + justify-content: flex-end; +} + +.flow-card.is-disabled { + opacity: .72; +} + +.flow-card.is-disabled:hover { + opacity: .9; +} + +.flow-workspace-toolbar { + min-height: 44px; + display: flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.flow-workspace-toolbar .secondary { + padding: 7px 9px; + font-size: .78rem; +} + +.flow-workspace-toolbar .badge { + margin-left: 2px; +} + +.flow-shared-input-list { + display: grid; + gap: 10px; +} + +.flow-shared-input-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-2); +} + +.flow-shared-input-row>div:first-child { + min-width: 0; + display: grid; + gap: 4px; +} + +.flow-shared-input-row small, +.flow-shared-input-row code { + color: var(--muted); + overflow-wrap: anywhere; +} + +.flow-shared-input-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.flow-shared-input-fields { + display: grid; + gap: 12px; +} + +.flow-shared-input-fields .two { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.flow-template-list { + display: grid; + grid-template-columns: 1fr; + gap: 22px; +} + +.flow-template-category-head { + display: grid; + gap: 3px; +} + +.flow-template-category-head span { + color: var(--muted); + font-size: .84rem; +} + +.flow-template-category-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 12px; +} + +@media (max-width:760px) { + .flow-list-heading { + align-items: flex-start; + } + + .flow-shared-input-row { + align-items: flex-start; + flex-direction: column; + } + + .flow-shared-input-actions { + width: 100%; + } + + .flow-shared-input-actions button { + flex: 1; + } + + .flow-shared-input-fields .two { + grid-template-columns: 1fr; + } +} + +.flow-shared-settings-head { + justify-content: space-between; + align-items: flex-start; +} + +.flow-shared-settings-head>div { + min-width: 0; +} + +.flow-shared-add-button { + flex: 0 0 auto; + align-self: flex-start; + margin-left: auto; +} + +.flow-shared-settings-link { + margin-top: 12px; + width: max-content; + max-width: 100%; +} + +.flow-shared-settings.is-linked-target { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.flow-template-tabs { + display: flex; + gap: 8px; + overflow-x: auto; + padding: 4px 0 10px; + margin-top: 14px; + border-bottom: 1px solid var(--line); + scrollbar-width: thin; +} + +.flow-template-tab { + flex: 0 0 auto; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-2); + color: var(--muted); + padding: 8px 12px; + white-space: nowrap; +} + +.flow-template-tab.active { + border-color: var(--accent-border); + background: var(--accent-soft); + color: var(--accent); +} + +.flow-template-list { + margin-top: 14px; +} + +.flow-template-category-single { + display: grid; + gap: 12px; +} + +.flow-template-category-single .flow-template-category-head { + padding-bottom: 2px; +} + +@media (max-width:760px) { + .flow-shared-settings-head { + gap: 12px; + } + + .flow-shared-add-button { + width: auto; + } +} + +.flow-shared-usage { + display: grid; + gap: 5px; + margin-top: 5px; +} + +.flow-shared-usage>div { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.flow-shared-usage .link-button { + border: 0; + padding: 0; + background: transparent; + color: var(--accent); + text-align: left; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + font: inherit; + font-size: .78rem; +} + +.flow-shared-unused { + margin-top: 4px; +} + +.flow-shared-test-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px 14px; + align-items: start; + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-2); +} + +.flow-shared-test-panel .field-note { + margin: 3px 0 0; +} + +.flow-shared-test-result { + grid-column: 1 / -1; + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); +} + +.flow-shared-test-result.pass { + border-left: 4px solid var(--teal); +} + +.flow-shared-test-result.fail { + border-left: 4px solid var(--line-strong); +} + +.flow-shared-test-result>div:first-child { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; +} + +.flow-shared-test-result dl { + display: grid; + gap: 5px; + margin: 0; +} + +.flow-shared-test-result dl>div { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 8px; +} + +.flow-shared-test-result dt { + color: var(--muted); +} + +.flow-shared-test-result dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} + +.flow-shared-test-result>small { + color: var(--muted); +} + +#flowTemplateDialog { + width: min(1180px, calc(100vw - 32px)); +} + +.flow-template-toolbar { + margin-top: 14px; +} + +.flow-template-toolbar label { + display: grid; + gap: 6px; +} + +.flow-template-toolbar input { + width: 100%; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--input-bg); + color: var(--text); + padding: 10px 12px; +} + +.flow-template-browser { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(320px, .85fr); + gap: 18px; + align-items: start; +} + +.flow-template-catalog { + min-width: 0; +} + +.flow-template-preview { + position: sticky; + top: 0; + display: grid; + gap: 12px; + margin-top: 14px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface-2); + min-height: 250px; +} + +.flow-template-preview-head { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.flow-template-preview-head h3 { + margin: 3px 0 0; +} + +.flow-template-preview>p { + margin: 0; + color: var(--muted); + line-height: 1.45; +} + +.flow-template-preview-stats { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.flow-template-preview-stats span { + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-size: .78rem; +} + +.flow-template-preview-canvas { + position: relative; + height: 250px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); +} + +.flow-template-preview-canvas svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.flow-template-preview-canvas line { + stroke: var(--line-strong); + stroke-width: .7; + vector-effect: non-scaling-stroke; +} + +.flow-template-preview-node { + position: absolute; + transform: translate(-50%, -50%); + width: 104px; + max-width: 28%; + padding: 6px 7px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface-2); + box-shadow: 0 3px 10px rgba(0, 0, 0, .12); + font-size: .68rem; + line-height: 1.2; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.flow-template-preview-node-sensor { + border-top: 3px solid var(--blue, var(--accent)); +} + +.flow-template-preview-node-logic { + border-top: 3px solid var(--accent); +} + +.flow-template-preview-node-action { + border-top: 3px solid var(--teal); +} + +.flow-template-requirements { + display: grid; + gap: 8px; +} + +.flow-template-requirements>div { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.flow-template-requirement { + padding: 5px 8px; + border: 1px solid var(--line); + border-radius: 999px; + font-size: .75rem; +} + +.flow-template-requirement.ok { + background: var(--surface); +} + +.flow-template-requirement.missing { + border-color: var(--danger, var(--line-strong)); +} + +.flow-template-requirement.info { + color: var(--muted); +} + +.flow-template-card { + position: relative; + display: grid; + min-height: 0; + padding: 0; + overflow: hidden; +} + +.flow-template-card.selected { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-border); +} + +.flow-template-card-main { + display: grid; + gap: 7px; + width: 100%; + min-height: 118px; + border: 0; + background: transparent; + color: var(--text); + padding: 14px 42px 14px 14px; + text-align: left; + align-content: start; + cursor: pointer; +} + +.flow-template-card-main span { + color: var(--muted); + font-size: .86rem; + line-height: 1.4; +} + +.flow-template-favorite { + width: 26px; + height: 26px; + display: grid; + place-items: center; + border: 0; + padding: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 1.22rem; + line-height: 1; + opacity: .72; + transition: color .15s ease, opacity .15s ease, transform .15s ease; +} + +.flow-template-card>.flow-template-favorite { + position: absolute; + right: 8px; + top: 8px; +} + +.flow-template-favorite:hover { + color: var(--text); + background: transparent; + opacity: 1; + transform: scale(1.08); +} + +.flow-template-favorite:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + opacity: 1; +} + +.flow-template-favorite.active { + color: var(--accent); + background: transparent; + opacity: 1; +} + +.flow-template-favorite.active:hover { + background: transparent; +} + +.flow-template-favorite:active { + transform: scale(.92); +} + +.flow-template-preview .form-actions { + margin-top: 0; + padding-top: 10px; + border-top: 1px solid var(--line); +} + +@media (max-width:900px) { + .flow-template-browser { + grid-template-columns: 1fr; + } + + .flow-template-preview { + position: static; + } +} + +@media (max-width:760px) { + .flow-shared-test-panel { + grid-template-columns: 1fr; + } + + .flow-shared-test-panel>button { + width: 100%; + } + + .flow-shared-test-result { + grid-column: 1; + } + + .flow-shared-test-result dl>div { + grid-template-columns: 1fr; + gap: 2px; + } + + .flow-template-preview-canvas { + height: 220px; + } +} + +.zone-hysteresis-row { + align-items: start; +} + +.flow-editor .flow-node-current { + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent); + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.flow-editor .flow-node-current span { + color: var(--muted); + font-size: .68rem; + white-space: nowrap; +} + +.flow-editor .flow-node-current strong { + min-width: 0; + max-width: 72px; + color: var(--text); + font-size: .76rem; + font-weight: 700; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.flow-palette-group { + padding-left: 8px; + border-left: 3px solid var(--line-strong); +} + +.flow-palette-group button { + min-height: 30px; + padding: 6px 8px; + border-radius: 8px; + font-size: .76rem; + line-height: 1.15; +} + +.flow-palette-trigger { + border-left-color: var(--warning); +} + +.flow-palette-time { + border-left-color: var(--blue); +} + +.flow-palette-timeop { + border-left-color: var(--info); +} + +.flow-palette-sensor { + border-left-color: var(--teal); +} + +.flow-palette-logic { + border-left-color: var(--purple); +} + +.flow-palette-action { + border-left-color: var(--orange); +} + +.flow-palette-haaction { + border-left-color: var(--accent); +} + +.flow-editor .flow-node-trigger { + border-top: 3px solid var(--warning); +} + +.flow-editor .flow-node-timeop { + border-top: 3px solid var(--info); +} + +.flow-editor .flow-node-haaction { + border-top: 3px solid var(--accent); +} + +.flow-template-preview-node-trigger { + border-top: 3px solid var(--warning); +} + +.flow-template-preview-node-time, +.flow-template-preview-node-timeop { + border-top: 3px solid var(--blue); +} + +.flow-template-preview-node-haaction { + border-top: 3px solid var(--accent); +} + + +.flow-enabled { + white-space: nowrap; +} + +.flow-enabled input { + width: 16px; + min-height: 16px; + accent-color: var(--accent); +} + +.flow-mobile-actions-button { + display: none; +} + +.flow-inspector-head-actions { + display: flex; + align-items: center; + gap: 7px; +} + +.flow-inspector-close { + display: none; + width: 34px; + height: 34px; + padding: 0; + font-size: 1.1rem; +} + +.sensor-alias-entity { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} + +.sensor-alias-entity>.mono { + min-width: 0; + overflow: hidden; + color: var(--muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sensor-source-badges { + display: flex; + flex: 0 0 auto; + gap: 4px; +} + +.sensor-source-badge { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 2px 6px; + border: 1px solid var(--line); + border-radius: 999px; + font-size: .64rem; + font-weight: 750; + line-height: 1; + white-space: nowrap; +} + +.sensor-source-badge.metrics { + border-color: var(--accent-border); + background: var(--accent-soft); + color: var(--accent); +} + +.sensor-source-badge.flow { + background: var(--surface); + color: var(--muted); +} + +.device-card, +.panel, +.list-card { + border-radius: var(--radius); +} + +button { + border-radius: 11px; +} + +.metric { + border-radius: 14px; +} + +.lead { + max-width: 780px; + line-height: 1.55; +} + +.section-heading h1 { + margin-bottom: 0; +} + +.automation-tabs { + display: flex; + align-items: center; + gap: 5px; + width: max-content; + max-width: 100%; + margin: -2px 0 14px; + padding: 5px; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface); + scrollbar-width: none; +} + +.automation-tabs::-webkit-scrollbar { + display: none; +} + +.automation-tabs button { + flex: 0 0 auto; + min-height: 38px; + padding: 8px 12px; + color: var(--muted); + background: transparent; + font-size: 12px; + font-weight: 750; +} + +.automation-tabs button.active { + color: var(--accent); + background: var(--accent-soft); + box-shadow: inset 0 0 0 1px var(--accent-border); +} + +.mobile-nav-only { + display: block; +} + +.desktop-nav-only { + display: none; +} + +.nav-section-label, +.menu-section-label { + color: var(--muted); + font-size: 9px; + font-weight: 800; + letter-spacing: .09em; + text-transform: uppercase; +} + +.menu-section-label { + display: block; + padding: 14px 9px 5px; +} + +.more-menu-list .menu-section-label:first-child { + padding-top: 5px; +} + +.card-menu { + align-items: center; +} + +.card-primary-action { + font-weight: 700; +} + +.card-overflow { + position: relative; + flex: 0 0 auto; +} + +.card-overflow summary { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border: 1px solid var(--line); + border-radius: 10px; + color: var(--muted); + background: var(--surface-muted); + cursor: pointer; + list-style: none; + font-size: 16px; + font-weight: 800; + letter-spacing: .08em; +} + +.card-overflow summary::-webkit-details-marker { + display: none; +} + +.card-overflow[open] summary { + color: var(--text); + border-color: var(--line-strong); + background: var(--surface-2); +} + +.card-overflow-menu { + position: absolute; + z-index: 25; + right: 0; + bottom: calc(100% + 7px); + min-width: 150px; + padding: 6px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface); + box-shadow: 0 14px 34px rgba(0, 0, 0, .28); +} + +.card-overflow-menu button { + width: 100%; + text-align: left; +} + +.card-overflow-menu .danger { + background: var(--danger-soft); +} + +.flow-save-status { + display: inline-flex; + align-items: center; + min-height: 24px; + color: var(--muted); + font-size: .72rem; + font-weight: 750; + white-space: nowrap; +} + +.flow-save-status::before { + content: ""; + width: 6px; + height: 6px; + margin-right: 6px; + border-radius: 50%; + background: var(--accent); +} + +.flow-save-status.is-dirty { + color: var(--warning); +} + +.flow-save-status.is-dirty::before { + background: var(--warning); +} + +.flow-canvas { + position: relative; + width: 2400px; + height: 1500px; + transform-origin: 0 0; +} + +.flow-canvas .flow-nodes, +.flow-canvas .flow-edges { + width: 2400px; + height: 1500px; +} + +.flow-workspace-toolbar { + justify-content: space-between; +} + +.flow-workspace-primary-actions, +.flow-zoom-controls { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.flow-add-block-button { + display: none; + align-items: center; + justify-content: center; + gap: 5px; +} + +.flow-zoom-controls .icon-button { + width: 32px; + height: 32px; + min-height: 32px; + padding: 0; + font-size: 17px; +} + +.flow-zoom-controls>span { + min-width: 44px; + color: var(--muted); + font-size: .72rem; + font-weight: 800; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.flow-fit-button { + min-height: 32px; + padding: 6px 9px; + font-size: .75rem; +} + +.flow-inspector-expand { + display: none; + width: 34px; + height: 34px; + padding: 0; +} + +.flow-block-dialog { + width: min(620px, calc(100% - 24px)); +} + +.flow-block-dialog .dialog-head { + padding: 16px 16px 8px; +} + +.flow-block-search { + padding: 4px 16px 10px; +} + +.flow-block-library { + display: grid; + gap: 14px; + padding: 0 16px 18px; +} + +.flow-block-library-group { + padding-left: 10px; + border-left: 3px solid var(--line-strong); +} + +.flow-block-library-group.flow-palette-trigger { + border-left-color: var(--warning); +} + +.flow-block-library-group.flow-palette-time { + border-left-color: var(--blue); +} + +.flow-block-library-group.flow-palette-timeop { + border-left-color: var(--info); +} + +.flow-block-library-group.flow-palette-sensor { + border-left-color: var(--teal); +} + +.flow-block-library-group.flow-palette-logic { + border-left-color: var(--purple); +} + +.flow-block-library-group.flow-palette-action { + border-left-color: var(--orange); +} + +.flow-block-library-group.flow-palette-haaction { + border-left-color: var(--accent); +} + +.flow-block-library-group h3 { + margin: 0 0 7px; + color: var(--muted); + font-size: .72rem; + letter-spacing: .07em; + text-transform: uppercase; +} + +.flow-block-library-group>div { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.flow-block-library-group button { + min-height: 42px; + text-align: left; +} + +.flow-editor .flow-port { + width: 16px; + height: 16px; + margin-top: -8px; +} + +.flow-editor .flow-port-in { + left: -9px; +} + +.flow-editor .flow-port-out { + right: -9px; +} + +.flow-editor .flow-port::after { + content: ""; + position: absolute; + inset: -11px; + border-radius: 50%; +} + +.flow-preview-toggle { + display: none; +} + +.flow-preview-details { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: center; + width: 100%; + min-width: 0; +} + +@media (min-width:900px) { + .mobile-nav-only { + display: none !important; + } + + .desktop-nav-only { + display: flex; + } + + .bottom-nav { + top: calc(var(--topbar-height, 72px) + 14px); + bottom: 14px; + left: max(14px, calc((100vw - 1500px)/2)); + display: flex; + flex-direction: column; + gap: 2px; + width: 190px; + max-height: calc(100dvh - var(--topbar-height, 72px) - 28px); + padding: 10px; + overflow-y: auto; + border-radius: 16px; + scrollbar-width: thin; + } + + .bottom-nav .nav-section-label { + padding: 10px 9px 4px; + } + + .bottom-nav .nav-section-label:first-child { + padding-top: 3px; + } + + .bottom-nav button { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + min-height: 39px; + padding: 8px 10px; + border-radius: 10px; + text-align: left; + } + + .bottom-nav button span { + display: grid; + place-items: center; + flex: 0 0 24px; + width: 24px; + font-size: 17px; + } + + .bottom-nav button b { + min-width: 0; + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .bottom-nav button.active { + background: var(--accent-soft); + } + + main { + width: min(1500px, 100%); + padding-left: 222px; + padding-right: 24px; + } +} + +@media (max-width:760px) { + main { + padding: 18px 12px calc(94px + env(safe-area-inset-bottom)); + } + + .desktop-nav-only { + display: none !important; + } + + .mobile-nav-only { + display: grid; + } + + .bottom-nav { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + + .bottom-nav button { + min-height: 48px; + } + + .bottom-nav button span { + font-size: 19px; + } + + .section-heading { + gap: 12px; + margin: 4px 0 14px; + } + + .section-heading h1 { + font-size: 26px; + } + + .section-heading>button, + .section-heading>.section-actions, + .section-heading>.section-heading-actions { + width: 100%; + } + + .section-heading>button { + min-height: 44px; + } + + .flow-page-actions, + .section-heading-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; + } + + .flow-page-actions button, + .section-heading-actions button { + width: 100%; + min-height: 44px; + } + + .automation-tabs { + width: 100%; + margin-bottom: 12px; + scroll-snap-type: x proximity; + } + + .automation-tabs button { + flex: 1 0 auto; + scroll-snap-align: start; + } + + .device-card, + .panel, + .list-card { + border-radius: 14px; + } + + .device-card { + padding: 15px; + } + + .card-footer { + gap: 9px; + } + + .card-menu { + width: 100%; + justify-content: flex-end; + } + + .card-menu>button { + min-height: 40px; + } + + .card-primary-action { + flex: 1 1 auto; + } + + .card-overflow summary { + width: 40px; + height: 40px; + } + + dialog:not(.auth-dialog) { + inset: auto 0 0; + width: 100%; + max-width: none; + max-height: 88dvh; + margin: 0; + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 18px 18px 0 0; + overscroll-behavior: contain; + } + + dialog:not(.auth-dialog) .dialog-head { + position: sticky; + top: 0; + z-index: 5; + padding-top: 14px; + background: var(--dialog-bg); + } + + dialog:not(.auth-dialog) .dialog-head::before { + content: ""; + position: absolute; + top: 6px; + left: 50%; + width: 38px; + height: 4px; + border-radius: 999px; + background: var(--line-strong); + transform: translateX(-50%); + } + + .dialog-form { + padding: 14px 14px calc(14px + env(safe-area-inset-bottom)); + } + + .dialog-form .form-actions { + position: sticky; + bottom: 0; + z-index: 4; + margin: 6px -14px -14px; + padding: 10px 14px calc(10px + env(safe-area-inset-bottom)); + border-top: 1px solid var(--line); + background: color-mix(in srgb, var(--dialog-bg) 96%, transparent); + backdrop-filter: blur(12px); + } + + .dialog-form .form-actions>button { + flex: 1 1 0; + min-height: 44px; + } + + .history-tabs { + margin-inline: -12px; + padding-inline: 12px; + scroll-padding-inline: 12px; + scroll-snap-type: x proximity; + } + + .history-tabs button { + min-height: 40px; + scroll-snap-align: start; + } + + .history-toolbar-panel { + padding: 12px; + } + + .settings-mode-tabs { + position: sticky; + top: calc(var(--topbar-height, 64px) + 6px); + z-index: 8; + margin-inline: -4px; + background: color-mix(in srgb, var(--bg) 94%, transparent); + backdrop-filter: blur(12px); + } + + .settings-save-bar { + bottom: calc(74px + env(safe-area-inset-bottom)); + margin-inline: -4px; + padding: 10px; + } + + .settings-save-bar span { + display: none; + } + + .settings-save-bar button { + width: 100%; + min-height: 44px; + } + + .flow-editor-bar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto; + align-items: center; + width: 100%; + gap: 6px 8px; + padding: 7px 8px; + overflow: hidden; + } + + .flow-editor-title { + grid-column: 1; + grid-row: 1; + width: 100%; + min-width: 0; + gap: 8px; + } + + .flow-editor-title>div { + width: 100%; + min-width: 0; + } + + .flow-editor-title .eyebrow { + display: none; + } + + .flow-name-wrap { + display: flex; + width: 100%; + min-width: 0; + gap: 6px; + } + + .flow-name-view { + min-width: 0; + flex: 1 1 auto; + } + + .flow-name-text { + min-width: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .flow-editor-title input { + width: 100%; + min-width: 0; + flex: 1 1 auto; + font-size: 1rem; + } + + .flow-editor-bar>.flow-save-button { + grid-column: 2; + grid-row: 1; + align-self: stretch; + min-width: 0; + min-height: 38px; + padding-inline: 11px; + white-space: nowrap; + } + + .flow-editor-actions { + grid-column: 1 / -1; + grid-row: 2; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-width: 0; + max-width: none; + gap: 8px; + overflow: hidden; + } + + .flow-editor-actions .flow-desktop-action, + .flow-save-status, + .flow-compile-status { + display: none; + } + + .flow-editor-actions .flow-enabled { + display: flex; + flex: 0 1 auto; + width: auto; + min-width: 0; + min-height: 34px; + justify-content: flex-start; + gap: 7px; + padding: 0 4px; + font-size: .78rem; + } + + .flow-editor-actions .flow-enabled span { + display: inline; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .flow-editor-actions .flow-mobile-actions-button { + display: inline-flex; + flex: 0 0 auto; + width: auto; + min-width: 96px; + min-height: 34px; + margin-left: auto; + padding: 6px 12px; + justify-content: center; + } + + .flow-editor-body { + position: relative; + display: flex; + flex: 1 1 auto; + flex-direction: column; + height: 0; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + .flow-workspace-wrap { + order: 1; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + } + + .flow-workspace-toolbar { + display: flex; + flex: 0 0 auto; + width: 100%; + min-width: 0; + min-height: 48px; + padding: 6px 8px; + gap: 7px; + justify-content: space-between; + overflow: hidden; + } + + .flow-workspace-primary-actions { + display: flex; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + } + + .flow-add-block-button { + display: inline-flex; + min-width: 0; + max-width: 128px; + min-height: 36px; + padding: 7px 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .flow-selection-action, + #flowSelectionCount { + display: none; + } + + .flow-zoom-controls { + display: flex; + flex: 0 0 auto; + margin-left: auto; + gap: 4px; + } + + .flow-zoom-controls .icon-button { + width: 34px; + height: 34px; + min-height: 34px; + } + + .flow-zoom-controls>span { + display: none; + } + + .flow-fit-button { + width: 34px; + min-width: 34px; + min-height: 34px; + padding: 0; + font-size: 0; + } + + .flow-fit-button::before { + content: "⊡"; + font-size: 17px; + line-height: 1; + } + + .flow-palette { + display: none !important; + } + + .flow-workspace { + height: 100%; + min-width: 0; + min-height: 0 !important; + max-height: none; + overflow: auto; + overscroll-behavior: contain; + touch-action: pan-x pan-y; + } + + .flow-natural-preview { + display: block; + flex: 0 0 auto; + min-height: 0; + max-height: none; + padding: 0; + overflow: hidden; + } + + .flow-preview-toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: 38px; + padding: 7px 10px; + border: 0; + border-radius: 0; + background: var(--surface); + color: var(--text); + text-align: left; + } + + .flow-preview-toggle strong { + font-size: .78rem; + } + + .flow-preview-toggle-icon { + color: var(--muted); + font-size: 1rem; + transition: transform .15s ease; + } + + .flow-preview-details { + display: none; + flex-direction: column; + align-items: stretch; + gap: 7px; + max-height: 32dvh; + padding: 8px 10px 10px; + overflow: auto; + } + + .flow-natural-preview.is-expanded .flow-preview-details { + display: flex; + } + + .flow-natural-preview.is-expanded .flow-preview-toggle { + border-bottom: 1px solid var(--line); + } + + .flow-natural-preview.is-expanded .flow-preview-toggle-icon { + transform: rotate(180deg); + } + + .flow-preview-item { + gap: 1px; + } + + .flow-natural-preview strong { + font-size: .78rem; + } + + .flow-natural-preview span { + font-size: .76rem; + } + + .flow-inspector { + display: none; + position: fixed; + inset: auto 0 0; + width: 100%; + max-height: 56dvh; + padding: 22px 14px calc(14px + env(safe-area-inset-bottom)); + overflow: auto; + border: 0; + border-top: 1px solid var(--line-strong); + border-radius: 18px 18px 0 0; + z-index: 95; + box-shadow: 0 -14px 34px rgba(0, 0, 0, .3); + transition: max-height .18s ease; + } + + .flow-inspector.has-selection { + display: block; + } + + .flow-inspector::before { + content: ""; + position: absolute; + top: 7px; + left: 50%; + width: 38px; + height: 4px; + border-radius: 999px; + background: var(--line-strong); + transform: translateX(-50%); + } + + .flow-inspector.is-expanded { + max-height: 90dvh; + } + + .flow-inspector-close, + .flow-inspector-expand { + display: inline-grid; + place-items: center; + } + + .flow-inspector-head { + position: sticky; + top: -22px; + z-index: 4; + margin: -8px -2px 14px; + padding: 10px 2px 8px; + background: var(--surface); + } + + .flow-inspector-delete { + padding-inline: 10px; + } + + .flow-inspector .two { + grid-template-columns: 1fr; + } + + .flow-inspector label { + font-size: .78rem; + } + + .flow-inspector input, + .flow-inspector select, + .flow-inspector textarea { + min-height: 44px; + } + + .flow-block-dialog { + max-height: 82dvh !important; + } + + .flow-block-library-group>div { + grid-template-columns: 1fr; + } + + .flow-block-library-group button { + min-height: 46px; + } + + .sensor-alias-row { + grid-template-columns: minmax(0, 1fr) 34px; + } + + .sensor-alias-entity { + grid-column: 1 / -1; + } + + .sensor-alias-row input { + grid-column: 1; + } + + .sensor-alias-clear { + grid-column: 2; + } + + .settings-block { + gap: 14px; + } + + .settings-block-head h3 { + font-size: 16px; + } + + .settings-block-head p, + .field-note { + line-height: 1.45; + } } @media (max-width:420px) { - .section-heading h1 { font-size:24px; } - .flow-editor-actions .flow-mobile-actions-button { min-width:88px; padding-inline:10px; } + .section-heading h1 { + font-size: 24px; + } + + .flow-editor-actions .flow-mobile-actions-button { + min-width: 88px; + padding-inline: 10px; + } } -/* Readability and form consistency fixes found during the wider UX pass. */ -textarea { font:inherit; } textarea { - width:100%; min-height:96px; resize:vertical; border:1px solid var(--line); border-radius:12px; - outline:0; padding:10px 12px; color:var(--text); background:var(--input-bg); line-height:1.45; + font: inherit; +} + +textarea { + width: 100%; + min-height: 96px; + resize: vertical; + border: 1px solid var(--line); + border-radius: 12px; + outline: 0; + padding: 10px 12px; + color: var(--text); + background: var(--input-bg); + line-height: 1.45; +} + +textarea:focus { + border-color: var(--accent); + outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); + outline-offset: 1px; +} + +.flow-empty-hint button { + pointer-events: auto; + margin-top: 7px; + justify-self: center; +} + +#controlPlan .plan-card>p { + font-size: 11.5px; + line-height: 1.45; +} + +#controlPlan .plan-events li { + font-size: 10.5px; + line-height: 1.4; +} + +#controlPlan .plan-rule-summary strong { + font-size: 11px; +} + +#controlPlan .plan-rule-summary small { + font-size: 10px; +} + +#controlPlan .plan-card-head .eyebrow { + font-size: 9px; } -textarea:focus { border-color:var(--accent); outline:2px solid color-mix(in srgb,var(--accent) 18%,transparent); outline-offset:1px; } -.flow-empty-hint button { pointer-events:auto; margin-top:7px; justify-self:center; } -#controlPlan .plan-card>p { font-size:11.5px; line-height:1.45; } -#controlPlan .plan-events li { font-size:10.5px; line-height:1.4; } -#controlPlan .plan-rule-summary strong { font-size:11px; } -#controlPlan .plan-rule-summary small { font-size:10px; } -#controlPlan .plan-card-head .eyebrow { font-size:9px; } @media (prefers-reduced-motion:reduce) { - *,*::before,*::after { scroll-behavior:auto !important; animation-duration:.01ms !important; animation-iteration-count:1 !important; transition-duration:.01ms !important; } + + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + } +} + +.flow-card[data-flow-card-id] { + cursor: pointer; +} + +.flow-card[data-flow-card-id]:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 2px; +} + +.flow-card[data-flow-card-id] button, +.flow-card[data-flow-card-id] details { + cursor: pointer; +} + +.flow-generated-card { + border-left: 3px solid var(--accent-border); +} + +.flow-generated-card .card-footer>small { + color: var(--accent); } -.flow-card[data-flow-card-id] { cursor:pointer; } -.flow-card[data-flow-card-id]:focus-visible { outline:2px solid color-mix(in srgb,var(--accent) 55%,transparent); outline-offset:2px; } -.flow-card[data-flow-card-id] button,.flow-card[data-flow-card-id] details { cursor:pointer; } -.flow-generated-card { border-left:3px solid var(--accent-border); } -.flow-generated-card .card-footer > small { color:var(--accent); } -/* 0.12.0 production layout guards and in-app chart preview. */ -html,body { max-width:100%; overflow-x:clip; } -main,.view { min-width:0; max-width:100%; } -.flow-info-panel,.flow-info-panel > div { min-width:0; } -.flow-info-panel > .badge { flex:0 1 auto; max-width:100%; white-space:normal; overflow-wrap:anywhere; text-align:center; } +html, +body { + max-width: 100%; + overflow-x: clip; +} -body.flow-editor-open { width:100%; height:100vh; height:100dvh; overflow:hidden; overscroll-behavior:none; } -.flow-editor { width:100vw; width:100dvw; height:100vh; height:100dvh; max-width:100%; overflow:hidden; } -.flow-editor-bar,.flow-editor-title,.flow-editor-actions,.flow-editor-body,.flow-workspace-wrap,.flow-workspace-toolbar { min-width:0; max-width:100%; } +main, +.view { + min-width: 0; + max-width: 100%; +} + +.flow-info-panel, +.flow-info-panel>div { + min-width: 0; +} + +.flow-info-panel>.badge { + flex: 0 1 auto; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + text-align: center; +} + +body.flow-editor-open { + width: 100%; + height: 100vh; + height: 100dvh; + overflow: hidden; + overscroll-behavior: none; +} + +.flow-editor { + width: 100vw; + width: 100dvw; + height: 100vh; + height: 100dvh; + max-width: 100%; + overflow: hidden; +} + +.flow-editor-bar, +.flow-editor-title, +.flow-editor-actions, +.flow-editor-body, +.flow-workspace-wrap, +.flow-workspace-toolbar { + min-width: 0; + max-width: 100%; +} + +.chart-title-row>div:first-child { + min-width: 0; +} + +.chart-title-actions { + display: flex; + align-items: center; + gap: 7px; + flex: 0 0 auto; +} + +.chart-fullscreen-button { + min-width: 34px; + height: 32px; + padding: 0 9px; + border: 1px solid var(--line); + background: var(--surface-muted); + color: var(--text-soft); + font-size: 16px; + line-height: 1; +} + +.chart-fullscreen-button:hover { + background: var(--surface-2); +} + +body.chart-fullscreen-open { + overflow: hidden; +} + +body.chart-fullscreen-open::before { + content: ""; + position: fixed; + inset: 0; + z-index: 139; + background: rgba(0, 0, 0, .68); + backdrop-filter: blur(3px); +} -.chart-title-row > div:first-child { min-width:0; } -.chart-title-actions { display:flex; align-items:center; gap:7px; flex:0 0 auto; } -.chart-fullscreen-button { min-width:34px; height:32px; padding:0 9px; border:1px solid var(--line); background:var(--surface-muted); color:var(--text-soft); font-size:16px; line-height:1; } -.chart-fullscreen-button:hover { background:var(--surface-2); } -body.chart-fullscreen-open { overflow:hidden; } -body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-index:139; background:rgba(0,0,0,.68); backdrop-filter:blur(3px); } .history-chart-card.chart-fullscreen-fallback { - position:fixed; left:50%; top:50%; z-index:140; - width:min(1200px,calc(100vw - 28px)); height:min(820px,calc(100dvh - 28px)); max-width:none; - margin:0; padding:14px; transform:translate(-50%,-50%); - display:grid; grid-template-rows:auto minmax(0,1fr) auto; gap:8px; overflow:hidden; - border:1px solid var(--line-strong); border-radius:16px; background:var(--surface); - box-shadow:0 28px 90px rgba(0,0,0,.58); + position: fixed; + left: 50%; + top: 50%; + z-index: 140; + width: min(1200px, calc(100vw - 28px)); + height: min(820px, calc(100dvh - 28px)); + max-width: none; + margin: 0; + padding: 14px; + transform: translate(-50%, -50%); + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 8px; + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: 16px; + background: var(--surface); + box-shadow: 0 28px 90px rgba(0, 0, 0, .58); } -.history-chart-card.chart-fullscreen-fallback .chart-title-row { margin:0; } + +.history-chart-card.chart-fullscreen-fallback .chart-title-row { + margin: 0; +} + .history-chart-card.chart-fullscreen-fallback .chart-title-row p, -.history-chart-card.chart-fullscreen-fallback .chart-hover-hint { display:none; } -.history-chart-card.chart-fullscreen-fallback .chart-wrap { height:100%; min-height:0; overflow:auto; border:1px solid var(--line); border-radius:10px; background:var(--surface-muted); } -.history-chart-card.chart-fullscreen-fallback .legend { max-height:88px; margin:0; overflow:auto; } +.history-chart-card.chart-fullscreen-fallback .chart-hover-hint { + display: none; +} + +.history-chart-card.chart-fullscreen-fallback .chart-wrap { + height: 100%; + min-height: 0; + overflow: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-muted); +} + +.history-chart-card.chart-fullscreen-fallback .legend { + max-height: 88px; + margin: 0; + overflow: auto; +} @media (max-width:760px) { - body:not(.flow-editor-open) .bottom-nav { position:fixed; right:0; bottom:0; left:0; z-index:60; max-width:100vw; overflow-x:hidden; } - .flow-info-panel { flex-direction:column; align-items:flex-start; gap:12px; padding:16px; overflow:hidden; } - .flow-info-panel > div { width:100%; } - .flow-info-panel > .badge { align-self:flex-start; text-align:left; } - .flow-shared-settings-link { width:100%; max-width:100%; white-space:normal; text-align:left; } - .automation-tabs { width:100%; min-width:0; } + body:not(.flow-editor-open) .bottom-nav { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 60; + max-width: 100vw; + overflow-x: hidden; + } - .chart-title-row { gap:8px; } - .chart-title-actions { width:100%; justify-content:flex-end; } - .chart-fullscreen-button { min-height:36px; height:36px; } - .history-chart-card.chart-fullscreen-fallback { width:calc(100vw - 12px); height:calc(100dvh - 18px); padding:9px; border-radius:13px; } - .history-chart-card.chart-fullscreen-fallback .chart-title-row { flex-direction:row; align-items:center; gap:8px; } - .history-chart-card.chart-fullscreen-fallback .chart-title-row > div:first-child { flex:1 1 auto; min-width:0; } - .history-chart-card.chart-fullscreen-fallback .chart-title-row h3 { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } - .history-chart-card.chart-fullscreen-fallback .chart-title-actions { width:auto; flex:0 0 auto; } - .history-chart-card.chart-fullscreen-fallback .legend { max-height:70px; } + .flow-info-panel { + flex-direction: column; + align-items: flex-start; + gap: 12px; + padding: 16px; + overflow: hidden; + } + + .flow-info-panel>div { + width: 100%; + } + + .flow-info-panel>.badge { + align-self: flex-start; + text-align: left; + } + + .flow-shared-settings-link { + width: 100%; + max-width: 100%; + white-space: normal; + text-align: left; + } + + .automation-tabs { + width: 100%; + min-width: 0; + } + + .chart-title-row { + gap: 8px; + } + + .chart-title-actions { + width: 100%; + justify-content: flex-end; + } + + .chart-fullscreen-button { + min-height: 36px; + height: 36px; + } + + .history-chart-card.chart-fullscreen-fallback { + width: calc(100vw - 12px); + height: calc(100dvh - 18px); + padding: 9px; + border-radius: 13px; + } + + .history-chart-card.chart-fullscreen-fallback .chart-title-row { + flex-direction: row; + align-items: center; + gap: 8px; + } + + .history-chart-card.chart-fullscreen-fallback .chart-title-row>div:first-child { + flex: 1 1 auto; + min-width: 0; + } + + .history-chart-card.chart-fullscreen-fallback .chart-title-row h3 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .history-chart-card.chart-fullscreen-fallback .chart-title-actions { + width: auto; + flex: 0 0 auto; + } + + .history-chart-card.chart-fullscreen-fallback .legend { + max-height: 70px; + } } @media (max-width:560px) { - .chart-title-actions { gap:5px; } - .chart-zoom-controls button { height:36px; } + .chart-title-actions { + gap: 5px; + } + + .chart-zoom-controls button { + height: 36px; + } } -/* Manual unit control and technical device diagnostics. */ .manual-zone-blocked { border-color: color-mix(in srgb, var(--warning) 62%, var(--line)); } @@ -6867,7 +9060,7 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde gap: 8px; } -.technical-device-grid > div { +.technical-device-grid>div { display: grid; min-width: 0; gap: 3px; @@ -6930,7 +9123,7 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde padding-top: 2px; } -.technical-device-actions > button { +.technical-device-actions>button { min-height: 36px; padding: 7px 10px; background: transparent; @@ -6949,7 +9142,7 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde gap: 10px; } -.ping-toolbar > label:first-child { +.ping-toolbar>label:first-child { display: grid; gap: 5px; } @@ -6988,7 +9181,7 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde gap: 10px; } -.ping-live-head > div { +.ping-live-head>div { min-width: 0; } @@ -7071,7 +9264,7 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde gap: 6px; } -.ping-stat-grid > div { +.ping-stat-grid>div { display: grid; gap: 2px; min-width: 0; @@ -7102,18 +9295,39 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde } @media (max-width: 620px) { - .technical-device-grid { grid-template-columns: 1fr; } - .technical-device-head { align-items: center; } - .device-ping-button { min-width: 76px; } - .ping-toolbar { grid-template-columns: 1fr auto; } - .ping-toolbar > label:first-child { grid-column: 1 / -1; } - .ping-all-toggle { justify-self: start; } - #pingToggleButton { justify-self: end; } - .ping-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .technical-device-grid { + grid-template-columns: 1fr; + } + + .technical-device-head { + align-items: center; + } + + .device-ping-button { + min-width: 76px; + } + + .ping-toolbar { + grid-template-columns: 1fr auto; + } + + .ping-toolbar>label:first-child { + grid-column: 1 / -1; + } + + .ping-all-toggle { + justify-self: start; + } + + #pingToggleButton { + justify-self: end; + } + + .ping-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } - -/* 0.12.0 diagnostics, manual-control safety and technical connection checks. */ .manual-device-problem { display: grid; gap: 4px; @@ -7123,10 +9337,24 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde border-radius: 10px; background: var(--surface-2); } -.manual-device-problem.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); } -.manual-device-problem.warning { border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); } -.manual-device-problem strong { font-size: .82rem; } -.manual-device-problem small { color: var(--muted); overflow-wrap: anywhere; } + +.manual-device-problem.error { + border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); +} + +.manual-device-problem.warning { + border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); +} + +.manual-device-problem strong { + font-size: .82rem; +} + +.manual-device-problem small { + color: var(--muted); + overflow-wrap: anywhere; +} + .config-check-result { padding: 10px 12px; border: 1px solid var(--border); @@ -7135,14 +9363,25 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde font-weight: 650; overflow-wrap: anywhere; } -.config-check-result.success { border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); } -.config-check-result.error { border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); } -.technical-config-actions { flex-wrap: wrap; } -@media (max-width: 640px) { - .technical-config-actions button { flex: 1 1 100%; } + +.config-check-result.success { + border-color: color-mix(in srgb, var(--accent) 48%, var(--border)); +} + +.config-check-result.error { + border-color: color-mix(in srgb, var(--danger) 48%, var(--border)); +} + +.technical-config-actions { + flex-wrap: wrap; +} + +@media (max-width: 640px) { + .technical-config-actions button { + flex: 1 1 100%; + } } -/* Ping diagnostics: make packet loss and device unavailability explicit on the chart. */ .ping-sparkline .ping-success-line { fill: none; stroke: var(--accent); @@ -7171,4 +9410,4 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde stroke-width: 2; stroke-linecap: round; vector-effect: non-scaling-stroke; -} +} \ No newline at end of file