This commit is contained in:
Mateusz Gruszczyński committed 2026-09-28 14:30:44 +02:00
1 parent aa1cb05b2f
commit 8a7501e4ab
9 files changed
+175 -32

No files matched your search

+84
View File
@@ -12,6 +12,64 @@ fn device_has_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
zones.iter().any(|zone| zone.device_id == device_id)
}
fn flow_zone_action_already_satisfied(
zone: &Zone,
device_power: bool,
device_swing_vertical: u8,
device_swing_horizontal: u8,
preset: Option<&str>,
action: &DeviceCommand,
) -> bool {
let mode_satisfied = match action.mode.as_deref() {
Some("auto") => zone.inherit_house_mode,
Some(mode) if matches!(mode, "heat" | "cool") => {
!zone.inherit_house_mode && zone.mode == mode
}
Some(_) => false,
None => true,
};
if !mode_satisfied {
return false;
}
let preset_satisfied = match preset.unwrap_or("auto") {
"auto" => zone.manual_preset.is_none()
&& zone.manual_setpoint.is_none()
&& zone.manual_override_until.is_none(),
"custom" => action.target_temperature.is_some_and(|target| {
zone.manual_preset.as_deref() == Some("custom")
&& zone.manual_setpoint.is_some_and(|value| {
(value - normalize_thermostat_target(target.clamp(8.0, 30.0))).abs() < 0.05
})
}),
preset @ ("comfort" | "sleep" | "away") => {
zone.manual_preset.as_deref() == Some(preset) && zone.manual_setpoint.is_none()
}
_ => false,
};
if !preset_satisfied {
return false;
}
let power_satisfied = match action.power {
Some(false) => !device_power
&& !zone.demand
&& zone.effective_mode == "off"
&& zone.device_setpoint.is_none(),
// A Flow thermostat ON action establishes durable thermostat intent. Once the same
// mode/preset/target is already present, the normal zone controller owns retries,
// compressor lockout and physical power-on. Re-firing the Flow would only refresh
// metadata and produce duplicate automation.fired events.
Some(true) | None => true,
};
if !power_satisfied {
return false;
}
action.swing_vertical.map(|value| value == device_swing_vertical).unwrap_or(true)
&& action.swing_horizontal.map(|value| value == device_swing_horizontal).unwrap_or(true)
}
async fn run_automations(state: &AppState) -> Result<()> {
if state.settings.read().await.emergency_stop_enabled {
return Ok(());
@@ -109,6 +167,32 @@ async fn run_automations(state: &AppState) -> Result<()> {
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
// Level-style Flow conditions (stable_for + rate_limit) intentionally remain true
// after they first fire. Do not execute or log the same thermostat command forever.
// This check happens before same-cycle target arbitration so two equivalent OFF
// branches do not manufacture automation.conflict noise either.
if let Some(zone_id) = item.action_zone_id.as_deref() {
if let Some(zone) = zones.iter().find(|zone| zone.id == zone_id) {
if let Some(device) = state.db.get_device(&zone.device_id)? {
if flow_zone_action_already_satisfied(
zone,
device.power,
device.swing_vertical,
device.swing_horizontal,
item.action_zone_preset.as_deref(),
&effective_action,
) {
let before_runtime = item.flow_runtime.clone();
flow_acknowledge_change_gates(&mut item);
if item.flow_runtime != before_runtime {
state.db.save_automation(&item)?;
}
continue;
}
}
}
}
if item.action_group_id.is_none() && item.action_zone_id.is_none()
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
&& effective_action.power != Some(true)