This commit is contained in:
Mateusz Gruszczyński committed 2026-10-21 08:08:59 +02:00
1 parent 05185a94f7
commit bb5caa769f
28 files changed
+592 -130

No files matched your search

+38 -6
View File
@@ -26,6 +26,8 @@ fn flow_off_physical_mode(action: &DeviceCommand) -> Option<String> {
fn flow_zone_action_already_satisfied(
zone: &Zone,
flow_id: Option<&str>,
flow_node_id: Option<&str>,
device_power: bool,
device_swing_vertical: u8,
device_swing_horizontal: u8,
@@ -35,9 +37,18 @@ fn flow_zone_action_already_satisfied(
standby_offset_c: Option<f64>,
simple_heat: Option<&SimpleHeatSettings>,
) -> bool {
// Matching settings from another (or formerly deleted) Flow are not ownership of
// this action. It needs to execute and record its own provenance.
if zone.control_source != "automation.flow"
// Legacy zone rows without attribution must run once to claim their
// current Flow; matching settings alone do not prove ownership.
|| (flow_id.is_some() && zone.flow_control_id.as_deref() != flow_id)
|| (flow_node_id.is_some() && zone.flow_control_node_id.as_deref() != flow_node_id)
{
return false;
}
if let Some(profile) = simple_heat {
return zone.simple_heat.as_ref() == Some(profile)
&& zone.control_source == "automation.flow";
return zone.simple_heat.as_ref() == Some(profile);
}
if heat_anticipation_c.is_some_and(|value| (zone.heat_anticipation_c - value).abs() > 0.001)
|| standby_offset_c.is_some_and(|value| (zone.standby_offset_c - value).abs() > 0.001)
@@ -115,7 +126,13 @@ async fn run_automations(state: &AppState) -> Result<()> {
"flow" => {
let before_runtime = item.flow_runtime.clone();
let result = flow_conditions_match(state, &devices, &mut item).await;
if item.flow_runtime != before_runtime { state.db.save_automation(&item)?; }
if item.flow_runtime != before_runtime
&& !state.db.save_flow_runtime_if_current(&item)?
{
// A Flow was disabled, deleted or recompiled during asynchronous
// condition evaluation. Never execute or resurrect its old snapshot.
continue;
}
match result {
Ok((value, trace)) => {
trigger_trace = trace;
@@ -146,7 +163,11 @@ async fn run_automations(state: &AppState) -> Result<()> {
continue;
}
let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; };
if latest_item.updated_at != item.updated_at { continue; }
if !latest_item.enabled || latest_item.updated_at != item.updated_at
|| !state.db.flow_output_enabled(latest_item.flow_id.as_deref())?
{
continue;
}
item = latest_item;
let mut effective_action = item.action.clone();
let mut effective_ha_data = item.action_ha_data.clone();
@@ -200,6 +221,8 @@ async fn run_automations(state: &AppState) -> Result<()> {
if let Some(device) = state.db.get_device(&zone.device_id)? {
if flow_zone_action_already_satisfied(
zone,
item.flow_id.as_deref(),
item.flow_node_id.as_deref(),
device.power,
device.swing_vertical,
device.swing_horizontal,
@@ -212,7 +235,9 @@ async fn run_automations(state: &AppState) -> Result<()> {
let before_runtime = item.flow_runtime.clone();
flow_acknowledge_change_gates(&mut item);
if item.flow_runtime != before_runtime {
state.db.save_automation(&item)?;
// The action lock prevents Flow edits here. A runtime-only
// update still guarantees we cannot overwrite enablement.
if !state.db.save_flow_runtime_if_current(&item)? { continue; }
}
continue;
}
@@ -296,6 +321,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
state, zone_id, item.action_zone_preset.as_deref(), &effective_action,
item.action_zone_heat_anticipation_c, item.action_zone_standby_offset_c,
item.action_zone_simple_heat.as_ref(),
item.flow_id.as_deref(), item.flow_node_id.as_deref(),
).await
} else if let Some(group_id) = item.action_group_id.as_deref() {
let group_mode = effective_action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
@@ -306,7 +332,10 @@ async fn run_automations(state: &AppState) -> Result<()> {
setpoint: effective_action.target_temperature,
}, "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, effective_action.clone()).await {
match apply_automatic_device_action(
state, &item.action_device_id, effective_action.clone(),
item.flow_id.as_deref().zip(item.flow_node_id.as_deref()),
).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!({
@@ -1128,6 +1157,7 @@ async fn apply_flow_zone_action(
state: &AppState, zone_id: &str, preset: Option<&str>, action: &DeviceCommand,
heat_anticipation_c: Option<f64>, standby_offset_c: Option<f64>,
simple_heat: Option<&SimpleHeatSettings>,
flow_id: Option<&str>, flow_node_id: Option<&str>,
) -> Result<bool, AppError> {
let _schedule_guard = state.lock_schedule_operation().await;
// Match the canonical thermostat ordering used by Web/HA zone control. This prevents
@@ -1185,6 +1215,8 @@ async fn apply_flow_zone_action(
zone.updated_at = Utc::now();
zone.control_owner = "automation".into();
zone.control_source = "automation.flow".into();
zone.flow_control_id = flow_id.map(str::to_string);
zone.flow_control_node_id = flow_node_id.map(str::to_string);
zone.control_reason = "Visual Flow automation".into();
let schedules = state.db.list_schedules()?;
let runtime_settings = state.settings.read().await;