From d0346dd797eb4e3d3286635906253b1b5aec8d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 1 Sep 2026 14:18:00 +0200 Subject: [PATCH] v0.9.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 64 +++---- README.md | 6 +- docs/API.md | 27 +-- home-assistant/README.md | 4 +- .../gree_controller/manifest.json | 2 +- .../gree_controller/sensor.py | 6 +- .../gree_controller/switch.py | 3 +- lang/en.json | 12 +- lang/pl.json | 12 +- src/api/groups.rs | 2 +- src/api/house.rs | 163 ++++++------------ src/api/public_settings.rs | 2 +- src/api/settings.rs | 11 +- src/api/zones.rs | 8 +- src/engine/automations.rs | 1 - src/engine/control_plan.rs | 10 +- src/engine/groups.rs | 31 ++-- src/engine/ownership.rs | 85 ++++++--- src/engine/targets.rs | 5 +- src/engine/tests.rs | 34 ++++ src/engine/zone_actions.rs | 5 +- src/engine/zone_control.rs | 66 +++++-- src/main.rs | 4 + src/models/control_plan.rs | 2 +- src/models/runtime.rs | 3 +- src/models/zone.rs | 4 +- web/index.html | 2 +- web/js/dashboard.js | 6 +- web/js/entities.js | 9 +- web/js/events.js | 2 +- web/js/realtime.js | 2 +- 33 files changed, 327 insertions(+), 270 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe63eb8..9d392f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.9.2" +version = "0.9.3" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 1bedd26..1d604e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index dd76432..e081fde 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index 9f640d1..573b863 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.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 diff --git a/docs/API.md b/docs/API.md index 111ada9..dc60360 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.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 `8–30°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. diff --git a/home-assistant/README.md b/home-assistant/README.md index 1d06bb2..f4a2c2d 100644 --- a/home-assistant/README.md +++ b/home-assistant/README.md @@ -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. diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 5b9d61e..779a5e6 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.2", + "version": "0.9.3", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/home-assistant/custom_components/gree_controller/sensor.py b/home-assistant/custom_components/gree_controller/sensor.py index 8ecb7da..24ed3f2 100644 --- a/home-assistant/custom_components/gree_controller/sensor.py +++ b/home-assistant/custom_components/gree_controller/sensor.py @@ -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" diff --git a/home-assistant/custom_components/gree_controller/switch.py b/home-assistant/custom_components/gree_controller/switch.py index 02be150..2bd3291 100644 --- a/home-assistant/custom_components/gree_controller/switch.py +++ b/home-assistant/custom_components/gree_controller/switch.py @@ -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: diff --git a/lang/en.json b/lang/en.json index f122dfa..e0e9fc7 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": "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?" diff --git a/lang/pl.json b/lang/pl.json index 3d4dafe..9304683 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": "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?" diff --git a/src/api/groups.rs b/src/api/groups.rs index 22ce3f5..465a61c 100644 --- a/src/api/groups.rs +++ b/src/api/groups.rs @@ -256,7 +256,7 @@ async fn list_home_assistant_groups(State(state): State) -> Result 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 = 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 { - 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, AppError> { let mut failed = Vec::new(); - let enabled_zone_devices: std::collections::HashSet = 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, Json(input): Json, Json(input): Json, Json(input): Json) -> Result, 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 = 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 = 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, Json(input): Json, Json(input): Json = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); @@ -251,11 +187,15 @@ async fn update_house_preset(State(state): State, Json(input): Json, Json(input): Json 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, diff --git a/src/api/settings.rs b/src/api/settings.rs index 8f376f3..eef5532 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -8,11 +8,13 @@ async fn update_settings(State(state): State, Json(mut input): Json 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, Json(mut export): Json = 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()) diff --git a/src/api/zones.rs b/src/api/zones.rs index 27090a7..fb1a5ee 100644 --- a/src/api/zones.rs +++ b/src/api/zones.rs @@ -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(); diff --git a/src/engine/automations.rs b/src/engine/automations.rs index f81ae63..a5f16e7 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -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. diff --git a/src/engine/control_plan.rs b/src/engine/control_plan.rs index 8379bb4..4746df4 100644 --- a/src/engine/control_plan.rs +++ b/src/engine/control_plan.rs @@ -9,7 +9,7 @@ pub async fn build_control_plan(state: &AppState) -> Result Result Result Result 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()), diff --git a/src/engine/groups.rs b/src/engine/groups.rs index e06d876..f0d2956 100644 --- a/src/engine/groups.rs +++ b/src/engine/groups.rs @@ -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; diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index 1920891..1b34913 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -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 Result { - // 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 { + // 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 = 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 { + // 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 = 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 { let _device_guard = state.lock_device_operation(device_id).await; send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await diff --git a/src/engine/targets.rs b/src/engine/targets.rs index f2b71f7..5e80715 100644 --- a/src/engine/targets.rs +++ b/src/engine/targets.rs @@ -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() diff --git a/src/engine/tests.rs b/src/engine/tests.rs index a351779..5715f71 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -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"); diff --git a/src/engine/zone_actions.rs b/src/engine/zone_actions.rs index 82c90b2..c248c75 100644 --- a/src/engine/zone_actions.rs +++ b/src/engine/zone_actions.rs @@ -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 { - 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, 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 diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index 83943a7..7f37a1f 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -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, reason: &str) { +pub(crate) fn queue_compressor_action(zone: &mut Zone, action: String, until: DateTime, 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 diff --git a/src/main.rs b/src/main.rs index 2e57cbf..e26e86d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { diff --git a/src/models/control_plan.rs b/src/models/control_plan.rs index b081f6c..c313c93 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, - /// 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, pub control_strategy: String, diff --git a/src/models/runtime.rs b/src/models/runtime.rs index 436bb94..aee2764 100644 --- a/src/models/runtime.rs +++ b/src/models/runtime.rs @@ -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. diff --git a/src/models/zone.rs b/src/models/zone.rs index e5003f1..c71229c 100644 --- a/src/models/zone.rs +++ b/src/models/zone.rs @@ -86,8 +86,8 @@ pub struct Zone { pub manual_setpoint: Option, #[serde(default)] pub manual_override_until: Option>, - /// 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, /// Automatic hand-back deadline after the local quick thermostat is switched OFF. diff --git a/web/index.html b/web/index.html index eaff810..a538088 100644 --- a/web/index.html +++ b/web/index.html @@ -62,7 +62,7 @@
-
Global power

All air conditioners

Master power for thermostat zones. Turn all off powers units down without changing group-control settings; turn all on resumes eligible zones.

+
Global power

All air conditioners

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

diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 2626f98..d0c1318 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -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 = `
${esc(tr('house.seasonMode'))}

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

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

${esc(tr('house.outdoor'))}${esc(outdoor)}
diff --git a/web/js/entities.js b/web/js/entities.js index cd51c68..9378598 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -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 `
${index + 1}
@@ -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) {
${esc(tr('groups.mode'))}
${['house','heat','cool'].map(mode => ``).join('')}
${esc(tr('groups.profile'))}
${['auto','comfort','sleep','away'].map(preset => ``).join('')}
${customDraftPending ? `${esc(tr('groups.customDraftPending'))}` : ''}
- ${masterOff ? `

${esc(tr('groups.masterOff'))}

` : ''} ${detailed ? `` : ''}
`; } diff --git a/web/js/events.js b/web/js/events.js index 05ffe9a..3a6b428 100644 --- a/web/js/events.js +++ b/web/js/events.js @@ -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 () => { diff --git a/web/js/realtime.js b/web/js/realtime.js index 223d9d8..adaaffe 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -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') {