This commit is contained in:
Mateusz Gruszczyński
2026-08-28 09:44:39 +02:00
parent 016aac0f8d
commit d159795267
16 changed files with 782 additions and 48 deletions
+111 -7
View File
@@ -23,7 +23,7 @@ use crate::{
home_assistant,
influxdb,
notifications,
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, Zone, ZoneControlPatch, ZoneReading},
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPatch, ZoneReading},
protocol::merge_discovered,
state::AppState,
};
@@ -540,7 +540,7 @@ impl ZoneInput {
sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()),
external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, sensor_stale_after_seconds: self.sensor_stale_after_seconds,
device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(),
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None,
active_preset: "comfort".into(), manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, temporary_quick_thermostat: None,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(),
last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
@@ -611,6 +611,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.manual_override_until = existing.manual_override_until;
zone.local_thermostat_power = existing.local_thermostat_power;
zone.local_thermostat_resume_at = existing.local_thermostat_resume_at;
zone.temporary_quick_thermostat = existing.temporary_quick_thermostat;
zone.device_manual_override = existing.device_manual_override;
zone.device_manual_override_since = existing.device_manual_override_since;
zone.device_manual_override_until = existing.device_manual_override_until;
@@ -666,19 +667,102 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let schedules = state.db.list_schedules()?;
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false);
let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false);
// Any thermostat action takes ownership back from a physical/pilot takeover. Local power
// is a thermostat state of its own and must never be recorded as device manual control.
let resume_device_automation = resume_device_takeover
|| patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some()
|| patch.preset.is_some() || patch.enabled.is_some();
if resume_device_automation || resume_local_thermostat {
|| patch.preset.is_some() || patch.enabled.is_some() || patch.temporary_quick_thermostat.is_some();
if resume_device_automation || resume_local_thermostat || stop_temporary_quick_thermostat || patch.temporary_quick_thermostat.is_some() {
zone.control_source = if source.contains("home_assistant") { "home_assistant_thermostat".into() } else { "web_thermostat".into() };
}
if resume_local_thermostat {
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 let Some(request) = patch.temporary_quick_thermostat.as_ref() {
let now = Utc::now();
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()));
}
let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)));
if !(8.0..=30.0).contains(&target) {
return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into()));
}
let target = (target * 2.0).round() / 2.0;
let tolerance = request.tolerance_c.unwrap_or(0.3);
if !(0.1..=3.0).contains(&tolerance) {
return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into()));
}
let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within");
if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") {
return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into()));
}
let expires_at = match finish_kind {
"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))
}
"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())); }
Some(until)
}
"schedule_boundary" => Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now())
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?),
_ => None,
};
let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable");
let hold_seconds = if finish_kind == "temperature_stable" {
let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?;
if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); }
minutes.saturating_mul(60)
} else { 0 };
let safety_expires_at = if is_temperature_condition {
request.max_duration_minutes.map(|minutes| {
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))
}).transpose()?
} else { None };
// Replacing/editing an active temporary session must keep the state that existed
// before the very first temporary takeover. Otherwise editing a session that was
// started from a disabled zone would incorrectly restore automation as enabled.
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;
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
finish_kind: finish_kind.into(),
started_at: now,
restore_zone_enabled: Some(restore_zone_enabled),
expires_at,
temperature_target: is_temperature_condition.then_some(target),
temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()),
tolerance_c: tolerance,
hold_seconds,
condition_started_at: None,
safety_expires_at,
});
}
if let Some(power) = patch.power {
zone.temporary_quick_thermostat = None;
engine::set_local_thermostat_power(&mut zone, power, Utc::now());
if power { zone.enabled = true; }
// A manually started local thermostat keeps an already selected target/profile until
@@ -694,6 +778,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;
}
}
zone.manual_override_until = if zone.local_thermostat_power == Some(true) {
None
} else {
@@ -735,8 +825,12 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
zone.manual_override_until = None;
}
if let Some(value) = patch.enabled {
zone.enabled = value;
if !value { engine::reset_local_thermostat_override(&mut zone); }
if !value {
engine::reset_local_thermostat_override(&mut zone);
zone.enabled = false;
} else {
zone.enabled = true;
}
}
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
let runtime = state.settings.read().await.clone();
@@ -745,6 +839,15 @@ 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() {
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") {
session.temperature_target = current_target;
session.condition_started_at = None;
}
}
}
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
@@ -773,6 +876,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
"override_until": zone.manual_override_until, "enabled": zone.enabled,
"local_thermostat_power": zone.local_thermostat_power,
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
"temporary_quick_thermostat": zone.temporary_quick_thermostat,
"device_manual_override_cleared": device_override_cleared
}));
Ok(zone)
@@ -1074,7 +1178,7 @@ fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> {
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
let mut cleared = 0;
for mut zone in state.db.list_zones()? {
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() { continue; }
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; }
engine::reset_local_thermostat_override(&mut zone);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
+211 -8
View File
@@ -7,7 +7,7 @@ use crate::{
error::AppError,
home_assistant,
influxdb,
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, Zone, ZoneControlPlan, ZoneReading},
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
state::{AppState, PendingControllerCommand},
};
@@ -48,10 +48,10 @@ pub fn start(state: AppState) {
}
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
let normal_delay = Duration::from_secs(seconds);
let resume_delay = match next_local_thermostat_resume_delay(&control_state) {
let resume_delay = match next_zone_control_deadline_delay(&control_state) {
Ok(value) => value,
Err(err) => {
tracing::warn!(error=?err, "cannot calculate local thermostat resume deadline");
tracing::warn!(error=?err, "cannot calculate thermostat control deadline");
None
}
};
@@ -622,19 +622,123 @@ fn local_thermostat_handback_is_active(zone: &Zone) -> bool {
}
pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
let restore_zone_enabled = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled);
let changed = zone.local_thermostat_power.is_some()
|| zone.local_thermostat_resume_at.is_some()
|| zone.temporary_quick_thermostat.is_some()
|| zone.manual_preset.is_some()
|| zone.manual_setpoint.is_some()
|| zone.manual_override_until.is_some();
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.temporary_quick_thermostat = None;
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
if let Some(enabled) = restore_zone_enabled {
zone.enabled = enabled;
}
changed
}
fn temporary_quick_thermostat_hard_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> {
match (session.expires_at.clone(), session.safety_expires_at.clone()) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
fn temporary_quick_thermostat_next_deadline(session: &TemporaryQuickThermostat) -> Option<DateTime<Utc>> {
let hard = temporary_quick_thermostat_hard_deadline(session);
let hold = if session.finish_kind == "temperature_stable" && session.hold_seconds > 0 {
session.condition_started_at.clone().map(|started| started + chrono::Duration::seconds(session.hold_seconds as i64))
} else {
None
};
match (hard, hold) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
}
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();
for zone in zones.iter_mut() {
let Some(session) = zone.temporary_quick_thermostat.as_ref() else { continue; };
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);
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"
}));
if restores_disabled { restored_disabled_zones.push(zone.id.clone()); }
}
Ok(restored_disabled_zones)
}
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;
let should_stop = state.db.get_zone(&zone.id).ok().flatten()
.map(|latest| !latest.enabled && latest.temporary_quick_thermostat.is_none() && latest.local_thermostat_power.is_none())
.unwrap_or(false);
if !should_stop { return; }
if let Err(err) = send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
state.log("error", "zone.temporary_quick_thermostat_poweroff_error", &err.to_string(), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
}
fn temporary_temperature_condition_met(zone: &Zone, session: &TemporaryQuickThermostat) -> bool {
let (Some(current), Some(target)) = (zone.current_temperature, session.temperature_target) else { return false; };
let tolerance = session.tolerance_c.max(0.0);
match session.temperature_operator.as_deref().unwrap_or("within") {
"at_or_below" => current <= target + tolerance,
"at_or_above" => current >= target - tolerance,
_ => (current - target).abs() <= tolerance,
}
}
/// 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> {
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))?;
let session = zone.temporary_quick_thermostat.as_mut()?;
match session.finish_kind.as_str() {
"temperature_reached" => {
if met { return Some("temperature_reached".into()); }
session.condition_started_at = None;
}
"temperature_stable" => {
if !met {
session.condition_started_at = None;
return None;
}
let started = session.condition_started_at.get_or_insert(now.clone()).clone();
if session.hold_seconds == 0 || now.signed_duration_since(started).num_seconds() >= session.hold_seconds as i64 {
return Some("temperature_stable".into());
}
}
_ => {}
}
None
}
fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], schedules: &[Schedule], house_mode: &str) -> Result<(), AppError> {
let now = Utc::now();
for zone in zones.iter_mut() {
@@ -669,11 +773,19 @@ fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], sched
Ok(())
}
fn next_local_thermostat_resume_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
let now = Utc::now();
Ok(state.db.list_zones()?.into_iter()
.filter(local_thermostat_handback_is_active)
.filter_map(|zone| zone.local_thermostat_resume_at)
.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);
match (local, temporary) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
}
})
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
.min())
}
@@ -693,7 +805,17 @@ pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blo
"home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(),
_ => "local_thermostat".into(),
};
("local_thermostat", source, zone.local_thermostat_resume_at, if zone.local_thermostat_power == Some(false) { "Local thermostat is explicitly off".into() } else { "Local thermostat owns the zone".into() })
let resume_at = zone.temporary_quick_thermostat.as_ref()
.and_then(temporary_quick_thermostat_next_deadline)
.or(zone.local_thermostat_resume_at.clone());
let reason = if zone.local_thermostat_power == Some(false) {
"Local thermostat is explicitly off".into()
} else if zone.temporary_quick_thermostat.is_some() {
"Temporary Quick Thermostat owns the zone".into()
} else {
"Local thermostat owns the zone".into()
};
("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())
} else {
@@ -1146,6 +1268,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
// before the house-power early return so the hand-back still happens while the master
// 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)?;
// Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the
@@ -1286,11 +1409,32 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.control_temperature_source = control_source;
zone.updated_at = Utc::now();
let condition_now = zone.updated_at.clone();
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(&mut zone, condition_now) {
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
reset_local_thermostat_override(&mut zone);
refresh_zone_runtime_target(&mut zone, &schedules, &settings.house_mode);
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
if persisted_zone.temporary_quick_thermostat.is_none() {
ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await;
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": reason
}));
}
state.wake_zone_control();
continue;
}
// A disabled thermostat zone is completely outside normal controller ownership.
// Keep its sensors fresh, but do not let group state, schedules or thermostat
// modulation touch the unit. Manual control from the technical Devices view may
// therefore remain active until the zone is explicitly enabled again.
if !zone.enabled {
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
}
zone.demand = false;
zone.demand_since = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
@@ -2523,7 +2667,7 @@ mod tests {
sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()),
external_sensor_weight: 0.4, max_sensor_difference: 3.0, sensor_stale_after_seconds: 300, device_temperature: None, external_temperature: None,
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None,
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, temporary_quick_thermostat: None,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
@@ -2603,6 +2747,65 @@ mod tests {
assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15);
}
fn temporary_session(now: DateTime<Utc>) -> TemporaryQuickThermostat {
TemporaryQuickThermostat {
finish_kind: "temperature_stable".into(),
started_at: now,
restore_zone_enabled: Some(true),
expires_at: None,
temperature_target: Some(23.0),
temperature_operator: Some("within".into()),
tolerance_c: 0.3,
hold_seconds: 3600,
condition_started_at: None,
safety_expires_at: None,
}
}
#[test]
fn temporary_quick_thermostat_restores_previous_zone_enabled_state() {
let now = Utc::now();
let mut zone = test_zone("device");
zone.enabled = true;
zone.local_thermostat_power = Some(true);
let mut session = temporary_session(now);
session.restore_zone_enabled = Some(false);
zone.temporary_quick_thermostat = Some(session);
zone.manual_setpoint = Some(23.0);
assert!(reset_local_thermostat_override(&mut zone));
assert!(!zone.enabled);
assert!(zone.local_thermostat_power.is_none());
assert!(zone.temporary_quick_thermostat.is_none());
assert!(zone.manual_setpoint.is_none());
}
#[test]
fn temporary_stable_condition_requires_continuous_hold_time() {
let now = Utc::now();
let mut zone = test_zone("device");
zone.current_temperature = Some(23.2);
let mut session = temporary_session(now.clone());
session.condition_started_at = Some(now.clone() - chrono::Duration::seconds(3599));
zone.temporary_quick_thermostat = Some(session);
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now.clone()).is_none());
assert_eq!(evaluate_temporary_quick_thermostat_condition(&mut zone, now + chrono::Duration::seconds(2)), Some("temperature_stable".into()));
}
#[test]
fn temporary_stable_condition_resets_when_temperature_leaves_range() {
let now = Utc::now();
let mut zone = test_zone("device");
zone.current_temperature = Some(24.0);
let mut session = temporary_session(now.clone());
session.condition_started_at = Some(now.clone() - chrono::Duration::minutes(30));
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 local_thermostat_off_restarts_backend_handback_deadline() {
let mut zone = test_zone("device");
+62
View File
@@ -37,6 +37,7 @@ fn default_night_start() -> String { "22:00".into() }
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 }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Device {
@@ -268,6 +269,56 @@ impl From<&Device> for ManualDeviceBaseline {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporaryQuickThermostat {
/// duration | until | temperature_reached | temperature_stable | schedule_boundary
pub finish_kind: String,
pub started_at: 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)]
pub restore_zone_enabled: Option<bool>,
/// Hard end for duration/until/schedule-boundary modes.
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
/// Temperature condition used by reached/stable modes.
#[serde(default)]
pub temperature_target: Option<f64>,
/// within | at_or_below | at_or_above
#[serde(default)]
pub temperature_operator: Option<String>,
#[serde(default = "default_temporary_tolerance")]
pub tolerance_c: f64,
/// Continuous in-condition time required by temperature_stable.
#[serde(default)]
pub hold_seconds: u64,
/// Set only while the latest room samples continuously satisfy the condition.
#[serde(default)]
pub condition_started_at: Option<DateTime<Utc>>,
/// Optional fail-safe for temperature-based modes.
#[serde(default)]
pub safety_expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemporaryQuickThermostatRequest {
pub finish_kind: String,
#[serde(default)]
pub duration_minutes: Option<u64>,
#[serde(default)]
pub until: Option<DateTime<Utc>>,
#[serde(default)]
pub target_temperature: Option<f64>,
#[serde(default)]
pub temperature_operator: Option<String>,
#[serde(default)]
pub tolerance_c: Option<f64>,
#[serde(default)]
pub hold_minutes: Option<u64>,
#[serde(default)]
pub max_duration_minutes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Zone {
pub id: String,
@@ -358,6 +409,11 @@ pub struct Zone {
/// the current group/schedule state is evaluated again.
#[serde(default)]
pub local_thermostat_resume_at: Option<DateTime<Utc>>,
/// Separate, user-defined temporary Quick Thermostat session. This is intentionally
/// independent from local_thermostat_resume_at, which belongs to the local-OFF
/// hand-back mechanism.
#[serde(default)]
pub temporary_quick_thermostat: Option<TemporaryQuickThermostat>,
/// True when the physical unit was changed outside the thermostat engine (for example by IR remote).
/// While active, normal zone/group/schedule automation observes the unit but does not overwrite it.
#[serde(default)]
@@ -463,6 +519,12 @@ pub struct ZoneControlPatch {
/// Return a locally forced quick thermostat to normal group/schedule ownership.
#[serde(default)]
pub clear_local_thermostat_override: Option<bool>,
/// Start or replace a temporary Quick Thermostat session.
#[serde(default)]
pub temporary_quick_thermostat: Option<TemporaryQuickThermostatRequest>,
/// Stop only the temporary Quick Thermostat session and return to automation.
#[serde(default)]
pub clear_temporary_quick_thermostat: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]