This commit is contained in:
Mateusz Gruszczyński
2026-08-25 23:59:04 +02:00
parent 9fa0a5399e
commit b5a4265148
11 changed files with 598 additions and 193 deletions
+395 -108
View File
@@ -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);
}