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
+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> {