This commit is contained in:
Mateusz Gruszczyński
2026-09-01 13:05:44 +02:00
parent e496f911da
commit 7e0160834d
32 changed files with 486 additions and 67 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.8.24"
version = "0.9.0"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.8.24"
version = "0.9.0"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+32 -32
View File
@@ -1,11 +1,11 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
b38e05f8a00969270280b0062ee129fccee39ae9c11cb614a6ad7b2b7ae2dfc7 ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
05cd9d64999d2d6957aff2a38fd749ba2ec08bc82aba9aca740b7b0775153a56 ./Cargo.lock
cb09462abf591a7126bc40c627b66149bdc662613e7d89e94ba532b251b6c8f2 ./Cargo.toml
71363f41de7b4cd53b99e5c97fc6c6f8d5c1065f8fa4cbd02496c928d31be195 ./Cargo.lock
c68db6fcbaea3aee0864ed78d12d84aa6e669f1865666d97aca7ac530918d75e ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
bd64c474576e2e3e38e14c16c6c281098ad7c10ab2489fda10029de99fd0ccfb ./README.md
0d465ef4bc80d52bbca3357b57d61d18724ca322e930cec47274a61a9ffd5d44 ./README.md
ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs
dcbbaf9f3dce7bd6f5048662f97d68e6c7fe0040389ebbf382a11b690647a7e8 ./docs/API.md
c5e8a97b7b35ff6c14ec3e36aca6a88a02d4791878fffb8f498fd141d65ecc9a ./docs/API.md
a4571b7d31c96be8b3f540574444742f99a208fc0cdf27a14bebad70bc916ab3 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
@@ -16,7 +16,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
83a0ca6c4e90076544b2528790db9be3113b8ca7c5de6e943b70e6cce632d9cb ./home-assistant/custom_components/gree_controller/manifest.json
b13f95ff5b6d2fbf884142360c907ef4e26b44d5097d596624e16b1b554be05d ./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
@@ -25,8 +25,8 @@ c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assista
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
1b6d4dbbc7176abb113996d250dfec4dd1eb66b05b1971a584638b4c1b1cf664 ./lang/en.json
ad75001644c6fbbca092bfac5bf5cedd77c4b2421b69679023c611c19ac65c38 ./lang/pl.json
953f215e80df7896a6b5d1da8c00d8e8d954f7c8aaed488e846cbd461f688cfc ./lang/en.json
b0baaff08e70757841c238c5f689fb054774608ef70a4d43f75cb2ca66cd53d2 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
@@ -39,7 +39,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
ffa4d44d5f9f7a3e0e80e34c61ee510ab0f6b4b80a8d6c8137aae2ea74fbab02 ./src/api.rs
4c8b0d10d83d6130ed6e2703c233ac380b25a6c57fc23a29821f823df90331f9 ./src/api.rs
50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
08a732a445f44d34c644109762ac389b58c96d0fe47b309ce34f4479d67c3712 ./src/api/automations.rs
@@ -48,16 +48,16 @@ ffa4d44d5f9f7a3e0e80e34c61ee510ab0f6b4b80a8d6c8137aae2ea74fbab02 ./src/api.rs
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
aab086f56cbb42d5aa48c04111bdc87637e06e35f2aa1e475f4a6bf06f5dee67 ./src/api/groups.rs
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
3a85cf4d9de30f67538091ab40c7f6ca729da78426a8dd251452366e884f4cb0 ./src/api/house.rs
31359c99592515225779967f2347643b24ff21889875e707abafee570d618f70 ./src/api/house.rs
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
2abb8d155b832bd7bde575b12ef37fbbf09c246255d9abd2adae0d56016ce170 ./src/api/public_settings.rs
d3ddc310a4033e400f521babec02ec06990092d7349c40cd271a3d8406f70f4a ./src/api/public_settings.rs
8f7f0b8c157e9c97d7dc305b79923646ef47d76497abfcf09e9c67bb12af5dd1 ./src/api/schedules.rs
12d4b69478a9bc6dc4857b3c9c5144d96ba33529e68b36cccca67b1093d7cc71 ./src/api/settings.rs
678990ae03c96628927eac90ef0e84b4da4518c78fb5208a5a0980f96b0496ee ./src/api/settings.rs
74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
90f6cdaa688efb0fa4929f28186df47eb5be44b8600fde9a04ee9a28f47b17e4 ./src/api/zones.rs
9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs
9d8ab056dbd2047fd00062e7252c3e9bb972c988a56de23daea61b8be871bd61 ./src/api/zones.rs
2b6eecead75cd4cdb98a9f74dd1c374dbf11ebfa716abbc3476aa12398af9acd ./src/config.rs
369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs
24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs
3de3fa4262be00029dde2344c4cfa41acd4acc0adec176f344761ff35ea730b6 ./src/db/configuration.rs
@@ -72,20 +72,20 @@ a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_
d418f27edde4a8a8ea89817899abfc48df632ec581deacf64db1dc409eef920f ./src/engine/automations.rs
910248f30ac9d697816458cd393c5f7d4bbdd3a12e48cb41cbb3ddb68a1ce395 ./src/engine/commands.rs
d91c50e68baa8f90a30fd94be1fda8cf548ed7386c1058f14f8d5b32625c0b25 ./src/engine/control_plan.rs
47acda24f31ec6af5ac94b00f52e232f0fdd443106abb9eeca7cc28d26b994bd ./src/engine/deadlines.rs
a1afe1d6ef1869ecc7ab01eb43b005e54c58b314ee0f41a34ab4c2afbe03e892 ./src/engine/groups.rs
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
de747652055f222da31b8f671ffdcbe94277ace5082c24b6b579d9bdaa6d16ff ./src/engine/groups.rs
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
dad8930c3d811f02cfeced3aa4b9a404fe273ba1b99539bb174b9430575f3e22 ./src/engine/local_thermostat.rs
059dc7f6fe687a66efa9660538edabbf51e2b053796dc8f70c14e9737a584599 ./src/engine/ownership.rs
ea41d99d69a2b7593df7e243009211a65b227b9a88ffbf4a2badba88a49c01bc ./src/engine/ownership.rs
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
fcd40789045b7cbf168880ff4a7fee12fb4afdc6fd376c8cca9c0cf63390e5a0 ./src/engine/temporary_thermostat.rs
05a5b0eaa9a556dd7a4d775636926647b7dda087c3a270ea3de299958f7e4c91 ./src/engine/tests.rs
c725a0471a5ff840f6624ca494cc970562f591f603d030640e2b27c34cda1f9e ./src/engine/zone_actions.rs
9629836c63a5fc34d86d37407805ea4c90cb6bde8c103dd6ddd50b60c53864d0 ./src/engine/zone_control.rs
31b14b6412fced31aeae92401af2c4a0501063f78b4577224c5df291ad7d3df4 ./src/engine/temporary_thermostat.rs
3fe9825d9ff5f41c9f988489ebe606b4700322e553cbe6cae1bec7c82affd25c ./src/engine/tests.rs
6f4541bad148d172a44cd3ddd22b49dc97e4b755554a7a8820222169090e7022 ./src/engine/zone_actions.rs
856bd291229ae9d0b3f21fb7fd47058b7af5a18bd195e48829f8162533613472 ./src/engine/zone_control.rs
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
@@ -96,13 +96,13 @@ c3ff7232a2fa1d29428872a10cfd72ee60a3a1faaa662eff6c2d8c7dffc1ed3a ./src/main.rs
e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs
fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs
60ff96965cf9531b3964add783a6a45578aa1986844e99a239f182eb4f962d5b ./src/models/control_plan.rs
10b78ee6c225119fd86435928d5269fd9a9b482d3575f073157d5d20e0a17785 ./src/models/defaults.rs
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs
c19f243d709acb59db7bc4a91c02c538146fc5a7c014d90c53e8d8c62c4d6008 ./src/models/runtime.rs
b1af7f8d4c3607b1c0ae978feaab4dfa03faa0154c7328712bb4eeafc27faa02 ./src/models/runtime.rs
e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
98944fff61013ec10aa5fcb3840abe90d10ba7a901c57bab86fc71180a3eef1b ./src/models/zone.rs
0bdc68fc1b3d1fd4d1e1253bd48b5c149cfc9b87e2fedb13288b3ea8a096af3d ./src/models/zone.rs
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
@@ -127,23 +127,23 @@ f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
cf7723c6ab3bb7af3858171e3e100b2327649d7065541980cb2f24b57583f39d ./web/index.html
3c2a8f09e64a7bc3002aee713a0edfb1441e95711de5fb60b9d177e8014192a4 ./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
4edcb49aa9feb11294aeaa7453bfde3f3d0921cf02446bdab3b47c7cf7a38988 ./web/js/entities.js
018c26f2eb33a2b3f1ab55bf1f4f3dec60eb153f1aaa8996513b705b3cd2a050 ./web/js/events.js
2ce40c25e2e0bf4dbdd77f22155fb9ed5f64a7f866dd2fe40df188cdb38183d8 ./web/js/entities.js
77fbf1ad0e0f12a5ca8ea65716fb993cba07bdfe03cd6d43966ae79efb971908 ./web/js/events.js
2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js
be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js
6d4a9f17ff241eaf44e8d2260163d9226fca3c8d6e1a287d31a2c96b976532f9 ./web/js/main.js
a0d9515b5ff693edda8707e08e0b2e604443ddde4be57521bafadb8014b5ee62 ./web/js/navigation.js
d375868ded6ffcd6b18d4c661bb2051e7cc5b136f95d4d68355cdd6e0caaf278 ./web/js/realtime.js
e27d5bb4a1579fd5b1d9762a5067b953004ca8ae63506af4e8c2cad754c0c532 ./web/js/navigation.js
935a9282f8c7a0e1405752931607751cb0d314eabca74e3f1e0b5f21e1b93988 ./web/js/realtime.js
45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js
28ff1324f4495b7bcd431eb94f51012c1b1e558f8acb931ac35b71b48374c200 ./web/js/settings-ui.js
9d44596929f67400f5f09d82330af4452914f2b4729eadcd4f695672d2cd849c ./web/js/settings.js
f9d23d7142ebcd77085ad30b8b5bf927b754b51f10976309d5b0a3f22f3cfafb ./web/js/settings-ui.js
a189f4f7cc253087ac271b3c5b7a82751176032fc0f7040f073cdc451d354e06 ./web/js/settings.js
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
e61c58b8acf64147d39c34b83f051693f6d15b3fc7eb9af8ab2dd46c0839793d ./web/styles.css
7e52b34a45d70714659e18aa92839b0059d67cbbeb2774ee3a59d452421a6139 ./web/styles.css
bc95830ec8273818ffde0a6b50eb3cc9ecbeee65a7a1d3aeff08cde86d05dc7b ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+7 -1
View File
@@ -2,7 +2,7 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
**Current release: 0.8.24**
**Current release: 0.9.0**
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
@@ -131,6 +131,8 @@ Environment values explicitly supplied for supported runtime overrides win over
| `GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS` | `3000` | Discovery timeout. |
| `GREE_CONTROLLER_DISCOVERY_BROADCAST` | `255.255.255.255:7000` | GREE discovery target. |
| `GREE_CONTROLLER_GREE_INTERFACE` | auto | Optional interface name or local IP used for GREE UDP traffic. |
| `GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED` | `true` | Enable controller-side compressor restart/mode-change protection. |
| `GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS` | `180` | Compressor protection window (301800 seconds; 180 seconds recommended). |
Additional environment variables cover history retention, debug, night mode, Home Assistant and InfluxDB. See [`.env.example`](.env.example).
@@ -177,6 +179,10 @@ A **Device** is the physical GREE unit. Device commands are direct/technical com
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.
### Compressor protection
Controller-side compressor protection is enabled by default and uses a 3-minute window. The switch and duration are available under **Settings → GREE**. Thermostat, group and whole-house starts or Heat/Cool reversals that fall inside the window are kept as visible pending tasks instead of being discarded. The pending task is shown on both the thermostat and its device card, can be cancelled individually, and all pending tasks can be cancelled from the Thermostats header. Safety OFF commands remain immediate.
This distinction is important: direct device control intentionally behaves differently from thermostat control.
### Control ownership
+13 -3
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.8.24**.
HTTP and WebSocket API for GREE Controller **0.9.0**.
[← Main documentation](../README.md)
@@ -212,7 +212,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.8.24",
"version": "0.9.0",
"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.8.24",
"version": "0.9.0",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -1479,3 +1479,13 @@ Use that token:
curl -H 'Authorization: Bearer gree_controller_RESTRICTED_TOKEN' \
"$BASE/api/integrations/home-assistant/control-plan"
```
## Compressor protection queue
Runtime settings expose `compressor_protection_enabled` and `compressor_protection_seconds` (301800; default 180). While enabled, thermostat starts and Heat/Cool reversals that fall inside the protection window are represented on the owning zone by `compressor_pending_action`, `compressor_pending_since`, and `compressor_pending_until`.
- `POST /api/zones/:id/compressor-queue/cancel` cancels the currently pending compressor-protection task for one thermostat.
- `POST /api/compressor-queue/cancel-all` cancels all currently pending compressor-protection tasks.
Cancellation suppresses the same pending intent until a new explicit thermostat/group/house command re-arms it, a scheduled Temporary Quick Thermostat session takes ownership, or a different mode/target creates a new intent. Safety OFF commands are not delayed by compressor protection. Direct technical device commands remain immediate manual-control operations.
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.8.24",
"version": "0.9.0",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
+15 -1
View File
@@ -780,6 +780,20 @@
"groups.gateOff": "Group off",
"zones.disabledByGroup": "Disabled by group: {groups}",
"groups.controlToggle": "Group power",
"groups.enableControlFirst": "Turn the group on first."
"groups.enableControlFirst": "Turn the group on first.",
"settings.compressorProtection": "Compressor protection",
"settings.compressorProtectionTime": "Protection time (min)",
"settings.compressorProtectionHint": "Recommended: 3 minutes. Starts and Heat/Cool changes requested during protection are queued and can be cancelled from Thermostats.",
"zones.compressorQueueTitle": "Waiting for compressor protection",
"zones.queuedPowerOn": "Queued: power on · {mode} · {temperature}°C",
"zones.queuedModeChange": "Queued: change mode to {mode} · {temperature}°C",
"zones.queuedAction": "Queued control task",
"zones.compressorQueueUntil": "Earliest execution: {time}",
"zones.afterProtection": "after protection ends",
"zones.cancelAllQueued": "Cancel all waiting",
"zones.queuedCancelled": "Queued task cancelled.",
"zones.noQueuedTask": "There is no queued task to cancel.",
"zones.queuedCancelledAll": "Cancelled queued tasks: {count}.",
"zones.queueCancelledStatus": "Protection task cancelled — waiting for a new command"
}
}
+15 -1
View File
@@ -780,6 +780,20 @@
"groups.gateOff": "Grupa wyłączona",
"zones.disabledByGroup": "Wyłączona przez grupę: {groups}",
"groups.controlToggle": "Zasilanie grupy",
"groups.enableControlFirst": "Najpierw włącz grupę."
"groups.enableControlFirst": "Najpierw włącz grupę.",
"settings.compressorProtection": "Ochrona sprężarki",
"settings.compressorProtectionTime": "Czas ochrony (min)",
"settings.compressorProtectionHint": "Zalecane: 3 minuty. Uruchomienia i zmiany trybu Grzanie/Chłodzenie zlecone podczas ochrony są kolejkowane i można je anulować w Termostatach.",
"zones.compressorQueueTitle": "Oczekuje po ochronie sprężarki",
"zones.queuedPowerOn": "Zakolejkowane: włączenie · {mode} · {temperature}°C",
"zones.queuedModeChange": "Zakolejkowane: zmiana trybu na {mode} · {temperature}°C",
"zones.queuedAction": "Zakolejkowane zadanie sterowania",
"zones.compressorQueueUntil": "Wykonanie najwcześniej: {time}",
"zones.afterProtection": "po zakończeniu ochrony",
"zones.cancelAllQueued": "Anuluj wszystkie oczekujące",
"zones.queuedCancelled": "Zakolejkowane zadanie anulowane.",
"zones.noQueuedTask": "Brak oczekującego zadania do anulowania.",
"zones.queuedCancelledAll": "Anulowano oczekujące zadania: {count}.",
"zones.queueCancelledStatus": "Zadanie po ochronie anulowane — oczekuje na nowe polecenie"
}
}
+2
View File
@@ -71,6 +71,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/zones", get(list_zones).post(create_zone))
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
.route("/api/zones/:id/control", post(update_zone_control))
.route("/api/zones/:id/compressor-queue/cancel", post(cancel_zone_compressor_queue))
.route("/api/compressor-queue/cancel-all", post(cancel_all_compressor_queues))
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
.route("/api/groups", get(list_groups).post(create_group))
.route("/api/groups/:id", get(get_group).put(update_group).delete(delete_group))
+20
View File
@@ -1,6 +1,23 @@
#[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String }
async fn rearm_all_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();
for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; }
engine::rearm_compressor_queue(&mut zone);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
}
async fn clear_group_control_sources(state: &AppState, reason: &str) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
@@ -105,6 +122,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
};
state.broadcast("settings.updated", payload.clone());
clear_group_control_sources(&state, "Whole-house mode control took ownership").await?;
rearm_all_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 {
@@ -141,6 +159,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
// 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?;
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();
@@ -217,6 +236,7 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
};
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?;
let schedules = state.db.list_schedules()?;
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
+2
View File
@@ -14,6 +14,8 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
"history_compaction_enabled": settings.history_compaction_enabled,
"event_log_retention_days": settings.event_log_retention_days,
"suppress_device_beep": settings.suppress_device_beep,
"compressor_protection_enabled": settings.compressor_protection_enabled,
"compressor_protection_seconds": settings.compressor_protection_seconds,
"debug": settings.debug,
"night_mode": settings.night_mode,
"notifications": {
+31 -1
View File
@@ -37,6 +37,9 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
let compressor_settings_changed = input.compressor_protection_enabled != old.compressor_protection_enabled
|| input.compressor_protection_seconds != old.compressor_protection_seconds;
normalize_sensor_aliases(&mut input);
canonicalize_home_assistant_entities(&mut input);
validate_night_mode(&mut input)?;
@@ -52,7 +55,29 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
canonicalize_saved_zone_entities(&state, &input).await?;
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = input.clone();
state.log("info", "settings.updated", "Settings updated", json!({}));
if compressor_settings_changed {
// A changed protection policy invalidates old deadlines. Clear transient tasks under
// the same control-cycle exclusion; the next thermostat pass re-evaluates intent
// against the new enabled flag/duration.
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; }
engine::clear_compressor_pending(&mut zone, true);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
}
state.log("info", "settings.updated", "Settings updated", json!({
"compressor_protection_enabled": input.compressor_protection_enabled,
"compressor_protection_seconds": input.compressor_protection_seconds
}));
state.broadcast("settings.updated", public_settings(&input));
state.wake_zone_control();
Ok(Json(public_settings(&input)))
@@ -299,6 +324,10 @@ fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
zone.last_mode_change_at = None;
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.compressor_cancelled_action = None;
zone.effective_mode.clear();
zone.effective_setpoint = None;
zone.device_setpoint = None;
@@ -319,6 +348,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
validate_configuration_export(&export)?;
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
export.settings.compressor_protection_seconds = export.settings.compressor_protection_seconds.clamp(30, 1800);
normalize_sensor_aliases(&mut export.settings);
canonicalize_home_assistant_entities(&mut export.settings);
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
+75
View File
@@ -109,6 +109,7 @@ impl ZoneInput {
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "zone created".into(),
last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
compressor_pending_action: None, compressor_pending_since: None, compressor_pending_until: None, compressor_cancelled_action: None,
effective_mode: String::new(), effective_setpoint: None, device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None,
created_at, updated_at: Utc::now(),
@@ -208,6 +209,10 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
zone.last_mode_change_at = existing.last_mode_change_at;
zone.lockout_until = existing.lockout_until;
zone.lockout_reason = existing.lockout_reason;
zone.compressor_pending_action = existing.compressor_pending_action;
zone.compressor_pending_since = existing.compressor_pending_since;
zone.compressor_pending_until = existing.compressor_pending_until;
zone.compressor_cancelled_action = existing.compressor_cancelled_action;
zone.effective_mode = existing.effective_mode;
zone.effective_setpoint = existing.effective_setpoint;
zone.device_setpoint = existing.device_setpoint;
@@ -234,6 +239,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
}
engine::reset_local_thermostat_override(&mut zone);
engine::reset_device_manual_override(&mut zone);
engine::rearm_compressor_queue(&mut zone);
zone.enabled = false;
}
state.db.save_zone(&zone)?;
@@ -263,6 +269,13 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let mut zone = state.db.get_zone(id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let was_enabled = zone.enabled;
let schedules = state.db.list_schedules()?;
let rearm_compressor = patch.power.is_some() || patch.setpoint.is_some() || patch.mode.is_some()
|| patch.enabled.is_some() || patch.preset.is_some() || patch.clear_override.unwrap_or(false)
|| patch.clear_device_manual_override.unwrap_or(false) || patch.clear_local_thermostat_override.unwrap_or(false)
|| patch.temporary_quick_thermostat.is_some() || patch.clear_temporary_quick_thermostat.unwrap_or(false);
if rearm_compressor {
engine::rearm_compressor_queue(&mut zone);
}
let resume_device_takeover = patch.clear_device_manual_override.unwrap_or(false);
let resume_local_thermostat = patch.clear_local_thermostat_override.unwrap_or(false);
let stop_temporary_quick_thermostat = patch.clear_temporary_quick_thermostat.unwrap_or(false);
@@ -692,3 +705,65 @@ async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
}
}
async fn cancel_zone_compressor_queue(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Value>, AppError> {
// Serialize cancellation with the thermostat cycle. If cancellation wins this lock, the
// pending action cannot expire and execute between the UI click and the persisted marker.
let _cycle_guard = state.lock_zone_control_cycle().await;
let _zone_guard = state.lock_zone_operation(&id).await;
let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let cancelled = zone.compressor_pending_action.clone();
if let Some(action) = cancelled.clone() {
zone.compressor_cancelled_action = Some(action);
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.lockout_until = None;
zone.lockout_reason = None;
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)?);
state.log("info", "zone.compressor_queue_cancelled", &format!("Cancelled compressor-protection task for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "action": cancelled.clone()
}));
}
if cancelled.is_some() { state.wake_zone_control(); }
Ok(Json(json!({"cancelled": cancelled.is_some(), "action": cancelled, "zone": zone})))
}
async fn cancel_all_compressor_queues(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
// Stop the thermostat arbiter while taking all zone locks, so a task cannot expire and
// execute between discovering it and persisting the cancellation marker.
let _cycle_guard = state.lock_zone_control_cycle().await;
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); }
let mut cancelled = 0usize;
let mut zones = Vec::new();
for zone_id in &zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
let Some(action) = zone.compressor_pending_action.clone() else { continue; };
zone.compressor_cancelled_action = Some(action.clone());
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.lockout_until = None;
zone.lockout_reason = None;
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)?);
state.log("info", "zone.compressor_queue_cancelled", &format!("Cancelled compressor-protection task for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "action": action, "source": "cancel_all"
}));
cancelled += 1;
zones.push(zone);
}
if cancelled > 0 { state.wake_zone_control(); }
Ok(Json(json!({"cancelled": cancelled, "zones": zones})))
}
+4
View File
@@ -60,6 +60,8 @@ impl Config {
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true),
event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false),
compressor_protection_enabled: env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED").unwrap_or(true),
compressor_protection_seconds: env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS").unwrap_or(180).clamp(30, 1800),
influxdb: influx_settings_from_env(),
debug: DebugSettings {
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
@@ -98,6 +100,8 @@ impl Config {
settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(settings.event_log_retention_days).clamp(1, 3650);
}
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED") { settings.compressor_protection_enabled = value; }
if let Some(value) = env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") { settings.compressor_protection_seconds = value.clamp(30, 1800); }
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; }
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { settings.night_mode.enabled = value; }
+9 -1
View File
@@ -24,6 +24,9 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>
if local_thermostat_handback_is_active(zone) {
if let Some(at) = zone.local_thermostat_resume_at.clone() { deadlines.push(at); }
}
if zone.compressor_pending_action.is_some() {
if let Some(at) = zone.compressor_pending_until.clone().or(zone.lockout_until.clone()) { deadlines.push(at); }
}
if let Some(at) = temporary_quick_thermostat_wakeup_at(zone, now.clone()) { deadlines.push(at); }
if let Some(at) = next_schedule_boundary_utc(&zone.id, &schedules, local_now.clone()) { deadlines.push(at); }
}
@@ -31,8 +34,13 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result<Option<Duration>
if let Some(at) = next_time_automation_utc(automation, local_now.clone()) { deadlines.push(at); }
}
// Ignore already-expired deadlines here. The control cycle that just ran had the
// opportunity to consume them; if another prerequisite (offline sensor/device, manual
// ownership, etc.) prevents execution, the normal thermostat interval should retry
// instead of creating a zero-delay busy loop.
Ok(deadlines.into_iter()
.map(|at| (at - now.clone()).to_std().unwrap_or(Duration::ZERO))
.filter(|at| at > &now)
.filter_map(|at| (at - now.clone()).to_std().ok())
.min())
}
+3
View File
@@ -92,6 +92,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
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 patch.power.is_some() || climate_change {
rearm_compressor_queue(&mut zone);
}
let mut temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now());
// A user/HA group power click is an explicit scoped takeover. End any older direct
+3
View File
@@ -168,6 +168,9 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec<Str
}
zone.demand = false;
zone.demand_since = None;
// Direct/manual takeover supersedes any thermostat task that was waiting behind
// compressor protection; never leave a stale queued badge behind.
rearm_compressor_queue(zone);
zone.updated_at = now;
state.db.save_zone(zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?);
+8
View File
@@ -91,6 +91,9 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule]
}
}
refresh_zone_runtime_target(zone, schedules, house_mode);
// The temporary session has ended and ownership/intent changes again. A cancellation
// that belonged to the temporary intent must not suppress the restored thermostat state.
rearm_compressor_queue(zone);
true
}
@@ -221,6 +224,11 @@ async fn activate_due_temporary_quick_thermostats(
continue;
}
// A scheduled temporary session becoming active is a new explicit thermostat intent.
// Replace any older cancelled/pending compressor task so this session can queue its
// own start or mode change against the current protection window.
rearm_compressor_queue(zone);
// Temporary ownership is independent from the ordinary local hand-back state.
zone.local_thermostat_power = Some(true);
zone.local_thermostat_resume_at = None;
+27 -1
View File
@@ -114,7 +114,7 @@ mod tests {
current_temperature: None, control_temperature_source: "device".into(), active_preset: "comfort".into(),
manual_preset: None, manual_setpoint: None, manual_override_until: None, local_thermostat_power: None, local_thermostat_resume_at: None, local_thermostat_restore_zone_enabled: None, temporary_quick_thermostat: None,
device_manual_override: false, device_manual_override_since: None, device_manual_override_until: None, device_manual_override_fields: Vec::new(), device_manual_override_baseline: None,
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None,
revision: 1, control_owner: "automation".into(), control_source: "automation".into(), control_since: Some(Utc::now()), control_resume_at: None, control_reason: "test".into(), last_power_change_at: None, last_mode_change_at: None, lockout_until: None, lockout_reason: None, compressor_pending_action: None, compressor_pending_since: None, compressor_pending_until: None, compressor_cancelled_action: None,
effective_mode: "heat".into(), effective_setpoint: Some(21.0), device_setpoint: None,
demand: false, demand_since: None, target_alerted_at: None, last_action_at: None, created_at: Utc::now(), updated_at: Utc::now(),
}
@@ -690,6 +690,32 @@ mod tests {
assert_eq!(gree_outdoor_temperature(&[a, b, c]), Some(12.0));
}
#[test]
fn compressor_queue_helpers_track_and_clear_pending_intent() {
let mut zone = test_zone("device");
let until = Utc::now() + chrono::Duration::seconds(180);
let action = compressor_action_id("power_on", "cool", 23.04);
assert_eq!(action, "power_on:cool:23.0");
queue_compressor_action(&mut zone, action.clone(), until.clone(), "minimum_off_before_start");
assert_eq!(zone.compressor_pending_action.as_deref(), Some(action.as_str()));
assert!(zone.compressor_pending_since.is_some());
assert_eq!(zone.compressor_pending_until, Some(until.clone()));
assert_eq!(zone.lockout_until, Some(until));
assert_eq!(zone.lockout_reason.as_deref(), Some("minimum_off_before_start"));
zone.compressor_cancelled_action = Some(action.clone());
clear_compressor_pending(&mut zone, false);
assert!(zone.compressor_pending_action.is_none());
assert_eq!(zone.compressor_cancelled_action.as_deref(), Some(action.as_str()));
rearm_compressor_queue(&mut zone);
assert!(zone.compressor_cancelled_action.is_none());
assert!(zone.lockout_until.is_none());
assert!(zone.lockout_reason.is_none());
}
#[test]
fn outdoor_assist_is_bounded_and_direction_neutral() {
let cool = outdoor_assist_offset("cool", Some(36.0), 27.0, 23.0);
+3
View File
@@ -109,6 +109,9 @@ async fn apply_automatic_device_action(
}
if domain_changed {
// A fresh automation climate command is a new explicit intent. Re-arm a queue that
// may have been cancelled for an older request so protection can defer this new one.
rearm_compressor_queue(&mut zone);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
zone.control_source = "automation.device".into();
+92 -16
View File
@@ -1,3 +1,35 @@
fn compressor_action_id(kind: &str, mode: &str, target: f64) -> String {
format!("{kind}:{mode}:{target:.1}")
}
pub(crate) fn clear_compressor_pending(zone: &mut Zone, clear_cancelled: bool) {
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
if clear_cancelled { zone.compressor_cancelled_action = None; }
}
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);
}
zone.compressor_pending_action = Some(action);
zone.compressor_pending_until = Some(until.clone());
zone.lockout_until = Some(until);
zone.lockout_reason = Some(reason.to_string());
}
fn compressor_action_is_cancelled(zone: &Zone, action: &str) -> bool {
zone.compressor_cancelled_action.as_deref() == Some(action)
}
pub(crate) fn rearm_compressor_queue(zone: &mut Zone) {
clear_compressor_pending(zone, true);
}
async fn control_zones(state: &AppState) -> Result<()> {
let _cycle_guard = state.lock_zone_control_cycle().await;
let schedules = state.db.list_schedules()?;
@@ -173,6 +205,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
}
clear_compressor_pending(&mut zone, true);
zone.demand = false;
zone.demand_since = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
@@ -184,6 +217,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
// A technically disabled device is outside thermostat ownership. Do not create
// repeated command errors while keeping any available external sensor data visible.
if !device.enabled {
clear_compressor_pending(&mut zone, true);
zone.demand = false;
zone.demand_since = None;
zone.device_setpoint = None;
@@ -197,6 +231,9 @@ async fn control_zones(state: &AppState) -> Result<()> {
// automation control. Continue sensor/history updates, but reflect the unit's real state
// instead of sending corrective frames that would fight the person holding the remote.
if zone.device_manual_override {
// Direct/manual ownership and the thermostat compressor queue are mutually
// exclusive. Clean any stale persisted task before remaining passive.
clear_compressor_pending(&mut zone, true);
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
// selected profile/target. Keep the intended target visible and report the physical
// unit target separately through device_setpoint. This makes Resume/Profile actions
@@ -419,30 +456,44 @@ async fn control_zones(state: &AppState) -> Result<()> {
device.sleep,
);
// Compressor protection for automatic ownership. Direct/manual commands and global safety OFF
// deliberately bypass this path, while the thermostat never performs an immediate Heat<->Cool swap.
// Global compressor protection. Automatic thermostat/group/house requests are not
// discarded while the protection window is active: they become a visible pending
// task which can be cancelled from the thermostat UI. Safety OFF paths still bypass
// protection. A cancelled task is not silently re-created until a new control intent
// re-arms the queue (or a different mode/target produces a different task id).
let now = Utc::now();
if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
if !settings.compressor_protection_enabled {
clear_compressor_pending(&mut zone, true);
} else if zone.lockout_until.map(|until| until <= now).unwrap_or(false) {
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_until = None;
}
if device.power && device.mode != effective_mode {
let min_on = chrono::Duration::seconds(zone.min_on_seconds as i64);
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_on).unwrap_or(false) {
let until = zone.last_power_change_at.map(|at| at + min_on);
zone.lockout_until = until;
zone.lockout_reason = Some("minimum_on_before_mode_change".into());
let protection = chrono::Duration::seconds(settings.compressor_protection_seconds as i64);
if settings.compressor_protection_enabled && device.power && device.mode != effective_mode {
let action = compressor_action_id("mode_change", effective_mode, desired_device_target);
if compressor_action_is_cancelled(&zone, &action) {
clear_compressor_pending(&mut zone, false);
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;
}
if let Some(last_change) = zone.last_power_change_at {
if now.signed_duration_since(last_change) < protection {
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_on_before_mode_change");
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;
}
}
match send_zone_command_if_owned(state, &zone.id, &zone.device_id, DeviceCommand { power: Some(false), ..Default::default() }).await {
Ok(Some(_)) => {
zone.last_power_change_at = Some(now);
zone.lockout_until = Some(now + chrono::Duration::seconds(zone.min_off_seconds as i64));
zone.lockout_reason = Some("mode_change_off_delay".into());
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until}));
queue_compressor_action(&mut zone, action, now + protection, "mode_change_off_delay");
state.log("info", "zone.mode_change_lockout", &format!("Zone {} switched off before {} mode", zone.name, effective_mode), json!({"zone_id": zone.id, "resume_at": zone.lockout_until, "compressor_protection_enabled": settings.compressor_protection_enabled}));
}
Ok(None) => {}
Err(err) => state.log("error", "zone.mode_change_off_error", &err.to_string(), json!({"zone_id": zone.id})),
@@ -453,18 +504,42 @@ async fn control_zones(state: &AppState) -> Result<()> {
continue;
}
if !device.power {
let min_off = chrono::Duration::seconds(zone.min_off_seconds as i64);
if zone.last_power_change_at.map(|at| now.signed_duration_since(at) < min_off).unwrap_or(false) {
zone.lockout_until = zone.last_power_change_at.map(|at| at + min_off);
zone.lockout_reason = Some("minimum_off_before_start".into());
let action = zone.compressor_pending_action.clone()
.filter(|value| value.starts_with("mode_change:"))
.unwrap_or_else(|| compressor_action_id("power_on", effective_mode, desired_device_target));
if compressor_action_is_cancelled(&zone, &action) {
clear_compressor_pending(&mut zone, false);
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;
}
if settings.compressor_protection_enabled {
if let Some(last_change) = zone.last_power_change_at {
if now.signed_duration_since(last_change) < protection {
queue_compressor_action(&mut zone, action, last_change + protection, "minimum_off_before_start");
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;
}
}
}
// The safe window is open. Remove the pending marker before attempting the
// command; a transport error will be retried by the normal thermostat cycle.
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.lockout_until = None;
zone.lockout_reason = None;
} else {
// Reaching the intended powered/mode state retires both pending and cancelled
// markers so a future independent request starts with a clean queue.
clear_compressor_pending(&mut zone, true);
}
let core_needs_command = !device.power
|| device.mode != effective_mode
|| (device.target_temperature - desired_device_target).abs() >= 0.5;
// In normal standby, Low fan is a transition hint rather than a state that should
// be reasserted forever. Some GREE firmwares accept the frame but later report Auto
@@ -496,6 +571,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
if device.mode != updated_device.mode { zone.last_mode_change_at = Some(transition_at); }
zone.device_setpoint = if updated_device.power { Some(updated_device.target_temperature) } else { None };
zone.last_action_at = Some(transition_at);
clear_compressor_pending(&mut zone, true);
state.log("info", "zone.setpoint_modulation", &format!("Zone {} -> {:.1} C ({})", zone.name, desired_device_target, if zone.demand { "demand" } else { "standby" }), json!({
"zone_id": zone.id,
"room_temperature": temp,
+1
View File
@@ -9,6 +9,7 @@ fn default_external_sensor_weight() -> f64 { 0.4 }
fn default_max_sensor_difference() -> f64 { 3.0 }
fn default_control_temperature_source() -> String { "device".into() }
fn default_min_cycle() -> u64 { 180 }
fn default_compressor_protection_seconds() -> u64 { 180 }
fn default_sensor_stale_after() -> u64 { 300 }
fn default_cooldown() -> u64 { 300 }
fn default_house_mode() -> String { "cool".into() }
+7
View File
@@ -28,6 +28,13 @@ pub struct RuntimeSettings {
/// Add protocol-specific buzzer suppression fields to command frames.
#[serde(default)]
pub suppress_device_beep: bool,
/// Global thermostat compressor protection. When enabled, automatic starts and
/// Heat/Cool reversals are delayed instead of being sent during the protection window.
#[serde(default = "default_true")]
pub compressor_protection_enabled: bool,
/// Global compressor protection window in seconds. The UI exposes this as minutes.
#[serde(default = "default_compressor_protection_seconds")]
pub compressor_protection_seconds: u64,
#[serde(default)]
pub influxdb: InfluxDbSettings,
#[serde(default)]
+12
View File
@@ -142,6 +142,18 @@ pub struct Zone {
pub lockout_until: Option<DateTime<Utc>>,
#[serde(default)]
pub lockout_reason: Option<String>,
/// Human/API-visible compressor-protection task waiting for its safe execution window.
/// Values are stable identifiers such as `power_on:cool:23.0` or `mode_change:heat:21.0`.
#[serde(default)]
pub compressor_pending_action: Option<String>,
#[serde(default)]
pub compressor_pending_since: Option<DateTime<Utc>>,
#[serde(default)]
pub compressor_pending_until: Option<DateTime<Utc>>,
/// The last explicitly cancelled pending action. The same thermostat intent is suppressed
/// until a new control request changes/re-arms it; this prevents an immediate re-queue.
#[serde(default)]
pub compressor_cancelled_action: Option<String>,
#[serde(default)]
pub effective_mode: String,
#[serde(default)]
+8 -3
View File
@@ -87,7 +87,7 @@
<section class="panel dashboard-section-panel">
<div class="dashboard-section-head compact-head">
<div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h2 data-i18n="dashboard.quickThermostats">Thermostats</h2></div>
<span class="badge" id="dashboardZoneCount">0</span>
<div class="dashboard-section-actions"><button type="button" class="secondary" id="cancelAllCompressorTasks" data-compressor-cancel-all data-action="cancel-all-compressor-tasks" data-i18n="zones.cancelAllQueued" hidden>Cancel all waiting</button><span class="badge" id="dashboardZoneCount">0</span></div>
</div>
<div class="list-grid dashboard-zones" id="dashboardZones"></div>
</section>
@@ -113,7 +113,7 @@
</section>
<section class="view" data-view="zones">
<div class="section-heading"><div><span class="eyebrow" data-i18n="zones.automation">Automation</span><h1 data-i18n="nav.zones">Zones</h1></div><button class="secondary" data-open="zoneDialog" data-i18n="zones.new">New zone</button></div>
<div class="section-heading"><div><span class="eyebrow" data-i18n="zones.automation">Automation</span><h1 data-i18n="nav.zones">Zones</h1></div><div class="section-heading-actions"><button type="button" class="secondary" data-compressor-cancel-all data-action="cancel-all-compressor-tasks" data-i18n="zones.cancelAllQueued" hidden>Cancel all waiting</button><button class="secondary" data-open="zoneDialog" data-i18n="zones.new">New zone</button></div></div>
<p class="lead" data-i18n="zones.description">Zones define thermostat logic and sensor configuration. Use Quick thermostats on the Dashboard for current temperature changes and presets.</p>
<div class="list-grid" id="zoneList"></div>
</section>
@@ -340,7 +340,12 @@
</section>
<section class="panel settings-block">
<div class="settings-block-head"><div><h3 data-i18n="settings.greeCommands">GREE commands</h3><p data-i18n="settings.suppressBeepHint">Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.</p></div></div>
<div class="settings-grid"><label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label></div>
<div class="settings-grid">
<label class="check wide"><input type="checkbox" name="suppress_device_beep"> <span data-i18n="settings.suppressBeep">Try to suppress command beeps</span></label>
<label class="check wide"><input type="checkbox" name="compressor_protection_enabled"> <span data-i18n="settings.compressorProtection">Compressor protection</span></label>
<label><span data-i18n="settings.compressorProtectionTime">Protection time (min)</span><input type="number" name="compressor_protection_minutes" min="0.5" max="30" step="0.5" value="3"></label>
<p class="field-note wide" data-i18n="settings.compressorProtectionHint">Recommended: 3 minutes. Starts and Heat/Cool changes requested during the protection window are queued and can be cancelled from Thermostats.</p>
</div>
</section>
<section class="panel settings-block">
<div class="settings-block-head"><div><h3 data-i18n="settings.greeTraffic">GREE traffic</h3><p data-i18n="settings.greeTrafficHint">Live counters of UDP frames received from configured air conditioners since controller startup.</p></div></div>
+34 -2
View File
@@ -14,6 +14,10 @@ function deviceZoneDisabled(deviceId) {
return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false);
}
function zoneForDevice(deviceId) {
return app.zones.find(zone => zone.device_id === deviceId) || null;
}
function deviceCard(device, detailed = false) {
const modes = ['auto','cool','dry','fan','heat'];
const fans = [0,1,3,5];
@@ -21,6 +25,7 @@ function deviceCard(device, detailed = false) {
const responseTime = hasResponseTime ? `${Math.max(0, Math.round(Number(device.response_time_ms)))} ms` : '— ms';
const protocol = device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto');
const networkInfo = `<div class="device-network-info"><small>${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}</small>${device.last_error ? `<small class="device-network-error" title="${esc(device.last_error)}">${esc(device.last_error)}</small>` : ''}</div>`;
const managedZone = app.zones.find(zone => zone.device_id === device.id && zone.compressor_pending_action) || zoneForDevice(device.id);
const zoneLocked = !detailed && deviceZoneDisabled(device.id);
const locked = zoneLocked ? ' disabled' : '';
const quickClass = detailed ? '' : ' quick-control-card quick-device-control';
@@ -36,6 +41,7 @@ function deviceCard(device, detailed = false) {
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}"${locked}>+</button>
</div>
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
${compressorQueuePanel(managedZone)}
${zoneLocked ? `<div class="field-note warning-note">${esc(tr('devices.disabledZoneTechnicalOnly'))}</div>` : ''}
<div class="mode-row${quickRow}${detailed ? '' : ' quick-control-row-5'}">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}"${locked}>${esc(modeLabel(mode))}</button>`).join('')}</div>
<div class="fan-row${quickRow}${detailed ? '' : ' quick-control-row-4'}">${fans.map(fan => `<button class="${Number(device.fan_speed) === fan ? 'active' : ''}" data-action="fan" data-value="${fan}" data-device="${esc(device.id)}"${locked}>${esc(fanLabel(fan))}</button>`).join('')}</div>
@@ -102,6 +108,24 @@ function zoneLockoutActive(zone) {
return Number.isFinite(until) && until>Date.now();
}
function compressorPendingDescription(zone) {
const action=String(zone?.compressor_pending_action || '');
if(!action) return '';
const [kind, mode, target] = action.split(':');
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):'—'});
return tr('zones.queuedAction');
}
function compressorQueuePanel(zone) {
if(!zone?.compressor_pending_action) return '';
const until=zone.compressor_pending_until || zone.lockout_until;
const untilMs=until ? new Date(until).getTime() : NaN;
const when=Number.isFinite(untilMs) && untilMs>Date.now() ? dateTime(until) : tr('zones.afterProtection');
return `<div class="compressor-queue-panel"><div><strong>${esc(tr('zones.compressorQueueTitle'))}</strong><span>${esc(compressorPendingDescription(zone))}</span><small>${esc(tr('zones.compressorQueueUntil', {time:when}))}</small></div><button type="button" class="secondary" data-action="cancel-compressor-task" data-id="${esc(zone.id)}">${esc(tr('actions.cancel'))}</button></div>`;
}
function zoneControlOwnerLabel(zone) {
const owner=zone.control_owner || (zone.device_manual_override ? 'direct_manual' : (zone.local_thermostat_power != null ? 'local_thermostat' : 'automation'));
const source=zone.control_source || '';
@@ -134,7 +158,8 @@ function zoneRuntimeStatusLabel(zone, effectiveMode, device = null) {
if (device?.enabled === false) return tr('zones.waitingDeviceDisabled');
if (device && (!device.online || Number(device.communication_failures || 0) > 0)) return tr('zones.waitingOffline');
if (zone.current_temperature == null) return tr('zones.noMeasurement');
if (zone.demand && zoneLockoutActive(zone)) return tr('zones.waitingLockout');
if (zone.compressor_pending_action && zoneLockoutActive(zone)) return tr('zones.waitingLockout');
if (zone.compressor_cancelled_action && device && (!device.power || (['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode))) return tr('zones.queueCancelledStatus');
if (zone.demand && device && !device.power) return tr('zones.waitingStart');
if (zone.demand && device?.power && ['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode) return tr('zones.waitingMode');
return zone.demand ? tr('zones.runningDemand') : tr('zones.satisfied');
@@ -158,7 +183,7 @@ function zoneCard(zone, detailed = true) {
const deviceManualOverride = zone.device_manual_override === true;
const controlGroup = groupControlForZone(zone);
const deviceUnavailable = !!device && (device.enabled === false || !device.online || Number(device.communication_failures || 0) > 0);
const lockoutWaiting = zone.demand && zoneLockoutActive(zone);
const lockoutWaiting = !!zone.compressor_pending_action && zoneLockoutActive(zone);
const startWaiting = zone.demand && !!device && !deviceUnavailable && !device.power;
const modeWaiting = zone.demand && !!device?.power && !deviceUnavailable && ['heat','cool'].includes(effectiveMode) && device.mode !== effectiveMode;
const visualGroup = controlGroup;
@@ -211,6 +236,7 @@ function zoneCard(zone, detailed = true) {
<div><small>${esc(tr('zones.smartFan'))}</small><strong>${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}</strong></div>
</div>
<div class="sensor-detail">${esc(sensorDetails)}</div>
${compressorQueuePanel(zone)}
${manualTakeover}
<div class="card-footer"><small>${esc(tr('zones.controlOnDashboard'))}</small><div class="card-menu"><button class="primary" data-action="zone-go-control" data-id="${esc(zone.id)}">${esc(tr('zones.controlNow'))}</button><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
</article>`;
@@ -226,6 +252,7 @@ function zoneCard(zone, detailed = true) {
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'&&globalModeAvailable?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
<div class="zone-state-line"><span class="zone-runtime-status" title="${esc(runtimeStatus)}">${esc(runtimeStatus)}${controlGroup ? ` <b class="group-control-tag">${esc(controlGroup.name)}</b>` : ''}</span><span>${esc(override)}</span></div>
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
${compressorQueuePanel(zone)}
${manualTakeover}
</article>`;
}
@@ -237,6 +264,11 @@ function renderZones() {
if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty;
const dashboardCount = $('#dashboardZoneCount');
if (dashboardCount) dashboardCount.textContent = String(app.zones.length);
const pendingCount = app.zones.filter(zone => !!zone.compressor_pending_action).length;
$$('[data-compressor-cancel-all]').forEach(cancelAll => {
cancelAll.hidden = pendingCount === 0;
cancelAll.textContent = pendingCount ? `${tr('zones.cancelAllQueued')} (${pendingCount})` : tr('zones.cancelAllQueued');
});
}
function groupZones(group) {
+3
View File
@@ -144,6 +144,8 @@ document.addEventListener('click', async event => {
if(!Number.isFinite(value) || value<8 || value>30) return toast(tr('groups.customTemperatureRange'),true);
return sendGroupControl(button.dataset.id,{preset:'custom',setpoint:Math.round(value*10)/10});
}
if (action === 'cancel-compressor-task') return cancelCompressorTask(button.dataset.id);
if (action === 'cancel-all-compressor-tasks') return cancelAllCompressorTasks();
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
@@ -210,6 +212,7 @@ $('#languageSelect').addEventListener('change', event => setLanguage(event.targe
$('#themeSelect')?.addEventListener('change', event => setTheme(event.target.value));
$('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs);
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
$('#settingsForm [name=compressor_protection_enabled]')?.addEventListener('change', updateCompressorProtectionFields);
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
document.addEventListener('input', event => {
const input=event.target.closest?.('[data-group-custom-temperature]');
+25
View File
@@ -107,6 +107,31 @@ async function sendZoneControl(id, patch) {
}
async function cancelCompressorTask(id) {
try {
const result = await api(`/api/zones/${encodeURIComponent(id)}/compressor-queue/cancel`, {method:'POST'});
if (result.zone) {
const index = app.zones.findIndex(item => item.id === result.zone.id);
if (index >= 0) app.zones[index] = result.zone; else app.zones.push(result.zone);
}
renderAll(); scheduleControlPlanLoad();
toast(tr(result.cancelled ? 'zones.queuedCancelled' : 'zones.noQueuedTask'));
} catch (error) { toast(error.message, true); }
}
async function cancelAllCompressorTasks() {
try {
const result = await api('/api/compressor-queue/cancel-all', {method:'POST'});
(result.zones || []).forEach(zone => {
const index = app.zones.findIndex(item => item.id === zone.id);
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
});
renderAll(); scheduleControlPlanLoad();
toast(tr('zones.queuedCancelledAll', {count:Number(result.cancelled || 0)}));
} catch (error) { toast(error.message, true); }
}
function queueZoneTemperature(zone, value, {snapToHalf=true}={}) {
const clamped = clamp(value, 8, 30);
const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10;
+1 -1
View File
@@ -29,7 +29,7 @@ function connectWebSocket() {
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.deleted') { app.zones=app.zones.filter(v=>v.id!==data.id); renderAll(); scheduleControlPlanLoad(); }
else if (['group.updated','group.created'].includes(message.event)) { const i=app.groups.findIndex(v=>v.id===data.id); if(i>=0) app.groups[i]=data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'group.deleted') { app.groups=app.groups.filter(v=>v.id!==data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
+9
View File
@@ -48,6 +48,9 @@ function renderSettings() {
form.event_log_retention_days.value = app.settings.event_log_retention_days || 30;
form.history_compaction_enabled.checked = app.settings.history_compaction_enabled !== false;
form.suppress_device_beep.checked = !!app.settings.suppress_device_beep;
form.compressor_protection_enabled.checked = app.settings.compressor_protection_enabled !== false;
form.compressor_protection_minutes.value = (Number(app.settings.compressor_protection_seconds || 180) / 60).toFixed(1).replace(/\.0$/, '');
updateCompressorProtectionFields();
form.influx_enabled.checked = !!app.settings.influxdb?.enabled;
form.influx_version.value = String(app.settings.influxdb?.version || '2');
form.influx_threshold_days.value = app.settings.influxdb?.history_threshold_days || 30;
@@ -89,6 +92,12 @@ function renderSettings() {
markFormClean(form);
}
function updateCompressorProtectionFields() {
const form = $('#settingsForm');
if (!form?.compressor_protection_enabled || !form?.compressor_protection_minutes) return;
form.compressor_protection_minutes.disabled = !form.compressor_protection_enabled.checked;
}
function renderSimulationModeBanner() {
const banner = $('#simulationModeBanner');
if (!banner) return;
+4
View File
@@ -18,6 +18,8 @@ function currentSettingsBody() {
history_compaction_enabled: settings.history_compaction_enabled !== false,
event_log_retention_days: Number(settings.event_log_retention_days || 30),
suppress_device_beep: !!settings.suppress_device_beep,
compressor_protection_enabled: settings.compressor_protection_enabled !== false,
compressor_protection_seconds: Number(settings.compressor_protection_seconds || 180),
notifications: {
enabled: !!settings.notifications?.enabled, mode: settings.notifications?.mode || 'problems', provider: settings.notifications?.provider || 'pushover',
pushover_app_token: '', pushover_user_key: '', slack_webhook_url: '', discord_webhook_url: '',
@@ -82,6 +84,8 @@ function settingsBodyFromForm(form) {
body.history_compaction_enabled = form.history_compaction_enabled.checked;
body.event_log_retention_days = Number(raw.event_log_retention_days);
body.suppress_device_beep = form.suppress_device_beep.checked;
body.compressor_protection_enabled = form.compressor_protection_enabled.checked;
body.compressor_protection_seconds = Math.max(30, Math.min(1800, Math.round(Number(raw.compressor_protection_minutes || 3) * 60)));
body.influxdb = {
enabled: form.influx_enabled.checked, version: raw.influx_version, url: raw.influx_url,
database: raw.influx_database, username: raw.influx_username, password: raw.influx_password,
+18 -1
View File
@@ -1386,7 +1386,7 @@ textarea[aria-invalid="true"] {
.group-custom-temperature-row button { grid-column:auto; }
}
/* v0.8.24: group OFF powers members down but releases group ownership; OFF groups do not membership-block thermostats. */
/* Group OFF powers members down but releases group ownership; OFF groups do not membership-block thermostats. */
.list-card.group-card.group-linked {
border-color:color-mix(in srgb,var(--group-control-color) 42%,var(--line));
}
@@ -1404,3 +1404,20 @@ textarea[aria-invalid="true"] {
border-color:var(--group-control-color);
box-shadow:0 0 0 1px color-mix(in srgb,var(--group-control-color) 30%,transparent);
}
/* v0.9.0: visible/cancellable compressor-protection queue. */
.compressor-queue-panel {
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
margin-top:10px;
padding:10px 12px;
border:1px dashed var(--warning, #b7791f);
border-radius:12px;
background:color-mix(in srgb, var(--warning, #b7791f) 9%, transparent);
}
.compressor-queue-panel > div { display:grid; gap:2px; min-width:0; }
.compressor-queue-panel strong { font-size:.88rem; }
.compressor-queue-panel span,.compressor-queue-panel small { overflow-wrap:anywhere; }
.compressor-queue-panel button { flex:0 0 auto; }