This commit is contained in:
Mateusz Gruszczyński
2026-09-01 11:10:28 +02:00
parent 3479ed750d
commit 9f08d7ccf9
30 changed files with 559 additions and 150 deletions
+26
View File
@@ -1,3 +1,16 @@
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()?;
@@ -62,6 +75,19 @@ async fn run_automations(state: &AppState) -> Result<()> {
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()
+9 -9
View File
@@ -170,15 +170,15 @@ async fn send_command_locked_inner(
}
fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
if before.power == after.power && before.mode == after.mode { return Ok(()); }
let now = Utc::now();
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
if before.power != after.power { zone.last_power_change_at = Some(now); }
if before.mode != after.mode { zone.last_mode_change_at = Some(now); }
// Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field
// as its optimistic snapshot guard. The cycle mirrors these timestamps into its own
// computed Zone after a successful automatic command.
state.db.save_zone(&zone)?;
let power_changed = before.power != after.power;
let mode_changed = before.mode != after.mode;
if !power_changed && !mode_changed { return Ok(()); }
// This function is often called while the device lock is held, so acquiring a zone lock
// here would invert the global zone -> device order. Merge only these timestamp fields
// with a DB compare-and-swap instead of saving a stale whole-zone snapshot.
for zone in state.db.merge_zone_device_transition_timestamps(
&after.id, power_changed, mode_changed, Utc::now(),
)? {
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
+30 -3
View File
@@ -25,6 +25,10 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
// (especially from an automation) from resurrecting the master while whole-house OFF
// is being applied. Group and zone locks then make the member update atomic.
let _house_guard = state.lock_house_operation().await;
// Group state participates in thermostat arbitration. Exclude an already-running cycle
// while changing the group gate/profile so no cycle can act on a stale group snapshot.
// Lock order stays house -> cycle -> group -> zone -> device.
let _cycle_guard = state.lock_zone_control_cycle().await;
let _group_guard = state.lock_group_operation(group_id).await;
let mut group = state.db.get_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
@@ -123,6 +127,12 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
}
}
if !temporary_owns_zone && !zone.device_manual_override && zone.local_thermostat_power.is_none() {
zone.control_owner = "automation".into();
zone.control_source = format!("group:{}", group.id);
zone.control_since = Some(Utc::now());
zone.control_reason = format!("Controlled by group {}", group.name);
}
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
@@ -158,9 +168,26 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
}
}
if desired_power && (should_command_power || climate_change) {
state.wake_zone_control();
}
let run_immediately = desired_power && (should_command_power || climate_change);
let zones = if run_immediately {
// Do not just wake the background loop: a group ON/profile/setpoint action is expected
// to arbitrate every member before the HTTP request completes. Release member/domain
// locks first, then run one globally serialized thermostat cycle.
drop(_zone_guards);
drop(_group_guard);
drop(_cycle_guard);
drop(_house_guard);
if let Err(err) = run_zone_control_now(state).await {
state.log("error", "group.immediate_control_error", &err.to_string(), json!({
"group_id": group.id, "source": source,
}));
failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()}));
}
group.zone_ids.iter().filter_map(|zone_id| state.db.get_zone(zone_id).ok().flatten()).collect::<Vec<_>>()
} else {
zones
};
state.log("info", source, &format!("Updated group {}", group.name), json!({
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, "setpoint": custom_setpoint,
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
+34 -23
View File
@@ -25,9 +25,11 @@ pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blo
};
("local_thermostat", source, resume_at, reason)
} else if blocked_by_group {
("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string())
let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "group".into() };
("automation", source, None, "Zone is blocked by a disabled group".to_string())
} else {
("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "automation".into() };
("automation", source, zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
};
if zone.control_owner != owner || zone.control_source != source {
zone.control_since = Some(now);
@@ -156,12 +158,11 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
zone.control_owner = "direct_manual".into();
zone.control_source = normalized_direct_source(source).into();
zone.control_reason = "Direct/manual device control has priority".into();
zone.device_manual_override_until = if zone.enabled {
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now())
} else {
None
};
zone.control_resume_at = zone.device_manual_override_until;
// Direct/pilot control is an explicit ownership takeover, not a temporary preset.
// Keep it manual until the user chooses Resume automation (or a global safety OFF).
// A schedule boundary must never silently take the unit back from the person controlling it.
zone.device_manual_override_until = None;
zone.control_resume_at = None;
for field in fields {
if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) {
zone.device_manual_override_fields.push(field);
@@ -196,10 +197,9 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
after,
raw_fields.clone(),
).await;
if zone.device_manual_override && manual_override_matches_baseline(&zone, after) {
persist_manual_override_clear(state, &mut zone, "gree_poll", true)?;
continue;
}
// Once direct/pilot ownership has been detected, merely returning the device to a
// previous physical state must not silently hand control back to schedules. Only the
// explicit Resume automation action (or global OFF safety reset) ends manual ownership.
if fields.is_empty() { continue; }
// A disabled zone is outside controller ownership. When its manually operated unit is
// switched off there is no takeover left to display or remember.
@@ -243,15 +243,14 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
let _device_guard = state.lock_device_operation(device_id).await;
let before = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() };
let fields = command_manual_control_fields(&effective_command);
// An explicit direct-control request is an ownership action even when the requested
// value already matches the cached device state. Derive takeover fields from the user's
// request, not only from the physical delta, so clicking ON / entering the current target
// still switches the thermostat zone to persistent manual control.
let fields = command_manual_control_fields(&command);
let updated = send_command_locked_inner(state, device_id, command, true, false).await?;
if !fields.is_empty() {
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) {
persist_manual_override_clear(state, &mut zone, source, true)?;
continue;
}
if !zone.enabled && !updated.power {
persist_manual_override_clear(state, &mut zone, source, false)?;
continue;
@@ -263,11 +262,22 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
}
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, source: &str) -> Result<Device, AppError> {
// Global OFF is a one-shot authority transition. Clear takeover and send OFF while
// polling for this unit is excluded; a later remote change happens after the lock and
// is therefore preserved as a new manual takeover.
// Preserve the global zone -> device lock order even for the whole-house safety path.
// Otherwise a zone edit could hold its zone lock while waiting for this device lock as
// this function saved a stale zone snapshot without owning the corresponding zone lock.
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
.filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id)
.collect();
zone_ids.sort();
zone_ids.dedup();
let mut _zone_guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids {
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let _device_guard = state.lock_device_operation(device_id).await;
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
for zone_id in &zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if !reset_device_manual_override(&mut zone) { continue; }
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
@@ -276,7 +286,8 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, sou
"zone_id": zone.id, "device_id": zone.device_id, "source": source
}));
}
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
// The device lock is already held, so use the locked forced-command variant directly.
force_power_off_device_locked(state, device_id).await
}
pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
+9
View File
@@ -167,6 +167,15 @@ mod tests {
assert!(!command_field_matches_device(&command, "target_temperature", &device));
}
#[test]
fn conflicting_thermostat_output_fields_are_detected_for_direct_automation() {
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { power: Some(true), ..Default::default() }));
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { target_temperature: Some(22.0), ..Default::default() }));
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { fan_speed: Some(3), ..Default::default() }));
assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { quiet: Some(true), ..Default::default() }));
assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { light: Some(false), turbo: Some(true), ..Default::default() }));
}
#[test]
fn local_thermostat_ownership_blocks_direct_automation() {
let mut zone = test_zone("device");
+3
View File
@@ -117,6 +117,9 @@ async fn apply_automatic_device_action(
device_id: &str,
command: DeviceCommand,
) -> Result<Option<Device>, AppError> {
// Direct automation target/setpoint is translated into zone state and may use the next
// schedule boundary. Serialize that derivation with schedule edits before locking the zone.
let _schedule_guard = state.lock_schedule_operation().await;
let zones = state.db.list_zones()?;
let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else {
return send_automatic_device_command_if_owned(state, device_id, command).await;
+24 -4
View File
@@ -1,4 +1,5 @@
async fn control_zones(state: &AppState) -> Result<()> {
let _cycle_guard = state.lock_zone_control_cycle().await;
let schedules = state.db.list_schedules()?;
let groups = state.db.list_groups()?;
let settings = state.settings.read().await.clone();
@@ -78,16 +79,29 @@ async fn control_zones(state: &AppState) -> Result<()> {
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
.collect();
for mut zone in zone_snapshot {
for zone_snapshot_item in zone_snapshot {
// Every thermostat decision participates in the same zone -> device ordering as
// Web/HA/manual control and polling. Re-read after taking the zone lock so an
// interactive change cannot be evaluated from a stale snapshot.
let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await;
let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; };
let cycle_started_at = zone.updated_at;
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
if zone.control_source.starts_with("group:") {
zone.control_source = "automation".into();
zone.control_since = Some(Utc::now());
zone.control_reason = "Group override expired at schedule boundary".into();
}
}
if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
reset_device_manual_override(&mut zone);
state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({
// v0.8.20 makes direct/manual takeover persistent. Normalize any legacy persisted
// boundary from older releases instead of silently returning ownership to schedules.
if zone.device_manual_override && zone.device_manual_override_until.is_some() {
zone.device_manual_override_until = None;
zone.control_resume_at = None;
state.log("info", "zone.device_manual_override_migrated", &format!("Manual device control remains active for {} until explicit resume", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
@@ -541,3 +555,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
Ok(())
}
/// Run one thermostat arbitration cycle immediately and wait for all currently eligible zones.
/// The cycle lock prevents overlap with the background regulator.
pub async fn run_zone_control_now(state: &AppState) -> Result<(), AppError> {
control_zones(state).await.map_err(AppError::from)
}