fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings { ApplicationSettings { simulator_enabled: settings.simulator_enabled, } } fn gree_settings(settings: &RuntimeSettings) -> GreeSettings { GreeSettings { controller_id: settings.controller_id.clone(), 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.clone(), suppress_device_beep: settings.suppress_device_beep, compressor_protection_enabled: settings.compressor_protection_enabled, compressor_protection_seconds: settings.compressor_protection_seconds, } } fn history_settings(settings: &RuntimeSettings) -> HistorySettings { HistorySettings { retention_days: settings.history_retention_days, compaction_enabled: settings.history_compaction_enabled, event_retention_days: settings.event_log_retention_days, } } fn influxdb_settings(settings: &RuntimeSettings) -> InfluxDbSettingsView { InfluxDbSettingsView { enabled: settings.influxdb.enabled, version: settings.influxdb.version.clone(), url: settings.influxdb.url.clone(), database: settings.influxdb.database.clone(), username: settings.influxdb.username.clone(), password_configured: !settings.influxdb.password.trim().is_empty(), org: settings.influxdb.org.clone(), bucket: settings.influxdb.bucket.clone(), token_configured: !settings.influxdb.token.trim().is_empty(), history_threshold_days: settings.influxdb.history_threshold_days, } } fn notification_settings(settings: &RuntimeSettings) -> NotificationSettingsView { NotificationSettingsView { enabled: settings.notifications.enabled, mode: settings.notifications.mode.clone(), provider: settings.notifications.provider.clone(), pushover_configured: !settings.notifications.pushover_app_token.trim().is_empty() && !settings.notifications.pushover_user_key.trim().is_empty(), slack_configured: !settings.notifications.slack_webhook_url.trim().is_empty(), 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, alert_types: settings.notifications.alert_types.clone(), } } fn home_assistant_settings(settings: &RuntimeSettings) -> HomeAssistantSettingsView { HomeAssistantSettingsView { url: settings.home_assistant.url.clone(), token_configured: !settings.home_assistant.token.trim().is_empty(), default_entity_id: settings.home_assistant.default_entity_id.clone(), outdoor_entity_id: settings.home_assistant.outdoor_entity_id.clone(), sensor_stale_after_seconds: settings.home_assistant.sensor_stale_after_seconds, allow_invalid_tls: settings.home_assistant.allow_invalid_tls, sensor_aliases: settings.home_assistant.sensor_aliases.clone(), flow_inputs: settings.home_assistant.flow_inputs.clone(), outdoor_assist_enabled: settings.outdoor_assist_enabled, } } fn settings_snapshot(settings: &RuntimeSettings) -> SettingsSnapshot { SettingsSnapshot { application: application_settings(settings), gree: gree_settings(settings), history: history_settings(settings), influxdb: influxdb_settings(settings), notifications: notification_settings(settings), night: settings.night_mode.clone(), home_assistant: home_assistant_settings(settings), debug: settings.debug.clone(), } } async fn get_application_settings(State(state): State) -> Json { Json(application_settings(&*state.settings.read().await)) } async fn update_application_settings( State(state): State, Json(input): Json, ) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let payload = { let mut settings = state.settings.write().await; settings.simulator_enabled = input.simulator_enabled; state.db.save_runtime_settings(&settings)?; application_settings(&settings) }; state.log( "info", "settings.application.updated", "Application settings updated", json!({"simulator_enabled": payload.simulator_enabled}), ); state.broadcast( "settings.application.updated", serde_json::to_value(&payload)?, ); Ok(Json(payload)) } async fn get_gree_settings(State(state): State) -> Json { Json(gree_settings(&*state.settings.read().await)) } fn normalize_gree_settings(mut input: GreeSettings) -> Result { input.controller_id = input.controller_id.trim().to_string(); if input.controller_id.is_empty() { return Err(AppError::BadRequest("controller_id cannot be empty".into())); } 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); input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800); 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()))?; } Ok(input) } async fn clear_compressor_runtime_after_settings_change(state: &AppState) -> Result<(), AppError> { let mut zone_ids: Vec = state .db .list_zones()? .into_iter() .map(|zone| zone.id) .collect(); zone_ids.sort(); zone_ids.dedup(); for zone_id in zone_ids { let _zone_guard = state.lock_zone_operation(&zone_id).await; let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none() && zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; } engine::clear_compressor_pending(&mut zone, true); zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); } Ok(()) } async fn update_gree_settings( State(state): State, Json(input): Json, ) -> Result, AppError> { let input = normalize_gree_settings(input)?; let _configuration_guard = state.lock_configuration_operation().await; let _house_guard = state.lock_house_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let (payload, compressor_changed) = { let mut settings = state.settings.write().await; let compressor_changed = settings.compressor_protection_enabled != input.compressor_protection_enabled || settings.compressor_protection_seconds != input.compressor_protection_seconds; settings.controller_id = input.controller_id; settings.poll_interval_seconds = input.poll_interval_seconds; settings.zone_interval_seconds = input.zone_interval_seconds; settings.discovery_timeout_ms = input.discovery_timeout_ms; settings.discovery_broadcast = input.discovery_broadcast; settings.suppress_device_beep = input.suppress_device_beep; settings.compressor_protection_enabled = input.compressor_protection_enabled; settings.compressor_protection_seconds = input.compressor_protection_seconds; state.db.save_runtime_settings(&settings)?; (gree_settings(&settings), compressor_changed) }; if compressor_changed { clear_compressor_runtime_after_settings_change(&state).await?; } state.log( "info", "settings.gree.updated", "GREE settings updated", json!({ "compressor_protection_enabled": payload.compressor_protection_enabled, "compressor_protection_seconds": payload.compressor_protection_seconds }), ); state.broadcast("settings.gree.updated", serde_json::to_value(&payload)?); state.wake_zone_control(); Ok(Json(payload)) } async fn get_history_settings(State(state): State) -> Json { Json(history_settings(&*state.settings.read().await)) } async fn update_history_settings( State(state): State, Json(mut input): Json, ) -> Result, AppError> { input.retention_days = input.retention_days.clamp(1, 3650); input.event_retention_days = input.event_retention_days.clamp(1, 3650); let _configuration_guard = state.lock_configuration_operation().await; let payload = { let mut settings = state.settings.write().await; settings.history_retention_days = input.retention_days; settings.history_compaction_enabled = input.compaction_enabled; settings.event_log_retention_days = input.event_retention_days; state.db.save_runtime_settings(&settings)?; history_settings(&settings) }; let removed = state.db.prune_events(payload.event_retention_days as i64)?; state.log( "info", "settings.history.updated", "History settings updated", json!({ "retention_days": payload.retention_days, "event_retention_days": payload.event_retention_days, "event_rows_removed": removed }), ); state.broadcast("settings.history.updated", serde_json::to_value(&payload)?); Ok(Json(payload)) } async fn get_influxdb_settings(State(state): State) -> Json { Json(influxdb_settings(&*state.settings.read().await)) } fn apply_influxdb_update( current: &InfluxDbSettings, input: InfluxDbSettingsUpdate, ) -> Result { let mut next = InfluxDbSettings { enabled: input.enabled, version: input.version, url: input.url, database: input.database, username: input.username, password: current.password.clone(), org: input.org, bucket: input.bucket, token: current.token.clone(), history_threshold_days: input.history_threshold_days.clamp(1, 3650), }; if let Some(password) = input.password { next.password = password; } if let Some(token) = input.token { next.token = token; } influxdb::validate(&next).map_err(|err| AppError::BadRequest(err.to_string()))?; Ok(next) } async fn update_influxdb_settings( State(state): State, Json(input): Json, ) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let payload = { let mut settings = state.settings.write().await; let next = apply_influxdb_update(&settings.influxdb, input)?; settings.influxdb = next; state.db.save_runtime_settings(&settings)?; influxdb_settings(&settings) }; state.log( "info", "settings.influxdb.updated", "InfluxDB settings updated", json!({"enabled": payload.enabled, "version": payload.version}), ); state.broadcast("settings.influxdb.updated", serde_json::to_value(&payload)?); Ok(Json(payload)) } async fn get_notification_settings( State(state): State, ) -> Json { Json(notification_settings(&*state.settings.read().await)) } fn apply_notification_update( current: &NotificationSettings, mut input: NotificationSettingsUpdate, ) -> Result { input.cooldown_seconds = input.cooldown_seconds.clamp(30, 86_400); input.communication_failure_threshold = input.communication_failure_threshold.clamp(2, 100); input.target_timeout_minutes = input.target_timeout_minutes.clamp(5, 24 * 60); if !matches!(input.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest( "notification mode must be problems or important".into(), )); } if !matches!(input.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest( "unsupported notification provider".into(), )); } let mut next = NotificationSettings { enabled: input.enabled, mode: input.mode, provider: input.provider, pushover_app_token: current.pushover_app_token.clone(), pushover_user_key: current.pushover_user_key.clone(), slack_webhook_url: current.slack_webhook_url.clone(), discord_webhook_url: current.discord_webhook_url.clone(), cooldown_seconds: input.cooldown_seconds, communication_failure_threshold: input.communication_failure_threshold, target_timeout_minutes: input.target_timeout_minutes, alert_types: input.alert_types, }; if let Some(value) = input.pushover_app_token { next.pushover_app_token = value; } if let Some(value) = input.pushover_user_key { next.pushover_user_key = value; } if let Some(value) = input.slack_webhook_url { next.slack_webhook_url = value; } if let Some(value) = input.discord_webhook_url { next.discord_webhook_url = value; } Ok(next) } async fn update_notification_settings( State(state): State, Json(input): Json, ) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let payload = { let mut settings = state.settings.write().await; let next = apply_notification_update(&settings.notifications, input)?; settings.notifications = next; state.db.save_runtime_settings(&settings)?; notification_settings(&settings) }; state.log( "info", "settings.notifications.updated", "Notification settings updated", json!({"enabled": payload.enabled, "provider": payload.provider}), ); state.broadcast( "settings.notifications.updated", serde_json::to_value(&payload)?, ); Ok(Json(payload)) } async fn get_night_settings(State(state): State) -> Json { Json(state.settings.read().await.night_mode.clone()) } fn validate_night_mode(settings: &mut NightModeSettings) -> Result<(), AppError> { NaiveTime::parse_from_str(&settings.start_time, "%H:%M") .map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?; NaiveTime::parse_from_str(&settings.end_time, "%H:%M") .map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?; settings.max_fan_speed = settings.max_fan_speed.clamp(1, 5); Ok(()) } async fn update_night_settings( State(state): State, Json(mut input): Json, ) -> Result, AppError> { validate_night_mode(&mut input)?; let _configuration_guard = state.lock_configuration_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; { let mut settings = state.settings.write().await; settings.night_mode = input.clone(); state.db.save_runtime_settings(&settings)?; } state.log( "info", "settings.night.updated", "Night mode settings updated", json!({"enabled": input.enabled}), ); state.broadcast("settings.night.updated", serde_json::to_value(&input)?); state.wake_zone_control(); Ok(Json(input)) } async fn get_home_assistant_settings( State(state): State, ) -> Json { Json(home_assistant_settings(&*state.settings.read().await)) } fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) { settings.sensor_aliases = settings .sensor_aliases .iter() .filter_map(|(entity, alias)| { let entity = entity.trim(); let alias = alias.trim(); if entity.is_empty() || alias.is_empty() { return None; } Some(( entity.chars().take(160).collect::(), alias.chars().take(80).collect::(), )) }) .collect(); } fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> { let mut ids = std::collections::HashSet::new(); if settings.flow_inputs.len() > 128 { return Err(AppError::BadRequest( "too many shared Flow inputs (max 128)".into(), )); } for item in &mut settings.flow_inputs { item.id = item.id.trim().chars().take(120).collect(); item.name = item.name.trim().chars().take(100).collect(); item.kind = item.kind.trim().to_string(); if item.id.is_empty() || item.name.is_empty() { return Err(AppError::BadRequest( "shared Flow input requires id and name".into(), )); } if !ids.insert(item.id.clone()) { return Err(AppError::BadRequest( "shared Flow input IDs must be unique".into(), )); } if !matches!( item.kind.as_str(), "outdoor_temperature" | "device_temperature" | "zone_temperature" | "ha_state" | "ha_numeric" | "ha_attribute" | "ha_available" | "house_mode" | "device_state" | "zone_state" | "group_state" | "night_mode" | "constant" ) { return Err(AppError::BadRequest(format!( "unsupported shared Flow input kind: {}", item.kind ))); } if !item.config.is_object() { return Err(AppError::BadRequest( "shared Flow input config must be an object".into(), )); } if item.config.get("operator").is_some() { return Err(AppError::BadRequest( "shared Flow inputs are value sources; operator belongs to the Flow block".into(), )); } if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() { return Err(AppError::BadRequest( "shared Flow inputs are value sources; comparison value belongs to the Flow block" .into(), )); } } Ok(()) } fn validate_flow_shared_inputs( settings: &mut HomeAssistantSettings, state: &AppState, ) -> Result<(), AppError> { normalize_flow_shared_inputs(settings)?; for item in &settings.flow_inputs { validate_shared_input_source(&item.kind, &item.config, state)?; } Ok(()) } fn canonicalize_home_assistant_entities(settings: &mut HomeAssistantSettings) { let default_entity = settings.default_entity_id.clone(); if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&default_entity)) { settings.default_entity_id = entity_id; } let outdoor_entity = settings.outdoor_entity_id.clone(); if !outdoor_entity.trim().is_empty() { if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity)) { settings.outdoor_entity_id = entity_id; } } } fn validate_home_assistant_url(settings: &HomeAssistantSettings) -> Result<(), AppError> { if settings.url.trim().is_empty() { return Ok(()); } let parsed = url::Url::parse(&settings.url) .map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest( "Home Assistant URL must use http or https".into(), )); } Ok(()) } fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &HomeAssistantSettings) { let Some(configured) = zone.ha_entity_id.clone() else { return; }; zone.ha_entity_id = home_assistant::resolve_entity_id(settings, Some(&configured)); } async fn canonicalize_saved_zone_entities( state: &AppState, settings: &HomeAssistantSettings, ) -> Result<(), AppError> { let mut zone_ids: Vec = state .db .list_zones()? .into_iter() .map(|zone| zone.id) .collect(); zone_ids.sort(); zone_ids.dedup(); for zone_id in zone_ids { let _zone_guard = state.lock_zone_operation(&zone_id).await; let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; }; let _device_guard = state.lock_device_operation(&snapshot.device_id).await; let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; let previous = zone.ha_entity_id.clone(); canonicalize_zone_ha_entity(&mut zone, settings); if zone.ha_entity_id != previous { zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); } } Ok(()) } fn apply_home_assistant_update( current: &HomeAssistantSettings, input: HomeAssistantSettingsUpdate, ) -> HomeAssistantSettings { let mut next = HomeAssistantSettings { url: input.url, token: current.token.clone(), default_entity_id: input.default_entity_id, outdoor_entity_id: input.outdoor_entity_id, sensor_stale_after_seconds: input.sensor_stale_after_seconds.clamp(30, 86_400), allow_invalid_tls: input.allow_invalid_tls, sensor_aliases: input.sensor_aliases, flow_inputs: input.flow_inputs, }; if let Some(token) = input.token { next.token = token; } next } async fn update_home_assistant_settings( State(state): State, Json(input): Json, ) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _cycle_guard = state.lock_zone_control_cycle().await; let payload = { let mut settings = state.settings.write().await; let mut next = apply_home_assistant_update(&settings.home_assistant, input.clone()); normalize_sensor_aliases(&mut next); validate_flow_shared_inputs(&mut next, &state)?; canonicalize_home_assistant_entities(&mut next); validate_home_assistant_url(&next)?; settings.home_assistant = next.clone(); settings.outdoor_assist_enabled = input.outdoor_assist_enabled; state.db.save_runtime_settings(&settings)?; home_assistant_settings(&settings) }; let saved = state.settings.read().await.home_assistant.clone(); canonicalize_saved_zone_entities(&state, &saved).await?; state.log( "info", "settings.home_assistant.updated", "Home Assistant settings updated", json!({ "configured": payload.token_configured, "flow_inputs": payload.flow_inputs.len() }), ); state.broadcast( "settings.home_assistant.updated", serde_json::to_value(&payload)?, ); state.wake_zone_control(); Ok(Json(payload)) } async fn get_debug_settings(State(state): State) -> Json { Json(state.settings.read().await.debug.clone()) } async fn update_debug_settings( State(state): State, Json(input): Json, ) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; { 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("settings.debug.updated", serde_json::to_value(&input)?); Ok(Json(input)) }