v0.8.19
This commit is contained in:
@@ -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
@@ -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!({
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user