555 lines
21 KiB
Rust
555 lines
21 KiB
Rust
pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime<Utc>) -> bool {
|
|
zone.temporary_quick_thermostat
|
|
.as_ref()
|
|
.and_then(|session| session.activated_at.as_ref())
|
|
.map(|activated_at| activated_at <= &now)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn temporary_quick_thermostat_hard_deadline(
|
|
session: &TemporaryQuickThermostat,
|
|
) -> Option<DateTime<Utc>> {
|
|
if session.state == "paused_manual" {
|
|
return None;
|
|
}
|
|
match (session.expires_at, session.safety_expires_at) {
|
|
(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
|
|
.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 temporary_quick_thermostat_wakeup_at(zone: &Zone, now: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
|
let session = zone.temporary_quick_thermostat.as_ref()?;
|
|
if temporary_quick_thermostat_is_active(zone, now) {
|
|
return temporary_quick_thermostat_next_deadline(session);
|
|
}
|
|
// Once a due session is waiting for master/manual ownership, normal wakeups or an
|
|
// explicit state-change notification will retry it. Returning a past start would spin.
|
|
(session.started_at > now).then_some(session.started_at)
|
|
}
|
|
|
|
/// Finish an active temporary session and apply climate changes that were deferred while
|
|
/// it owned the zone. Pending-session cancellation should simply remove the session instead.
|
|
pub fn finish_temporary_quick_thermostat(
|
|
zone: &mut Zone,
|
|
schedules: &[Schedule],
|
|
house_mode: &str,
|
|
) -> bool {
|
|
let now = Utc::now();
|
|
let was_active = temporary_quick_thermostat_is_active(zone, now);
|
|
let Some(session) = zone.temporary_quick_thermostat.take() else {
|
|
return false;
|
|
};
|
|
if !was_active {
|
|
return false;
|
|
}
|
|
|
|
zone.local_thermostat_power = session.restore_local_thermostat_power;
|
|
zone.local_thermostat_resume_at = session.restore_local_thermostat_resume_at;
|
|
zone.local_thermostat_restore_zone_enabled = session.restore_local_thermostat_zone_enabled;
|
|
zone.manual_preset = session.restore_manual_preset;
|
|
zone.manual_setpoint = session.restore_manual_setpoint;
|
|
zone.manual_override_until = session.restore_manual_override_until;
|
|
if let Some(enabled) = session.restore_zone_enabled {
|
|
zone.enabled = enabled;
|
|
}
|
|
|
|
if let Some(mode) = session.deferred_mode.as_deref() {
|
|
match mode {
|
|
"house" | "auto" => zone.inherit_house_mode = true,
|
|
"cool" | "heat" => {
|
|
zone.inherit_house_mode = false;
|
|
zone.mode = mode.to_string();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if let Some(preset) = session.deferred_preset.as_deref() {
|
|
if preset == "auto" {
|
|
zone.manual_preset = None;
|
|
zone.manual_setpoint = None;
|
|
zone.manual_override_until = None;
|
|
} 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());
|
|
}
|
|
}
|
|
refresh_zone_runtime_target(zone, schedules, house_mode);
|
|
// The temporary session has ended and ownership/intent changes again. A cancellation
|
|
// that belonged to the temporary intent must not suppress the restored thermostat state.
|
|
rearm_compressor_queue(zone);
|
|
true
|
|
}
|
|
|
|
async 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 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
|
|
&& zone
|
|
.temporary_quick_thermostat
|
|
.as_ref()
|
|
.and_then(|session| session.activated_at)
|
|
.is_some();
|
|
if active_under_manual {
|
|
set_temporary_wait_state(state, zone, "paused_manual", now)?;
|
|
continue;
|
|
}
|
|
let Some((deadline, finish_kind, restore_zone_enabled)) = zone
|
|
.temporary_quick_thermostat
|
|
.as_ref()
|
|
.and_then(|session| {
|
|
temporary_quick_thermostat_hard_deadline(session).map(|deadline| {
|
|
(
|
|
deadline,
|
|
session.finish_kind.clone(),
|
|
session.restore_zone_enabled,
|
|
)
|
|
})
|
|
})
|
|
else {
|
|
continue;
|
|
};
|
|
if deadline > now {
|
|
continue;
|
|
}
|
|
let was_activated = temporary_quick_thermostat_is_active(zone, now);
|
|
let restores_disabled = was_activated && restore_zone_enabled == Some(false);
|
|
if was_activated {
|
|
finish_temporary_quick_thermostat(zone, schedules, house_mode);
|
|
} else {
|
|
// A delayed session that expires before it acquires ownership must not clear
|
|
// unrelated local/manual/schedule state that was active while it was waiting.
|
|
zone.temporary_quick_thermostat = None;
|
|
}
|
|
zone.updated_at = now;
|
|
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": if was_activated { "deadline" } else { "expired_before_activation" }
|
|
}),
|
|
);
|
|
if restores_disabled {
|
|
restored_disabled_zones.push(zone.id.clone());
|
|
}
|
|
}
|
|
Ok(restored_disabled_zones)
|
|
}
|
|
|
|
fn set_temporary_wait_state(
|
|
state: &AppState,
|
|
zone: &mut Zone,
|
|
value: &str,
|
|
now: DateTime<Utc>,
|
|
) -> Result<(), AppError> {
|
|
let Some(session) = zone.temporary_quick_thermostat.as_mut() else {
|
|
return Ok(());
|
|
};
|
|
let mut changed = false;
|
|
if session.state != value {
|
|
session.state = value.to_string();
|
|
changed = true;
|
|
}
|
|
if value == "paused_manual" && session.paused_at.is_none() {
|
|
session.paused_at = Some(now);
|
|
changed = true;
|
|
}
|
|
if !changed {
|
|
return Ok(());
|
|
}
|
|
session.condition_started_at = None;
|
|
session.condition_last_observed_at = None;
|
|
zone.updated_at = now;
|
|
state.db.save_zone(zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
|
Ok(())
|
|
}
|
|
|
|
async fn activate_due_temporary_quick_thermostats(
|
|
state: &AppState,
|
|
zones: &mut [Zone],
|
|
schedules: &[Schedule],
|
|
house_mode: &str,
|
|
) -> Result<(), AppError> {
|
|
let now = Utc::now();
|
|
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()
|
|
.map(|session| (session.state.clone(), session.started_at))
|
|
else {
|
|
continue;
|
|
};
|
|
if temporary_quick_thermostat_is_active(zone, now) {
|
|
if session_state != "active" && !zone.device_manual_override {
|
|
set_temporary_wait_state(state, zone, "active", now)?;
|
|
}
|
|
continue;
|
|
}
|
|
if started_at > now {
|
|
continue;
|
|
}
|
|
if zone.device_manual_override {
|
|
set_temporary_wait_state(state, zone, "paused_manual", now)?;
|
|
continue;
|
|
}
|
|
|
|
let (temperature_target, duration_seconds, safety_duration_seconds, finish_kind) = {
|
|
let session = zone
|
|
.temporary_quick_thermostat
|
|
.as_ref()
|
|
.expect("temporary session checked above");
|
|
(
|
|
session.temperature_target,
|
|
session.duration_seconds,
|
|
session.safety_duration_seconds,
|
|
session.finish_kind.clone(),
|
|
)
|
|
};
|
|
let target = temperature_target
|
|
.or(zone.manual_setpoint)
|
|
.or(zone.effective_setpoint)
|
|
.unwrap_or(zone.setpoint);
|
|
let restore_enabled = zone.enabled;
|
|
let restore_local_power = zone.local_thermostat_power;
|
|
let restore_local_resume_at = zone.local_thermostat_resume_at;
|
|
let restore_local_zone_enabled = zone.local_thermostat_restore_zone_enabled;
|
|
let restore_manual_preset = zone.manual_preset.clone();
|
|
let restore_manual_setpoint = zone.manual_setpoint;
|
|
let restore_manual_override_until = zone.manual_override_until;
|
|
let configured_mode = if zone.inherit_house_mode {
|
|
house_mode
|
|
} else {
|
|
zone.mode.as_str()
|
|
};
|
|
let active_mode = if configured_mode == "off" {
|
|
zone.mode.clone()
|
|
} else {
|
|
configured_mode.to_string()
|
|
};
|
|
let schedule_boundary = if finish_kind == "schedule_boundary" {
|
|
next_schedule_boundary_utc(&zone.id, schedules, Local::now())
|
|
} else {
|
|
None
|
|
};
|
|
if finish_kind == "schedule_boundary" && schedule_boundary.is_none() {
|
|
zone.temporary_quick_thermostat = None;
|
|
zone.updated_at = now;
|
|
state.db.save_zone(zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
|
state.log("warn", "zone.temporary_quick_thermostat_cancelled", &format!("Temporary Quick Thermostat cancelled for {} because no future schedule boundary exists", zone.name), json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
// A scheduled temporary session becoming active is a new explicit thermostat intent.
|
|
// Replace any older cancelled/pending compressor task so this session can queue its
|
|
// own start or mode change against the current protection window.
|
|
rearm_compressor_queue(zone);
|
|
|
|
// Temporary ownership is independent from the ordinary local hand-back state.
|
|
zone.local_thermostat_power = Some(true);
|
|
zone.local_thermostat_resume_at = None;
|
|
zone.local_thermostat_restore_zone_enabled = None;
|
|
zone.enabled = true;
|
|
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);
|
|
session.state = "active".into();
|
|
session.active_mode = Some(active_mode);
|
|
session.restore_zone_enabled = Some(restore_enabled);
|
|
session.restore_local_thermostat_power = restore_local_power;
|
|
session.restore_local_thermostat_resume_at = restore_local_resume_at;
|
|
session.restore_local_thermostat_zone_enabled = restore_local_zone_enabled;
|
|
session.restore_manual_preset = restore_manual_preset;
|
|
session.restore_manual_setpoint = restore_manual_setpoint;
|
|
session.restore_manual_override_until = restore_manual_override_until;
|
|
session.condition_started_at = None;
|
|
session.condition_last_observed_at = None;
|
|
session.paused_at = None;
|
|
if session.finish_kind == "duration" {
|
|
session.expires_at =
|
|
duration_seconds.map(|seconds| now + chrono::Duration::seconds(seconds as i64));
|
|
} else if session.finish_kind == "schedule_boundary" {
|
|
session.expires_at = schedule_boundary;
|
|
}
|
|
if matches!(
|
|
session.finish_kind.as_str(),
|
|
"temperature_reached" | "temperature_stable"
|
|
) {
|
|
session.safety_expires_at = safety_duration_seconds
|
|
.map(|seconds| now + chrono::Duration::seconds(seconds as i64));
|
|
}
|
|
}
|
|
zone.updated_at = now;
|
|
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
|
|
|| zone.device_manual_override
|
|
|| !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.device_manual_override
|
|
&& 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 only from a fresh sensor observation.
|
|
/// Cached samples and long controller gaps cannot count as continuous hold time.
|
|
fn evaluate_temporary_quick_thermostat_condition(
|
|
zone: &mut Zone,
|
|
now: DateTime<Utc>,
|
|
sample_at: Option<DateTime<Utc>>,
|
|
max_gap_seconds: u64,
|
|
) -> Option<String> {
|
|
if !temporary_quick_thermostat_is_active(zone, now) || zone.device_manual_override {
|
|
return None;
|
|
}
|
|
let is_condition = zone
|
|
.temporary_quick_thermostat
|
|
.as_ref()
|
|
.map(|session| {
|
|
matches!(
|
|
session.finish_kind.as_str(),
|
|
"temperature_reached" | "temperature_stable"
|
|
)
|
|
})
|
|
.unwrap_or(false);
|
|
if !is_condition {
|
|
return None;
|
|
}
|
|
|
|
let Some(sample_at) = sample_at else {
|
|
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
|
session.condition_started_at = None;
|
|
session.condition_last_observed_at = None;
|
|
}
|
|
return None;
|
|
};
|
|
let last_observed = zone
|
|
.temporary_quick_thermostat
|
|
.as_ref()
|
|
.and_then(|session| session.condition_last_observed_at);
|
|
if last_observed.map(|last| sample_at <= last).unwrap_or(false) {
|
|
return None;
|
|
}
|
|
let gap_broken = last_observed
|
|
.map(|last| {
|
|
sample_at.signed_duration_since(last).num_seconds() > max_gap_seconds.max(1) as i64
|
|
})
|
|
.unwrap_or(false);
|
|
let met = zone
|
|
.temporary_quick_thermostat
|
|
.as_ref()
|
|
.map(|session| temporary_temperature_condition_met(zone, session))?;
|
|
|
|
let session = zone.temporary_quick_thermostat.as_mut()?;
|
|
session.condition_last_observed_at = Some(sample_at);
|
|
if gap_broken {
|
|
session.condition_started_at = None;
|
|
}
|
|
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(sample_at);
|
|
if session.hold_seconds == 0
|
|
|| sample_at.signed_duration_since(*started).num_seconds()
|
|
>= session.hold_seconds as i64
|
|
{
|
|
return Some("temperature_stable".into());
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
None
|
|
}
|
|
|
|
async 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() {
|
|
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.
|
|
// Do not let the old timer expire underneath someone who is actively controlling
|
|
// the unit. When that takeover ends and the device returns to OFF, the deadline is
|
|
// re-armed from that moment.
|
|
// Only an explicit timed local-OFF participates in automatic hand-back.
|
|
// local_thermostat_power=false with resume_at=None is a deliberate indefinite OFF
|
|
// (notably the state produced by group OFF) and must stay off until the user/group
|
|
// explicitly turns it back on or resumes automation.
|
|
if !local_thermostat_handback_is_active(zone) {
|
|
continue;
|
|
}
|
|
let expired = zone
|
|
.local_thermostat_resume_at
|
|
.as_ref()
|
|
.map(|at| at <= &now)
|
|
.unwrap_or(false);
|
|
if !expired {
|
|
continue;
|
|
}
|
|
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.local_thermostat_resumed", &format!("Local thermostat hand-back completed for {}", zone.name), json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
|
|
}));
|
|
}
|
|
Ok(())
|
|
}
|