use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}}; use axum::{ body::Body, extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}}, http::{header, HeaderMap, HeaderValue, StatusCode}, middleware::{self, Next}, response::{Redirect, Response}, routing::{get, post}, Json, Router, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 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 tower_http::{compression::CompressionLayer, trace::TraceLayer}; use uuid::Uuid; use crate::{ engine, error::AppError, home_assistant, influxdb, notifications, models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading}, protocol::merge_discovered, state::AppState, }; const INDEX_HTML: &str = include_str!("../web/index.html"); const APP_JS: &str = include_str!("../web/app.js"); const THEME_INIT_JS: &str = include_str!("../web/theme-init.js"); const STYLES_CSS: &str = include_str!("../web/styles.css"); const MANIFEST: &str = include_str!("../web/manifest.webmanifest"); const SERVICE_WORKER: &str = include_str!("../web/sw.js"); const FAVICON: &str = include_str!("../web/favicon.svg"); include!(concat!(env!("OUT_DIR"), "/languages.rs")); pub fn router(state: AppState) -> Router { let protected = Router::new() .route("/api/bootstrap", get(bootstrap)) .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/bind", post(bind_device)) .route("/api/devices/:id/poll", post(poll_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/control", post(update_zone_control)) .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/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/readings", get(readings)) .route("/api/history", get(history)) .route("/api/control-plan", get(control_plan)) .route("/api/events", get(events)) .route("/api/events/retention", get(get_event_retention).put(update_event_retention)) .route("/api/settings", get(get_settings).put(update_settings)) .route("/api/settings/export", get(export_settings)) .route("/api/settings/import", post(import_settings)) .route("/api/debug", get(get_debug).put(update_debug)) .route("/api/access-tokens", get(list_access_tokens).post(create_access_token)) .route("/api/access-tokens/:id", axum::routing::delete(delete_access_token)) .route("/api/integrations/home-assistant/test", post(test_home_assistant)) .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_zone_control)) .route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); let app = Router::new() .route("/api/health", get(health)) .route("/ws", get(websocket)) .route("/", get(index)) .route("/index.html", get(index)) .route("/app.js", get(app_js)) .route("/theme-init.js", get(theme_init_js)) .route("/styles.css", get(styles_css)) .route("/manifest.webmanifest", get(manifest)) .route("/sw.js", get(service_worker)) .route("/favicon.svg", get(favicon)) .route("/lang/index.json", get(language_index)) .route("/lang/:file", get(language_file)) .merge(protected) .merge(home_assistant_api) .fallback(index) ; let app = if state.config.base_path.is_empty() { app } else { 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) } })) .nest(&base, app) }; 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)) .with_state(state) } 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; } let method = request.method().clone(); let path = request.uri().path().to_string(); let started = Instant::now(); let response = next.run(request).await; state.broadcast("api.request", json!({ "method": method.as_str(), "path": path, "status": response.status().as_u16(), "duration_ms": started.elapsed().as_millis(), })); response } async fn auth(State(state): State, request: Request, next: Next) -> Result { let expected = state.config.app_token.trim(); if expected.is_empty() { return Ok(next.run(request).await); } let supplied = request_token(&request); if supplied.as_deref() != Some(expected) { return Err(AppError::Unauthorized); } Ok(next.run(request).await) } async fn home_assistant_auth( State(state): State, request: Request, next: Next, ) -> Result { let supplied = request_token(&request).ok_or(AppError::Unauthorized)?; let admin_token = state.config.app_token.trim(); if !admin_token.is_empty() && supplied == admin_token { return Ok(next.run(request).await); } if state.db.api_token_exists(&hash_token(&supplied))? { return Ok(next.run(request).await); } Err(AppError::Unauthorized) } fn request_token(request: &Request) -> Option { 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())) .map(str::to_owned) } fn hash_token(token: &str) -> String { URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes())) } fn generate_access_token() -> String { let mut bytes = [0u8; 32]; let mut rng = OsRng; rng.fill_bytes(&mut bytes); format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes)) } async fn health(State(state): State) -> Json { Json(json!({ "status": "ok", "name": "gree-controller", "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": state.started.elapsed().as_secs(), "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), "time": Utc::now(), })) } async fn bootstrap(State(state): State) -> Result, AppError> { Ok(Json(build_bootstrap(&state).await?)) } async fn build_bootstrap(state: &AppState) -> Result { let settings = state.settings.read().await.clone(); Ok(json!({ "devices": state.db.list_devices()?, "zones": state.db.list_zones()?, "groups": state.db.list_groups()?, "schedules": state.db.list_schedules()?, "automations": state.db.list_automations()?, "access_tokens": state.db.list_api_tokens()?, "settings": public_settings(&settings), "outdoor_temperature": *state.outdoor_temperature.read().await, "system": { "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": state.started.elapsed().as_secs(), "auth_required": !state.config.app_token.trim().is_empty(), "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), } })) } async fn system_info(State(state): State) -> Result, AppError> { let devices = state.db.list_devices()?; Ok(Json(json!({ "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": state.started.elapsed().as_secs(), "database": state.config.database.display().to_string(), "device_count": devices.len(), "online_count": devices.iter().filter(|v| v.online).count(), "simulator_count": devices.iter().filter(|v| v.simulated).count(), "control_ready": state.initial_device_sync_complete.load(Ordering::Acquire), "bind": state.config.bind.to_string(), "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, }))) } async fn discover(State(state): State, Json(request): Json) -> Result, AppError> { let settings = state.settings.read().await.clone(); 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 .map_err(|e| AppError::Device(e.to_string()))?; let mut saved = Vec::new(); let mut new_device_ids = Vec::new(); for item in discovered { let existing = state.db.get_device_by_mac(&item.mac)?; let is_new = existing.is_none(); let mut merged = merge_discovered(existing, item); let _device_guard = state.lock_device_operation(&merged.id).await; // A poll/command may have updated the same known device between discovery and // acquiring its operation lock. Re-merge against the freshest persisted state. if !is_new { if let Some(current) = state.db.get_device(&merged.id)? { merged = merge_discovered(Some(current), merged); } } // Bind right after discovery. GREE modules can have a short bind window; // bind() also refreshes it with a direct scan before the handshake. if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() { match state.gree.bind(&merged).await { Ok(bound) => { merged.key = Some(bound.key); merged.protocol_version = bound.protocol_version; merged.communication_failures = 0; merged.last_error = None; } Err(err) => { 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.db.save_device(&merged)?; 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}))) } 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> { 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()))?; if state.db.get_device_by_mac(&input.mac)?.is_some() { 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(); let device = Device { id: format!("gree-{}", normalized_mac.to_ascii_lowercase()), mac: normalized_mac, name: input.name.trim().to_string(), ip: input.ip, port: input.port, protocol_version: input.protocol_version.min(2), model: String::new(), firmware: String::new(), key: input.key.filter(|v| !v.trim().is_empty()), cid: Some("app".into()), enabled: true, simulated: input.simulated, power: false, mode: "cool".into(), target_temperature: 24.0, fan_speed: 0, swing_vertical: false, swing_horizontal: false, quiet: false, turbo: false, light: true, air: false, xfan: false, health: false, sleep: false, supports_light: None, supports_quiet: None, supports_turbo: None, supports_air: None, supports_xfan: None, supports_health: None, supports_sleep: None, current_temperature: if input.simulated { Some(25.0) } else { None }, outdoor_temperature: None, temperature_sensor_offset: None, online: input.simulated, response_time_ms: if input.simulated { Some(0) } else { None }, last_seen: if input.simulated { Some(now) } else { None }, last_error: None, communication_failures: 0, created_at: now, updated_at: now, }; state.db.save_device(&device)?; state.log("info", "device.created", &format!("Added {}", device.name), json!({"device_id": device.id})); state.broadcast("device.created", serde_json::to_value(&device)?); Ok((StatusCode::CREATED, Json(device))) } 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> { 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; } if let Some(v) = patch.protocol_version { let v = v.min(2); if device.protocol_version != v { device.protocol_version = v; device.key = None; device.supports_light = None; device.supports_quiet = None; device.supports_turbo = None; device.supports_air = None; device.supports_xfan = None; device.supports_health = None; 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; } device.updated_at = Utc::now(); state.db.save_device(&device)?; state.broadcast("device.updated", serde_json::to_value(&device)?); Ok(Json(device)) } async fn delete_device(State(state): State, Path(id): Path) -> Result { 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())); } let removed_zone_ids: std::collections::HashSet = state.db.list_zones()?.into_iter() .filter(|zone| zone.device_id == id) .map(|zone| zone.id) .collect(); 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}"))); } remove_zone_ids_from_groups(&state, &removed_zone_ids)?; 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> { 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()))?; device.key = Some(bound.key); device.protocol_version = bound.protocol_version; device.communication_failures = 0; device.online = true; device.last_seen = Some(Utc::now()); 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})); Ok(Json(device)) } async fn poll_device(State(state): State, Path(id): Path) -> Result, AppError> { Ok(Json(engine::poll_one(&state, &id).await?)) } async fn command_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?)) } async fn command_home_assistant_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) { return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into())); } Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?)) } #[derive(Debug, Deserialize)] struct ZoneInput { name: String, device_id: String, #[serde(default = "yes")] enabled: bool, #[serde(default = "cool")] mode: String, #[serde(default = "yes")] inherit_house_mode: bool, #[serde(default = "setpoint")] setpoint: f64, #[serde(default = "cool_comfort")] cool_comfort_setpoint: f64, #[serde(default = "cool_sleep")] cool_sleep_setpoint: f64, #[serde(default = "cool_away")] cool_away_setpoint: f64, #[serde(default = "heat_comfort")] heat_comfort_setpoint: f64, #[serde(default = "heat_sleep")] heat_sleep_setpoint: f64, #[serde(default = "heat_away")] heat_away_setpoint: f64, #[serde(default = "hysteresis")] hysteresis: f64, #[serde(default = "cycle")] min_on_seconds: u64, #[serde(default = "cycle")] min_off_seconds: u64, #[serde(default = "min_adjust")] min_adjust_seconds: u64, #[serde(default = "standby_offset")] standby_offset_c: f64, #[serde(default = "yes")] smart_fan: bool, #[serde(default = "device_source")] sensor_source: String, #[serde(default)] ha_entity_id: Option, #[serde(default = "external_sensor_weight")] external_sensor_weight: f64, #[serde(default = "max_sensor_difference")] max_sensor_difference: f64, } 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 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 !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("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, 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, 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, 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, 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())); } 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 create_zone(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { input.validate()?; 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)?; let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now()); let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); state.db.save_zone(&zone)?; state.broadcast("zone.created", serde_json::to_value(&zone)?); Ok((StatusCode::CREATED, Json(zone))) } async fn update_zone(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { input.validate()?; let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; 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; let mut zone = input.into_zone(id, existing.created_at); if !device_changed { zone.device_temperature = existing.device_temperature; zone.external_temperature = existing.external_temperature; zone.current_temperature = existing.current_temperature; zone.control_temperature_source = existing.control_temperature_source; zone.active_preset = existing.active_preset; zone.manual_preset = existing.manual_preset; zone.manual_setpoint = existing.manual_setpoint; zone.manual_override_until = existing.manual_override_until; zone.local_thermostat_power = existing.local_thermostat_power; zone.local_thermostat_resume_at = existing.local_thermostat_resume_at; zone.device_manual_override = existing.device_manual_override; zone.device_manual_override_since = existing.device_manual_override_since; zone.device_manual_override_until = existing.device_manual_override_until; zone.device_manual_override_fields = existing.device_manual_override_fields; zone.device_manual_override_baseline = existing.device_manual_override_baseline; zone.effective_mode = existing.effective_mode; zone.effective_setpoint = existing.effective_setpoint; zone.device_setpoint = existing.device_setpoint; zone.demand = existing.demand; zone.demand_since = existing.demand_since; zone.target_alerted_at = existing.target_alerted_at; zone.last_action_at = existing.last_action_at; } 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(&state, &existing.device_id, "zone.device_reassigned").await?; } let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); let power_off_device = !device_changed && existing.enabled && !zone.enabled; state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); if power_off_device { power_off_zone_device(&state, &zone, "zone.disabled").await; } Ok(Json(zone)) } async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch) -> Result { 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 resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false); let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false); // Any thermostat action takes ownership back from a physical/pilot takeover. Local power // is a thermostat state of its own and must never be recorded as device manual 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_local_thermostat { engine::reset_local_thermostat_override(&mut zone); } if let Some(power) = patch.power { zone.local_thermostat_power = Some(power); zone.local_thermostat_resume_at = if power { None } else { Some(Utc::now() + chrono::Duration::minutes(engine::LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)) }; 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. if power && (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) { zone.manual_override_until = None; } } 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())); } let value = (value * 2.0).round() / 2.0; zone.setpoint = value; zone.manual_setpoint = Some(value); zone.effective_setpoint = Some(value); zone.manual_override_until = if zone.local_thermostat_power == Some(true) { None } else { engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) }; } if let Some(value) = patch.mode.as_deref() { match value { "house" | "auto" => zone.inherit_house_mode = true, "cool" | "heat" => { zone.inherit_house_mode = false; zone.mode = value.to_string(); } _ => return Err(AppError::BadRequest("zone mode must be house, cool or heat".into())), } } if let Some(value) = patch.preset.as_deref() { match value { "auto" => { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; } "comfort" | "sleep" | "away" | "custom" => { zone.manual_preset = Some(value.to_string()); zone.manual_setpoint = None; zone.manual_override_until = if zone.local_thermostat_power == Some(true) { None } else { engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()) }; } _ => return Err(AppError::BadRequest("unsupported zone preset".into())), } } if patch.clear_override.unwrap_or(false) { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; } if let Some(value) = patch.enabled { zone.enabled = value; if !value { engine::reset_local_thermostat_override(&mut zone); } } let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false }; let house_mode = state.settings.read().await.house_mode.clone(); engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); if was_enabled && !zone.enabled { power_off_zone_device(state, &zone, "zone.quick_disabled").await; } else if patch.power == Some(false) { if let Err(err) = engine::send_command( 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, "power": false })); } state.wake_zone_control(); } else { state.wake_zone_control(); } state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({ "zone_id": zone.id, "setpoint": zone.setpoint, "manual_setpoint": zone.manual_setpoint, "mode": zone.mode, "inherit_house_mode": zone.inherit_house_mode, "preset": zone.manual_preset, "override_until": zone.manual_override_until, "enabled": zone.enabled, "local_thermostat_power": zone.local_thermostat_power, "local_thermostat_resume_at": zone.local_thermostat_resume_at, "device_manual_override_cleared": device_override_cleared })); 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).await?)) } async fn ensure_device_stopped_for_detach(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(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 })); Ok(()) } 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; } 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, })); } } #[derive(Debug, Deserialize)] struct GroupInput { name: String, #[serde(default)] zone_ids: Vec, #[serde(default)] power_enabled: Option, } fn normalize_group_zone_ids(zone_ids: Vec) -> Vec { let mut values: Vec = zone_ids.into_iter() .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .collect(); values.sort(); values.dedup(); values } fn validate_group_input(state: &AppState, input: &GroupInput) -> Result, AppError> { if input.name.trim().is_empty() { return Err(AppError::BadRequest("group name is required".into())); } let zone_ids = normalize_group_zone_ids(input.zone_ids.clone()); if zone_ids.is_empty() { return Err(AppError::BadRequest("group must contain at least one zone".into())); } for zone_id in &zone_ids { if state.db.get_zone(zone_id)?.is_none() { return Err(AppError::BadRequest(format!("group references missing zone {zone_id}"))); } } Ok(zone_ids) } async fn list_groups(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_groups()?)) } 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> { let zone_ids = validate_group_input(&state, &input)?; let now = Utc::now(); let group = ClimateGroup { id: Uuid::new_v4().to_string(), name: input.name.trim().to_string(), zone_ids, power_enabled: input.power_enabled.unwrap_or(true), created_at: now, updated_at: now, }; state.db.save_group(&group)?; state.broadcast("group.created", serde_json::to_value(&group)?); Ok((StatusCode::CREATED, Json(group))) } async fn update_group(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { 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, name: input.name.trim().to_string(), zone_ids, power_enabled: input.power_enabled.unwrap_or(existing.power_enabled), created_at: existing.created_at, updated_at: Utc::now(), }; state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); Ok(Json(group)) } async fn delete_group(State(state): State, Path(id): Path) -> Result { 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}"))); } state.broadcast("group.deleted", json!({"id": id})); 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() .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) { return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name))); } } Ok(()) } fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet) -> Result<(), AppError> { if zone_ids.is_empty() { return Ok(()); } for mut group in state.db.list_groups()? { 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.is_empty() { state.db.delete_group(&group.id)?; state.broadcast("group.deleted", json!({"id": group.id})); continue; } group.updated_at = Utc::now(); state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); } 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?)) } 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() }; if !matches!(current, "house" | "cool" | "heat") { return "mixed".into(); } if let Some(previous) = value { if previous != current { return "mixed".into(); } } else { value = Some(current); } } value.unwrap_or("mixed").to_string() } fn home_assistant_group_preset(zones: &[&Zone]) -> String { let mut value: Option<&str> = None; for zone in zones { let current = zone.manual_preset.as_deref().unwrap_or("auto"); if !matches!(current, "auto" | "comfort" | "sleep" | "away") { return "mixed".into(); } if let Some(previous) = value { if previous != current { return "mixed".into(); } } else { value = Some(current); } } value.unwrap_or("mixed").to_string() } 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()?; let plan = engine::build_control_plan(&state).await?; let settings = state.settings.read().await.clone(); let mut output = Vec::with_capacity(groups.len()); for group in groups { let members = zones.iter() .filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id)) .collect::>(); 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() .filter(|device| member_device_ids.contains(device.id.as_str()) && device.online) .count(); let current_temperatures = planned_members.iter() .filter_map(|zone| zone.current_temperature) .collect::>(); let current_temperature = if current_temperatures.is_empty() { None } else { Some(current_temperatures.iter().sum::() / current_temperatures.len() as f64) }; let mut next_events = Vec::new(); for zone in &planned_members { for event in &zone.next_events { let mut event = event.clone(); event.label = format!("{}: {}", zone.zone_name, event.label); next_events.push(event); } } next_events.sort_by_key(|event| event.at); next_events.truncate(8); 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, "name": group.name, "zone_ids": group.zone_ids, "zone_names": zone_names, "power_enabled": group.power_enabled, "effective_power": settings.house_power_enabled && group.power_enabled, "mode": home_assistant_group_mode(&members), "preset": home_assistant_group_preset(&members), "house_mode": settings.house_mode, "zone_count": members.len(), "enabled_zones": members.iter().filter(|zone| zone.enabled).count(), "active_zones": planned_members.iter().filter(|zone| zone.effective_enabled).count(), "demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(), "device_count": member_device_ids.len(), "online_devices": online_devices, "current_temperature": current_temperature, "members": member_states, "next_events": next_events, })); } Ok(Json(output)) } async fn update_home_assistant_group_control( State(state): State, Path(id): Path, Json(patch): Json, ) -> Result, AppError> { Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?)) } #[derive(Debug, Deserialize)] struct HouseControlPatch { mode: String } fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> { for mut group in state.db.list_groups()? { if group.power_enabled == power { continue; } group.power_enabled = power; group.updated_at = Utc::now(); state.db.save_group(&group)?; state.broadcast("group.updated", serde_json::to_value(&group)?); } Ok(()) } fn clear_all_local_thermostat_overrides(state: &AppState) -> Result { let mut cleared = 0; for mut zone in state.db.list_zones()? { if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() { continue; } engine::reset_local_thermostat_override(&mut zone); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); cleared += 1; } Ok(cleared) } async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result, AppError> { let mut failed = Vec::new(); let enabled_zone_devices: std::collections::HashSet = if power { state.db.list_zones()?.into_iter() .filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false)) .map(|zone| zone.device_id) .collect() } else { std::collections::HashSet::new() }; for device in state.db.list_devices()? { if !device.enabled { continue; } // Whole-house ON only operates thermostat-managed, enabled zones. Devices with // a disabled zone (or no zone at all) remain manual/technical Devices controls. if power && !enabled_zone_devices.contains(&device.id) { continue; } // Do not trust the pre-loop power snapshot for deciding whether to send. The engine // reloads state under the per-device lock and turns an already-matching command into // a no-op. This closes the polling/command race without extra UDP frames. let result = if power { engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).await } else { 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, })); failed.push(json!({ "device_id": device.id, "device_name": device.name, "error": err.to_string(), })); } } Ok(failed) } async fn update_house_control(State(state): State, Json(input): Json) -> Result, AppError> { if !matches!(input.mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); } let mode = input.mode; let activate_all = mode != "off"; let payload = { let mut settings = state.settings.write().await; settings.house_mode = mode.clone(); // Choosing a real whole-house operating mode is an explicit request to run the // house climate. It therefore clears a previous global power-off. "off" keeps // its separate meaning: do not control, without changing master power. if activate_all { settings.house_power_enabled = true; } state.db.save_runtime_settings(&settings)?; public_settings(&settings) }; state.broadcast("settings.updated", payload.clone()); if activate_all { set_all_groups_power(&state, true)?; let failed = command_all_enabled_devices_power(&state, true, "house_mode").await?; if !failed.is_empty() { state.log("warn", "house.mode_power_partial", "House mode enabled master power, but some devices could not be powered on", json!({ "mode": mode, "failed": failed.len(), })); } } state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all})); Ok(Json(payload)) } #[derive(Debug, Deserialize)] struct HousePowerPatch { power: bool } async fn update_house_power(State(state): State, Json(input): Json) -> Result, AppError> { // Whole-house power is independent from the thermostat mode. Publish/persist the master // first so the regulator becomes passive before the one-shot OFF cascade starts. { let mut settings = state.settings.write().await; if settings.house_power_enabled != input.power { settings.house_power_enabled = input.power; state.db.save_runtime_settings(&settings)?; let payload = public_settings(&settings); state.broadcast("settings.updated", payload); } } // Global power is a true cascade across group gates. OFF clears the current takeover // markers once, then each enabled device is re-cleared atomically with its OFF command. // A later pilot action is therefore not erased by subsequent controller cycles. set_all_groups_power(&state, input.power)?; if !input.power { engine::clear_all_device_manual_overrides(&state, "house_power_off")?; clear_all_local_thermostat_overrides(&state)?; } let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?; let devices = state.db.list_devices()?; let groups = state.db.list_groups()?; let settings = state.settings.read().await; let settings_payload = public_settings(&settings); drop(settings); state.log("info", "house.power_all", if input.power { "Whole-house power enabled; all enabled thermostat zones powered on" } else { "Whole-house power disabled; all groups and enabled devices powered off" }, json!({ "power": input.power, "failed": failed.len(), })); Ok(Json(json!({ "power": input.power, "devices": devices, "groups": groups, "settings": settings_payload, "failed": failed, }))) } #[derive(Debug, Deserialize)] struct HousePresetPatch { preset: String } async fn update_house_preset(State(state): State, Json(input): Json) -> Result, AppError> { if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") { return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into())); } // A whole-house profile is also an explicit whole-house activation. This mirrors // selecting cooling/heating and makes the separate master-power control intuitive. let settings_payload = { let mut settings = state.settings.write().await; settings.house_power_enabled = true; state.db.save_runtime_settings(&settings)?; public_settings(&settings) }; state.broadcast("settings.updated", settings_payload.clone()); set_all_groups_power(&state, true)?; let schedules = state.db.list_schedules()?; let mut zones = state.db.list_zones()?; for zone in &mut zones { if input.preset == "auto" { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; } else { zone.manual_preset = Some(input.preset.clone()); zone.manual_setpoint = None; zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); } zone.updated_at = Utc::now(); state.db.save_zone(zone)?; state.broadcast("zone.updated", serde_json::to_value(&*zone)?); } let failed = command_all_enabled_devices_power(&state, true, "house_preset").await?; state.wake_zone_control(); let devices = state.db.list_devices()?; state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({ "preset": input.preset, "master_power_enabled": true, "failed": failed.len(), })); Ok(Json(json!({ "preset": input.preset, "zones": zones, "devices": devices, "settings": settings_payload, "failed": failed, }))) } #[derive(Debug, Deserialize)] struct ScheduleTemplateRequest { template: String } async fn apply_schedule_template(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { 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(), }); }; 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"); add("Sleep", all, "22:30", "06:30", "sleep"); } "child" => { add("Comfort", all.clone(), "06:30", "20:30", "comfort"); add("Sleep", all, "20:30", "06:30", "sleep"); } "bedroom" => { add("Comfort", all.clone(), "06:30", "22:00", "comfort"); add("Sleep", all, "22:00", "06:30", "sleep"); } "workday" => { 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"); add("Sleep", weekdays, "22:30", "06:30", "sleep"); add("Weekend", weekend, "08:00", "23:00", "comfort"); // Saturday can sleep until the Sunday weekend block starts at 08:00. add("Saturday sleep", vec![6], "23:00", "08:00", "sleep"); // Sunday must hand over at 06:30 so it never overlaps Monday morning. add("Sunday sleep", vec![7], "23:00", "06:30", "sleep"); } "always" => add("Comfort", all, "00:00", "00:00", "comfort"), _ => return Err(AppError::BadRequest("unknown schedule template".into())), } validate_schedule_set(&items)?; state.db.replace_schedules_for_zone(&id, &items)?; refresh_zone_override_boundary(&state, &id)?; state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()})); Ok(Json(json!({"zone": zone, "schedules": items}))) } async fn delete_zone(State(state): State, Path(id): Path) -> Result { 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}"))); } remove_zone_ids_from_groups(&state, &removed)?; state.broadcast("zone.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Deserialize)] struct ScheduleInput { zone_id: String, name: String, #[serde(default = "yes")] enabled: bool, weekdays: Vec, start_time: String, end_time: String, #[serde(default = "schedule_preset")] preset: String, setpoint: f64, } 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())); } 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() } } } 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))); } } } Ok(()) } 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 engine::schedules_overlap(item, &existing) { return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name))); } } Ok(()) } fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> { let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); }; if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() { return Ok(()); } let schedules = state.db.list_schedules()?; zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); 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 create_schedule(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { input.validate()?; 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)?; refresh_zone_override_boundary(&state, &item.zone_id)?; state.broadcast("schedule.created", serde_json::to_value(&item)?); Ok((StatusCode::CREATED, Json(item))) } async fn update_schedule(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { input.validate()?; let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; 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)?; if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; } state.broadcast("schedule.updated", serde_json::to_value(&item)?); Ok(Json(item)) } async fn delete_schedule(State(state): State, Path(id): Path) -> Result { let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); } refresh_zone_override_boundary(&state, &existing.zone_id)?; state.broadcast("schedule.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Deserialize)] struct AutomationInput { name: String, #[serde(default = "yes")] enabled: bool, trigger_kind: String, #[serde(default)] trigger_device_id: Option, #[serde(default)] threshold: Option, #[serde(default)] at_time: Option, #[serde(default)] action_device_id: String, #[serde(default)] action_group_id: Option, #[serde(default)] action_preset: Option, #[serde(default)] action: DeviceCommand, #[serde(default = "automation_cooldown")] cooldown_seconds: u64, } 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())); } 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())); } } "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()))?; } _ => 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()); 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())); } 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())); } if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { 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())); } } if self.action.target_temperature.is_some() || self.action.fan_speed.is_some() || self.action.swing_vertical.is_some() || self.action.swing_horizontal.is_some() || self.action.quiet.is_some() || self.action.turbo.is_some() || self.action.light.is_some() || self.action.air.is_some() || self.action.xfan.is_some() || 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())); } 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())); } } 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, 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())); } } 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())); } } else if state.db.get_device(input.action_device_id.trim())?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); } 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 create_automation(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { input.validate()?; 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> { input.validate()?; let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; 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 { if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); } state.broadcast("automation.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Deserialize)] 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))?; Ok(Json(json!({"readings": values}))) } #[derive(Debug, Deserialize)] struct HistoryQuery { scope: Option, zone_id: Option, device_id: Option, entity_id: Option, hours: Option, limit: Option, } fn history_bucket_seconds(hours: i64) -> i64 { match hours { 1..=6 => 30, 7..=24 => 120, 25..=168 => 600, 169..=720 => 1800, 721..=2160 => 7200, 2161..=8760 => 21600, _ => 86400, } } 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() } fn zone_history_with_fallback( state: &AppState, zone_id: Option<&str>, since: chrono::DateTime, bucket_seconds: i64, limit: u32, ) -> Result, AppError> { 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}")))?; 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)?; values = fallback_zone_rows(&zone, &device, rows); } } return Ok(values); } 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)?; values.extend(fallback_zone_rows(&zone, &device, rows)); } values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); if values.len() > limit as usize { let keep_from = values.len() - limit as usize; values.drain(0..keep_from); } Ok(values) } fn sensor_history_with_fallback( state: &AppState, since: chrono::DateTime, bucket_seconds: i64, 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(); 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 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 }); added = true; } } 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 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 }); added = true; } } if added { break; } } } values.sort_by(|left, right| left.timestamp.cmp(&right.timestamp)); if values.len() > limit as usize { let keep_from = values.len() - limit as usize; values.drain(0..keep_from); } Ok(values) } async fn combined_device_history( state: &AppState, device_id: Option<&str>, since: chrono::DateTime, bucket_seconds: i64, limit: u32, ) -> Result<(Vec, String, Option), AppError> { let influx = state.settings.read().await.influxdb.clone(); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); if !influx.enabled || since >= cutoff { return Ok((state.db.list_device_history(device_id, since, bucket_seconds, limit)?, "sqlite".into(), None)); } let mut warning = None; let mut values = match influxdb::query_devices(&state.http, &influx, device_id, since, cutoff, bucket_seconds, limit).await { Ok(rows) => rows, Err(err) => { warning = Some(err.to_string()); state.log("warn", "influx.query_error", "InfluxDB device history query failed", json!({"error": err.to_string()})); state.db.list_device_history(device_id, since, bucket_seconds, limit)? } }; if warning.is_none() { values.extend(state.db.list_device_history(device_id, cutoff, bucket_seconds, limit)?); } values.sort_by_key(|row| row.timestamp); trim_history(&mut values, limit); let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; Ok((values, source.into(), warning)) } async fn combined_zone_history( state: &AppState, zone_id: Option<&str>, since: chrono::DateTime, bucket_seconds: i64, limit: u32, ) -> Result<(Vec, String, Option), AppError> { let influx = state.settings.read().await.influxdb.clone(); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); if !influx.enabled || since >= cutoff { return Ok((zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)?, "sqlite".into(), None)); } let mut warning = None; let mut values = match influxdb::query_zones(&state.http, &influx, zone_id, since, cutoff, bucket_seconds, limit).await { Ok(rows) => rows, Err(err) => { warning = Some(err.to_string()); state.log("warn", "influx.query_error", "InfluxDB zone history query failed", json!({"error": err.to_string()})); zone_history_with_fallback(state, zone_id, since, bucket_seconds, limit)? } }; if warning.is_none() { values.extend(zone_history_with_fallback(state, zone_id, cutoff, bucket_seconds, limit)?); } values.sort_by_key(|row| row.timestamp); trim_history(&mut values, limit); let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; Ok((values, source.into(), warning)) } async fn combined_sensor_history( state: &AppState, entity_id: Option<&str>, since: chrono::DateTime, bucket_seconds: i64, limit: u32, outdoor_entity: &str, ) -> Result<(Vec, String, Option), AppError> { let influx = state.settings.read().await.influxdb.clone(); let cutoff = Utc::now() - ChronoDuration::days(influx.history_threshold_days.max(1) as i64); let local = |start| -> Result, AppError> { if entity_id.is_some() { Ok(state.db.list_ha_history(entity_id, start, bucket_seconds, limit)?) } else { sensor_history_with_fallback(state, start, bucket_seconds, limit, outdoor_entity) } }; if !influx.enabled || since >= cutoff { return Ok((local(since)?, "sqlite".into(), None)); } let mut warning = None; let mut values = match influxdb::query_ha(&state.http, &influx, entity_id, since, cutoff, bucket_seconds, limit).await { Ok(rows) => rows, Err(err) => { warning = Some(err.to_string()); state.log("warn", "influx.query_error", "InfluxDB HA history query failed", json!({"error": err.to_string()})); local(since)? } }; if warning.is_none() { values.extend(local(cutoff)?); } values.sort_by_key(|row| row.timestamp); trim_history(&mut values, limit); let source = if warning.is_some() { "sqlite_fallback" } else { "influx+sqlite" }; Ok((values, source.into(), warning)) } fn trim_history(values: &mut Vec, limit: u32) { if values.len() > limit as usize { let keep_from = values.len() - limit as usize; values.drain(0..keep_from); } } async fn history(State(state): State, 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 (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?; Ok(Json(json!({ "scope": "devices", "readings": readings, "bucket_seconds": bucket_seconds, "storage": storage, "storage_warning": warning, "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} }))) } "sensors" => { let entity_id = query.entity_id.as_deref().filter(|value| !value.is_empty() && *value != "all"); let (readings, storage, warning) = combined_sensor_history(&state, entity_id, since, bucket_seconds, limit, &outdoor_entity).await?; Ok(Json(json!({ "scope": "sensors", "readings": readings, "bucket_seconds": bucket_seconds, "storage": storage, "storage_warning": warning, "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} }))) } "overview" => { let (zones, zone_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() .collect::>(); Ok(Json(json!({ "scope": "overview", "bucket_seconds": bucket_seconds, "zones": zones, "devices": devices, "sensors": sensors, "storage": {"zones": zone_storage, "devices": device_storage, "sensors": sensor_storage}, "storage_warning": storage_warning, "counts": {"devices": device_count, "zones": zone_count, "ha": ha_count} }))) } "zones" | "zone" => { 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?; 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())), } } async fn control_plan(State(state): State) -> Result, AppError> { Ok(Json(serde_json::to_value(engine::build_control_plan(&state).await?)?)) } #[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))?}))) } #[derive(Debug, Deserialize)] struct EventRetentionInput { days: u32 } async fn get_event_retention(State(state): State) -> Json { let days = state.settings.read().await.event_log_retention_days; Json(json!({"days": days})) } async fn update_event_retention(State(state): State, Json(input): Json) -> Result, AppError> { let mut settings = state.settings.write().await; settings.event_log_retention_days = input.days.clamp(1, 3650); state.db.save_runtime_settings(&settings)?; let days = settings.event_log_retention_days; drop(settings); let removed = state.db.prune_events(days as i64)?; state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed})); let public = { let settings = state.settings.read().await; public_settings(&*settings) }; state.broadcast("settings.updated", public); Ok(Json(json!({"days": days, "removed": removed}))) } async fn get_settings(State(state): State) -> Json { let settings = state.settings.read().await; Json(public_settings(&*settings)) } async fn update_settings(State(state): State, Json(mut input): Json) -> Result, AppError> { let old = state.settings.read().await.clone(); input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600); input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600); input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000); if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); } if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); } if !(input.discovery_broadcast.eq_ignore_ascii_case("auto") || input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) { input.discovery_broadcast.parse::() .map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?; } if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; } if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; } if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; } if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; } if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; } if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; } input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400); input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100); input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60); if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); } if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); } input.history_retention_days = input.history_retention_days.clamp(1, 3650); input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650); normalize_sensor_aliases(&mut input); canonicalize_home_assistant_entities(&mut input); validate_night_mode(&mut input)?; input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650); if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; } if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; } influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; if !input.home_assistant.url.trim().is_empty() { let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); } } state.db.save_runtime_settings(&input)?; canonicalize_saved_zone_entities(&state, &input)?; state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed); *state.settings.write().await = input.clone(); state.log("info", "settings.updated", "Settings updated", json!({})); state.broadcast("settings.updated", public_settings(&input)); Ok(Json(public_settings(&input))) } fn normalize_sensor_aliases(settings: &mut RuntimeSettings) { settings.home_assistant.sensor_aliases = settings.home_assistant.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::())) }) .collect(); } fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) { let default_entity = settings.home_assistant.default_entity_id.clone(); if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) { settings.home_assistant.default_entity_id = entity_id; } let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone(); if !outdoor_entity.trim().is_empty() { if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) { settings.home_assistant.outdoor_entity_id = entity_id; } } } fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) { let Some(configured) = zone.ha_entity_id.clone() else { return; }; zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured)); } fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> { for mut zone in state.db.list_zones()? { let previous = zone.ha_entity_id.clone(); canonicalize_zone_ha_entity(&mut zone, settings); if zone.ha_entity_id != previous { zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); } } Ok(()) } fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> { NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M") .map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?; NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M") .map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?; settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5); Ok(()) } async fn export_settings(State(state): State) -> Result, AppError> { let settings = state.settings.read().await.clone(); Ok(Json(state.db.export_configuration(settings)?)) } fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> { if export.format_version != 1 { return Err(AppError::BadRequest("unsupported configuration export version".into())); } influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?; if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("import contains an invalid house mode".into())); } let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect(); let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect(); let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect(); let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect(); if devices.len() != export.devices.len() || zones.len() != export.zones.len() || schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") { return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into())); } 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())); } if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) { return Err(AppError::BadRequest("import contains a zone referencing a missing device".into())); } 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())); } if !matches!(zone.mode.as_str(), "cool" | "heat") { 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 export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) { return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into())); } for item in &export.schedules { if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) { 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())); } if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) { return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into())); } } validate_schedule_set(&export.schedules)?; if export.groups.iter().any(|group| { 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| !zones.contains(zone_id.as_str())) }) { 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(); if groups.len() != export.groups.len() { return Err(AppError::BadRequest("import contains duplicate group IDs".into())); } for item in &export.automations { 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())); }; if !devices.contains(trigger_id) || item.threshold.is_none() { 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()))?; } _ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())), } if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) { if !groups.contains(group_id) { 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())); } } if let Some(preset) = item.action_preset.as_deref() { if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { return Err(AppError::BadRequest("import contains an invalid group automation preset".into())); } } if item.action.target_temperature.is_some() || item.action.fan_speed.is_some() || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some() || item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some() || item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some() { return Err(AppError::BadRequest("import contains unsupported fields in a group automation".into())); } if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() { return Err(AppError::BadRequest("import contains an empty group automation action".into())); } } else { if !devices.contains(item.action_device_id.as_str()) { 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())); } } } Ok(()) } async fn import_settings(State(state): State, Json(mut export): Json) -> Result, AppError> { validate_configuration_export(&export)?; export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650); export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650); normalize_sensor_aliases(&mut export.settings); canonicalize_home_assistant_entities(&mut export.settings); for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); } validate_night_mode(&mut export.settings)?; export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650); state.db.replace_configuration(&export)?; state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed); *state.settings.write().await = export.settings.clone(); state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version})); state.broadcast("configuration.imported", json!({"at": Utc::now()})); Ok(Json(json!({"ok": true}))) } async fn get_debug(State(state): State) -> Json { Json(state.settings.read().await.debug.clone()) } async fn update_debug(State(state): State, Json(input): Json) -> Result, AppError> { let mut settings = state.settings.write().await; settings.debug = input.clone(); state.db.save_runtime_settings(&settings)?; state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed); state.broadcast("debug.settings", serde_json::to_value(&input)?); Ok(Json(input)) } #[derive(Debug, Deserialize)] struct CreateAccessTokenRequest { name: Option, } async fn list_access_tokens(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_api_tokens()?)) } 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(); if name.is_empty() || name.len() > 80 { return Err(AppError::BadRequest("token name must contain 1 to 80 characters".into())); } let secret = generate_access_token(); let item = ApiTokenInfo { id: Uuid::new_v4().to_string(), name, token_prefix: format!("{}...", secret.chars().take(24).collect::()), created_at: Utc::now(), }; state.db.save_api_token(&item, &hash_token(&secret))?; state.log( "info", "access_token.created", "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})))) } 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}"))); } state.log( "info", "access_token.revoked", "Revoked a Home Assistant access token", json!({"token_id": id}), ); Ok(StatusCode::NO_CONTENT) } #[derive(Debug, Deserialize)] 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()) .await.map_err(|e| AppError::Device(e.to_string()))?; Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id}))) } 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)?; Ok(Json(json!({"ok": true}))) } async fn security_headers(request: Request, next: Next) -> Response { let is_api = request.uri().path().contains("/api/"); let mut response = next.run(request).await; 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("x-frame-options"), HeaderValue::from_static("SAMEORIGIN")); headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin")); 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=()")); if is_api { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); } response } fn public_settings(settings: &RuntimeSettings) -> Value { json!({ "controller_id": settings.controller_id, "simulator_enabled": settings.simulator_enabled, "poll_interval_seconds": settings.poll_interval_seconds, "zone_interval_seconds": settings.zone_interval_seconds, "discovery_timeout_ms": settings.discovery_timeout_ms, "discovery_broadcast": settings.discovery_broadcast, "house_mode": settings.house_mode, "house_power_enabled": settings.house_power_enabled, "control_strategy": settings.control_strategy, "outdoor_assist_enabled": settings.outdoor_assist_enabled, "history_retention_days": settings.history_retention_days, "history_compaction_enabled": settings.history_compaction_enabled, "event_log_retention_days": settings.event_log_retention_days, "suppress_device_beep": settings.suppress_device_beep, "debug": settings.debug, "night_mode": settings.night_mode, "notifications": { "enabled": settings.notifications.enabled, "mode": settings.notifications.mode, "provider": settings.notifications.provider, "pushover_app_token": "", "pushover_user_key": "", "pushover_configured": !settings.notifications.pushover_app_token.trim().is_empty() && !settings.notifications.pushover_user_key.trim().is_empty(), "slack_webhook_url": "", "slack_configured": !settings.notifications.slack_webhook_url.trim().is_empty(), "discord_webhook_url": "", "discord_configured": !settings.notifications.discord_webhook_url.trim().is_empty(), "cooldown_seconds": settings.notifications.cooldown_seconds, "communication_failure_threshold": settings.notifications.communication_failure_threshold, "target_timeout_minutes": settings.notifications.target_timeout_minutes, }, "influxdb": { "enabled": settings.influxdb.enabled, "version": settings.influxdb.version, "url": settings.influxdb.url, "database": settings.influxdb.database, "username": settings.influxdb.username, "password": "", "password_configured": !settings.influxdb.password.trim().is_empty(), "org": settings.influxdb.org, "bucket": settings.influxdb.bucket, "token": "", "token_configured": !settings.influxdb.token.trim().is_empty(), "history_threshold_days": settings.influxdb.history_threshold_days, }, "home_assistant": { "url": settings.home_assistant.url, "token": "", "token_configured": !settings.home_assistant.token.trim().is_empty(), "default_entity_id": settings.home_assistant.default_entity_id, "outdoor_entity_id": settings.home_assistant.outdoor_entity_id, "allow_invalid_tls": settings.home_assistant.allow_invalid_tls, "sensor_aliases": settings.home_assistant.sensor_aliases, } }) } #[derive(Debug, Deserialize)] 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); } 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()}}), }; if socket.send(Message::Text(initial.to_string())).await.is_err() { return; } let mut receiver = state.events.subscribe(); loop { tokio::select! { event = receiver.recv() => { match event { Ok(event) => { if let Ok(text) = serde_json::to_string(&event) { if socket.send(Message::Text(text)).await.is_err() { break; } } } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(_) => break, } } message = socket.next() => { match message { Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } } Some(Ok(Message::Text(text))) if text == "ping" => { if socket.send(Message::Text("pong".into())).await.is_err() { break; } } Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, _ => {} } } } } } async fn index(State(state): State, headers: HeaderMap) -> Response { let base = if !state.config.base_path.is_empty() { state.config.base_path.clone() } else { forwarded_prefix(&headers).unwrap_or_default() }; let body = INDEX_HTML.replace("__GREE_BASE_PATH__", &base); 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("no-cache")); response } 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; } Some(format!("/{}", raw.trim_matches('/'))) } async fn app_js() -> 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=86400") } async fn styles_css() -> 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 { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") } 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") } 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) { 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 } 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 }