v0.8.14
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
async fn run_automations(state: &AppState) -> Result<()> {
|
||||
if !state.settings.read().await.house_power_enabled { return Ok(()); }
|
||||
let devices = state.db.list_devices()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
let groups = state.db.list_groups()?;
|
||||
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; }
|
||||
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;
|
||||
}
|
||||
|
||||
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(),
|
||||
}, "automation.group").await.map(|_| true)
|
||||
} 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user