This commit is contained in:
Mateusz Gruszczyński
2026-09-01 14:18:00 +02:00
parent 97c26c67b6
commit d0346dd797
33 changed files with 327 additions and 270 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.9.2"
version = "0.9.3"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.9.2"
version = "0.9.3"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+32 -32
View File
@@ -1,12 +1,12 @@
b38e05f8a00969270280b0062ee129fccee39ae9c11cb614a6ad7b2b7ae2dfc7 ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
567a478d1e1ceb9fefba0f091cf4100a2953297d6e3cfb254f621d36faec3235 ./Cargo.lock
285e8936199d086103e73cdac7d0257429aa6b3c5a91c3be5d964eb6e6064f58 ./Cargo.toml
3ce3d5d41bf609d9f3e3edc20c51e470c105a44a0050fd08beef4dd61bbdec2d ./Cargo.lock
761439549d8f923adc732c10dbedd81a7e2811dbe3faea9af61dc94bed3151c1 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
0ba2649e1746f3d2084501d7dd4b7610fe6f530b8c748769313fa515a04b2e81 ./README.md
18040a0fcc08f95609850727fd825cde75e37d44145fb05eb54e1c0f6ee238ee ./README.md
ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs
4a41bc92108df6764af95979e035722d844918c4106dcc10d79b871da8aea2cb ./docs/API.md
a4571b7d31c96be8b3f540574444742f99a208fc0cdf27a14bebad70bc916ab3 ./home-assistant/README.md
11873ce428fffabb85a2f1152c3694256c8cbdced6b8bb1afc439e66e62600c9 ./docs/API.md
3b3041f6e3e8c1017fa7bca3b6b4b474f5279869952000a5549a5b2749f8396e ./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,17 +16,17 @@ 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
71d6215226168129fcd616cb1de0a9ab70aa2aa678269b2e7234802768ce9822 ./home-assistant/custom_components/gree_controller/manifest.json
c403e68570a6406c1a333b2f788a5182cc502c17e6c85837e57e6f11a26ee7ae ./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
1b8f5ddd5fb169e1db43b897f0af29eb197f113c36b3e8fae9af04a579a11d76 ./home-assistant/custom_components/gree_controller/switch.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py
338a42662e77f91215150851e55bbd39a5c77f5612cc77f3e037c06a6e6bdadb ./home-assistant/custom_components/gree_controller/switch.py
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
09f9b9184f09ae8439368299ae2922a04738266fa261240bcc74adba549522db ./lang/en.json
0d566784bb9c8f0a4420f24ffd86946919d7075ce867d0d78606144c26945559 ./lang/pl.json
f15fbad03393a5f5e971fb3842c1c312da4cdb776ac08731d91e8e35257683f3 ./lang/en.json
2272debce08cf971375a748844d79f83548c8696e63fcb0b25d6c212651242ff ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
@@ -46,17 +46,17 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
aab086f56cbb42d5aa48c04111bdc87637e06e35f2aa1e475f4a6bf06f5dee67 ./src/api/groups.rs
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
31359c99592515225779967f2347643b24ff21889875e707abafee570d618f70 ./src/api/house.rs
34129cf37c5e544b506024c82cc917d31c705f42bfbb1dd5c46c27687bae63f8 ./src/api/house.rs
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
d3ddc310a4033e400f521babec02ec06990092d7349c40cd271a3d8406f70f4a ./src/api/public_settings.rs
2b3feefbafc1f813c00dfe20db147aa1f5067ec5b8455f041ac3793802911be3 ./src/api/public_settings.rs
8f7f0b8c157e9c97d7dc305b79923646ef47d76497abfcf09e9c67bb12af5dd1 ./src/api/schedules.rs
678990ae03c96628927eac90ef0e84b4da4518c78fb5208a5a0980f96b0496ee ./src/api/settings.rs
918164d24faf02861986b78774ceae4add72b6b2de53b3c15fd1a94dff3de38a ./src/api/settings.rs
74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
434f1e56112f76c931eba2df5556738d3a315547f6b918cba8322cee8c411af9 ./src/api/zones.rs
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
2b6eecead75cd4cdb98a9f74dd1c374dbf11ebfa716abbc3476aa12398af9acd ./src/config.rs
369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs
24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs
@@ -69,40 +69,40 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
d418f27edde4a8a8ea89817899abfc48df632ec581deacf64db1dc409eef920f ./src/engine/automations.rs
384f7027ca48bd4c4048320bc6d4cacd631bee40e8630ea22e0d6e5f22ddc3a6 ./src/engine/automations.rs
910248f30ac9d697816458cd393c5f7d4bbdd3a12e48cb41cbb3ddb68a1ce395 ./src/engine/commands.rs
d91c50e68baa8f90a30fd94be1fda8cf548ed7386c1058f14f8d5b32625c0b25 ./src/engine/control_plan.rs
ced02b351ba86c43bd3683480a33ff7bd95c8a8109216c85979bc1cb14db5f41 ./src/engine/control_plan.rs
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
de747652055f222da31b8f671ffdcbe94277ace5082c24b6b579d9bdaa6d16ff ./src/engine/groups.rs
b353d78e8a6d0fa1a76a731a1ebe8e49fab33869d2834ffc5a7ee46d78135896 ./src/engine/groups.rs
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
e199628c4580e6a225e77d17acaaf8edc2a1ff0e3f91595234bba1f53e6450e1 ./src/engine/local_thermostat.rs
ea41d99d69a2b7593df7e243009211a65b227b9a88ffbf4a2badba88a49c01bc ./src/engine/ownership.rs
20964553f2691c6eb79932271ceb0627f9665a7f7cfb9d61ca3fe5a6f093f6e7 ./src/engine/ownership.rs
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs
2e000ede494bafb5309af3bbcdf1f10024bb01012ab3f9555ff0c8f1898bc9d7 ./src/engine/targets.rs
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs
08a450ec80d7ac61814cd6afad5667214e0eac5c0f064d73fe527cbc6d647224 ./src/engine/tests.rs
6f4541bad148d172a44cd3ddd22b49dc97e4b755554a7a8820222169090e7022 ./src/engine/zone_actions.rs
856bd291229ae9d0b3f21fb7fd47058b7af5a18bd195e48829f8162533613472 ./src/engine/zone_control.rs
9dd08b509057445f14d88881a3c76eb74bf333fd722db10d3e655b825f94c637 ./src/engine/tests.rs
ebf8b9ec5c5a84850d8cad4c4faea090e52625b5a0be56625dc84060ecb35186 ./src/engine/zone_actions.rs
15de7195e91336a9e865a6a9c5c0e6768c3deea183941fc7cf3bca387e1b2724 ./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
c3ff7232a2fa1d29428872a10cfd72ee60a3a1faaa662eff6c2d8c7dffc1ed3a ./src/main.rs
f34ab82de49536119b1abe32c2726f6fa91bf365f3b00e65d2b1371344e5b4ab ./src/main.rs
e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs
fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs
60ff96965cf9531b3964add783a6a45578aa1986844e99a239f182eb4f962d5b ./src/models/control_plan.rs
c88fa0555d8549b4cadf7fa56469898b911054c39e774c9e9122941d65f760b6 ./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
b1af7f8d4c3607b1c0ae978feaab4dfa03faa0154c7328712bb4eeafc27faa02 ./src/models/runtime.rs
29448846a4835ea511ef54326866ecc3eaac37bc27bbec38c4048be23fc81029 ./src/models/runtime.rs
e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
0bdc68fc1b3d1fd4d1e1253bd48b5c149cfc9b87e2fedb13288b3ea8a096af3d ./src/models/zone.rs
5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
@@ -127,19 +127,19 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
2f29bd02e3a74089b28621978882b1191e26d7ec612e1ccd306b65f204b68e99 ./web/index.html
0ca4021a39142112632ef5c2f2407eedbb53a4d5519f97d682aa0864dbe01979 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js
64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js
cf8d97770985b8cf920382725fa33d5f61497a639544bfc528af72b276de0b6a ./web/js/core.js
6e421388c7f6deb2aa42f1205dfdc52839f9f178fbd672129972528b8517a870 ./web/js/dashboard.js
1bc7b2c4ab81b9942dd04b8b445de087d439f56c8dac3531fa4a62b8287d4a60 ./web/js/entities.js
e448b48ead4b63ca4c498f2743c571876789a26fd4233cb63b00cbf2e84ca4ad ./web/js/events.js
dcba9512d6376bf06ce5e521597a4e849fbaabc258407a536f6ca81d9ba7e6c0 ./web/js/dashboard.js
a41a5d768452872e9e88abeab8d97c0869a3285448165ec306f59341265b4b59 ./web/js/entities.js
69a052771dfafbd4b778cbc9d1b7a7695427621ed66047282088a3a07e6e4c2e ./web/js/events.js
2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js
be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js
9aa199a9ca8d1109cc06d7a7d69f5c5ed45c53f488128ca520258e9c91c31352 ./web/js/main.js
e27d5bb4a1579fd5b1d9762a5067b953004ca8ae63506af4e8c2cad754c0c532 ./web/js/navigation.js
935a9282f8c7a0e1405752931607751cb0d314eabca74e3f1e0b5f21e1b93988 ./web/js/realtime.js
40446c6bbfa21b645732bb56d7d8a1dd2d7e940b84fc882670d3a3607d127a55 ./web/js/realtime.js
45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js
f9d23d7142ebcd77085ad30b8b5bf927b754b51f10976309d5b0a3f22f3cfafb ./web/js/settings-ui.js
a189f4f7cc253087ac271b3c5b7a82751176032fc0f7040f073cdc451d354e06 ./web/js/settings.js
+3 -3
View File
@@ -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.2**
**Current release: 0.9.3**
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
@@ -177,7 +177,7 @@ The hardened systemd unit allows `AF_NETLINK`, which is required for interface d
A **Device** is the physical GREE unit. Device commands are direct/technical commands.
A **Zone** is the thermostat owner for a device. It decides demand from room temperature, setpoint, hysteresis, schedules, house master state, manual ownership and safety lockouts. Group control can temporarily apply shared settings. Turning a group OFF powers its member units down and releases group ownership; each thermostat can then be switched back on independently without an OFF group acting as a membership block.
A **Zone** is the thermostat owner for a device. It decides demand from room temperature, setpoint, hysteresis, schedules, house rules, manual ownership and safety lockouts. Group control can temporarily apply shared settings. Turning a group OFF powers its member units down and releases group ownership; each thermostat can then be switched back on independently without an OFF group acting as a membership block.
### Compressor protection
@@ -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.
Whole-house master OFF remains authoritative and powers managed devices down.
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.
### Profiles and schedules
+14 -13
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.9.2**.
HTTP and WebSocket API for GREE Controller **0.9.3**.
[← 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` | Set whole-house master power. |
| POST | `/api/house/power` | Send a one-shot ON/OFF command to all enabled units. |
| 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 master power. |
| POST | `/api/integrations/home-assistant/house/power` | Restricted one-shot all-units 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.2",
"version": "0.9.3",
"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.2",
"version": "0.9.3",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -667,15 +667,15 @@ All fields optional:
- `preset`: `auto`, `comfort`, `sleep`, `away`, `custom`,
- `setpoint`: custom group target in the `830°C` range; requires `preset: "custom"`.
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.
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 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 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.
---
## Whole-house control
House thermostat mode and master power are intentionally separate.
House thermostat rules and global power actions are intentionally separate. Global ON/OFF is one-shot; it is not an automation enable/disable state.
### `POST /api/house/control`
@@ -687,8 +687,8 @@ House thermostat mode and master power are intentionally separate.
Valid modes: `cool`, `heat`, `off`.
- `cool`/`heat` are explicit whole-house activation requests: master power is enabled and the thermostat arbiter starts eligible managed zones. Existing per-group control enable/disable settings are preserved.
- `off` means **do not perform house-level thermostat control** for inherited zones. It does not itself change master power and does not forcibly power direct/manual devices down.
- `cool`/`heat` select the house rule used by zones that inherit the global mode and immediately re-run arbitration for free zones. Explicit local/group/direct ownership is preserved.
- `off` means **do not perform house-level thermostat control** for inherited free zones. It does not block local thermostats, groups, device-manual control or controller automations.
Returns public runtime settings.
@@ -700,13 +700,14 @@ Returns public runtime settings.
}
```
`false` is authoritative: it clears local/manual ownership markers as required and powers every enabled physical device down. It does **not** modify per-group control enable/disable settings. `true` resumes thermostat arbitration rather than blindly sending a bare ON frame.
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.
Response includes:
```json
{
"power": false,
"one_shot": true,
"devices": [],
"groups": [],
"settings": {},
@@ -724,7 +725,7 @@ Response includes:
Valid: `auto`, `comfort`, `sleep`, `away`.
A non-`auto` preset creates zone overrides that normally expire at each zone's next schedule boundary. `auto` clears them. Selecting a house preset explicitly re-enables master power while preserving each group's control enable/disable setting.
A non-`auto` preset creates overrides for free house-controlled zones and normally expires at each zone's next schedule boundary. `auto` clears those free-zone overrides. Explicit local thermostat, group, temporary thermostat and direct/manual ownership is not overwritten by a house profile action.
---
@@ -1108,7 +1109,7 @@ Modes: `problems`, `important`. Providers: `pushover`, `slack`, `discord`.
Accepts the complete `RuntimeSettings` document. Important behavior:
- `house_mode` and `house_power_enabled` cannot be changed here; use House Control API.
- `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.
- polling interval is clamped `2..3600` seconds.
- zone interval is clamped `2..3600` seconds.
- discovery timeout is clamped `300..30000` ms.
+2 -2
View File
@@ -134,8 +134,8 @@ 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** in the thermostat-mode select or any whole-house **Work profile** is treated as explicit whole-house activation: it switches **All air conditioners** back on. **Do not control** does not change master power.
- **All air conditioners** (`switch`) — separate master power. Turning it off powers every enabled unit down and prevents zones/controller automations from restarting them. Turning it on powers all enabled units on again without changing the selected thermostat mode or profile.
- 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.
These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints.
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.9.2",
"version": "0.9.3",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -63,7 +63,9 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator]
"outdoor_temperature": plan.get("outdoor_temperature"),
"control_strategy": plan.get("control_strategy"),
"work_profile": plan.get("house_preset"),
"master_power": bool(plan.get("house_power", False)),
"all_units_powered": bool(self.coordinator.data) and all(
bool(device.get("power")) for device in self.coordinator.data.values() if device.get("enabled", True)
),
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
"effective_enabled_zones": sum(bool(zone.get("effective_enabled", zone.get("enabled"))) for zone in zones),
"demanding_zones": sum(bool(zone.get("demand")) for zone in zones),
@@ -165,8 +167,6 @@ class GreeControllerGroupPlanSensor(CoordinatorEntity[GreeControllerCoordinator]
group = self._group
if not group.get("power_enabled", False):
return "off"
if not group.get("effective_power", False):
return "master_off"
if group.get("mode") == "house" and group.get("house_mode") == "off":
return "paused"
return "requesting" if int(group.get("demanding_zones") or 0) > 0 else "satisfied"
@@ -62,7 +62,8 @@ class GreeControllerHousePowerSwitch(CoordinatorEntity[GreeControllerCoordinator
@property
def is_on(self) -> bool:
return bool(self.coordinator.plan.get("house_power", False))
devices = [device for device in self.coordinator.data.values() if device.get("enabled", True)]
return bool(devices) and all(bool(device.get("power")) for device in devices)
@property
def device_info(self) -> DeviceInfo:
+7 -5
View File
@@ -549,11 +549,11 @@
"house.noControl": "Do not control",
"house.powerAllEyebrow": "Global power",
"house.powerAllTitle": "All air conditioners",
"house.powerAllHint": "Whole-house automation control. “Turn all off” powers units down and stops automation without changing each group control enable setting. “Turn all on” resumes automation using the current zone and group settings.",
"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.powerOnAll": "Turn all on",
"house.powerOffAll": "Turn all off",
"house.powerOnDone": "Whole-house automation resumed",
"house.powerOffDone": "All air conditioners turned off",
"house.powerOnDone": "One-shot ON sent to all air conditioners",
"house.powerOffDone": "One-shot OFF sent to all air conditioners",
"house.powerPartial": "Could not change power on {count} devices",
"preset.manual": "Manual",
"nav.groups": "Groups",
@@ -572,7 +572,7 @@
"groups.followHouse": "Global mode",
"groups.mode": "Mode",
"groups.profile": "Profile",
"groups.masterOff": "Whole-house automation is off. You can prepare group control, but units remain off until whole-house control is enabled.",
"groups.masterOff": "",
"groups.memberCount": "{count} zones",
"groups.emptyTitle": "No groups yet",
"groups.emptyText": "Create a group to control selected air conditioners together.",
@@ -728,7 +728,7 @@
"history.zoomOut": "Zoom out",
"history.zoomReset": "Reset zoom",
"history.zoomIn": "Zoom in",
"house.powerOffConfirm": "Turn off all air conditioners? Group ON/OFF settings will be preserved.",
"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.",
"zones.temporaryStarting": "Starting…",
"notifications.applicationTokenPlaceholder": "Application token",
"notifications.userKeyPlaceholder": "User/group key",
@@ -786,6 +786,7 @@
"settings.compressorProtectionHint": "Recommended: 3 minutes. Starts and Heat/Cool changes requested during protection are queued. Open the full list with the Queue button above Thermostats.",
"zones.compressorQueueTitle": "Waiting for compressor protection",
"zones.queuedPowerOn": "Queued: power on · {mode} · {temperature}°C",
"zones.queuedGlobalPowerOn": "Queued: global unit power on",
"zones.queuedModeChange": "Queued: change mode to {mode} · {temperature}°C",
"zones.queuedAction": "Queued control task",
"zones.compressorQueueUntil": "Earliest execution: {time}",
@@ -804,6 +805,7 @@
"zones.queueEmptyText": "There are no commands waiting for compressor protection to end.",
"zones.queueReasonProtection": "Compressor protection",
"zones.queueSource": "Source: {source}",
"zones.queueSourceGlobal": "Global power",
"zones.queueRemaining": "Remaining {time}",
"zones.queueReady": "Ready to execute",
"zones.queueCancelAllConfirm": "Cancel all tasks ({count}) waiting for compressor protection?"
+7 -5
View File
@@ -549,11 +549,11 @@
"house.noControl": "Nie steruj",
"house.powerAllEyebrow": "Zasilanie globalne",
"house.powerAllTitle": "Wszystkie klimatyzatory",
"house.powerAllHint": "Sterowanie automatyki domu. „Wyłącz wszystkie wyłącza jednostki i zatrzymuje automatykę, ale nie zmienia ustawienia włącz/wyłącz poszczególnych grup. „Włącz wszystkie” wznawia automatykę zgodnie z bieżącymi ustawieniami stref i grup.",
"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.powerOnAll": "Włącz wszystkie",
"house.powerOffAll": "Wyłącz wszystkie",
"house.powerOnDone": "Wznowiono automatykę domu",
"house.powerOffDone": "Wyłączono wszystkie klimatyzatory",
"house.powerOnDone": "Wysłano jednorazowe włączenie wszystkich klimatyzatorów",
"house.powerOffDone": "Wysłano jednorazowe wyłączenie wszystkich klimatyzatorów",
"house.powerPartial": "Nie udało się zmienić zasilania {count} urządzeń",
"preset.manual": "Ręcznie",
"nav.groups": "Grupy",
@@ -572,7 +572,7 @@
"groups.followHouse": "Tryb globalny",
"groups.mode": "Tryb",
"groups.profile": "Profil",
"groups.masterOff": "Automatyka domu jest wyłączona. Możesz przygotować sterowanie grupą, ale jednostki pozostaną wyłączone do czasu włączenia sterowania domem.",
"groups.masterOff": "",
"groups.memberCount": "Strefy: {count}",
"groups.emptyTitle": "Brak grup",
"groups.emptyText": "Utwórz grupę, aby wspólnie sterować wybranymi klimatyzatorami.",
@@ -728,7 +728,7 @@
"history.zoomOut": "Pomniejsz wykres",
"history.zoomReset": "Przywróć powiększenie",
"history.zoomIn": "Powiększ wykres",
"house.powerOffConfirm": "Wyłączyć wszystkie klimatyzatory? Ustawienia WŁ./WYŁ. grup pozostaną bez zmian.",
"house.powerOffConfirm": "Wyłączyć teraz wszystkie klimatyzatory? To jednorazowe polecenie — termostaty, grupy i automatyka pozostają aktywne i mogą później ponownie zmienić stan jednostek.",
"zones.temporaryStarting": "Uruchamianie…",
"notifications.applicationTokenPlaceholder": "Token aplikacji",
"notifications.userKeyPlaceholder": "Klucz użytkownika/grupy",
@@ -786,6 +786,7 @@
"settings.compressorProtectionHint": "Zalecane: 3 minuty. Uruchomienia i zmiany trybu Grzanie/Chłodzenie zlecone podczas ochrony są kolejkowane. Pełną kolejkę otworzysz przyciskiem „Kolejka” nad Termostatami.",
"zones.compressorQueueTitle": "Oczekuje po ochronie sprężarki",
"zones.queuedPowerOn": "Zakolejkowane: włączenie · {mode} · {temperature}°C",
"zones.queuedGlobalPowerOn": "Zakolejkowane: globalne włączenie jednostki",
"zones.queuedModeChange": "Zakolejkowane: zmiana trybu na {mode} · {temperature}°C",
"zones.queuedAction": "Zakolejkowane zadanie sterowania",
"zones.compressorQueueUntil": "Wykonanie najwcześniej: {time}",
@@ -804,6 +805,7 @@
"zones.queueEmptyText": "Nie ma poleceń oczekujących na zakończenie ochrony sprężarki.",
"zones.queueReasonProtection": "Ochrona sprężarki",
"zones.queueSource": "Źródło: {source}",
"zones.queueSourceGlobal": "Zasilanie globalne",
"zones.queueRemaining": "Pozostało {time}",
"zones.queueReady": "Gotowe do wykonania",
"zones.queueCancelAllConfirm": "Anulować wszystkie oczekujące zadania ({count}) ochrony sprężarki?"
+1 -1
View File
@@ -256,7 +256,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"zone_ids": group.zone_ids,
"zone_names": zone_names,
"power_enabled": group.power_enabled,
"effective_power": settings.house_power_enabled && group.power_enabled,
"effective_power": group.power_enabled,
"mode": home_assistant_group_mode(&members),
"preset": home_assistant_group_preset(&members),
"custom_setpoint": home_assistant_group_custom_setpoint(&members),
+51 -112
View File
@@ -19,66 +19,37 @@ async fn rearm_all_compressor_queues(state: &AppState) -> Result<(), AppError> {
Ok(())
}
async fn clear_group_control_sources(state: &AppState, reason: &str) -> Result<(), AppError> {
async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
let mut guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids { guards.push(state.lock_zone_operation(zone_id).await); }
for zone_id in &zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if !zone.control_source.starts_with("group:") { continue; }
zone.control_source = "automation".into();
zone.control_since = Some(Utc::now());
zone.control_reason = reason.to_string();
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; };
let scoped_manual = zone.device_manual_override
|| zone.local_thermostat_power.is_some()
|| zone.control_source.starts_with("group:")
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
if scoped_manual { 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)?);
}
drop(guards);
Ok(())
}
fn clear_all_local_thermostat_overrides(state: &AppState) -> Result<usize, AppError> {
let mut cleared = 0;
for mut zone in state.db.list_zones()? {
if zone.local_thermostat_power.is_none() && zone.local_thermostat_resume_at.is_none() && zone.temporary_quick_thermostat.is_none() { continue; }
let temporary_was_active = engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
let temporary_restore = zone.temporary_quick_thermostat.as_ref().and_then(|session| session.restore_zone_enabled);
zone.temporary_quick_thermostat = None;
engine::reset_local_thermostat_override(&mut zone);
if temporary_was_active {
if let Some(enabled) = temporary_restore { zone.enabled = enabled; }
}
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
cleared += 1;
}
Ok(cleared)
}
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
let mut failed = Vec::new();
let enabled_zone_devices: std::collections::HashSet<String> = if power {
state.db.list_zones()?.into_iter()
.filter(|zone| zone.enabled && !zone.device_manual_override && zone.local_thermostat_power != Some(false))
.map(|zone| zone.device_id)
.collect()
} else {
std::collections::HashSet::new()
};
for device in state.db.list_devices()? {
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; }
// 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.
// 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.
let result = if power {
engine::send_command(state, &device.id, DeviceCommand { power: Some(true), ..Default::default() }).await
engine::one_shot_house_power_on_device(state, &device.id).await
} else {
engine::force_house_power_off_device(state, &device.id, source).await
};
@@ -113,16 +84,14 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
let payload = {
let mut settings = state.settings.write().await;
settings.house_mode = mode.clone();
// Choosing a real whole-house operating mode is an explicit request to run the
// house climate. It therefore clears a previous global power-off. "off" keeps
// its separate meaning: do not control, without changing master power.
if activate_all { settings.house_power_enabled = true; }
settings.house_power_enabled = true;
state.db.save_runtime_settings(&settings)?;
public_settings(&settings)
};
state.broadcast("settings.updated", payload.clone());
clear_group_control_sources(&state, "Whole-house mode control took ownership").await?;
rearm_all_compressor_queues(&state).await?;
// House rules do not steal explicit local/group/manual ownership. Free zones follow the
// new mode immediately; scoped manual controls continue independently.
rearm_house_automation_compressor_queues(&state).await?;
// run_zone_control_now takes the same cycle lock, so release the mutation window first.
drop(cycle_guard);
if activate_all {
@@ -132,7 +101,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
} else {
state.wake_zone_control();
}
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all}));
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode}));
Ok(Json(payload))
}
@@ -141,74 +110,42 @@ struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
let _house_guard = state.lock_house_operation().await;
let cycle_guard = state.lock_zone_control_cycle().await;
// 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 _cycle_guard = state.lock_zone_control_cycle().await;
// v0.9.3: this endpoint is a one-shot physical action, not a persistent automation gate.
// Preserve every zone/group/manual owner exactly as-is. A later thermostat, group, rule or
// manual action is therefore free to issue its own command independently.
{
let mut settings = state.settings.write().await;
if settings.house_power_enabled != input.power {
settings.house_power_enabled = input.power;
if !settings.house_power_enabled {
settings.house_power_enabled = true;
state.db.save_runtime_settings(&settings)?;
let payload = public_settings(&settings);
state.broadcast("settings.updated", payload);
state.broadcast("settings.updated", public_settings(&settings));
}
}
// Whole-house power is independent from group-control enablement. OFF clears current
// ownership markers and powers devices down, but preserves which groups the user has
// enabled for future group actions. A later house ON therefore does not silently turn
// disabled group control back on.
clear_group_control_sources(&state, if input.power { "Whole-house power control resumed automation" } else { "Whole-house power disabled" }).await?;
// Drop stale protection tasks from the intent that existed before this global physical
// command. They may be recreated by a later thermostat cycle if demand still exists.
rearm_all_compressor_queues(&state).await?;
if !input.power {
let zone_snapshot = state.db.list_zones()?;
let mut zone_ids: Vec<String> = zone_snapshot.iter().map(|zone| zone.id.clone()).collect();
zone_ids.sort();
zone_ids.dedup();
let mut zone_guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids {
zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let mut device_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.device_id).collect();
device_ids.sort();
device_ids.dedup();
let mut device_guards = Vec::with_capacity(device_ids.len());
for device_id in &device_ids {
device_guards.push(state.lock_device_operation(device_id).await);
}
engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
clear_all_local_thermostat_overrides(&state)?;
drop(device_guards);
drop(zone_guards);
}
let failed = if input.power {
// The immediate cycle acquires this lock itself.
drop(cycle_guard);
match engine::run_zone_control_now(&state).await {
Ok(()) => Vec::new(),
Err(err) => {
state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_power"}));
vec![json!({"scope":"thermostat_cycle","error":err.to_string()})]
}
}
} else {
// Keep the cycle excluded through the one-shot safety OFF cascade.
let failed = command_all_enabled_devices_power(&state, false, "house_power").await?;
drop(cycle_guard);
failed
};
let failed = command_all_enabled_devices_power(&state, input.power, "house_power_one_shot").await?;
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
let settings = state.settings.read().await;
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power { "Whole-house automation enabled; thermostat arbiter resumed" } else { "Whole-house power disabled; enabled devices powered off while group-control preferences were preserved" }, json!({
state.log("info", "house.power_all", if input.power {
"One-shot whole-house ON sent; thermostat/group/manual ownership preserved"
} else {
"One-shot whole-house OFF sent; thermostat/group/manual ownership preserved"
}, json!({
"power": input.power,
"failed": failed.len(),
"one_shot": true,
}));
Ok(Json(json!({
"power": input.power,
"one_shot": true,
"devices": devices,
"groups": groups,
"settings": settings_payload,
@@ -226,8 +163,6 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
}
// A whole-house profile is also an explicit whole-house activation. This mirrors
// selecting cooling/heating and makes the separate master-power control intuitive.
let settings_payload = {
let mut settings = state.settings.write().await;
settings.house_power_enabled = true;
@@ -235,8 +170,9 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
public_settings(&settings)
};
state.broadcast("settings.updated", settings_payload.clone());
clear_group_control_sources(&state, "Whole-house preset control took ownership").await?;
rearm_all_compressor_queues(&state).await?;
// A house profile applies to free house-controlled zones. Explicit local/group/direct
// owners remain higher priority and are not cleared or re-armed by changing house rules.
rearm_house_automation_compressor_queues(&state).await?;
let schedules = state.db.list_schedules()?;
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
@@ -251,11 +187,15 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_preset = Some(input.preset.clone());
session.deferred_setpoint = None;
}
let scoped_manual = zone.device_manual_override
|| zone.local_thermostat_power.is_some()
|| zone.control_source.starts_with("group:")
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
if scoped_manual {
// House rules never overwrite explicit manual/group/local ownership. The scoped
// controller keeps its own target/profile until the user releases it.
zones.push(zone);
continue;
} else if input.preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
@@ -286,7 +226,6 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
let devices = state.db.list_devices()?;
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
"preset": input.preset,
"master_power_enabled": true,
"failed": failed.len(),
}));
Ok(Json(json!({
+1 -1
View File
@@ -7,7 +7,7 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"discovery_timeout_ms": settings.discovery_timeout_ms,
"discovery_broadcast": settings.discovery_broadcast,
"house_mode": settings.house_mode,
"house_power_enabled": settings.house_power_enabled,
"house_power_enabled": true,
"control_strategy": settings.control_strategy,
"outdoor_assist_enabled": settings.outdoor_assist_enabled,
"history_retention_days": settings.history_retention_days,
+7 -4
View File
@@ -8,11 +8,13 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let old = state.settings.read().await.clone();
if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode {
if input.house_mode != old.house_mode {
return Err(AppError::BadRequest(
"house_power_enabled and house_mode must be changed through the House Control API".into(),
"house_mode must be changed through the House Control API".into(),
));
}
// Compatibility-only field: global ON/OFF is one-shot and never disables automation.
input.house_power_enabled = true;
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
@@ -274,6 +276,8 @@ fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), App
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
let now = Utc::now();
// Do not restore the pre-v0.9.3 persistent global-OFF gate from backups.
export.settings.house_power_enabled = true;
for device in &mut export.devices {
device.power = false;
device.mode = "cool".into();
@@ -420,8 +424,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
.filter(|zone| {
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
export.settings.house_power_enabled
&& zone.enabled
zone.enabled
&& effective_mode != "off"
})
.map(|zone| zone.device_id.clone())
+2 -6
View File
@@ -346,10 +346,6 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
(requested_start_kind.to_string(), started_at, None)
};
if !editing_active && start_kind == "now" && !runtime.house_power_enabled {
return Err(AppError::BadRequest("temporary thermostat cannot start while whole-house automation is off; enable house power or schedule it for later".into()));
}
let finish_kind = request.finish_kind.as_str();
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
@@ -414,7 +410,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
} else { None };
let starts_now = start_kind == "now";
let immediate_activation = !editing_active && starts_now && runtime.house_power_enabled && !zone.device_manual_override;
let immediate_activation = !editing_active && starts_now && !zone.device_manual_override;
let restore_zone_enabled = if editing_active {
existing_session.as_ref().and_then(|session| session.restore_zone_enabled)
} else if immediate_activation {
@@ -636,7 +632,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
let runtime = state.settings.read().await.clone();
let house_mode = runtime.house_mode.clone();
engine::refresh_control_ownership(&mut zone, runtime.house_power_enabled);
engine::refresh_control_ownership(&mut zone, true);
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
-1
View File
@@ -12,7 +12,6 @@ fn device_has_enabled_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
}
async fn run_automations(state: &AppState) -> Result<()> {
if !state.settings.read().await.house_power_enabled { return Ok(()); }
let devices = state.db.list_devices()?;
let mut automations = state.db.list_automations()?;
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
+5 -5
View File
@@ -9,7 +9,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zones.iter().all(|zone| zone.manual_preset.as_deref().unwrap_or("auto") == first_preset)
.then(|| first_preset.to_string())
});
let house_power = settings.house_power_enabled;
let house_power = true;
let now = Local::now();
let night_active = night_mode_active(&settings.night_mode, now.time());
let mut zones_out = Vec::new();
@@ -18,7 +18,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
for mut zone in zones {
let device = devices.iter().find(|item| item.id == zone.device_id);
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
refresh_control_ownership(&mut zone, settings.house_power_enabled);
refresh_control_ownership(&mut zone, true);
let configured_effective_mode = configured_effective_mode_owned.as_str();
let manual_device_mode = device.map(|item| if item.power { item.mode.as_str() } else { "off" });
let effective_mode = if zone.device_manual_override {
@@ -68,12 +68,12 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
zone.effective_setpoint.or(Some(resolved_target))
},
device_setpoint: device.filter(|item| item.power).map(|item| item.target_temperature),
desired_power: settings.house_power_enabled && zone.enabled && effective_mode != "off" && !zone.device_manual_override,
desired_power: zone.enabled && effective_mode != "off" && !zone.device_manual_override,
desired_mode: effective_mode.to_string(),
actual_power: device.map(|item| item.power),
actual_mode: device.map(|item| if item.power { item.mode.clone() } else { "off".into() }),
actual_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,
demand: 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,
local_thermostat_power: zone.local_thermostat_power,
@@ -85,7 +85,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
control_since: zone.control_since,
resume_at: zone.control_resume_at,
control_reason: zone.control_reason.clone(),
blocked_reason: if !settings.house_power_enabled { Some("global_off".into()) } else if zone.device_manual_override { Some("manual_override".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None },
blocked_reason: if zone.device_manual_override { Some("manual_override".into()) } else if zone.lockout_until.map(|until| until > Utc::now()).unwrap_or(false) { Some(zone.lockout_reason.clone().unwrap_or_else(|| "lockout".into())) } else if !zone.enabled { Some("zone_disabled".into()) } else if device.map(|d| !d.online || d.communication_failures > 0).unwrap_or(true) { Some("offline".into()) } else { None },
lockout_until: zone.lockout_until,
current_schedule_id: active.map(|item| item.id.clone()),
current_schedule_name: active.map(|item| item.name.clone()),
+13 -18
View File
@@ -32,19 +32,6 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let _group_guard = state.lock_group_operation(group_id).await;
let mut group = state.db.get_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
if source == "automation.group" && !state.settings.read().await.house_power_enabled {
state.log("info", "automation.blocked_by_house_power", &format!("Group automation suppressed while whole-house power is off for {}", group.name), json!({
"group_id": group.id, "source": source
}));
return Ok(json!({
"group": group,
"zones": [],
"devices": state.db.list_devices()?,
"failed": [],
"master_power_enabled": false,
"suppressed": true,
}));
}
let mut locked_zone_ids = group.zone_ids.clone();
locked_zone_ids.sort();
locked_zone_ids.dedup();
@@ -66,7 +53,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
"zones": [],
"devices": state.db.list_devices()?,
"failed": [],
"master_power_enabled": state.settings.read().await.house_power_enabled,
"master_power_enabled": true,
"suppressed": true,
}));
}
@@ -84,6 +71,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
// "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 mut zones = Vec::new();
let mut failed = Vec::new();
@@ -148,7 +136,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
} else {
zone.manual_preset = Some(preset.to_string());
if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
zone.manual_override_until = if manual_group_control {
None
} else {
next_schedule_boundary_utc(&zone.id, &schedules, Local::now())
};
}
}
if let Some(setpoint) = custom_setpoint {
@@ -156,7 +148,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zone.manual_preset = Some("custom".into());
zone.manual_setpoint = Some(setpoint);
zone.effective_setpoint = Some(setpoint);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
zone.manual_override_until = if manual_group_control {
None
} else {
next_schedule_boundary_utc(&zone.id, &schedules, Local::now())
};
}
}
// Power OFF is persisted as an ordinary per-zone thermostat OFF, with no automatic
@@ -237,8 +233,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zones.push(zone);
}
let runtime = state.settings.read().await.clone();
let master_power_enabled = runtime.house_power_enabled;
let master_power_enabled = true;
let control_toggled_on = patch.power == Some(true);
let control_enabled = group.power_enabled;
+64 -21
View File
@@ -1,8 +1,6 @@
pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool) {
pub fn refresh_control_ownership(zone: &mut Zone, _house_power_enabled: bool) {
let now = Utc::now();
let (owner, source, resume_at, reason) = if !house_power_enabled {
("global_off", "global".to_string(), None, "Whole-house power is disabled".to_string())
} else if zone.device_manual_override {
let (owner, source, resume_at, reason) = if zone.device_manual_override {
let source = match zone.control_source.as_str() {
"home_assistant_direct" | "web_direct" | "external" => zone.control_source.clone(),
_ => "external".into(),
@@ -157,7 +155,8 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
zone.control_source = normalized_direct_source(source).into();
zone.control_reason = "Direct/manual device control has priority".into();
// Direct/pilot control is an explicit ownership takeover, not a temporary preset.
// Keep it manual until the user chooses Resume automation (or a global safety OFF).
// Keep it manual until the user explicitly chooses Resume automation. A one-shot global
// ON/OFF command changes physical power only and must never erase manual ownership.
// A schedule boundary must never silently take the unit back from the person controlling it.
zone.device_manual_override_until = None;
zone.control_resume_at = None;
@@ -200,7 +199,7 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
).await;
// Once direct/pilot ownership has been detected, merely returning the device to a
// previous physical state must not silently hand control back to schedules. Only the
// explicit Resume automation action (or global OFF safety reset) ends manual ownership.
// explicit Resume automation action ends manual ownership.
if fields.is_empty() { continue; }
// A disabled zone is outside controller ownership. When its manually operated unit is
// switched off there is no takeover left to display or remember.
@@ -262,10 +261,10 @@ 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> {
// Preserve the global zone -> device lock order even for the whole-house safety path.
// Otherwise a zone edit could hold its zone lock while waiting for this device lock as
// this function saved a stale zone snapshot without owning the corresponding zone lock.
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, _source: &str) -> Result<Device, AppError> {
// 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.
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter()
.filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id)
@@ -277,20 +276,64 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, sou
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let _device_guard = state.lock_device_operation(device_id).await;
for zone_id in &zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
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
}));
}
// The device lock is already held, so use the locked forced-command variant directly.
force_power_off_device_locked(state, device_id).await
}
pub async fn one_shot_house_power_on_device(state: &AppState, device_id: &str) -> Result<Device, AppError> {
// Global ON is also one-shot and must not take thermostat ownership. 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<String> = state.db.list_zones()?.into_iter()
.filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id)
.collect();
zone_ids.sort();
zone_ids.dedup();
let mut _zone_guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids {
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let _device_guard = state.lock_device_operation(device_id).await;
let 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())); }
if device.power {
return Ok(device);
}
let settings = state.settings.read().await.clone();
if settings.compressor_protection_enabled {
if let Some(zone_id) = zone_ids.first() {
if let Some(mut zone) = state.db.get_zone(zone_id)? {
let now = Utc::now();
let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64);
if let Some(last_change) = zone.last_power_change_at {
let until = last_change + protection;
if until > now {
rearm_compressor_queue(&mut zone);
queue_compressor_action(&mut zone, "global_power_on".into(), until, "minimum_off_before_global_start");
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = now;
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
state.log("info", "zone.compressor_queue_queued", &format!("Queued global ON for {} behind compressor protection", zone.name), json!({
"zone_id": zone.id,
"device_id": zone.device_id,
"action": "global_power_on",
"resume_at": zone.compressor_pending_until,
}));
state.wake_zone_control();
return Ok(device);
}
}
}
}
}
// The device lock is already held. This is physical one-shot power only: no manual marker.
send_command_locked(state, device_id, DeviceCommand { power: Some(true), ..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
+4 -1
View File
@@ -50,7 +50,10 @@ fn effective_zone_mode(zone: &Zone, house_mode: &str) -> String {
}
}
let configured = if zone.inherit_house_mode { house_mode } else { zone.mode.as_str() };
if zone.local_thermostat_power == Some(true) && configured == "off" {
let scoped_manual = zone.local_thermostat_power == Some(true) || zone.control_source.starts_with("group:");
if scoped_manual && configured == "off" {
// Explicit local/group control is independent from house "Do not control". Reuse the
// zone's last concrete heat/cool mode instead of turning a manual action into a no-op.
zone.mode.clone()
} else {
configured.to_string()
+34
View File
@@ -439,6 +439,25 @@ mod tests {
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
}
#[test]
fn group_manual_control_can_run_when_house_mode_is_off() {
let mut zone = test_zone("device");
zone.inherit_house_mode = true;
zone.mode = "cool".into();
zone.control_source = "group:downstairs".into();
assert_eq!(effective_zone_mode(&zone, "off"), "cool");
}
#[test]
fn legacy_global_power_flag_does_not_take_ownership() {
let mut zone = test_zone("device");
zone.local_thermostat_power = Some(true);
zone.control_source = "web_thermostat".into();
refresh_control_ownership(&mut zone, false);
assert_eq!(zone.control_owner, "local_thermostat");
assert_ne!(zone.control_source, "global");
}
#[test]
fn indefinite_local_off_does_not_create_or_rearm_handback() {
let mut zone = test_zone("device");
@@ -712,6 +731,21 @@ mod tests {
}
#[test]
fn global_one_shot_queue_does_not_change_zone_ownership() {
let mut zone = test_zone("device");
zone.local_thermostat_power = Some(true);
zone.control_source = "web_thermostat".into();
refresh_control_ownership(&mut zone, true);
let owner = zone.control_owner.clone();
let source = zone.control_source.clone();
let until = Utc::now() + chrono::Duration::seconds(180);
queue_compressor_action(&mut zone, "global_power_on".into(), until, "minimum_off_before_global_start");
assert_eq!(zone.control_owner, owner);
assert_eq!(zone.control_source, source);
assert_eq!(zone.compressor_pending_action.as_deref(), Some("global_power_on"));
}
#[test]
fn compressor_queue_helpers_track_and_clear_pending_intent() {
let mut zone = test_zone("device");
+1 -4
View File
@@ -19,7 +19,6 @@ fn persist_zone_cycle(state: &AppState, computed: &Zone, cycle_started_at: DateT
}
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 || zone.local_thermostat_power == Some(false) { return Ok(false); }
Ok(true)
@@ -46,7 +45,6 @@ async fn send_automatic_device_command_if_owned(
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)
@@ -73,8 +71,7 @@ async fn apply_automatic_device_action(
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let _device_guard = state.lock_device_operation(device_id).await;
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
let settings = state.settings.read().await.clone();
if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() {
if zone.device_manual_override || zone.local_thermostat_power.is_some() {
return Ok(None);
}
// A power-on automation is an explicit domain transition and may re-enable a zone that
+51 -15
View File
@@ -11,7 +11,7 @@ pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) {
if clear_cancelled { zone.compressor_cancelled_action = None; }
}
fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime<Utc>, reason: &str) {
pub(crate) fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime<Utc>, reason: &str) {
let now = Utc::now();
if zone.compressor_pending_action.as_deref() != Some(action.as_str()) {
zone.compressor_pending_since = Some(now);
@@ -35,12 +35,11 @@ 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 quick-thermostat OFF is intentionally temporary. Expire the ownership marker
// before the house-power early return so the hand-back still happens while the master
// is off; no physical state is restored here, only automation ownership.
// Local/temporary thermostat ownership is independent from one-shot whole-house power
// 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?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, settings.house_power_enabled).await?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, true).await?;
// Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the
@@ -84,13 +83,6 @@ async fn control_zones(state: &AppState) -> Result<()> {
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
if !settings.house_power_enabled {
// 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(());
}
// 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| {
@@ -146,11 +138,11 @@ async fn control_zones(state: &AppState) -> Result<()> {
// A zone explicitly switched to heat/cool remains independent and may still run.
// Local Quick Thermostat is an explicit per-zone request. If the inherited house
// climate mode is "off" (no automatic climate control), use the zone's last local
// heat/cool mode while local ownership is ON. The separate whole-house master power
// remains authoritative and is checked before this loop.
// heat/cool mode while local ownership is ON. Global ON/OFF is intentionally not a
// persistent gate: later thermostat/group/manual intent may act independently.
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
zone.effective_mode = effective_mode_owned.clone();
refresh_control_ownership(&mut zone, settings.house_power_enabled);
refresh_control_ownership(&mut zone, true);
let effective_mode = effective_mode_owned.as_str();
let previous_source = zone.control_temperature_source.clone();
@@ -197,6 +189,50 @@ 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
// 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") {
let now = Utc::now();
if device.power {
clear_compressor_pending(&mut zone, true);
zone.updated_at = now;
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
let due = !settings.compressor_protection_enabled
|| zone.compressor_pending_until.map(|until| until <= now).unwrap_or(true);
if due {
let _device_guard = state.lock_device_operation(&zone.device_id).await;
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await {
Ok(updated_device) => {
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); }
clear_compressor_pending(&mut zone, true);
zone.last_action_at = Some(Utc::now());
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
Err(err) => {
// Keep the user-visible task and retry on a bounded deadline instead of
// spinning immediately or silently dropping a one-shot request.
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());
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
}
}
zone.updated_at = Utc::now();
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;
}
// A disabled thermostat zone is completely outside normal controller ownership.
// Keep its sensors fresh, but do not let group state, schedules or thermostat
// modulation touch the unit. Manual control from the technical Devices view may
+4
View File
@@ -34,6 +34,10 @@ 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.
// Normalize databases upgraded from older releases so a historical OFF cannot suppress
// thermostats, groups or automations after restart.
runtime_settings.house_power_enabled = true;
db.save_runtime_settings(&runtime_settings)?;
if config.simulate && config.auto_seed && db.count_devices()? == 0 {
+1 -1
View File
@@ -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<String>,
/// Whole-house master power state.
/// Compatibility field. Global ON/OFF is a one-shot action; this is always true in v0.9.3+.
pub house_power: bool,
pub outdoor_temperature: Option<f64>,
pub control_strategy: String,
+2 -1
View File
@@ -9,7 +9,8 @@ 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,
/// Whole-house master power. False is authoritative and suppresses zone/automation restarts.
/// Legacy compatibility flag. Since v0.9.3 global ON/OFF is a one-shot command, not an
/// automation gate. The controller normalizes this field to true on load/import.
#[serde(default = "default_true")]
pub house_power_enabled: bool,
/// `setpoint` keeps units powered and modulates compressor demand by changing target temperature.
+2 -2
View File
@@ -86,8 +86,8 @@ pub struct Zone {
pub manual_setpoint: Option<f64>,
#[serde(default)]
pub manual_override_until: Option<DateTime<Utc>>,
/// Local quick-thermostat power override. None follows group/global power gates;
/// Some(true) runs this zone locally through the full thermostat; Some(false) keeps it locally off.
/// Local quick-thermostat power override. None follows group/house rules; Some(true) runs
/// this zone locally through the full thermostat; Some(false) keeps it locally off.
#[serde(default)]
pub local_thermostat_power: Option<bool>,
/// Automatic hand-back deadline after the local quick thermostat is switched OFF.
+1 -1
View File
@@ -62,7 +62,7 @@
</div>
<div class="panel dashboard-global-panel">
<div class="dashboard-global-power">
<div><span class="eyebrow" data-i18n="house.powerAllEyebrow">Global power</span><h3 data-i18n="house.powerAllTitle">All air conditioners</h3><p data-i18n="house.powerAllHint">Master power for thermostat zones. Turn all off powers units down without changing group-control settings; turn all on resumes eligible zones.</p></div>
<div><span class="eyebrow" data-i18n="house.powerAllEyebrow">Global power</span><h3 data-i18n="house.powerAllTitle">All air conditioners</h3><p data-i18n="house.powerAllHint">One-shot power command for all units. Thermostats, groups, house rules and manual controls remain independent afterwards.</p></div>
<div class="house-power-actions"><button id="housePowerOn" type="button" class="secondary" data-action="house-power" data-value="true" data-i18n="house.powerOnAll">Turn all on</button><button id="housePowerOff" type="button" class="secondary" data-action="house-power" data-value="false" data-i18n="house.powerOffAll">Turn all off</button></div>
</div>
<div class="dashboard-global-divider"></div>
+3 -3
View File
@@ -44,10 +44,10 @@ function renderHouseClimate() {
const zonePresets = (app.zones || []).map(zone => zone.manual_preset || 'auto');
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 powerEnabled = app.settings.house_power_enabled !== false;
const powerOn = $('#housePowerOn'), powerOff = $('#housePowerOff');
if (powerOn) { powerOn.classList.toggle('active', powerEnabled); powerOn.setAttribute('aria-pressed', String(powerEnabled)); }
if (powerOff) { powerOff.classList.toggle('active', !powerEnabled); powerOff.setAttribute('aria-pressed', String(!powerEnabled)); }
// Global power buttons are one-shot actions, not a persistent ON/OFF mode.
if (powerOn) { powerOn.classList.remove('active'); powerOn.removeAttribute('aria-pressed'); }
if (powerOff) { powerOff.classList.remove('active'); powerOff.removeAttribute('aria-pressed'); }
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></div>
<div class="house-mode-row">
<button class="${mode==='cool'?'active':''}" data-action="house-mode" data-value="cool" aria-pressed="${mode==='cool'}">${esc(tr('mode.cool'))}</button>
+5 -4
View File
@@ -96,7 +96,7 @@ function groupControlStyle(group) {
}
function groupControlForZone(zone) {
if(app.settings?.house_power_enabled === false || zone?.device_manual_override || zone?.local_thermostat_power != null) return null;
if(zone?.device_manual_override || zone?.local_thermostat_power != null) return null;
const source=String(zone?.control_source || '');
if(!source.startsWith('group:')) return null;
const groupId=source.slice(6);
@@ -115,6 +115,7 @@ function compressorPendingDescription(zone) {
const targetValue = Number(target);
if(kind==='mode_change') return tr('zones.queuedModeChange', {mode:modeLabel(mode), temperature:Number.isFinite(targetValue)?targetValue.toFixed(1):'—'});
if(kind==='power_on') return tr('zones.queuedPowerOn', {mode:modeLabel(mode), temperature:Number.isFinite(targetValue)?targetValue.toFixed(1):'—'});
if(kind==='global_power_on') return tr('zones.queuedGlobalPowerOn');
return tr('zones.queuedAction');
}
@@ -158,7 +159,9 @@ function renderCompressorQueueModal() {
const device = app.devices.find(item => item.id === zone.device_id);
const until = zone.compressor_pending_until || zone.lockout_until;
const group = groupControlForZone(zone);
const source = group ? `${zoneControlOwnerLabel(zone)}: ${group.name}` : zoneControlOwnerLabel(zone);
const source = zone.compressor_pending_action === 'global_power_on'
? tr('zones.queueSourceGlobal')
: (group ? `${zoneControlOwnerLabel(zone)}: ${group.name}` : zoneControlOwnerLabel(zone));
const exact = until ? dateTime(until) : tr('zones.afterProtection');
return `<article class="compressor-queue-item">
<div class="compressor-queue-index">${index + 1}</div>
@@ -361,7 +364,6 @@ function groupCard(group, detailed = false) {
const memberNames = state.zones.map(zone => zone.name);
const memberText = memberNames.length ? memberNames.join(' · ') : tr('groups.noMembers');
const powerEnabled = group.power_enabled !== false;
const masterOff = app.settings?.house_power_enabled === 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);
const fallbackTarget = Number(state.zones[0]?.manual_setpoint ?? state.zones[0]?.effective_setpoint ?? state.zones[0]?.setpoint ?? 23);
@@ -383,7 +385,6 @@ function groupCard(group, detailed = false) {
<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}"${climateControlsDisabled}>${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}"${climateControlsDisabled}>${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : (customDraft?.open === true ? 'editing' : '')}" data-action="group-custom-open" data-id="${esc(group.id)}" title="${esc(tr('groups.customOpenHint'))}"${climateControlsDisabled}>${esc(tr('preset.custom'))}</button></div>
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button><button type="button" class="secondary" data-action="group-custom-cancel" data-id="${esc(group.id)}">${esc(tr('actions.cancel'))}</button></div></label>${customDraftPending ? `<small class="group-custom-draft-note">${esc(tr('groups.customDraftPending'))}</small>` : ''}</div></div>
${masterOff ? `<p class="field-note warning-note">${esc(tr('groups.masterOff'))}</p>` : ''}
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`;
}
+1 -1
View File
@@ -94,7 +94,7 @@ document.addEventListener('click', async event => {
}
if (action === 'house-power') {
const power = button.dataset.value === 'true';
if (!power && app.settings?.house_power_enabled !== false && !confirm(tr('house.powerOffConfirm'))) return;
if (!power && !confirm(tr('house.powerOffConfirm'))) return;
try {
button.disabled = true;
const result = await enqueueClimateControlTask(async () => {
+1 -1
View File
@@ -42,7 +42,7 @@ function connectWebSocket() {
if (!haDirty) renderHomeAssistantSettings();
renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); 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 === 'house.power_changed') { renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) 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_received') {