Files
gree-controller/src/engine/temporary_thermostat.rs
T

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(())
}