From 97c26c67b6a520684b5392b3f2eebd6222e91227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 1 Sep 2026 13:33:16 +0200 Subject: [PATCH] v0.9.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 32 +++++++++---------- README.md | 2 +- docs/API.md | 8 ++--- .../gree_controller/manifest.json | 2 +- src/api/zones.rs | 15 ++++++--- src/engine/local_thermostat.rs | 13 +++++--- src/engine/temporary_thermostat.rs | 16 +++------- src/engine/tests.rs | 21 ++++++++++++ 10 files changed, 67 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6255392..fe63eb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.9.1" +version = "0.9.2" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 5ffc8bc..1bedd26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.9.1" +version = "0.9.2" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 8c54a85..dd76432 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,11 +1,11 @@ b38e05f8a00969270280b0062ee129fccee39ae9c11cb614a6ad7b2b7ae2dfc7 ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -d16e2c4867f9e651053bbbb080927134e7e72df96ca1b26f62f7818f0a1f7f09 ./Cargo.lock -c180bd9e6cdd1db88d195237ccce4e8a8bef44888468e0fb1afd3b5e5dbefb6f ./Cargo.toml +567a478d1e1ceb9fefba0f091cf4100a2953297d6e3cfb254f621d36faec3235 ./Cargo.lock +285e8936199d086103e73cdac7d0257429aa6b3c5a91c3be5d964eb6e6064f58 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -57ed2504d5924581030d74ca4899813532165d08f6317704ee2f2002d4cc8ad0 ./README.md +0ba2649e1746f3d2084501d7dd4b7610fe6f530b8c748769313fa515a04b2e81 ./README.md ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs -027a96e4254e45305f5f573d3562920d4f253cba32bcd2c96b15eaaae94b3cfd ./docs/API.md +4a41bc92108df6764af95979e035722d844918c4106dcc10d79b871da8aea2cb ./docs/API.md a4571b7d31c96be8b3f540574444742f99a208fc0cdf27a14bebad70bc916ab3 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py @@ -16,7 +16,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista 5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png -01df8a32e3159ce761f395b859e4b00f0485c8cf8b37cfdc844dbdf979268bce ./home-assistant/custom_components/gree_controller/manifest.json +71d6215226168129fcd616cb1de0a9ab70aa2aa678269b2e7234802768ce9822 ./home-assistant/custom_components/gree_controller/manifest.json 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py @@ -39,6 +39,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh +4c8b0d10d83d6130ed6e2703c233ac380b25a6c57fc23a29821f823df90331f9 ./src/api.rs 50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs 08a732a445f44d34c644109762ac389b58c96d0fe47b309ce34f4479d67c3712 ./src/api/automations.rs @@ -55,9 +56,9 @@ d3ddc310a4033e400f521babec02ec06990092d7349c40cd271a3d8406f70f4a ./src/api/publ 678990ae03c96628927eac90ef0e84b4da4518c78fb5208a5a0980f96b0496ee ./src/api/settings.rs 74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs 681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs -9d8ab056dbd2047fd00062e7252c3e9bb972c988a56de23daea61b8be871bd61 ./src/api/zones.rs -4c8b0d10d83d6130ed6e2703c233ac380b25a6c57fc23a29821f823df90331f9 ./src/api.rs +434f1e56112f76c931eba2df5556738d3a315547f6b918cba8322cee8c411af9 ./src/api/zones.rs 2b6eecead75cd4cdb98a9f74dd1c374dbf11ebfa716abbc3476aa12398af9acd ./src/config.rs +369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs 24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs 3de3fa4262be00029dde2344c4cfa41acd4acc0adec176f344761ff35ea730b6 ./src/db/configuration.rs 56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs @@ -67,32 +68,32 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event 7e2fbce70f40aef360534ba38c6700163db77cb7288b3bf58728fce72a126435 ./src/db/schedules_automations.rs 35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs -369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs +952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs d418f27edde4a8a8ea89817899abfc48df632ec581deacf64db1dc409eef920f ./src/engine/automations.rs 910248f30ac9d697816458cd393c5f7d4bbdd3a12e48cb41cbb3ddb68a1ce395 ./src/engine/commands.rs d91c50e68baa8f90a30fd94be1fda8cf548ed7386c1058f14f8d5b32625c0b25 ./src/engine/control_plan.rs 38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs de747652055f222da31b8f671ffdcbe94277ace5082c24b6b579d9bdaa6d16ff ./src/engine/groups.rs 18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs -dad8930c3d811f02cfeced3aa4b9a404fe273ba1b99539bb174b9430575f3e22 ./src/engine/local_thermostat.rs +e199628c4580e6a225e77d17acaaf8edc2a1ff0e3f91595234bba1f53e6450e1 ./src/engine/local_thermostat.rs ea41d99d69a2b7593df7e243009211a65b227b9a88ffbf4a2badba88a49c01bc ./src/engine/ownership.rs 9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs 3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs -31b14b6412fced31aeae92401af2c4a0501063f78b4577224c5df291ad7d3df4 ./src/engine/temporary_thermostat.rs -3fe9825d9ff5f41c9f988489ebe606b4700322e553cbe6cae1bec7c82affd25c ./src/engine/tests.rs +c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs +08a450ec80d7ac61814cd6afad5667214e0eac5c0f064d73fe527cbc6d647224 ./src/engine/tests.rs 6f4541bad148d172a44cd3ddd22b49dc97e4b755554a7a8820222169090e7022 ./src/engine/zone_actions.rs 856bd291229ae9d0b3f21fb7fd47058b7af5a18bd195e48829f8162533613472 ./src/engine/zone_control.rs -952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs +6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs 553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs 5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs -6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs c3ff7232a2fa1d29428872a10cfd72ee60a3a1faaa662eff6c2d8c7dffc1ed3a ./src/main.rs +e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs 60ff96965cf9531b3964add783a6a45578aa1986844e99a239f182eb4f962d5b ./src/models/control_plan.rs 8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs @@ -102,9 +103,9 @@ cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/h b1af7f8d4c3607b1c0ae978feaab4dfa03faa0154c7328712bb4eeafc27faa02 ./src/models/runtime.rs e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs 0bdc68fc1b3d1fd4d1e1253bd48b5c149cfc9b87e2fedb13288b3ea8a096af3d ./src/models/zone.rs -e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs 9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs +f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol/gree/binding.rs 563ded1758b135eb523f1fa0b1bf55fe5430ef55935b301fd6d79cbb980e1820 ./src/protocol/gree/commands.rs 5d4328199e2285016fd1941e909e5c09f7cdf9fd21ab6538c1bf8bc2f4af3ba3 ./src/protocol/gree/core.rs @@ -114,15 +115,14 @@ a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol 45d0cd0ea40b9255bd811362547f5a667dac6bc7324074726130baf60aa41d2b ./src/protocol/gree/polling.rs 35fc903404045129e3046b7997e941b88f98ceb42485cccc872c2e4aa530c75b ./src/protocol/gree/tests.rs 55c2d615e76c9e4b08c4067ea734e81d81915144e637f3b51c19bea4fc879137 ./src/protocol/gree/transport.rs -f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs +830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs cd5d0befe9f51a7067201a6eaa8d8ed72182d359e42cbdc67c80aa12d722654b ./src/queries/entities.rs 2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs 2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs -830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs 6ee23fcaa62231d15006db078d4f763d1b67d2365fcc2d736d8a7da8df5ea382 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html diff --git a/README.md b/README.md index 26e13cf..9f640d1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Current release: 0.9.1** +**Current release: 0.9.2** > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. diff --git a/docs/API.md b/docs/API.md index dd59a55..111ada9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.9.1**. +HTTP and WebSocket API for GREE Controller **0.9.2**. [← Main documentation](../README.md) @@ -212,7 +212,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.9.1", + "version": "0.9.2", "uptime_seconds": 1234, "control_ready": true, "time": "2026-08-30T06:54:00Z" @@ -236,7 +236,7 @@ Returns the initial Web UI snapshot: "settings": {}, "outdoor_temperature": null, "system": { - "version": "0.9.1", + "version": "0.9.2", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -669,7 +669,7 @@ All fields optional: A custom setpoint creates the same temporary manual override for every member zone and normally expires at that zone's next schedule boundary. Returns a group control/result object including updated members/state. -Group `power` is a scoped bulk-power/control action. `power=false` immediately powers member units off and releases `group:*` ownership; members are stored as individually-off thermostats rather than being blocked by membership in an OFF group, so a user can turn a thermostat back on independently. `power=true` clears that scoped thermostat-OFF state and immediately re-runs group thermostat arbitration. Explicit Web/Home Assistant group power actions take over older member manual/temporary ownership; scheduled group automations still respect higher-priority manual/local ownership. Group mode/preset/setpoint changes are accepted only while the group is ON. Whole-house power remains independent and authoritative. +Group `power` is a scoped bulk-power/control action. `power=false` immediately powers member units off and releases `group:*` ownership; members are stored as individually-off thermostats rather than being blocked by membership in an OFF group. This group-created OFF is indefinite (no 15-minute local hand-back), so the regulator cannot restart the unit by itself; a user can still turn an individual thermostat back on independently. `power=true` clears that scoped thermostat-OFF state and immediately re-runs group thermostat arbitration. Explicit Web/Home Assistant group power actions take over older member manual/temporary ownership; scheduled group automations still respect higher-priority manual/local ownership. Group mode/preset/setpoint changes are accepted only while the group is ON. Whole-house power remains independent and authoritative. --- diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 51adf07..5b9d61e 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.9.1", + "version": "0.9.2", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/src/api/zones.rs b/src/api/zones.rs index 068a4f1..27090a7 100644 --- a/src/api/zones.rs +++ b/src/api/zones.rs @@ -257,6 +257,11 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl // lock before the per-zone lock so a concurrent schedule edit cannot leave an override // pointing at an obsolete boundary (and so lock order stays schedule -> zone -> device). let _schedule_guard = state.lock_schedule_operation().await; + // A local thermostat click is an ownership transition, not merely a field update. + // Serialize it with the complete thermostat cycle so an already-running arbitration + // pass cannot reach an automatic ON after the user has committed local OFF. Lock order + // stays schedule -> cycle -> zone -> device, matching schedule mutation paths. + let _cycle_guard = state.lock_zone_control_cycle().await; // Serialize quick-thermostat changes with the same device lock used by GREE polling and // manual-takeover detection. Without this, a poll that started just before a Web/HA // thermostat action could save an older zone snapshot afterwards and resurrect a false @@ -642,11 +647,11 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl if was_enabled && !zone.enabled { power_off_zone_device(state, &zone, "zone.quick_disabled").await; } else if patch.power == Some(false) { - if let Err(err) = engine::send_command( - state, - &zone.device_id, - DeviceCommand { power: Some(false), ..Default::default() }, - ).await { + // Local OFF is an explicit safety/user intent. Force one OFF frame instead of + // trusting the cached device.power flag; a stale cache must never let a running + // unit survive a thermostat OFF click. The cycle lock held by this handler keeps + // automatic arbitration out until the OFF marker and physical command are complete. + if let Err(err) = engine::force_power_off_device(state, &zone.device_id).await { state.log("error", "zone.local_power_error", &err.to_string(), json!({ "zone_id": zone.id, "device_id": zone.device_id, "power": false })); diff --git a/src/engine/local_thermostat.rs b/src/engine/local_thermostat.rs index 6494e3c..70d45db 100644 --- a/src/engine/local_thermostat.rs +++ b/src/engine/local_thermostat.rs @@ -23,16 +23,19 @@ pub fn set_local_thermostat_power(zone: &mut Zone, power: bool, now: DateTime) -> bool { - if zone.local_thermostat_power != Some(false) { return false; } + if zone.local_thermostat_power != Some(false) || zone.local_thermostat_resume_at.is_none() { return false; } set_local_thermostat_power(zone, false, now) } fn local_thermostat_handback_is_active(zone: &Zone) -> bool { - zone.local_thermostat_power == Some(false) && !zone.device_manual_override + zone.local_thermostat_power == Some(false) + && zone.local_thermostat_resume_at.is_some() + && !zone.device_manual_override } /// Clear only the ordinary local Quick Thermostat. Temporary Quick Thermostat state is diff --git a/src/engine/temporary_thermostat.rs b/src/engine/temporary_thermostat.rs index 782458c..86e8bb9 100644 --- a/src/engine/temporary_thermostat.rs +++ b/src/engine/temporary_thermostat.rs @@ -362,19 +362,11 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone], // Do not let the old timer expire underneath someone who is actively controlling // the unit. When that takeover ends and the device returns to OFF, the deadline is // re-armed from that moment. + // Only an explicit timed local-OFF participates in automatic hand-back. + // local_thermostat_power=false with resume_at=None is a deliberate indefinite OFF + // (notably the state produced by group OFF) and must stay off until the user/group + // explicitly turns it back on or resumes automation. if !local_thermostat_handback_is_active(zone) { continue; } - if zone.local_thermostat_resume_at.is_none() { - // Upgrade safety for a persisted 0.7.10/0.7.11 local-OFF state: old releases - // had no hand-back deadline, so start one from the first cycle after upgrade. - set_local_thermostat_power(zone, false, now.clone()); - zone.updated_at = now.clone(); - state.db.save_zone(zone)?; - state.broadcast("zone.updated", serde_json::to_value(&*zone)?); - state.log("info", "zone.local_thermostat_resume_scheduled", &format!("Local thermostat hand-back scheduled for {}", zone.name), json!({ - "zone_id": zone.id, "device_id": zone.device_id, "delay_minutes": LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES - })); - continue; - } let expired = zone.local_thermostat_resume_at.as_ref().map(|at| at <= &now).unwrap_or(false); if !expired { continue; } reset_local_thermostat_override(zone); diff --git a/src/engine/tests.rs b/src/engine/tests.rs index eb581de..a351779 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -439,6 +439,27 @@ mod tests { assert_eq!(effective_zone_mode(&zone, "off"), "cool"); } + #[test] + fn indefinite_local_off_does_not_create_or_rearm_handback() { + let mut zone = test_zone("device"); + zone.local_thermostat_power = Some(false); + zone.local_thermostat_resume_at = None; + assert!(!local_thermostat_handback_is_active(&zone)); + assert!(!rearm_local_thermostat_resume(&mut zone, Utc::now())); + assert_eq!(zone.local_thermostat_power, Some(false)); + assert!(zone.local_thermostat_resume_at.is_none()); + } + + #[test] + fn timed_local_off_remains_blocking_until_explicit_deadline() { + let mut zone = test_zone("device"); + let now = Utc::now(); + set_local_thermostat_power(&mut zone, false, now); + assert!(local_thermostat_handback_is_active(&zone)); + assert_eq!(zone.local_thermostat_power, Some(false)); + assert!(zone.local_thermostat_resume_at.is_some()); + } + #[test] fn local_thermostat_off_restarts_backend_handback_deadline() { let mut zone = test_zone("device");