v0.8.4
This commit is contained in:
+135
-16
@@ -665,6 +665,12 @@ fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat)
|
||||
}
|
||||
}
|
||||
|
||||
fn temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let session = zone.temporary_quick_thermostat.as_ref()?;
|
||||
let pending = session.activated_at.is_none() && zone.local_thermostat_power != Some(true) && session.started_at > now;
|
||||
if pending { Some(session.started_at.clone()) } else { temporary_quick_thermostat_next_deadline(session) }
|
||||
}
|
||||
|
||||
fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<Vec<String>, AppError> {
|
||||
let now = Utc::now();
|
||||
let mut restored_disabled_zones = Vec::new();
|
||||
@@ -673,20 +679,62 @@ fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone], sche
|
||||
let Some(deadline) = temporary_quick_thermostat_hard_deadline(session) else { continue; };
|
||||
if deadline > now { continue; }
|
||||
let finish_kind = session.finish_kind.clone();
|
||||
let restores_disabled = session.restore_zone_enabled == Some(false);
|
||||
reset_local_thermostat_override(zone);
|
||||
refresh_zone_runtime_target(zone, schedules, house_mode);
|
||||
let was_activated = session.activated_at.is_some() || zone.local_thermostat_power == Some(true);
|
||||
let restores_disabled = was_activated && session.restore_zone_enabled == Some(false);
|
||||
if was_activated {
|
||||
reset_local_thermostat_override(zone);
|
||||
refresh_zone_runtime_target(zone, schedules, house_mode);
|
||||
} else {
|
||||
// A delayed session that expired before it ever acquired ownership must not
|
||||
// clear unrelated manual/schedule state that was active while it was waiting.
|
||||
zone.temporary_quick_thermostat = None;
|
||||
}
|
||||
zone.updated_at = now.clone();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": "deadline"
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind,
|
||||
"reason": if was_activated { "deadline" } else { "expired_before_activation" }
|
||||
}));
|
||||
if restores_disabled { restored_disabled_zones.push(zone.id.clone()); }
|
||||
}
|
||||
Ok(restored_disabled_zones)
|
||||
}
|
||||
|
||||
fn activate_due_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone]) -> Result<(), AppError> {
|
||||
let now = Utc::now();
|
||||
for zone in zones.iter_mut() {
|
||||
let Some(session) = zone.temporary_quick_thermostat.as_ref() else { continue; };
|
||||
let already_active = session.activated_at.is_some() || zone.local_thermostat_power == Some(true);
|
||||
if already_active || session.started_at > now { continue; }
|
||||
if temporary_quick_thermostat_hard_deadline(session).map(|deadline| deadline <= now).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let target = session.temperature_target
|
||||
.or(zone.manual_setpoint)
|
||||
.or(zone.effective_setpoint)
|
||||
.unwrap_or(zone.setpoint);
|
||||
set_local_thermostat_power(zone, true, now.clone());
|
||||
zone.enabled = true;
|
||||
zone.setpoint = target;
|
||||
zone.manual_setpoint = Some(target);
|
||||
zone.effective_setpoint = Some(target);
|
||||
zone.manual_override_until = None;
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.activated_at = Some(now.clone());
|
||||
session.condition_started_at = None;
|
||||
}
|
||||
zone.updated_at = now.clone();
|
||||
state.db.save_zone(zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
||||
state.log("info", "zone.temporary_quick_thermostat_started", &format!("Temporary Quick Thermostat started for {}", zone.name), json!({
|
||||
"zone_id": zone.id, "device_id": zone.device_id, "scheduled_start": true, "target_temperature": target
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_device_off_after_temporary_disabled_restore(state: &AppState, zone: &Zone, device: &Device) {
|
||||
if zone.enabled || !device.enabled || !device.online || device.communication_failures > 0 || !device.power { return; }
|
||||
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
||||
@@ -714,6 +762,7 @@ fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickTher
|
||||
/// Update a temperature-based temporary session from the freshly selected room sensor.
|
||||
/// Returns a completion reason when ownership should be handed back immediately.
|
||||
fn evaluate_temporary_quick_thermostat_condition(zone: &mut Zone, now: DateTime<Utc>) -> Option<String> {
|
||||
if zone.local_thermostat_power != Some(true) { return None; }
|
||||
let met = zone.temporary_quick_thermostat.as_ref()
|
||||
.filter(|session| matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable"))
|
||||
.map(|session| temporary_temperature_condition_met(zone, session))?;
|
||||
@@ -778,7 +827,7 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>
|
||||
Ok(state.db.list_zones()?.into_iter()
|
||||
.filter_map(|zone| {
|
||||
let local = if local_thermostat_handback_is_active(&zone) { zone.local_thermostat_resume_at.clone() } else { None };
|
||||
let temporary = zone.temporary_quick_thermostat.as_ref().and_then(temporary_quick_thermostat_next_deadline);
|
||||
let temporary = temporary_quick_thermostat_wakeup_at(&zone, now.clone());
|
||||
match (local, temporary) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
(Some(a), None) => Some(a),
|
||||
@@ -1072,7 +1121,8 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
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; };
|
||||
if let Some(mode) = patch.mode.as_deref() {
|
||||
let temporary_owns_zone = zone.local_thermostat_power == Some(true) && zone.temporary_quick_thermostat.is_some();
|
||||
if let Some(mode) = patch.mode.as_deref().filter(|_| !temporary_owns_zone) {
|
||||
match mode {
|
||||
"house" | "auto" => zone.inherit_house_mode = true,
|
||||
"cool" | "heat" => {
|
||||
@@ -1082,7 +1132,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(preset) = patch.preset.as_deref() {
|
||||
if let Some(preset) = patch.preset.as_deref().filter(|_| !temporary_owns_zone) {
|
||||
if preset == "auto" {
|
||||
zone.manual_preset = None;
|
||||
zone.manual_setpoint = None;
|
||||
@@ -1093,6 +1143,11 @@ 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 && (patch.mode.is_some() || patch.preset.is_some()) {
|
||||
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!({
|
||||
"zone_id": zone.id, "group_id": group.id, "source": source
|
||||
}));
|
||||
}
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
@@ -1269,6 +1324,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
// is off; no physical state is restored here, only automation ownership.
|
||||
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode)?;
|
||||
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode)?;
|
||||
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot)?;
|
||||
|
||||
// Outdoor temperature is deliberately optional. Prefer the configured Home
|
||||
// Assistant entity, but keep the dashboard/assist useful by falling back to the
|
||||
@@ -1359,11 +1415,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
|
||||
// House "off" means the smart thermostat does not control inherited zones.
|
||||
// A zone explicitly switched to heat/cool remains independent and may still run.
|
||||
let effective_mode_owned = if zone.inherit_house_mode {
|
||||
settings.house_mode.clone()
|
||||
} else {
|
||||
zone.mode.clone()
|
||||
};
|
||||
// Local Quick Thermostat is an explicit per-zone request. If the inherited house
|
||||
// climate mode is "off" (no automatic climate control), use the zone's last local
|
||||
// heat/cool mode while local ownership is ON. The separate whole-house master power
|
||||
// remains authoritative and is checked before this loop.
|
||||
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
|
||||
zone.effective_mode = effective_mode_owned.clone();
|
||||
let ownership_blocked_by_group = zone.local_thermostat_power != Some(true)
|
||||
&& groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
|
||||
@@ -2055,9 +2111,9 @@ fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) ->
|
||||
}
|
||||
|
||||
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
|
||||
let configured_mode = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
|
||||
zone.effective_mode = configured_mode.to_string();
|
||||
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode };
|
||||
let configured_mode = effective_zone_mode(zone, house_mode);
|
||||
zone.effective_mode = configured_mode.clone();
|
||||
let target_mode = if configured_mode == "off" { zone.mode.as_str() } else { configured_mode.as_str() };
|
||||
let schedule = active_schedule_for_zone(zone, schedules, Local::now());
|
||||
let (preset, target) = resolve_zone_target(zone, schedule, target_mode);
|
||||
zone.active_preset = preset;
|
||||
@@ -2066,6 +2122,15 @@ pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], hous
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
|
||||
let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
|
||||
if zone.local_thermostat_power == Some(true) && configured == "off" {
|
||||
zone.mode.clone()
|
||||
} else {
|
||||
configured.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn active_schedule_for_zone<'a>(zone: &Zone, schedules: &'a [Schedule], now: DateTime<Local>) -> Option<&'a Schedule> {
|
||||
schedules.iter()
|
||||
.filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now))
|
||||
@@ -2749,8 +2814,10 @@ mod tests {
|
||||
|
||||
fn temporary_session(now: DateTime<Utc>) -> TemporaryQuickThermostat {
|
||||
TemporaryQuickThermostat {
|
||||
start_kind: "now".into(),
|
||||
finish_kind: "temperature_stable".into(),
|
||||
started_at: now,
|
||||
started_at: now.clone(),
|
||||
activated_at: Some(now),
|
||||
restore_zone_enabled: Some(true),
|
||||
expires_at: None,
|
||||
temperature_target: Some(23.0),
|
||||
@@ -2806,6 +2873,58 @@ mod tests {
|
||||
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_temporary_session_does_not_block_automation_before_start() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
let mut session = temporary_session(now.clone() + chrono::Duration::hours(1));
|
||||
session.start_kind = "delay".into();
|
||||
session.activated_at = None;
|
||||
session.expires_at = Some(now.clone() + chrono::Duration::hours(3));
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(!device_blocked_by_local_thermostat(&zone.device_id, std::slice::from_ref(&zone)));
|
||||
assert_eq!(temporary_quick_thermostat_wakeup_at(&zone, now.clone()), Some(now + chrono::Duration::hours(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_temperature_condition_cannot_finish_before_activation() {
|
||||
let now = Utc::now();
|
||||
let mut zone = test_zone("device");
|
||||
zone.current_temperature = Some(23.0);
|
||||
let mut session = temporary_session(now.clone() + chrono::Duration::hours(1));
|
||||
session.start_kind = "at".into();
|
||||
session.activated_at = None;
|
||||
zone.temporary_quick_thermostat = Some(session);
|
||||
|
||||
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now).is_none());
|
||||
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_setpoint_keeps_priority_over_active_schedule() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.local_thermostat_power = Some(true);
|
||||
zone.manual_setpoint = Some(23.0);
|
||||
zone.temporary_quick_thermostat = Some(temporary_session(Utc::now()));
|
||||
let mut schedule = test_schedule("night", vec![1,2,3,4,5,6,7], "00:00", "00:00");
|
||||
schedule.preset = "custom".into();
|
||||
schedule.setpoint = 19.0;
|
||||
|
||||
let (_preset, target) = resolve_zone_target(&zone, Some(&schedule), "cool");
|
||||
assert_eq!(target, 23.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_quick_thermostat_can_run_when_inherited_house_mode_is_off() {
|
||||
let mut zone = test_zone("device");
|
||||
zone.inherit_house_mode = true;
|
||||
zone.mode = "cool".into();
|
||||
assert_eq!(effective_zone_mode(&zone, "off"), "off");
|
||||
zone.local_thermostat_power = Some(true);
|
||||
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_thermostat_off_restarts_backend_handback_deadline() {
|
||||
let mut zone = test_zone("device");
|
||||
|
||||
Reference in New Issue
Block a user