use std::{net::IpAddr, time::Duration}; use axum::{ body::Body, extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}}, http::{header, HeaderValue, StatusCode}, middleware::{self, Next}, response::{Html, Response}, routing::{get, post}, Json, Router, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use chrono::{Duration as ChronoDuration, 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, cors::CorsLayer, trace::TraceLayer}; use uuid::Uuid; use crate::{ engine, error::AppError, home_assistant, models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone, ZoneControlPatch}, protocol::merge_discovered, state::AppState, }; const INDEX_HTML: &str = include_str!("../web/index.html"); const APP_JS: &str = include_str!("../web/app.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/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/events", get(events)) .route("/api/settings", get(get_settings).put(update_settings)) .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_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_device)) .route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); 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("/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) .layer(CompressionLayer::new()) .layer(CorsLayer::permissive()) .layer(TraceLayer::new_for_http()) .with_state(state) } 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(), "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()?, "schedules": state.db.list_schedules()?, "automations": state.db.list_automations()?, "access_tokens": state.db.list_api_tokens()?, "settings": public_settings(&settings), "system": { "version": env!("CARGO_PKG_VERSION"), "uptime_seconds": state.started.elapsed().as_secs(), "auth_required": !state.config.app_token.trim().is_empty(), } })) } 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(), "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); // 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, current_temperature: if input.simulated { Some(25.0) } else { None }, outdoor_temperature: None, online: input.simulated, 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 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; } } 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.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); state.broadcast("device.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) } async fn bind_device(State(state): State, Path(id): Path) -> Result, AppError> { 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_command(&state, &id, command).await?)) } #[derive(Debug, Deserialize)] struct ZoneInput { name: String, device_id: String, #[serde(default = "yes")] enabled: bool, #[serde(default = "cool")] mode: String, #[serde(default = "setpoint")] setpoint: f64, #[serde(default = "hysteresis")] hysteresis: f64, #[serde(default = "cycle")] min_on_seconds: u64, #[serde(default = "cycle")] min_off_seconds: u64, #[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 hysteresis() -> f64 { 0.6 } fn cycle() -> u64 { 180 } 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())); } if !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint 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 !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, setpoint: self.setpoint, hysteresis: self.hysteresis, min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds, 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(), demand: false, last_action_at: None, created_at, updated_at: Utc::now(), } } } 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())); } let zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now()); 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())); } let mut zone = input.into_zone(id, existing.created_at); 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.demand = existing.demand; zone.last_action_at = existing.last_action_at; state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); Ok(Json(zone)) } async fn update_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; 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())); } zone.setpoint = (value * 2.0).round() / 2.0; } if let Some(value) = patch.mode.as_deref() { if !matches!(value, "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); } zone.mode = value.to_string(); } if let Some(value) = patch.enabled { zone.enabled = value; } zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; // Quick zone controls also update the paired climate unit immediately. Power is // intentionally left unchanged; the zone engine still owns ON/OFF demand. if patch.setpoint.is_some() || patch.mode.is_some() { let command = DeviceCommand { mode: patch.mode.as_ref().map(|_| zone.mode.clone()), target_temperature: patch.setpoint.map(|_| zone.setpoint), ..Default::default() }; if let Err(err) = engine::send_command(&state, &zone.device_id, command).await { state.log("warn", "zone.quick_control_device_error", &format!("{}: {err}", zone.name), json!({"zone_id": zone.id, "device_id": zone.device_id})); } } state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode, "enabled": zone.enabled})); Ok(Json(zone)) } async fn delete_zone(State(state): State, Path(id): Path) -> Result { if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } 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, setpoint: f64, } 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 !(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, setpoint: self.setpoint, created_at, updated_at: Utc::now() } } } 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()); state.db.save_schedule(&item)?; 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 item = input.into_schedule(id, existing.created_at); state.db.save_schedule(&item)?; state.broadcast("schedule.updated", serde_json::to_value(&item)?); Ok(Json(item)) } async fn delete_schedule(State(state): State, Path(id): Path) -> Result { if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {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, action_device_id: String, #[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())), } 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, threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id, action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at, created_at, updated_at: Utc::now() } } } 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()?; if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); } 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}")))?; if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); } 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 * 31); 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 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))?}))) } 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 !(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.home_assistant.url.trim().is_empty() { let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); } } state.db.save_runtime_settings(&input)?; *state.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))) } #[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 temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, input.entity_id.as_deref()) .await.map_err(|e| AppError::Device(e.to_string()))?; Ok(Json(json!({"ok": true, "temperature_c": temperature}))) } 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, "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, } }) } #[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() -> Html<&'static str> { Html(INDEX_HTML) } async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") } 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 }