v0.9.3
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.9.2"
|
||||
version = "0.9.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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!({
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user