pub fn automation_action_conflicts_with_thermostat(command: &DeviceCommand) -> bool { // Power/mode/target are translated by apply_automatic_device_action into durable zone // state, so they do not fight the thermostat. Fan/quiet/sleep are thermostat outputs with // no independent zone override model; accepting them as one-shot direct automation would // let the next thermostat cycle immediately overwrite them. command.fan_speed.is_some() || command.quiet.is_some() || command.sleep.is_some() } fn device_has_enabled_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool { zones.iter().any(|zone| zone.device_id == device_id && zone.enabled) } async fn run_automations(state: &AppState) -> Result<()> { if !state.settings.read().await.house_power_enabled { return Ok(()); } let devices = state.db.list_devices()?; let mut automations = state.db.list_automations()?; // Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID. // This avoids database row order deciding the physical outcome (M2). automations.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id))); let mut claimed_devices = std::collections::HashSet::::new(); for mut item in automations { if !item.enabled || !automation_ready(&item) { continue; } let should_fire = match item.trigger_kind.as_str() { "temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref()) .zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false), "temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref()) .zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false), "time" => time_automation_due(&item, Local::now()), _ => false, }; if !should_fire { continue; } // API edits/deletes and execution share one short ownership window. If the rule // changed since this cycle snapshot was taken, skip it now and evaluate the new // definition on the next cycle instead of firing stale configuration. let _automation_guard = state.lock_automation_operation().await; let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; }; if latest_item.updated_at != item.updated_at { continue; } item = latest_item; // Group membership and zone ownership may have changed after the cycle snapshot but // before we acquired the automation lock. Reload them inside this serialized window so // same-cycle conflict arbitration claims the actual current target set. let zones = state.db.list_zones()?; let groups = state.db.list_groups()?; if item.action_group_id.is_none() && device_blocked_by_disabled_zone(&item.action_device_id, &zones) && item.action.power != Some(true) { state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) { state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) { state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) { // Group power-off is authoritative for normal controller-owned zones. A manual // takeover is filtered above and therefore remains higher priority than the group. state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } if item.action_group_id.is_none() && device_has_enabled_thermostat_zone(&item.action_device_id, &zones) && automation_action_conflicts_with_thermostat(&item.action) { // Fan/quiet/sleep are outputs continuously managed by the thermostat. Unlike // power/mode/target they cannot be translated into durable zone state, so a direct // automation would be immediately overwritten by the next thermostat cycle. state.log("warn", "automation.blocked_by_thermostat_owner", &format!("Automation {} suppressed because the device is owned by an enabled thermostat zone", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); continue; } let target_devices: Vec = if let Some(group_id) = item.action_group_id.as_deref() { groups.iter().find(|group| group.id == group_id) .map(|group| group.zone_ids.iter() .filter_map(|zone_id| zones.iter().find(|zone| &zone.id == zone_id).map(|zone| zone.device_id.clone())) .collect()) .unwrap_or_default() } else { vec![item.action_device_id.clone()] }; if target_devices.iter().any(|device_id| claimed_devices.contains(device_id)) { state.log("warn", "automation.conflict", &format!("Automation {} skipped because an older due automation already claimed the same target", item.name), json!({ "automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id, "target_devices": target_devices, })); continue; } let result: Result = if let Some(group_id) = item.action_group_id.as_deref() { let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() }); control_group(state, group_id, GroupControlPatch { power: item.action.power, mode: group_mode, preset: item.action_preset.clone(), setpoint: None, }, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false)) } else { match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await { Ok(Some(_)) => Ok(true), Ok(None) => { state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({ "automation_id": item.id, "device_id": item.action_device_id })); Ok(false) } Err(err) => Err(err), } }; match result { Ok(true) => { for device_id in target_devices { claimed_devices.insert(device_id); } item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({ "automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id })); } Ok(false) => { // Ownership suppression is not an execution. Do not consume cooldown (M3), // so a still-valid trigger may run as soon as the higher-priority owner leaves. } Err(err) => { // A failed action is still an execution attempt. Apply the configured cooldown // so an offline/disabled target cannot be hammered on every automation cycle. item.last_fired_at = Some(Utc::now()); item.updated_at = Utc::now(); state.db.save_automation(&item)?; state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})); } } } Ok(()) } fn device_blocked_by_disabled_zone(device_id: &str, zones: &[Zone]) -> bool { zones.iter().any(|zone| zone.device_id == device_id && !zone.enabled) } fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool { zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override) } fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool { zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some()) } fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool { let zone_ids: std::collections::HashSet<&str> = zones.iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id.as_str()) .collect(); if zone_ids.is_empty() { return false; } groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str()))) } fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option { let id = device_id?; // Never fire a temperature automation from stale cached data of an offline/disabled unit. devices.iter().find(|d| d.id == id && d.enabled && d.online && d.communication_failures == 0)?.current_temperature } fn automation_ready(item: &Automation) -> bool { item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true) } fn time_automation_due(item: &Automation, now: DateTime) -> bool { let Some(expected) = item.at_time.as_deref() else { return false; }; let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; }; if now.hour() != value.hour() || now.minute() != value.minute() { return false; } if let Some(last) = item.last_fired_at { let local_last = last.with_timezone(&Local); if local_last.date_naive() == now.date_naive() && local_last.hour() == now.hour() && local_last.minute() == now.minute() { return false; } } true }