v0.9.0
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.8.24"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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 (30–1800 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
@@ -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` (30–1800; 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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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})))
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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]');
|
||||
|
||||
@@ -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
@@ -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(); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user