#[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, action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(), created_at, updated_at: Utc::now(), } } } fn validate_automation_references( state: &AppState, input: &AutomationInput, ) -> Result<(), AppError> { if matches!( input.trigger_kind.as_str(), "temperature_above" | "temperature_below" ) { let trigger_id = input .trigger_device_id .as_deref() .map(str::trim) .unwrap_or_default(); if state.db.get_device(trigger_id)?.is_none() { return Err(AppError::BadRequest( "automation trigger device does not exist".into(), )); } } 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 { let device_id = input.action_device_id.trim(); if state.db.get_device(device_id)?.is_none() { return Err(AppError::BadRequest( "automation action device does not exist".into(), )); } if engine::automation_action_conflicts_with_thermostat(&input.action) && state .db .list_zones()? .iter() .any(|zone| zone.enabled && zone.device_id == device_id) { return Err(AppError::BadRequest( "direct fan/quiet/sleep automation conflicts with an enabled thermostat zone; use thermostat/group policy instead".into(), )); } } 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> { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; input.validate()?; let action_group_id = input .action_group_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); let _group_guard = if let Some(group_id) = action_group_id.as_deref() { Some(state.lock_group_operation(group_id).await) } else { None }; validate_automation_references(&state, &input)?; let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None); state.db.save_automation(&item)?; state.broadcast("automation.created", serde_json::to_value(&item)?); Ok((StatusCode::CREATED, Json(item))) } async fn update_automation( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; input.validate()?; let existing = state .db .get_automation(&id)? .ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; if existing.flow_id.is_some() { return Err(AppError::BadRequest( "this automation is generated by Flow; edit it in the Flow editor".into(), )); } let action_group_id = input .action_group_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string); let _group_guard = if let Some(group_id) = action_group_id.as_deref() { Some(state.lock_group_operation(group_id).await) } else { None }; validate_automation_references(&state, &input)?; let item = input.into_automation(id, existing.created_at, existing.last_fired_at); state.db.save_automation(&item)?; state.broadcast("automation.updated", serde_json::to_value(&item)?); Ok(Json(item)) } async fn delete_automation( State(state): State, Path(id): Path, ) -> Result { let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; let existing = state .db .get_automation(&id)? .ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; if existing.flow_id.is_some() { return Err(AppError::BadRequest( "this automation is generated by Flow; delete it from the Flow editor".into(), )); } if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); } state.broadcast("automation.deleted", json!({"id": id})); Ok(StatusCode::NO_CONTENT) }