From 3c490b3064354b1a17b2548bdd7d1e44157c7609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 1 Sep 2026 14:33:45 +0200 Subject: [PATCH] v0.9.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 42 ++++++------- README.md | 4 +- docs/API.md | 18 +++--- home-assistant/README.md | 2 +- .../gree_controller/manifest.json | 2 +- lang/en.json | 8 +-- lang/pl.json | 8 +-- src/api/house.rs | 63 ++++++++++--------- src/api/settings.rs | 2 +- src/engine/commands.rs | 5 -- src/engine/groups.rs | 12 +++- src/engine/local_thermostat.rs | 27 ++++++++ src/engine/ownership.rs | 26 ++------ src/engine/tests.rs | 30 +++++++++ src/engine/zone_control.rs | 6 +- src/main.rs | 2 +- src/models/control_plan.rs | 2 +- src/models/runtime.rs | 2 +- web/index.html | 2 +- web/js/dashboard.js | 2 +- 22 files changed, 158 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d392f0..b2cd7c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.9.3" +version = "0.9.4" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 1d604e2..4293ac6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.9.3" +version = "0.9.4" 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 e081fde..794f3bd 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,12 +1,12 @@ b38e05f8a00969270280b0062ee129fccee39ae9c11cb614a6ad7b2b7ae2dfc7 ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -3ce3d5d41bf609d9f3e3edc20c51e470c105a44a0050fd08beef4dd61bbdec2d ./Cargo.lock -761439549d8f923adc732c10dbedd81a7e2811dbe3faea9af61dc94bed3151c1 ./Cargo.toml +37ba4d5222b221d9a3d560246d968092fa4402288410eaba7e6bab87ebcc5aea ./Cargo.lock +a0976abed3b84a5ba0c726410c87d31017d42098fb7e053af986869ea0c9db74 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -18040a0fcc08f95609850727fd825cde75e37d44145fb05eb54e1c0f6ee238ee ./README.md +4011536d5181ce7d8132c3b2ad50699a3ffa057f33b361aaa96fe71b6ae3efe3 ./README.md ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs -11873ce428fffabb85a2f1152c3694256c8cbdced6b8bb1afc439e66e62600c9 ./docs/API.md -3b3041f6e3e8c1017fa7bca3b6b4b474f5279869952000a5549a5b2749f8396e ./home-assistant/README.md +e183861962799504fe7dfc7f43ba5d75dc7f7f68c898689dd2c178f566af73e2 ./docs/API.md +a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py 6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py 71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.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 -c403e68570a6406c1a333b2f788a5182cc502c17e6c85837e57e6f11a26ee7ae ./home-assistant/custom_components/gree_controller/manifest.json +f30f751b4e561e51a3ff9bf9c8384bc2d160dd157b954dd7061d5844ee410b77 ./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 cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py @@ -25,8 +25,8 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./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 -f15fbad03393a5f5e971fb3842c1c312da4cdb776ac08731d91e8e35257683f3 ./lang/en.json -2272debce08cf971375a748844d79f83548c8696e63fcb0b25d6c212651242ff ./lang/pl.json +669da4cb511c9d7f72017edf3f16718eb3b32fc832d8431a597a330f4bc8f5c0 ./lang/en.json +9bf8bccb7eaf99aba65f4b4b8776f2834f335fc0b007999d7e5c8d9d287067c8 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -48,12 +48,12 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda 417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs -34129cf37c5e544b506024c82cc917d31c705f42bfbb1dd5c46c27687bae63f8 ./src/api/house.rs +b2a730970a767c67727d5bc8f7090edb26d8332a47bc72a28b5a1195a4dbafe7 ./src/api/house.rs 0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs 2b3feefbafc1f813c00dfe20db147aa1f5067ec5b8455f041ac3793802911be3 ./src/api/public_settings.rs 8f7f0b8c157e9c97d7dc305b79923646ef47d76497abfcf09e9c67bb12af5dd1 ./src/api/schedules.rs -918164d24faf02861986b78774ceae4add72b6b2de53b3c15fd1a94dff3de38a ./src/api/settings.rs +4ffab50d931ca46dc5708914b6b54256d639cc11ad1b4281f649c310db5d41a0 ./src/api/settings.rs 74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs 681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs 923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs @@ -70,37 +70,37 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs 952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs 384f7027ca48bd4c4048320bc6d4cacd631bee40e8630ea22e0d6e5f22ddc3a6 ./src/engine/automations.rs -910248f30ac9d697816458cd393c5f7d4bbdd3a12e48cb41cbb3ddb68a1ce395 ./src/engine/commands.rs +77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs ced02b351ba86c43bd3683480a33ff7bd95c8a8109216c85979bc1cb14db5f41 ./src/engine/control_plan.rs 38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs -b353d78e8a6d0fa1a76a731a1ebe8e49fab33869d2834ffc5a7ee46d78135896 ./src/engine/groups.rs +511caa52f9d1a6c13c15731e6a394ca2790ba642845367da1f0b767c12b5be98 ./src/engine/groups.rs 18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs -e199628c4580e6a225e77d17acaaf8edc2a1ff0e3f91595234bba1f53e6450e1 ./src/engine/local_thermostat.rs -20964553f2691c6eb79932271ceb0627f9665a7f7cfb9d61ca3fe5a6f093f6e7 ./src/engine/ownership.rs +f7f2570c5b4a0f655e7c68e44d3dcca478b546e31586e98227b0e8c21e73e8c4 ./src/engine/local_thermostat.rs +ea61ee04f7cda0c4835a87b1e28f1ff8134a8b977741ef6e8ccb8f284d1c9470 ./src/engine/ownership.rs 9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs 3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs 2e000ede494bafb5309af3bbcdf1f10024bb01012ab3f9555ff0c8f1898bc9d7 ./src/engine/targets.rs a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs -9dd08b509057445f14d88881a3c76eb74bf333fd722db10d3e655b825f94c637 ./src/engine/tests.rs +9e86ea344e0bf6a2232905a98e5addce5da6f845cfed5b04a5d2d20787490b15 ./src/engine/tests.rs ebf8b9ec5c5a84850d8cad4c4faea090e52625b5a0be56625dc84060ecb35186 ./src/engine/zone_actions.rs -15de7195e91336a9e865a6a9c5c0e6768c3deea183941fc7cf3bca387e1b2724 ./src/engine/zone_control.rs +51e0d547709da755622c88bea5e69912b519040d5111ba5c2d74d071b73b52a9 ./src/engine/zone_control.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 -f34ab82de49536119b1abe32c2726f6fa91bf365f3b00e65d2b1371344e5b4ab ./src/main.rs +ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs -c88fa0555d8549b4cadf7fa56469898b911054c39e774c9e9122941d65f760b6 ./src/models/control_plan.rs +da672b06295370a36c69136e15684ab10ccb8fbdc814d76527a05e01213c2d2b ./src/models/control_plan.rs 8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs 7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs 668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs -29448846a4835ea511ef54326866ecc3eaac37bc27bbec38c4048be23fc81029 ./src/models/runtime.rs +268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs 5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs 9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs @@ -127,12 +127,12 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg -0ca4021a39142112632ef5c2f2407eedbb53a4d5519f97d682aa0864dbe01979 ./web/index.html +44ce3d17b83c1ab505247ba050a923d5ae109a3a06209aa3d362e9d6daf1ecbf ./web/index.html 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js 64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js cf8d97770985b8cf920382725fa33d5f61497a639544bfc528af72b276de0b6a ./web/js/core.js -dcba9512d6376bf06ce5e521597a4e849fbaabc258407a536f6ca81d9ba7e6c0 ./web/js/dashboard.js +9767d5f2bd2844245245c0bd86dddd528975c1d1dd1d05b1449feda7a95214c4 ./web/js/dashboard.js a41a5d768452872e9e88abeab8d97c0869a3285448165ec306f59341265b4b59 ./web/js/entities.js 69a052771dfafbd4b778cbc9d1b7a7695427621ed66047282088a3a07e6e4c2e ./web/js/events.js 2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js diff --git a/README.md b/README.md index 573b863..3955dfe 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.3** +**Current release: 0.9.4** > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. @@ -189,7 +189,7 @@ This distinction is important: direct device control intentionally behaves diffe The zone state exposes who currently owns control (`control_owner`, `control_source`, timestamps and reason). Physical remote/direct device actions can create a manual-device takeover so normal schedules do not immediately overwrite the user. Explicit zone thermostat control hands ownership back to the thermostat engine when appropriate. -Global **Turn all on/off** is a one-shot physical action. It does not erase or suspend thermostat, group, house-rule or manual ownership; those controllers may act again later. Explicit local/group/manual control has priority over house rules. Global ON respects compressor protection and is shown in the shared queue when a start must wait; global OFF remains immediate. +Global **Turn all off** is an immediate bulk thermostat action: it powers every enabled unit off and leaves every thermostat locally OFF with no hand-back timer, but it does not create a persistent global gate. A later local thermostat or manual group action can therefore re-enable only its own scope. **Turn all on** releases those local OFF markers and sends ON to all enabled units; compressor protection may queue protected starts. Explicit local/group/manual control has priority over house rules. ### Profiles and schedules diff --git a/docs/API.md b/docs/API.md index dc60360..bde022b 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.3**. +HTTP and WebSocket API for GREE Controller **0.9.4**. [← Main documentation](../README.md) @@ -139,7 +139,7 @@ Common statuses: | DELETE | `/api/groups/{id}` | Delete a group. | | POST | `/api/groups/{id}/control` | Group control enable/mode/preset/custom-temperature control. | | POST | `/api/house/control` | Set global thermostat mode. | -| POST | `/api/house/power` | Send a one-shot ON/OFF command to all enabled units. | +| POST | `/api/house/power` | Bulk ON/OFF for all thermostats and enabled units; no persistent global gate. | | POST | `/api/house/preset` | Set/clear whole-house preset override. | ### Schedules and automations @@ -195,7 +195,7 @@ Common statuses: | POST | `/api/integrations/home-assistant/groups/{id}/control` | Restricted group control. | | POST | `/api/integrations/home-assistant/house/control` | Restricted house mode. | | POST | `/api/integrations/home-assistant/house/preset` | Restricted house preset. | -| POST | `/api/integrations/home-assistant/house/power` | Restricted one-shot all-units power action. | +| POST | `/api/integrations/home-assistant/house/power` | Restricted bulk all-thermostat/all-unit power action. | | POST | `/api/integrations/home-assistant/zones/{id}/control` | Restricted thermostat-zone control. | --- @@ -212,7 +212,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.9.3", + "version": "0.9.4", "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.3", + "version": "0.9.4", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, @@ -669,13 +669,13 @@ All fields optional: A custom setpoint creates the same override for every member zone. For explicit Web/Home Assistant group control it stays active until the group is changed/released; scheduled group automation remains bounded by the normal schedule hand-back. 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. 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 control has higher priority than house rules and schedules; scheduled group automations still respect higher-priority manual/local ownership. Group mode/preset/setpoint changes are accepted only while the group is ON. Global ON/OFF actions are one-shot physical commands and never create a persistent group/zone gate. +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 control has higher priority than house rules and schedules; scheduled group automations still respect higher-priority manual/local ownership. Group mode/preset/setpoint changes are accepted only while the group is ON. Global ON/OFF never creates a persistent global gate. Global OFF does leave member thermostats locally OFF until they are explicitly re-enabled by local/group/global ON. --- ## Whole-house control -House thermostat rules and global power actions are intentionally separate. Global ON/OFF is one-shot; it is not an automation enable/disable state. +House thermostat rules and global power actions are intentionally separate. Global ON/OFF does not create a master automation gate; OFF is persisted as per-zone local OFF so ordinary demand cannot immediately undo it. ### `POST /api/house/control` @@ -700,7 +700,7 @@ Returns public runtime settings. } ``` -This endpoint is a **one-shot physical command**. `false` sends OFF to every technically enabled unit; `true` sends ON to every technically enabled unit. It does not change group enablement, local thermostat ownership, direct/manual takeover, schedules, house mode or automation rules. Those controllers may issue a later command independently. Pending compressor-protection tasks from before the global action are cleared and may be recreated by a later fresh thermostat decision. When compressor protection is enabled, a global `true` request for a recently stopped thermostat-managed unit is queued until its safe start deadline and appears in the compressor queue; `false` is never delayed by compressor protection. +This endpoint is a **bulk thermostat power action without a persistent global gate**. `false` first stores every thermostat as an indefinite local OFF and then sends immediate OFF to every technically enabled unit, so the next normal demand cycle cannot turn the house back on. A later explicit local thermostat or manual group action may re-enable only its own scope. `true` releases local OFF markers for all thermostats and sends ON to all technically enabled units. Group enablement, profiles, schedules and house mode are preserved. Pending compressor-protection tasks from before the action are cleared. When compressor protection is enabled, protected global starts are queued until their safe deadline; OFF is never delayed. Response includes: @@ -1109,7 +1109,7 @@ Modes: `problems`, `important`. Providers: `pushover`, `slack`, `discord`. Accepts the complete `RuntimeSettings` document. Important behavior: -- `house_mode` cannot be changed here; use House Control API. `house_power_enabled` is a legacy compatibility field and is normalized to `true` because global ON/OFF is one-shot. +- `house_mode` cannot be changed here; use House Control API. `house_power_enabled` is a legacy compatibility field and is normalized to `true`; global ON/OFF no longer uses a persistent master gate. - polling interval is clamped `2..3600` seconds. - zone interval is clamped `2..3600` seconds. - discovery timeout is clamped `300..30000` ms. diff --git a/home-assistant/README.md b/home-assistant/README.md index f4a2c2d..d5baf73 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -135,7 +135,7 @@ The integration also exposes three controller-level entities on the **GREE Contr - **Thermostat mode** (`select`) — Cooling, Heating or **Do not control**. Do not control pauses house-level thermostat commands but leaves direct device control and explicit per-zone Heat/Cool overrides untouched. - **Work profile** (`select`) — Auto schedule, Comfort, Sleep or Away. If zones have mixed manual profiles, the select has no single current option until a whole-house profile is chosen again. - Choosing **Cooling/Heating** changes the house rule for zones that inherit the global mode. **Do not control** pauses only that inherited house rule; explicit local/group/manual control remains independent. -- **All air conditioners** (`switch`) sends one-shot physical ON/OFF commands to all enabled units. It does not enable/disable automation or erase local/group/manual ownership. Its displayed state reflects whether all currently enabled units are physically ON. +- **All air conditioners** (`switch`) performs bulk thermostat power control. OFF powers all enabled units down and leaves their thermostats locally OFF with no hand-back timer; later local/group/global ON can re-enable the selected scope. ON releases local OFF states and respects compressor protection. Its displayed state reflects whether all currently enabled units are physically ON. These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints. diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 779a5e6..7cd19fc 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.3", + "version": "0.9.4", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index e0e9fc7..012701f 100644 --- a/lang/en.json +++ b/lang/en.json @@ -549,11 +549,11 @@ "house.noControl": "Do not control", "house.powerAllEyebrow": "Global power", "house.powerAllTitle": "All air conditioners", - "house.powerAllHint": "One-shot power control for all air conditioners. It does not enable or disable automation: thermostats, groups, house rules and manual controls continue independently after the command.", + "house.powerAllHint": "Bulk power control for all units. OFF leaves thermostats locally off until you explicitly turn a thermostat, group or the whole house back on.", "house.powerOnAll": "Turn all on", "house.powerOffAll": "Turn all off", - "house.powerOnDone": "One-shot ON sent to all air conditioners", - "house.powerOffDone": "One-shot OFF sent to all air conditioners", + "house.powerOnDone": "All thermostats enabled and ON sent to all units", + "house.powerOffDone": "All units turned off; thermostats remain locally off", "house.powerPartial": "Could not change power on {count} devices", "preset.manual": "Manual", "nav.groups": "Groups", @@ -728,7 +728,7 @@ "history.zoomOut": "Zoom out", "history.zoomReset": "Reset zoom", "history.zoomIn": "Zoom in", - "house.powerOffConfirm": "Turn all air conditioners off now? This is a one-shot command — thermostats, groups and automation stay active and may change unit state later.", + "house.powerOffConfirm": "Turn all air conditioners off? All thermostats will remain locally OFF with no timer. You can later enable an individual thermostat, a group, or the whole house.", "zones.temporaryStarting": "Starting…", "notifications.applicationTokenPlaceholder": "Application token", "notifications.userKeyPlaceholder": "User/group key", diff --git a/lang/pl.json b/lang/pl.json index 9304683..2d0634d 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -549,11 +549,11 @@ "house.noControl": "Nie steruj", "house.powerAllEyebrow": "Zasilanie globalne", "house.powerAllTitle": "Wszystkie klimatyzatory", - "house.powerAllHint": "Jednorazowe sterowanie zasilaniem wszystkich klimatyzatorów. Nie włącza ani nie wyłącza automatyki: po wykonaniu polecenia termostaty, grupy, zasady domu i sterowanie ręczne działają dalej niezależnie.", + "house.powerAllHint": "Sterowanie zasilaniem wszystkich jednostek. WYŁ. pozostawia termostaty lokalnie wyłączone do ręcznego włączenia termostatu, grupy lub całego domu.", "house.powerOnAll": "Włącz wszystkie", "house.powerOffAll": "Wyłącz wszystkie", - "house.powerOnDone": "Wysłano jednorazowe włączenie wszystkich klimatyzatorów", - "house.powerOffDone": "Wysłano jednorazowe wyłączenie wszystkich klimatyzatorów", + "house.powerOnDone": "Włączono wszystkie termostaty i wysłano polecenie włączenia jednostek", + "house.powerOffDone": "Wyłączono wszystkie jednostki; termostaty pozostają lokalnie wyłączone", "house.powerPartial": "Nie udało się zmienić zasilania {count} urządzeń", "preset.manual": "Ręcznie", "nav.groups": "Grupy", @@ -728,7 +728,7 @@ "history.zoomOut": "Pomniejsz wykres", "history.zoomReset": "Przywróć powiększenie", "history.zoomIn": "Powiększ wykres", - "house.powerOffConfirm": "Wyłączyć teraz wszystkie klimatyzatory? To jednorazowe polecenie — termostaty, grupy i automatyka pozostają aktywne i mogą później ponownie zmienić stan jednostek.", + "house.powerOffConfirm": "Wyłączyć wszystkie klimatyzatory? Wszystkie termostaty pozostaną lokalnie WYŁ. bez timera. Później możesz włączyć pojedynczy termostat, grupę albo cały dom.", "zones.temporaryStarting": "Uruchamianie…", "notifications.applicationTokenPlaceholder": "Token aplikacji", "notifications.userKeyPlaceholder": "Klucz użytkownika/grupy", diff --git a/src/api/house.rs b/src/api/house.rs index c1981a0..b8de167 100644 --- a/src/api/house.rs +++ b/src/api/house.rs @@ -1,24 +1,6 @@ #[derive(Debug, Deserialize)] struct HouseControlPatch { mode: String } -async fn rearm_all_compressor_queues(state: &AppState) -> Result<(), AppError> { - let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); - zone_ids.sort(); - zone_ids.dedup(); - for zone_id in zone_ids { - let _zone_guard = state.lock_zone_operation(&zone_id).await; - let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; - if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none() - && zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; } - engine::rearm_compressor_queue(&mut zone); - zone.revision = zone.revision.saturating_add(1); - zone.updated_at = Utc::now(); - state.db.save_zone(&zone)?; - state.broadcast("zone.updated", serde_json::to_value(&zone)?); - } - Ok(()) -} - async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> { let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); @@ -42,12 +24,31 @@ async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<() } Ok(()) } +async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result { + let mut zone_ids: Vec = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); + zone_ids.sort(); + zone_ids.dedup(); + let mut changed = 0usize; + for zone_id in zone_ids { + let _zone_guard = state.lock_zone_operation(&zone_id).await; + let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; }; + engine::rearm_compressor_queue(&mut zone); + if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; } + engine::refresh_control_ownership(&mut zone, true); + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + } + Ok(changed) +} + async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result, AppError> { let mut failed = Vec::new(); for device in state.db.list_devices()? { if !device.enabled { continue; } - // Global ON/OFF is deliberately a one-shot physical command. It does not mutate - // thermostat/group/manual ownership, so those controllers may make a later decision. + // The per-zone thermostat power state is persisted before these physical commands. + // OFF is immediate; ON still respects compressor protection. let result = if power { engine::one_shot_house_power_on_device(state, &device.id).await } else { @@ -112,9 +113,10 @@ async fn update_house_power(State(state): State, Json(input): Json, Json(input): Json, Json(input): Json, Json(mut input): Json Result { - let _device_guard = state.lock_device_operation(device_id).await; - send_command_locked(state, device_id, command).await -} - async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { send_command_locked_inner(state, device_id, command, true, true).await } diff --git a/src/engine/groups.rs b/src/engine/groups.rs index f0d2956..62ecd33 100644 --- a/src/engine/groups.rs +++ b/src/engine/groups.rs @@ -70,8 +70,13 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl // releases group ownership: members are represented as individually OFF, not as // "blocked by a disabled group". ON clears that scoped OFF and lets group arbitration // take ownership again. The whole-house master remains independent. - let explicit_group_power = patch.power.is_some() && source != "automation.group"; let manual_group_control = source != "automation.group"; + let explicit_group_power = patch.power.is_some() && manual_group_control; + // Applying a manual mode/profile/custom target to an already-enabled group is itself an + // explicit scoped takeover. It must wake members that were left locally OFF by a previous + // group/whole-house OFF; otherwise the UI says "group control" while every unit stays OFF. + let manual_group_activation = manual_group_control && group.power_enabled && climate_change; + let explicit_group_takeover = explicit_group_power || manual_group_activation; let mut zones = Vec::new(); let mut failed = Vec::new(); @@ -89,7 +94,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl // or temporary ownership so all members react consistently to the bulk command. // Scheduled automations deliberately do not do this: manual/local ownership keeps // its higher priority and the automation can only affect currently free members. - if explicit_group_power { + if explicit_group_takeover { if zone.temporary_quick_thermostat.is_some() { if temporary_owns_zone { finish_temporary_quick_thermostat(&mut zone, &schedules, &state.settings.read().await.house_mode); @@ -161,7 +166,8 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl // Power ON clears this scoped OFF and returns the member to group arbitration. let mut force_power_off_after_save = false; let mut applied_group_power: Option = None; - if let Some(power) = patch.power { + let requested_member_power = patch.power.or(manual_group_activation.then_some(true)); + if let Some(power) = requested_member_power { let automatic_power_blocked = source == "automation.group" && (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone); if automatic_power_blocked { diff --git a/src/engine/local_thermostat.rs b/src/engine/local_thermostat.rs index 70d45db..38c8301 100644 --- a/src/engine/local_thermostat.rs +++ b/src/engine/local_thermostat.rs @@ -1,5 +1,32 @@ pub const LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES: i64 = 15; +/// Apply the whole-house bulk power state to a thermostat without creating a persistent +/// global gate. OFF leaves the zone explicitly/indefinitely off; ON releases that local OFF +/// so the zone can immediately return to its group/manual/automatic controller. +pub fn set_house_bulk_thermostat_power(zone: &mut Zone, power: bool) -> bool { + if power { + if zone.local_thermostat_power != Some(false) { return false; } + zone.local_thermostat_power = None; + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; + true + } else { + let changed = zone.local_thermostat_power != Some(false) + || zone.local_thermostat_resume_at.is_some() + || zone.local_thermostat_restore_zone_enabled.is_some() + || zone.demand + || zone.demand_since.is_some(); + zone.local_thermostat_power = Some(false); + zone.local_thermostat_resume_at = None; + zone.local_thermostat_restore_zone_enabled = None; + zone.demand = false; + zone.demand_since = None; + zone.effective_mode = "off".into(); + zone.device_setpoint = None; + changed + } +} + /// Apply local quick-thermostat power ownership and keep the automatic hand-back /// deadline in one backend-owned place. Every fresh OFF action receives a fresh /// deadline; ON cancels any pending hand-back. diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index 1b34913..b6beb5f 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -155,7 +155,7 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec Result { - // Whole-house OFF is a one-shot physical action. Preserve every zone/manual/group owner, - // while still taking zone -> device locks so a concurrent local/manual action cannot race - // the forced OFF frame. Controllers are free to make a later independent decision. + // Whole-house OFF physically forces the unit down after the API has persisted per-zone local OFF. + // Keep zone -> device ordering so a concurrent local/manual action cannot race the frame. let mut zone_ids: Vec = state.db.list_zones()?.into_iter() .filter(|zone| zone.device_id == device_id) .map(|zone| zone.id) @@ -280,7 +279,7 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, _so } pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -> Result { - // Global ON is also one-shot and must not take thermostat ownership. For thermostat-managed + // Global ON releases per-zone OFF state in the API and must not create a local-ON ownership marker. For thermostat-managed // units it still respects compressor protection; a protected start is stored as a visible // queue item and executed at the protection deadline unless a newer intent cancels/replaces it. let mut zone_ids: Vec = state.db.list_zones()?.into_iter() @@ -330,7 +329,7 @@ pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) - } } - // The device lock is already held. This is physical one-shot power only: no manual marker. + // The device lock is already held. This is physical global power only: no manual marker. send_command_locked(state, device_id, DeviceCommand { power: Some(true), ..Default::default() }).await } @@ -365,18 +364,3 @@ pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result< Ok(device) } -pub fn clear_all_device_manual_overrides(state: &AppState, source: &str) -> Result { - let mut cleared = 0usize; - for mut zone in state.db.list_zones()? { - 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 - })); - cleared += 1; - } - Ok(cleared) -} - diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 5715f71..8e57701 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -780,4 +780,34 @@ mod tests { assert_eq!(outdoor_assist_offset("cool", None, 27.0, 23.0), 0.0); } + #[test] + fn whole_house_off_is_indefinite_until_explicit_reenable() { + let mut zone = test_zone("device"); + zone.local_thermostat_power = Some(true); + zone.local_thermostat_resume_at = Some(Utc::now() + chrono::Duration::minutes(15)); + zone.demand = true; + zone.demand_since = Some(Utc::now()); + + assert!(set_house_bulk_thermostat_power(&mut zone, false)); + assert_eq!(zone.local_thermostat_power, Some(false)); + assert!(zone.local_thermostat_resume_at.is_none()); + assert!(zone.local_thermostat_restore_zone_enabled.is_none()); + assert!(!zone.demand); + assert!(zone.demand_since.is_none()); + assert_eq!(zone.effective_mode, "off"); + + // A normal cycle must see an indefinite local OFF. Global/local/group ON releases it. + assert!(set_house_bulk_thermostat_power(&mut zone, true)); + assert!(zone.local_thermostat_power.is_none()); + assert!(zone.local_thermostat_resume_at.is_none()); + } + + #[test] + fn whole_house_on_does_not_create_a_local_on_override() { + let mut zone = test_zone("device"); + zone.local_thermostat_power = None; + assert!(!set_house_bulk_thermostat_power(&mut zone, true)); + assert!(zone.local_thermostat_power.is_none()); + } + } diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index 7f37a1f..ca67cc4 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -35,7 +35,7 @@ async fn control_zones(state: &AppState) -> Result<()> { let schedules = state.db.list_schedules()?; let settings = state.settings.read().await.clone(); let mut zone_snapshot = state.db.list_zones()?; - // Local/temporary thermostat ownership is independent from one-shot whole-house power + // Local/temporary thermostat ownership is independent from the legacy whole-house master gate // commands. Expire/activate sessions on their own deadlines. expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?; @@ -189,7 +189,7 @@ async fn control_zones(state: &AppState) -> Result<()> { zone.control_temperature_source = control_source; zone.updated_at = Utc::now(); - // A queued whole-house ON is a delayed one-shot physical action, not thermostat + // A queued whole-house ON is a delayed bulk physical action, not thermostat // ownership. It must survive local/group/manual state while compressor protection is // active, then execute once and hand control straight back to the existing owner. if zone.compressor_pending_action.as_deref() == Some("global_power_on") { @@ -216,7 +216,7 @@ async fn control_zones(state: &AppState) -> Result<()> { } Err(err) => { // Keep the user-visible task and retry on a bounded deadline instead of - // spinning immediately or silently dropping a one-shot request. + // spinning immediately or silently dropping the requested global start. zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10)); zone.lockout_until = zone.compressor_pending_until; zone.lockout_reason = Some("global_start_retry".into()); diff --git a/src/main.rs b/src/main.rs index e26e86d..5f5f443 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,7 @@ async fn main() -> Result<()> { runtime_settings.discovery_broadcast = config.discovery_broadcast.clone(); } config.apply_runtime_env_overrides(&mut runtime_settings); - // v0.9.3: whole-house ON/OFF is a one-shot physical action, not a persistent master gate. + // v0.9.4: whole-house ON/OFF is a bulk thermostat action, not a persistent master gate. // Normalize databases upgraded from older releases so a historical OFF cannot suppress // thermostats, groups or automations after restart. runtime_settings.house_power_enabled = true; diff --git a/src/models/control_plan.rs b/src/models/control_plan.rs index c313c93..b2ce987 100644 --- a/src/models/control_plan.rs +++ b/src/models/control_plan.rs @@ -95,7 +95,7 @@ pub struct ControlPlan { pub house_mode: String, /// Uniform whole-house preset when every zone uses the same override; None for a mixed state. pub house_preset: Option, - /// Compatibility field. Global ON/OFF is a one-shot action; this is always true in v0.9.3+. + /// Compatibility field. Global ON/OFF does not use a persistent master gate; this remains true in v0.9.4+. pub house_power: bool, pub outdoor_temperature: Option, pub control_strategy: String, diff --git a/src/models/runtime.rs b/src/models/runtime.rs index aee2764..396ebeb 100644 --- a/src/models/runtime.rs +++ b/src/models/runtime.rs @@ -9,7 +9,7 @@ pub struct RuntimeSettings { /// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control. #[serde(default = "default_house_mode")] pub house_mode: String, - /// Legacy compatibility flag. Since v0.9.3 global ON/OFF is a one-shot command, not an + /// Legacy compatibility flag. Since v0.9.4 global ON/OFF is a bulk thermostat action, not an /// automation gate. The controller normalizes this field to true on load/import. #[serde(default = "default_true")] pub house_power_enabled: bool, diff --git a/web/index.html b/web/index.html index a538088..2e509f9 100644 --- a/web/index.html +++ b/web/index.html @@ -62,7 +62,7 @@
-
Global power

All air conditioners

One-shot power command for all units. Thermostats, groups, house rules and manual controls remain independent afterwards.

+
Global power

All air conditioners

Bulk power action for all units. OFF leaves thermostats locally off until you explicitly turn a thermostat, group or the whole house back on.

diff --git a/web/js/dashboard.js b/web/js/dashboard.js index d0c1318..705c0f8 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -45,7 +45,7 @@ function renderHouseClimate() { const preset = zonePresets.length && zonePresets.every(value => value === zonePresets[0]) ? zonePresets[0] : null; const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature); const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff'); - // Global power buttons are one-shot actions, not a persistent ON/OFF mode. + // Global power buttons are bulk thermostat actions, not a persistent master gate. if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); } if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); } node.innerHTML = `
${esc(tr('house.seasonMode'))}

${esc(tr('house.smartThermostat'))}

${esc(tr('house.setpointStrategy'))}

${esc(tr('house.outdoor'))}${esc(outdoor)}