This commit is contained in:
Mateusz Gruszczyński
2026-08-28 10:04:35 +02:00
parent d159795267
commit eb325cc0c8
15 changed files with 392 additions and 653 deletions
+71 -20
View File
@@ -681,10 +681,37 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
engine::reset_local_thermostat_override(&mut zone);
}
if stop_temporary_quick_thermostat && zone.temporary_quick_thermostat.is_some() {
engine::reset_local_thermostat_override(&mut zone);
if zone.local_thermostat_power == Some(true) {
engine::reset_local_thermostat_override(&mut zone);
} else {
// Cancelling a delayed session before it starts must not erase unrelated
// quick preset/setpoint state that automation may be using in the meantime.
zone.temporary_quick_thermostat = None;
}
}
if let Some(request) = patch.temporary_quick_thermostat.as_ref() {
let now = Utc::now();
let start_kind = request.start_kind.as_str();
if !matches!(start_kind, "now" | "delay" | "at") {
return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into()));
}
let started_at = match start_kind {
"now" => now.clone(),
"delay" => {
let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?;
if !(1..=43_200).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into()));
}
now.clone() + ChronoDuration::minutes(minutes as i64)
}
"at" => {
let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?;
if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); }
if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); }
at
}
_ => unreachable!(),
};
let finish_kind = request.finish_kind.as_str();
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
@@ -708,15 +735,15 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
"duration" => {
let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?;
if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); }
Some(now.clone() + ChronoDuration::minutes(minutes as i64))
Some(started_at.clone() + ChronoDuration::minutes(minutes as i64))
}
"until" => {
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
if until <= now { return Err(AppError::BadRequest("temporary thermostat end time must be in the future".into())); }
if until > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days away".into())); }
if until <= started_at { return Err(AppError::BadRequest("temporary thermostat end time must be after its start time".into())); }
if until > started_at.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); }
Some(until)
}
"schedule_boundary" => Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
"schedule_boundary" => Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, started_at.with_timezone(&chrono::Local))
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?),
_ => None,
};
@@ -732,7 +759,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
if !(1..=14_400).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into()));
}
Ok(now.clone() + ChronoDuration::minutes(minutes as i64))
Ok(started_at.clone() + ChronoDuration::minutes(minutes as i64))
}).transpose()?
} else { None };
@@ -742,18 +769,32 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref()
.and_then(|session| session.restore_zone_enabled)
.unwrap_or(zone.enabled);
engine::set_local_thermostat_power(&mut 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;
let activate_now = start_kind == "now";
if !activate_now && zone.temporary_quick_thermostat.is_some() {
if zone.local_thermostat_power == Some(true) {
engine::reset_local_thermostat_override(&mut zone);
} else {
zone.temporary_quick_thermostat = None;
}
}
if activate_now {
engine::set_local_thermostat_power(&mut 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;
}
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
start_kind: start_kind.into(),
finish_kind: finish_kind.into(),
started_at: now,
started_at,
activated_at: activate_now.then_some(now),
restore_zone_enabled: Some(restore_zone_enabled),
expires_at,
temperature_target: is_temperature_condition.then_some(target),
// Keep the requested thermostat target even for delayed time-based sessions;
// it is applied only when ownership actually starts.
temperature_target: Some(target),
temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()),
tolerance_c: tolerance,
hold_seconds,
@@ -778,10 +819,12 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
zone.setpoint = value;
zone.manual_setpoint = Some(value);
zone.effective_setpoint = Some(value);
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
session.temperature_target = Some(value);
session.condition_started_at = None;
if zone.local_thermostat_power == Some(true) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
session.temperature_target = Some(value);
session.condition_started_at = None;
}
}
}
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
@@ -839,7 +882,9 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
&& state.db.list_groups()?.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_id == &zone.id));
engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled, blocked_by_group);
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
if (patch.preset.is_some() || patch.clear_override.unwrap_or(false)) && zone.temporary_quick_thermostat.is_some() {
if (patch.preset.is_some() || patch.clear_override.unwrap_or(false))
&& zone.local_thermostat_power == Some(true)
&& zone.temporary_quick_thermostat.is_some() {
let current_target = zone.effective_setpoint;
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
@@ -1478,7 +1523,13 @@ fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(),
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override { return Ok(()); }
let schedules = state.db.list_schedules()?;
let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
if zone.manual_preset.is_some() || zone.manual_setpoint.is_some() { zone.manual_override_until = boundary; }
// An active Temporary Quick Thermostat explicitly owns its target until its own finish
// rule. Editing/applying schedules must not arm the generic quick-setpoint boundary and
// accidentally clear that target at the next schedule transition.
let temporary_owns_zone = zone.local_thermostat_power == Some(true) && zone.temporary_quick_thermostat.is_some();
if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone {
zone.manual_override_until = boundary;
}
if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; }
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
+135 -16
View File
@@ -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");
+16
View File
@@ -38,6 +38,7 @@ fn default_night_end() -> String { "06:00".into() }
fn default_night_max_fan_speed() -> u8 { 1 }
fn default_group_power_enabled() -> bool { true }
fn default_temporary_tolerance() -> f64 { 0.3 }
fn default_temporary_start_kind() -> String { "now".into() }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
@@ -271,9 +272,18 @@ impl From<&Device> for ManualDeviceBaseline {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporaryQuickThermostat {
/// now | delay | at. `started_at` is the effective start instant and may be in the future.
#[serde(default = "default_temporary_start_kind")]
pub start_kind: String,
/// duration | until | temperature_reached | temperature_stable | schedule_boundary
pub finish_kind: String,
/// Effective start instant. Future values mean the temporary thermostat is scheduled
/// but does not yet own the zone or block normal schedules/automations.
pub started_at: DateTime<Utc>,
/// Set when the controller actually activates ownership. Kept separate from started_at
/// so a delayed session can survive restarts without being mistaken for an active one.
#[serde(default)]
pub activated_at: Option<DateTime<Utc>>,
/// Zone automation enabled-state from before the temporary session. A temporary
/// Quick Thermostat may run even when normal automation was disabled, then restore it.
#[serde(default)]
@@ -302,6 +312,12 @@ pub struct TemporaryQuickThermostat {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporaryQuickThermostatRequest {
#[serde(default = "default_temporary_start_kind")]
pub start_kind: String,
#[serde(default)]
pub start_delay_minutes: Option<u64>,
#[serde(default)]
pub start_at: Option<DateTime<Utc>>,
pub finish_kind: String,
#[serde(default)]
pub duration_minutes: Option<u64>,