This commit is contained in:
Mateusz Gruszczyński
2026-09-01 10:20:19 +02:00
parent 1a5c1305dc
commit 3479ed750d
34 changed files with 575 additions and 113 deletions
Generated
+1 -1
View File
@@ -633,7 +633,7 @@ dependencies = [
[[package]] [[package]]
name = "gree-controller" name = "gree-controller"
version = "0.8.17" version = "0.8.19"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gree-controller" name = "gree-controller"
version = "0.8.17" version = "0.8.19"
edition = "2021" edition = "2021"
authors = ["GREE Controller contributors"] authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+33 -33
View File
@@ -1,11 +1,11 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
d3ee62701b27064418df4f62f43ef4a00528275e8338c2f48b4ea2010e11c10d ./Cargo.lock 6efa62dde5c1b169e60a30bcfaa43ec20c7ada84546f58d33d61e3aba7e56ed1 ./Cargo.lock
6ab182c92c822b5f1685538d1d7f9e22e17ad1a748932fdda6e04444b4eefaf8 ./Cargo.toml 245b9ca11d543ab2bf475e46d24f076f9ac80f4f9f0726f0dcb8356e3099af61 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
4a40bef9464c7da8e46c3a7396b417bafee37fea8e0d89406d025eb4e8e6c47e ./README.md 030f04d570b57bb7d427007125230564fc85ed9037a22908f231a51c9375e7f3 ./README.md
ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs ec0f495ee6a618bdec10b260ee7838f7a872d8c5b0aa91c546226c1ad818f601 ./build.rs
bbac52c61e28b554027902f97044ab5039acf3f58aeca1d5fd204013ffcc0cbf ./docs/API.md 4cc13a859ec285cabcf339a9c817c0aef6da32ce2b79e1c7af43d793e514ece7 ./docs/API.md
95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md 95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.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 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/icon.png
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
548759fff3e2f96352db7b3f96caced9f5d3a57fa4165ab27f22640ad53c1fab ./home-assistant/custom_components/gree_controller/manifest.json 85200f0f91e27e74549f8a7ca9892a6481b328381f15fe51659a5f1efb0c96f5 ./home-assistant/custom_components/gree_controller/manifest.json
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py 38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py 39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.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 13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json 4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md 253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
4b1b820432bd96ba16c0c3a9f363cf6d3a6bdd12b58bdbf7b45a0611ad56324b ./lang/en.json 78bc797f28939a0e0372fe497db44d53a26d31d3a0cae593d2a0319ab1205085 ./lang/en.json
a1eb1b3cab4d9f23c6121259157060ba3375e29c6507bc94095f033170242244 ./lang/pl.json c24adedfe0f66f8518ed5d175de853582000900b8a732e9dac84ebce4ff0c616 ./lang/pl.json
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh 5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
@@ -42,21 +42,21 @@ b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/upda
ffa4d44d5f9f7a3e0e80e34c61ee510ab0f6b4b80a8d6c8137aae2ea74fbab02 ./src/api.rs ffa4d44d5f9f7a3e0e80e34c61ee510ab0f6b4b80a8d6c8137aae2ea74fbab02 ./src/api.rs
50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs 50b653f5ae1c9d24eb2e158fb4af713f5ccabe259c83053fa3cd2a310c7780da ./src/api/assets.rs
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs 6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
bc6dbb4b8fb6972ee9065f3caf36881e4316e70f2b7a7d04e7848839fd0801f3 ./src/api/automations.rs 387bd4e2d9f73ad19acadf388a898d45c8039c4f6cf364681ce3402f0a684f38 ./src/api/automations.rs
05fdfca0e5d0eca7ace3267c4d95409ef12ead087bb9aad73d5a09b5cc7c3de1 ./src/api/debug_tokens.rs 2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
ffa0a35a0c6badbbbaad88dfaedd46049a0bc4ce2f7e8b930dbdbffe5df4cffb ./src/api/devices.rs d048cc3bff5b1817a4155308c9bdc92644bbbf2bf6d75f3d531c51a26035276a ./src/api/devices.rs
1f13d0fffc9d0d0ff0c008645da57291d924a855de4b8fcb98756026d098e60d ./src/api/events.rs 417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
28e6ae79a22ef87037dc6fb43d515878151abe9200a318903b3140fe06f9c2ca ./src/api/groups.rs 242ba0d2267ce009e90d3e593110a116c7ccfb755b676bab44067110f3db92e2 ./src/api/groups.rs
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
f312f8a91d7ce1e60b189e684632edbd68e5496723f49d07aef885699a9f60f3 ./src/api/house.rs 53cb0c85729961d3665840b278889444bc8e5008d3e671922ab41a30b05636dd ./src/api/house.rs
0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs 0d7d598f7ed24315ed0547a60a72a06f20ebcf926489e1c454986f42d22cc305 ./src/api/integrations.rs
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
2abb8d155b832bd7bde575b12ef37fbbf09c246255d9abd2adae0d56016ce170 ./src/api/public_settings.rs 2abb8d155b832bd7bde575b12ef37fbbf09c246255d9abd2adae0d56016ce170 ./src/api/public_settings.rs
eed70af84e9ff9f0ec1b2005836e9ab9aa6a4e73c5c876ec161e5d5c96965b26 ./src/api/schedules.rs e47da65c53ac271ea1f3ed21eb939f6ff1d6e9d87271de194c6b05fa63bd26d8 ./src/api/schedules.rs
a4d0d6c6ffb7944d336a2cf749a27ee5ed966fd94ab29464ce641873783ce756 ./src/api/settings.rs 0f1c14e611db08e33b88e467fd7957b0b0479cd2ee25ea424696cb2039238b88 ./src/api/settings.rs
74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs 74c156717b37e83d329f7c52ac622e02f690621544c4eb07c4502194db55e6e9 ./src/api/system.rs
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs 681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
29b6503aa80dd4ad4e444c4f25ca763adfb60c2de099afbb4fc1131e9d080cfd ./src/api/zones.rs d1d6b49ab6051d6b16aa4648606781843824d41af1b727bf8b12b86b44ea4706 ./src/api/zones.rs
9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs 9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs
369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs 369455b5ffb125f1a80b767f62fd8c84a953adb69548dbd8adb653a6fbaaa45b ./src/db.rs
ae0f7635ba0523c1be980a28314d45445be1354444bcf74a6397da1d6d7c79df ./src/db/climate.rs ae0f7635ba0523c1be980a28314d45445be1354444bcf74a6397da1d6d7c79df ./src/db/climate.rs
@@ -69,22 +69,22 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs 35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs 952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
2d9476fb5d3071b0f49c5844df93768f17f882b0bf05182763ab79628f50e63d ./src/engine/automations.rs 808a65705b31a0887873601c8066958d3b6dc6e876e5b85949ba9cea5a49ddff ./src/engine/automations.rs
832fe59c59a0ab1a0d949b656b194eab7aae578fa1eb30c6d55753d97a508c3b ./src/engine/commands.rs 832fe59c59a0ab1a0d949b656b194eab7aae578fa1eb30c6d55753d97a508c3b ./src/engine/commands.rs
3967cc60a108c71bd7e5d6a99c7caf32ed8f6cbbb63282a5a5d3357e7c07f933 ./src/engine/control_plan.rs 3967cc60a108c71bd7e5d6a99c7caf32ed8f6cbbb63282a5a5d3357e7c07f933 ./src/engine/control_plan.rs
47acda24f31ec6af5ac94b00f52e232f0fdd443106abb9eeca7cc28d26b994bd ./src/engine/deadlines.rs 47acda24f31ec6af5ac94b00f52e232f0fdd443106abb9eeca7cc28d26b994bd ./src/engine/deadlines.rs
445c1f7503c6f01adefa10b5b4414431aa630b739678de7fe6161f3d938b4de2 ./src/engine/groups.rs fabb590ab74c37d6d75b8bcae30b16900dcb6cd97df30937308f765ec5d36528 ./src/engine/groups.rs
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs 18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
dad8930c3d811f02cfeced3aa4b9a404fe273ba1b99539bb174b9430575f3e22 ./src/engine/local_thermostat.rs dad8930c3d811f02cfeced3aa4b9a404fe273ba1b99539bb174b9430575f3e22 ./src/engine/local_thermostat.rs
92b8c4a386fb883f93eb774f3e94545cdf6e48cc674ad7ba40a4c3821c454980 ./src/engine/ownership.rs 2e91d677fa8bef74cd5a56c46b72b3d101b07629724d124b0bbfcc4e7bafaa30 ./src/engine/ownership.rs
88b88dc1e7778bd65d04d58172cfc01b473687264742fd50896cb5eb5f2080f1 ./src/engine/polling.rs 9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs 3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs e0d5347f73066404f9df9d211e059fd10cf054466ae35bdad6533a862b5a816a ./src/engine/targets.rs
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
b453a8841494fd82d8fa4133dcd47dc59715a6a327375b5e13ffbaef431be700 ./src/engine/temporary_thermostat.rs fcd40789045b7cbf168880ff4a7fee12fb4afdc6fd376c8cca9c0cf63390e5a0 ./src/engine/temporary_thermostat.rs
36038bf3f6d5c5ef3a146e99caacfbf20f7636872a8bdfe08866e3037db82f11 ./src/engine/tests.rs 5efbf22c557679fbf88c8c1ade75bc8a65070c92ee69bd23ce1e72f6c40a0b6a ./src/engine/tests.rs
c7e07dcf4419a4a64566620ba2006c5532a6d4576c90cb92855bca4c2deddcf6 ./src/engine/zone_actions.rs 8333115324fea27f52908a00766a56d31138fa2d63574c9ca20d9ea27977d059 ./src/engine/zone_actions.rs
9f95022773670a9c369bac90cf1514a13b289cbd7b497d8faf91d0fe6b33c353 ./src/engine/zone_control.rs 9f95022773670a9c369bac90cf1514a13b289cbd7b497d8faf91d0fe6b33c353 ./src/engine/zone_control.rs
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs 4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
@@ -92,7 +92,7 @@ c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_ass
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs 553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs 5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs 4d45200b03d43d1869760b7cf3281776d268bbaa7860167ea2a533d2f4f12d3d ./src/main.rs
e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs e935e1fcf0d379b627df5d5055dbdad3dd55786fde7191376918b05afdde1727 ./src/models.rs
fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/automation.rs
9a100f778d1f71697dca7ae4db9991a1b7b2da4f26e006ce0612f98c5406acec ./src/models/control_plan.rs 9a100f778d1f71697dca7ae4db9991a1b7b2da4f26e006ce0612f98c5406acec ./src/models/control_plan.rs
@@ -101,8 +101,8 @@ fca124ec30b5c9286f0f44cc18e8953bb2809f5009d4d521deaebfdeb00c1bd1 ./src/models/a
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs 668c9cf157679f8c9fe72846f743629c79e263b14827110834b4addbdf9d13ec ./src/models/integrations.rs
c19f243d709acb59db7bc4a91c02c538146fc5a7c014d90c53e8d8c62c4d6008 ./src/models/runtime.rs c19f243d709acb59db7bc4a91c02c538146fc5a7c014d90c53e8d8c62c4d6008 ./src/models/runtime.rs
0e0d47d88bb6b7b446fe9a89a69cf46dfe041f8f279eda2f5d8f91c296e19312 ./src/models/temporary_thermostat.rs e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
799113b1209cc40c99a463a49fc28aca4d49d10d3e8dc6861bb579f1e3f637d0 ./src/models/zone.rs c78d7f10437c92d9fa9d6227e1de336dd22684c14fd20ee3b50dec28e23b56a7 ./src/models/zone.rs
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs 9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs 7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
@@ -123,27 +123,27 @@ cd5d0befe9f51a7067201a6eaa8d8ed72182d359e42cbdc67c80aa12d722654b ./src/queries/
2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs 2b46a8cdc5efb5f930dd8ff7b9f6c0e720cbca096a51d9ae047711dd280fa3b8 ./src/queries/maintenance.rs
f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs f688db6f440ee7b0129ccc22838de0ee0e0cf2b6376173b05f853ca2555c2c1e ./src/queries/schema.rs
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.rs 5a6d9134310c801a6378598b2057ac1369f8e7c0f42b3eedd8352bb3e5c222ec ./src/state.rs
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html de66cab9b650cb448814bfeda6d216e168dd3e6f07a1a438e4a9de9acbe6b798 ./web/404.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
4df1b4c17f178d8edff61394da9195961f850db4d926d30ae44b04c3120e7a12 ./web/index.html 68fbf1630c68f28e94008e609d33ba2d5b4a922a021c7386714e49e913f289d2 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md 9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js b3d5cb473bf9771de78e14c7de730cb86c73f8cdbb6344da67ba7d6abac879ea ./web/js/bootstrap.js
64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js 64a43e2142a69ebddf4160c0d0d3b573fa379c650431090fd15c9ba3b62bc4e7 ./web/js/charts.js
0f743533728e3be60a30cb7955afc8dcefc13b68f967538e3a6ffbe76cfa601c ./web/js/core.js c14eb49105d83d02f4ca77ee68044ee18304e3a074fb69c3124cf00b45d75c69 ./web/js/core.js
6e421388c7f6deb2aa42f1205dfdc52839f9f178fbd672129972528b8517a870 ./web/js/dashboard.js 6e421388c7f6deb2aa42f1205dfdc52839f9f178fbd672129972528b8517a870 ./web/js/dashboard.js
04247ee5a716376dcb959f6f9d2005b0b058da7bacca2cd7721bccfe0123b07e ./web/js/entities.js e8f1d1966acad8864af001e92e02044c629692f42ab26df700d962b010f4090b ./web/js/entities.js
668b52056d6e083b6adaa72e483cf70eca680144d1f821ca6485b601e340b4aa ./web/js/events.js 44957aa415047f7cce6f55cf4f1f8ea1439159a27512e8426792d4192074e89a ./web/js/events.js
2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js 2fe6c804f86b0613a50a56c54f7a0cbee77273759edf3e6fec36ac77d35edbd7 ./web/js/forms.js
be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js be07a341ba4b1f514a5e13553494194b22aa87108cb04de40635a0158921780b ./web/js/history.js
6d4a9f17ff241eaf44e8d2260163d9226fca3c8d6e1a287d31a2c96b976532f9 ./web/js/main.js 6d4a9f17ff241eaf44e8d2260163d9226fca3c8d6e1a287d31a2c96b976532f9 ./web/js/main.js
963282960efbaf4f52954f828304ed1f452a8b661c500a6fe7c230c0ff3b210a ./web/js/navigation.js 900f1b083bfcef7782a35d1e708d1cd9fd975d89c03d828290400a85b1006826 ./web/js/navigation.js
d375868ded6ffcd6b18d4c661bb2051e7cc5b136f95d4d68355cdd6e0caaf278 ./web/js/realtime.js d375868ded6ffcd6b18d4c661bb2051e7cc5b136f95d4d68355cdd6e0caaf278 ./web/js/realtime.js
45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js 45a666dbc1cddcc0ece507130cde7719f6232c710b4288d6a16e1232791f2a3d ./web/js/router.js
28ff1324f4495b7bcd431eb94f51012c1b1e558f8acb931ac35b71b48374c200 ./web/js/settings-ui.js 28ff1324f4495b7bcd431eb94f51012c1b1e558f8acb931ac35b71b48374c200 ./web/js/settings-ui.js
9d44596929f67400f5f09d82330af4452914f2b4729eadcd4f695672d2cd849c ./web/js/settings.js 9d44596929f67400f5f09d82330af4452914f2b4729eadcd4f695672d2cd849c ./web/js/settings.js
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
98fe010f13bec1f408bcd1a0015e4d866ae2a966812d6a5e76ec47fbf8b0bdfd ./web/styles.css a27f36816edbe82780cac97683ed574cbd4e5e8f782df3eadf930dfbdbd25fd5 ./web/styles.css
03ac3a5918539904ae829ee24999d8d59efb41bdf6c0e30f76bab4cee98c3130 ./web/sw.js 03ac3a5918539904ae829ee24999d8d59efb41bdf6c0e30f76bab4cee98c3130 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+2 -2
View File
@@ -2,7 +2,7 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API. 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.17** **Current release: 0.8.19**
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples. > [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
@@ -28,7 +28,7 @@ The interface is intentionally split by responsibility:
| Dashboard | Whole-house summary and Quick Thermostat controls. | | Dashboard | Whole-house summary and Quick Thermostat controls. |
| Devices | Discovery, binding and direct/technical GREE device control. | | Devices | Discovery, binding and direct/technical GREE device control. |
| Zones | Thermostat configuration, profiles, sensors and control ownership. | | Zones | Thermostat configuration, profiles, sensors and control ownership. |
| Groups | Climate group membership and group power/mode/preset control. | | Groups | Climate group membership and group power/mode/preset/custom-temperature control. |
| Schedules | Weekly thermostat schedules and ready-made templates. | | Schedules | Weekly thermostat schedules and ready-made templates. |
| Automations | Time/temperature triggered device or group actions. | | Automations | Time/temperature triggered device or group actions. |
| History | Overview, zones, devices and HA sensors with precise chart tooltips and zoom. | | History | Overview, zones, devices and HA sensors with precise chart tooltips and zoom. |
+9 -7
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference # GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.8.17**. HTTP and WebSocket API for GREE Controller **0.8.19**.
[← Main documentation](../README.md) [← Main documentation](../README.md)
@@ -137,7 +137,7 @@ Common statuses:
| GET | `/api/groups/{id}` | Read a group. | | GET | `/api/groups/{id}` | Read a group. |
| PUT | `/api/groups/{id}` | Replace group definition. | | PUT | `/api/groups/{id}` | Replace group definition. |
| DELETE | `/api/groups/{id}` | Delete a group. | | DELETE | `/api/groups/{id}` | Delete a group. |
| POST | `/api/groups/{id}/control` | Group power/mode/preset control. | | POST | `/api/groups/{id}/control` | Group power/mode/preset/custom-temperature control. |
| POST | `/api/house/control` | Set global thermostat mode. | | POST | `/api/house/control` | Set global thermostat mode. |
| POST | `/api/house/power` | Set whole-house master power. | | POST | `/api/house/power` | Set whole-house master power. |
| POST | `/api/house/preset` | Set/clear whole-house preset override. | | POST | `/api/house/preset` | Set/clear whole-house preset override. |
@@ -212,7 +212,7 @@ Response:
{ {
"status": "ok", "status": "ok",
"name": "gree-controller", "name": "gree-controller",
"version": "0.8.17", "version": "0.8.19",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"control_ready": true, "control_ready": true,
"time": "2026-08-30T06:54:00Z" "time": "2026-08-30T06:54:00Z"
@@ -236,7 +236,7 @@ Returns the initial Web UI snapshot:
"settings": {}, "settings": {},
"outdoor_temperature": null, "outdoor_temperature": null,
"system": { "system": {
"version": "0.8.17", "version": "0.8.19",
"uptime_seconds": 1234, "uptime_seconds": 1234,
"auth_required": false, "auth_required": false,
"control_ready": true, "control_ready": true,
@@ -655,7 +655,8 @@ Read, replace or delete a group. `DELETE` returns `204`.
{ {
"power": true, "power": true,
"mode": "house", "mode": "house",
"preset": "comfort" "preset": "custom",
"setpoint": 22.3
} }
``` ```
@@ -663,9 +664,10 @@ All fields optional:
- `power`: group gate, - `power`: group gate,
- `mode`: `house`, `cool`, `heat`, - `mode`: `house`, `cool`, `heat`,
- `preset`: `auto`, `comfort`, `sleep`, `away`. - `preset`: `auto`, `comfort`, `sleep`, `away`, `custom`,
- `setpoint`: custom group target in the `830°C` range; requires `preset: "custom"`.
Returns a group control/result object including updated members/state. A custom setpoint creates the same temporary manual override for every member zone and normally expires at that zone's next schedule boundary. Returns a group control/result object including updated members/state.
--- ---
@@ -1,7 +1,7 @@
{ {
"domain": "gree_controller", "domain": "gree_controller",
"name": "GREE Controller", "name": "GREE Controller",
"version": "0.8.17", "version": "0.8.19",
"config_flow": true, "config_flow": true,
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
+8 -3
View File
@@ -559,10 +559,10 @@
"nav.groups": "Groups", "nav.groups": "Groups",
"groups.eyebrow": "Group control", "groups.eyebrow": "Group control",
"groups.dashboardTitle": "Selected air conditioners", "groups.dashboardTitle": "Selected air conditioners",
"groups.dashboardHint": "Control each group independently with its own heating/cooling mode and Auto/Comfort/Sleep/Away profile.", "groups.dashboardHint": "Control each group independently: choose a mode, profile, or set one custom temperature for the whole group.",
"groups.manage": "Manage groups", "groups.manage": "Manage groups",
"groups.new": "New group", "groups.new": "New group",
"groups.description": "Combine thermostat zones, for example upstairs and downstairs, and control only that set. One group can use Sleep + Cooling while another uses Comfort + Heating.", "groups.description": "Combine thermostat zones, for example upstairs and downstairs, and control only that set. A group can use a profile or one custom temperature for all member zones.",
"groups.group": "Group", "groups.group": "Group",
"groups.namePlaceholder": "Upstairs", "groups.namePlaceholder": "Upstairs",
"groups.members": "Group members", "groups.members": "Group members",
@@ -759,6 +759,11 @@
"debug.emptyRequests": "No HTTP requests captured yet.", "debug.emptyRequests": "No HTTP requests captured yet.",
"debug.emptyGree": "No GREE frames captured yet.", "debug.emptyGree": "No GREE frames captured yet.",
"simulation.bannerTitle": "SIMULATION MODE", "simulation.bannerTitle": "SIMULATION MODE",
"simulation.bannerText": "Simulation mode is enabled. Treat current states and results as test data." "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.",
"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."
} }
} }
+8 -3
View File
@@ -559,10 +559,10 @@
"nav.groups": "Grupy", "nav.groups": "Grupy",
"groups.eyebrow": "Sterowanie grupami", "groups.eyebrow": "Sterowanie grupami",
"groups.dashboardTitle": "Wybrane klimatyzatory", "groups.dashboardTitle": "Wybrane klimatyzatory",
"groups.dashboardHint": "Steruj każdą grupą niezależnie: własny tryb grzania/chłodzenia oraz profil Auto/Komfort/Sen/Poza domem.", "groups.dashboardHint": "Steruj każdą grupą niezależnie: wybierz tryb, profil albo ustaw własną temperaturę dla całej grupy.",
"groups.manage": "Zarządzaj grupami", "groups.manage": "Zarządzaj grupami",
"groups.new": "Nowa grupa", "groups.new": "Nowa grupa",
"groups.description": "Połącz strefy termostatów, np. góra i dół, i steruj tylko wybranym zestawem. Jedna grupa może mieć Sen + Chłodzenie, a druga Komfort + Grzanie.", "groups.description": "Połącz strefy termostatów, np. góra i dół, i steruj tylko wybranym zestawem. Grupa może używać profilu lub jednej własnej temperatury dla wszystkich stref.",
"groups.group": "Grupa", "groups.group": "Grupa",
"groups.namePlaceholder": "Góra", "groups.namePlaceholder": "Góra",
"groups.members": "Członkowie grupy", "groups.members": "Członkowie grupy",
@@ -759,6 +759,11 @@
"debug.emptyRequests": "Brak przechwyconych requestów HTTP.", "debug.emptyRequests": "Brak przechwyconych requestów HTTP.",
"debug.emptyGree": "Brak przechwyconych ramek GREE.", "debug.emptyGree": "Brak przechwyconych ramek GREE.",
"simulation.bannerTitle": "TRYB SYMULACJI", "simulation.bannerTitle": "TRYB SYMULACJI",
"simulation.bannerText": "Tryb symulacji jest włączony. Traktuj bieżące stany i wyniki jako testowe." "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.",
"groups.customTemperature": "Własna temperatura",
"groups.customTemperatureHint": "Wpisz temperaturę docelową dla wszystkich stref w tej grupie.",
"groups.applyCustomTemperatureHint": "Ustaw tę temperaturę jako ręczny cel dla całej grupy.",
"groups.customTemperatureRange": "Temperatura grupy musi mieścić się w zakresie 830°C."
} }
} }
+14
View File
@@ -112,7 +112,13 @@ async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}"))) state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
} }
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> { async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await;
input.validate()?; input.validate()?;
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
Some(state.lock_group_operation(group_id).await)
} else { None };
validate_automation_references(&state, &input)?; validate_automation_references(&state, &input)?;
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None); let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
state.db.save_automation(&item)?; state.db.save_automation(&item)?;
@@ -120,8 +126,14 @@ async fn create_automation(State(state): State<AppState>, Json(input): Json<Auto
Ok((StatusCode::CREATED, Json(item))) Ok((StatusCode::CREATED, Json(item)))
} }
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> { async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await;
input.validate()?; input.validate()?;
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
Some(state.lock_group_operation(group_id).await)
} else { None };
validate_automation_references(&state, &input)?; validate_automation_references(&state, &input)?;
let item = input.into_automation(id, existing.created_at, existing.last_fired_at); let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
state.db.save_automation(&item)?; state.db.save_automation(&item)?;
@@ -129,6 +141,8 @@ async fn update_automation(State(state): State<AppState>, Path(id): Path<String>
Ok(Json(item)) Ok(Json(item))
} }
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _automation_guard = state.lock_automation_operation().await;
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); } if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
state.broadcast("automation.deleted", json!({"id": id})); state.broadcast("automation.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
+2
View File
@@ -3,6 +3,8 @@ async fn get_debug(State(state): State<AppState>) -> Json<DebugSettings> {
} }
async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> { async fn update_debug(State(state): State<AppState>, Json(input): Json<DebugSettings>) -> Result<Json<DebugSettings>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _house_guard = state.lock_house_operation().await;
let mut settings = state.settings.write().await; let mut settings = state.settings.write().await;
settings.debug = input.clone(); settings.debug = input.clone();
state.db.save_runtime_settings(&settings)?; state.db.save_runtime_settings(&settings)?;
+18 -1
View File
@@ -1,4 +1,5 @@
async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> { async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000); let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000);
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast); let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
@@ -50,6 +51,7 @@ async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>
} }
async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDeviceRequest>) -> Result<(StatusCode, Json<Device>), AppError> { async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDeviceRequest>) -> Result<(StatusCode, Json<Device>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() { if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() {
return Err(AppError::BadRequest("name, mac and ip are required".into())); return Err(AppError::BadRequest("name, mac and ip are required".into()));
} }
@@ -114,6 +116,7 @@ async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Re
} }
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> { async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
if patch.enabled == Some(false) { if patch.enabled == Some(false) {
engine::disable_device_safely(&state, &id).await?; engine::disable_device_safely(&state, &id).await?;
} }
@@ -145,6 +148,12 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
} }
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { 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.
let _automation_guard = state.lock_automation_operation().await;
let _house_guard = state.lock_house_operation().await;
let _schedule_guard = state.lock_schedule_operation().await;
if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); } if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); }
if state.db.list_automations()?.iter().any(|item| { if state.db.list_automations()?.iter().any(|item| {
item.trigger_device_id.as_deref() == Some(id.as_str()) item.trigger_device_id.as_deref() == Some(id.as_str())
@@ -156,16 +165,24 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
.filter(|zone| zone.device_id == id) .filter(|zone| zone.device_id == id)
.map(|zone| zone.id) .map(|zone| zone.id)
.collect(); .collect();
let mut sorted_zone_ids: Vec<String> = removed_zone_ids.iter().cloned().collect();
sorted_zone_ids.sort();
let mut zone_guards = Vec::with_capacity(sorted_zone_ids.len());
for zone_id in &sorted_zone_ids {
zone_guards.push(state.lock_zone_operation(zone_id).await);
}
ensure_zone_removal_safe(&state, &removed_zone_ids)?; ensure_zone_removal_safe(&state, &removed_zone_ids)?;
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?; ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
remove_zone_ids_from_groups(&state, &removed_zone_ids)?; drop(zone_guards);
remove_zone_ids_from_groups_locked(&state, &removed_zone_ids).await?;
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
state.broadcast("device.deleted", json!({"id": id})); state.broadcast("device.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> { async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _device_guard = state.lock_device_operation(&id).await; let _device_guard = state.lock_device_operation(&id).await;
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
if device.simulated { return Ok(Json(device)); } if device.simulated { return Ok(Json(device)); }
+2
View File
@@ -13,6 +13,8 @@ async fn get_event_retention(State(state): State<AppState>) -> Json<Value> {
} }
async fn update_event_retention(State(state): State<AppState>, Json(input): Json<EventRetentionInput>) -> Result<Json<Value>, AppError> { async fn update_event_retention(State(state): State<AppState>, Json(input): Json<EventRetentionInput>) -> Result<Json<Value>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _house_guard = state.lock_house_operation().await;
let mut settings = state.settings.write().await; let mut settings = state.settings.write().await;
settings.event_log_retention_days = input.days.clamp(1, 3650); settings.event_log_retention_days = input.days.clamp(1, 3650);
state.db.save_runtime_settings(&settings)?; state.db.save_runtime_settings(&settings)?;
+37 -3
View File
@@ -42,6 +42,9 @@ async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Res
} }
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> { async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
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 zone_ids = validate_group_input(&state, &input)?; let zone_ids = validate_group_input(&state, &input)?;
let now = Utc::now(); let now = Utc::now();
let group = ClimateGroup { let group = ClimateGroup {
@@ -59,6 +62,12 @@ async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInpu
} }
async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, AppError> { async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
// Membership changes alter the target set of group automations, so serialize them with
// 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 _group_guard = state.lock_group_operation(&id).await;
let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?; let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
let zone_ids = validate_group_input(&state, &input)?; let zone_ids = validate_group_input(&state, &input)?;
let group = ClimateGroup { let group = ClimateGroup {
@@ -76,6 +85,10 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
} }
async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
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 _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())) { 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())); return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into()));
} }
@@ -99,9 +112,14 @@ fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashS
Ok(()) Ok(())
} }
fn remove_zone_ids_from_groups(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> { async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
if zone_ids.is_empty() { return Ok(()); } if zone_ids.is_empty() { return Ok(()); }
for mut group in state.db.list_groups()? { let mut group_ids: Vec<String> = state.db.list_groups()?.into_iter().map(|group| group.id).collect();
group_ids.sort();
group_ids.dedup();
for group_id in group_ids {
let _group_guard = state.lock_group_operation(&group_id).await;
let Some(mut group) = state.db.get_group(&group_id)? else { continue; };
let before = group.zone_ids.len(); let before = group.zone_ids.len();
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id)); group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
if group.zone_ids.len() == before { continue; } if group.zone_ids.len() == before { continue; }
@@ -141,7 +159,7 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
let mut value: Option<&str> = None; let mut value: Option<&str> = None;
for zone in zones { for zone in zones {
let current = zone.manual_preset.as_deref().unwrap_or("auto"); let current = zone.manual_preset.as_deref().unwrap_or("auto");
if !matches!(current, "auto" | "comfort" | "sleep" | "away") { if !matches!(current, "auto" | "comfort" | "sleep" | "away" | "custom") {
return "mixed".into(); return "mixed".into();
} }
if let Some(previous) = value { if let Some(previous) = value {
@@ -153,6 +171,21 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
value.unwrap_or("mixed").to_string() value.unwrap_or("mixed").to_string()
} }
fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
let mut value: Option<f64> = None;
for zone in zones {
if zone.manual_preset.as_deref() != Some("custom") { return None; }
let current = zone.manual_setpoint.or(zone.effective_setpoint)?;
if let Some(previous) = value {
if (previous - current).abs() > 0.05 { return None; }
} else {
value = Some(current);
}
}
value
}
async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Json<Vec<Value>>, AppError> { async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Json<Vec<Value>>, AppError> {
let groups = state.db.list_groups()?; let groups = state.db.list_groups()?;
let zones = state.db.list_zones()?; let zones = state.db.list_zones()?;
@@ -223,6 +256,7 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
"effective_power": settings.house_power_enabled && group.power_enabled, "effective_power": settings.house_power_enabled && group.power_enabled,
"mode": home_assistant_group_mode(&members), "mode": home_assistant_group_mode(&members),
"preset": home_assistant_group_preset(&members), "preset": home_assistant_group_preset(&members),
"custom_setpoint": home_assistant_group_custom_setpoint(&members),
"house_mode": settings.house_mode, "house_mode": settings.house_mode,
"zone_count": members.len(), "zone_count": members.len(),
"enabled_zones": members.iter().filter(|zone| zone.enabled).count(), "enabled_zones": members.iter().filter(|zone| zone.enabled).count(),
+60 -11
View File
@@ -1,7 +1,14 @@
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct HouseControlPatch { mode: String } struct HouseControlPatch { mode: String }
fn set_all_groups_power(state: &AppState, power: bool) -> Result<(), AppError> { 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();
group_ids.dedup();
let mut _group_guards = Vec::with_capacity(group_ids.len());
for group_id in &group_ids {
_group_guards.push(state.lock_group_operation(group_id).await);
}
for mut group in state.db.list_groups()? { for mut group in state.db.list_groups()? {
if group.power_enabled == power { continue; } if group.power_enabled == power { continue; }
group.power_enabled = power; group.power_enabled = power;
@@ -72,6 +79,7 @@ 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> { 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;
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") { if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
} }
@@ -89,7 +97,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
}; };
state.broadcast("settings.updated", payload.clone()); state.broadcast("settings.updated", payload.clone());
if activate_all { if activate_all {
set_all_groups_power(&state, true)?; set_all_groups_power(&state, true).await?;
// Never send a bare power=true frame. Wake the thermostat arbiter so every unit // 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. // starts only with a valid effective Heat/Cool mode and compressor lockout policy.
state.wake_zone_control(); state.wake_zone_control();
@@ -102,6 +110,7 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
struct HousePowerPatch { power: bool } struct HousePowerPatch { power: bool }
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> { 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;
// Whole-house power is independent from the thermostat mode. Publish/persist the master // 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. // first so the regulator becomes passive before the one-shot OFF cascade starts.
{ {
@@ -117,10 +126,27 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
// Global power is a true cascade across group gates. OFF clears the current takeover // Global power is a true cascade across group gates. OFF clears the current takeover
// markers once, then each enabled device is re-cleared atomically with its OFF command. // 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. // A later pilot action is therefore not erased by subsequent controller cycles.
set_all_groups_power(&state, input.power)?; set_all_groups_power(&state, input.power).await?;
if !input.power { 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();
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 mut device_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.device_id).collect();
device_ids.sort();
device_ids.dedup();
let mut device_guards = Vec::with_capacity(device_ids.len());
for device_id in &device_ids {
device_guards.push(state.lock_device_operation(device_id).await);
}
engine::clear_all_device_manual_overrides(&state, "house_power_off")?; engine::clear_all_device_manual_overrides(&state, "house_power_off")?;
clear_all_local_thermostat_overrides(&state)?; clear_all_local_thermostat_overrides(&state)?;
drop(device_guards);
drop(zone_guards);
} }
let failed = if input.power { let failed = if input.power {
state.wake_zone_control(); state.wake_zone_control();
@@ -151,6 +177,7 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
struct HousePresetPatch { preset: String } struct HousePresetPatch { preset: String }
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> { 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;
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") { if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into())); return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
} }
@@ -164,14 +191,25 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
public_settings(&settings) public_settings(&settings)
}; };
state.broadcast("settings.updated", settings_payload.clone()); state.broadcast("settings.updated", settings_payload.clone());
set_all_groups_power(&state, true)?; set_all_groups_power(&state, true).await?;
let schedules = state.db.list_schedules()?; let schedules = state.db.list_schedules()?;
let mut zones = state.db.list_zones()?; let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
for zone in &mut zones { zone_ids.sort();
if engine::temporary_quick_thermostat_is_active(zone, Utc::now()) { 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 mut zones = Vec::with_capacity(zone_ids.len());
for zone_id in &zone_ids {
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_preset = Some(input.preset.clone()); session.deferred_preset = Some(input.preset.clone());
session.deferred_setpoint = None;
} }
} else if input.preset == "auto" { } else if input.preset == "auto" {
zone.manual_preset = None; zone.manual_preset = None;
@@ -183,8 +221,9 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now()); zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
} }
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
state.db.save_zone(zone)?; state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&*zone)?); state.broadcast("zone.updated", serde_json::to_value(&zone)?);
zones.push(zone);
} }
// As with house mode/power ON, the central thermostat arbiter performs the physical // As with house mode/power ON, the central thermostat arbiter performs the physical
@@ -210,6 +249,8 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
struct ScheduleTemplateRequest { template: String } 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> { 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 zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let mut items: Vec<Schedule> = Vec::new(); let mut items: Vec<Schedule> = Vec::new();
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| { let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
@@ -251,7 +292,7 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
} }
validate_schedule_set(&items)?; validate_schedule_set(&items)?;
state.db.replace_schedules_for_zone(&id, &items)?; state.db.replace_schedules_for_zone(&id, &items)?;
refresh_zone_override_boundary(&state, &id)?; refresh_zone_override_boundary(&state, &id).await?;
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()})); state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
Ok(Json(json!({"zone": zone, "schedules": items}))) Ok(Json(json!({"zone": zone, "schedules": items})))
} }
@@ -261,13 +302,21 @@ async fn update_home_assistant_zone_control(State(state): State<AppState>, Path(
} }
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
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 _schedule_guard = state.lock_schedule_operation().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 zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
let mut removed = std::collections::HashSet::new(); let mut removed = std::collections::HashSet::new();
removed.insert(id.clone()); removed.insert(id.clone());
ensure_zone_removal_safe(&state, &removed)?; ensure_zone_removal_safe(&state, &removed)?;
ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?; ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?;
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
remove_zone_ids_from_groups(&state, &removed)?; // Group control locks group first and zone second. Release the zone lock before taking
// group locks so deletion cannot form the inverse zone -> group lock order.
drop(zone_guard);
remove_zone_ids_from_groups_locked(&state, &removed).await?;
state.broadcast("zone.deleted", json!({"id": id})); state.broadcast("zone.deleted", json!({"id": id}));
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+18 -5
View File
@@ -49,7 +49,14 @@ fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Op
Ok(()) Ok(())
} }
fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> { async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Result<(), AppError> {
let _zone_guard = state.lock_zone_operation(zone_id).await;
let device_id = state.db.get_zone(zone_id)?.map(|zone| zone.device_id);
let _device_guard = if let Some(device_id) = device_id.as_deref() {
Some(state.lock_device_operation(device_id).await)
} else {
None
};
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); }; let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref() let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref()
.map(|session| session.finish_kind == "schedule_boundary") .map(|session| session.finish_kind == "schedule_boundary")
@@ -87,17 +94,21 @@ async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) ->
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}"))) state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
} }
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), AppError> { 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;
input.validate()?; input.validate()?;
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } 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()); let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
validate_schedule_conflicts(&state, &item, None)?; validate_schedule_conflicts(&state, &item, None)?;
state.db.save_schedule(&item)?; state.db.save_schedule(&item)?;
refresh_zone_override_boundary(&state, &item.zone_id)?; refresh_zone_override_boundary(&state, &item.zone_id).await?;
state.broadcast("schedule.created", serde_json::to_value(&item)?); state.broadcast("schedule.created", serde_json::to_value(&item)?);
state.wake_zone_control(); state.wake_zone_control();
Ok((StatusCode::CREATED, Json(item))) Ok((StatusCode::CREATED, Json(item)))
} }
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> { 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;
input.validate()?; input.validate()?;
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; 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())); } if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
@@ -105,16 +116,18 @@ async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>,
let item = input.into_schedule(id.clone(), existing.created_at); let item = input.into_schedule(id.clone(), existing.created_at);
validate_schedule_conflicts(&state, &item, Some(&id))?; validate_schedule_conflicts(&state, &item, Some(&id))?;
state.db.save_schedule(&item)?; state.db.save_schedule(&item)?;
refresh_zone_override_boundary(&state, &old_zone_id)?; refresh_zone_override_boundary(&state, &old_zone_id).await?;
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id)?; } if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id).await?; }
state.broadcast("schedule.updated", serde_json::to_value(&item)?); state.broadcast("schedule.updated", serde_json::to_value(&item)?);
state.wake_zone_control(); state.wake_zone_control();
Ok(Json(item)) Ok(Json(item))
} }
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> { 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 existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; 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}"))); } if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
refresh_zone_override_boundary(&state, &existing.zone_id)?; refresh_zone_override_boundary(&state, &existing.zone_id).await?;
state.broadcast("schedule.deleted", json!({"id": id})); state.broadcast("schedule.deleted", json!({"id": id}));
state.wake_zone_control(); state.wake_zone_control();
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
+54 -8
View File
@@ -4,6 +4,8 @@ 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> { 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 old = state.settings.read().await.clone(); let old = state.settings.read().await.clone();
if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode { if input.house_power_enabled != old.house_power_enabled || input.house_mode != old.house_mode {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
@@ -46,7 +48,7 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); } if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); }
} }
state.db.save_runtime_settings(&input)?; state.db.save_runtime_settings(&input)?;
canonicalize_saved_zone_entities(&state, &input)?; canonicalize_saved_zone_entities(&state, &input).await?;
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed); state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = input.clone(); *state.settings.write().await = input.clone();
state.log("info", "settings.updated", "Settings updated", json!({})); state.log("info", "settings.updated", "Settings updated", json!({}));
@@ -84,8 +86,15 @@ fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured)); zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
} }
fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> { async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
for mut zone in state.db.list_zones()? { let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
let previous = zone.ha_entity_id.clone(); let previous = zone.ha_entity_id.clone();
canonicalize_zone_ha_entity(&mut zone, settings); canonicalize_zone_ha_entity(&mut zone, settings);
if zone.ha_entity_id != previous { if zone.ha_entity_id != previous {
@@ -314,6 +323,36 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
validate_night_mode(&mut export.settings)?; validate_night_mode(&mut export.settings)?;
export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650); export.settings.influxdb.history_threshold_days = export.settings.influxdb.history_threshold_days.clamp(1, 3650);
// Configuration replacement is the broadest mutation in the application. Serialize it
// against every structural editor and every live owner that can write zone/device state.
// Global lock order: configuration -> automation -> house -> schedule -> zones -> devices.
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 _schedule_guard = state.lock_schedule_operation().await;
let current_zones = state.db.list_zones()?;
let current_devices = state.db.list_devices()?;
let mut locked_zone_ids: Vec<String> = current_zones.iter().map(|zone| zone.id.clone())
.chain(export.zones.iter().map(|zone| zone.id.clone()))
.collect();
locked_zone_ids.sort();
locked_zone_ids.dedup();
let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len());
for zone_id in &locked_zone_ids {
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let mut locked_device_ids: Vec<String> = current_devices.iter().map(|device| device.id.clone())
.chain(export.devices.iter().map(|device| device.id.clone()))
.collect();
locked_device_ids.sort();
locked_device_ids.dedup();
let mut _device_guards = Vec::with_capacity(locked_device_ids.len());
for device_id in &locked_device_ids {
_device_guards.push(state.lock_device_operation(device_id).await);
}
// Before replacing ownership, safely stop every currently managed device whose zone is // Before replacing ownership, safely stop every currently managed device whose zone is
// removed or rewired by the imported configuration. Otherwise an orphaned physical unit // removed or rewired by the imported configuration. Otherwise an orphaned physical unit
// could keep running after its database owner disappears. // could keep running after its database owner disappears.
@@ -321,13 +360,20 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
.map(|zone| (zone.id.clone(), zone.device_id.clone())) .map(|zone| (zone.id.clone(), zone.device_id.clone()))
.collect(); .collect();
let mut detach_devices = std::collections::HashSet::new(); let mut detach_devices = std::collections::HashSet::new();
for current in state.db.list_zones()? { for current in &current_zones {
if imported_zone_map.get(&current.id).map(String::as_str) != Some(current.device_id.as_str()) { if imported_zone_map.get(&current.id).map(String::as_str) != Some(current.device_id.as_str()) {
detach_devices.insert(current.device_id); detach_devices.insert(current.device_id.clone());
} }
} }
for device_id in detach_devices { for device_id in detach_devices {
ensure_device_stopped_for_detach(&state, &device_id, "configuration.import").await?; let Some(device) = state.db.get_device(&device_id)? else { continue; };
if !device.enabled {
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
}
engine::force_power_off_device_locked(&state, &device_id).await?;
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
"device_id": device.id, "source": "configuration.import"
}));
} }
// Configuration import never restores ephemeral owners/timers or cached physical state. // Configuration import never restores ephemeral owners/timers or cached physical state.
@@ -353,7 +399,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
.map(|zone| zone.device_id.clone()) .map(|zone| zone.device_id.clone())
.collect(); .collect();
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) { for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
if let Err(err) = engine::force_power_off_device(&state, &device.id).await { if let Err(err) = engine::force_power_off_device_locked(&state, &device.id).await {
state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id})); state.log("error", "settings.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
return Err(err); return Err(err);
} }
@@ -361,7 +407,7 @@ async fn import_settings(State(state): State<AppState>, Json(mut export): Json<C
// Rebuild live device snapshots before allowing the thermostat loop to make decisions. // Rebuild live device snapshots before allowing the thermostat loop to make decisions.
// Network failures are represented in device health by poll_one rather than reviving // Network failures are represented in device health by poll_one rather than reviving
// imported cache values. // imported cache values.
engine::poll_all(&state).await?; engine::poll_all_locked(&state).await?;
state.initial_device_sync_complete.store(true, Ordering::Release); state.initial_device_sync_complete.store(true, Ordering::Release);
state.wake_zone_control(); state.wake_zone_control();
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version})); state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
+30 -8
View File
@@ -128,9 +128,15 @@ async fn get_zone(State(state): State<AppState>, Path(id): Path<String>) -> Resu
state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}"))) state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}")))
} }
async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> { async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>) -> Result<(StatusCode, Json<Zone>), AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _reference_guard = state.lock_automation_operation().await;
input.validate()?; input.validate()?;
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
validate_zone_device_assignment(&state, &input.device_id, None)?; validate_zone_device_assignment(&state, &input.device_id, None)?;
// Creating thermostat ownership must not overlap a poll of the device. Otherwise a poll
// that started before the zone existed could apply its old physical-control snapshot to
// the newly created zone without participating in the zone operation lock.
let _device_guard = state.lock_device_operation(&input.device_id).await;
let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now()); let mut zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now());
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
canonicalize_zone_ha_entity(&mut zone, &settings); canonicalize_zone_ha_entity(&mut zone, &settings);
@@ -139,6 +145,8 @@ async fn create_zone(State(state): State<AppState>, Json(input): Json<ZoneInput>
Ok((StatusCode::CREATED, Json(zone))) 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> { async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ZoneInput>) -> Result<Json<Zone>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _reference_guard = state.lock_automation_operation().await;
input.validate()?; input.validate()?;
let _zone_guard = state.lock_zone_operation(&id).await; let _zone_guard = state.lock_zone_operation(&id).await;
let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; let mut existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
@@ -150,10 +158,16 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); }
validate_zone_device_assignment(&state, &input.device_id, Some(&id))?; validate_zone_device_assignment(&state, &input.device_id, Some(&id))?;
let device_changed = existing.device_id != input.device_id; let device_changed = existing.device_id != input.device_id;
// Serialize a normal zone edit with polling/manual-takeover detection for its device. // Keep the zone lock while taking all involved device locks in stable order. This makes a
// Device reassignment uses ensure_device_stopped_for_detach below, which acquires the // reassignment atomic against polling of both the old and the new unit and preserves the
// old device lock itself while this zone lock is held. // global zone -> device ordering used by live control paths.
let _device_guard = if !device_changed { Some(state.lock_device_operation(&existing.device_id).await) } else { None }; let mut locked_device_ids = vec![existing.device_id.clone(), input.device_id.clone()];
locked_device_ids.sort();
locked_device_ids.dedup();
let mut device_guards = Vec::with_capacity(locked_device_ids.len());
for device_id in &locked_device_ids {
device_guards.push(state.lock_device_operation(device_id).await);
}
if !device_changed { if !device_changed {
// Polling may have updated takeover/runtime state while we were waiting for the // Polling may have updated takeover/runtime state while we were waiting for the
// device lock. Re-read under both locks before building the replacement Zone. // device lock. Re-read under both locks before building the replacement Zone.
@@ -203,7 +217,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
} else { } else {
// A new physical unit starts with a clean ownership/runtime state. Never transfer // A new physical unit starts with a clean ownership/runtime state. Never transfer
// demand, sensor cache or remote-control takeover from the previous device. // demand, sensor cache or remote-control takeover from the previous device.
ensure_device_stopped_for_detach(&state, &existing.device_id, "zone.device_reassigned").await?; ensure_device_stopped_for_detach_locked(&state, &existing.device_id, "zone.device_reassigned").await?;
zone.revision = existing.revision.saturating_add(1); zone.revision = existing.revision.saturating_add(1);
} }
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
@@ -223,7 +237,7 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
} }
state.db.save_zone(&zone)?; state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.broadcast("zone.updated", serde_json::to_value(&zone)?);
drop(_device_guard); drop(device_guards);
if power_off_device { if power_off_device {
power_off_zone_device(&state, &zone, "zone.disabled").await; power_off_zone_device(&state, &zone, "zone.disabled").await;
} }
@@ -473,6 +487,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
} else { None }, } else { None },
deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()), deferred_mode: existing_session.as_ref().and_then(|session| session.deferred_mode.clone()),
deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()), deferred_preset: existing_session.as_ref().and_then(|session| session.deferred_preset.clone()),
deferred_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint),
safety_expires_at: if immediate_activation && is_temperature_condition { safety_expires_at: if immediate_activation && is_temperature_condition {
safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64)) safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
} else { safety_expires_at }, } else { safety_expires_at },
@@ -546,6 +561,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_preset = Some(value.to_string()); session.deferred_preset = Some(value.to_string());
if value != "custom" { session.deferred_setpoint = None; }
} }
} else { } else {
match value { match value {
@@ -571,6 +587,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) { if engine::temporary_quick_thermostat_is_active(&zone, Utc::now()) {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
session.deferred_preset = Some("auto".into()); session.deferred_preset = Some("auto".into());
session.deferred_setpoint = None;
} }
} else { } else {
zone.manual_preset = None; zone.manual_preset = None;
@@ -639,20 +656,25 @@ async fn update_zone_control(State(state): State<AppState>, Path(id): Path<Strin
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> { async fn ensure_device_stopped_for_detach_locked(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
let Some(device) = state.db.get_device(device_id)? else { return Ok(()); }; let Some(device) = state.db.get_device(device_id)? else { return Ok(()); };
if !device.enabled { if !device.enabled {
return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into())); return Err(AppError::BadRequest("cannot safely detach a technically disabled device; enable it so the controller can confirm it is powered off first".into()));
} }
// Force one OFF transition even when the cached state already says OFF. A remote change // Force one OFF transition even when the cached state already says OFF. A remote change
// may not have been polled yet and detaching must not leave a running unit without owner. // may not have been polled yet and detaching must not leave a running unit without owner.
engine::force_power_off_device(state, device_id).await?; engine::force_power_off_device_locked(state, device_id).await?;
state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({ state.log("info", "zone.detach_power_off", &format!("Powered off {} before detaching thermostat ownership", device.name), json!({
"device_id": device.id, "source": source "device_id": device.id, "source": source
})); }));
Ok(()) Ok(())
} }
async fn ensure_device_stopped_for_detach(state: &AppState, device_id: &str, source: &str) -> Result<(), AppError> {
let _device_guard = state.lock_device_operation(device_id).await;
ensure_device_stopped_for_detach_locked(state, device_id, source).await
}
async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) { async fn power_off_zone_device(state: &AppState, zone: &Zone, source: &str) {
let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; }; let Ok(Some(device)) = state.db.get_device(&zone.device_id) else { return; };
if !device.enabled { return; } if !device.enabled { return; }
+16 -3
View File
@@ -1,8 +1,6 @@
async fn run_automations(state: &AppState) -> Result<()> { async fn run_automations(state: &AppState) -> Result<()> {
if !state.settings.read().await.house_power_enabled { return Ok(()); } if !state.settings.read().await.house_power_enabled { return Ok(()); }
let devices = state.db.list_devices()?; let devices = state.db.list_devices()?;
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
let mut automations = state.db.list_automations()?; let mut automations = state.db.list_automations()?;
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID. // Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
// This avoids database row order deciding the physical outcome (M2). // This avoids database row order deciding the physical outcome (M2).
@@ -20,6 +18,20 @@ async fn run_automations(state: &AppState) -> Result<()> {
_ => false, _ => false,
}; };
if !should_fire { continue; } if !should_fire { continue; }
// API edits/deletes and execution share one short ownership window. If the rule
// changed since this cycle snapshot was taken, skip it now and evaluate the new
// definition on the next cycle instead of firing stale configuration.
let _automation_guard = state.lock_automation_operation().await;
let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; };
if latest_item.updated_at != item.updated_at { continue; }
item = latest_item;
// Group membership and zone ownership may have changed after the cycle snapshot but
// before we acquired the automation lock. Reload them inside this serialized window so
// same-cycle conflict arbitration claims the actual current target set.
let zones = state.db.list_zones()?;
let groups = state.db.list_groups()?;
if item.action_group_id.is_none() if item.action_group_id.is_none()
&& device_blocked_by_disabled_zone(&item.action_device_id, &zones) && device_blocked_by_disabled_zone(&item.action_device_id, &zones)
&& item.action.power != Some(true) && item.action.power != Some(true)
@@ -75,7 +87,8 @@ async fn run_automations(state: &AppState) -> Result<()> {
power: item.action.power, power: item.action.power,
mode: group_mode, mode: group_mode,
preset: item.action_preset.clone(), preset: item.action_preset.clone(),
}, "automation.group").await.map(|_| true) setpoint: None,
}, "automation.group").await.map(|value| !value.get("suppressed").and_then(Value::as_bool).unwrap_or(false))
} else { } else {
match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await { match apply_automatic_device_action(state, &item.action_device_id, item.action.clone()).await {
Ok(Some(_)) => Ok(true), Ok(Some(_)) => Ok(true),
+55 -8
View File
@@ -5,15 +5,52 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
} }
} }
if let Some(preset) = patch.preset.as_deref() { if let Some(preset) = patch.preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") { if !matches!(preset, "auto" | "comfort" | "sleep" | "away" | "custom") {
return Err(AppError::BadRequest("group preset must be auto, comfort, sleep or away".into())); return Err(AppError::BadRequest("group preset must be auto, comfort, sleep, away or custom".into()));
}
}
if patch.preset.as_deref() == Some("custom") && patch.setpoint.is_none() {
return Err(AppError::BadRequest("group custom preset requires a setpoint".into()));
}
if let Some(setpoint) = patch.setpoint {
if !(8.0..=30.0).contains(&setpoint) {
return Err(AppError::BadRequest("group setpoint must be between 8 and 30 C".into()));
}
if patch.preset.as_deref() != Some("custom") {
return Err(AppError::BadRequest("group setpoint requires preset=custom".into()));
} }
} }
// House/group actions share one ordering domain. This prevents a concurrent group ON
// (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;
let _group_guard = state.lock_group_operation(group_id).await;
let mut group = state.db.get_group(group_id)? let mut group = state.db.get_group(group_id)?
.ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?; .ok_or_else(|| AppError::NotFound(format!("group {group_id}")))?;
if source == "automation.group" && !state.settings.read().await.house_power_enabled {
state.log("info", "automation.blocked_by_house_power", &format!("Group automation suppressed while whole-house power is off for {}", group.name), json!({
"group_id": group.id, "source": source
}));
return Ok(json!({
"group": group,
"zones": [],
"devices": state.db.list_devices()?,
"failed": [],
"master_power_enabled": false,
"suppressed": true,
}));
}
let mut locked_zone_ids = group.zone_ids.clone();
locked_zone_ids.sort();
locked_zone_ids.dedup();
let mut _zone_guards = Vec::with_capacity(locked_zone_ids.len());
for zone_id in &locked_zone_ids {
_zone_guards.push(state.lock_zone_operation(zone_id).await);
}
let schedules = state.db.list_schedules()?; let schedules = state.db.list_schedules()?;
let climate_change = patch.mode.is_some() || patch.preset.is_some(); let custom_setpoint = patch.setpoint.map(|value| (value * 10.0).round() / 10.0);
let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some();
if let Some(power) = patch.power { if let Some(power) = patch.power {
group.power_enabled = power; group.power_enabled = power;
} }
@@ -24,7 +61,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
// Explicit group ON is a conscious request to run this group. Resume the global // Explicit group ON is a conscious request to run this group. Resume the global
// master without changing the gates of any other groups. This makes group ON work // master without changing the gates of any other groups. This makes group ON work
// even after a previous whole-house OFF while preserving multi-group OFF priority. // even after a previous whole-house OFF while preserving multi-group OFF priority.
if patch.power == Some(true) { if patch.power == Some(true) && source != "automation.group" {
let mut settings = state.settings.write().await; let mut settings = state.settings.write().await;
if !settings.house_power_enabled { if !settings.house_power_enabled {
settings.house_power_enabled = true; settings.house_power_enabled = true;
@@ -38,7 +75,6 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let mut zones = Vec::new(); let mut zones = Vec::new();
for zone_id in &group.zone_ids { for zone_id in &group.zone_ids {
let _zone_guard = state.lock_zone_operation(zone_id).await;
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; }; let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await; let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; }; let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
@@ -46,7 +82,11 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
if temporary_owns_zone { if temporary_owns_zone {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() { if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); } if let Some(mode) = patch.mode.as_deref() { session.deferred_mode = Some(mode.to_string()); }
if let Some(preset) = patch.preset.as_deref() { session.deferred_preset = Some(preset.to_string()); } if let Some(preset) = patch.preset.as_deref() {
session.deferred_preset = Some(preset.to_string());
if preset != "custom" { session.deferred_setpoint = None; }
}
if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); }
} }
if climate_change { if climate_change {
state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({ state.log("info", "group.control_deferred_by_temporary_thermostat", &format!("Group climate change deferred for {} while Temporary Quick Thermostat owns the zone", zone.name), json!({
@@ -71,10 +111,17 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
zone.manual_override_until = None; zone.manual_override_until = None;
} else { } else {
zone.manual_preset = Some(preset.to_string()); zone.manual_preset = Some(preset.to_string());
zone.manual_setpoint = None; if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now()); zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
} }
} }
if let Some(setpoint) = custom_setpoint {
zone.setpoint = setpoint;
zone.manual_preset = Some("custom".into());
zone.manual_setpoint = Some(setpoint);
zone.effective_setpoint = Some(setpoint);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &schedules, Local::now());
}
} }
zone.revision = zone.revision.saturating_add(1); zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now(); zone.updated_at = Utc::now();
@@ -115,7 +162,7 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
state.wake_zone_control(); state.wake_zone_control();
} }
state.log("info", source, &format!("Updated group {}", group.name), json!({ 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, "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, "zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
})); }));
Ok(json!({ Ok(json!({
+9 -1
View File
@@ -233,7 +233,9 @@ async fn detect_external_device_control(state: &AppState, before: &Device, after
pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> { pub async fn send_manual_command(state: &AppState, device_id: &str, command: DeviceCommand, source: &str) -> Result<Device, AppError> {
// Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits. // Keep zone -> device lock ordering consistent with Quick Thermostat/full-zone edits.
// A device belongs to at most one thermostat zone, but keep this generic for legacy data. // A device belongs to at most one thermostat zone, but keep this generic for legacy data.
let zone_ids: Vec<String> = state.db.list_zones()?.into_iter().filter(|zone| zone.device_id == device_id).map(|zone| zone.id).collect(); 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::new(); let mut _zone_guards = Vec::new();
for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); } for zone_id in &zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
// Keep the device lock until the zone takeover marker is persisted. Otherwise a poll // Keep the device lock until the zone takeover marker is persisted. Otherwise a poll
@@ -282,6 +284,12 @@ pub async fn force_power_off_device(state: &AppState, device_id: &str) -> Result
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
} }
/// Same safety transition for callers that already hold the per-device operation lock.
/// Keeping this separate avoids recursive lock acquisition during atomic configuration import.
pub async fn force_power_off_device_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
send_command_locked_forced(state, device_id, DeviceCommand { power: Some(false), ..Default::default() }).await
}
/// Technical device disable is a safety transition, not just a database flag. The unit is /// Technical device disable is a safety transition, not just a database flag. The unit is
/// explicitly powered off while it is still commandable, then removed from controller polling. /// explicitly powered off while it is still commandable, then removed from controller polling.
pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<Device, AppError> { pub async fn disable_device_safely(state: &AppState, device_id: &str) -> Result<Device, AppError> {
+33
View File
@@ -1,8 +1,27 @@
async fn lock_poll_zone_operations(state: &AppState, device_id: &str) -> Result<Vec<tokio::sync::OwnedMutexGuard<()>>, AppError> {
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 guards = Vec::with_capacity(zone_ids.len());
for zone_id in zone_ids {
guards.push(state.lock_zone_operation(&zone_id).await);
}
Ok(guards)
}
pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> { pub async fn poll_one(state: &AppState, device_id: &str) -> Result<Device, AppError> {
// Polling can update zone ownership when it detects physical/pilot control. Acquire
// the same zone -> device lock order used by thermostat/manual actions so those writes
// cannot race and overwrite a fresh override or hand-back state.
let _zone_guards = lock_poll_zone_operations(state, device_id).await?;
let _device_guard = state.lock_device_operation(device_id).await; let _device_guard = state.lock_device_operation(device_id).await;
poll_one_locked(state, device_id).await poll_one_locked(state, device_id).await
} }
// Caller must hold the device lock and every current zone lock associated with this device.
async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> { async fn poll_one_locked(state: &AppState, device_id: &str) -> Result<Device, AppError> {
let mut device = state.db.get_device(device_id)? let mut device = state.db.get_device(device_id)?
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
@@ -26,12 +45,26 @@ pub(crate) async fn poll_all(state: &AppState) -> Result<()> {
.map(|device| device.id) .map(|device| device.id)
.collect(); .collect();
for device_id in device_ids { for device_id in device_ids {
let _zone_guards = lock_poll_zone_operations(state, &device_id).await?;
let _device_guard = state.lock_device_operation(&device_id).await; let _device_guard = state.lock_device_operation(&device_id).await;
let _ = poll_one_locked(state, &device_id).await?; let _ = poll_one_locked(state, &device_id).await?;
} }
Ok(()) Ok(())
} }
/// Poll all enabled devices when the caller already holds the corresponding zone and device locks.
/// Used by configuration import so no command/poll can interleave with the replacement.
pub(crate) async fn poll_all_locked(state: &AppState) -> Result<()> {
let device_ids: Vec<String> = state.db.list_devices()?.into_iter()
.filter(|device| device.enabled)
.map(|device| device.id)
.collect();
for device_id in device_ids {
let _ = poll_one_locked(state, &device_id).await?;
}
Ok(())
}
async fn poll_device(state: &AppState, device: &mut Device) { async fn poll_device(state: &AppState, device: &mut Device) {
if device.simulated { if device.simulated {
simulate_tick(device); simulate_tick(device);
+19 -1
View File
@@ -73,8 +73,20 @@ pub fn finish_temporary_quick_thermostat(zone: &mut Zone, schedules: &[Schedule]
zone.manual_preset = None; zone.manual_preset = None;
zone.manual_setpoint = None; zone.manual_setpoint = None;
zone.manual_override_until = None; zone.manual_override_until = None;
} else if matches!(preset, "comfort" | "sleep" | "away") { } else if matches!(preset, "comfort" | "sleep" | "away" | "custom") {
zone.manual_preset = Some(preset.to_string()); zone.manual_preset = Some(preset.to_string());
if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now());
}
}
// A deferred custom temperature is valid only while the final deferred preset is custom.
// This makes the hand-back robust even if an older persisted session contains a stale
// deferred_setpoint from a previously selected Custom action.
if session.deferred_preset.as_deref() == Some("custom") {
if let Some(setpoint) = session.deferred_setpoint {
zone.setpoint = setpoint;
zone.manual_preset = Some("custom".into());
zone.manual_setpoint = Some(setpoint);
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now()); zone.manual_override_until = next_schedule_boundary_utc(&zone.id, schedules, Local::now());
} }
} }
@@ -88,6 +100,8 @@ async fn expire_temporary_quick_thermostats(state: &AppState, zones: &mut [Zone]
for zone in zones.iter_mut() { for zone in zones.iter_mut() {
let zone_id = zone.id.clone(); let zone_id = zone.id.clone();
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
*zone = latest; *zone = latest;
let active_under_manual = zone.device_manual_override let active_under_manual = zone.device_manual_override
@@ -153,6 +167,8 @@ async fn activate_due_temporary_quick_thermostats(
for zone in zones.iter_mut() { for zone in zones.iter_mut() {
let zone_id = zone.id.clone(); let zone_id = zone.id.clone();
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
*zone = latest; *zone = latest;
let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref() let Some((session_state, started_at)) = zone.temporary_quick_thermostat.as_ref()
@@ -330,6 +346,8 @@ async fn expire_local_thermostat_overrides(state: &AppState, zones: &mut [Zone],
for zone in zones.iter_mut() { for zone in zones.iter_mut() {
let zone_id = zone.id.clone(); let zone_id = zone.id.clone();
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(snapshot) = state.db.get_zone(&zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&snapshot.device_id).await;
let Some(latest) = state.db.get_zone(&zone_id)? else { continue; }; let Some(latest) = state.db.get_zone(&zone_id)? else { continue; };
*zone = latest; *zone = latest;
// A direct device/pilot takeover has higher priority than the local-OFF hand-back. // A direct device/pilot takeover has higher priority than the local-OFF hand-back.
+15
View File
@@ -233,6 +233,7 @@ mod tests {
paused_at: None, paused_at: None,
deferred_mode: None, deferred_mode: None,
deferred_preset: None, deferred_preset: None,
deferred_setpoint: None,
safety_expires_at: None, safety_expires_at: None,
} }
} }
@@ -309,6 +310,20 @@ mod tests {
assert_eq!(zone.manual_setpoint, Some(22.0)); assert_eq!(zone.manual_setpoint, Some(22.0));
} }
#[test]
fn deferred_non_custom_preset_wins_over_stale_custom_setpoint() {
let now = Utc::now();
let mut zone = test_zone("device");
let mut session = temporary_session(now);
session.deferred_preset = Some("comfort".into());
session.deferred_setpoint = Some(27.0);
zone.temporary_quick_thermostat = Some(session);
assert!(finish_temporary_quick_thermostat(&mut zone, &[], "cool"));
assert_eq!(zone.manual_preset.as_deref(), Some("comfort"));
assert!(zone.manual_setpoint.is_none());
}
#[test] #[test]
fn temporary_session_without_activation_marker_is_never_active() { fn temporary_session_without_activation_marker_is_never_active() {
let now = Utc::now(); let now = Utc::now();
+10 -1
View File
@@ -123,6 +123,7 @@ async fn apply_automatic_device_action(
}; };
let _zone_guard = state.lock_zone_operation(&zone_id).await; let _zone_guard = state.lock_zone_operation(&zone_id).await;
let _device_guard = state.lock_device_operation(device_id).await;
let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?; let mut zone = state.db.get_zone(&zone_id)?.ok_or_else(|| AppError::NotFound(format!("zone {zone_id}")))?;
let settings = state.settings.read().await.clone(); let settings = state.settings.read().await.clone();
if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() { if !settings.house_power_enabled || zone.device_manual_override || zone.local_thermostat_power.is_some() {
@@ -173,7 +174,14 @@ async fn apply_automatic_device_action(
} }
if command.power == Some(false) { if command.power == Some(false) {
return force_power_off_device(state, device_id).await.map(Some); // We already hold the device lock and have re-checked ownership under it. Keep that
// lock through the physical OFF so a poll/manual-takeover update cannot slip between
// the durable zone transition and the device command.
return send_command_locked_forced(
state,
device_id,
DeviceCommand { power: Some(false), ..Default::default() },
).await.map(Some);
} }
// Climate fields above are durable zone state. Only non-climate device capabilities remain // Climate fields above are durable zone state. Only non-climate device capabilities remain
@@ -196,6 +204,7 @@ async fn apply_automatic_device_action(
if residual.is_empty() { if residual.is_empty() {
return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}"))); return state.db.get_device(device_id)?.map(Some).ok_or_else(|| AppError::NotFound(format!("device {device_id}")));
} }
drop(_device_guard);
send_automatic_device_command_if_owned(state, device_id, residual).await send_automatic_device_command_if_owned(state, device_id, residual).await
} }
+5
View File
@@ -70,6 +70,11 @@ async fn main() -> Result<()> {
zone_control_wakeup: Arc::new(Notify::new()), zone_control_wakeup: Arc::new(Notify::new()),
device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), device_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
zone_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), zone_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
group_operation_locks: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
schedule_operation_lock: Arc::new(tokio::sync::Mutex::new(())),
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(())),
pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), pending_controller_commands: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
started: Instant::now(), started: Instant::now(),
}; };
+3
View File
@@ -74,6 +74,9 @@ pub struct TemporaryQuickThermostat {
pub deferred_mode: Option<String>, pub deferred_mode: Option<String>,
#[serde(default)] #[serde(default)]
pub deferred_preset: Option<String>, pub deferred_preset: Option<String>,
/// Group custom target received while the temporary thermostat owns the zone.
#[serde(default)]
pub deferred_setpoint: Option<f64>,
/// Optional fail-safe for temperature-based modes. /// Optional fail-safe for temperature-based modes.
#[serde(default)] #[serde(default)]
pub safety_expires_at: Option<DateTime<Utc>>, pub safety_expires_at: Option<DateTime<Utc>>,
+4 -1
View File
@@ -193,9 +193,12 @@ pub struct GroupControlPatch {
/// house follows the global house mode; cool/heat set an explicit mode on every member zone. /// house follows the global house mode; cool/heat set an explicit mode on every member zone.
#[serde(default)] #[serde(default)]
pub mode: Option<String>, pub mode: Option<String>,
/// auto clears temporary overrides; comfort/sleep/away apply a temporary preset to every member zone. /// auto clears temporary overrides; comfort/sleep/away/custom apply a temporary preset to every member zone.
#[serde(default)] #[serde(default)]
pub preset: Option<String>, pub preset: Option<String>,
/// Optional custom target temperature for the whole group. Used together with preset=custom.
#[serde(default)]
pub setpoint: Option<f64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
+29
View File
@@ -32,6 +32,11 @@ pub struct AppState {
pub zone_control_wakeup: Arc<Notify>, pub zone_control_wakeup: Arc<Notify>,
pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>, pub(crate) device_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub(crate) zone_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>, pub(crate) zone_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub(crate) group_operation_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
pub(crate) schedule_operation_lock: Arc<Mutex<()>>,
pub(crate) automation_operation_lock: Arc<Mutex<()>>,
pub(crate) house_operation_lock: Arc<Mutex<()>>,
pub(crate) configuration_operation_lock: Arc<Mutex<()>>,
/// Short-lived expected climate state from controller-originated commands. It prevents /// Short-lived expected climate state from controller-originated commands. It prevents
/// a delayed GREE status update from being mistaken for remote/manual takeover. /// a delayed GREE status update from being mistaken for remote/manual takeover.
pub(crate) pending_controller_commands: Arc<Mutex<HashMap<String, PendingControllerCommand>>>, pub(crate) pending_controller_commands: Arc<Mutex<HashMap<String, PendingControllerCommand>>>,
@@ -55,6 +60,30 @@ impl AppState {
lock.lock_owned().await lock.lock_owned().await
} }
pub async fn lock_group_operation(&self, group_id: &str) -> OwnedMutexGuard<()> {
let lock = {
let mut locks = self.group_operation_locks.lock().await;
locks.entry(group_id.to_string()).or_insert_with(|| Arc::new(Mutex::new(()))).clone()
};
lock.lock_owned().await
}
pub async fn lock_schedule_operation(&self) -> OwnedMutexGuard<()> {
self.schedule_operation_lock.clone().lock_owned().await
}
pub async fn lock_automation_operation(&self) -> OwnedMutexGuard<()> {
self.automation_operation_lock.clone().lock_owned().await
}
pub async fn lock_house_operation(&self) -> OwnedMutexGuard<()> {
self.house_operation_lock.clone().lock_owned().await
}
pub async fn lock_configuration_operation(&self) -> OwnedMutexGuard<()> {
self.configuration_operation_lock.clone().lock_owned().await
}
pub fn wake_zone_control(&self) { pub fn wake_zone_control(&self) {
self.zone_control_wakeup.notify_one(); self.zone_control_wakeup.notify_one();
} }
+1 -1
View File
@@ -437,7 +437,7 @@
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="cool_away_setpoint" value="27"></label></div> <div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="cool_away_setpoint" value="27"></label></div>
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="heat_away_setpoint" value="17"></label></div> <div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="text" inputmode="decimal" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="text" inputmode="decimal" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="text" inputmode="decimal" step="0.5" name="heat_away_setpoint" value="17"></label></div>
</details> </details>
<div class="two"><div class="hysteresis-config"><label id="commonHysteresisField"><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label class="check"><input type="checkbox" name="separate_hysteresis"> <span data-i18n="zones.separateHysteresis">Set heating and cooling hysteresis separately</span></label></div><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div> <div class="two"><div class="hysteresis-config"><label id="commonHysteresisField"><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label class="check" data-i18n-title="zones.separateHysteresisHint" title="Use different hysteresis values for heating and cooling"><input type="checkbox" name="separate_hysteresis"> <span data-i18n="zones.separateHysteresis">Set heating and cooling hysteresis separately</span></label></div><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
<div id="separateHysteresisFields" class="two" hidden><label><span data-i18n="zones.coolHysteresis">Cooling hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="cool_hysteresis" value="0.6"></label><label><span data-i18n="zones.heatHysteresis">Heating hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="heat_hysteresis" value="0.6"></label></div> <div id="separateHysteresisFields" class="two" hidden><label><span data-i18n="zones.coolHysteresis">Cooling hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="cool_hysteresis" value="0.6"></label><label><span data-i18n="zones.heatHysteresis">Heating hysteresis °C</span><input type="text" inputmode="decimal" step="0.1" min="0.1" max="5" name="heat_hysteresis" value="0.6"></label></div>
<div id="externalSensorFields"> <div id="externalSensorFields">
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label> <label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
+1 -1
View File
@@ -31,7 +31,7 @@ const app = {
languages: [], translations: {}, locales: {}, languages: [], translations: {}, locales: {},
historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {}, historyTab: 'overview', historyData: {zones:[], devices:[], sensors:[]}, historyCounts: {},
historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false, historyZone: 'all', historyDevice: 'all', historySensor: 'all', historyLoading: false,
customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, zoneTemperatureTimers: {}, customChartSeries: [], savedCharts: [], chartZooms: {}, chartHiddenSeries: {}, zoneControlSeq: {}, groupControlSeq: {}, zoneControlQueue: {}, groupControlQueue: {}, zoneTemperatureTimers: {},
controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {}, controlPlan: null, controlPlanTimer: null, debugLines: [], debugBacklogLoaded: false, debugFilter: 'all', sensorAliases: {},
simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(), simulationScope: 'units', simulationTarget: 'all', standaloneSimulation: false, dashboardTab: 'main', settingsTab: 'app', systemSnapshotAt: Date.now(),
}; };
+27 -3
View File
@@ -208,10 +208,18 @@ function groupState(group) {
const zones = groupZones(group); const zones = groupZones(group);
const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))]; const modes = [...new Set(zones.map(zone => zone.inherit_house_mode ? 'house' : (zone.mode || 'cool')))];
const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))]; const presets = [...new Set(zones.map(zone => zone.manual_preset || 'auto'))];
const customTargets = zones
.filter(zone => (zone.manual_preset || 'auto') === 'custom')
.map(zone => Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint))
.filter(Number.isFinite);
const customTemperature = customTargets.length === zones.length && customTargets.length && customTargets.every(value => Math.abs(value - customTargets[0]) < 0.05)
? customTargets[0]
: null;
return { return {
zones, zones,
mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'), mode: modes.length === 1 ? modes[0] : (modes.length ? 'mixed' : 'house'),
preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'), preset: presets.length === 1 ? presets[0] : (presets.length ? 'mixed' : 'auto'),
customTemperature,
}; };
} }
@@ -223,12 +231,16 @@ function groupCard(group, detailed = false) {
const masterOff = app.settings?.house_power_enabled === false; const masterOff = app.settings?.house_power_enabled === false;
const modeLabelForGroup = value => value === 'house' ? tr('groups.followHouse') : (value === 'mixed' ? tr('groups.mixed') : modeLabel(value)); 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 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'}"> 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> <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>
${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>` : ''} ${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('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.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('')}</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>
${masterOff ? `<p class="field-note warning-note">${esc(tr('groups.masterOff'))}</p>` : ''} ${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>` : ''} ${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>`; </article>`;
@@ -245,8 +257,16 @@ function renderGroups() {
} }
async function sendGroupControl(id, patch) { 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 { try {
const result = await api(`/api/groups/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); const result = await request;
if (app.groupControlSeq[id] !== sequence) return;
if (result.group) { if (result.group) {
const index = app.groups.findIndex(group => group.id === result.group.id); 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); if (index >= 0) app.groups[index] = result.group; else app.groups.push(result.group);
@@ -261,7 +281,11 @@ async function sendGroupControl(id, patch) {
const failed = Array.isArray(result.failed) ? result.failed.length : 0; const failed = Array.isArray(result.failed) ? result.failed.length : 0;
if (failed) toast(tr('groups.partial', {count: failed}), true); if (failed) toast(tr('groups.partial', {count: failed}), true);
else toast(tr('groups.controlUpdated')); else toast(tr('groups.controlUpdated'));
} catch (error) { toast(error.message, true); } } catch (error) {
if (app.groupControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
} finally {
if (app.groupControlQueue[id] === request) delete app.groupControlQueue[id];
}
} }
function renderGroupZoneChoices(selectedIds = null) { function renderGroupZoneChoices(selectedIds = null) {
+20 -2
View File
@@ -116,6 +116,17 @@ document.addEventListener('click', async event => {
if (action === 'group-power') return sendGroupControl(button.dataset.id,{power:button.dataset.value==='true'}); 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-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') 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(); }
return;
}
if (action === 'group-custom-temperature') {
const input=button.closest('.group-card')?.querySelector('[data-group-custom-temperature]');
const value=parseDecimal(input?.value);
if(!Number.isFinite(value) || value<8 || value>30) return toast(tr('groups.customTemperatureRange'),true);
return sendGroupControl(button.dataset.id,{preset:'custom',setpoint:Math.round(value*10)/10});
}
if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true'); if (action === 'zone-device-power') return sendZoneLocalPower(button.dataset.id, button.dataset.value === 'true');
if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id); if (action === 'zone-open-temporary') return populateTemporaryThermostat(button.dataset.id);
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; } if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.manual_setpoint ?? zone.effective_setpoint ?? zone.setpoint); queueZoneTemperature(zone, base+Number(button.dataset.delta)); } return; }
@@ -183,6 +194,13 @@ $('#themeSelect')?.addEventListener('change', event => setTheme(event.target.val
$('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs); $('#logLevelFilter')?.addEventListener('change', loadLogs); $('#logCategoryFilter')?.addEventListener('change', loadLogs);
$('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields); $('#settingsForm [name=notifications_provider]')?.addEventListener('change', updateNotificationFields);
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields); $('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
document.addEventListener('keydown', event => {
const input=event.target.closest?.('[data-group-custom-temperature]');
if(!input || event.key!=='Enter') return;
event.preventDefault();
input.closest('.group-card')?.querySelector('[data-action="group-custom-temperature"]')?.click();
});
$('#zoneForm [name=separate_hysteresis]')?.addEventListener('change', () => updateZoneHysteresisFields({syncFromCommon:true})); $('#zoneForm [name=separate_hysteresis]')?.addEventListener('change', () => updateZoneHysteresisFields({syncFromCommon:true}));
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField); $('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
$('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields); $('#automationForm [name=action_target_kind]')?.addEventListener('change', updateAutomationTargetFields);
@@ -317,7 +335,7 @@ $('#temporaryThermostatStop')?.addEventListener('click', async event => {
const id = event.currentTarget.dataset.id; if (!id) return; const id = event.currentTarget.dataset.id; if (!id) return;
try { try {
event.currentTarget.disabled = true; event.currentTarget.disabled = true;
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{clear_temporary_quick_thermostat:true}}); const zone = await enqueueZoneControlRequest(id, {clear_temporary_quick_thermostat:true});
const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone; const index = app.zones.findIndex(item => item.id === zone.id); if (index >= 0) app.zones[index] = zone;
$('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped')); $('#temporaryThermostatDialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.temporaryStopped'));
} catch (error) { toast(error.message, true); } } catch (error) { toast(error.message, true); }
@@ -356,7 +374,7 @@ $('#temporaryThermostatForm')?.addEventListener('submit', async event => {
if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety; if (Number.isFinite(safety) && safety > 0) request.max_duration_minutes = safety;
} }
await runFormTask(form, async () => { await runFormTask(form, async () => {
const updatedZone = await api(`/api/zones/${encodeURIComponent(raw.zone_id)}/control`, {method:'POST', body:{temporary_quick_thermostat:request}}); const updatedZone = await enqueueZoneControlRequest(raw.zone_id, {temporary_quick_thermostat:request});
const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone; const index = app.zones.findIndex(item => item.id === updatedZone.id); if (index >= 0) app.zones[index] = updatedZone;
form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast')); form.closest('dialog').close(); renderAll(); scheduleControlPlanLoad(); toast(tr(activeSession || startKind === 'now' ? 'zones.temporaryStarted' : 'zones.temporaryScheduledToast'));
}, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'}); }, {busyKey: activeSession ? 'actions.saving' : 'zones.temporaryStarting'});
+22 -3
View File
@@ -46,20 +46,37 @@ function updateDevice(device) {
if (index >= 0) app.devices[index] = device; else app.devices.push(device); if (index >= 0) app.devices[index] = device; else app.devices.push(device);
} }
function enqueueZoneControlRequest(id, patch) {
const previous = app.zoneControlQueue[id] || Promise.resolve();
const request = previous.catch(() => {}).then(() =>
api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch})
);
app.zoneControlQueue[id] = request;
request.finally(() => {
if (app.zoneControlQueue[id] === request) delete app.zoneControlQueue[id];
}).catch(() => {});
return request;
}
async function sendZoneLocalPower(id, power) { async function sendZoneLocalPower(id, power) {
const sequence = (app.zoneControlSeq[id] || 0) + 1;
app.zoneControlSeq[id] = sequence;
try { try {
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:{power}}); const zone = await enqueueZoneControlRequest(id, {power});
if (app.zoneControlSeq[id] !== sequence) return;
const index = app.zones.findIndex(item => item.id === zone.id); const index = app.zones.findIndex(item => item.id === zone.id);
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated')); renderAll(); scheduleControlPlanLoad(); toast(tr('zones.localPowerUpdated'));
} catch (error) { toast(error.message, true); } } catch (error) {
if (app.zoneControlSeq[id] === sequence) { await loadBootstrap(); toast(error.message, true); }
}
} }
async function sendZoneControl(id, patch) { async function sendZoneControl(id, patch) {
const sequence = (app.zoneControlSeq[id] || 0) + 1; const sequence = (app.zoneControlSeq[id] || 0) + 1;
app.zoneControlSeq[id] = sequence; app.zoneControlSeq[id] = sequence;
try { try {
const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); const zone = await enqueueZoneControlRequest(id, patch);
if (app.zoneControlSeq[id] !== sequence) return; if (app.zoneControlSeq[id] !== sequence) return;
const index = app.zones.findIndex(item => item.id === zone.id); const index = app.zones.findIndex(item => item.id === zone.id);
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
@@ -69,6 +86,7 @@ async function sendZoneControl(id, patch) {
} }
} }
function queueZoneTemperature(zone, value, {snapToHalf=true}={}) { function queueZoneTemperature(zone, value, {snapToHalf=true}={}) {
const clamped = clamp(value, 8, 30); const clamped = clamp(value, 8, 30);
const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10; const next = snapToHalf ? Math.round(clamped * 2) / 2 : Math.round(clamped * 10) / 10;
@@ -93,6 +111,7 @@ function beginInlineTemperatureEdit(target) {
input.inputMode = 'decimal'; input.inputMode = 'decimal';
input.value = value.toFixed(decimals); input.value = value.toFixed(decimals);
input.setAttribute('aria-label', tr('common.targetTemperature')); input.setAttribute('aria-label', tr('common.targetTemperature'));
input.title = tr('common.targetTemperature');
target.classList.add('editing'); target.classList.add('editing');
target.replaceChildren(input); target.replaceChildren(input);
input.focus(); input.focus();
+8 -1
View File
@@ -710,6 +710,13 @@ html[data-theme='light'] .simulation-metrics > div, html[data-theme='light'] .si
.group-button-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } .group-button-row { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; }
.group-button-row button,.group-mode-row button,.group-preset-row button { min-height:36px; } .group-button-row button,.group-mode-row button,.group-preset-row button { min-height:36px; }
.group-button-row button.active { color:var(--accent-text); background:var(--accent); font-weight:800; } .group-button-row button.active { color:var(--accent-text); background:var(--accent); font-weight:800; }
.group-custom-temperature { padding:8px 9px; border:1px solid var(--line); border-radius:11px; background:var(--surface-muted); }
.group-custom-temperature label > span { display:block; margin-bottom:5px; color:var(--muted); font-size:10px; }
.group-custom-temperature-row { display:grid; grid-template-columns:minmax(0,1fr) auto auto; align-items:center; gap:6px; }
.group-custom-temperature-row input { min-width:0; height:36px; text-align:center; font-weight:700; }
.group-custom-temperature-row > span { color:var(--muted); font-size:12px; }
.group-custom-temperature-row button { min-height:36px; white-space:nowrap; }
.group-zone-choices { display:grid; gap:7px; max-height:300px; overflow:auto; } .group-zone-choices { display:grid; gap:7px; max-height:300px; overflow:auto; }
.group-zone-choice { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:10px; padding:10px; border:1px solid var(--line); border-radius:12px; background:var(--surface-muted); } .group-zone-choice { display:grid; grid-template-columns:auto 1fr; align-items:center; gap:10px; padding:10px; border:1px solid var(--line); border-radius:12px; background:var(--surface-muted); }
.group-zone-choice input { width:18px; min-height:18px; accent-color:var(--accent); } .group-zone-choice input { width:18px; min-height:18px; accent-color:var(--accent); }
@@ -772,7 +779,7 @@ body.simulation-standalone [data-view="simulation"] { min-height:calc(100vh - 36
.dashboard-group-strip .group-mode-row, .dashboard-group-strip .group-mode-row,
.dashboard-group-strip .group-preset-row { display:grid; gap:5px; overflow:visible; padding:0; } .dashboard-group-strip .group-preset-row { display:grid; gap:5px; overflow:visible; padding:0; }
.dashboard-group-strip .group-mode-row { grid-template-columns:repeat(3,minmax(0,1fr)); } .dashboard-group-strip .group-mode-row { grid-template-columns:repeat(3,minmax(0,1fr)); }
.dashboard-group-strip .group-preset-row { grid-template-columns:repeat(4,minmax(0,1fr)); } .dashboard-group-strip .group-preset-row { grid-template-columns:repeat(5,minmax(0,1fr)); }
.dashboard-group-strip .group-button-row button, .dashboard-group-strip .group-button-row button,
.dashboard-group-strip .group-mode-row button, .dashboard-group-strip .group-mode-row button,
.dashboard-group-strip .group-preset-row button { min-width:0; min-height:32px; padding:6px 5px; font-size:10px; line-height:1.15; } .dashboard-group-strip .group-preset-row button { min-width:0; min-height:32px; padding:6px 5px; font-size:10px; line-height:1.15; }