204 lines
11 KiB
Rust
204 lines
11 KiB
Rust
|
|
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::<String>::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<String> = 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<bool, AppError> = 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<f64> {
|
|
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<Local>) -> 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
|
|
}
|
|
|