v0.8.20
This commit is contained in:
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.8.19"
|
||||
version = "0.8.20"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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?;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user