v0.7.4
This commit is contained in:
+395
-108
@@ -1,4 +1,4 @@
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{collections::HashMap, time::{Duration, Instant}};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use serde_json::{json, Value};
|
||||
@@ -97,21 +97,42 @@ async fn archive_old_history(state: &AppState, threshold_days: u32) -> Result<u6
|
||||
}
|
||||
|
||||
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).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).await
|
||||
}
|
||||
|
||||
async fn send_command_locked_inner(
|
||||
state: &AppState,
|
||||
device_id: &str,
|
||||
command: DeviceCommand,
|
||||
dedupe_against_cache: 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())); }
|
||||
|
||||
// Do not wake/beep a unit for fields that already match the last known state.
|
||||
// Offline devices still receive the full request because their cached state may be stale.
|
||||
let command = if device.online { command.changed_from(&device) } else { command };
|
||||
// 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 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());
|
||||
@@ -136,16 +157,34 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
||||
match state.gree.command(&device, &command, suppress_beep).await {
|
||||
Ok(result) => applied_command = result,
|
||||
Err(first_err) => {
|
||||
// Retry once after a fresh bind. This covers stale keys and devices that
|
||||
// switched between ECB/GCM after a firmware update.
|
||||
let retry_result = 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
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
Err(_) => Err(first_err),
|
||||
};
|
||||
match retry_result {
|
||||
Ok(result) => applied_command = result,
|
||||
@@ -158,32 +197,59 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm
|
||||
}
|
||||
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); }
|
||||
applied_command.apply(&mut device);
|
||||
device.online = true;
|
||||
device.communication_failures = 0;
|
||||
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
|
||||
// A command ACK confirms transport/acceptance, not the resulting climate state. Read
|
||||
// status before publishing device_setpoint/power/mode as factual. If verification is
|
||||
// unavailable, keep the previous confirmed values and mark communication uncertainty.
|
||||
if !confirmed_state {
|
||||
let mut observed = device.clone();
|
||||
match state.gree.poll(&mut observed).await {
|
||||
Ok(()) => {
|
||||
if !applied_command.changed_from(&observed).is_empty() {
|
||||
confirmed_requested_state = false;
|
||||
tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but verified status differs");
|
||||
}
|
||||
device = observed;
|
||||
confirmed_state = true;
|
||||
}
|
||||
Err(err) => {
|
||||
record_poll_failure(&mut device, &format!("command accepted but status verification failed: {err}"));
|
||||
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": err.to_string()
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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;
|
||||
let concurrent_command = state.db.get_device(device_id)?
|
||||
.map(|current| current.updated_at > before.updated_at)
|
||||
.unwrap_or(false);
|
||||
if !concurrent_command && poll_completed_successfully(&device) {
|
||||
if poll_completed_successfully(&device) {
|
||||
detect_external_device_control(state, &before, &device)?;
|
||||
}
|
||||
state.db.save_device(&device)?;
|
||||
@@ -193,19 +259,13 @@ pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppEr
|
||||
}
|
||||
|
||||
async fn poll_all(state: &AppState) -> Result<()> {
|
||||
for mut device in state.db.list_devices()? {
|
||||
if !device.enabled { continue; }
|
||||
let before = device.clone();
|
||||
poll_device(state, &mut device).await;
|
||||
let concurrent_command = state.db.get_device(&device.id)?
|
||||
.map(|current| current.updated_at > before.updated_at)
|
||||
.unwrap_or(false);
|
||||
if !concurrent_command && poll_completed_successfully(&device) {
|
||||
detect_external_device_control(state, &before, &device)?;
|
||||
}
|
||||
state.db.save_device(&device)?;
|
||||
record_reading(state, &device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
||||
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(())
|
||||
}
|
||||
@@ -232,17 +292,22 @@ async fn poll_device(state: &AppState, device: &mut Device) {
|
||||
}
|
||||
}
|
||||
if let Err(first_err) = state.gree.poll(device).await {
|
||||
// A stale key or wrong cipher should heal automatically during polling.
|
||||
// Rebind once, then retry the status request before counting a failure.
|
||||
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());
|
||||
// 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()),
|
||||
}
|
||||
Err(_) => record_poll_failure(device, &first_err.to_string()),
|
||||
}
|
||||
}
|
||||
if device.communication_failures == 0 && device.online {
|
||||
@@ -364,7 +429,7 @@ fn externally_changed_control_fields(before: &Device, after: &Device, zone: &Zon
|
||||
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 - after.target_temperature).abs() >= 0.5 {
|
||||
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
|
||||
@@ -441,11 +506,14 @@ fn detect_external_device_control(state: &AppState, before: &Device, after: &Dev
|
||||
}
|
||||
|
||||
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
|
||||
// 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 { command.changed_from(&before) } else { command.clone() };
|
||||
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(state, device_id, command).await?;
|
||||
let updated = send_command_locked(state, device_id, command).await?;
|
||||
if !fields.is_empty() {
|
||||
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
|
||||
if !zone.enabled && !updated.power {
|
||||
@@ -462,6 +530,28 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
|
||||
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
|
||||
}
|
||||
|
||||
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()? {
|
||||
@@ -502,6 +592,21 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
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 Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
|
||||
@@ -538,31 +643,24 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
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 group_snapshot = state.db.list_groups()?;
|
||||
let mut failed = Vec::new();
|
||||
if should_command_power && (!desired_power || master_power_enabled) {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for zone in &zones {
|
||||
if !seen.insert(zone.device_id.clone()) { continue; }
|
||||
// Group actions never own disabled zones and never override an active manual/pilot
|
||||
// takeover. Whole-house OFF is handled separately and remains authoritative.
|
||||
if !zone.enabled || zone.device_manual_override { continue; }
|
||||
// Re-check manual takeover and all group gates only after acquiring the
|
||||
// per-device lock, so a pilot event detected by polling cannot be overwritten.
|
||||
let Some(device) = state.db.get_device(&zone.device_id)? else { continue; };
|
||||
if !device.enabled || device.power == desired_power { continue; }
|
||||
if desired_power {
|
||||
let blocked_by_other_group = group_snapshot.iter().any(|other| {
|
||||
other.id != group.id && !other.power_enabled && other.zone_ids.iter().any(|zone_id| zone_id == &zone.id)
|
||||
});
|
||||
if blocked_by_other_group { continue; }
|
||||
let zone_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
|
||||
if zone_mode == "off" { continue; }
|
||||
}
|
||||
if let Err(err) = send_command(state, &device.id, DeviceCommand { power: Some(desired_power), ..Default::default() }).await {
|
||||
state.log("error", "group.power_error", &err.to_string(), json!({
|
||||
"group_id": group.id, "device_id": device.id, "device_name": device.name,
|
||||
"power": desired_power, "source": source,
|
||||
}));
|
||||
failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()}));
|
||||
if !device.enabled { continue; }
|
||||
match send_group_power_if_current(state, &group.id, &zone.id, &device.id, desired_power).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": desired_power, "source": source,
|
||||
}));
|
||||
failed.push(json!({"device_id": device.id, "device_name": device.name, "error": err.to_string()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,10 +678,124 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
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 { return Ok(false); }
|
||||
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 { 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 { 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_disabled_group(device_id, &zones, &state.db.list_groups()?)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
send_command_locked(state, device_id, command).await.map(Some)
|
||||
}
|
||||
|
||||
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 zone_snapshot = state.db.list_zones()?;
|
||||
|
||||
// Outdoor temperature is deliberately optional. Prefer the configured Home
|
||||
// Assistant entity, but keep the dashboard/assist useful by falling back to the
|
||||
@@ -628,19 +840,32 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
|
||||
|
||||
if !settings.house_power_enabled {
|
||||
// Whole-house OFF is the one deliberate authority above manual/pilot takeover.
|
||||
// Clear remembered takeovers as well, so a later whole-house ON starts cleanly.
|
||||
clear_all_device_manual_overrides(state, "house_master_off")?;
|
||||
for device in &device_snapshot {
|
||||
if !device.enabled || !device.power { continue; }
|
||||
if let Err(err) = send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await {
|
||||
state.log("error", "house.master_power_error", &err.to_string(), json!({"device_id": device.id}));
|
||||
}
|
||||
}
|
||||
// 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(());
|
||||
}
|
||||
|
||||
for mut zone in state.db.list_zones()? {
|
||||
// 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;
|
||||
Some(async move {
|
||||
let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref()).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;
|
||||
@@ -668,19 +893,24 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.effective_mode = effective_mode.to_string();
|
||||
|
||||
let previous_source = zone.control_temperature_source.clone();
|
||||
let device_temperature = device.current_temperature;
|
||||
// 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") {
|
||||
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref());
|
||||
match home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity.as_deref()).await {
|
||||
Ok(value) => {
|
||||
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)
|
||||
}
|
||||
Err(err) => {
|
||||
Some((resolved_entity, Err(err))) => {
|
||||
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
|
||||
state.log("warn", "ha.sensor_error", &err.to_string(), json!({
|
||||
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,
|
||||
@@ -688,6 +918,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
}
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
@@ -708,8 +939,20 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -724,8 +967,8 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -735,14 +978,21 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.demand = false;
|
||||
zone.demand_since = None;
|
||||
zone.device_setpoint = None;
|
||||
if device.power {
|
||||
if let Err(err) = send_command(state, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
|
||||
state.log("error", "group.power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
|
||||
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);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -766,8 +1016,8 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
zone.demand_since = None;
|
||||
zone.target_alerted_at = None;
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -778,8 +1028,8 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
|
||||
let Some(temp) = temperature else {
|
||||
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -828,7 +1078,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
_ => 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 });
|
||||
zone.device_setpoint = Some(desired_device_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 {
|
||||
@@ -896,8 +1148,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
sleep: desired_sleep,
|
||||
..Default::default()
|
||||
};
|
||||
match send_command(state, &zone.device_id, command).await {
|
||||
Ok(updated_device) => {
|
||||
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, command, false).await {
|
||||
Ok(Some(updated_device)) => {
|
||||
zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None };
|
||||
zone.last_action_at = Some(Utc::now());
|
||||
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,
|
||||
@@ -913,13 +1166,19 @@ async fn control_zones(state: &AppState) -> Result<()> {
|
||||
"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);
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1019,7 +1278,7 @@ fn queue_influx_ha(state: &AppState, reading: HaReading) {
|
||||
|
||||
fn gree_outdoor_temperature(devices: &[Device]) -> Option<f64> {
|
||||
let mut values: Vec<f64> = devices.iter()
|
||||
.filter(|device| device.enabled && device.online)
|
||||
.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();
|
||||
@@ -1101,6 +1360,10 @@ fn smart_quiet_command(
|
||||
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
|
||||
@@ -1122,8 +1385,12 @@ fn native_sleep_command(
|
||||
sleep_supported: bool,
|
||||
device_sleep: bool,
|
||||
) -> Option<bool> {
|
||||
if !night_enabled || !use_native_sleep || !sleep_supported { return None; }
|
||||
if night_active { return Some(true); }
|
||||
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
|
||||
}
|
||||
@@ -1354,7 +1621,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
|
||||
} else {
|
||||
zone.effective_setpoint.or(Some(resolved_target))
|
||||
},
|
||||
device_setpoint: zone.device_setpoint.or_else(|| device.map(|item| item.target_temperature)),
|
||||
device_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,
|
||||
@@ -1565,7 +1832,16 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
||||
preset: item.action_preset.clone(),
|
||||
}, "automation.group").await.map(|_| ())
|
||||
} else {
|
||||
send_command(state, &item.action_device_id, item.action.clone()).await.map(|_| ())
|
||||
match send_automatic_device_command_if_owned(state, &item.action_device_id, item.action.clone()).await {
|
||||
Ok(Some(_)) => Ok(()),
|
||||
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(())
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
};
|
||||
match result {
|
||||
Ok(()) => {
|
||||
@@ -1610,7 +1886,7 @@ fn device_blocked_by_disabled_group(device_id: &str, zones: &[Zone], groups: &[c
|
||||
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)?.current_temperature
|
||||
devices.iter().find(|d| d.id == id && d.enabled && d.online && d.communication_failures == 0)?.current_temperature
|
||||
}
|
||||
|
||||
fn automation_ready(item: &Automation) -> bool {
|
||||
@@ -1759,6 +2035,16 @@ mod tests {
|
||||
assert!(fields.iter().any(|field| field == "fan_speed"));
|
||||
}
|
||||
|
||||
#[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");
|
||||
@@ -1893,9 +2179,10 @@ mod tests {
|
||||
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), None);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user