This commit is contained in:
Mateusz Gruszczyński
2026-09-01 10:20:19 +02:00
parent 1a5c1305dc
commit 3479ed750d
34 changed files with 575 additions and 113 deletions
+16 -3
View File
@@ -1,8 +1,6 @@
async fn run_automations(state: &AppState) -> Result<()> {
if !state.settings.read().await.house_power_enabled { return Ok(()); }
let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
let mut automations = state.db.list_automations()?;
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
// This avoids database row order deciding the physical outcome (M2).
@@ -20,6 +18,20 @@ async fn run_automations(state: &AppState) -> Result<()> {
_ => false,
};
if !should_fire { continue; }
// API edits/deletes and execution share one short ownership window. If the rule
// changed since this cycle snapshot was taken, skip it now and evaluate the new
// definition on the next cycle instead of firing stale configuration.
let _automation_guard = state.lock_automation_operation().await;
let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; };
if latest_item.updated_at != item.updated_at { continue; }
item = latest_item;
// Group membership and zone ownership may have changed after the cycle snapshot but
// before we acquired the automation lock. Reload them inside this serialized window so
// same-cycle conflict arbitration claims the actual current target set.
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
if item.action_group_id.is_none()
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
&& item.action.power != Some(true)
@@ -75,7 +87,8 @@ async fn run_automations(state: &AppState) -> Result<()> {
power: item.action.power,
mode: group_mode,
preset: item.action_preset.clone(),
}, "automation.group").await.map(|_| true)
setpoint: None,
}, "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, item.action.clone()).await {
Ok(Some(_)) => Ok(true),
+55 -8
View File
@@ -5,15 +5,52 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
}
}
if let Some(preset) = patch.preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into()));
if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") {
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep, away or custom".into()));
}
}
if patch.preset.as_deref() == Some("custom") && patch.setpoint.is_none() {
return Err(AppError::BadRequest("group custom preset requires a setpoint".into()));
}
if let Some(setpoint) = patch.setpoint {
if !(8.0..=30.0).contains(&setpoint) {
return Err(AppError::BadRequest("group setpoint must be between 8 and 30 C".into()));
}
if patch.preset.as_deref() != Some("custom") {
return Err(AppError::BadRequest("group setpoint requires preset=custom".into()));
}
}
// House/group actions share one ordering domain. This prevents a concurrent group ON
// (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;
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}")))?;
if source == "automation.group" && !state.settings.read().await.house_power_enabled {
state.log("info", "automation.blocked_by_house_power", &format!("Group automation suppressed while whole-house power is off for {}", group.name), json!({
"group_id": group.id, "source": source
}));
return Ok(json!({
"group": group,
"zones": [],
"devices": state.db.list_devices()?,
"failed": [],
"master_power_enabled": false,
"suppressed": true,
}));
}
let mut locked_zone_ids = group.zone_ids.clone();
locked_zone_ids.sort();
locked_zone_ids.dedup();
let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len());
for zone_id in &locked_zone_ids {
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let schedules = state.db.list_schedules()?;
let climate_change = patch.mode.is_some() || patch.preset.is_some();
let custom_setpoint = patch.setpoint.map(|value| (value * 10.0).round() / 10.0);
let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some();
if let Some(power) = patch.power {
group.power_enabled = power;
}
@@ -24,7 +61,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
// Explicit group ON is a conscious request to run this group. Resume the global
// master without changing the gates of any other groups. This makes group ON work
// even after a previous whole-house OFF while preserving multi-group OFF priority.
if patch.power == Some(true) {
if patch.power == Some(true) && source != "automation.group" {
let mut settings = state.settings.write().await;
if !settings.house_power_enabled {
settings.house_power_enabled = true;
@@ -38,7 +75,6 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let mut zones = Vec::new();
for zone_id in &group.zone_ids {
let _zone_guard = state.lock_zone_operation(zone_id).await;
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
@@ -46,7 +82,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
if temporary_owns_zone {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); }
if let Some(preset) = patch.preset.as_deref() { session.deferred_preset = Some(preset.to_string()); }
if let Some(preset) = patch.preset.as_deref() {
session.deferred_preset = Some(preset.to_string());
if preset != "custom" { session.deferred_setpoint = None; }
}
if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); }
}
if climate_change {
state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({
@@ -71,10 +111,17 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zone.manual_override_until = None;
} else {
zone.manual_preset = Some(preset.to_string());
zone.manual_setpoint = None;
if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
}
}
if let Some(setpoint) = custom_setpoint {
zone.setpoint = setpoint;
zone.manual_preset = Some("custom".into());
zone.manual_setpoint = Some(setpoint);
zone.effective_setpoint = Some(setpoint);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
}
}
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
@@ -115,7 +162,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
state.wake_zone_control();
}
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,
"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,
}));
Ok(json!({
+9 -1
View File
@@ -233,7 +233,9 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
// Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits.
// A device belongs to at most one thermostat zone, but keep this generic for legacy data.
let zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect();
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::new();
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
// Keep the device lock until the zone takeover marker is persisted. Otherwise a poll
@@ -282,6 +284,12 @@ pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
}
/// Same safety transition for callers that already hold the per-device operation lock.
/// Keeping this separate avoids recursive lock acquisition during atomic configuration import.
pub async fn force_power_off_device_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
}
/// Technical device disable is a safety transition, not just a database flag. The unit is
/// explicitly powered off while it is still commandable, then removed from controller polling.
pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<Device, AppError> {
+33
View File
@@ -1,8 +1,27 @@
async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> {
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 guards = Vec::with_capacity(zone_ids.len());
for zone_id in zone_ids {
guards.push(state.lock_zone_operation(&zone_id).await);
}
Ok(guards)
}
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
// Polling can update zone ownership when it detects physical/pilot control. Acquire
// the same zone -> device lock order used by thermostat/manual actions so those writes
// cannot race and overwrite a fresh override or hand-back state.
let _zone_guards = lock_poll_zone_operations(state, device_id).await?;
let _device_guard = state.lock_device_operation(device_id).await;
poll_one_locked(state, device_id).await
}
// Caller must hold the device lock and every current zone lock associated with this device.
async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let mut device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
@@ -26,12 +45,26 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
.map(|device| device.id)
.collect();
for device_id in device_ids {
let _zone_guards = lock_poll_zone_operations(state, &device_id).await?;
let _device_guard = state.lock_device_operation(&device_id).await;
let _ = poll_one_locked(state, &device_id).await?;
}
Ok(())
}
/// Poll all enabled devices when the caller already holds the corresponding zone and device locks.
/// Used by configuration import so no command/poll can interleave with the replacement.
pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> {
let device_ids: Vec<String> = state.db.list_devices()?.into_iter()
.filter(|device| device.enabled)
.map(|device| device.id)
.collect();
for device_id in device_ids {
let _ = poll_one_locked(state, &device_id).await?;
}
Ok(())
}
async fn poll_device(state: &AppState, device: &mut Device) {
if device.simulated {
simulate_tick(device);
+19 -1
View File
@@ -73,8 +73,20 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule]
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
} else if matches!(preset, "comfort" | "sleep" | "away") {
} else if matches!(preset, "comfort" | "sleep" | "away" | "custom") {
zone.manual_preset = Some(preset.to_string());
if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now());
}
}
// A deferred custom temperature is valid only while the final deferred preset is custom.
// This makes the hand-back robust even if an older persisted session contains a stale
// deferred_setpoint from a previously selected Custom action.
if session.deferred_preset.as_deref() == Some("custom") {
if let Some(setpoint) = session.deferred_setpoint {
zone.setpoint = setpoint;
zone.manual_preset = Some("custom".into());
zone.manual_setpoint = Some(setpoint);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now());
}
}
@@ -88,6 +100,8 @@ async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone]
for zone in zones.iter_mut() {
let zone_id = zone.id.clone();
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
*zone = latest;
let active_under_manual = zone.device_manual_override
@@ -153,6 +167,8 @@ async fn activate_due_temporary_quick_thermostats(
for zone in zones.iter_mut() {
let zone_id = zone.id.clone();
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
*zone = latest;
let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref()
@@ -330,6 +346,8 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone],
for zone in zones.iter_mut() {
let zone_id = zone.id.clone();
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
*zone = latest;
// A direct device/pilot takeover has higher priority than the local-OFF hand-back.
+15
View File
@@ -233,6 +233,7 @@ mod tests {
paused_at: None,
deferred_mode: None,
deferred_preset: None,
deferred_setpoint: None,
safety_expires_at: None,
}
}
@@ -309,6 +310,20 @@ mod tests {
assert_eq!(zone.manual_setpoint, Some(22.0));
}
#[test]
fn deferred_non_custom_preset_wins_over_stale_custom_setpoint() {
let now = Utc::now();
let mut zone = test_zone("device");
let mut session = temporary_session(now);
session.deferred_preset = Some("comfort".into());
session.deferred_setpoint = Some(27.0);
zone.temporary_quick_thermostat = Some(session);
assert!(finish_temporary_quick_thermostat(&mut zone, &[], "cool"));
assert_eq!(zone.manual_preset.as_deref(), Some("comfort"));
assert!(zone.manual_setpoint.is_none());
}
#[test]
fn temporary_session_without_activation_marker_is_never_active() {
let now = Utc::now();
+10 -1
View File
@@ -123,6 +123,7 @@ async fn apply_automatic_device_action(
};
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let _device_guard = state.lock_device_operation(device_id).await;
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
let settings = state.settings.read().await.clone();
if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() {
@@ -173,7 +174,14 @@ async fn apply_automatic_device_action(
}
if command.power == Some(false) {
return force_power_off_device(state, device_id).await.map(Some);
// We already hold the device lock and have re-checked ownership under it. Keep that
// lock through the physical OFF so a poll/manual-takeover update cannot slip between
// the durable zone transition and the device command.
return send_command_locked_forced(
state,
device_id,
DeviceCommand { power: Some(false), ..Default::default() },
).await.map(Some);
}
// Climate fields above are durable zone state. Only non-climate device capabilities remain
@@ -196,6 +204,7 @@ async fn apply_automatic_device_action(
if residual.is_empty() {
return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}")));
}
drop(_device_guard);
send_automatic_device_command_if_owned(state, device_id, residual).await
}