This commit is contained in:
Mateusz Gruszczyński
2026-09-01 11:10:28 +02:00
parent 3479ed750d
commit 9f08d7ccf9
30 changed files with 559 additions and 150 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.8.19"
version = "0.8.20"
dependencies = [
"aes",
"aes-gcm",
+1 -1
View File
@@ -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"
+29 -29
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
**Current release: 0.8.19**
**Current release: 0.8.20**
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
+3 -3
View File
@@ -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,
@@ -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",
+15 -3
View File
@@ -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"
}
}
+15 -3
View File
@@ -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 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 830°C."
"groups.customTemperatureRange": "Temperatura grupy musi mieścić się w zakresie 830°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"
}
}
+12 -2
View File
@@ -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(())
}
+6 -1
View File
@@ -144,16 +144,21 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, 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<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
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())
+3
View File
@@ -45,6 +45,7 @@ async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInpu
let _configuration_guard = state.lock_configuration_operation().await;
let _reference_guard = state.lock_automation_operation().await;
let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let zone_ids = validate_group_input(&state, &input)?;
let now = Utc::now();
let group = ClimateGroup {
@@ -67,6 +68,7 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, 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<AppState>, Path(id): Path<String>) ->
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()));
+67 -9
View File
@@ -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<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
let mut guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids { guards.push(state.lock_zone_operation(zone_id).await); }
for zone_id in &zone_ids {
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if !zone.control_source.starts_with("group:") { continue; }
zone.control_source = "automation".into();
zone.control_since = Some(Utc::now());
zone.control_reason = reason.to_string();
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<String> = 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<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, 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<AppState>, Json(input): Json<H
public_settings(&settings)
};
state.broadcast("settings.updated", payload.clone());
clear_group_control_sources(&state, "Whole-house mode control took ownership").await?;
if activate_all {
set_all_groups_power(&state, true).await?;
// Never send a bare power=true frame. Wake the thermostat arbiter so every unit
// starts only with a valid effective Heat/Cool mode and compressor lockout policy.
}
// run_zone_control_now takes the same cycle lock, so release the mutation window first.
drop(cycle_guard);
if activate_all {
// House mode changes are interactive controls: arbitrate all zones now instead of
// leaving part of the house waiting for the background interval.
engine::run_zone_control_now(&state).await?;
} else {
state.wake_zone_control();
}
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode, "master_power_enabled": activate_all}));
@@ -111,6 +144,7 @@ struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
let _house_guard = state.lock_house_operation().await;
let cycle_guard = state.lock_zone_control_cycle().await;
// Whole-house power is independent from the thermostat mode. Publish/persist the master
// first so the regulator becomes passive before the one-shot OFF cascade starts.
{
@@ -127,6 +161,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
// markers once, then each enabled device is re-cleared atomically with its OFF command.
// A later pilot action is therefore not erased by subsequent controller cycles.
set_all_groups_power(&state, input.power).await?;
clear_group_control_sources(&state, if input.power { "Whole-house power control resumed automation" } else { "Whole-house power disabled" }).await?;
if !input.power {
let zone_snapshot = state.db.list_zones()?;
let mut zone_ids: Vec<String> = zone_snapshot.iter().map(|zone| zone.id.clone()).collect();
@@ -149,10 +184,20 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
drop(zone_guards);
}
let failed = if input.power {
state.wake_zone_control();
Vec::new()
// The immediate cycle acquires this lock itself.
drop(cycle_guard);
match engine::run_zone_control_now(&state).await {
Ok(()) => Vec::new(),
Err(err) => {
state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_power"}));
vec![json!({"scope":"thermostat_cycle","error":err.to_string()})]
}
}
} else {
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<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, 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<AppState>, Json(input): Json<Ho
};
state.broadcast("settings.updated", settings_payload.clone());
set_all_groups_power(&state, true).await?;
clear_group_control_sources(&state, "Whole-house preset control took ownership").await?;
let schedules = state.db.list_schedules()?;
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
@@ -226,10 +273,18 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
zones.push(zone);
}
// As with house mode/power ON, the central thermostat arbiter performs the physical
// start with a valid mode/target. This prevents unmanaged power-on while house mode=off.
let failed: Vec<Value> = 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<Value> = 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<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, 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<Schedule> = Vec::new();
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
@@ -294,6 +350,7 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
state.db.replace_schedules_for_zone(&id, &items)?;
refresh_zone_override_boundary(&state, &id).await?;
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
state.wake_zone_control();
Ok(Json(json!({"zone": zone, "schedules": items})))
}
@@ -306,6 +363,7 @@ async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> 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();
+10 -1
View File
@@ -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<AppState>, Path(id): Path<String>) ->
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), 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<AppState>, Json(input): Json<Schedu
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, 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<AppState>, Path(id): Path<String>,
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, 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 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?;
+3
View File
@@ -6,6 +6,7 @@ async fn get_settings(State(state): State<AppState>) -> Json<Value> {
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, 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<AppState>, Json(mut input): Json<Ru
*state.settings.write().await = input.clone();
state.log("info", "settings.updated", "Settings updated", json!({}));
state.broadcast("settings.updated", public_settings(&input));
state.wake_zone_control();
Ok(Json(public_settings(&input)))
}
@@ -330,6 +332,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
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 current_zones = state.db.list_zones()?;
let current_devices = state.db.list_devices()?;
+6
View File
@@ -142,6 +142,7 @@ async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>
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<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, AppError> {
@@ -241,10 +242,15 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, 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<Zone, AppError> {
// 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
+45
View File
@@ -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<Utc>,
) -> Result<Vec<Zone>> {
if !power_changed && !mode_changed { return Ok(Vec::new()); }
let zone_ids: Vec<String> = 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)
}
}
+26
View File
@@ -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<String> = 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()
+9 -9
View File
@@ -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(())
+30 -3
View File
@@ -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::<Vec<_>>()
} 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,
+34 -23
View File
@@ -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<Str
zone.control_owner = "direct_manual".into();
zone.control_source = normalized_direct_source(source).into();
zone.control_reason = "Direct/manual device control has priority".into();
zone.device_manual_override_until = if zone.enabled {
next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now())
} else {
None
};
zone.control_resume_at = zone.device_manual_override_until;
// Direct/pilot control is an explicit ownership takeover, not a temporary preset.
// Keep it manual until the user chooses Resume automation (or a global safety OFF).
// A schedule boundary must never silently take the unit back from the person controlling it.
zone.device_manual_override_until = None;
zone.control_resume_at = None;
for field in fields {
if !zone.device_manual_override_fields.iter().any(|existing| existing == &field) {
zone.device_manual_override_fields.push(field);
@@ -196,10 +197,9 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
after,
raw_fields.clone(),
).await;
if zone.device_manual_override && manual_override_matches_baseline(&zone, after) {
persist_manual_override_clear(state, &mut zone, "gree_poll", true)?;
continue;
}
// Once direct/pilot ownership has been detected, merely returning the device to a
// previous physical state must not silently hand control back to schedules. Only the
// explicit Resume automation action (or global OFF safety reset) ends manual ownership.
if fields.is_empty() { continue; }
// A disabled zone is outside controller ownership. When its manually operated unit is
// switched off there is no takeover left to display or remember.
@@ -243,15 +243,14 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
let _device_guard = state.lock_device_operation(device_id).await;
let before = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
let effective_command = if before.online && before.communication_failures == 0 { command.changed_from(&before) } else { command.clone() };
let fields = command_manual_control_fields(&effective_command);
// An explicit direct-control request is an ownership action even when the requested
// value already matches the cached device state. Derive takeover fields from the user's
// request, not only from the physical delta, so clicking ON / entering the current target
// still switches the thermostat zone to persistent manual control.
let fields = command_manual_control_fields(&command);
let updated = send_command_locked_inner(state, device_id, command, true, false).await?;
if !fields.is_empty() {
for mut zone in state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id) {
if zone.device_manual_override && manual_override_matches_baseline(&zone, &updated) {
persist_manual_override_clear(state, &mut zone, source, true)?;
continue;
}
if !zone.enabled && !updated.power {
persist_manual_override_clear(state, &mut zone, source, false)?;
continue;
@@ -263,11 +262,22 @@ pub async fn send_manual_command(state: &AppState, device_id: &str, command: Dev
}
pub async fn force_house_power_off_device(state: &AppState, device_id: &str, source: &str) -> Result<Device, AppError> {
// 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<String> = state.db.list_zones()?.into_iter()
.filter(|zone| zone.device_id == device_id)
.map(|zone| zone.id)
.collect();
zone_ids.sort();
zone_ids.dedup();
let mut _zone_guards = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids {
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let _device_guard = state.lock_device_operation(device_id).await;
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<Device, AppError> {
+9
View File
@@ -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");
+3
View File
@@ -117,6 +117,9 @@ async fn apply_automatic_device_action(
device_id: &str,
command: DeviceCommand,
) -> Result<Option<Device>, 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;
+24 -4
View File
@@ -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)
}
+1
View File
@@ -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(),
};
+6
View File
@@ -37,6 +37,8 @@ pub struct AppState {
pub(crate) automation_operation_lock: Arc<Mutex<()>>,
pub(crate) house_operation_lock: Arc<Mutex<()>>,
pub(crate) configuration_operation_lock: Arc<Mutex<()>>,
/// Serializes thermostat control cycles, including explicit immediate runs requested by group/house control.
pub(crate) zone_control_cycle_lock: Arc<Mutex<()>>,
/// 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<Mutex<HashMap<String, PendingControllerCommand>>>,
@@ -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();
}
+1 -1
View File
@@ -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(),
};
+86 -37
View File
@@ -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 `<article class="list-card zone-config-card ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-config="${esc(zone.id)}">
return `<article class="list-card zone-config-card ${workingDemand ? 'demanding' : ''} ${waitingDemand ? 'lockout-waiting' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''} ${controlGroup ? 'group-controlled' : ''}" data-zone-config="${esc(zone.id)}"${groupControlStyle(controlGroup)}>
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('zones.configuration'))}</span><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(tr('groups.group'))}: ${esc(groupText)}</p></div><button type="button" class="zone-enable-toggle ${zone.enabled ? 'active' : ''}" data-action="zone-enabled" data-id="${esc(zone.id)}" data-value="${zone.enabled ? 'false' : 'true'}" aria-label="${esc(tr(zone.enabled ? 'zones.disable' : 'zones.enable'))}" title="${esc(tr('zones.automationToggleHint'))}"><span>${zone.enabled ? '' : ''}</span>${esc(state)}</button></div>
<div class="zone-config-status">
<div class="zone-config-temperature"><small>${esc(tr('zones.currentStatus'))}</small><div><span>${fmtTemp(roomTemperature)}</span><b></b><strong>${Number.isFinite(target) ? `${target.toFixed(1)}°C` : ''}</strong></div></div>
<div class="zone-config-runtime"><span class="badge ${zone.enabled && zone.demand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
<div class="zone-config-runtime"><span class="badge ${workingDemand ? 'active' : ''}">${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}</span><span>${esc(houseModeLabel(effectiveMode))} · ${esc(zonePresetLabel(displayPreset))}</span><small>${esc(override)}</small><small><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}${zoneControlOwnerMeta(zone) ? ` · ${esc(zoneControlOwnerMeta(zone))}` : ''}</small></div>
</div>
<div class="zone-config-grid">
<div><small>${esc(tr('zones.modePolicy'))}</small><strong>${esc(policy)}</strong></div>
@@ -177,14 +226,14 @@ function zoneCard(zone, detailed = true) {
</article>`;
}
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${zone.demand ? 'demanding' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''}" data-zone-card="${esc(zone.id)}">
return `<article class="list-card zone-thermostat quick-control-card quick-thermostat-control ${workingDemand ? 'demanding' : ''} ${waitingDemand ? 'lockout-waiting' : ''} ${zone.enabled ? '' : 'zone-disabled'} ${deviceManualOverride ? 'manual-takeover' : ''} ${controlGroup ? 'group-controlled' : ''}" data-zone-card="${esc(zone.id)}"${groupControlStyle(controlGroup)}>
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(displayPreset))}</p></div><div class="zone-quick-actions">${localThermostatPowerControl}${temporaryThermostatControl}</div></div>
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(roomTemperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5"></button><div class="target-temp compact editable-target" data-temperature-kind="zone" data-id="${esc(zone.id)}" data-value="${Number.isFinite(target) ? target : ''}" tabindex="0" role="button" title="${esc(tr('common.clickTemperatureToEdit'))}">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
<div class="preset-row quick-control-row quick-control-row-4">
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
</div>
<div class="mode-row zone-mode-row quick-control-row quick-control-row-3"><button class="${mode==='house'&&globalModeAvailable?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house"${globalModeDisabled}>${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
<div class="zone-state-line"><span>${esc(zoneRuntimeStatusLabel(zone, effectiveMode))}</span><span>${esc(override)}</span></div>
<div class="zone-state-line"><span>${esc(zoneRuntimeStatusLabel(zone, effectiveMode, device))}${controlGroup ? ` <b class="group-control-tag">${esc(controlGroup.name)}</b>` : ''}</span><span>${esc(override)}</span></div>
<div class="zone-state-line control-owner-line"><span><strong>${esc(tr('zones.controlOwner'))}:</strong> ${esc(zoneControlOwnerLabel(zone))}</span><span>${esc(zoneControlOwnerMeta(zone))}</span></div>
${manualTakeover}
</article>`;
@@ -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 `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'}">
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div></div>
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 `<article class="list-card group-card ${powerEnabled ? '' : 'group-off'} ${groupIsControlSource ? 'group-controlled' : ''}"${groupControlStyle(groupIsControlSource ? group : null)}>
<div class="list-card-head"><div><span class="eyebrow">${esc(tr('groups.group'))}</span><h3>${esc(group.name)}</h3><p>${esc(memberText)}</p></div>${groupIsControlSource ? `<span class="group-control-badge">${esc(tr('groups.activeControl'))}</span>` : ''}</div>
${detailed ? `<div class="group-state-summary"><div><small>${esc(tr('groups.mode'))}</small><strong>${esc(modeLabelForGroup(state.mode))}</strong></div><div><small>${esc(tr('groups.profile'))}</small><strong>${esc(presetLabelForGroup(state.preset))}</strong></div><div><small>${esc(tr('groups.members'))}</small><strong>${state.zones.length}</strong></div></div>` : ''}
<div class="group-control-block"><small>${esc(tr('common.power'))}</small><div class="group-button-row"><button class="${powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="true">${esc(tr('common.on'))}</button><button class="${!powerEnabled ? 'active' : ''}" data-action="group-power" data-id="${esc(group.id)}" data-value="false">${esc(tr('common.off'))}</button></div></div>
<div class="group-control-block"><small>${esc(tr('groups.mode'))}</small><div class="mode-row group-mode-row">${['house','heat','cool'].map(mode => `<button class="${state.mode === mode ? 'active' : ''}" data-action="group-mode" data-id="${esc(group.id)}" data-value="${mode}">${esc(mode === 'house' ? tr('groups.followHouse') : modeLabel(mode))}</button>`).join('')}</div></div>
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}">${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : ''}" data-action="group-custom-open" data-id="${esc(group.id)}">${esc(tr('preset.custom'))}</button></div>
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button></div></label></div></div>
<div class="group-control-block"><small>${esc(tr('groups.profile'))}</small><div class="preset-row group-preset-row">${['auto','comfort','sleep','away'].map(preset => `<button class="${state.preset === preset ? 'active' : ''}" data-action="group-preset" data-id="${esc(group.id)}" data-value="${preset}">${esc(zonePresetLabel(preset))}</button>`).join('')}<button class="${state.preset === 'custom' ? 'active' : (customDraft?.open === true ? 'editing' : '')}" data-action="group-custom-open" data-id="${esc(group.id)}" title="${esc(tr('groups.customOpenHint'))}">${esc(tr('preset.custom'))}</button></div>
<div class="group-custom-temperature" data-group-custom-editor="${esc(group.id)}" ${customEditorVisible ? '' : 'hidden'}><label><span>${esc(tr('groups.customTemperature'))}</span><div class="group-custom-temperature-row"><input type="text" inputmode="decimal" min="8" max="30" step="0.1" value="${customTarget.toFixed(1)}" data-group-custom-temperature="${esc(group.id)}" aria-label="${esc(tr('groups.customTemperature'))}" title="${esc(tr('groups.customTemperatureHint'))}"><span>°C</span><button type="button" class="primary" data-action="group-custom-temperature" data-id="${esc(group.id)}" title="${esc(tr('groups.applyCustomTemperatureHint'))}">${esc(tr('actions.apply'))}</button><button type="button" class="secondary" data-action="group-custom-cancel" data-id="${esc(group.id)}">${esc(tr('actions.cancel'))}</button></div></label>${customDraftPending ? `<small class="group-custom-draft-note">${esc(tr('groups.customDraftPending'))}</small>` : ''}</div></div>
${masterOff ? `<p class="field-note warning-note">${esc(tr('groups.masterOff'))}</p>` : ''}
${detailed ? `<div class="card-footer"><small>${esc(tr('groups.memberCount', {count: state.zones.length}))}</small><div class="card-menu"><button data-action="edit-group" data-id="${esc(group.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-group" data-id="${esc(group.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
</article>`;
@@ -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);
}
}
+41 -16
View File
@@ -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;
+22 -2
View File
@@ -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) {
+49
View File
@@ -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; }
}