v0.7.4
This commit is contained in:
+10
-10
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user