3750 lines
181 KiB
Rust
3750 lines
181 KiB
Rust
use std::{collections::HashMap, sync::atomic::Ordering, time::{Duration, Instant}};
|
|
use anyhow::Result;
|
|
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
|
|
use serde_json::{json, Value};
|
|
use tokio::time::sleep;
|
|
use crate::{
|
|
error::AppError,
|
|
home_assistant,
|
|
influxdb,
|
|
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
|
|
state::{AppState, PendingControllerCommand},
|
|
};
|
|
|
|
fn reset_temporary_condition_observations_after_restart(state: &AppState) -> Result<usize, AppError> {
|
|
let mut changed = 0usize;
|
|
for mut zone in state.db.list_zones()? {
|
|
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { continue; };
|
|
if session.condition_started_at.is_none() && session.condition_last_observed_at.is_none() { continue; }
|
|
session.condition_started_at = None;
|
|
session.condition_last_observed_at = None;
|
|
zone.updated_at = Utc::now();
|
|
state.db.save_zone(&zone)?;
|
|
changed += 1;
|
|
}
|
|
Ok(changed)
|
|
}
|
|
|
|
pub fn start(state: AppState) {
|
|
// A continuous temperature hold cannot span controller downtime. Preserve the session
|
|
// itself, but require fresh observations after every process restart (H11).
|
|
if let Err(err) = reset_temporary_condition_observations_after_restart(&state) {
|
|
tracing::warn!(error=?err, "cannot reset temporary thermostat observation continuity after restart");
|
|
}
|
|
let poll_state = state.clone();
|
|
tokio::spawn(async move {
|
|
sleep(Duration::from_millis(500)).await;
|
|
loop {
|
|
match poll_all(&poll_state).await {
|
|
Ok(()) => {
|
|
if !poll_state.initial_device_sync_complete.swap(true, Ordering::AcqRel) {
|
|
tracing::info!("initial device state synchronized; thermostat control enabled");
|
|
}
|
|
}
|
|
Err(err) => tracing::error!(error=?err, "device poll cycle failed"),
|
|
}
|
|
let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2);
|
|
sleep(Duration::from_secs(seconds)).await;
|
|
}
|
|
});
|
|
|
|
let control_state = state.clone();
|
|
tokio::spawn(async move {
|
|
sleep(Duration::from_secs(2)).await;
|
|
loop {
|
|
// A restart must never make decisions from the persisted, potentially stale
|
|
// device snapshot. Wait for one full live poll before thermostat/schedule/automation
|
|
// ownership can emit commands. Manual API/remote control remains available.
|
|
if !control_state.initial_device_sync_complete.load(Ordering::Acquire) {
|
|
sleep(Duration::from_millis(250)).await;
|
|
continue;
|
|
}
|
|
if let Err(err) = control_zones(&control_state).await {
|
|
tracing::error!(error=?err, "zone cycle failed");
|
|
}
|
|
if let Err(err) = run_automations(&control_state).await {
|
|
tracing::error!(error=?err, "automation cycle failed");
|
|
}
|
|
let seconds = control_state.settings.read().await.zone_interval_seconds.max(2);
|
|
let normal_delay = Duration::from_secs(seconds);
|
|
let resume_delay = match next_zone_control_deadline_delay(&control_state) {
|
|
Ok(value) => value,
|
|
Err(err) => {
|
|
tracing::warn!(error=?err, "cannot calculate thermostat control deadline");
|
|
None
|
|
}
|
|
};
|
|
let sleep_for = resume_delay.map(|delay| delay.min(normal_delay)).unwrap_or(normal_delay);
|
|
tokio::select! {
|
|
_ = sleep(sleep_for) => {},
|
|
_ = control_state.zone_control_wakeup.notified() => {},
|
|
}
|
|
}
|
|
});
|
|
|
|
let maintenance_state = state;
|
|
tokio::spawn(async move {
|
|
sleep(Duration::from_secs(60)).await;
|
|
loop {
|
|
let settings = maintenance_state.settings.read().await.clone();
|
|
// When InfluxDB is enabled, compact all locally retained legacy history before
|
|
// transferring old buckets. Without Influx, compact only the configured retention window.
|
|
let compaction_days = if settings.influxdb.enabled { 3650 } else { settings.history_retention_days.max(1) } as i64;
|
|
if settings.history_compaction_enabled {
|
|
match maintenance_state.db.compact_history(compaction_days) {
|
|
Ok(count) if count > 0 => tracing::info!(count, "history samples compacted"),
|
|
Ok(_) => {}
|
|
Err(err) => tracing::warn!(error=?err, "cannot compact history"),
|
|
}
|
|
}
|
|
if settings.influxdb.enabled {
|
|
match archive_old_history(&maintenance_state, settings.influxdb.history_threshold_days.max(1)).await {
|
|
Ok(count) if count > 0 => tracing::info!(count, "old local readings archived to InfluxDB and removed from SQLite"),
|
|
Ok(_) => {}
|
|
Err(err) => tracing::warn!(error=?err, "cannot archive old history to InfluxDB; SQLite copies were kept"),
|
|
}
|
|
} else {
|
|
let retention_days = settings.history_retention_days.max(1) as i64;
|
|
match maintenance_state.db.prune_readings(retention_days) {
|
|
Ok(count) if count > 0 => tracing::info!(count, retention_days, "old local readings pruned"),
|
|
Ok(_) => {}
|
|
Err(err) => tracing::warn!(error=?err, "cannot prune readings"),
|
|
}
|
|
}
|
|
let event_retention_days = settings.event_log_retention_days.max(1) as i64;
|
|
match maintenance_state.db.prune_events(event_retention_days) {
|
|
Ok(count) if count > 0 => tracing::info!(count, event_retention_days, "old event log rows pruned"),
|
|
Ok(_) => {}
|
|
Err(err) => tracing::warn!(error=?err, "cannot prune event log"),
|
|
}
|
|
sleep(Duration::from_secs(6 * 60 * 60)).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result<u64> {
|
|
let cutoff = Utc::now() - chrono::Duration::days(threshold_days.max(1) as i64);
|
|
let settings = state.settings.read().await.influxdb.clone();
|
|
let mut moved = 0_u64;
|
|
// Bound one maintenance pass so a very large legacy database never monopolizes the runtime.
|
|
// Successful batches are deleted from SQLite, so the next pass naturally continues forward.
|
|
for _ in 0..50 {
|
|
let (devices, zones, ha) = state.db.history_before(cutoff, 1_000)?;
|
|
if devices.is_empty() && zones.is_empty() && ha.is_empty() { break; }
|
|
influxdb::write_batch(&state.http, &settings, &devices, &zones, &ha).await?;
|
|
let deleted = state.db.delete_history_batch(&devices, &zones, &ha)?;
|
|
moved += deleted;
|
|
if deleted == 0 { break; }
|
|
}
|
|
Ok(moved)
|
|
}
|
|
|
|
pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
send_command_locked(state, device_id, command).await
|
|
}
|
|
|
|
async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
|
send_command_locked_inner(state, device_id, command, true, true).await
|
|
}
|
|
|
|
async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
|
send_command_locked_inner(state, device_id, command, false, true).await
|
|
}
|
|
|
|
async fn send_command_locked_inner(
|
|
state: &AppState,
|
|
device_id: &str,
|
|
command: DeviceCommand,
|
|
dedupe_against_cache: bool,
|
|
track_controller_command: bool,
|
|
) -> Result<Device, AppError> {
|
|
validate_command(&command)?;
|
|
let mut device = state.db.get_device(device_id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
|
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
|
|
|
|
// Routine control avoids redundant frames. Explicit safety transitions (global/group OFF,
|
|
// detach) may bypass cache de-duplication so stale state cannot leave a unit powered.
|
|
let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command };
|
|
if command.is_empty() { return Ok(device); }
|
|
let controller_command_baseline = device.clone();
|
|
let suppress_beep = state.settings.read().await.suppress_device_beep;
|
|
let response_started = Instant::now();
|
|
|
|
let mut applied_command = command.clone();
|
|
let mut confirmed_state = false;
|
|
let mut confirmed_requested_state = true;
|
|
if device.simulated {
|
|
applied_command.apply(&mut device);
|
|
confirmed_state = true;
|
|
device.online = true;
|
|
device.response_time_ms = Some(0);
|
|
device.last_seen = Some(Utc::now());
|
|
device.last_error = None;
|
|
state.db.save_device(&device)?;
|
|
} else {
|
|
if device.key.as_deref().unwrap_or_default().is_empty() {
|
|
match state.gree.bind(&device).await {
|
|
Ok(bound) => {
|
|
device.key = Some(bound.key);
|
|
device.protocol_version = bound.protocol_version;
|
|
device.communication_failures = 0;
|
|
state.db.save_device(&device)?;
|
|
state.log("info", "device.bound", &format!("Bound {} using protocol V{}", device.name, device.protocol_version), json!({"device_id": device.id, "protocol_version": device.protocol_version}));
|
|
}
|
|
Err(err) => {
|
|
register_device_failure(state, &mut device, &err.to_string())?;
|
|
return Err(AppError::Device(err.to_string()));
|
|
}
|
|
}
|
|
}
|
|
match state.gree.command(&device, &command, suppress_beep).await {
|
|
Ok(result) => applied_command = result,
|
|
Err(first_err) => {
|
|
// A lost command ACK does not mean the command was lost. Read the device
|
|
// first and avoid sending the same frame (and another beep) when the requested
|
|
// state is already present. Only rebind when the verification read also fails.
|
|
let mut observed = device.clone();
|
|
let retry_result = match state.gree.poll(&mut observed).await {
|
|
Ok(()) if command.changed_from(&observed).is_empty() => {
|
|
device = observed;
|
|
confirmed_state = true;
|
|
tracing::debug!(device=%device.id, "GREE command ACK was uncertain, but status confirms the requested state");
|
|
Ok(command.clone())
|
|
}
|
|
Ok(()) => {
|
|
device = observed;
|
|
let remaining = command.changed_from(&device);
|
|
if remaining.is_empty() { Ok(command.clone()) }
|
|
else { state.gree.command(&device, &remaining, suppress_beep).await }
|
|
}
|
|
Err(_) => {
|
|
match state.gree.bind(&device).await {
|
|
Ok(bound) => {
|
|
device.key = Some(bound.key);
|
|
device.protocol_version = bound.protocol_version;
|
|
state.db.save_device(&device)?;
|
|
state.gree.command(&device, &command, suppress_beep).await
|
|
}
|
|
Err(_) => Err(first_err),
|
|
}
|
|
}
|
|
};
|
|
match retry_result {
|
|
Ok(result) => applied_command = result,
|
|
Err(err) => {
|
|
register_device_failure(state, &mut device, &err.to_string())?;
|
|
return Err(AppError::Device(err.to_string()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); }
|
|
if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); }
|
|
|
|
// A command ACK confirms transport/acceptance, but several GREE firmwares keep
|
|
// returning the pre-command status for a short settling window. Publishing that first
|
|
// stale read makes Home Assistant visibly bounce ON -> OFF -> ON. Verify a few times
|
|
// with bounded backoff and only publish a differing state after the settling window.
|
|
if !confirmed_state {
|
|
let verification_delays_ms = [0_u64, 150, 350, 650];
|
|
let mut last_verification_error: Option<String> = None;
|
|
for delay_ms in verification_delays_ms {
|
|
if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; }
|
|
let mut observed = device.clone();
|
|
match state.gree.poll(&mut observed).await {
|
|
Ok(()) => {
|
|
let requested_matches = applied_command.changed_from(&observed).is_empty();
|
|
device = observed;
|
|
confirmed_state = true;
|
|
confirmed_requested_state = requested_matches;
|
|
last_verification_error = None;
|
|
if requested_matches { break; }
|
|
}
|
|
Err(err) => {
|
|
last_verification_error = Some(err.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
if confirmed_state && !confirmed_requested_state {
|
|
tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window");
|
|
} else if !confirmed_state {
|
|
let error = last_verification_error.unwrap_or_else(|| "status verification failed".into());
|
|
record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}"));
|
|
state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({
|
|
"device_id": device.id, "error": error
|
|
}));
|
|
}
|
|
}
|
|
if confirmed_state {
|
|
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
|
}
|
|
state.db.save_device(&device)?;
|
|
if !dedupe_against_cache && confirmed_state && !confirmed_requested_state {
|
|
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() {
|
|
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await;
|
|
}
|
|
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
|
return Err(AppError::Device("device did not confirm the requested forced state change".into()));
|
|
}
|
|
}
|
|
|
|
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() {
|
|
// Keep a bounded settling history even after the requested state has already been
|
|
// observed once. Several GREE modules can briefly publish an older snapshot again
|
|
// and then return to the controller-requested state. Without this guard that normal
|
|
// firmware bounce can be misclassified as a physical/pilot takeover.
|
|
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await;
|
|
}
|
|
|
|
record_device_transition_timestamps(state, &controller_command_baseline, &device)?;
|
|
|
|
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
|
"device_id": device.id,
|
|
"command": applied_command,
|
|
"confirmed": confirmed_state,
|
|
}));
|
|
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
|
Ok(device)
|
|
}
|
|
|
|
fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
|
if before.power == after.power && before.mode == after.mode { return Ok(()); }
|
|
let now = Utc::now();
|
|
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
|
|
if before.power != after.power { zone.last_power_change_at = Some(now); }
|
|
if before.mode != after.mode { zone.last_mode_change_at = Some(now); }
|
|
// Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field
|
|
// as its optimistic snapshot guard. The cycle mirrors these timestamps into its own
|
|
// computed Zone after a successful automatic command.
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
poll_one_locked(state, device_id).await
|
|
}
|
|
|
|
async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
|
let mut device = state.db.get_device(device_id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
|
let before = device.clone();
|
|
poll_device(state, &mut device).await;
|
|
if poll_completed_successfully(&device) {
|
|
if state.initial_device_sync_complete.load(Ordering::Acquire) {
|
|
record_device_transition_timestamps(state, &before, &device)?;
|
|
}
|
|
detect_external_device_control(state, &before, &device).await?;
|
|
}
|
|
state.db.save_device(&device)?;
|
|
record_reading(state, &device)?;
|
|
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
|
Ok(device)
|
|
}
|
|
|
|
pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
|
|
let device_ids: Vec<String> = state.db.list_devices()?.into_iter()
|
|
.filter(|device| device.enabled)
|
|
.map(|device| device.id)
|
|
.collect();
|
|
for device_id in device_ids {
|
|
let _device_guard = state.lock_device_operation(&device_id).await;
|
|
let _ = poll_one_locked(state, &device_id).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn poll_device(state: &AppState, device: &mut Device) {
|
|
if device.simulated {
|
|
simulate_tick(device);
|
|
return;
|
|
}
|
|
let previous_failures = device.communication_failures;
|
|
let response_started = Instant::now();
|
|
if device.key.as_deref().unwrap_or_default().is_empty() {
|
|
match state.gree.bind(device).await {
|
|
Ok(bound) => {
|
|
device.key = Some(bound.key);
|
|
device.protocol_version = bound.protocol_version;
|
|
device.communication_failures = 0;
|
|
}
|
|
Err(err) => {
|
|
record_poll_failure(device, &err.to_string());
|
|
log_poll_health_transition(state, device, previous_failures).await;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
if let Err(first_err) = state.gree.poll(device).await {
|
|
// One lost UDP response is common on Wi-Fi and must not trigger a bind storm.
|
|
// Rebind only after at least one consecutive failed poll; a successful retry clears
|
|
// the counter in GreeClient::poll.
|
|
if previous_failures == 0 {
|
|
record_poll_failure(device, &first_err.to_string());
|
|
} else {
|
|
match state.gree.bind(device).await {
|
|
Ok(bound) => {
|
|
device.key = Some(bound.key);
|
|
device.protocol_version = bound.protocol_version;
|
|
if let Err(err) = state.gree.poll(device).await {
|
|
record_poll_failure(device, &err.to_string());
|
|
}
|
|
}
|
|
Err(_) => record_poll_failure(device, &first_err.to_string()),
|
|
}
|
|
}
|
|
}
|
|
if device.communication_failures == 0 && device.online {
|
|
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
|
}
|
|
log_poll_health_transition(state, device, previous_failures).await;
|
|
}
|
|
|
|
async fn log_poll_health_transition(state: &AppState, device: &Device, previous_failures: u8) {
|
|
let threshold = state.settings.read().await.notifications.communication_failure_threshold.max(2);
|
|
let current_failures = u32::from(device.communication_failures);
|
|
let previous_failures = u32::from(previous_failures);
|
|
if current_failures >= threshold && previous_failures < threshold {
|
|
state.log("warn", "device.offline", &format!("{} did not respond {} times in a row", device.name, device.communication_failures), json!({
|
|
"device_id": device.id, "consecutive_failures": device.communication_failures, "threshold": threshold
|
|
}));
|
|
} else if current_failures == 0 && previous_failures >= threshold {
|
|
state.log("info", "device.recovered", &format!("{} is responding again", device.name), json!({"device_id": device.id}));
|
|
}
|
|
}
|
|
|
|
fn simulate_tick(device: &mut Device) {
|
|
let mut current = device.current_temperature.unwrap_or(25.0);
|
|
let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin();
|
|
let ambient = 25.5 + minute_wave * 0.35;
|
|
if device.power {
|
|
match device.mode.as_str() {
|
|
"cool" => {
|
|
let floor = device.target_temperature - 0.2;
|
|
if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; }
|
|
}
|
|
"heat" => {
|
|
let ceiling = device.target_temperature + 0.2;
|
|
if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; }
|
|
}
|
|
"dry" => current -= 0.04,
|
|
_ => current += (ambient - current) * 0.02,
|
|
}
|
|
} else {
|
|
current += (ambient - current) * 0.04;
|
|
}
|
|
device.current_temperature = Some((current * 10.0).round() / 10.0);
|
|
device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0);
|
|
// Correct rounding for outdoor temperature without accumulating precision noise.
|
|
device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0);
|
|
device.online = true;
|
|
device.response_time_ms = Some(0);
|
|
device.last_seen = Some(Utc::now());
|
|
device.last_error = None;
|
|
device.updated_at = Utc::now();
|
|
}
|
|
|
|
fn record_reading(state: &AppState, device: &Device) -> Result<()> {
|
|
let reading = Reading {
|
|
id: 0,
|
|
device_id: device.id.clone(),
|
|
timestamp: Utc::now(),
|
|
indoor_temperature: device.current_temperature,
|
|
outdoor_temperature: device.outdoor_temperature,
|
|
target_temperature: device.target_temperature,
|
|
power: device.power,
|
|
source: if device.simulated { "simulator".into() } else { "gree".into() },
|
|
};
|
|
state.db.add_reading(&reading)?;
|
|
queue_influx_device(state, reading);
|
|
Ok(())
|
|
}
|
|
|
|
fn record_poll_failure(device: &mut Device, error: &str) {
|
|
device.communication_failures = device.communication_failures.saturating_add(1);
|
|
// A single dropped UDP response is not enough to declare an AC offline.
|
|
if device.communication_failures >= 3 { device.online = false; }
|
|
device.last_error = Some(error.to_string());
|
|
device.updated_at = Utc::now();
|
|
}
|
|
|
|
fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> {
|
|
record_poll_failure(device, error);
|
|
state.db.save_device(device)?;
|
|
state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({
|
|
"device_id": device.id,
|
|
"consecutive_failures": device.communication_failures,
|
|
"offline": !device.online,
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) fn validate_command(command: &DeviceCommand) -> Result<(), AppError> {
|
|
if let Some(value) = command.target_temperature {
|
|
if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 C".into())); }
|
|
}
|
|
if let Some(value) = command.fan_speed {
|
|
if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); }
|
|
}
|
|
if let Some(value) = &command.mode {
|
|
if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") {
|
|
return Err(AppError::BadRequest("unsupported HVAC mode".into()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn poll_completed_successfully(device: &Device) -> bool {
|
|
device.online && device.communication_failures == 0 && device.last_error.is_none()
|
|
}
|
|
|
|
fn command_manual_control_fields(command: &DeviceCommand) -> Vec<String> {
|
|
let mut fields = Vec::new();
|
|
if command.power.is_some() { fields.push("power".to_string()); }
|
|
if command.mode.is_some() { fields.push("mode".to_string()); }
|
|
if command.target_temperature.is_some() { fields.push("target_temperature".to_string()); }
|
|
if command.fan_speed.is_some() { fields.push("fan_speed".to_string()); }
|
|
if command.quiet.is_some() { fields.push("quiet".to_string()); }
|
|
if command.sleep.is_some() { fields.push("sleep".to_string()); }
|
|
fields
|
|
}
|
|
|
|
fn command_baseline_from_device(command: &DeviceCommand, device: &Device) -> DeviceCommand {
|
|
DeviceCommand {
|
|
power: command.power.map(|_| device.power),
|
|
mode: command.mode.as_ref().map(|_| device.mode.clone()),
|
|
target_temperature: command.target_temperature.map(|_| device.target_temperature),
|
|
fan_speed: command.fan_speed.map(|_| device.fan_speed),
|
|
quiet: command.quiet.map(|_| device.quiet),
|
|
sleep: command.sleep.map(|_| device.sleep),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
async fn remember_controller_command(state: &AppState, device_id: &str, command: &DeviceCommand, baseline_device: &Device) {
|
|
let poll_seconds = state.settings.read().await.poll_interval_seconds.max(2);
|
|
let ttl = Duration::from_secs(poll_seconds.saturating_mul(2).saturating_add(5).min(120));
|
|
let mut pending = state.pending_controller_commands.lock().await;
|
|
let expires_at = Instant::now() + ttl;
|
|
let baseline = command_baseline_from_device(command, baseline_device);
|
|
if let Some(existing) = pending.get_mut(device_id) {
|
|
existing.commands.push(command.clone());
|
|
existing.baselines.push(baseline);
|
|
// The history only spans one settling window; cap it defensively so a noisy device
|
|
// cannot grow this allocation without bound.
|
|
if existing.commands.len() > 8 { existing.commands.remove(0); }
|
|
if existing.baselines.len() > 8 { existing.baselines.remove(0); }
|
|
existing.expires_at = expires_at;
|
|
} else {
|
|
pending.insert(device_id.to_string(), PendingControllerCommand {
|
|
commands: vec![command.clone()],
|
|
baselines: vec![baseline],
|
|
expires_at,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn command_field_matches_device(command: &DeviceCommand, field: &str, device: &Device) -> bool {
|
|
match field {
|
|
"power" => command.power.map(|value| value == device.power).unwrap_or(false),
|
|
"mode" => command.mode.as_deref().map(|value| value == device.mode.as_str()).unwrap_or(false),
|
|
"target_temperature" => command.target_temperature
|
|
.map(|value| value.clamp(8.0, 30.0).round() == device.target_temperature.clamp(8.0, 30.0).round())
|
|
.unwrap_or(false),
|
|
"fan_speed" => command.fan_speed.map(|value| value.min(5) == device.fan_speed).unwrap_or(false),
|
|
"quiet" => command.quiet.map(|value| value == device.quiet).unwrap_or(false),
|
|
"sleep" => command.sleep.map(|value| value == device.sleep).unwrap_or(false),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
async fn suppress_expected_controller_changes(
|
|
state: &AppState,
|
|
device: &Device,
|
|
fields: Vec<String>,
|
|
) -> Vec<String> {
|
|
if fields.is_empty() { return fields; }
|
|
let mut pending = state.pending_controller_commands.lock().await;
|
|
let expired = pending.get(&device.id)
|
|
.map(|expected| Instant::now() > expected.expires_at)
|
|
.unwrap_or(false);
|
|
if expired {
|
|
pending.remove(&device.id);
|
|
return fields;
|
|
}
|
|
let Some(expected) = pending.get(&device.id).cloned() else { return fields; };
|
|
let filtered = fields.into_iter()
|
|
.filter(|field| {
|
|
let matches_recent_controller_state = expected.commands.iter()
|
|
.chain(expected.baselines.iter())
|
|
.any(|command| command_field_matches_device(command, field, device));
|
|
!matches_recent_controller_state
|
|
})
|
|
.collect();
|
|
// Do not clear the settling guard merely because one poll matched the requested state.
|
|
// A later status packet can still briefly roll back to the pre-command snapshot. The
|
|
// bounded TTL is what ends this ambiguity window.
|
|
filtered
|
|
}
|
|
|
|
fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zone) -> Vec<String> {
|
|
let mut fields = Vec::new();
|
|
if before.power != after.power { fields.push("power".to_string()); }
|
|
if before.mode != after.mode { fields.push("mode".to_string()); }
|
|
if before.target_temperature.round() != after.target_temperature.round() {
|
|
fields.push("target_temperature".to_string());
|
|
}
|
|
// Some GREE units accept the controller's standby Low fan hint and later report Auto
|
|
// again without user interaction. Treat that one known normalization as firmware drift,
|
|
// not as a remote-control takeover. Other fan changes remain meaningful manual input.
|
|
let standby_low_to_auto = zone.smart_fan && !zone.demand && before.fan_speed == 1 && after.fan_speed == 0;
|
|
if before.fan_speed != after.fan_speed && !standby_low_to_auto {
|
|
fields.push("fan_speed".to_string());
|
|
}
|
|
fields
|
|
}
|
|
|
|
|
|
pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15;
|
|
|
|
/// Apply local quick-thermostat power ownership and keep the automatic hand-back
|
|
/// deadline in one backend-owned place. Every fresh OFF action receives a fresh
|
|
/// deadline; ON cancels any pending hand-back.
|
|
pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime<Utc>) -> bool {
|
|
let previous_restore = zone.local_thermostat_restore_zone_enabled;
|
|
// Ordinary Quick Thermostat has its own restore state. A temporary session never uses
|
|
// this field, so its lifecycle cannot be erased by the 15-minute local hand-back.
|
|
if power && zone.local_thermostat_power != Some(true) && zone.local_thermostat_restore_zone_enabled.is_none() {
|
|
zone.local_thermostat_restore_zone_enabled = Some(zone.enabled);
|
|
}
|
|
let resume_at = if power {
|
|
None
|
|
} else {
|
|
Some(now + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES))
|
|
};
|
|
let changed = zone.local_thermostat_power != Some(power)
|
|
|| zone.local_thermostat_resume_at != resume_at
|
|
|| zone.local_thermostat_restore_zone_enabled != previous_restore;
|
|
zone.local_thermostat_power = Some(power);
|
|
zone.local_thermostat_resume_at = resume_at;
|
|
changed
|
|
}
|
|
|
|
/// Re-arm a local-OFF hand-back after a temporary direct/manual device takeover.
|
|
/// The countdown must start from the moment that manual control ends, not from the
|
|
/// older OFF action that happened before the takeover.
|
|
fn rearm_local_thermostat_resume(zone: &mut Zone, now: DateTime<Utc>) -> bool {
|
|
if zone.local_thermostat_power != Some(false) { return false; }
|
|
set_local_thermostat_power(zone, false, now)
|
|
}
|
|
|
|
fn local_thermostat_handback_is_active(zone: &Zone) -> bool {
|
|
zone.local_thermostat_power == Some(false) && !zone.device_manual_override
|
|
}
|
|
|
|
/// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is
|
|
/// deliberately untouched; the two ownership mechanisms have independent cleanup paths.
|
|
pub fn reset_local_thermostat_override(zone: &mut Zone) -> bool {
|
|
let temporary_active = zone.temporary_quick_thermostat.as_ref()
|
|
.map(|session| session.activated_at.is_some()
|
|
|| (session.generation == 0 && session.start_kind == "now" && zone.local_thermostat_power == Some(true)))
|
|
.unwrap_or(false);
|
|
if temporary_active {
|
|
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return false; };
|
|
let changed = session.restore_local_thermostat_power.is_some()
|
|
|| session.restore_local_thermostat_resume_at.is_some()
|
|
|| session.restore_local_thermostat_zone_enabled.is_some()
|
|
|| session.restore_manual_preset.is_some()
|
|
|| session.restore_manual_setpoint.is_some()
|
|
|| session.restore_manual_override_until.is_some();
|
|
session.restore_local_thermostat_power = None;
|
|
session.restore_local_thermostat_resume_at = None;
|
|
session.restore_local_thermostat_zone_enabled = None;
|
|
session.restore_manual_preset = None;
|
|
session.restore_manual_setpoint = None;
|
|
session.restore_manual_override_until = None;
|
|
return changed;
|
|
}
|
|
|
|
let restore_zone_enabled = zone.local_thermostat_restore_zone_enabled;
|
|
let changed = zone.local_thermostat_power.is_some()
|
|
|| zone.local_thermostat_resume_at.is_some()
|
|
|| zone.local_thermostat_restore_zone_enabled.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.local_thermostat_restore_zone_enabled = 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
|
|
}
|
|
|
|
pub fn temporary_quick_thermostat_is_active(zone: &Zone, now: DateTime<Utc>) -> bool {
|
|
let Some(session) = zone.temporary_quick_thermostat.as_ref() else { return false; };
|
|
if session.activated_at.as_ref().map(|at| at <= &now).unwrap_or(false) { return true; }
|
|
// Legacy recovery only for old immediate sessions persisted before activated_at existed.
|
|
// Fresh delay/at sessions must never inherit activity from an unrelated local Quick ON.
|
|
session.generation == 0
|
|
&& session.start_kind == "now"
|
|
&& session.started_at <= now
|
|
&& zone.local_thermostat_power == Some(true)
|
|
}
|
|
|
|
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") {
|
|
zone.manual_preset = Some(preset.to_string());
|
|
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now());
|
|
}
|
|
}
|
|
refresh_zone_runtime_target(zone, schedules, house_mode);
|
|
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(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,
|
|
house_power_enabled: bool,
|
|
) -> 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(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 !house_power_enabled {
|
|
set_temporary_wait_state(state, zone, "waiting_master", 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;
|
|
}
|
|
|
|
// 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(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.
|
|
if !local_thermostat_handback_is_active(zone) { continue; }
|
|
if zone.local_thermostat_resume_at.is_none() {
|
|
// Upgrade safety for a persisted 0.7.10/0.7.11 local-OFF state: old releases
|
|
// had no hand-back deadline, so start one from the first cycle after upgrade.
|
|
set_local_thermostat_power(zone, false, now.clone());
|
|
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_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES
|
|
}));
|
|
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(())
|
|
}
|
|
|
|
fn next_time_automation_utc(item: &Automation, now: DateTime<Local>) -> Option<DateTime<Utc>> {
|
|
if !item.enabled || item.trigger_kind != "time" { return None; }
|
|
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
|
|
let minute_floor = now.with_second(0)?.with_nanosecond(0)?;
|
|
for offset in 0..=(24 * 60) {
|
|
let candidate = minute_floor + chrono::Duration::minutes(offset);
|
|
if candidate <= now { continue; }
|
|
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
|
|
return Some(candidate.with_timezone(&Utc));
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>, AppError> {
|
|
let now = Utc::now();
|
|
let local_now = now.with_timezone(&Local);
|
|
let zones = state.db.list_zones()?;
|
|
let schedules = state.db.list_schedules()?;
|
|
let automations = state.db.list_automations()?;
|
|
let mut deadlines: Vec<DateTime<Utc>> = Vec::new();
|
|
|
|
for zone in &zones {
|
|
if local_thermostat_handback_is_active(zone) {
|
|
if let Some(at) = zone.local_thermostat_resume_at.clone() { deadlines.push(at); }
|
|
}
|
|
if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { deadlines.push(at); }
|
|
if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { deadlines.push(at); }
|
|
}
|
|
for automation in &automations {
|
|
if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { deadlines.push(at); }
|
|
}
|
|
|
|
Ok(deadlines.into_iter()
|
|
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
|
|
.min())
|
|
}
|
|
|
|
pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blocked_by_group: bool) {
|
|
let now = Utc::now();
|
|
let (owner, source, resume_at, reason) = if !house_power_enabled {
|
|
("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string())
|
|
} else if zone.device_manual_override {
|
|
let source = match zone.control_source.as_str() {
|
|
"home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(),
|
|
_ => "external".into(),
|
|
};
|
|
("direct_manual", source, zone.device_manual_override_until, "Direct/manual device control has priority".to_string())
|
|
} else if zone.local_thermostat_power.is_some() {
|
|
let source = match zone.control_source.as_str() {
|
|
"home_assistant_thermostat" | "web_thermostat" => zone.control_source.clone(),
|
|
_ => "local_thermostat".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 {
|
|
("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string())
|
|
};
|
|
if zone.control_owner != owner || zone.control_source != source {
|
|
zone.control_since = Some(now);
|
|
} else if zone.control_since.is_none() {
|
|
zone.control_since = Some(now);
|
|
}
|
|
zone.control_owner = owner.into();
|
|
zone.control_source = source;
|
|
zone.control_resume_at = resume_at;
|
|
zone.control_reason = reason;
|
|
}
|
|
|
|
fn normalized_direct_source(source: &str) -> &'static str {
|
|
if source.contains("home_assistant") { "home_assistant_direct" }
|
|
else if source == "device.manual_control" { "web_direct" }
|
|
else { "external" }
|
|
}
|
|
|
|
pub fn reset_device_manual_override(zone: &mut Zone) -> bool {
|
|
let now = Utc::now();
|
|
let changed = zone.device_manual_override
|
|
|| zone.device_manual_override_since.is_some()
|
|
|| zone.device_manual_override_until.is_some()
|
|
|| !zone.device_manual_override_fields.is_empty()
|
|
|| zone.device_manual_override_baseline.is_some();
|
|
zone.device_manual_override = false;
|
|
zone.device_manual_override_since = None;
|
|
zone.device_manual_override_until = None;
|
|
zone.device_manual_override_fields.clear();
|
|
zone.device_manual_override_baseline = None;
|
|
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
|
let pause = session.paused_at.take()
|
|
.map(|paused_at| now.signed_duration_since(paused_at))
|
|
.filter(|pause| *pause > chrono::Duration::zero());
|
|
if session.activated_at.is_some() {
|
|
if let Some(pause) = pause {
|
|
if matches!(session.finish_kind.as_str(), "duration" | "until") {
|
|
session.expires_at = session.expires_at.map(|at| at + pause);
|
|
}
|
|
if matches!(session.finish_kind.as_str(), "temperature_reached" | "temperature_stable") {
|
|
session.safety_expires_at = session.safety_expires_at.map(|at| at + pause);
|
|
}
|
|
}
|
|
session.state = "active".into();
|
|
} else {
|
|
// A due session blocked by manual ownership has not started its work clock.
|
|
// Preserve the requested remaining `until` window by excluding manual wait time.
|
|
if session.finish_kind == "until" {
|
|
if let Some(pause) = pause {
|
|
session.expires_at = session.expires_at.map(|at| at + pause);
|
|
}
|
|
}
|
|
session.state = "scheduled".into();
|
|
}
|
|
session.condition_started_at = None;
|
|
session.condition_last_observed_at = None;
|
|
}
|
|
if zone.control_owner == "direct_manual" {
|
|
zone.control_owner = "automation".into();
|
|
zone.control_source = "automation".into();
|
|
zone.control_since = Some(now);
|
|
zone.control_resume_at = None;
|
|
zone.control_reason = "Manual takeover cleared; automation may resume".into();
|
|
}
|
|
changed
|
|
}
|
|
|
|
fn manual_override_matches_baseline(zone: &Zone, device: &Device) -> bool {
|
|
let Some(baseline) = zone.device_manual_override_baseline.as_ref() else { return false; };
|
|
if zone.device_manual_override_fields.is_empty() { return false; }
|
|
// If the unit was OFF before takeover, returning it to OFF is operationally the same
|
|
// controller state even if the remote retained a different mode/target internally.
|
|
// Those dormant values will be set explicitly if automation later powers the unit.
|
|
if !baseline.power { return !device.power; }
|
|
zone.device_manual_override_fields.iter().all(|field| match field.as_str() {
|
|
"power" => device.power == baseline.power,
|
|
"mode" => device.mode == baseline.mode,
|
|
"target_temperature" => device.target_temperature.round() == baseline.target_temperature.round(),
|
|
"fan_speed" => device.fan_speed == baseline.fan_speed,
|
|
"quiet" => device.quiet == baseline.quiet,
|
|
"sleep" => device.sleep == baseline.sleep,
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
fn persist_manual_override_clear(state: &AppState, zone: &mut Zone, source: &str, restored: bool) -> Result<bool, AppError> {
|
|
if !reset_device_manual_override(zone) { return Ok(false); }
|
|
let now = Utc::now();
|
|
let local_resume_rearmed = restored && rearm_local_thermostat_resume(zone, now.clone());
|
|
zone.updated_at = now;
|
|
state.db.save_zone(zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
|
let (kind, message) = if restored {
|
|
("zone.device_manual_override_restored", format!("Manual device control returned {} to its previous state", zone.name))
|
|
} else {
|
|
("zone.device_manual_override_cleared", format!("Manual device control ended for {}", zone.name))
|
|
};
|
|
state.log("info", kind, &message, json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id, "source": source,
|
|
"local_thermostat_resume_rearmed": local_resume_rearmed,
|
|
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
|
}));
|
|
state.wake_zone_control();
|
|
Ok(true)
|
|
}
|
|
|
|
fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<String>, source: &str, baseline: &Device) -> Result<(), AppError> {
|
|
if fields.is_empty() { return Ok(()); }
|
|
let now = Utc::now();
|
|
if !zone.device_manual_override {
|
|
zone.device_manual_override_since = Some(now);
|
|
zone.device_manual_override_baseline = Some(baseline.into());
|
|
zone.control_since = Some(now);
|
|
}
|
|
zone.device_manual_override = true;
|
|
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
|
// A future scheduled session has no ownership yet. Do not mark it paused until its
|
|
// requested start actually becomes due while manual control is still present.
|
|
if session.activated_at.is_some() || session.started_at <= now {
|
|
if session.paused_at.is_none() { session.paused_at = Some(now); }
|
|
session.state = "paused_manual".into();
|
|
session.condition_started_at = None;
|
|
session.condition_last_observed_at = None;
|
|
}
|
|
}
|
|
zone.control_owner = "direct_manual".into();
|
|
zone.control_source = normalized_direct_source(source).into();
|
|
zone.control_reason = "Direct/manual device control has priority".into();
|
|
zone.device_manual_override_until = if zone.enabled {
|
|
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now())
|
|
} else {
|
|
None
|
|
};
|
|
zone.control_resume_at = zone.device_manual_override_until;
|
|
for field in fields {
|
|
if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) {
|
|
zone.device_manual_override_fields.push(field);
|
|
}
|
|
}
|
|
zone.demand = false;
|
|
zone.demand_since = None;
|
|
zone.updated_at = now;
|
|
state.db.save_zone(zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
|
|
state.log("info", "zone.device_manual_override", &format!("Manual device control detected for {}", zone.name), json!({
|
|
"zone_id": zone.id,
|
|
"device_id": zone.device_id,
|
|
"fields": zone.device_manual_override_fields,
|
|
"source": source,
|
|
"override_until": zone.device_manual_override_until,
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
async fn detect_external_device_control(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
|
if before.id != after.id { return Ok(()); }
|
|
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) {
|
|
let fields = suppress_expected_controller_changes(
|
|
state,
|
|
after,
|
|
externally_changed_control_fields(before, after, &zone),
|
|
).await;
|
|
if zone.device_manual_override && manual_override_matches_baseline(&zone, after) {
|
|
persist_manual_override_clear(state, &mut zone, "gree_poll", true)?;
|
|
continue;
|
|
}
|
|
if fields.is_empty() { continue; }
|
|
// A disabled zone is outside controller ownership. When its manually operated unit is
|
|
// switched off there is no takeover left to display or remember.
|
|
if !zone.enabled && !after.power {
|
|
persist_manual_override_clear(state, &mut zone, "gree_poll", false)?;
|
|
continue;
|
|
}
|
|
set_device_manual_override(state, &mut zone, fields, "gree_poll", before)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
|
|
// Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits.
|
|
// A device belongs to at most one thermostat zone, but keep this generic for legacy data.
|
|
let zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect();
|
|
let mut _zone_guards = Vec::new();
|
|
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
|
|
// Keep the device lock until the zone takeover marker is persisted. Otherwise a poll
|
|
// could observe our own just-sent command before the controller records manual ownership.
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
let before = state.db.get_device(device_id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
|
let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() };
|
|
let fields = command_manual_control_fields(&effective_command);
|
|
let updated = send_command_locked_inner(state, device_id, command, true, false).await?;
|
|
if !fields.is_empty() {
|
|
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
|
|
if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) {
|
|
persist_manual_override_clear(state, &mut zone, source, true)?;
|
|
continue;
|
|
}
|
|
if !zone.enabled && !updated.power {
|
|
persist_manual_override_clear(state, &mut zone, source, false)?;
|
|
continue;
|
|
}
|
|
set_device_manual_override(state, &mut zone, fields.clone(), source, &before)?;
|
|
}
|
|
}
|
|
Ok(updated)
|
|
}
|
|
|
|
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, source: &str) -> Result<Device, AppError> {
|
|
// Global OFF is a one-shot authority transition. Clear takeover and send OFF while
|
|
// polling for this unit is excluded; a later remote change happens after the lock and
|
|
// is therefore preserved as a new manual takeover.
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
|
|
if !reset_device_manual_override(&mut zone) { continue; }
|
|
zone.updated_at = Utc::now();
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id, "source": source
|
|
}));
|
|
}
|
|
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
|
|
}
|
|
|
|
pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
|
|
}
|
|
|
|
/// Technical device disable is a safety transition, not just a database flag. The unit is
|
|
/// explicitly powered off while it is still commandable, then removed from controller polling.
|
|
pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<Device, AppError> {
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
let mut device = state.db.get_device(device_id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
|
if !device.enabled { return Ok(device); }
|
|
device = send_command_locked_forced(
|
|
state,
|
|
device_id,
|
|
DeviceCommand { power: Some(false), ..Default::default() },
|
|
).await?;
|
|
device.enabled = false;
|
|
device.updated_at = Utc::now();
|
|
state.db.save_device(&device)?;
|
|
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
|
state.wake_zone_control();
|
|
Ok(device)
|
|
}
|
|
|
|
pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result<usize, AppError> {
|
|
let mut cleared = 0usize;
|
|
for mut zone in state.db.list_zones()? {
|
|
if !reset_device_manual_override(&mut zone) { continue; }
|
|
zone.updated_at = Utc::now();
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
state.log("info", "zone.device_manual_override_cleared", &format!("Automation resumed for {}", zone.name), json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id, "source": source
|
|
}));
|
|
cleared += 1;
|
|
}
|
|
Ok(cleared)
|
|
}
|
|
|
|
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
|
|
if let Some(mode) = patch.mode.as_deref() {
|
|
if !matches!(mode, "house" | "auto" | "cool" | "heat") {
|
|
return Err(AppError::BadRequest("group mode must be house, cool or heat".into()));
|
|
}
|
|
}
|
|
if let Some(preset) = patch.preset.as_deref() {
|
|
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
|
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into()));
|
|
}
|
|
}
|
|
|
|
let mut group = state.db.get_group(group_id)?
|
|
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
|
|
let schedules = state.db.list_schedules()?;
|
|
let climate_change = patch.mode.is_some() || patch.preset.is_some();
|
|
if let Some(power) = patch.power {
|
|
group.power_enabled = power;
|
|
}
|
|
group.updated_at = Utc::now();
|
|
state.db.save_group(&group)?;
|
|
state.broadcast("group.updated", serde_json::to_value(&group)?);
|
|
|
|
// Explicit group ON is a conscious request to run this group. Resume the global
|
|
// master without changing the gates of any other groups. This makes group ON work
|
|
// even after a previous whole-house OFF while preserving multi-group OFF priority.
|
|
if patch.power == Some(true) {
|
|
let mut settings = state.settings.write().await;
|
|
if !settings.house_power_enabled {
|
|
settings.house_power_enabled = true;
|
|
state.db.save_runtime_settings(&settings)?;
|
|
state.broadcast("house.power_changed", json!({"house_power_enabled": true}));
|
|
state.log("info", "house.power_resumed_by_group", &format!("Whole-house master resumed by group {}", group.name), json!({
|
|
"group_id": group.id, "source": source
|
|
}));
|
|
}
|
|
}
|
|
|
|
let mut zones = Vec::new();
|
|
for zone_id in &group.zone_ids {
|
|
let _zone_guard = state.lock_zone_operation(zone_id).await;
|
|
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; };
|
|
let temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now());
|
|
if temporary_owns_zone {
|
|
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
|
if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); }
|
|
if let Some(preset) = patch.preset.as_deref() { session.deferred_preset = Some(preset.to_string()); }
|
|
}
|
|
if climate_change {
|
|
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
|
|
}));
|
|
}
|
|
} else {
|
|
if let Some(mode) = patch.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) = patch.preset.as_deref() {
|
|
if preset == "auto" {
|
|
zone.manual_preset = None;
|
|
zone.manual_setpoint = None;
|
|
zone.manual_override_until = None;
|
|
} else {
|
|
zone.manual_preset = Some(preset.to_string());
|
|
zone.manual_setpoint = None;
|
|
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
|
|
}
|
|
}
|
|
}
|
|
zone.revision = zone.revision.saturating_add(1);
|
|
zone.updated_at = Utc::now();
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
zones.push(zone);
|
|
}
|
|
|
|
let runtime = state.settings.read().await.clone();
|
|
let master_power_enabled = runtime.house_power_enabled;
|
|
let should_command_power = patch.power.is_some();
|
|
let desired_power = group.power_enabled;
|
|
// A zone may intentionally belong to more than one group. Power-off is authoritative:
|
|
// turning one group on must never briefly wake a member that is still blocked by another group.
|
|
let mut failed = Vec::new();
|
|
if should_command_power && !desired_power {
|
|
let mut seen = std::collections::HashSet::new();
|
|
for zone in &zones {
|
|
if !seen.insert(zone.device_id.clone()) { continue; }
|
|
// Group OFF is an immediate safety transition. Group ON never emits a bare
|
|
// power=true frame; the thermostat arbiter starts the unit with mode/target.
|
|
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
|
|
if !device.enabled { continue; }
|
|
match send_group_power_if_current(state, &group.id, &zone.id, &device.id, false).await {
|
|
Ok(_) => {}
|
|
Err(err) => {
|
|
state.log("error", "group.power_error", &err.to_string(), json!({
|
|
"group_id": group.id, "device_id": device.id, "device_name": device.name,
|
|
"power": false, "source": source,
|
|
}));
|
|
failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()}));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if desired_power && (should_command_power || climate_change) {
|
|
state.wake_zone_control();
|
|
}
|
|
state.log("info", source, &format!("Updated group {}", group.name), json!({
|
|
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
|
|
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
|
|
}));
|
|
Ok(json!({
|
|
"group": group,
|
|
"zones": zones,
|
|
"devices": state.db.list_devices()?,
|
|
"failed": failed,
|
|
"master_power_enabled": master_power_enabled,
|
|
}))
|
|
}
|
|
|
|
|
|
fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateTime<Utc>) -> Result<Zone, AppError> {
|
|
let Some(mut latest) = state.db.get_zone(&computed.id)? else { return Ok(computed.clone()); };
|
|
if latest.updated_at <= cycle_started_at {
|
|
state.db.save_zone(computed)?;
|
|
return Ok(computed.clone());
|
|
}
|
|
// Another actor changed this zone while the regulator was doing network I/O. Never
|
|
// write the old controller snapshot over fresh configuration or manual takeover state.
|
|
// Sensor observations are safe to carry forward only while the device assignment matches.
|
|
if latest.device_id == computed.device_id {
|
|
latest.device_temperature = computed.device_temperature;
|
|
latest.external_temperature = computed.external_temperature;
|
|
latest.current_temperature = computed.current_temperature;
|
|
latest.control_temperature_source = computed.control_temperature_source.clone();
|
|
latest.updated_at = Utc::now();
|
|
state.db.save_zone(&latest)?;
|
|
}
|
|
Ok(latest)
|
|
}
|
|
|
|
async fn thermostat_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
|
|
if !state.settings.read().await.house_power_enabled { return Ok(false); }
|
|
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
|
|
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power == Some(false) { return Ok(false); }
|
|
if zone.local_thermostat_power == Some(true) { return Ok(true); }
|
|
let blocked = state.db.list_groups()?.iter().any(|group| {
|
|
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
|
|
});
|
|
Ok(!blocked)
|
|
}
|
|
|
|
async fn group_off_ownership_is_current(state: &AppState, zone_id: &str, device_id: &str) -> Result<bool, AppError> {
|
|
if !state.settings.read().await.house_power_enabled { return Ok(false); }
|
|
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(false); };
|
|
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(false); }
|
|
Ok(state.db.list_groups()?.iter().any(|group| {
|
|
!group.power_enabled && group.zone_ids.iter().any(|member| member == zone_id)
|
|
}))
|
|
}
|
|
|
|
async fn send_zone_command_if_owned(
|
|
state: &AppState,
|
|
zone_id: &str,
|
|
device_id: &str,
|
|
command: DeviceCommand,
|
|
require_group_block: bool,
|
|
) -> Result<Option<Device>, AppError> {
|
|
// Ownership must be checked after acquiring the same per-device lock used by polling.
|
|
// Otherwise polling could detect a remote takeover while this task is waiting for the lock,
|
|
// and a stale thermostat decision would still be sent immediately afterwards.
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
let owned = if require_group_block {
|
|
group_off_ownership_is_current(state, zone_id, device_id).await?
|
|
} else {
|
|
thermostat_ownership_is_current(state, zone_id, device_id).await?
|
|
};
|
|
if !owned { return Ok(None); }
|
|
send_command_locked(state, device_id, command).await.map(Some)
|
|
}
|
|
|
|
async fn send_group_power_if_current(
|
|
state: &AppState,
|
|
group_id: &str,
|
|
zone_id: &str,
|
|
device_id: &str,
|
|
desired_power: bool,
|
|
) -> Result<Option<Device>, AppError> {
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
let Some(zone) = state.db.get_zone(zone_id)? else { return Ok(None); };
|
|
if zone.device_id != device_id || !zone.enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { return Ok(None); }
|
|
let groups = state.db.list_groups()?;
|
|
let Some(group) = groups.iter().find(|group| group.id == group_id) else { return Ok(None); };
|
|
if group.power_enabled != desired_power || !group.zone_ids.iter().any(|member| member == zone_id) { return Ok(None); }
|
|
if desired_power {
|
|
let settings = state.settings.read().await;
|
|
if !settings.house_power_enabled { return Ok(None); }
|
|
let effective_mode = if zone.inherit_house_mode { settings.house_mode.as_str() } else { zone.mode.as_str() };
|
|
if effective_mode == "off" { return Ok(None); }
|
|
if groups.iter().any(|other| {
|
|
other.id != group_id && !other.power_enabled && other.zone_ids.iter().any(|member| member == zone_id)
|
|
}) {
|
|
return Ok(None);
|
|
}
|
|
}
|
|
let Some(device) = state.db.get_device(device_id)? else { return Ok(None); };
|
|
if !device.enabled { return Ok(None); }
|
|
let command = DeviceCommand { power: Some(desired_power), ..Default::default() };
|
|
if desired_power {
|
|
send_command_locked(state, device_id, command).await.map(Some)
|
|
} else {
|
|
// A deliberate group OFF is a one-shot safety transition. Send it even if the
|
|
// cached state already says OFF; the regulator itself will not keep repeating it.
|
|
send_command_locked_forced(state, device_id, command).await.map(Some)
|
|
}
|
|
}
|
|
|
|
async fn send_automatic_device_command_if_owned(
|
|
state: &AppState,
|
|
device_id: &str,
|
|
command: DeviceCommand,
|
|
) -> Result<Option<Device>, AppError> {
|
|
let _device_guard = state.lock_device_operation(device_id).await;
|
|
if !state.settings.read().await.house_power_enabled { return Ok(None); }
|
|
let zones = state.db.list_zones()?;
|
|
if device_blocked_by_disabled_zone(device_id, &zones)
|
|
|| device_blocked_by_manual_override(device_id, &zones)
|
|
|| device_blocked_by_local_thermostat(device_id, &zones)
|
|
|| device_blocked_by_disabled_group(device_id, &zones, &state.db.list_groups()?)
|
|
{
|
|
return Ok(None);
|
|
}
|
|
send_command_locked(state, device_id, command).await.map(Some)
|
|
}
|
|
|
|
async fn apply_automatic_device_action(
|
|
state: &AppState,
|
|
device_id: &str,
|
|
command: DeviceCommand,
|
|
) -> Result<Option<Device>, AppError> {
|
|
let zones = state.db.list_zones()?;
|
|
let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else {
|
|
return send_automatic_device_command_if_owned(state, device_id, command).await;
|
|
};
|
|
|
|
let _zone_guard = state.lock_zone_operation(&zone_id).await;
|
|
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
|
|
let settings = state.settings.read().await.clone();
|
|
if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() {
|
|
return Ok(None);
|
|
}
|
|
let groups = state.db.list_groups()?;
|
|
if groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|member| member == &zone.id)) {
|
|
return Ok(None);
|
|
}
|
|
// A power-on automation is an explicit domain transition and may re-enable a zone that
|
|
// a previous power automation disabled. Other actions still respect a disabled zone gate.
|
|
if !zone.enabled && command.power != Some(true) { return Ok(None); }
|
|
|
|
let mut domain_changed = false;
|
|
if let Some(power) = command.power {
|
|
zone.enabled = power;
|
|
domain_changed = true;
|
|
}
|
|
if let Some(mode) = command.mode.as_deref() {
|
|
match mode {
|
|
"heat" | "cool" => {
|
|
zone.mode = mode.to_string();
|
|
zone.inherit_house_mode = false;
|
|
domain_changed = true;
|
|
}
|
|
"auto" => {
|
|
zone.inherit_house_mode = true;
|
|
domain_changed = true;
|
|
}
|
|
_ => return Err(AppError::BadRequest(
|
|
"device automation for a thermostat-managed unit supports only heat, cool or auto mode".into(),
|
|
)),
|
|
}
|
|
}
|
|
if let Some(target) = command.target_temperature {
|
|
zone.manual_setpoint = Some((target.clamp(8.0, 30.0) * 2.0).round() / 2.0);
|
|
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now());
|
|
domain_changed = true;
|
|
}
|
|
|
|
if domain_changed {
|
|
zone.revision = zone.revision.saturating_add(1);
|
|
zone.updated_at = Utc::now();
|
|
zone.control_source = "automation.device".into();
|
|
state.db.save_zone(&zone)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
|
state.wake_zone_control();
|
|
}
|
|
|
|
if command.power == Some(false) {
|
|
return force_power_off_device(state, device_id).await.map(Some);
|
|
}
|
|
|
|
// Climate fields above are durable zone state. Only non-climate device capabilities remain
|
|
// a one-shot command; the thermostat can no longer undo power/mode/target next cycle.
|
|
let residual = DeviceCommand {
|
|
power: None,
|
|
mode: None,
|
|
target_temperature: None,
|
|
fan_speed: command.fan_speed,
|
|
swing_vertical: command.swing_vertical,
|
|
swing_horizontal: command.swing_horizontal,
|
|
quiet: command.quiet,
|
|
turbo: command.turbo,
|
|
light: command.light,
|
|
air: command.air,
|
|
xfan: command.xfan,
|
|
health: command.health,
|
|
sleep: command.sleep,
|
|
};
|
|
if residual.is_empty() {
|
|
return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}")));
|
|
}
|
|
send_automatic_device_command_if_owned(state, device_id, residual).await
|
|
}
|
|
|
|
async fn control_zones(state: &AppState) -> Result<()> {
|
|
let schedules = state.db.list_schedules()?;
|
|
let groups = state.db.list_groups()?;
|
|
let settings = state.settings.read().await.clone();
|
|
let mut zone_snapshot = state.db.list_zones()?;
|
|
// Local quick-thermostat OFF is intentionally temporary. Expire the ownership marker
|
|
// 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).await?;
|
|
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
|
|
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, settings.house_power_enabled).await?;
|
|
|
|
// Outdoor temperature is deliberately optional. Prefer the configured Home
|
|
// Assistant entity, but keep the dashboard/assist useful by falling back to the
|
|
// outdoor sensors reported by GREE units when HA is temporarily unavailable.
|
|
let device_snapshot = state.db.list_devices()?;
|
|
let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim();
|
|
let resolved_outdoor = if configured_outdoor.is_empty() {
|
|
None
|
|
} else {
|
|
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor))
|
|
};
|
|
let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() {
|
|
match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(settings.home_assistant.sensor_stale_after_seconds)).await {
|
|
Ok(value) => {
|
|
record_ha_history(
|
|
state,
|
|
entity_id,
|
|
None,
|
|
"outdoor",
|
|
value,
|
|
settings.poll_interval_seconds,
|
|
);
|
|
Some(value)
|
|
}
|
|
Err(err) => {
|
|
tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot));
|
|
{
|
|
let mut current = state.outdoor_temperature.write().await;
|
|
if *current != outdoor_temperature {
|
|
*current = outdoor_temperature;
|
|
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
|
|
}
|
|
}
|
|
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
|
|
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
|
|
|
if !settings.house_power_enabled {
|
|
// Whole-house OFF is a one-shot action performed by the API endpoint. While the
|
|
// master remains off the regulator stays passive. A later physical/remote change
|
|
// is therefore detected as manual takeover and is not erased or forced OFF again.
|
|
return Ok(());
|
|
}
|
|
|
|
// Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost
|
|
// one request timeout per cycle, not one timeout multiplied by the number of zones.
|
|
let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| {
|
|
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; }
|
|
let zone_id = zone.id.clone();
|
|
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref());
|
|
let http = &state.http;
|
|
let ha_settings = &settings.home_assistant;
|
|
let stale_after_seconds = effective_sensor_stale_after_seconds(zone.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds);
|
|
Some(async move {
|
|
let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await
|
|
.map_err(|err| err.to_string());
|
|
(zone_id, resolved_entity, result)
|
|
})
|
|
})).await;
|
|
let mut room_sensor_results: HashMap<String, (Option<String>, Result<f64, String>)> = room_sensor_reads.into_iter()
|
|
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
|
|
.collect();
|
|
|
|
for mut zone in zone_snapshot {
|
|
let cycle_started_at = zone.updated_at;
|
|
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
|
zone.manual_preset = None;
|
|
zone.manual_setpoint = None;
|
|
zone.manual_override_until = None;
|
|
}
|
|
if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
|
|
reset_device_manual_override(&mut zone);
|
|
state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({
|
|
"zone_id": zone.id, "device_id": zone.device_id
|
|
}));
|
|
}
|
|
|
|
let Some(device) = state.db.get_device(&zone.device_id)? else {
|
|
state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id}));
|
|
continue;
|
|
};
|
|
|
|
// House "off" means the smart thermostat does not control inherited zones.
|
|
// A zone explicitly switched to heat/cool remains independent and may still run.
|
|
// 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));
|
|
refresh_control_ownership(&mut zone, settings.house_power_enabled, ownership_blocked_by_group);
|
|
let effective_mode = effective_mode_owned.as_str();
|
|
|
|
let previous_source = zone.control_temperature_source.clone();
|
|
// Never feed the thermostat a cached GREE temperature after any communication
|
|
// failure. External HA sensors may still keep a zone operational when configured.
|
|
let device_temperature = if device.enabled && device.online && device.communication_failures == 0 {
|
|
device.current_temperature
|
|
} else {
|
|
None
|
|
};
|
|
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
|
|
match room_sensor_results.remove(&zone.id) {
|
|
Some((resolved_entity, Ok(value))) => {
|
|
if let Some(entity_id) = resolved_entity.as_deref() {
|
|
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
|
|
}
|
|
Some(value)
|
|
}
|
|
Some((resolved_entity, Err(err))) => {
|
|
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
|
state.log("warn", "ha.sensor_error", &err, json!({
|
|
"zone_id": zone.id,
|
|
"configured_entity_id": zone.ha_entity_id.as_deref(),
|
|
"resolved_entity_id": resolved_entity,
|
|
}));
|
|
}
|
|
None
|
|
}
|
|
None => None,
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature);
|
|
zone.device_temperature = device_temperature;
|
|
zone.external_temperature = external_temperature;
|
|
zone.current_temperature = temperature;
|
|
zone.control_temperature_source = control_source;
|
|
zone.updated_at = Utc::now();
|
|
|
|
// 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);
|
|
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
|
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
|
continue;
|
|
}
|
|
|
|
// A technically disabled device is outside thermostat ownership. Do not create
|
|
// repeated command errors while keeping any available external sensor data visible.
|
|
if !device.enabled {
|
|
zone.demand = false;
|
|
zone.demand_since = None;
|
|
zone.device_setpoint = None;
|
|
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)?);
|
|
continue;
|
|
}
|
|
|
|
// A physical/manual takeover has higher priority than thermostat, schedule, group and
|
|
// automation control. Continue sensor/history updates, but reflect the unit's real state
|
|
// instead of sending corrective frames that would fight the person holding the remote.
|
|
if zone.device_manual_override {
|
|
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
|
|
// selected profile/target. Keep the intended target visible and report the physical
|
|
// unit target separately through device_setpoint. This makes Resume/Profile actions
|
|
// deterministic and avoids a standby device target (for example 25 C) masquerading
|
|
// as the zone's Sleep/Comfort target.
|
|
let temporary_active = temporary_quick_thermostat_is_active(&zone, zone.updated_at.clone());
|
|
let pause_started_at = zone.updated_at;
|
|
if temporary_active {
|
|
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
|
if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); }
|
|
session.state = "paused_manual".into();
|
|
session.condition_started_at = None;
|
|
session.condition_last_observed_at = None;
|
|
}
|
|
}
|
|
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
|
|
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
|
let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode);
|
|
zone.active_preset = preset;
|
|
zone.effective_setpoint = Some(target);
|
|
// Keep effective_mode's existing meaning during takeover: it reflects the physical
|
|
// unit, while effective_setpoint above remains the thermostat intent.
|
|
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
|
|
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
|
zone.demand = false;
|
|
zone.demand_since = None;
|
|
zone.target_alerted_at = None;
|
|
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)?);
|
|
continue;
|
|
}
|
|
|
|
// Temperature completion belongs to the temporary thermostat only while it truly owns
|
|
// the zone. A manual/device takeover above therefore pauses the hold instead of silently
|
|
// consuming it. GREE samples use last_seen; HA/combined samples were freshly read in this
|
|
// control cycle. A long gap resets continuous-hold evidence after restart/stale sensors.
|
|
let condition_sample_at = match zone.control_temperature_source.as_str() {
|
|
"home_assistant" | "combined" => Some(zone.updated_at.clone()),
|
|
_ => device.last_seen.clone(),
|
|
};
|
|
let max_condition_gap_seconds = settings.poll_interval_seconds
|
|
.max(settings.zone_interval_seconds)
|
|
.saturating_mul(2)
|
|
.saturating_add(5);
|
|
let condition_now = zone.updated_at.clone();
|
|
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(
|
|
&mut zone,
|
|
condition_now,
|
|
condition_sample_at,
|
|
max_condition_gap_seconds,
|
|
) {
|
|
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
|
|
finish_temporary_quick_thermostat(&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)?);
|
|
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;
|
|
}
|
|
|
|
if zone.local_thermostat_power == Some(false) {
|
|
zone.effective_mode = "off".into();
|
|
zone.demand = false;
|
|
zone.demand_since = None;
|
|
zone.device_setpoint = None;
|
|
if device.online && device.communication_failures == 0 && device.power {
|
|
let _device_guard = state.lock_device_operation(&zone.device_id).await;
|
|
let latest = state.db.get_zone(&zone.id)?;
|
|
if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) {
|
|
if let Err(err) = send_command_locked(
|
|
state,
|
|
&zone.device_id,
|
|
DeviceCommand { power: Some(false), ..Default::default() },
|
|
).await {
|
|
state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
|
|
}
|
|
}
|
|
}
|
|
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)?);
|
|
continue;
|
|
}
|
|
|
|
let blocked_by_group = ownership_blocked_by_group;
|
|
if blocked_by_group {
|
|
zone.effective_mode = "off".into();
|
|
zone.demand = false;
|
|
zone.demand_since = None;
|
|
zone.device_setpoint = None;
|
|
if device.online && device.communication_failures == 0 && device.power {
|
|
match send_zone_command_if_owned(
|
|
state,
|
|
&zone.id,
|
|
&zone.device_id,
|
|
DeviceCommand { power: Some(false), ..Default::default() },
|
|
true,
|
|
).await {
|
|
Ok(_) => {}
|
|
Err(err) => state.log("error", "group.power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id})),
|
|
}
|
|
}
|
|
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)?);
|
|
continue;
|
|
}
|
|
|
|
if discrepancy && previous_source != "device_discrepancy_fallback" {
|
|
state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({
|
|
"zone_id": zone.id,
|
|
"device_temperature": zone.device_temperature,
|
|
"external_temperature": zone.external_temperature,
|
|
"max_difference": zone.max_sensor_difference,
|
|
"entity_id": zone.ha_entity_id.as_deref(),
|
|
}));
|
|
}
|
|
|
|
// House "off" is a no-control state, not a power-off command. Keep polling and
|
|
// publishing the zone, but never overwrite manual device state while it follows
|
|
// the house mode. Explicit per-zone heat/cool bypasses this branch above.
|
|
if effective_mode == "off" {
|
|
zone.effective_setpoint = None;
|
|
zone.device_setpoint = None;
|
|
zone.demand = false;
|
|
zone.demand_since = None;
|
|
zone.target_alerted_at = None;
|
|
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)?);
|
|
continue;
|
|
}
|
|
|
|
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
|
|
let (preset, target) = resolve_zone_target(&zone, active_schedule, effective_mode);
|
|
zone.active_preset = preset;
|
|
zone.effective_setpoint = Some(target);
|
|
|
|
let Some(temp) = temperature else {
|
|
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)?);
|
|
continue;
|
|
};
|
|
|
|
let half = zone.hysteresis.max(0.1) / 2.0;
|
|
let previous_demand = zone.demand;
|
|
zone.demand = match effective_mode {
|
|
"heat" => {
|
|
if temp <= target - half { true }
|
|
else if temp >= target + half { false }
|
|
else { zone.demand }
|
|
}
|
|
_ => {
|
|
if temp >= target + half { true }
|
|
else if temp <= target - half { false }
|
|
else { zone.demand }
|
|
}
|
|
};
|
|
if zone.demand && !previous_demand {
|
|
zone.demand_since = Some(Utc::now());
|
|
zone.target_alerted_at = None;
|
|
} else if !zone.demand {
|
|
zone.demand_since = None;
|
|
zone.target_alerted_at = None;
|
|
}
|
|
if zone.demand && zone.target_alerted_at.is_none() {
|
|
let timeout_minutes = settings.notifications.target_timeout_minutes.max(5) as i64;
|
|
if let Some(since) = zone.demand_since {
|
|
if (Utc::now() - since).num_minutes() >= timeout_minutes {
|
|
state.log("warn", "zone.target_timeout", &format!("Zone {} has not reached {:.1} C within {} minutes", zone.name, target, timeout_minutes), json!({
|
|
"zone_id": zone.id, "room_temperature": temp, "target_temperature": target, "minutes": timeout_minutes
|
|
}));
|
|
zone.target_alerted_at = Some(Utc::now());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Setpoint modulation: keep the indoor unit powered and let its own inverter/compressor
|
|
// stop naturally when we move the target to the satisfied side of room temperature.
|
|
let outdoor_assist = outdoor_assist_offset(effective_mode, outdoor_assist_temperature, temp, target);
|
|
// When an independent room sensor is actually driving cooling, the indoor unit's
|
|
// own sensor can satisfy too early. Apply a half-degree pre-rounding bias: because
|
|
// GREE setpoints are sent as whole degrees, this selects the next lower whole-degree
|
|
// target (0.5-1.0 C below the room target). Do not stack it with outdoor assist and
|
|
// do not use it during device/fallback control.
|
|
let room_sensor_assist = external_room_sensor_cooling_assist(effective_mode, &zone.control_temperature_source);
|
|
let demand_assist = outdoor_assist.max(room_sensor_assist);
|
|
let active_target = match effective_mode {
|
|
"heat" => target + outdoor_assist,
|
|
_ => target - demand_assist,
|
|
};
|
|
let standby_target = match effective_mode {
|
|
"heat" => target - zone.standby_offset_c.max(0.5),
|
|
_ => target + zone.standby_offset_c.max(0.5),
|
|
};
|
|
let desired_device_target = round_device_setpoint(effective_mode, zone.demand, if zone.demand { active_target } else { standby_target });
|
|
// Report only the last confirmed device state here. The desired target belongs to
|
|
// effective_setpoint/command planning until a device command succeeds.
|
|
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
|
|
|
|
let demand_changed = previous_demand != zone.demand;
|
|
let desired_fan = if night_active {
|
|
let max_fan = settings.night_mode.max_fan_speed.clamp(1, 5);
|
|
if zone.smart_fan {
|
|
Some(night_limited_fan_speed(
|
|
smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand),
|
|
max_fan,
|
|
))
|
|
} else if device.fan_speed == 0 || device.fan_speed > max_fan {
|
|
Some(max_fan)
|
|
} else {
|
|
Some(device.fan_speed)
|
|
}
|
|
} else if zone.smart_fan {
|
|
Some(smart_fan_speed(effective_mode, temp, target, outdoor_assist_temperature, zone.demand))
|
|
} else {
|
|
None
|
|
};
|
|
// When the room becomes satisfied, ask compatible units for Quiet in the same
|
|
// frame as the standby setpoint and Low fan. When demand returns, disable Quiet
|
|
// on the normal smart-fan transition. When scheduled night mode owns Quiet, it
|
|
// explicitly enables it inside the window and releases it outside the window.
|
|
let desired_quiet = smart_quiet_command(
|
|
zone.smart_fan,
|
|
state.gree.quiet_command_supported(&device.id),
|
|
previous_demand,
|
|
zone.demand,
|
|
device.quiet,
|
|
settings.night_mode.enabled,
|
|
night_active,
|
|
settings.night_mode.force_quiet,
|
|
);
|
|
let desired_sleep = native_sleep_command(
|
|
settings.night_mode.enabled,
|
|
night_active,
|
|
settings.night_mode.use_native_sleep,
|
|
device.supports_sleep == Some(true) && state.gree.sleep_command_supported(&device.id),
|
|
device.sleep,
|
|
);
|
|
|
|
// Compressor protection for automatic ownership. Direct/manual commands and global safety OFF
|
|
// deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap.
|
|
let now = Utc::now();
|
|
if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
|
|
zone.lockout_until = None;
|
|
zone.lockout_reason = None;
|
|
}
|
|
if device.power && device.mode != effective_mode {
|
|
let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64);
|
|
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) {
|
|
let until = zone.last_power_change_at.map(|at| at + min_on);
|
|
zone.lockout_until = until;
|
|
zone.lockout_reason = Some("minimum_on_before_mode_change".into());
|
|
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)?);
|
|
continue;
|
|
}
|
|
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }, false).await {
|
|
Ok(Some(_)) => {
|
|
zone.last_power_change_at = Some(now);
|
|
zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64));
|
|
zone.lockout_reason = Some("mode_change_off_delay".into());
|
|
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until}));
|
|
}
|
|
Ok(None) => {}
|
|
Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})),
|
|
}
|
|
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)?);
|
|
continue;
|
|
}
|
|
if !device.power {
|
|
let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64);
|
|
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) {
|
|
zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off);
|
|
zone.lockout_reason = Some("minimum_off_before_start".into());
|
|
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)?);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let core_needs_command = !device.power
|
|
|| (device.target_temperature - desired_device_target).abs() >= 0.5;
|
|
// In normal standby, Low fan is a transition hint rather than a state that should
|
|
// be reasserted forever. Some GREE firmwares accept the frame but later report Auto
|
|
// again; retrying every min_adjust_seconds only causes needless command beeps.
|
|
let fan_needs_command = desired_fan
|
|
.map(|fan| fan != device.fan_speed)
|
|
.unwrap_or(false)
|
|
&& (zone.demand || demand_changed || core_needs_command || night_active);
|
|
let needs_command = core_needs_command
|
|
|| fan_needs_command
|
|
|| desired_quiet.map(|quiet| quiet != device.quiet).unwrap_or(false)
|
|
|| desired_sleep.map(|sleep| sleep != device.sleep).unwrap_or(false);
|
|
|
|
let urgent_start = !device.power;
|
|
if needs_command && (urgent_start || adjustment_allowed(&zone)) {
|
|
let command = DeviceCommand {
|
|
power: Some(true),
|
|
mode: Some(effective_mode.to_string()),
|
|
target_temperature: Some(desired_device_target),
|
|
fan_speed: if fan_needs_command { desired_fan } else { None },
|
|
quiet: desired_quiet,
|
|
sleep: desired_sleep,
|
|
..Default::default()
|
|
};
|
|
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command, false).await {
|
|
Ok(Some(updated_device)) => {
|
|
let transition_at = Utc::now();
|
|
if device.power != updated_device.power { zone.last_power_change_at = Some(transition_at); }
|
|
if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); }
|
|
zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None };
|
|
zone.last_action_at = Some(transition_at);
|
|
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
|
|
"zone_id": zone.id,
|
|
"room_temperature": temp,
|
|
"comfort_target": target,
|
|
"device_target": desired_device_target,
|
|
"mode": effective_mode,
|
|
"preset": zone.active_preset,
|
|
"outdoor_temperature": outdoor_temperature,
|
|
"fan_speed": updated_device.fan_speed,
|
|
"quiet": updated_device.quiet,
|
|
"sleep": updated_device.sleep,
|
|
"night_mode": night_active,
|
|
}));
|
|
}
|
|
Ok(None) => {
|
|
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)?);
|
|
continue;
|
|
}
|
|
Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})),
|
|
}
|
|
}
|
|
|
|
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)?);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn record_zone_history(state: &AppState, zone: &Zone, outdoor_temperature: Option<f64>, poll_interval_seconds: u64) {
|
|
let device = match state.db.get_device(&zone.device_id) {
|
|
Ok(Some(device)) => device,
|
|
Ok(None) => return,
|
|
Err(err) => {
|
|
tracing::warn!(error=?err, zone_id=%zone.id, "cannot load device for zone history");
|
|
return;
|
|
}
|
|
};
|
|
let reading = ZoneReading {
|
|
id: 0,
|
|
zone_id: zone.id.clone(),
|
|
device_id: zone.device_id.clone(),
|
|
timestamp: Utc::now(),
|
|
gree_temperature: zone.device_temperature.or(device.current_temperature),
|
|
external_temperature: zone.external_temperature,
|
|
control_temperature: zone.current_temperature.or(zone.device_temperature).or(device.current_temperature),
|
|
target_temperature: zone.effective_setpoint.or(zone.manual_setpoint).or(Some(zone.setpoint)),
|
|
device_setpoint: zone.device_setpoint.or(Some(device.target_temperature)),
|
|
outdoor_temperature: outdoor_temperature.or(device.outdoor_temperature),
|
|
power: device.power,
|
|
mode: if zone.effective_mode.is_empty() { device.mode.clone() } else { zone.effective_mode.clone() },
|
|
fan_speed: device.fan_speed,
|
|
demand: zone.demand,
|
|
control_source: zone.control_temperature_source.clone(),
|
|
active_preset: zone.active_preset.clone(),
|
|
};
|
|
let interval = poll_interval_seconds.max(15) as i64;
|
|
match state.db.add_zone_reading_if_due(&reading, interval) {
|
|
Ok(true) => queue_influx_zone(state, reading),
|
|
Ok(false) => {}
|
|
Err(err) => tracing::warn!(error=?err, zone_id=%zone.id, "cannot save zone history sample"),
|
|
}
|
|
}
|
|
|
|
fn record_ha_history(
|
|
state: &AppState,
|
|
entity_id: &str,
|
|
zone_id: Option<&str>,
|
|
kind: &str,
|
|
temperature: f64,
|
|
poll_interval_seconds: u64,
|
|
) {
|
|
let reading = HaReading {
|
|
id: 0,
|
|
entity_id: entity_id.to_string(),
|
|
zone_id: zone_id.map(str::to_string),
|
|
kind: kind.to_string(),
|
|
timestamp: Utc::now(),
|
|
temperature,
|
|
};
|
|
let interval = poll_interval_seconds.max(15) as i64;
|
|
match state.db.add_ha_reading_if_due(&reading, interval) {
|
|
Ok(true) => queue_influx_ha(state, reading),
|
|
Ok(false) => {}
|
|
Err(err) => tracing::warn!(error=?err, entity_id=%entity_id, "cannot save Home Assistant history sample"),
|
|
}
|
|
}
|
|
|
|
fn queue_influx_device(state: &AppState, reading: Reading) {
|
|
let state = state.clone();
|
|
tokio::spawn(async move {
|
|
let settings = state.settings.read().await.influxdb.clone();
|
|
if !settings.enabled { return; }
|
|
if let Err(err) = influxdb::write_device(&state.http, &settings, &reading).await {
|
|
tracing::warn!(error=?err, device_id=%reading.device_id, "cannot write device metric to InfluxDB");
|
|
}
|
|
});
|
|
}
|
|
|
|
fn queue_influx_zone(state: &AppState, reading: ZoneReading) {
|
|
let state = state.clone();
|
|
tokio::spawn(async move {
|
|
let settings = state.settings.read().await.influxdb.clone();
|
|
if !settings.enabled { return; }
|
|
if let Err(err) = influxdb::write_zone(&state.http, &settings, &reading).await {
|
|
tracing::warn!(error=?err, zone_id=%reading.zone_id, "cannot write zone metric to InfluxDB");
|
|
}
|
|
});
|
|
}
|
|
|
|
fn queue_influx_ha(state: &AppState, reading: HaReading) {
|
|
let state = state.clone();
|
|
tokio::spawn(async move {
|
|
let settings = state.settings.read().await.influxdb.clone();
|
|
if !settings.enabled { return; }
|
|
if let Err(err) = influxdb::write_ha(&state.http, &settings, &reading).await {
|
|
tracing::warn!(error=?err, entity_id=%reading.entity_id, "cannot write HA metric to InfluxDB");
|
|
}
|
|
});
|
|
}
|
|
|
|
fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
|
|
let mut values: Vec<f64> = devices.iter()
|
|
.filter(|device| device.enabled && device.online && device.communication_failures == 0)
|
|
.filter_map(|device| device.outdoor_temperature)
|
|
.filter(|value| value.is_finite() && (-60.0..=70.0).contains(value))
|
|
.collect();
|
|
if values.is_empty() { return None; }
|
|
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
|
let middle = values.len() / 2;
|
|
let value = if values.len() % 2 == 0 {
|
|
(values[middle - 1] + values[middle]) / 2.0
|
|
} else {
|
|
values[middle]
|
|
};
|
|
Some((value * 10.0).round() / 10.0)
|
|
}
|
|
|
|
fn select_zone_temperature(zone: &Zone, device_temperature: Option<f64>, external_temperature: Option<f64>) -> (Option<f64>, String, bool) {
|
|
match zone.sensor_source.as_str() {
|
|
"home_assistant" => match (external_temperature, device_temperature) {
|
|
(Some(value), _) => (Some(value), "external".into(), false),
|
|
(None, Some(value)) => (Some(value), "device_fallback".into(), false),
|
|
(None, None) => (None, "unavailable".into(), false),
|
|
},
|
|
"combined" => match (device_temperature, external_temperature) {
|
|
(Some(device), Some(external)) => {
|
|
if (device - external).abs() > zone.max_sensor_difference.max(0.1) {
|
|
(Some(device), "device_discrepancy_fallback".into(), true)
|
|
} else {
|
|
let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0);
|
|
let value = device * (1.0 - external_weight) + external * external_weight;
|
|
(Some((value * 10.0).round() / 10.0), "combined".into(), false)
|
|
}
|
|
}
|
|
(Some(value), None) => (Some(value), "device_fallback".into(), false),
|
|
(None, Some(value)) => (Some(value), "external".into(), false),
|
|
(None, None) => (None, "unavailable".into(), false),
|
|
},
|
|
_ => match device_temperature {
|
|
Some(value) => (Some(value), "device".into(), false),
|
|
None => (None, "unavailable".into(), false),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn adjustment_allowed(zone: &Zone) -> bool {
|
|
let Some(last) = zone.last_action_at else { return true; };
|
|
(Utc::now() - last).num_seconds().max(0) as u64 >= zone.min_adjust_seconds.max(15)
|
|
}
|
|
|
|
fn external_room_sensor_cooling_assist(mode: &str, control_source: &str) -> f64 {
|
|
if mode == "cool" && matches!(control_source, "external" | "combined") { 0.5 } else { 0.0 }
|
|
}
|
|
|
|
fn effective_sensor_stale_after_seconds(zone_value: u64, global_value: u64) -> u64 {
|
|
let global = global_value.clamp(30, 86_400);
|
|
// 0 and the historical hidden default (300 s) mean "inherit the HA setting".
|
|
// A non-default value supplied through the existing zone API remains a per-zone override.
|
|
if zone_value == 0 || zone_value == 300 { global } else { zone_value.clamp(30, 86_400) }
|
|
}
|
|
|
|
fn round_device_setpoint(mode: &str, demand: bool, value: f64) -> f64 {
|
|
let value = value.clamp(16.0, 30.0);
|
|
match (mode, demand) {
|
|
("heat", true) => value.ceil(),
|
|
("heat", false) => value.floor(),
|
|
(_, true) => value.floor(),
|
|
(_, false) => value.ceil(),
|
|
}
|
|
}
|
|
|
|
fn outdoor_assist_offset(mode: &str, outdoor: Option<f64>, room: f64, target: f64) -> f64 {
|
|
let Some(outdoor) = outdoor else { return 0.0; };
|
|
let room_error = (room - target).abs();
|
|
let weather = match mode {
|
|
"heat" => ((5.0 - outdoor) / 15.0).clamp(0.0, 1.0),
|
|
_ => ((outdoor - 30.0) / 10.0).clamp(0.0, 1.0),
|
|
};
|
|
(weather * room_error.clamp(0.0, 2.0) * 0.5).clamp(0.0, 1.0)
|
|
}
|
|
|
|
fn smart_quiet_command(
|
|
smart_fan: bool,
|
|
quiet_supported: bool,
|
|
previous_demand: bool,
|
|
demand: bool,
|
|
device_quiet: bool,
|
|
night_enabled: bool,
|
|
night_active: bool,
|
|
night_force_quiet: bool,
|
|
) -> Option<bool> {
|
|
if !quiet_supported { return None; }
|
|
if night_enabled && night_force_quiet && night_active {
|
|
return if device_quiet { None } else { Some(true) };
|
|
}
|
|
if night_enabled && night_force_quiet && !night_active && device_quiet {
|
|
// Explicitly release Quiet when the scheduled night window ends.
|
|
return Some(false);
|
|
}
|
|
if smart_fan {
|
|
// Smart Quiet follows demand transitions. Do not keep reasserting Quiet while a
|
|
// satisfied room remains in standby: some units report Quiet=false again even after
|
|
// accepting the command, which otherwise produces a beep every adjustment interval.
|
|
if previous_demand && !demand && !device_quiet { return Some(true); }
|
|
if !previous_demand && demand && device_quiet { return Some(false); }
|
|
return None;
|
|
}
|
|
// Without Smart Fan, Quiet can only have been requested by scheduled night mode,
|
|
// so release it after the night window ends.
|
|
if night_enabled && night_force_quiet && device_quiet { return Some(false); }
|
|
None
|
|
}
|
|
|
|
fn native_sleep_command(
|
|
night_enabled: bool,
|
|
night_active: bool,
|
|
use_native_sleep: bool,
|
|
sleep_supported: bool,
|
|
device_sleep: bool,
|
|
) -> Option<bool> {
|
|
if !sleep_supported { return None; }
|
|
if night_enabled && use_native_sleep && night_active {
|
|
return if device_sleep { None } else { Some(true) };
|
|
}
|
|
// If night mode ended or native Sleep was disabled in settings, remove a previously
|
|
// active device Sleep flag instead of leaving it latched indefinitely.
|
|
if device_sleep { return Some(false); }
|
|
None
|
|
}
|
|
|
|
fn night_limited_fan_speed(requested: u8, max_fan: u8) -> u8 {
|
|
let max_fan = max_fan.clamp(1, 5);
|
|
if requested == 0 { 1 } else { requested.min(max_fan) }
|
|
}
|
|
|
|
pub fn night_mode_active(settings: &NightModeSettings, time: NaiveTime) -> bool {
|
|
if !settings.enabled { return false; }
|
|
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return false; };
|
|
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return false; };
|
|
if start == end { return true; }
|
|
if start < end { time >= start && time < end } else { time >= start || time < end }
|
|
}
|
|
|
|
fn smart_fan_speed(mode: &str, room: f64, target: f64, outdoor: Option<f64>, demand: bool) -> u8 {
|
|
// When the thermostat is satisfied, keep airflow quiet instead of leaving the
|
|
// unit in Auto. The caller sends this together with the standby setpoint in
|
|
// the same GREE command, so e.g. 21 C reached -> 19 C + Low fan for heating.
|
|
if !demand { return 1; }
|
|
let error = (room - target).abs();
|
|
let extreme_weather = match (mode, outdoor) {
|
|
("heat", Some(value)) => value <= 0.0,
|
|
(_, Some(value)) => value >= 32.0,
|
|
_ => false,
|
|
};
|
|
if error >= 2.0 || extreme_weather { 3 } else if error >= 1.0 { 2 } else { 0 }
|
|
}
|
|
|
|
fn profile_setpoint(zone: &Zone, preset: &str, mode: &str) -> f64 {
|
|
if zone.profile_version == 0 && preset == "comfort" { return zone.setpoint; }
|
|
match (mode, preset) {
|
|
("heat", "sleep") => zone.heat_sleep_setpoint,
|
|
("heat", "away") => zone.heat_away_setpoint,
|
|
("heat", _) => zone.heat_comfort_setpoint,
|
|
(_, "sleep") => zone.cool_sleep_setpoint,
|
|
(_, "away") => zone.cool_away_setpoint,
|
|
(_, _) => zone.cool_comfort_setpoint,
|
|
}
|
|
}
|
|
|
|
fn resolve_zone_target(zone: &Zone, schedule: Option<&Schedule>, mode: &str) -> (String, f64) {
|
|
let (preset, base_target) = if let Some(manual) = zone.manual_preset.as_deref() {
|
|
if manual == "custom" {
|
|
("custom".into(), zone.setpoint)
|
|
} else {
|
|
(manual.to_string(), profile_setpoint(zone, manual, mode))
|
|
}
|
|
} else if let Some(item) = schedule {
|
|
if item.preset == "custom" {
|
|
("custom".into(), item.setpoint)
|
|
} else {
|
|
(item.preset.clone(), profile_setpoint(zone, &item.preset, mode))
|
|
}
|
|
} else {
|
|
("comfort".into(), profile_setpoint(zone, "comfort", mode))
|
|
};
|
|
// Quick +/- temperature adjustments are independent from the selected preset.
|
|
// The UI can therefore stay in Auto/Sleep/Comfort while temporarily nudging the target.
|
|
(preset, zone.manual_setpoint.unwrap_or(base_target))
|
|
}
|
|
|
|
pub fn refresh_zone_runtime_target(zone: &mut Zone, schedules: &[Schedule], house_mode: &str) {
|
|
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;
|
|
if !zone.device_manual_override {
|
|
zone.effective_setpoint = Some(target);
|
|
}
|
|
}
|
|
|
|
fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
|
|
if temporary_quick_thermostat_is_active(zone, Utc::now()) {
|
|
if let Some(mode) = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.active_mode.as_deref()) {
|
|
if matches!(mode, "cool" | "heat") { return mode.to_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))
|
|
// Overlaps are rejected by the API, but imported/legacy data may still contain one.
|
|
// Prefer the most recently edited entry instead of depending on database/name order.
|
|
.max_by_key(|item| item.updated_at)
|
|
}
|
|
|
|
fn minute_floor(now: DateTime<Local>) -> DateTime<Local> {
|
|
now.with_second(0).and_then(|value| value.with_nanosecond(0)).unwrap_or(now)
|
|
}
|
|
|
|
pub fn next_schedule_boundary_utc(zone_id: &str, schedules: &[Schedule], now: DateTime<Local>) -> Option<DateTime<Utc>> {
|
|
let current = schedules.iter()
|
|
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, now))
|
|
.max_by_key(|item| item.updated_at)
|
|
.map(|item| item.id.as_str());
|
|
let base = minute_floor(now);
|
|
// Eight days cover a complete weekly schedule plus the next transition.
|
|
for minute in 1..=(8 * 24 * 60) {
|
|
let candidate = base + chrono::Duration::minutes(minute);
|
|
let next = schedules.iter()
|
|
.filter(|item| item.enabled && item.zone_id == zone_id && schedule_active(item, candidate))
|
|
.max_by_key(|item| item.updated_at)
|
|
.map(|item| item.id.as_str());
|
|
if next != current {
|
|
return Some(candidate.with_timezone(&Utc));
|
|
}
|
|
}
|
|
// No schedule transition exists: keep a manual override until the user clears it.
|
|
None
|
|
}
|
|
|
|
fn schedule_active(item: &Schedule, now: DateTime<Local>) -> bool {
|
|
let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; };
|
|
let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; };
|
|
let time = now.time();
|
|
let today = now.weekday().number_from_monday();
|
|
if start == end {
|
|
// Equal times mean a 24-hour block starting on each selected weekday.
|
|
if time >= start {
|
|
item.weekdays.contains(&today)
|
|
} else {
|
|
let previous = previous_weekday(now.weekday()).number_from_monday();
|
|
item.weekdays.contains(&previous)
|
|
}
|
|
} else if start < end {
|
|
item.weekdays.contains(&today) && time >= start && time < end
|
|
} else if time >= start {
|
|
item.weekdays.contains(&today)
|
|
} else if time < end {
|
|
let previous = previous_weekday(now.weekday()).number_from_monday();
|
|
item.weekdays.contains(&previous)
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
fn schedule_week_mask(item: &Schedule) -> Option<Vec<bool>> {
|
|
let start = NaiveTime::parse_from_str(&item.start_time, "%H:%M").ok()?;
|
|
let end = NaiveTime::parse_from_str(&item.end_time, "%H:%M").ok()?;
|
|
let start_minute = (start.hour() * 60 + start.minute()) as usize;
|
|
let end_minute = (end.hour() * 60 + end.minute()) as usize;
|
|
let mut mask = vec![false; 7 * 24 * 60];
|
|
for weekday in &item.weekdays {
|
|
if !(1..=7).contains(weekday) { return None; }
|
|
let day = (*weekday as usize) - 1;
|
|
let mark = |mask: &mut [bool], day: usize, from: usize, to: usize| {
|
|
let base = (day % 7) * 24 * 60;
|
|
for minute in from..to { mask[base + minute] = true; }
|
|
};
|
|
if start_minute == end_minute {
|
|
mark(&mut mask, day, start_minute, 24 * 60);
|
|
mark(&mut mask, day + 1, 0, end_minute);
|
|
} else if start_minute < end_minute {
|
|
mark(&mut mask, day, start_minute, end_minute);
|
|
} else {
|
|
mark(&mut mask, day, start_minute, 24 * 60);
|
|
mark(&mut mask, day + 1, 0, end_minute);
|
|
}
|
|
}
|
|
Some(mask)
|
|
}
|
|
|
|
pub(crate) fn schedules_overlap(a: &Schedule, b: &Schedule) -> bool {
|
|
if !a.enabled || !b.enabled || a.zone_id != b.zone_id { return false; }
|
|
let (Some(left), Some(right)) = (schedule_week_mask(a), schedule_week_mask(b)) else { return false; };
|
|
left.iter().zip(right.iter()).any(|(a, b)| *a && *b)
|
|
}
|
|
|
|
fn previous_weekday(day: Weekday) -> Weekday {
|
|
match day {
|
|
Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue,
|
|
Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri,
|
|
Weekday::Sun => Weekday::Sat,
|
|
}
|
|
}
|
|
|
|
pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppError> {
|
|
let settings = state.settings.read().await.clone();
|
|
let schedules = state.db.list_schedules()?;
|
|
let devices = state.db.list_devices()?;
|
|
let zones = state.db.list_zones()?;
|
|
let groups = state.db.list_groups()?;
|
|
let house_preset = zones.first().and_then(|first| {
|
|
let first_preset = first.manual_preset.as_deref().unwrap_or("auto");
|
|
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
|
|
.then(|| first_preset.to_string())
|
|
});
|
|
let house_power = settings.house_power_enabled;
|
|
let now = Local::now();
|
|
let night_active = night_mode_active(&settings.night_mode, now.time());
|
|
let mut zones_out = Vec::new();
|
|
let mut house_events = next_night_mode_events(&settings.night_mode, now, 2);
|
|
|
|
for mut zone in zones {
|
|
let device = devices.iter().find(|item| item.id == zone.device_id);
|
|
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
|
|
let 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));
|
|
refresh_control_ownership(&mut zone, settings.house_power_enabled, blocked_by_group);
|
|
let configured_effective_mode = configured_effective_mode_owned.as_str();
|
|
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
|
|
let effective_mode = if zone.device_manual_override {
|
|
manual_device_mode.unwrap_or(configured_effective_mode)
|
|
} else if zone.local_thermostat_power == Some(false) || blocked_by_group {
|
|
"off"
|
|
} else {
|
|
configured_effective_mode
|
|
};
|
|
|
|
// Keep the thermostat target readable even while the zone/group/house control is off.
|
|
// Home Assistant climate entities otherwise expose target_temperature as unknown.
|
|
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
|
|
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
|
|
let (resolved_preset, resolved_target) = resolve_zone_target(&zone, active_for_target, target_mode);
|
|
let active = if effective_mode == "off" { None } else { active_for_target };
|
|
let next_events = if effective_mode == "off" {
|
|
Vec::new()
|
|
} else {
|
|
next_schedule_events(&zone, &schedules, effective_mode, now, 8)
|
|
};
|
|
for event in next_events.iter().take(2) {
|
|
let mut event = event.clone();
|
|
event.label = format!("{}: {}", zone.name, event.label);
|
|
house_events.push(event);
|
|
}
|
|
let effective_enabled = zone.enabled
|
|
&& zone.local_thermostat_power != Some(false)
|
|
&& (!blocked_by_group || zone.device_manual_override);
|
|
zones_out.push(ZoneControlPlan {
|
|
zone_id: zone.id.clone(),
|
|
zone_name: zone.name.clone(),
|
|
device_id: zone.device_id.clone(),
|
|
device_name: device.map(|item| item.name.clone()).unwrap_or_else(|| zone.device_id.clone()),
|
|
enabled: zone.enabled,
|
|
effective_enabled,
|
|
mode: effective_mode.to_string(),
|
|
configured_mode: zone.mode.clone(),
|
|
inherit_house_mode: zone.inherit_house_mode,
|
|
preset: resolved_preset,
|
|
preset_override: zone.manual_preset.clone(),
|
|
current_temperature: zone.current_temperature,
|
|
target_temperature: if zone.device_manual_override || !zone.enabled || effective_mode == "off" {
|
|
// A remote/manual takeover may leave a physical standby target persisted in the
|
|
// zone runtime snapshot. Never publish that value as the thermostat target.
|
|
Some(resolved_target)
|
|
} else {
|
|
zone.effective_setpoint.or(Some(resolved_target))
|
|
},
|
|
device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
|
|
desired_power: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && !blocked_by_group,
|
|
desired_mode: effective_mode.to_string(),
|
|
actual_power: device.map(|item| item.power),
|
|
actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }),
|
|
actual_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
|
|
demand: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override && zone.demand,
|
|
control_source: zone.control_temperature_source.clone(),
|
|
manual_override_until: zone.manual_override_until,
|
|
local_thermostat_power: zone.local_thermostat_power,
|
|
local_thermostat_resume_at: zone.local_thermostat_resume_at,
|
|
device_manual_override: zone.device_manual_override,
|
|
device_manual_override_until: zone.device_manual_override_until,
|
|
control_owner: zone.control_owner.clone(),
|
|
control_command_source: zone.control_source.clone(),
|
|
control_since: zone.control_since,
|
|
resume_at: zone.control_resume_at,
|
|
control_reason: zone.control_reason.clone(),
|
|
blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else if zone.device_manual_override { Some("manual_override".into()) } else if blocked_by_group { Some("group_off".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None },
|
|
lockout_until: zone.lockout_until,
|
|
current_schedule_id: active.map(|item| item.id.clone()),
|
|
current_schedule_name: active.map(|item| item.name.clone()),
|
|
next_events,
|
|
});
|
|
}
|
|
let mut rules = Vec::new();
|
|
for item in state.db.list_automations()? {
|
|
let action_group_name = item.action_group_id.as_deref()
|
|
.and_then(|id| groups.iter().find(|group| group.id == id))
|
|
.map(|group| group.name.clone());
|
|
let action_name = action_group_name.clone().unwrap_or_else(|| {
|
|
devices.iter().find(|device| device.id == item.action_device_id)
|
|
.map(|device| device.name.clone())
|
|
.unwrap_or_else(|| item.action_device_id.clone())
|
|
});
|
|
let trigger_name = item.trigger_device_id.as_deref().and_then(|id| devices.iter().find(|device| device.id == id)).map(|device| device.name.clone());
|
|
let next_ready_at = item.last_fired_at.map(|last| last + chrono::Duration::seconds(item.cooldown_seconds as i64));
|
|
if item.enabled && item.trigger_kind == "time" {
|
|
if let Some(event) = next_time_automation_event(&item, &action_name, now) {
|
|
house_events.push(event);
|
|
}
|
|
}
|
|
rules.push(AutomationPlanRule {
|
|
id: item.id,
|
|
name: item.name,
|
|
enabled: item.enabled,
|
|
trigger_kind: item.trigger_kind,
|
|
trigger_device_id: item.trigger_device_id,
|
|
trigger_device_name: trigger_name,
|
|
threshold: item.threshold,
|
|
at_time: item.at_time,
|
|
action_device_id: item.action_device_id,
|
|
action_device_name: action_name,
|
|
action_group_id: item.action_group_id,
|
|
action_group_name,
|
|
action_preset: item.action_preset,
|
|
action: item.action,
|
|
last_fired_at: item.last_fired_at,
|
|
next_ready_at,
|
|
});
|
|
}
|
|
|
|
house_events.sort_by_key(|event| event.at);
|
|
house_events.truncate(12);
|
|
|
|
Ok(ControlPlan {
|
|
generated_at: Utc::now(),
|
|
house_mode: settings.house_mode,
|
|
house_preset,
|
|
house_power,
|
|
outdoor_temperature: *state.outdoor_temperature.read().await,
|
|
control_strategy: settings.control_strategy,
|
|
night_mode_active: night_active,
|
|
night_mode_start: settings.night_mode.start_time,
|
|
night_mode_end: settings.night_mode.end_time,
|
|
night_mode_max_fan_speed: settings.night_mode.max_fan_speed.clamp(1, 5),
|
|
next_events: house_events,
|
|
zones: zones_out,
|
|
rules,
|
|
})
|
|
}
|
|
|
|
|
|
fn next_night_mode_events(settings: &NightModeSettings, now: DateTime<Local>, limit: usize) -> Vec<ControlPlanEvent> {
|
|
if !settings.enabled || limit == 0 { return Vec::new(); }
|
|
let Ok(start) = NaiveTime::parse_from_str(&settings.start_time, "%H:%M") else { return Vec::new(); };
|
|
let Ok(end) = NaiveTime::parse_from_str(&settings.end_time, "%H:%M") else { return Vec::new(); };
|
|
let mut events = Vec::new();
|
|
let base = minute_floor(now);
|
|
for minute in 1..=(48 * 60) {
|
|
let candidate = base + chrono::Duration::minutes(minute);
|
|
let time = candidate.time();
|
|
let (kind, label) = if time.hour() == start.hour() && time.minute() == start.minute() {
|
|
let quiet = if settings.force_quiet { " + Quiet" } else { "" };
|
|
("night_mode_start", format!("Night mode -> fan max {}{}", settings.max_fan_speed.clamp(1, 5), quiet))
|
|
} else if time.hour() == end.hour() && time.minute() == end.minute() {
|
|
("night_mode_end", "Night mode ends".to_string())
|
|
} else {
|
|
continue;
|
|
};
|
|
events.push(ControlPlanEvent {
|
|
at: candidate.with_timezone(&Utc),
|
|
kind: kind.into(),
|
|
label,
|
|
preset: None,
|
|
target_temperature: None,
|
|
});
|
|
if events.len() >= limit { break; }
|
|
}
|
|
events
|
|
}
|
|
|
|
fn next_time_automation_event(item: &Automation, action_name: &str, now: DateTime<Local>) -> Option<ControlPlanEvent> {
|
|
let expected = NaiveTime::parse_from_str(item.at_time.as_deref()?, "%H:%M").ok()?;
|
|
let base = minute_floor(now.clone());
|
|
if time_automation_due(item, now) {
|
|
return Some(ControlPlanEvent {
|
|
at: base.with_timezone(&Utc),
|
|
kind: "automation".into(),
|
|
label: format!("{} -> {}", item.name, action_name),
|
|
preset: None,
|
|
target_temperature: item.action.target_temperature,
|
|
});
|
|
}
|
|
// A local day can last 25 hours at the end of daylight saving time.
|
|
for minute in 1..=(26 * 60) {
|
|
let candidate = base + chrono::Duration::minutes(minute);
|
|
if candidate.hour() == expected.hour() && candidate.minute() == expected.minute() {
|
|
return Some(ControlPlanEvent {
|
|
at: candidate.with_timezone(&Utc),
|
|
kind: "automation".into(),
|
|
label: format!("{} -> {}", item.name, action_name),
|
|
preset: None,
|
|
target_temperature: item.action.target_temperature,
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn next_schedule_events(zone: &Zone, schedules: &[Schedule], mode: &str, now: DateTime<Local>, limit: usize) -> Vec<ControlPlanEvent> {
|
|
if mode == "off" { return Vec::new(); }
|
|
let mut events = Vec::new();
|
|
let mut current = active_schedule_for_zone(zone, schedules, now).map(|item| item.id.as_str());
|
|
let base = minute_floor(now);
|
|
for minute in 1..=(8 * 24 * 60) {
|
|
let candidate = base + chrono::Duration::minutes(minute);
|
|
let next = active_schedule_for_zone(zone, schedules, candidate);
|
|
let next_id = next.map(|item| item.id.as_str());
|
|
if next_id == current { continue; }
|
|
current = next_id;
|
|
let (preset, target, label) = if let Some(item) = next {
|
|
let target = if item.preset == "custom" { item.setpoint } else { profile_setpoint(zone, &item.preset, mode) };
|
|
(Some(item.preset.clone()), Some(target), format!("{} -> {} {:.1} C", item.name, item.preset, target))
|
|
} else {
|
|
let target = profile_setpoint(zone, "comfort", mode);
|
|
(Some("comfort".into()), Some(target), format!("comfort {:.1} C", target))
|
|
};
|
|
events.push(ControlPlanEvent {
|
|
at: candidate.with_timezone(&Utc),
|
|
kind: "schedule_transition".into(),
|
|
label,
|
|
preset,
|
|
target_temperature: target,
|
|
});
|
|
if events.len() >= limit { break; }
|
|
}
|
|
events
|
|
}
|
|
|
|
async fn run_automations(state: &AppState) -> Result<()> {
|
|
if !state.settings.read().await.house_power_enabled { return Ok(()); }
|
|
let devices = state.db.list_devices()?;
|
|
let zones = state.db.list_zones()?;
|
|
let groups = state.db.list_groups()?;
|
|
let mut automations = state.db.list_automations()?;
|
|
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
|
|
// This avoids database row order deciding the physical outcome (M2).
|
|
automations.sort_by(|a, b| a.created_at.cmp(&b.created_at).then_with(|| a.id.cmp(&b.id)));
|
|
let mut claimed_devices = std::collections::HashSet::<String>::new();
|
|
|
|
for mut item in automations {
|
|
if !item.enabled || !automation_ready(&item) { continue; }
|
|
let should_fire = match item.trigger_kind.as_str() {
|
|
"temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
|
.zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false),
|
|
"temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref())
|
|
.zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false),
|
|
"time" => time_automation_due(&item, Local::now()),
|
|
_ => false,
|
|
};
|
|
if !should_fire { continue; }
|
|
if item.action_group_id.is_none()
|
|
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones)
|
|
&& item.action.power != Some(true)
|
|
{
|
|
state.log("info", "automation.blocked_by_zone", &format!("Automation {} suppressed by disabled zone", item.name), json!({
|
|
"automation_id": item.id, "device_id": item.action_device_id
|
|
}));
|
|
continue;
|
|
}
|
|
if item.action_group_id.is_none() && device_blocked_by_manual_override(&item.action_device_id, &zones) {
|
|
state.log("info", "automation.blocked_by_manual_override", &format!("Automation {} suppressed by manual device control", item.name), json!({
|
|
"automation_id": item.id, "device_id": item.action_device_id
|
|
}));
|
|
continue;
|
|
}
|
|
if item.action_group_id.is_none() && device_blocked_by_local_thermostat(&item.action_device_id, &zones) {
|
|
state.log("info", "automation.blocked_by_local_thermostat", &format!("Automation {} suppressed by local thermostat ownership", item.name), json!({
|
|
"automation_id": item.id, "device_id": item.action_device_id
|
|
}));
|
|
continue;
|
|
}
|
|
if item.action_group_id.is_none() && device_blocked_by_disabled_group(&item.action_device_id, &zones, &groups) {
|
|
// Group power-off is authoritative for normal controller-owned zones. A manual
|
|
// takeover is filtered above and therefore remains higher priority than the group.
|
|
state.log("info", "automation.blocked_by_group", &format!("Automation {} suppressed by disabled group", item.name), json!({
|
|
"automation_id": item.id, "device_id": item.action_device_id
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
let target_devices: Vec<String> = if let Some(group_id) = item.action_group_id.as_deref() {
|
|
groups.iter().find(|group| group.id == group_id)
|
|
.map(|group| group.zone_ids.iter()
|
|
.filter_map(|zone_id| zones.iter().find(|zone| &zone.id == zone_id).map(|zone| zone.device_id.clone()))
|
|
.collect())
|
|
.unwrap_or_default()
|
|
} else {
|
|
vec![item.action_device_id.clone()]
|
|
};
|
|
if target_devices.iter().any(|device_id| claimed_devices.contains(device_id)) {
|
|
state.log("warn", "automation.conflict", &format!("Automation {} skipped because an older due automation already claimed the same target", item.name), json!({
|
|
"automation_id": item.id,
|
|
"group_id": item.action_group_id,
|
|
"device_id": item.action_device_id,
|
|
"target_devices": target_devices,
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
let result: Result<bool, AppError> = if let Some(group_id) = item.action_group_id.as_deref() {
|
|
let group_mode = item.action.mode.as_deref().map(|mode| if mode == "auto" { "house".to_string() } else { mode.to_string() });
|
|
control_group(state, group_id, GroupControlPatch {
|
|
power: item.action.power,
|
|
mode: group_mode,
|
|
preset: item.action_preset.clone(),
|
|
}, "automation.group").await.map(|_| true)
|
|
} else {
|
|
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
|
|
Ok(Some(_)) => Ok(true),
|
|
Ok(None) => {
|
|
state.log("info", "automation.blocked_by_fresh_ownership", &format!("Automation {} was suppressed after ownership changed", item.name), json!({
|
|
"automation_id": item.id, "device_id": item.action_device_id
|
|
}));
|
|
Ok(false)
|
|
}
|
|
Err(err) => Err(err),
|
|
}
|
|
};
|
|
match result {
|
|
Ok(true) => {
|
|
for device_id in target_devices { claimed_devices.insert(device_id); }
|
|
item.last_fired_at = Some(Utc::now());
|
|
item.updated_at = Utc::now();
|
|
state.db.save_automation(&item)?;
|
|
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
|
|
"automation_id": item.id, "group_id": item.action_group_id, "device_id": item.action_device_id
|
|
}));
|
|
}
|
|
Ok(false) => {
|
|
// Ownership suppression is not an execution. Do not consume cooldown (M3),
|
|
// so a still-valid trigger may run as soon as the higher-priority owner leaves.
|
|
}
|
|
Err(err) => {
|
|
// A failed action is still an execution attempt. Apply the configured cooldown
|
|
// so an offline/disabled target cannot be hammered on every automation cycle.
|
|
item.last_fired_at = Some(Utc::now());
|
|
item.updated_at = Utc::now();
|
|
state.db.save_automation(&item)?;
|
|
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id}));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn device_blocked_by_disabled_zone(device_id: &str, zones: &[Zone]) -> bool {
|
|
zones.iter().any(|zone| zone.device_id == device_id && !zone.enabled)
|
|
}
|
|
|
|
fn device_blocked_by_manual_override(device_id: &str, zones: &[Zone]) -> bool {
|
|
zones.iter().any(|zone| zone.device_id == device_id && zone.device_manual_override)
|
|
}
|
|
|
|
fn device_blocked_by_local_thermostat(device_id: &str, zones: &[Zone]) -> bool {
|
|
zones.iter().any(|zone| zone.device_id == device_id && zone.local_thermostat_power.is_some())
|
|
}
|
|
|
|
fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[crate::models::ClimateGroup]) -> bool {
|
|
let zone_ids: std::collections::HashSet<&str> = zones.iter()
|
|
.filter(|zone| zone.device_id == device_id)
|
|
.map(|zone| zone.id.as_str())
|
|
.collect();
|
|
if zone_ids.is_empty() { return false; }
|
|
groups.iter().any(|group| !group.power_enabled && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id.as_str())))
|
|
}
|
|
|
|
fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option<f64> {
|
|
let id = device_id?;
|
|
// Never fire a temperature automation from stale cached data of an offline/disabled unit.
|
|
devices.iter().find(|d| d.id == id && d.enabled && d.online && d.communication_failures == 0)?.current_temperature
|
|
}
|
|
|
|
fn automation_ready(item: &Automation) -> bool {
|
|
item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true)
|
|
}
|
|
|
|
fn time_automation_due(item: &Automation, now: DateTime<Local>) -> bool {
|
|
let Some(expected) = item.at_time.as_deref() else { return false; };
|
|
let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; };
|
|
if now.hour() != value.hour() || now.minute() != value.minute() { return false; }
|
|
if let Some(last) = item.last_fired_at {
|
|
let local_last = last.with_timezone(&Local);
|
|
if local_last.date_naive() == now.date_naive()
|
|
&& local_last.hour() == now.hour()
|
|
&& local_last.minute() == now.minute()
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use chrono::TimeZone;
|
|
|
|
#[test]
|
|
fn global_ha_sensor_stale_timeout_is_used_for_default_zone_value() {
|
|
assert_eq!(effective_sensor_stale_after_seconds(300, 600), 600);
|
|
assert_eq!(effective_sensor_stale_after_seconds(0, 900), 900);
|
|
assert_eq!(effective_sensor_stale_after_seconds(120, 600), 120);
|
|
assert_eq!(effective_sensor_stale_after_seconds(120_000, 600), 86_400);
|
|
}
|
|
|
|
#[test]
|
|
fn overnight_schedule_works() {
|
|
let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday
|
|
let item = Schedule {
|
|
id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true,
|
|
weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), preset: "custom".into(), setpoint: 20.0,
|
|
created_at: Utc::now(), updated_at: Utc::now(),
|
|
};
|
|
assert!(schedule_active(&item, now));
|
|
}
|
|
|
|
fn test_schedule(id: &str, weekdays: Vec<u32>, start: &str, end: &str) -> Schedule {
|
|
Schedule {
|
|
id: id.into(), zone_id: "z".into(), name: id.into(), enabled: true,
|
|
weekdays, start_time: start.into(), end_time: end.into(), preset: "comfort".into(), setpoint: 21.0,
|
|
created_at: Utc::now(), updated_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn equal_schedule_times_mean_a_full_day() {
|
|
let item = test_schedule("full", vec![1], "06:00", "06:00");
|
|
let monday_noon = Local.with_ymd_and_hms(2025, 1, 6, 12, 0, 0).single().unwrap();
|
|
let tuesday_early = Local.with_ymd_and_hms(2025, 1, 7, 5, 59, 0).single().unwrap();
|
|
let tuesday_after = Local.with_ymd_and_hms(2025, 1, 7, 6, 1, 0).single().unwrap();
|
|
assert!(schedule_active(&item, monday_noon));
|
|
assert!(schedule_active(&item, tuesday_early));
|
|
assert!(!schedule_active(&item, tuesday_after));
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_overlap_detection_handles_overnight_ranges() {
|
|
let daytime = test_schedule("day", vec![1,2,3,4,5,6,7], "06:30", "22:30");
|
|
let night = test_schedule("night", vec![1,2,3,4,5,6,7], "22:30", "06:30");
|
|
let conflict = test_schedule("conflict", vec![1], "22:00", "23:00");
|
|
assert!(!schedules_overlap(&daytime, &night));
|
|
assert!(schedules_overlap(&night, &conflict));
|
|
}
|
|
|
|
#[test]
|
|
fn next_schedule_boundary_scans_the_whole_week() {
|
|
let friday = test_schedule("friday", vec![5], "12:00", "13:00");
|
|
let monday = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
|
|
let boundary = next_schedule_boundary_utc("z", &[friday], monday).unwrap().with_timezone(&Local);
|
|
assert_eq!(boundary.weekday(), Weekday::Fri);
|
|
assert_eq!(boundary.hour(), 12);
|
|
assert_eq!(boundary.minute(), 0);
|
|
assert_eq!(boundary.second(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn full_week_schedule_has_no_manual_override_boundary() {
|
|
let always = test_schedule("always", vec![1,2,3,4,5,6,7], "00:00", "00:00");
|
|
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 0, 30).single().unwrap();
|
|
assert!(next_schedule_boundary_utc("z", &[always], now).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn workday_weekend_handoff_has_no_overlaps() {
|
|
let schedules = vec![
|
|
test_schedule("morning", vec![1,2,3,4,5], "06:30", "08:00"),
|
|
test_schedule("away", vec![1,2,3,4,5], "08:00", "16:00"),
|
|
test_schedule("evening", vec![1,2,3,4,5], "16:00", "22:30"),
|
|
test_schedule("sleep", vec![1,2,3,4,5], "22:30", "06:30"),
|
|
test_schedule("weekend", vec![6,7], "08:00", "23:00"),
|
|
test_schedule("saturday-sleep", vec![6], "23:00", "08:00"),
|
|
test_schedule("sunday-sleep", vec![7], "23:00", "06:30"),
|
|
];
|
|
for (index, item) in schedules.iter().enumerate() {
|
|
for other in schedules.iter().skip(index + 1) {
|
|
assert!(!schedules_overlap(item, other), "{} overlaps {}", item.name, other.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn time_automation_fires_only_once_in_the_same_minute() {
|
|
let now = Local.with_ymd_and_hms(2025, 1, 6, 10, 15, 40).single().unwrap();
|
|
let mut item = Automation {
|
|
id: "a".into(), name: "at time".into(), enabled: true, trigger_kind: "time".into(),
|
|
trigger_device_id: None, threshold: None, at_time: Some("10:15".into()),
|
|
action_device_id: "d".into(), action_group_id: None, action_preset: None,
|
|
action: DeviceCommand { power: Some(true), ..Default::default() }, cooldown_seconds: 30,
|
|
last_fired_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
|
};
|
|
assert!(time_automation_due(&item, now.clone()));
|
|
item.last_fired_at = Some((now.clone() - chrono::Duration::seconds(35)).with_timezone(&Utc));
|
|
assert!(!time_automation_due(&item, now));
|
|
}
|
|
|
|
fn test_zone(source: &str) -> Zone {
|
|
Zone {
|
|
id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true,
|
|
mode: "heat".into(), inherit_house_mode: true, setpoint: 21.0, profile_version: 1,
|
|
cool_comfort_setpoint: 23.0, cool_sleep_setpoint: 24.5, cool_away_setpoint: 27.0,
|
|
heat_comfort_setpoint: 21.0, heat_sleep_setpoint: 19.0, heat_away_setpoint: 17.0,
|
|
hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, min_adjust_seconds: 120, standby_offset_c: 2.0, smart_fan: true,
|
|
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, local_thermostat_restore_zone_enabled: 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,
|
|
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn external_device_change_detects_manual_climate_controls() {
|
|
let zone = test_zone("device");
|
|
let before = Device::simulated_default();
|
|
let mut after = before.clone();
|
|
after.power = !before.power;
|
|
after.target_temperature = before.target_temperature + 1.0;
|
|
after.fan_speed = 3;
|
|
let fields = externally_changed_control_fields(&before, &after, &zone);
|
|
assert!(fields.iter().any(|field| field == "power"));
|
|
assert!(fields.iter().any(|field| field == "target_temperature"));
|
|
assert!(fields.iter().any(|field| field == "fan_speed"));
|
|
}
|
|
|
|
#[test]
|
|
fn controller_expected_climate_change_is_recognized() {
|
|
let mut device = Device::simulated_default();
|
|
device.power = true;
|
|
device.mode = "cool".into();
|
|
device.target_temperature = 22.0;
|
|
let command = DeviceCommand {
|
|
power: Some(true),
|
|
mode: Some("cool".into()),
|
|
target_temperature: Some(22.0),
|
|
..Default::default()
|
|
};
|
|
assert!(command_field_matches_device(&command, "power", &device));
|
|
assert!(command_field_matches_device(&command, "mode", &device));
|
|
assert!(command_field_matches_device(&command, "target_temperature", &device));
|
|
device.target_temperature = 25.0;
|
|
assert!(!command_field_matches_device(&command, "target_temperature", &device));
|
|
}
|
|
|
|
#[test]
|
|
fn local_thermostat_ownership_blocks_direct_automation() {
|
|
let mut zone = test_zone("device");
|
|
assert!(!device_blocked_by_local_thermostat(
|
|
&zone.device_id,
|
|
std::slice::from_ref(&zone),
|
|
));
|
|
zone.local_thermostat_power = Some(true);
|
|
assert!(device_blocked_by_local_thermostat(
|
|
&zone.device_id,
|
|
std::slice::from_ref(&zone),
|
|
));
|
|
zone.local_thermostat_power = Some(false);
|
|
assert!(device_blocked_by_local_thermostat(
|
|
&zone.device_id,
|
|
std::slice::from_ref(&zone),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn local_thermostat_resume_clears_only_local_quick_control_state() {
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(false);
|
|
zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15));
|
|
zone.manual_preset = Some("comfort".into());
|
|
zone.manual_setpoint = Some(23.0);
|
|
zone.manual_override_until = Some(Utc::now() + chrono::Duration::hours(1));
|
|
zone.device_manual_override = true;
|
|
|
|
assert!(reset_local_thermostat_override(&mut zone));
|
|
assert!(zone.local_thermostat_power.is_none());
|
|
assert!(zone.local_thermostat_resume_at.is_none());
|
|
assert!(zone.manual_preset.is_none());
|
|
assert!(zone.manual_setpoint.is_none());
|
|
assert!(zone.manual_override_until.is_none());
|
|
assert!(zone.device_manual_override);
|
|
assert_eq!(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES, 15);
|
|
}
|
|
|
|
fn temporary_session(now: DateTime<Utc>) -> TemporaryQuickThermostat {
|
|
TemporaryQuickThermostat {
|
|
start_kind: "now".into(),
|
|
finish_kind: "temperature_stable".into(),
|
|
started_at: now.clone(),
|
|
activated_at: Some(now),
|
|
state: "active".into(),
|
|
generation: 1,
|
|
active_mode: Some("heat".into()),
|
|
restore_zone_enabled: Some(true),
|
|
restore_local_thermostat_power: None,
|
|
restore_local_thermostat_resume_at: None,
|
|
restore_local_thermostat_zone_enabled: None,
|
|
restore_manual_preset: None,
|
|
restore_manual_setpoint: None,
|
|
restore_manual_override_until: None,
|
|
expires_at: None,
|
|
duration_seconds: None,
|
|
safety_duration_seconds: None,
|
|
temperature_target: Some(23.0),
|
|
temperature_operator: Some("within".into()),
|
|
tolerance_c: 0.3,
|
|
hold_seconds: 3600,
|
|
condition_started_at: None,
|
|
condition_last_observed_at: None,
|
|
paused_at: None,
|
|
deferred_mode: None,
|
|
deferred_preset: None,
|
|
safety_expires_at: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn scheduled_temporary_session_is_not_activated_by_unrelated_local_quick_on() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(true);
|
|
let mut session = temporary_session(now + chrono::Duration::hours(1));
|
|
session.start_kind = "delay".into();
|
|
session.activated_at = None;
|
|
session.state = "scheduled".into();
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(!temporary_quick_thermostat_is_active(&zone, now));
|
|
assert_eq!(temporary_quick_thermostat_wakeup_at(&zone, now), Some(now + chrono::Duration::hours(1)));
|
|
}
|
|
|
|
#[test]
|
|
fn local_handback_cleanup_does_not_remove_pending_temporary_session() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
set_local_thermostat_power(&mut zone, false, now);
|
|
let mut session = temporary_session(now + chrono::Duration::minutes(30));
|
|
session.start_kind = "delay".into();
|
|
session.activated_at = None;
|
|
session.state = "scheduled".into();
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
reset_local_thermostat_override(&mut zone);
|
|
assert!(zone.temporary_quick_thermostat.is_some());
|
|
assert!(zone.local_thermostat_power.is_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!(finish_temporary_quick_thermostat(&mut zone, &[], "heat"));
|
|
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_quick_thermostat_returns_to_underlying_local_quick_state() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.enabled = true;
|
|
zone.local_thermostat_power = Some(true);
|
|
zone.local_thermostat_restore_zone_enabled = None;
|
|
zone.manual_setpoint = Some(24.0);
|
|
let mut session = temporary_session(now);
|
|
session.restore_zone_enabled = Some(true);
|
|
session.restore_local_thermostat_power = Some(true);
|
|
session.restore_local_thermostat_zone_enabled = Some(false);
|
|
session.restore_manual_setpoint = Some(22.0);
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
zone.manual_setpoint = Some(23.0);
|
|
|
|
assert!(finish_temporary_quick_thermostat(&mut zone, &[], "heat"));
|
|
assert!(zone.enabled);
|
|
assert_eq!(zone.local_thermostat_power, Some(true));
|
|
assert_eq!(zone.local_thermostat_restore_zone_enabled, Some(false));
|
|
assert_eq!(zone.manual_setpoint, Some(22.0));
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_immediate_session_can_use_local_power_fallback_but_fresh_session_cannot() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.local_thermostat_power = Some(true);
|
|
let mut session = temporary_session(now);
|
|
session.activated_at = None;
|
|
session.generation = 0;
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
assert!(temporary_quick_thermostat_is_active(&zone, now));
|
|
|
|
zone.temporary_quick_thermostat.as_mut().unwrap().generation = 1;
|
|
assert!(!temporary_quick_thermostat_is_active(&zone, now));
|
|
}
|
|
|
|
#[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(), Some(now.clone()), 10).is_none());
|
|
assert_eq!(evaluate_temporary_quick_thermostat_condition(&mut zone, now + chrono::Duration::seconds(2), Some(now + chrono::Duration::seconds(2)), 10), 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, Some(now), 10).is_none());
|
|
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn temporary_stable_condition_resets_after_observation_gap() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.current_temperature = Some(23.0);
|
|
let mut session = temporary_session(now);
|
|
session.condition_started_at = Some(now - chrono::Duration::hours(1));
|
|
session.condition_last_observed_at = Some(now - chrono::Duration::minutes(10));
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert!(evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 30).is_none());
|
|
assert_eq!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at, Some(now));
|
|
}
|
|
|
|
#[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.state = "scheduled".into();
|
|
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, Some(now), 10).is_none());
|
|
assert!(zone.temporary_quick_thermostat.as_ref().unwrap().condition_started_at.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_active_temporary_session_without_activated_at_still_evaluates_condition() {
|
|
let now = Utc::now();
|
|
let mut zone = test_zone("device");
|
|
zone.current_temperature = Some(23.0);
|
|
zone.local_thermostat_power = Some(true);
|
|
let mut session = temporary_session(now.clone());
|
|
session.activated_at = None;
|
|
session.condition_started_at = Some(now.clone() - chrono::Duration::hours(1));
|
|
zone.temporary_quick_thermostat = Some(session);
|
|
|
|
assert_eq!(
|
|
evaluate_temporary_quick_thermostat_condition(&mut zone, now, Some(now), 10),
|
|
Some("temperature_stable".into())
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
let first = Utc::now();
|
|
set_local_thermostat_power(&mut zone, false, first.clone());
|
|
let first_deadline = zone.local_thermostat_resume_at.clone().unwrap();
|
|
assert_eq!(first_deadline, first.clone() + chrono::Duration::minutes(15));
|
|
|
|
let second = first + chrono::Duration::minutes(4);
|
|
set_local_thermostat_power(&mut zone, true, second.clone());
|
|
assert!(zone.local_thermostat_resume_at.is_none());
|
|
set_local_thermostat_power(&mut zone, false, second.clone());
|
|
assert_eq!(zone.local_thermostat_resume_at, Some(second + chrono::Duration::minutes(15)));
|
|
assert!(zone.local_thermostat_resume_at.clone().unwrap() > first_deadline);
|
|
}
|
|
|
|
#[test]
|
|
fn manual_device_takeover_suspends_local_handback_until_control_returns() {
|
|
let mut zone = test_zone("device");
|
|
let now = Utc::now();
|
|
set_local_thermostat_power(&mut zone, false, now.clone());
|
|
assert!(local_thermostat_handback_is_active(&zone));
|
|
|
|
zone.device_manual_override = true;
|
|
assert!(!local_thermostat_handback_is_active(&zone));
|
|
|
|
zone.device_manual_override = false;
|
|
let returned = now + chrono::Duration::minutes(7);
|
|
assert!(rearm_local_thermostat_resume(&mut zone, returned.clone()));
|
|
assert_eq!(zone.local_thermostat_resume_at, Some(returned + chrono::Duration::minutes(15)));
|
|
assert!(local_thermostat_handback_is_active(&zone));
|
|
}
|
|
|
|
#[test]
|
|
fn rounded_gree_setpoint_does_not_create_manual_override() {
|
|
let zone = test_zone("device");
|
|
let mut before = Device::simulated_default();
|
|
before.target_temperature = 23.5;
|
|
let mut after = before.clone();
|
|
after.target_temperature = 24.0;
|
|
assert!(externally_changed_control_fields(&before, &after, &zone).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn standby_low_to_auto_fan_drift_is_not_manual_override() {
|
|
let mut zone = test_zone("device");
|
|
zone.smart_fan = true;
|
|
zone.demand = false;
|
|
let mut before = Device::simulated_default();
|
|
before.fan_speed = 1;
|
|
let mut after = before.clone();
|
|
after.fan_speed = 0;
|
|
assert!(externally_changed_control_fields(&before, &after, &zone).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn reset_device_manual_override_clears_takeover_state() {
|
|
let mut zone = test_zone("device");
|
|
let device = Device::simulated_default();
|
|
zone.device_manual_override = true;
|
|
zone.device_manual_override_since = Some(Utc::now());
|
|
zone.device_manual_override_until = Some(Utc::now());
|
|
zone.device_manual_override_fields = vec!["target_temperature".into()];
|
|
zone.device_manual_override_baseline = Some((&device).into());
|
|
assert!(reset_device_manual_override(&mut zone));
|
|
assert!(!zone.device_manual_override);
|
|
assert!(zone.device_manual_override_since.is_none());
|
|
assert!(zone.device_manual_override_until.is_none());
|
|
assert!(zone.device_manual_override_fields.is_empty());
|
|
assert!(zone.device_manual_override_baseline.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn restored_manual_device_state_matches_original_takeover_baseline() {
|
|
let mut zone = test_zone("device");
|
|
let baseline = Device::simulated_default();
|
|
zone.device_manual_override = true;
|
|
zone.device_manual_override_fields = vec!["power".into(), "target_temperature".into()];
|
|
zone.device_manual_override_baseline = Some((&baseline).into());
|
|
|
|
let mut changed = baseline.clone();
|
|
changed.power = !baseline.power;
|
|
changed.target_temperature = baseline.target_temperature + 2.0;
|
|
assert!(!manual_override_matches_baseline(&zone, &changed));
|
|
|
|
let mut returned_off = baseline.clone();
|
|
returned_off.target_temperature = baseline.target_temperature + 3.0;
|
|
assert!(manual_override_matches_baseline(&zone, &returned_off));
|
|
assert!(manual_override_matches_baseline(&zone, &baseline));
|
|
}
|
|
|
|
#[test]
|
|
fn device_command_drops_unchanged_fields() {
|
|
let device = Device::simulated_default();
|
|
let command = DeviceCommand {
|
|
power: Some(false),
|
|
mode: Some("cool".into()),
|
|
target_temperature: Some(23.4),
|
|
fan_speed: Some(3),
|
|
light: Some(false),
|
|
..DeviceCommand::default()
|
|
};
|
|
let changed = command.changed_from(&device);
|
|
assert_eq!(changed.power, None);
|
|
assert_eq!(changed.mode, None);
|
|
assert_eq!(changed.target_temperature, None);
|
|
assert_eq!(changed.fan_speed, Some(3));
|
|
assert_eq!(changed.light, Some(false));
|
|
}
|
|
|
|
#[test]
|
|
fn combined_temperature_prefers_room_sensor_weight() {
|
|
let zone = test_zone("combined");
|
|
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0));
|
|
assert_eq!(value, Some(21.2));
|
|
assert_eq!(source, "combined");
|
|
assert!(!discrepancy);
|
|
}
|
|
|
|
#[test]
|
|
fn combined_temperature_falls_back_on_large_discrepancy() {
|
|
let zone = test_zone("combined");
|
|
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0));
|
|
assert_eq!(value, Some(21.0));
|
|
assert_eq!(source, "device_discrepancy_fallback");
|
|
assert!(discrepancy);
|
|
}
|
|
|
|
#[test]
|
|
fn combined_temperature_falls_back_when_external_is_missing() {
|
|
let zone = test_zone("combined");
|
|
let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None);
|
|
assert_eq!(value, Some(21.5));
|
|
assert_eq!(source, "device_fallback");
|
|
assert!(!discrepancy);
|
|
}
|
|
|
|
#[test]
|
|
fn seasonal_profiles_resolve_independently() {
|
|
let zone = test_zone("device");
|
|
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 23.0);
|
|
assert_eq!(profile_setpoint(&zone, "sleep", "cool"), 24.5);
|
|
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 21.0);
|
|
assert_eq!(profile_setpoint(&zone, "sleep", "heat"), 19.0);
|
|
}
|
|
|
|
#[test]
|
|
fn quick_setpoint_keeps_active_preset() {
|
|
let mut zone = test_zone("device");
|
|
zone.manual_setpoint = Some(22.5);
|
|
let (preset, target) = resolve_zone_target(&zone, None, "cool");
|
|
assert_eq!(preset, "comfort");
|
|
assert_eq!(target, 22.5);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_target_refresh_applies_manual_profile_immediately() {
|
|
let mut zone = test_zone("device");
|
|
zone.inherit_house_mode = false;
|
|
zone.mode = "cool".into();
|
|
zone.manual_preset = Some("sleep".into());
|
|
zone.active_preset = "comfort".into();
|
|
zone.effective_setpoint = Some(25.0);
|
|
refresh_zone_runtime_target(&mut zone, &[], "cool");
|
|
assert_eq!(zone.active_preset, "sleep");
|
|
assert_eq!(zone.effective_setpoint, Some(24.5));
|
|
assert_eq!(zone.effective_mode, "cool");
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_zone_keeps_old_comfort_setpoint() {
|
|
let mut zone = test_zone("device");
|
|
zone.profile_version = 0;
|
|
zone.setpoint = 22.5;
|
|
assert_eq!(profile_setpoint(&zone, "comfort", "cool"), 22.5);
|
|
assert_eq!(profile_setpoint(&zone, "comfort", "heat"), 22.5);
|
|
}
|
|
|
|
#[test]
|
|
fn device_setpoint_rounding_preserves_control_direction() {
|
|
assert_eq!(round_device_setpoint("cool", true, 23.5), 23.0);
|
|
assert_eq!(round_device_setpoint("cool", false, 25.5), 26.0);
|
|
assert_eq!(round_device_setpoint("heat", true, 21.5), 22.0);
|
|
assert_eq!(round_device_setpoint("heat", false, 19.5), 19.0);
|
|
}
|
|
|
|
#[test]
|
|
fn external_room_sensor_selects_lower_cooling_setpoint_only_when_used() {
|
|
assert_eq!(external_room_sensor_cooling_assist("cool", "external"), 0.5);
|
|
assert_eq!(external_room_sensor_cooling_assist("cool", "combined"), 0.5);
|
|
assert_eq!(external_room_sensor_cooling_assist("cool", "device_fallback"), 0.0);
|
|
assert_eq!(external_room_sensor_cooling_assist("cool", "device_discrepancy_fallback"), 0.0);
|
|
assert_eq!(external_room_sensor_cooling_assist("heat", "external"), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn smart_fan_uses_low_speed_when_zone_is_satisfied() {
|
|
assert_eq!(smart_fan_speed("heat", 21.0, 21.0, None, false), 1);
|
|
assert_eq!(smart_fan_speed("cool", 23.0, 23.0, None, false), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn smart_quiet_follows_satisfied_transition_only_when_supported() {
|
|
assert_eq!(smart_quiet_command(true, true, true, false, false, false, false, true), Some(true));
|
|
assert_eq!(smart_quiet_command(true, true, false, false, false, false, false, true), None);
|
|
assert_eq!(smart_quiet_command(true, true, false, false, true, false, false, true), None);
|
|
assert_eq!(smart_quiet_command(true, true, false, true, true, false, false, true), Some(false));
|
|
assert_eq!(smart_quiet_command(true, true, true, true, true, false, false, true), None);
|
|
assert_eq!(smart_quiet_command(true, false, true, false, false, false, false, true), None);
|
|
assert_eq!(smart_quiet_command(false, true, true, false, false, false, false, true), None);
|
|
}
|
|
|
|
#[test]
|
|
fn night_mode_handles_midnight_and_limits_auto_fan() {
|
|
let settings = NightModeSettings { enabled: true, start_time: "22:00".into(), end_time: "06:00".into(), max_fan_speed: 1, force_quiet: true, use_native_sleep: true };
|
|
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(23, 30, 0).unwrap()));
|
|
assert!(night_mode_active(&settings, NaiveTime::from_hms_opt(5, 59, 0).unwrap()));
|
|
assert!(!night_mode_active(&settings, NaiveTime::from_hms_opt(12, 0, 0).unwrap()));
|
|
assert_eq!(night_limited_fan_speed(0, 1), 1);
|
|
assert_eq!(night_limited_fan_speed(3, 1), 1);
|
|
assert_eq!(smart_quiet_command(false, true, true, true, false, true, true, true), Some(true));
|
|
assert_eq!(smart_quiet_command(false, true, true, true, true, true, false, true), Some(false));
|
|
assert_eq!(smart_quiet_command(true, true, false, false, true, true, false, true), Some(false));
|
|
assert_eq!(native_sleep_command(true, true, true, true, false), Some(true));
|
|
assert_eq!(native_sleep_command(true, false, true, true, true), Some(false));
|
|
assert_eq!(native_sleep_command(false, false, false, true, true), Some(false));
|
|
assert_eq!(native_sleep_command(true, true, true, false, false), None);
|
|
}
|
|
|
|
#[test]
|
|
fn gree_outdoor_fallback_uses_median_of_online_units() {
|
|
let mut a = Device::simulated_default();
|
|
a.outdoor_temperature = Some(10.0);
|
|
let mut b = Device::simulated_default();
|
|
b.id = "sim-b".into();
|
|
b.outdoor_temperature = Some(12.0);
|
|
let mut c = Device::simulated_default();
|
|
c.id = "sim-c".into();
|
|
c.outdoor_temperature = Some(40.0);
|
|
assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0));
|
|
}
|
|
|
|
#[test]
|
|
fn outdoor_assist_is_bounded_and_direction_neutral() {
|
|
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
|
|
let heat = outdoor_assist_offset("heat", Some(-5.0), 17.0, 21.0);
|
|
assert!(cool > 0.0 && cool <= 1.0);
|
|
assert!(heat > 0.0 && heat <= 1.0);
|
|
assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0);
|
|
}
|
|
|
|
}
|