From 9f08d7ccf959435a67f5839e99604fd8276eebf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 1 Sep 2026 11:10:28 +0200 Subject: [PATCH] v0.8.20 --- Cargo.lock | 2 +- Cargo.toml | 2 +- FILE_MANIFEST.sha256 | 58 ++++----- README.md | 2 +- docs/API.md | 6 +- .../gree_controller/manifest.json | 2 +- lang/en.json | 18 ++- lang/pl.json | 18 ++- src/api/automations.rs | 14 +- src/api/devices.rs | 7 +- src/api/groups.rs | 3 + src/api/house.rs | 76 +++++++++-- src/api/schedules.rs | 11 +- src/api/settings.rs | 3 + src/api/zones.rs | 6 + src/db/climate.rs | 45 +++++++ src/engine/automations.rs | 26 ++++ src/engine/commands.rs | 18 +-- src/engine/groups.rs | 33 ++++- src/engine/ownership.rs | 57 ++++---- src/engine/tests.rs | 9 ++ src/engine/zone_actions.rs | 3 + src/engine/zone_control.rs | 28 +++- src/main.rs | 1 + src/state.rs | 6 + web/js/core.js | 2 +- web/js/entities.js | 123 ++++++++++++------ web/js/events.js | 57 +++++--- web/js/navigation.js | 24 +++- web/styles.css | 49 +++++++ 30 files changed, 559 insertions(+), 150 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b432aaf..58c04ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.8.19" +version = "0.8.20" dependencies = [ "aes", "aes-gcm", diff --git a/Cargo.toml b/Cargo.toml index 7f427ac..d1ea6a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.8.19" +version = "0.8.20" 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 3ad05d5..b616830 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,11 +1,11 @@ d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore -6efa62dde5c1b169e60a30bcfaa43ec20c7ada84546f58d33d61e3aba7e56ed1 ./Cargo.lock -245b9ca11d543ab2bf475e46d24f076f9ac80f4f9f0726f0dcb8356e3099af61 ./Cargo.toml +343a3349537c0c0488191341d57d63ea5e1a67bd8f39fc96ad28c69b95944357 ./Cargo.lock +c5a820ab63403ef204822f8601998f7e357e2c8c092d7398e540c100b777f492 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -030f04d570b57bb7d427007125230564fc85ed9037a22908f231a51c9375e7f3 ./README.md +0109b2ab7ad8cf745e841caf0761ad53157752935ea5937ca4c884f543aa6e84 ./README.md ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs -4cc13a859ec285cabcf339a9c817c0aef6da32ce2b79e1c7af43d793e514ece7 ./docs/API.md +e9d12c54954d0b273b87a74ee0d5bf1100245b0c1c56b0fcc8b615d70e9b8edd ./docs/API.md 95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./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 -85200f0f91e27e74549f8a7ca9892a6481b328381f15fe51659a5f1efb0c96f5 ./home-assistant/custom_components/gree_controller/manifest.json +f9ce5f36c8204716d0e43933c08bf31f7b21a468abe437c9d792c2728d4a9cd8 ./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 -78bc797f28939a0e0372fe497db44d53a26d31d3a0cae593d2a0319ab1205085 ./lang/en.json -c24adedfe0f66f8518ed5d175de853582000900b8a732e9dac84ebce4ff0c616 ./lang/pl.json +e627dfceebea91e7b7b01279960165020993fcb9f632117862e23d36b225c1c5 ./lang/en.json +5b7acd24f8b0f8f20044c4657270fe8eef1097afecd6f7dbe5a4168de227c8d3 ./lang/pl.json d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh @@ -42,24 +42,24 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda ffa4d44d5f9f7a3e0e80e34c61ee510ab0f6b4b80a8d6c8137aae2ea74fbab02 ./src/api.rs 50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs -387bd4e2d9f73ad19acadf388a898d45c8039c4f6cf364681ce3402f0a684f38 ./src/api/automations.rs +08a732a445f44d34c644109762ac389b58c96d0fe47b309ce34f4479d67c3712 ./src/api/automations.rs 2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs -d048cc3bff5b1817a4155308c9bdc92644bbbf2bf6d75f3d531c51a26035276a ./src/api/devices.rs +4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs 417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs -242ba0d2267ce009e90d3e593110a116c7ccfb755b676bab44067110f3db92e2 ./src/api/groups.rs +aab086f56cbb42d5aa48c04111bdc87637e06e35f2aa1e475f4a6bf06f5dee67 ./src/api/groups.rs a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs -53cb0c85729961d3665840b278889444bc8e5008d3e671922ab41a30b05636dd ./src/api/house.rs +8bdeed0880211cb782bdad48bf65335fe8b3da12a17513e50282358457caf76c ./src/api/house.rs 0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs 2abb8d155b832bd7bde575b12ef37fbbf09c246255d9abd2adae0d56016ce170 ./src/api/public_settings.rs -e47da65c53ac271ea1f3ed21eb939f6ff1d6e9d87271de194c6b05fa63bd26d8 ./src/api/schedules.rs -0f1c14e611db08e33b88e467fd7957b0b0479cd2ee25ea424696cb2039238b88 ./src/api/settings.rs +8f7f0b8c157e9c97d7dc305b79923646ef47d76497abfcf09e9c67bb12af5dd1 ./src/api/schedules.rs +9392cc0859bb94fe87742d25200e74c654eea6774ca32b4a3656abf2e1f05114 ./src/api/settings.rs 74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs 681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs -d1d6b49ab6051d6b16aa4648606781843824d41af1b727bf8b12b86b44ea4706 ./src/api/zones.rs +0ed177691a5780a9db4af9101872151c28292059b76407a97b18fe9034c74c33 ./src/api/zones.rs 9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs 369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs -ae0f7635ba0523c1be980a28314d45445be1354444bcf74a6397da1d6d7c79df ./src/db/climate.rs +24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs 3de3fa4262be00029dde2344c4cfa41acd4acc0adec176f344761ff35ea730b6 ./src/db/configuration.rs 56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs d8a2b323e864f2ec38ee15e3023be37a6aa05387f1ddbeedafe1f51556ad8c7d ./src/db/device_history.rs @@ -69,30 +69,30 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event 35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs 952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs -808a65705b31a0887873601c8066958d3b6dc6e876e5b85949ba9cea5a49ddff ./src/engine/automations.rs -832fe59c59a0ab1a0d949b656b194eab7aae578fa1eb30c6d55753d97a508c3b ./src/engine/commands.rs +65e2df80013c57c46f1e0d30ee84016024487557a26e499e08070507319b536a ./src/engine/automations.rs +910248f30ac9d697816458cd393c5f7d4bbdd3a12e48cb41cbb3ddb68a1ce395 ./src/engine/commands.rs 3967cc60a108c71bd7e5d6a99c7caf32ed8f6cbbb63282a5a5d3357e7c07f933 ./src/engine/control_plan.rs 47acda24f31ec6af5ac94b00f52e232f0fdd443106abb9eeca7cc28d26b994bd ./src/engine/deadlines.rs -fabb590ab74c37d6d75b8bcae30b16900dcb6cd97df30937308f765ec5d36528 ./src/engine/groups.rs +cb1fe22b11acdf6a593a196c4fa8f778bfe81765807f9e30416e3ca7fa9ef594 ./src/engine/groups.rs 18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs dad8930c3d811f02cfeced3aa4b9a404fe273ba1b99539bb174b9430575f3e22 ./src/engine/local_thermostat.rs -2e91d677fa8bef74cd5a56c46b72b3d101b07629724d124b0bbfcc4e7bafaa30 ./src/engine/ownership.rs +2110d961b20ec85356a89ed742f318e9dfca36cd61d3e9b9eb0537bb17d3271c ./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 -5efbf22c557679fbf88c8c1ade75bc8a65070c92ee69bd23ce1e72f6c40a0b6a ./src/engine/tests.rs -8333115324fea27f52908a00766a56d31138fa2d63574c9ca20d9ea27977d059 ./src/engine/zone_actions.rs -9f95022773670a9c369bac90cf1514a13b289cbd7b497d8faf91d0fe6b33c353 ./src/engine/zone_control.rs +05a5b0eaa9a556dd7a4d775636926647b7dda087c3a270ea3de299958f7e4c91 ./src/engine/tests.rs +970d5e7783827bb016bef6ec9727d2d74650fa0e157ccebc5348ff107ecc5446 ./src/engine/zone_actions.rs +43343463167e7d3c3601c91765005619bf934dc8af7ca2afde4f8806b4ffbe5b ./src/engine/zone_control.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs 6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs 553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs 5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs -4d45200b03d43d1869760b7cf3281776d268bbaa7860167ea2a533d2f4f12d3d ./src/main.rs +c3ff7232a2fa1d29428872a10cfd72ee60a3a1faaa662eff6c2d8c7dffc1ed3a ./src/main.rs e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs 9a100f778d1f71697dca7ae4db9991a1b7b2da4f26e006ce0612f98c5406acec ./src/models/control_plan.rs @@ -123,7 +123,7 @@ cd5d0befe9f51a7067201a6eaa8d8ed72182d359e42cbdc67c80aa12d722654b ./src/queries/ 2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs -5a6d9134310c801a6378598b2057ac1369f8e7c0f42b3eedd8352bb3e5c222ec ./src/state.rs +6ee23fcaa62231d15006db078d4f763d1b67d2365fcc2d736d8a7da8df5ea382 ./src/state.rs b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg @@ -131,19 +131,19 @@ e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon. 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js 64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js -c14eb49105d83d02f4ca77ee68044ee18304e3a074fb69c3124cf00b45d75c69 ./web/js/core.js +82da7a69028e159ef9b72ba32b111916e9dc21618f8c7c0e410e3492773e7c12 ./web/js/core.js 6e421388c7f6deb2aa42f1205dfdc52839f9f178fbd672129972528b8517a870 ./web/js/dashboard.js -e8f1d1966acad8864af001e92e02044c629692f42ab26df700d962b010f4090b ./web/js/entities.js -44957aa415047f7cce6f55cf4f1f8ea1439159a27512e8426792d4192074e89a ./web/js/events.js +362af3668dc529667edc654214c0e53911bb43f6831da274c52515e1f98a279d ./web/js/entities.js +8c94a3b4aa5455638332362379b09dc151cdf47ab24774c9fd52100ecad689c5 ./web/js/events.js 2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js 6d4a9f17ff241eaf44e8d2260163d9226fca3c8d6e1a287d31a2c96b976532f9 ./web/js/main.js -900f1b083bfcef7782a35d1e708d1cd9fd975d89c03d828290400a85b1006826 ./web/js/navigation.js +a0d9515b5ff693edda8707e08e0b2e604443ddde4be57521bafadb8014b5ee62 ./web/js/navigation.js d375868ded6ffcd6b18d4c661bb2051e7cc5b136f95d4d68355cdd6e0caaf278 ./web/js/realtime.js 45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js 28ff1324f4495b7bcd431eb94f51012c1b1e558f8acb931ac35b71b48374c200 ./web/js/settings-ui.js 9d44596929f67400f5f09d82330af4452914f2b4729eadcd4f695672d2cd849c ./web/js/settings.js fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest -a27f36816edbe82780cac97683ed574cbd4e5e8f782df3eadf930dfbdbd25fd5 ./web/styles.css +ce1c0ba9ebc5307a61548cb5faa36f9d1129df45075fc86ad7c7ff4179830281 ./web/styles.css 03ac3a5918539904ae829ee24999d8d59efb41bdf6c0e30f76bab4cee98c3130 ./web/sw.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js diff --git a/README.md b/README.md index a5e4a50..078a006 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.19** +**Current release: 0.8.20** > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. diff --git a/docs/API.md b/docs/API.md index 09b3e5c..b72dc9c 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.19**. +HTTP and WebSocket API for GREE Controller **0.8.20**. [← Main documentation](../README.md) @@ -212,7 +212,7 @@ Response: { "status": "ok", "name": "gree-controller", - "version": "0.8.19", + "version": "0.8.20", "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.19", + "version": "0.8.20", "uptime_seconds": 1234, "auth_required": false, "control_ready": true, diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 86e1a1d..a63b91e 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.19", + "version": "0.8.20", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index 1d4cbb7..6f9412d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -599,7 +599,7 @@ "zones.coolOnly": "Cooling only", "zones.heatOnly": "Heating only", "zones.manualDeviceControl": "Manual control active", - "zones.manualDeviceDescription": "A setting change was detected directly on the air conditioner or remote. Automation for this zone is paused.", + "zones.manualDeviceDescription": "Manual control has priority. Automation, schedules and group control for this zone stay paused until you explicitly choose Resume automation.", "zones.manualDeviceUntil": "Manual control / remote until {time}", "zones.manualDeviceNoBoundary": "Manual control / remote · until resumed", "zones.resumeAutomation": "Resume automation", @@ -760,10 +760,22 @@ "debug.emptyGree": "No GREE frames captured yet.", "simulation.bannerTitle": "SIMULATION MODE", "simulation.bannerText": "Simulation mode is enabled. Treat current states and results as test data.", - "zones.separateHysteresisHint": "Enable to use independent hysteresis values for heating and cooling.", + "zones.separateHysteresisHint": "Enable independent hysteresis values for heating and cooling. When disabled, one shared value is used.", "groups.customTemperature": "Custom temperature", "groups.customTemperatureHint": "Enter the target temperature for every zone in this group.", "groups.applyCustomTemperatureHint": "Set this temperature as the manual target for the whole group.", - "groups.customTemperatureRange": "Group temperature must be between 8 and 30°C." + "groups.customTemperatureRange": "Group temperature must be between 8 and 30°C.", + "groups.activeControl": "Group control", + "groups.customOpenHint": "Open a custom temperature. The group changes only after you press Apply.", + "zones.ownerGroup": "Group control", + "zones.waitingLockout": "Waiting — compressor protection", + "zones.blockedByGroup": "Blocked by disabled group: {group}", + "zones.waitingStart": "Waiting for the unit to start", + "zones.waitingGroup": "Waiting — blocked by group: {groups}", + "zones.runningDemand": "Running", + "groups.customDraftPending": "Not applied. The group changes profile only after you press Apply.", + "zones.waitingOffline": "Waiting — unit offline or communication error", + "zones.waitingDeviceDisabled": "Waiting — device is technically disabled", + "zones.waitingMode": "Waiting for the unit mode to change" } } diff --git a/lang/pl.json b/lang/pl.json index a9adee8..a5158bc 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -599,7 +599,7 @@ "zones.coolOnly": "Tylko chłodzenie", "zones.heatOnly": "Tylko grzanie", "zones.manualDeviceControl": "Sterowanie ręczne aktywne", - "zones.manualDeviceDescription": "Wykryto zmianę ustawień bezpośrednio na klimatyzatorze lub pilotem. Automatyka tej strefy jest wstrzymana.", + "zones.manualDeviceDescription": "Sterowanie ręczne ma priorytet. Automatyka, harmonogramy i sterowanie grupowe tej strefy są wstrzymane do ręcznego wybrania „Wznów automatykę”.", "zones.manualDeviceUntil": "Sterowanie ręczne / pilot do {time}", "zones.manualDeviceNoBoundary": "Sterowanie ręczne / pilot · do wznowienia", "zones.resumeAutomation": "Wznów automatykę", @@ -760,10 +760,22 @@ "debug.emptyGree": "Brak przechwyconych ramek GREE.", "simulation.bannerTitle": "TRYB SYMULACJI", "simulation.bannerText": "Tryb symulacji jest włączony. Traktuj bieżące stany i wyniki jako testowe.", - "zones.separateHysteresisHint": "Włącz, aby używać niezależnej histerezy dla grzania i chłodzenia.", + "zones.separateHysteresisHint": "Włącz, aby ustawiać niezależną histerezę dla grzania i chłodzenia. Po wyłączeniu używana jest jedna wspólna wartość.", "groups.customTemperature": "Własna temperatura", "groups.customTemperatureHint": "Wpisz temperaturę docelową dla wszystkich stref w tej grupie.", "groups.applyCustomTemperatureHint": "Ustaw tę temperaturę jako ręczny cel dla całej grupy.", - "groups.customTemperatureRange": "Temperatura grupy musi mieścić się w zakresie 8–30°C." + "groups.customTemperatureRange": "Temperatura grupy musi mieścić się w zakresie 8–30°C.", + "groups.activeControl": "Sterowanie grupowe", + "groups.customOpenHint": "Otwórz własną temperaturę. Ustawienie zmieni grupę dopiero po kliknięciu „Zastosuj”.", + "zones.ownerGroup": "Sterowanie grupą", + "zones.waitingLockout": "Oczekuje — ochrona sprężarki", + "zones.blockedByGroup": "Zablokowana przez wyłączoną grupę: {group}", + "zones.waitingStart": "Oczekuje na uruchomienie jednostki", + "zones.waitingGroup": "Oczekuje — blokada grupy: {groups}", + "zones.runningDemand": "Praca aktywna", + "groups.customDraftPending": "Nie zastosowano. Grupa zmieni profil dopiero po kliknięciu „Zastosuj”.", + "zones.waitingOffline": "Oczekuje — jednostka offline lub błąd komunikacji", + "zones.waitingDeviceDisabled": "Oczekuje — urządzenie jest wyłączone technicznie", + "zones.waitingMode": "Oczekuje na zmianę trybu jednostki" } } diff --git a/src/api/automations.rs b/src/api/automations.rs index 237adb7..43b351f 100644 --- a/src/api/automations.rs +++ b/src/api/automations.rs @@ -101,8 +101,18 @@ fn validate_automation_references(state: &AppState, input: &AutomationInput) -> if state.db.get_group(group_id)?.is_none() { return Err(AppError::BadRequest("automation action group does not exist".into())); } - } else if state.db.get_device(input.action_device_id.trim())?.is_none() { - return Err(AppError::BadRequest("automation action device does not exist".into())); + } else { + let device_id = input.action_device_id.trim(); + if state.db.get_device(device_id)?.is_none() { + return Err(AppError::BadRequest("automation action device does not exist".into())); + } + if engine::automation_action_conflicts_with_thermostat(&input.action) + && state.db.list_zones()?.iter().any(|zone| zone.enabled && zone.device_id == device_id) + { + return Err(AppError::BadRequest( + "direct fan/quiet/sleep automation conflicts with an enabled thermostat zone; use thermostat/group policy instead".into(), + )); + } } Ok(()) } diff --git a/src/api/devices.rs b/src/api/devices.rs index 5e90095..eb0e3f7 100644 --- a/src/api/devices.rs +++ b/src/api/devices.rs @@ -144,16 +144,21 @@ async fn patch_device(State(state): State, Path(id): Path, Jso device.updated_at = Utc::now(); state.db.save_device(&device)?; state.broadcast("device.updated", serde_json::to_value(&device)?); + // Enabling or changing a thermostat device should be reflected by the arbiter without + // waiting for the periodic loop. The device lock above keeps the edit ordered against + // polling and an in-flight thermostat command. + state.wake_zone_control(); Ok(Json(device)) } async fn delete_device(State(state): State, Path(id): Path) -> Result { let _configuration_guard = state.lock_configuration_operation().await; // Keep reference validation and the destructive DB operation in one serialized window. - // Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> zones -> device. + // Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> cycle -> zones -> device. let _automation_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; let _schedule_guard = state.lock_schedule_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); } if state.db.list_automations()?.iter().any(|item| { item.trigger_device_id.as_deref() == Some(id.as_str()) diff --git a/src/api/groups.rs b/src/api/groups.rs index c9d4dac..22ce3f5 100644 --- a/src/api/groups.rs +++ b/src/api/groups.rs @@ -45,6 +45,7 @@ async fn create_group(State(state): State, Json(input): Json, Path(id): Path, Jso // automation execution/reference validation before taking the group lock. let _automation_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; let _group_guard = state.lock_group_operation(&id).await; let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?; let zone_ids = validate_group_input(&state, &input)?; @@ -88,6 +90,7 @@ async fn delete_group(State(state): State, Path(id): Path) -> let _configuration_guard = state.lock_configuration_operation().await; let _automation_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; let _group_guard = state.lock_group_operation(&id).await; if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) { return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into())); diff --git a/src/api/house.rs b/src/api/house.rs index dad69fb..435ae29 100644 --- a/src/api/house.rs +++ b/src/api/house.rs @@ -1,6 +1,28 @@ #[derive(Debug, Deserialize)] struct HouseControlPatch { mode: String } + +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(); + zone_ids.sort(); + zone_ids.dedup(); + let mut guards = Vec::with_capacity(zone_ids.len()); + for zone_id in &zone_ids { guards.push(state.lock_zone_operation(zone_id).await); } + for zone_id in &zone_ids { + let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; + if !zone.control_source.starts_with("group:") { continue; } + zone.control_source = "automation".into(); + zone.control_since = Some(Utc::now()); + zone.control_reason = reason.to_string(); + zone.revision = zone.revision.saturating_add(1); + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + } + drop(guards); + Ok(()) +} + async fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> { let mut group_ids: Vec = state.db.list_groups()?.into_iter().map(|group| group.id).collect(); group_ids.sort(); @@ -80,6 +102,10 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source async fn update_house_control(State(state): State, Json(input): Json) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; + // Serialize the ownership/configuration transition against an already-running thermostat + // cycle. Otherwise a cycle that captured the previous house mode could send one stale + // climate command after this interactive change. + let cycle_guard = state.lock_zone_control_cycle().await; if !matches!(input.mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); } @@ -96,10 +122,17 @@ async fn update_house_control(State(state): State, Json(input): Json, Json(input): Json) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; + let cycle_guard = state.lock_zone_control_cycle().await; // Whole-house power is independent from the thermostat mode. Publish/persist the master // first so the regulator becomes passive before the one-shot OFF cascade starts. { @@ -127,6 +161,7 @@ async fn update_house_power(State(state): State, Json(input): Json = zone_snapshot.iter().map(|zone| zone.id.clone()).collect(); @@ -149,10 +184,20 @@ async fn update_house_power(State(state): State, Json(input): Json Vec::new(), + Err(err) => { + state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_power"})); + vec![json!({"scope":"thermostat_cycle","error":err.to_string()})] + } + } } else { - command_all_enabled_devices_power(&state, false, "house_power").await? + // Keep the cycle excluded through the one-shot safety OFF cascade. + let failed = command_all_enabled_devices_power(&state, false, "house_power").await?; + drop(cycle_guard); + failed }; let devices = state.db.list_devices()?; @@ -178,6 +223,7 @@ struct HousePresetPatch { preset: String } async fn update_house_preset(State(state): State, Json(input): Json) -> Result, AppError> { let _house_guard = state.lock_house_operation().await; + let cycle_guard = state.lock_zone_control_cycle().await; if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") { return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into())); } @@ -192,6 +238,7 @@ async fn update_house_preset(State(state): State, Json(input): Json = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect(); @@ -226,10 +273,18 @@ async fn update_house_preset(State(state): State, Json(input): Json = Vec::new(); - state.wake_zone_control(); + // The immediate regulator cycle takes the same per-zone locks; release the batch guards + // after the profile update is fully persisted to preserve the global zone -> device order. + drop(_zone_guards); + drop(cycle_guard); + + // Apply the whole-house profile before returning so every eligible thermostat gets the + // same arbitration cycle and no member is left waiting behind the periodic interval. + let mut failed: Vec = Vec::new(); + if let Err(err) = engine::run_zone_control_now(&state).await { + state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_preset"})); + failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()})); + } let devices = state.db.list_devices()?; state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({ "preset": input.preset, @@ -251,6 +306,7 @@ struct ScheduleTemplateRequest { template: String } async fn apply_schedule_template(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _schedule_guard = state.lock_schedule_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut items: Vec = Vec::new(); let mut add = |name: &str, days: Vec, start: &str, end: &str, preset: &str| { @@ -294,6 +350,7 @@ async fn apply_schedule_template(State(state): State, Path(id): Path, Path(id): Path) -> R let _automation_guard = state.lock_automation_operation().await; let _house_guard = state.lock_house_operation().await; let _schedule_guard = state.lock_schedule_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; let zone_guard = state.lock_zone_operation(&id).await; let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut removed = std::collections::HashSet::new(); diff --git a/src/api/schedules.rs b/src/api/schedules.rs index 760b2ab..9b7731c 100644 --- a/src/api/schedules.rs +++ b/src/api/schedules.rs @@ -71,7 +71,13 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu if (zone.manual_preset.is_some() || zone.manual_setpoint.is_some()) && !temporary_owns_zone { zone.manual_override_until = boundary; } - if zone.device_manual_override { zone.device_manual_override_until = boundary; zone.control_resume_at = boundary; } + // Editing schedules must never arm an expiry for direct device takeover. Manual device + // control is intentionally persistent until the user explicitly resumes automation (or + // whole-house OFF performs the global safety reset). + if zone.device_manual_override { + zone.device_manual_override_until = None; + zone.control_resume_at = None; + } if has_temporary_schedule_boundary { let reference = zone.temporary_quick_thermostat.as_ref() .filter(|session| session.activated_at.is_none()) @@ -96,6 +102,7 @@ async fn get_schedule(State(state): State, Path(id): Path) -> async fn create_schedule(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _schedule_guard = state.lock_schedule_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; input.validate()?; if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now()); @@ -109,6 +116,7 @@ async fn create_schedule(State(state): State, Json(input): Json, Path(id): Path, Json(input): Json) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _schedule_guard = state.lock_schedule_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; input.validate()?; let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } @@ -125,6 +133,7 @@ async fn update_schedule(State(state): State, Path(id): Path, async fn delete_schedule(State(state): State, Path(id): Path) -> Result { let _configuration_guard = state.lock_configuration_operation().await; let _schedule_guard = state.lock_schedule_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); } refresh_zone_override_boundary(&state, &existing.zone_id).await?; diff --git a/src/api/settings.rs b/src/api/settings.rs index ad2a7ef..94f9165 100644 --- a/src/api/settings.rs +++ b/src/api/settings.rs @@ -6,6 +6,7 @@ async fn get_settings(State(state): State) -> Json { async fn update_settings(State(state): State, Json(mut input): Json) -> Result, AppError> { let _configuration_guard = state.lock_configuration_operation().await; let _house_guard = state.lock_house_operation().await; + let _cycle_guard = state.lock_zone_control_cycle().await; let old = state.settings.read().await.clone(); if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode { return Err(AppError::BadRequest( @@ -53,6 +54,7 @@ async fn update_settings(State(state): State, Json(mut input): Json, Json(mut export): Json, Json(input): Json canonicalize_zone_ha_entity(&mut zone, &settings); state.db.save_zone(&zone)?; state.broadcast("zone.created", serde_json::to_value(&zone)?); + state.wake_zone_control(); Ok((StatusCode::CREATED, Json(zone))) } async fn update_zone(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { @@ -241,10 +242,15 @@ async fn update_zone(State(state): State, Path(id): Path, Json if power_off_device { power_off_zone_device(&state, &zone, "zone.disabled").await; } + state.wake_zone_control(); Ok(Json(zone)) } async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result { + // A quick preset/setpoint derives its resume boundary from schedules. Take the schedule + // lock before the per-zone lock so a concurrent schedule edit cannot leave an override + // pointing at an obsolete boundary (and so lock order stays schedule -> zone -> device). + let _schedule_guard = state.lock_schedule_operation().await; // Serialize quick-thermostat changes with the same device lock used by GREE polling and // manual-takeover detection. Without this, a poll that started just before a Web/HA // thermostat action could save an older zone snapshot afterwards and resurrect a false diff --git a/src/db/climate.rs b/src/db/climate.rs index 67897f6..02b36c1 100644 --- a/src/db/climate.rs +++ b/src/db/climate.rs @@ -50,3 +50,48 @@ impl Db { } } + +impl Db { + /// Merge device transition timestamps into zone JSON without replacing newer zone state. + /// `updated_at` acts as a compare-and-swap token but is intentionally not changed here: + /// thermostat cycles use it to detect real configuration/ownership updates. + pub fn merge_zone_device_transition_timestamps( + &self, + device_id: &str, + power_changed: bool, + mode_changed: bool, + at: DateTime, + ) -> Result> { + if !power_changed && !mode_changed { return Ok(Vec::new()); } + let zone_ids: Vec = self.list_zones()?.into_iter() + .filter(|zone| zone.device_id == device_id) + .map(|zone| zone.id) + .collect(); + let mut updated = Vec::with_capacity(zone_ids.len()); + for zone_id in zone_ids { + let mut saved = false; + for _ in 0..8 { + let Some(mut zone) = self.get_zone(&zone_id)? else { break; }; + let expected_updated_at = zone.updated_at.to_rfc3339(); + if power_changed { zone.last_power_change_at = Some(at); } + if mode_changed { zone.last_mode_change_at = Some(at); } + let payload = Self::to_json(&zone)?; + let conn = self.lock()?; + let changed = conn.execute( + "UPDATE zones SET payload = ?1 WHERE id = ?2 AND updated_at = ?3", + params![payload, zone.id, expected_updated_at], + )?; + drop(conn); + if changed > 0 { + updated.push(zone); + saved = true; + break; + } + } + if !saved && self.get_zone(&zone_id)?.is_some() { + return Err(anyhow::anyhow!("zone {zone_id} kept changing while device transition timestamps were merged")); + } + } + Ok(updated) + } +} diff --git a/src/engine/automations.rs b/src/engine/automations.rs index e4eaafc..a02e1c0 100644 --- a/src/engine/automations.rs +++ b/src/engine/automations.rs @@ -1,3 +1,16 @@ + +pub fn automation_action_conflicts_with_thermostat(command: &DeviceCommand) -> bool { + // Power/mode/target are translated by apply_automatic_device_action into durable zone + // state, so they do not fight the thermostat. Fan/quiet/sleep are thermostat outputs with + // no independent zone override model; accepting them as one-shot direct automation would + // let the next thermostat cycle immediately overwrite them. + command.fan_speed.is_some() || command.quiet.is_some() || command.sleep.is_some() +} + +fn device_has_enabled_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool { + zones.iter().any(|zone| zone.device_id == device_id && zone.enabled) +} + async fn run_automations(state: &AppState) -> Result<()> { if !state.settings.read().await.house_power_enabled { return Ok(()); } let devices = state.db.list_devices()?; @@ -62,6 +75,19 @@ async fn run_automations(state: &AppState) -> Result<()> { continue; } + if item.action_group_id.is_none() + && device_has_enabled_thermostat_zone(&item.action_device_id, &zones) + && automation_action_conflicts_with_thermostat(&item.action) + { + // Fan/quiet/sleep are outputs continuously managed by the thermostat. Unlike + // power/mode/target they cannot be translated into durable zone state, so a direct + // automation would be immediately overwritten by the next thermostat cycle. + state.log("warn", "automation.blocked_by_thermostat_owner", &format!("Automation {} suppressed because the device is owned by an enabled thermostat zone", item.name), json!({ + "automation_id": item.id, "device_id": item.action_device_id + })); + continue; + } + let target_devices: Vec = if let Some(group_id) = item.action_group_id.as_deref() { groups.iter().find(|group| group.id == group_id) .map(|group| group.zone_ids.iter() diff --git a/src/engine/commands.rs b/src/engine/commands.rs index 6b39870..1e19f6c 100644 --- a/src/engine/commands.rs +++ b/src/engine/commands.rs @@ -170,15 +170,15 @@ async fn send_command_locked_inner( } fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> { - if before.power == after.power && before.mode == after.mode { return Ok(()); } - let now = Utc::now(); - for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == after.id) { - if before.power != after.power { zone.last_power_change_at = Some(now); } - if before.mode != after.mode { zone.last_mode_change_at = Some(now); } - // Do not bump zone.updated_at here: an in-flight thermostat cycle uses that field - // as its optimistic snapshot guard. The cycle mirrors these timestamps into its own - // computed Zone after a successful automatic command. - state.db.save_zone(&zone)?; + let power_changed = before.power != after.power; + let mode_changed = before.mode != after.mode; + if !power_changed && !mode_changed { return Ok(()); } + // This function is often called while the device lock is held, so acquiring a zone lock + // here would invert the global zone -> device order. Merge only these timestamp fields + // with a DB compare-and-swap instead of saving a stale whole-zone snapshot. + for zone in state.db.merge_zone_device_transition_timestamps( + &after.id, power_changed, mode_changed, Utc::now(), + )? { state.broadcast("zone.updated", serde_json::to_value(&zone)?); } Ok(()) diff --git a/src/engine/groups.rs b/src/engine/groups.rs index 4da3a6d..f0ab441 100644 --- a/src/engine/groups.rs +++ b/src/engine/groups.rs @@ -25,6 +25,10 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl // (especially from an automation) from resurrecting the master while whole-house OFF // is being applied. Group and zone locks then make the member update atomic. let _house_guard = state.lock_house_operation().await; + // Group state participates in thermostat arbitration. Exclude an already-running cycle + // while changing the group gate/profile so no cycle can act on a stale group snapshot. + // Lock order stays house -> cycle -> group -> zone -> device. + let _cycle_guard = state.lock_zone_control_cycle().await; let _group_guard = state.lock_group_operation(group_id).await; let mut group = state.db.get_group(group_id)? .ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?; @@ -123,6 +127,12 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); } } + if !temporary_owns_zone && !zone.device_manual_override && zone.local_thermostat_power.is_none() { + zone.control_owner = "automation".into(); + zone.control_source = format!("group:{}", group.id); + zone.control_since = Some(Utc::now()); + zone.control_reason = format!("Controlled by group {}", group.name); + } zone.revision = zone.revision.saturating_add(1); zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; @@ -158,9 +168,26 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl } } - if desired_power && (should_command_power || climate_change) { - state.wake_zone_control(); - } + let run_immediately = desired_power && (should_command_power || climate_change); + let zones = if run_immediately { + // Do not just wake the background loop: a group ON/profile/setpoint action is expected + // to arbitrate every member before the HTTP request completes. Release member/domain + // locks first, then run one globally serialized thermostat cycle. + drop(_zone_guards); + drop(_group_guard); + drop(_cycle_guard); + drop(_house_guard); + if let Err(err) = run_zone_control_now(state).await { + state.log("error", "group.immediate_control_error", &err.to_string(), json!({ + "group_id": group.id, "source": source, + })); + failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()})); + } + group.zone_ids.iter().filter_map(|zone_id| state.db.get_zone(zone_id).ok().flatten()).collect::>() + } else { + zones + }; + state.log("info", source, &format!("Updated group {}", group.name), json!({ "group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, "setpoint": custom_setpoint, "zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled, diff --git a/src/engine/ownership.rs b/src/engine/ownership.rs index c917942..1dd12da 100644 --- a/src/engine/ownership.rs +++ b/src/engine/ownership.rs @@ -25,9 +25,11 @@ pub fn refresh_control_ownership(zone: &mut Zone, house_power_enabled: bool, blo }; ("local_thermostat", source, resume_at, reason) } else if blocked_by_group { - ("automation", "group".to_string(), None, "Zone is blocked by a disabled group".to_string()) + let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "group".into() }; + ("automation", source, None, "Zone is blocked by a disabled group".to_string()) } else { - ("automation", "automation".to_string(), zone.manual_override_until, "Automatic thermostat/schedule control".to_string()) + let source = if zone.control_source.starts_with("group:") { zone.control_source.clone() } else { "automation".into() }; + ("automation", source, zone.manual_override_until, "Automatic thermostat/schedule control".to_string()) }; if zone.control_owner != owner || zone.control_source != source { zone.control_since = Some(now); @@ -156,12 +158,11 @@ fn set_device_manual_override(state: &AppState, zone: &mut Zone, fields: Vec Result { - // Global OFF is a one-shot authority transition. Clear takeover and send OFF while - // polling for this unit is excluded; a later remote change happens after the lock and - // is therefore preserved as a new manual takeover. + // Preserve the global zone -> device lock order even for the whole-house safety path. + // Otherwise a zone edit could hold its zone lock while waiting for this device lock as + // this function saved a stale zone snapshot without owning the corresponding zone lock. + let mut zone_ids: Vec = state.db.list_zones()?.into_iter() + .filter(|zone| zone.device_id == device_id) + .map(|zone| zone.id) + .collect(); + zone_ids.sort(); + zone_ids.dedup(); + let mut _zone_guards = Vec::with_capacity(zone_ids.len()); + for zone_id in &zone_ids { + _zone_guards.push(state.lock_zone_operation(zone_id).await); + } let _device_guard = state.lock_device_operation(device_id).await; - for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) { + for zone_id in &zone_ids { + let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; if !reset_device_manual_override(&mut zone) { continue; } zone.updated_at = Utc::now(); state.db.save_zone(&zone)?; @@ -276,7 +286,8 @@ pub async fn force_house_power_off_device(state: &AppState, device_id: &str, sou "zone_id": zone.id, "device_id": zone.device_id, "source": source })); } - send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await + // The device lock is already held, so use the locked forced-command variant directly. + force_power_off_device_locked(state, device_id).await } pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 2b29563..9187063 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -167,6 +167,15 @@ mod tests { assert!(!command_field_matches_device(&command, "target_temperature", &device)); } + #[test] + fn conflicting_thermostat_output_fields_are_detected_for_direct_automation() { + assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { power: Some(true), ..Default::default() })); + assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { target_temperature: Some(22.0), ..Default::default() })); + assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { fan_speed: Some(3), ..Default::default() })); + assert!(automation_action_conflicts_with_thermostat(&DeviceCommand { quiet: Some(true), ..Default::default() })); + assert!(!automation_action_conflicts_with_thermostat(&DeviceCommand { light: Some(false), turbo: Some(true), ..Default::default() })); + } + #[test] fn local_thermostat_ownership_blocks_direct_automation() { let mut zone = test_zone("device"); diff --git a/src/engine/zone_actions.rs b/src/engine/zone_actions.rs index 9b5f30c..a1b6102 100644 --- a/src/engine/zone_actions.rs +++ b/src/engine/zone_actions.rs @@ -117,6 +117,9 @@ async fn apply_automatic_device_action( device_id: &str, command: DeviceCommand, ) -> Result, AppError> { + // Direct automation target/setpoint is translated into zone state and may use the next + // schedule boundary. Serialize that derivation with schedule edits before locking the zone. + let _schedule_guard = state.lock_schedule_operation().await; let zones = state.db.list_zones()?; let Some(zone_id) = zones.iter().find(|zone| zone.device_id == device_id).map(|zone| zone.id.clone()) else { return send_automatic_device_command_if_owned(state, device_id, command).await; diff --git a/src/engine/zone_control.rs b/src/engine/zone_control.rs index 4757d51..9a9814f 100644 --- a/src/engine/zone_control.rs +++ b/src/engine/zone_control.rs @@ -1,4 +1,5 @@ async fn control_zones(state: &AppState) -> Result<()> { + let _cycle_guard = state.lock_zone_control_cycle().await; let schedules = state.db.list_schedules()?; let groups = state.db.list_groups()?; let settings = state.settings.read().await.clone(); @@ -78,16 +79,29 @@ async fn control_zones(state: &AppState) -> Result<()> { .map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result))) .collect(); - for mut zone in zone_snapshot { + for zone_snapshot_item in zone_snapshot { + // Every thermostat decision participates in the same zone -> device ordering as + // Web/HA/manual control and polling. Re-read after taking the zone lock so an + // interactive change cannot be evaluated from a stale snapshot. + let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await; + let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; }; let cycle_started_at = zone.updated_at; if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; + if zone.control_source.starts_with("group:") { + zone.control_source = "automation".into(); + zone.control_since = Some(Utc::now()); + zone.control_reason = "Group override expired at schedule boundary".into(); + } } - if zone.device_manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) { - reset_device_manual_override(&mut zone); - state.log("info", "zone.device_manual_override_expired", &format!("Manual device control expired for {} at schedule transition", zone.name), json!({ + // v0.8.20 makes direct/manual takeover persistent. Normalize any legacy persisted + // boundary from older releases instead of silently returning ownership to schedules. + if zone.device_manual_override && zone.device_manual_override_until.is_some() { + zone.device_manual_override_until = None; + zone.control_resume_at = None; + state.log("info", "zone.device_manual_override_migrated", &format!("Manual device control remains active for {} until explicit resume", zone.name), json!({ "zone_id": zone.id, "device_id": zone.device_id })); } @@ -541,3 +555,9 @@ async fn control_zones(state: &AppState) -> Result<()> { Ok(()) } + +/// Run one thermostat arbitration cycle immediately and wait for all currently eligible zones. +/// The cycle lock prevents overlap with the background regulator. +pub async fn run_zone_control_now(state: &AppState) -> Result<(), AppError> { + control_zones(state).await.map_err(AppError::from) +} diff --git a/src/main.rs b/src/main.rs index fb459dd..2e57cbf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,7 @@ async fn main() -> Result<()> { automation_operation_lock: Arc::new(tokio::sync::Mutex::new(())), house_operation_lock: Arc::new(tokio::sync::Mutex::new(())), configuration_operation_lock: Arc::new(tokio::sync::Mutex::new(())), + zone_control_cycle_lock: Arc::new(tokio::sync::Mutex::new(())), pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), started: Instant::now(), }; diff --git a/src/state.rs b/src/state.rs index b335170..eee9544 100644 --- a/src/state.rs +++ b/src/state.rs @@ -37,6 +37,8 @@ pub struct AppState { pub(crate) automation_operation_lock: Arc>, pub(crate) house_operation_lock: Arc>, pub(crate) configuration_operation_lock: Arc>, + /// Serializes thermostat control cycles, including explicit immediate runs requested by group/house control. + pub(crate) zone_control_cycle_lock: Arc>, /// Short-lived expected climate state from controller-originated commands. It prevents /// a delayed GREE status update from being mistaken for remote/manual takeover. pub(crate) pending_controller_commands: Arc>>, @@ -84,6 +86,10 @@ impl AppState { self.configuration_operation_lock.clone().lock_owned().await } + pub async fn lock_zone_control_cycle(&self) -> OwnedMutexGuard<()> { + self.zone_control_cycle_lock.clone().lock_owned().await + } + pub fn wake_zone_control(&self) { self.zone_control_wakeup.notify_one(); } diff --git a/web/js/core.js b/web/js/core.js index b7e742b..e5531bf 100644 --- a/web/js/core.js +++ b/web/js/core.js @@ -31,7 +31,7 @@ const app = { languages: [], translations: {}, locales: {}, historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {}, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, - customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, zoneTemperatureTimers: {}, + customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, deviceControlQueue: {}, houseControlQueue: null, climateControlQueue: null, groupCustomDrafts: {}, zoneTemperatureTimers: {}, controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), }; diff --git a/web/js/entities.js b/web/js/entities.js index 42b15bc..8fa7a58 100644 --- a/web/js/entities.js +++ b/web/js/entities.js @@ -79,6 +79,36 @@ function zonePresetLabel(preset) { return tr(key) === key ? (preset || 'comfort') : tr(key); } +function groupControlHue(groupId) { + let hash=0; + for(const char of String(groupId || 'group')) hash=((hash*31)+char.charCodeAt(0))>>>0; + return (hash % 300) + 20; +} + +function groupControlStyle(group) { + return group ? ` style="--group-control-color:hsl(${groupControlHue(group.id)} 72% 48%)"` : ''; +} + +function groupControlForZone(zone) { + if(app.settings?.house_power_enabled === false || zone?.device_manual_override || zone?.local_thermostat_power != null) return null; + const source=String(zone?.control_source || ''); + if(!source.startsWith('group:')) return null; + const groupId=source.slice(6); + return app.groups.find(group => group.id===groupId && group.power_enabled !== false && (group.zone_ids || []).includes(zone.id)) || null; +} + +function blockingGroupsForZone(zone) { + if (app.settings?.house_power_enabled === false || zone?.local_thermostat_power === true) return []; + const zoneId=zone?.id || zone?.zone_id; + if(!zoneId) return []; + return app.groups.filter(group => group.power_enabled === false && (group.zone_ids || []).includes(zoneId)); +} + +function zoneLockoutActive(zone) { + const until=zone?.lockout_until ? new Date(zone.lockout_until).getTime() : NaN; + return Number.isFinite(until) && until>Date.now(); +} + 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 || ''; @@ -89,24 +119,34 @@ function zoneControlOwnerLabel(zone) { return tr('zones.ownerExternal'); } if(owner==='local_thermostat') return tr('zones.ownerLocalThermostat'); + if(source.startsWith('group:')) return tr('zones.ownerGroup'); return tr('zones.ownerAutomation'); } function zoneControlOwnerMeta(zone) { const parts=[]; + const controlGroup=groupControlForZone(zone); + if(controlGroup) parts.push(`${tr('groups.group')}: ${controlGroup.name}`); if(zone.control_since) parts.push(`${tr('zones.ownerSince')} ${dateTime(zone.control_since)}`); if(zone.control_resume_at) parts.push(`${tr('zones.ownerResume')} ${dateTime(zone.control_resume_at)}`); if(zone.lockout_until && new Date(zone.lockout_until)>new Date()) parts.push(`${tr('zones.lockoutUntil')} ${dateTime(zone.lockout_until)}`); return parts.join(' · '); } -function zoneRuntimeStatusLabel(zone, effectiveMode) { +function zoneRuntimeStatusLabel(zone, effectiveMode, device = null) { if (zone.device_manual_override === true) return tr('zones.manualDeviceControl'); if (!zone.enabled) return tr('common.disabled'); if (zone.local_thermostat_power === false) return tr('zones.localThermostatOff'); + const blockers=blockingGroupsForZone(zone); + if (blockers.length) return tr('zones.waitingGroup', {groups:blockers.map(group=>group.name).join(', ')}); if ((effectiveMode || 'off') === 'off') return tr('zones.waiting'); + 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'); - return zone.demand ? tr('zones.requesting') : tr('zones.satisfied'); + if (zone.demand && zoneLockoutActive(zone)) return tr('zones.waitingLockout'); + 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'); } function zoneCard(zone, detailed = true) { @@ -125,6 +165,15 @@ function zoneCard(zone, detailed = true) { const globalModeDisabled = globalModeAvailable ? '' : ` disabled aria-disabled="true" title="${esc(tr('zones.globalModeUnavailable'))}"`; const hasManualOverride = zone.manual_preset != null || zone.manual_setpoint != null; const deviceManualOverride = zone.device_manual_override === true; + const controlGroup = groupControlForZone(zone); + const blockingGroups = blockingGroupsForZone(zone); + const deviceUnavailable = !!device && (device.enabled === false || !device.online || Number(device.communication_failures || 0) > 0); + const lockoutWaiting = zone.demand && 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 groupBlockedWaiting = blockingGroups.length > 0; + const waitingDemand = lockoutWaiting || startWaiting || modeWaiting || groupBlockedWaiting || (zone.demand && deviceUnavailable); + const workingDemand = zone.demand && !!device?.power && !deviceUnavailable && !waitingDemand; const localThermostatPower = zone.local_thermostat_power; const localThermostatResumeAt = zone.local_thermostat_resume_at || null; const temporarySession = zone.temporary_quick_thermostat || null; @@ -159,11 +208,11 @@ function zoneCard(zone, detailed = true) { const groupNames = (app.groups || []).filter(group => (group.zone_ids || []).includes(zone.id)).map(group => group.name); const groupText = groupNames.length ? groupNames.join(' · ') : tr('zones.noGroup'); const policy = zone.inherit_house_mode ? tr('zones.followHouse') : tr(zone.mode === 'heat' ? 'zones.heatOnly' : 'zones.coolOnly'); - return `
+ return `
${esc(tr('zones.configuration'))}

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}

${esc(tr('zones.currentStatus'))}
${fmtTemp(roomTemperature)}${Number.isFinite(target) ? `${target.toFixed(1)}°C` : '—'}
-
${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}
+
${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}${esc(override)}${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}
${esc(tr('zones.modePolicy'))}${esc(policy)}
@@ -177,14 +226,14 @@ function zoneCard(zone, detailed = true) {
`; } - return `
+ return `

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}

${localThermostatPowerControl}${temporaryThermostatControl}
${esc(tr('zones.measurement'))}${fmtTemp(roomTemperature)}
${Number.isFinite(target)?target.toFixed(1):'--'}°C
${esc(tr('zones.deviceTarget'))}${fmtTemp(zone.device_setpoint)}
${['auto','comfort','sleep','away'].map(preset=>``).join('')}
-
${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}${esc(override)}
+
${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}${controlGroup ? ` ${esc(controlGroup.name)}` : ''}${esc(override)}
${esc(tr('zones.controlOwner'))}: ${esc(zoneControlOwnerLabel(zone))}${esc(zoneControlOwnerMeta(zone))}
${manualTakeover}
`; @@ -232,15 +281,20 @@ function groupCard(group, detailed = false) { const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value)); const presetLabelForGroup = value => value === 'mixed' ? tr('groups.mixed') : zonePresetLabel(value); const fallbackTarget = Number(state.zones[0]?.manual_setpoint ?? state.zones[0]?.effective_setpoint ?? state.zones[0]?.setpoint ?? 23); - const customTarget = Number.isFinite(state.customTemperature) ? state.customTemperature : (Number.isFinite(fallbackTarget) ? fallbackTarget : 23); - const customEditorVisible = state.preset === 'custom'; - return `
-
${esc(tr('groups.group'))}

${esc(group.name)}

${esc(memberText)}

+ const currentCustomTarget = Number.isFinite(state.customTemperature) ? state.customTemperature : (Number.isFinite(fallbackTarget) ? fallbackTarget : 23); + const customDraft = app.groupCustomDrafts?.[group.id]; + const draftTarget = Number(customDraft?.value); + const customTarget = Number.isFinite(draftTarget) ? draftTarget : currentCustomTarget; + const customEditorVisible = state.preset === 'custom' || customDraft?.open === true; + const customDraftPending = customDraft?.open === true && state.preset !== 'custom'; + const groupIsControlSource = powerEnabled && state.zones.some(zone => String(zone.control_source || '') === `group:${group.id}` && !zone.device_manual_override && zone.local_thermostat_power == null); + return `
+
${esc(tr('groups.group'))}

${esc(group.name)}

${esc(memberText)}

${groupIsControlSource ? `${esc(tr('groups.activeControl'))}` : ''}
${detailed ? `
${esc(tr('groups.mode'))}${esc(modeLabelForGroup(state.mode))}
${esc(tr('groups.profile'))}${esc(presetLabelForGroup(state.preset))}
${esc(tr('groups.members'))}${state.zones.length}
` : ''}
${esc(tr('common.power'))}
${esc(tr('groups.mode'))}
${['house','heat','cool'].map(mode => ``).join('')}
-
${esc(tr('groups.profile'))}
${['auto','comfort','sleep','away'].map(preset => ``).join('')}
-
+
${esc(tr('groups.profile'))}
${['auto','comfort','sleep','away'].map(preset => ``).join('')}
+
${customDraftPending ? `${esc(tr('groups.customDraftPending'))}` : ''}
${masterOff ? `

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

` : ''} ${detailed ? `` : ''}
`; @@ -257,34 +311,29 @@ function renderGroups() { } async function sendGroupControl(id, patch) { - const sequence = (app.groupControlSeq[id] || 0) + 1; - app.groupControlSeq[id] = sequence; - const previous = app.groupControlQueue[id] || Promise.resolve(); - const request = previous.catch(() => {}).then(() => - api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}) - ); - app.groupControlQueue[id] = request; try { - const result = await request; - if (app.groupControlSeq[id] !== sequence) return; - if (result.group) { - const index = app.groups.findIndex(group => group.id === result.group.id); - if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group); - } - (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); + await enqueueClimateControlTask(async () => { + const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); + if (result.group) { + const index = app.groups.findIndex(group => group.id === result.group.id); + if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group); + } + (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); + }); + (result.devices || []).forEach(updateDevice); + if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled; + if (patch.preset === 'custom' && patch.setpoint != null) delete app.groupCustomDrafts[id]; + renderAll(); scheduleControlPlanLoad(); + const failed = Array.isArray(result.failed) ? result.failed.length : 0; + if (failed) toast(tr('groups.partial', {count: failed}), true); + else toast(tr('groups.controlUpdated')); + return result; }); - (result.devices || []).forEach(updateDevice); - if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled; - renderAll(); scheduleControlPlanLoad(); - const failed = Array.isArray(result.failed) ? result.failed.length : 0; - if (failed) toast(tr('groups.partial', {count: failed}), true); - else toast(tr('groups.controlUpdated')); } catch (error) { - if (app.groupControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); } - } finally { - if (app.groupControlQueue[id] === request) delete app.groupControlQueue[id]; + await loadBootstrap(); + toast(error.message, true); } } diff --git a/web/js/events.js b/web/js/events.js index 428e7d2..b42ed26 100644 --- a/web/js/events.js +++ b/web/js/events.js @@ -73,29 +73,35 @@ document.addEventListener('click', async event => { if (device && app.currentView !== 'devices' && deviceZoneDisabled(device.id) && ['power','temperature','mode','fan','toggle'].includes(action)) { return toast(tr('devices.disabledZoneTechnicalOnly'), true); } - if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power}); - if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,30)}); + if (action === 'power' && device) return sendDeviceCommand(device.id, current => ({power:!current?.power})); + if (action === 'temperature' && device) return sendDeviceCommand(device.id, current => ({target_temperature:clamp(Number(current?.target_temperature ?? device.target_temperature)+Number(button.dataset.delta),8,30)})); if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true}); if (action === 'fan' && device) return sendDeviceCommand(device.id, {fan_speed:Number(button.dataset.value)}); - if (action === 'toggle' && device) return sendDeviceCommand(device.id, {[button.dataset.field]:!device[button.dataset.field]}); + if (action === 'toggle' && device) return sendDeviceCommand(device.id, current => ({[button.dataset.field]:!current?.[button.dataset.field]})); if (action === 'poll' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`,{method:'POST'})); renderAll(); toast(tr('devices.readDone')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } if (action === 'bind' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`,{method:'POST'})); renderAll(); toast(tr('devices.bound')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } if (action === 'rename-device' && device) return populateDeviceRename(device.id); if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device'); if (action === 'house-mode') { - try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); scheduleControlPlanLoad(); toast(tr('house.modeUpdated')); } - catch(error){ toast(error.message,true); } return; + try { + await enqueueClimateControlTask(async () => { + await api('/api/house/control',{method:'POST', body:{mode:button.dataset.value}}); + await loadBootstrap(); + }); + toast(tr('house.modeUpdated')); + } catch(error){ toast(error.message,true); } + return; } if (action === 'house-power') { const power = button.dataset.value === 'true'; if (!power && app.settings?.house_power_enabled !== false && !confirm(tr('house.powerOffConfirm'))) return; try { button.disabled = true; - const result = await api('/api/house/power',{method:'POST',body:{power}}); - app.settings = result.settings || app.settings; - app.devices = result.devices || app.devices; - app.groups = result.groups || app.groups; - renderAll(); scheduleControlPlanLoad(); + const result = await enqueueClimateControlTask(async () => { + const response = await api('/api/house/power',{method:'POST', body:{power}}); + await loadBootstrap(); + return response; + }); const failed = Array.isArray(result.failed) ? result.failed.length : 0; if (failed) toast(tr('house.powerPartial', {count: failed}), true); else toast(tr(power ? 'house.powerOnDone' : 'house.powerOffDone')); @@ -105,20 +111,31 @@ document.addEventListener('click', async event => { } if (action === 'house-preset') { try { - const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); - app.settings=result.settings||app.settings; app.zones=result.zones||app.zones; app.devices=result.devices||app.devices; - renderAll(); scheduleControlPlanLoad(); + const result = await enqueueClimateControlTask(async () => { + const response = await api('/api/house/preset',{method:'POST', body:{preset:button.dataset.value}}); + await loadBootstrap(); + return response; + }); const failed=Array.isArray(result.failed)?result.failed.length:0; if(failed) toast(tr('house.powerPartial',{count:failed}),true); else toast(tr('house.presetUpdated')); } - catch(error){ toast(error.message,true); } return; + catch(error){ toast(error.message,true); } + return; } if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'}); if (action === 'group-mode') return sendGroupControl(button.dataset.id,{mode:button.dataset.value}); - if (action === 'group-preset') return sendGroupControl(button.dataset.id,{preset:button.dataset.value}); + if (action === 'group-preset') { delete app.groupCustomDrafts[button.dataset.id]; return sendGroupControl(button.dataset.id,{preset:button.dataset.value}); } if (action === 'group-custom-open') { const editor=button.closest('.group-card')?.querySelector('[data-group-custom-editor]'); - if(editor){ editor.hidden=false; const input=editor.querySelector('[data-group-custom-temperature]'); input?.focus(); input?.select(); } + const input=editor?.querySelector('[data-group-custom-temperature]'); + const value=parseDecimal(input?.value); + app.groupCustomDrafts[button.dataset.id]={open:true,value:Number.isFinite(value)?value:23}; + if(editor){ editor.hidden=false; input?.focus(); input?.select(); } + return; + } + if (action === 'group-custom-cancel') { + delete app.groupCustomDrafts[button.dataset.id]; + renderGroups(); return; } if (action === 'group-custom-temperature') { @@ -194,6 +211,14 @@ $('#themeSelect')?.addEventListener('change', event => setTheme(event.target.val $('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs); $('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields); $('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields); +document.addEventListener('input', event => { + const input=event.target.closest?.('[data-group-custom-temperature]'); + if(!input) return; + const id=input.dataset.groupCustomTemperature; + if(!id) return; + app.groupCustomDrafts[id]={open:true,value:input.value}; +}); + document.addEventListener('keydown', event => { const input=event.target.closest?.('[data-group-custom-temperature]'); if(!input || event.key!=='Enter') return; diff --git a/web/js/navigation.js b/web/js/navigation.js index 6085871..271812d 100644 --- a/web/js/navigation.js +++ b/web/js/navigation.js @@ -34,11 +34,31 @@ function showHistoryTab(tab, {push=true, load=true}={}) { if (load) loadHistory(); } -async function sendDeviceCommand(id, command) { +async function sendDeviceCommand(id, commandOrFactory) { + const previous = app.deviceControlQueue[id] || Promise.resolve(); + const request = previous.catch(() => {}).then(() => { + const current = app.devices.find(device => device.id === id); + const command = typeof commandOrFactory === 'function' ? commandOrFactory(current) : commandOrFactory; + return api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command}); + }); + app.deviceControlQueue[id] = request; try { - const device = await api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command}); + const device = await request; updateDevice(device); renderAll(); } catch (error) { toast(error.message, true); } + finally { + if (app.deviceControlQueue[id] === request) delete app.deviceControlQueue[id]; + } +} + +function enqueueClimateControlTask(task) { + const previous = app.climateControlQueue || Promise.resolve(); + const request = previous.catch(() => {}).then(task); + app.climateControlQueue = request; + request.finally(() => { + if (app.climateControlQueue === request) app.climateControlQueue = null; + }).catch(() => {}); + return request; } function updateDevice(device) { diff --git a/web/styles.css b/web/styles.css index 412c0b7..23f991a 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1336,3 +1336,52 @@ textarea[aria-invalid="true"] { .button-link:active { transform: scale(.97); } .button-link.primary { background: var(--accent); color: var(--accent-text); font-weight: 800; } .button-link.primary:hover { background: var(--accent-strong); } + +/* Explicit control-source states */ +.list-card.group-controlled { + border-color: var(--group-control-color); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--group-control-color) 30%, transparent); +} +.group-control-badge,.group-control-tag { + display:inline-flex; + align-items:center; + width:max-content; + max-width:100%; + padding:4px 7px; + border-radius:999px; + color:var(--group-control-color); + background:color-mix(in srgb,var(--group-control-color) 12%,var(--surface)); + font-size:10px; + font-weight:800; + line-height:1.1; +} +.group-control-tag { margin-left:5px; vertical-align:middle; } +.group-preset-row button.editing:not(.active) { + border-color:color-mix(in srgb,var(--accent) 55%,var(--line)); + color:var(--accent); + background:color-mix(in srgb,var(--accent) 8%,var(--surface)); +} +.zone-thermostat.lockout-waiting:not(.group-controlled),.zone-config-card.lockout-waiting:not(.group-controlled) { + border-color:color-mix(in srgb,var(--warning) 58%,var(--line)); + box-shadow:0 0 0 1px color-mix(in srgb,var(--warning) 16%,transparent); +} +.zone-thermostat.lockout-waiting,.zone-config-card.lockout-waiting { + background-image:linear-gradient(color-mix(in srgb,var(--warning-soft) 42%,transparent),color-mix(in srgb,var(--warning-soft) 42%,transparent)); +} + +/* v0.8.20: group ownership and pending thermostat state clarity. */ +.group-custom-temperature-row { grid-template-columns:minmax(0,1fr) auto auto auto; } +.group-custom-draft-note { display:block; margin-top:6px; color:var(--warning); font-size:10px; line-height:1.35; } +.zone-thermostat.lockout-waiting,.zone-config-card.lockout-waiting { + background-image:linear-gradient(color-mix(in srgb,var(--warning-soft) 48%,transparent),color-mix(in srgb,var(--warning-soft) 48%,transparent)); +} +.zone-thermostat.group-controlled.lockout-waiting,.zone-config-card.group-controlled.lockout-waiting { + border-color:var(--group-control-color); + box-shadow:0 0 0 1px color-mix(in srgb,var(--group-control-color) 30%,transparent), inset 0 0 0 2px color-mix(in srgb,var(--warning) 18%,transparent); +} +@media (max-width:520px) { + .group-custom-temperature-row { grid-template-columns:minmax(0,1fr) auto; } + .group-custom-temperature-row input { grid-column:1; } + .group-custom-temperature-row > span { grid-column:2; } + .group-custom-temperature-row button { grid-column:auto; } +}