diff --git a/Cargo.lock b/Cargo.lock index 2c94ca6..a4bc718 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.24" +version = "0.9.0" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 25c4732..afe3504 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index c7803b7..8a0ddc0 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -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 diff --git a/README.md b/README.md index 485693a..eceb140 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. -**Current release: 0.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 diff --git a/docs/API.md b/docs/API.md index a4da02b..a23f05b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,6 @@ # GREE Controller API reference -HTTP and WebSocket API for GREE Controller **0.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. diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 14a1597..c32ed53 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.8.24", + "version": "0.9.0", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index cf7ee38..1981f08 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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" } } diff --git a/lang/pl.json b/lang/pl.json index 954f991..1055d2f 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -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" } } diff --git a/src/api.rs b/src/api.rs index 7633380..cff27c4 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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)) diff --git a/src/api/house.rs b/src/api/house.rs index 9c7bbb7..641dfb1 100644 --- a/src/api/house.rs +++ b/src/api/house.rs @@ -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 = 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 = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); @@ -105,6 +122,7 @@ async fn update_house_control(State(state): State, Json(input): Json, Json(input): Json = zone_snapshot.iter().map(|zone| zone.id.clone()).collect(); @@ -217,6 +236,7 @@ async fn update_house_preset(State(state): State, Json(input): Json = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); diff --git a/src/api/public_settings.rs b/src/api/public_settings.rs index b6ae275..1a4c381 100644 --- a/src/api/public_settings.rs +++ b/src/api/public_settings.rs @@ -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": { diff --git a/src/api/settings.rs b/src/api/settings.rs index 2db18f6..8f376f3 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -37,6 +37,9 @@ async fn update_settings(State(state): State, Json(mut input): Json, Json(mut input): Json = 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, Json(mut export): Json, Path(id): Path, 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, Path(id): Path, 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, Path(id): Path) -> Result, 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) -> Result, 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 = 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}))) +} diff --git a/src/config.rs b/src/config.rs index 9c61ce4..ebb58aa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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; } diff --git a/src/engine/deadlines.rs b/src/engine/deadlines.rs index 60e39d1..109d2c8 100644 --- a/src/engine/deadlines.rs +++ b/src/engine/deadlines.rs @@ -24,6 +24,9 @@ fn next_zone_control_deadline_delay(state: &AppState) -> Result 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 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()) } diff --git a/src/engine/groups.rs b/src/engine/groups.rs index 3eae456..e06d876 100644 --- a/src/engine/groups.rs +++ b/src/engine/groups.rs @@ -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 diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index 2b3ed79..1920891 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -168,6 +168,9 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec 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, 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, diff --git a/src/models/defaults.rs b/src/models/defaults.rs index 0fa13ff..44dcd64 100644 --- a/src/models/defaults.rs +++ b/src/models/defaults.rs @@ -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() } diff --git a/src/models/runtime.rs b/src/models/runtime.rs index 7c02da9..436bb94 100644 --- a/src/models/runtime.rs +++ b/src/models/runtime.rs @@ -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)] diff --git a/src/models/zone.rs b/src/models/zone.rs index 652d0d0..e5003f1 100644 --- a/src/models/zone.rs +++ b/src/models/zone.rs @@ -142,6 +142,18 @@ pub struct Zone { pub lockout_until: Option>, #[serde(default)] pub lockout_reason: Option, + /// 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, + #[serde(default)] + pub compressor_pending_since: Option>, + #[serde(default)] + pub compressor_pending_until: Option>, + /// 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, #[serde(default)] pub effective_mode: String, #[serde(default)] diff --git a/web/index.html b/web/index.html index be9f9a7..060b078 100644 --- a/web/index.html +++ b/web/index.html @@ -87,7 +87,7 @@
Zones

Thermostats

- 0 +
0
@@ -113,7 +113,7 @@
-
Automation

Zones

+
Automation

Zones

Zones define thermostat logic and sensor configuration. Use Quick thermostats on the Dashboard for current temperature changes and presets.

@@ -340,7 +340,12 @@

GREE commands

Only changed properties are sent. Buzzer suppression is also requested when supported by the unit firmware.

-
+
+ + + +

Recommended: 3 minutes. Starts and Heat/Cool changes requested during the protection window are queued and can be cancelled from Thermostats.

+

GREE traffic

Live counters of UDP frames received from configured air conditioners since controller startup.

diff --git a/web/js/entities.js b/web/js/entities.js index cc7e8ad..646f650 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -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 = `
${esc(device.ip)} · ${esc(responseTime)} · ${esc(protocol)}${device.last_error ? `${esc(device.last_error)}` : ''}
`; + 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) {
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}
+ ${compressorQueuePanel(managedZone)} ${zoneLocked ? `
${esc(tr('devices.disabledZoneTechnicalOnly'))}
` : ''}
${modes.map(mode => ``).join('')}
${fans.map(fan => ``).join('')}
@@ -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 `
${esc(tr('zones.compressorQueueTitle'))}${esc(compressorPendingDescription(zone))}${esc(tr('zones.compressorQueueUntil', {time:when}))}
`; +} + 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) {
${esc(tr('zones.smartFan'))}${esc(tr(zone.smart_fan === false ? 'common.off' : 'common.on'))}
${esc(sensorDetails)}
+ ${compressorQueuePanel(zone)} ${manualTakeover} `; @@ -226,6 +252,7 @@ function zoneCard(zone, detailed = true) {
${esc(runtimeStatus)}${controlGroup ? ` ${esc(controlGroup.name)}` : ''}${esc(override)}
${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${esc(zoneControlOwnerMeta(zone))}
+ ${compressorQueuePanel(zone)} ${manualTakeover} `; } @@ -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) { diff --git a/web/js/events.js b/web/js/events.js index c2218d4..3b0a845 100644 --- a/web/js/events.js +++ b/web/js/events.js @@ -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]'); diff --git a/web/js/navigation.js b/web/js/navigation.js index 271812d..5d79418 100644 --- a/web/js/navigation.js +++ b/web/js/navigation.js @@ -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; diff --git a/web/js/realtime.js b/web/js/realtime.js index 9e66253..223d9d8 100644 --- a/web/js/realtime.js +++ b/web/js/realtime.js @@ -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(); } diff --git a/web/js/settings-ui.js b/web/js/settings-ui.js index 22dda33..9589c2c 100644 --- a/web/js/settings-ui.js +++ b/web/js/settings-ui.js @@ -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; diff --git a/web/js/settings.js b/web/js/settings.js index 3a189d1..77c08e4 100644 --- a/web/js/settings.js +++ b/web/js/settings.js @@ -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, diff --git a/web/styles.css b/web/styles.css index 747fa19..6e07de2 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; }