From b5a42651484addd54d1ccaa9191e8dc377c20743 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 25 Aug 2026 23:59:04 +0200 Subject: [PATCH] v0.7.4 --- FILE_MANIFEST.sha256 | 20 +- lang/en.json | 5 +- lang/pl.json | 5 +- src/api.rs | 109 +++++++--- src/engine.rs | 503 +++++++++++++++++++++++++++++++++---------- src/main.rs | 1 + src/models.rs | 2 +- src/protocol/gree.rs | 115 +++++++--- src/state.rs | 13 +- web/app.js | 10 +- web/styles.css | 8 +- 11 files changed, 598 insertions(+), 193 deletions(-) diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 7b3bf71..489f655 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -32,8 +32,8 @@ a697d18173087621a666781137f07617d23c1fb2720d8b246637a804634d5299 ./home-assista 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md -e11f49c05499b7dafac87cab2fe40022f43dc88df7dec6831c2c4ca8e4235cc0 ./lang/en.json -af59c7fdd4a48a59305cba6518291238b16089c3876b837ca7b8db57d691c507 ./lang/pl.json +4ff030bd4d840d9262ad1e66e3e013aa6bc49d2ed772c6273078d496285e806d ./lang/en.json +28ad00ce447bf7ca1d6eaa4dac8bf2652f713105155f0a0110f0230536265091 ./lang/pl.json 028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -46,26 +46,26 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -c2d15048e637e5582fdf2b8efafec3b8e212592d117a38dcee3f4f9354742669 ./src/api.rs +85947aacd310bcb9dd11f6c674b515dcc9d2b253e95aac942d7f797d741f0c99 ./src/api.rs 32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs 5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs -32711788456b9e432b6954069706b8c313fe886d1d1b648e235e780e2082fed6 ./src/engine.rs +3ea3a5a92c30772bc3eacb561cc0c1e4ca08e00d6e18014cd0922b94706fcec3 ./src/engine.rs ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs 081f6909e95839c7da8c40b866af9de237e0540f3e65e1ce739ce70a6fa94846 ./src/home_assistant.rs 190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs -31d56301834ffe4afa7d8092feb033643c39c65040c62b73d4bd38ddd34407f0 ./src/main.rs -9e53273156b0bcc510726ff6bc5923d8c845873c8638f4fe2f27553607e3cd5b ./src/models.rs +f8d81810acce6f02802e62a455960c77f4d5afca9806e14e87809bd0c88b7e33 ./src/main.rs +c5c2bd15ddbec0bc387682b66c049a53c2164824f19bdaca66d7f3efb305270e ./src/models.rs ea4bd7f20759101c2525600a1f342d2fc0946e2f0b3208b03cc5a415b6947c10 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs -0008f13b1c921300aafb5b9f745c916a65fd06916e5de19f89599ebb618c6350 ./src/protocol/gree.rs +a60bd8d23b78c74447546722a03ff11c75367468d3bab9f2f5f9cb56f8e09572 ./src/protocol/gree.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs 6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs -f74ac398c6f2e64ebb965900330324f3ec7403e70339638a408229f29fbd07ae ./src/state.rs +800d3dfbae086bec7b6a9419ccb05ec66bbe25dcad43ec0d941714b76ab64eb4 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service -a8d6df8b217321c94441f1a3d4a14a71f4dbce874e8d3e9b7e1e2d323551ffb3 ./web/app.js +838fed1033349a4bcfb5aa8cdc5e6e95c9a8ace75f33ff6ac4a4ed7f92b279e0 ./web/app.js e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg 5e39be3a379e3036c03773d5f4cc22baed79e9688b574b438fe87b6f5828089a ./web/index.html fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest -b3448404b5f7ff6970af5d9812213cdd8a458086e9405e0308001e2101afb44d ./web/styles.css +056545319a32925b7c17050e839c694cb523b3c6a85125e92c5ae12d9a1fd366 ./web/styles.css de6d0a8feda7f25a3fa45ccd32e41fd70a786f20f328640575c457d6e298ffd5 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/lang/en.json b/lang/en.json index 8169f5c..1da117a 100644 --- a/lang/en.json +++ b/lang/en.json @@ -562,7 +562,7 @@ "groups.followHouse": "Global mode", "groups.mode": "Mode", "groups.profile": "Profile", - "groups.masterOff": "Global power is off. Group settings are saved, but units will stay off until global power is enabled.", + "groups.masterOff": "Global power is off. Pressing ON for this group resumes the master and starts eligible units in this group.", "groups.memberCount": "{count} zones", "groups.emptyTitle": "No groups yet", "groups.emptyText": "Create a group to control selected air conditioners together.", @@ -588,7 +588,8 @@ "zones.noGroup": "no group", "zones.coolOnly": "Cooling only", "zones.heatOnly": "Heating only", - "zones.manualDeviceControl": "Manual control / remote", + "zones.manualDeviceControl": "Manual control active", + "zones.manualDeviceDescription": "A setting change was detected directly on the air conditioner or remote. Automation for this zone is paused.", "zones.manualDeviceUntil": "Manual control / remote until {time}", "zones.manualDeviceNoBoundary": "Manual control / remote · until resumed", "zones.resumeAutomation": "Resume automation" diff --git a/lang/pl.json b/lang/pl.json index 91e3a6a..cda678b 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -562,7 +562,7 @@ "groups.followHouse": "Tryb globalny", "groups.mode": "Tryb", "groups.profile": "Profil", - "groups.masterOff": "Zasilanie globalne jest wyłączone. Ustawienia grupy zostaną zapisane, ale jednostki pozostaną wyłączone do włączenia zasilania globalnego.", + "groups.masterOff": "Globalne zasilanie jest wyłączone. Naciśnięcie WŁ. dla tej grupy wznowi master i uruchomi dozwolone jednostki tej grupy.", "groups.memberCount": "Strefy: {count}", "groups.emptyTitle": "Brak grup", "groups.emptyText": "Utwórz grupę, aby wspólnie sterować wybranymi klimatyzatorami.", @@ -588,7 +588,8 @@ "zones.noGroup": "bez grupy", "zones.coolOnly": "Tylko chłodzenie", "zones.heatOnly": "Tylko grzanie", - "zones.manualDeviceControl": "Sterowanie ręczne / pilot", + "zones.manualDeviceControl": "Sterowanie ręczne aktywne", + "zones.manualDeviceDescription": "Wykryto zmianę ustawień bezpośrednio na klimatyzatorze lub pilotem. Automatyka tej strefy jest wstrzymana.", "zones.manualDeviceUntil": "Sterowanie ręczne / pilot do {time}", "zones.manualDeviceNoBoundary": "Sterowanie ręczne / pilot · do wznowienia", "zones.resumeAutomation": "Wznów automatykę" diff --git a/src/api.rs b/src/api.rs index 09b8661..d439f4b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -252,6 +252,14 @@ async fn discover(State(state): State, Json(request): Json, Path(id): Path) -> Re } async fn patch_device(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { + let _device_guard = state.lock_device_operation(&id).await; let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } } if let Some(v) = patch.ip { v.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; } @@ -385,6 +394,7 @@ async fn delete_device(State(state): State, Path(id): Path) -> .map(|zone| zone.id) .collect(); ensure_zone_removal_safe(&state, &removed_zone_ids)?; + ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?; if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } remove_zone_ids_from_groups(&state, &removed_zone_ids)?; state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); @@ -393,6 +403,7 @@ async fn delete_device(State(state): State, Path(id): Path) -> } async fn bind_device(State(state): State, Path(id): Path) -> Result, AppError> { + let _device_guard = state.lock_device_operation(&id).await; let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; if device.simulated { return Ok(Json(device)); } let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?; @@ -551,29 +562,36 @@ async fn update_zone(State(state): State, Path(id): Path, Json let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } validate_zone_device_assignment(&state, &input.device_id, Some(&id))?; + let device_changed = existing.device_id != input.device_id; let mut zone = input.into_zone(id, existing.created_at); - zone.device_temperature = existing.device_temperature; - zone.external_temperature = existing.external_temperature; - zone.current_temperature = existing.current_temperature; - zone.control_temperature_source = existing.control_temperature_source; - zone.active_preset = existing.active_preset; - zone.manual_preset = existing.manual_preset; - zone.manual_setpoint = existing.manual_setpoint; - zone.manual_override_until = existing.manual_override_until; - zone.device_manual_override = existing.device_manual_override; - zone.device_manual_override_since = existing.device_manual_override_since; - zone.device_manual_override_until = existing.device_manual_override_until; - zone.device_manual_override_fields = existing.device_manual_override_fields; - zone.effective_mode = existing.effective_mode; - zone.effective_setpoint = existing.effective_setpoint; - zone.device_setpoint = existing.device_setpoint; - zone.demand = existing.demand; - zone.demand_since = existing.demand_since; - zone.target_alerted_at = existing.target_alerted_at; - zone.last_action_at = existing.last_action_at; + if !device_changed { + zone.device_temperature = existing.device_temperature; + zone.external_temperature = existing.external_temperature; + zone.current_temperature = existing.current_temperature; + zone.control_temperature_source = existing.control_temperature_source; + zone.active_preset = existing.active_preset; + zone.manual_preset = existing.manual_preset; + zone.manual_setpoint = existing.manual_setpoint; + zone.manual_override_until = existing.manual_override_until; + zone.device_manual_override = existing.device_manual_override; + zone.device_manual_override_since = existing.device_manual_override_since; + zone.device_manual_override_until = existing.device_manual_override_until; + zone.device_manual_override_fields = existing.device_manual_override_fields; + zone.effective_mode = existing.effective_mode; + zone.effective_setpoint = existing.effective_setpoint; + zone.device_setpoint = existing.device_setpoint; + zone.demand = existing.demand; + zone.demand_since = existing.demand_since; + zone.target_alerted_at = existing.target_alerted_at; + zone.last_action_at = existing.last_action_at; + } else { + // A new physical unit starts with a clean ownership/runtime state. Never transfer + // demand, sensor cache or remote-control takeover from the previous device. + ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?; + } let settings = state.settings.read().await.clone(); canonicalize_zone_ha_entity(&mut zone, &settings); - let power_off_device = existing.enabled && !zone.enabled; + let power_off_device = !device_changed && existing.enabled && !zone.enabled; state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); if power_off_device { @@ -581,6 +599,7 @@ async fn update_zone(State(state): State, Path(id): Path, Json } Ok(Json(zone)) } + async fn update_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let was_enabled = zone.enabled; @@ -645,10 +664,25 @@ async fn update_zone_control(State(state): State, Path(id): Path Result<(), AppError> { + let Some(device) = state.db.get_device(device_id)? else { return Ok(()); }; + if !device.enabled { + return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into())); + } + // Force one OFF transition even when the cached state already says OFF. A remote change + // may not have been polled yet and detaching must not leave a running unit without owner. + engine::force_power_off_device(state, device_id).await?; + state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({ + "device_id": device.id, "source": source + })); + Ok(()) +} + async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) { let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; }; - if !device.enabled || !device.power { return; } - if let Err(err) = engine::send_command(state, &device.id, DeviceCommand { power: Some(false), ..Default::default() }).await { + if !device.enabled { return; } + if let Err(err) = engine::force_power_off_device(state, &device.id).await { state.log("error", "zone.disable_power_error", &err.to_string(), json!({ "zone_id": zone.id, "device_id": device.id, @@ -926,12 +960,19 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source std::collections::HashSet::new() }; for device in state.db.list_devices()? { - if !device.enabled || device.power == power { continue; } + if !device.enabled { continue; } // Whole-house ON only operates thermostat-managed, enabled zones. Devices with // a disabled zone (or no zone at all) remain manual/technical Devices controls. if power && !enabled_zone_devices.contains(&device.id) { continue; } - let command = DeviceCommand { power: Some(power), ..Default::default() }; - if let Err(err) = engine::send_command(state, &device.id, command).await { + // Do not trust the pre-loop power snapshot for deciding whether to send. The engine + // reloads state under the per-device lock and turns an already-matching command into + // a no-op. This closes the polling/command race without extra UDP frames. + let result = if power { + engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).await + } else { + engine::force_house_power_off_device(state, &device.id, source).await + }; + if let Err(err) = result { state.log("error", "house.power_all_error", &err.to_string(), json!({ "device_id": device.id, "device_name": device.name, @@ -983,11 +1024,8 @@ async fn update_house_control(State(state): State, Json(input): Json, Json(input): Json) -> Result, AppError> { - // Whole-house power is independent from the thermostat mode. Turning it off is - // authoritative, while house mode `off` remains a separate "do not control" state. - if !input.power { - engine::clear_all_device_manual_overrides(&state, "house_power_off")?; - } + // Whole-house power is independent from the thermostat mode. Publish/persist the master + // first so the regulator becomes passive before the one-shot OFF cascade starts. { let mut settings = state.settings.write().await; if settings.house_power_enabled != input.power { @@ -998,9 +1036,13 @@ async fn update_house_power(State(state): State, Json(input): Json, Path(id): Path, Path(id): Path) -> Result { - if state.db.get_zone(&id)?.is_none() { return Err(AppError::NotFound(format!("zone {id}"))); } + let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut removed = std::collections::HashSet::new(); removed.insert(id.clone()); ensure_zone_removal_safe(&state, &removed)?; + ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?; if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } remove_zone_ids_from_groups(&state, &removed)?; state.broadcast("zone.deleted", json!({"id": id})); diff --git a/src/engine.rs b/src/engine.rs index df31b00..7fb2b07 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -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 Result { + 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 { + send_command_locked_inner(state, device_id, command, true).await +} + +async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { + 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 { 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 { + 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 { 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 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 = 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 { + // 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 { + // 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 { + 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 { 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) -> Result { + 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 { + 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 { + 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, 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, 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, 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, Result)> = 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 { let mut values: Vec = 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 { - 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 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 { 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); } diff --git a/src/main.rs b/src/main.rs index cea39f3..576e4af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,7 @@ async fn main() -> Result<()> { http, outdoor_temperature: Arc::new(RwLock::new(None)), debug_gree_frames, + device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), started: Instant::now(), }; diff --git a/src/models.rs b/src/models.rs index 82b60d2..050d395 100644 --- a/src/models.rs +++ b/src/models.rs @@ -229,7 +229,7 @@ impl DeviceCommand { pub fn apply(&self, device: &mut Device) { if let Some(v) = self.power { device.power = v; } if let Some(v) = &self.mode { device.mode = v.clone(); } - if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 30.0); } + if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 30.0).round(); } if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); } if let Some(v) = self.swing_vertical { device.swing_vertical = v; } if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; } diff --git a/src/protocol/gree.rs b/src/protocol/gree.rs index 356a458..33d837a 100644 --- a/src/protocol/gree.rs +++ b/src/protocol/gree.rs @@ -436,48 +436,66 @@ impl GreeClient { .ok_or_else(|| anyhow!("status response has no cols"))?; let data = response.get("dat").and_then(Value::as_array) .ok_or_else(|| anyhow!("status response has no dat"))?; + if data.len() < response_cols.len() { + bail!("status response contains fewer values than columns") + } + + // Parse into a clone and commit only when every climate-relevant value is valid. + // This prevents null/text/malformed frames from being silently converted into OFF, + // AUTO or a zero setpoint while leaving the rest of the packet partially applied. + let mut next = device.clone(); let mut set_temp = None; for (name, value) in response_cols.iter().zip(data.iter()) { let Some(name) = name.as_str() else { continue; }; match name { - "Pow" => device.power = value_as_i64(value) != 0, - "Mod" => device.mode = mode_name(value_as_i64(value)).into(), - "SetTem" => set_temp = Some(value_as_f64(value)), - "WdSpd" => device.fan_speed = value_as_i64(value).clamp(0, 5) as u8, - "SwUpDn" => device.swing_vertical = value_as_i64(value) != 0, - "SwingLfRig" => device.swing_horizontal = value_as_i64(value) != 0, - "Quiet" => { device.quiet = value_as_i64(value) != 0; device.supports_quiet = Some(true); }, - "Tur" => { device.turbo = value_as_i64(value) != 0; device.supports_turbo = Some(true); }, - "Lig" => { device.light = value_as_i64(value) != 0; device.supports_light = Some(true); }, - "Air" => { device.air = value_as_i64(value) != 0; device.supports_air = Some(true); }, - "Blo" => { device.xfan = value_as_i64(value) != 0; device.supports_xfan = Some(true); }, - "Health" => { device.health = value_as_i64(value) != 0; device.supports_health = Some(true); }, - "SwhSlp" => { device.sleep = value_as_i64(value) != 0; device.supports_sleep = Some(true); }, + "Pow" => next.power = status_flag(name, value)?, + "Mod" => { + let raw = status_i64(name, value)?; + next.mode = mode_name_checked(raw).ok_or_else(|| anyhow!("invalid GREE mode value for {name}: {raw}"))?.into(); + } + "SetTem" => { + let raw = status_f64(name, value)?; + if !(8.0..=30.0).contains(&raw) { bail!("invalid GREE setpoint for {name}: {raw}") } + set_temp = Some(raw.round()); + } + "WdSpd" => { + let raw = status_i64(name, value)?; + if !(0..=5).contains(&raw) { bail!("invalid GREE fan value for {name}: {raw}") } + next.fan_speed = raw as u8; + } + "SwUpDn" => next.swing_vertical = status_i64(name, value)? != 0, + "SwingLfRig" => next.swing_horizontal = status_i64(name, value)? != 0, + "Quiet" => { next.quiet = status_flag(name, value)?; next.supports_quiet = Some(true); }, + "Tur" => { next.turbo = status_flag(name, value)?; next.supports_turbo = Some(true); }, + "Lig" => { next.light = status_flag(name, value)?; next.supports_light = Some(true); }, + "Air" => { next.air = status_flag(name, value)?; next.supports_air = Some(true); }, + "Blo" => { next.xfan = status_flag(name, value)?; next.supports_xfan = Some(true); }, + "Health" => { next.health = status_flag(name, value)?; next.supports_health = Some(true); }, + "SwhSlp" => { next.sleep = status_flag(name, value)?; next.supports_sleep = Some(true); }, "TemSen" => { - let raw = value_as_f64(value); - // The room sensor is a useful discriminator because normal indoor - // temperatures cannot exceed 40 C in controller operation. Persist - // the detected wire format and reuse it for OutEnvTem, including - // sub-zero outdoor values encoded as (temperature + 40). + let raw = status_f64(name, value)?; if raw != 0.0 { let offset = raw > 40.0; - device.temperature_sensor_offset = Some(offset); - device.current_temperature = Some(if offset { raw - 40.0 } else { raw }); + let temperature = if offset { raw - 40.0 } else { raw }; + if !(-40.0..=80.0).contains(&temperature) { bail!("invalid GREE indoor temperature: {temperature}") } + next.temperature_sensor_offset = Some(offset); + next.current_temperature = Some(temperature); } } "OutEnvTem" => { - let raw = value_as_f64(value); + let raw = status_f64(name, value)?; if raw != 0.0 { - let offset = device.temperature_sensor_offset.unwrap_or(raw > 50.0); - device.outdoor_temperature = Some(if offset { raw - 40.0 } else { raw }); + let offset = next.temperature_sensor_offset.unwrap_or(raw > 50.0); + let temperature = if offset { raw - 40.0 } else { raw }; + if !(-60.0..=80.0).contains(&temperature) { bail!("invalid GREE outdoor temperature: {temperature}") } + next.outdoor_temperature = Some(temperature); } } _ => {} } } - if let Some(base) = set_temp { - device.target_temperature = base.clamp(8.0, 30.0); - } + if let Some(base) = set_temp { next.target_temperature = base; } + *device = next; Ok(()) } @@ -792,9 +810,24 @@ fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> { } fn interface_ipv4(interface: &str) -> Result { interface_ipv4_config(interface).map(|(ip, _)| ip) } -fn value_as_i64(value: &Value) -> i64 { value.as_i64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default() } -fn value_as_f64(value: &Value) -> f64 { value.as_f64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default() } -fn mode_name(value: i64) -> &'static str { match value { 0 => "auto", 1 => "cool", 2 => "dry", 3 => "fan", 4 => "heat", _ => "auto" } } +fn value_as_i64(value: &Value) -> Option { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) } +fn value_as_f64(value: &Value) -> Option { + value.as_f64().or_else(|| value.as_str()?.trim().parse().ok()).filter(|value| value.is_finite()) +} +fn status_i64(name: &str, value: &Value) -> Result { + value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}")) +} +fn status_f64(name: &str, value: &Value) -> Result { + value_as_f64(value).ok_or_else(|| anyhow!("invalid GREE numeric value for {name}: {value}")) +} +fn status_flag(name: &str, value: &Value) -> Result { + match status_i64(name, value)? { + 0 => Ok(false), + 1 => Ok(true), + other => bail!("invalid GREE flag value for {name}: {other}"), + } +} +fn mode_name_checked(value: i64) -> Option<&'static str> { match value { 0 => Some("auto"), 1 => Some("cool"), 2 => Some("dry"), 3 => Some("fan"), 4 => Some("heat"), _ => None } } fn mode_value(value: &str) -> Result { match value.to_ascii_lowercase().as_str() { "auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4), @@ -830,6 +863,30 @@ pub fn merge_discovered(existing: Option, discovered: Device) -> Device mod tests { use super::*; + #[test] + fn invalid_status_frame_does_not_partially_mutate_device() { + let client = GreeClient::new( + "test-controller".into(), + None, + None, + Arc::new(AtomicBool::new(false)), + ); + let mut device = Device::simulated_default(); + device.power = true; + device.mode = "heat".into(); + device.target_temperature = 24.0; + let before = device.clone(); + + let response = json!({ + "cols": ["Pow", "Mod", "SetTem"], + "dat": [0, null, "not-a-number"] + }); + assert!(client.apply_status(&mut device, &response).is_err()); + assert_eq!(device.power, before.power); + assert_eq!(device.mode, before.mode); + assert_eq!(device.target_temperature, before.target_temperature); + } + #[test] fn thermostat_standby_setpoint_low_fan_quiet_and_sleep_share_one_frame() { let payload = GreeClient::command_payload(&DeviceCommand { diff --git a/src/state.rs b/src/state.rs index fb3b737..8233b31 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,7 +1,7 @@ -use std::{sync::{Arc, atomic::AtomicBool}, time::Instant}; +use std::{collections::HashMap, sync::{Arc, atomic::AtomicBool}, time::Instant}; use chrono::Utc; use serde_json::Value; -use tokio::sync::{broadcast, RwLock}; +use tokio::sync::{broadcast, Mutex, OwnedMutexGuard, RwLock}; use crate::{config::Config, db::Db, models::{ApiEvent, RuntimeSettings}, protocol::GreeClient}; #[derive(Clone)] @@ -14,10 +14,19 @@ pub struct AppState { pub http: reqwest::Client, pub outdoor_temperature: Arc>>, pub debug_gree_frames: Arc, + pub(crate) device_operation_locks: Arc>>>>, pub started: Instant, } impl AppState { + pub async fn lock_device_operation(&self, device_id: &str) -> OwnedMutexGuard<()> { + let lock = { + let mut locks = self.device_operation_locks.lock().await; + locks.entry(device_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone() + }; + lock.lock_owned().await + } + pub fn broadcast(&self, event: impl Into, data: Value) { let _ = self.events.send(ApiEvent { event: event.into(), diff --git a/web/app.js b/web/app.js index c1c9988..02b0135 100644 --- a/web/app.js +++ b/web/app.js @@ -707,14 +707,14 @@ function zoneCard(zone, detailed = true) { ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` : (hasManualOverride ? tr('zones.manualNoBoundary') : tr('zones.scheduleControl'))); const manualTakeover = deviceManualOverride - ? `
${esc(tr('zones.manualDeviceControl'))}
` + ? `
${esc(tr('zones.manualDeviceControl'))}

${esc(tr('zones.manualDeviceDescription'))}

` : ''; if (detailed) { const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name); const groupText = groupNames.length ? groupNames.join(' · ') : tr('zones.noGroup'); const policy = zone.inherit_house_mode ? tr('zones.followHouse') : tr(zone.mode === 'heat' ? 'zones.heatOnly' : 'zones.coolOnly'); - return `
+ return `
${esc(tr('zones.configuration'))}

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}

${esc(state)}
${esc(tr('zones.currentStatus'))}
${fmtTemp(roomTemperature)}${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
@@ -732,7 +732,7 @@ function zoneCard(zone, detailed = true) {
`; } - return `
+ return `

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}

${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
${Number.isFinite(target)?target.toFixed(1):'--'}°C
${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
@@ -778,7 +778,7 @@ function groupCard(group, detailed = false) { const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value)); const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value); return `
-
${esc(tr('groups.group'))}

${esc(group.name)}

${esc(memberText)}

${esc(powerEnabled ? tr('common.on') : tr('common.off'))}
+
${esc(tr('groups.group'))}

${esc(group.name)}

${esc(memberText)}

${detailed ? `
${esc(tr('groups.mode'))}${esc(modeLabelForGroup(state.mode))}
${esc(tr('groups.profile'))}${esc(presetLabelForGroup(state.preset))}
${esc(tr('groups.members'))}${state.zones.length}
` : ''}
${esc(tr('common.power'))}
${esc(tr('groups.mode'))}
${['house','heat','cool'].map(mode => ``).join('')}
@@ -810,6 +810,7 @@ async function sendGroupControl(id, patch) { if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); }); (result.devices || []).forEach(updateDevice); + if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled; renderAll(); scheduleControlPlanLoad(); const failed = Array.isArray(result.failed) ? result.failed.length : 0; if (failed) toast(tr('groups.partial', {count: failed}), true); @@ -1629,6 +1630,7 @@ function connectWebSocket() { else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); } else if (message.event === 'settings.updated') { app.settings=data; app.sensorAliases={...(app.settings?.home_assistant?.sensor_aliases||{})}; renderSettings(); renderNightSettings(); renderHomeAssistantSettings(); renderHouseClimate(); renderDebugOverlay(); if(app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad(); } + else if (message.event === 'house.power_changed') { app.settings=app.settings||{}; app.settings.house_power_enabled=data.house_power_enabled !== false; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); } else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; renderSettings(); renderDebugOverlay(); if(data.overlay_enabled) loadDebugBacklog(); } else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); scheduleControlPlanLoad(); } else if (message.event === 'gree.frame') { if(app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); } diff --git a/web/styles.css b/web/styles.css index a530d65..e2fa179 100644 --- a/web/styles.css +++ b/web/styles.css @@ -161,8 +161,12 @@ h3 { margin-bottom: 10px; } .badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; } .badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); } .badge.manual-override { color: var(--warning); background: var(--warning-soft); } -.manual-override-row { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:10px; margin-top:8px; padding:8px 10px; border-radius:10px; background:var(--warning-soft); } -.manual-override-row button { flex:0 0 auto; min-height:30px; padding:5px 9px; } +.list-card.manual-takeover { border-color: var(--warning); box-shadow: 0 0 0 1px color-mix(in srgb, var(--warning) 24%, transparent); } +.manual-override-panel { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:12px; margin-top:8px; padding:11px 12px; border:1px solid color-mix(in srgb, var(--warning) 45%, var(--line)); border-radius:12px; background:var(--warning-soft); } +.manual-override-panel > div { flex:1 1 220px; min-width:0; } +.manual-override-panel strong { display:block; margin-bottom:4px; color:var(--warning); font-size:13px; } +.manual-override-panel p { margin:0; color:var(--text); font-size:12px; line-height:1.45; } +.manual-override-panel button { flex:0 0 auto; min-height:34px; padding:7px 11px; } .zone-enable-toggle { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; } .zone-enable-toggle.active { border-color: color-mix(in srgb, var(--accent) 34%, var(--line)); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); } .zone-enable-toggle span { font-size: 12px; font-weight: 900; }