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
+10 -10
View File
@@ -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
+3 -2
View File
@@ -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"
+3 -2
View File
@@ -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ę"
+76 -33
View File
@@ -252,6 +252,14 @@ async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRe
let existing = state.db.get_device_by_mac(&item.mac)?;
let is_new = existing.is_none();
let mut merged = merge_discovered(existing, item);
let _device_guard = state.lock_device_operation(&merged.id).await;
// A poll/command may have updated the same known device between discovery and
// acquiring its operation lock. Re-merge against the freshest persisted state.
if !is_new {
if let Some(current) = state.db.get_device(&merged.id)? {
merged = merge_discovered(Some(current), merged);
}
}
// Bind right after discovery. GREE modules can have a short bind window;
// bind() also refreshes it with a direct scan before the handshake.
if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() {
@@ -346,6 +354,7 @@ async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Re
}
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, 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::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; }
@@ -385,6 +394,7 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
.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<AppState>, Path(id): Path<String>) ->
}
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, 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<AppState>, Path(id): Path<String>, 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<AppState>, Path(id): Path<String>, Json
}
Ok(Json(zone))
}
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, 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<AppState>, Path(id): Path<Strin
Ok(Json(zone))
}
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> 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<AppState>, Json(input): Json<H
struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, 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<AppState>, Json(input): Json<Hou
}
}
// Global power is a true cascade across group gates. Disabled thermostat zones stay
// disabled and are therefore not powered by the ON direction of this command.
// Global power is a true cascade across group gates. OFF clears the current takeover
// markers once, then each enabled device is re-cleared atomically with its OFF command.
// A later pilot action is therefore not erased by subsequent controller cycles.
set_all_groups_power(&state, input.power)?;
if !input.power {
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
}
let failed = command_all_enabled_devices_power(&state, input.power, "house_power").await?;
let devices = state.db.list_devices()?;
@@ -1124,10 +1166,11 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
}
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
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}));
+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);
}
+1
View File
@@ -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(),
};
+1 -1
View File
@@ -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; }
+86 -29
View File
@@ -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<Ipv4Addr> { 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<i64> { value.as_i64().or_else(|| value.as_str()?.trim().parse().ok()) }
fn value_as_f64(value: &Value) -> Option<f64> {
value.as_f64().or_else(|| value.as_str()?.trim().parse().ok()).filter(|value| value.is_finite())
}
fn status_i64(name: &str, value: &Value) -> Result<i64> {
value_as_i64(value).ok_or_else(|| anyhow!("invalid GREE integer value for {name}: {value}"))
}
fn status_f64(name: &str, value: &Value) -> Result<f64> {
value_as_f64(value).ok_or_else(|| anyhow!("invalid GREE numeric value for {name}: {value}"))
}
fn status_flag(name: &str, value: &Value) -> Result<bool> {
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<i64> {
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<Device>, 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 {
+11 -2
View File
@@ -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<RwLock<Option<f64>>>,
pub debug_gree_frames: Arc<AtomicBool>,
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
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<String>, data: Value) {
let _ = self.events.send(ApiEvent {
event: event.into(),
+6 -4
View File
@@ -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
? `<div class="manual-override-row"><span class="badge manual-override">${esc(tr('zones.manualDeviceControl'))}</span><button type="button" data-action="zone-resume-automation" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
? `<div class="manual-override-panel"><div><strong>${esc(tr('zones.manualDeviceControl'))}</strong><p>${esc(tr('zones.manualDeviceDescription'))}</p></div><button type="button" data-action="zone-resume-automation" data-id="${esc(zone.id)}">${esc(tr('zones.resumeAutomation'))}</button></div>`
: '';
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 `<article class="list-card zone-config-card ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'}" data-zone-config="${esc(zone.id)}">
return `<article class="list-card zone-config-card ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-config="${esc(zone.id)}">
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
<div class="zone-config-status">
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b>→</b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}</strong></div></div>
@@ -732,7 +732,7 @@ function zoneCard(zone, detailed = true) {
</article>`;
}
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'}" data-zone-card="${esc(zone.id)}">
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-card="${esc(zone.id)}">
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}"><span>${zone.enabled ? '✓' : '○'}</span>${esc(state)}</button></div>
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5"></button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
<div class="preset-row quick-control-row quick-control-row-4">
@@ -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 `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'}">
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div><span class="badge ${powerEnabled ? 'active' : ''}">${esc(powerEnabled ? tr('common.on') : tr('common.off'))}</span></div>
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div></div>
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
<div class="group-control-block"><small>${esc(tr('common.power'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}">${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
@@ -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); }
+6 -2
View File
@@ -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; }