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

+105 -20
View File
@@ -533,6 +533,102 @@ async fn handle_zone_pre_control_state(
Ok(false)
}
/// Flow actions are durable zone intent, not just one-shot physical commands. An
/// inactive/deleted Flow must release any remaining thermostat demand and compressor
/// task even if a previous version persisted an enabled compiled automation.
fn release_inactive_flow_zone_intent(
zone: &mut Zone,
active_actions: &HashSet<(String, String, String)>,
) -> bool {
let old_profile = zone.simple_heat.as_ref();
let stale_simple_heat = old_profile.is_some_and(|profile| {
!active_actions.contains(&(
profile.flow_id.clone(), profile.flow_node_id.clone(), zone.id.clone(),
))
});
let actual_owner = zone.flow_control_id.as_deref()
.zip(zone.flow_control_node_id.as_deref())
.or_else(|| old_profile.map(|profile| (profile.flow_id.as_str(), profile.flow_node_id.as_str())));
let active_owner = match actual_owner {
Some((flow_id, node_id)) => active_actions.contains(&(
flow_id.to_string(), node_id.to_string(), zone.id.clone(),
)),
None => active_actions.iter().any(|(_, _, zone_id)| zone_id == &zone.id),
};
// Never clear genuine manual/local/temporary control because a disabled Flow
// happened to be the previous owner of the same thermostat.
let flow_owned = zone.control_source == "automation.flow"
|| (zone.control_source == "automation.device" && zone.flow_control_id.is_some());
let stale_control = flow_owned
&& !zone.device_manual_override && zone.local_thermostat_power.is_none()
&& zone.temporary_quick_thermostat.is_none()
&& !active_owner;
if stale_simple_heat {
zone.simple_heat = None;
zone.simple_heat_runtime = SimpleHeatRuntime::default();
}
if stale_control {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
zone.flow_control_id = None;
zone.flow_control_node_id = None;
zone.control_source = "automation".into();
zone.control_reason = "Disabled/deleted Flow released thermostat control".into();
zone.control_since = Some(Utc::now());
zone.demand = false;
zone.demand_since = None;
zone.heat_coasting = false;
clear_compressor_pending(zone, true);
}
stale_control || stale_simple_heat
}
fn active_flow_zone_action_ids(state: &AppState) -> Result<HashSet<(String, String, String)>> {
let zone_by_device: HashMap<String, String> = state.db.list_zones()?
.into_iter().map(|zone| (zone.device_id, zone.id)).collect();
let mut owners = HashSet::new();
for rule in state.db.list_automations()?.into_iter().filter(|item| item.enabled) {
let (Some(flow_id), Some(node_id)) = (rule.flow_id, rule.flow_node_id) else { continue; };
// Both explicit Flow thermostat actions and Flow commands addressed to the
// underlying GREE device can own durable thermostat state.
let zone_id = rule.action_zone_id.or_else(|| {
if rule.action_group_id.is_some() || rule.action_ha_domain.is_some() {
None
} else {
zone_by_device.get(&rule.action_device_id).cloned()
}
});
if let Some(zone_id) = zone_id {
owners.insert((flow_id, node_id, zone_id));
}
}
Ok(owners)
}
/// Call only with the automation, schedule and thermostat-cycle locks held, in
/// that order (as in the Flow update/delete handlers).
pub(crate) async fn release_inactive_flow_intents(state: &AppState) -> Result<()> {
let active_actions = active_flow_zone_action_ids(state)?;
for item in state.db.list_zones()? {
if item.control_source != "automation.flow"
&& !(item.control_source == "automation.device" && item.flow_control_id.is_some())
&& item.simple_heat.is_none()
{
continue;
}
let _zone_guard = state.lock_zone_operation(&item.id).await;
let Some(mut zone) = state.db.get_zone(&item.id)? else { continue; };
if !release_inactive_flow_zone_intent(&mut zone, &active_actions) { continue; }
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "flow.zone_released", "Inactive Flow released thermostat intent", json!({"zone_id": zone.id}));
}
Ok(())
}
async fn control_zones(state: &AppState) -> Result<()> {
let _cycle_guard = state.lock_zone_control_cycle().await;
let settings = state.settings.read().await.clone();
@@ -541,17 +637,8 @@ async fn control_zones(state: &AppState) -> Result<()> {
}
let schedules = state.db.list_schedules()?;
let mut zone_snapshot = state.db.list_zones()?;
// Only an enabled, compiled Flow action is allowed to retain simple heating
// ownership. Disabling, deleting or recompiling a Flow immediately removes
// stale heating profiles in the next control cycle.
let enabled_heat_actions: HashSet<(String, String, String)> = state.db.list_automations()?
.into_iter()
.filter(|item| item.enabled)
.filter_map(|item| {
let profile = item.action_zone_simple_heat?;
Some((profile.flow_id, profile.flow_node_id, item.action_zone_id?))
})
.collect();
// Flow ownership is valid only while its exact compiled action is enabled.
let active_flow_actions = active_flow_zone_action_ids(state)?;
// Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently.
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode)
.await?;
@@ -587,15 +674,13 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
};
let cycle_started_at = zone.updated_at.clone();
if zone.simple_heat.as_ref().is_some_and(|profile| {
!enabled_heat_actions.contains(&(profile.flow_id.clone(), profile.flow_node_id.clone(), zone.id.clone()))
}) {
zone.simple_heat = None;
zone.simple_heat_runtime = SimpleHeatRuntime::default();
if zone.control_source == "automation.flow" {
zone.control_source = "automation".into();
zone.control_reason = "Simple heating Flow disabled; thermostat idle".into();
}
if release_inactive_flow_zone_intent(&mut zone, &active_flow_actions) {
// Persist even if this device is currently unavailable and the control
// cycle would otherwise return early without saving the zone.
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
let outdoor_temperature = zone_outdoor_sensor_results
.remove(&zone.id)