This commit is contained in:
Mateusz Gruszczyński
2026-09-03 13:08:28 +02:00
parent d7b6d0478f
commit b8ff31f205
52 changed files with 3874 additions and 1912 deletions
Generated
+282 -10
View File
@@ -14,7 +14,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"generic-array",
]
@@ -129,6 +129,15 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "async-compression"
version = "0.4.43"
@@ -255,6 +264,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -326,7 +344,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"inout",
]
@@ -393,6 +411,12 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -426,6 +450,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -437,6 +467,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctr"
version = "0.9.2"
@@ -452,14 +491,36 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
]
[[package]]
@@ -479,6 +540,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@@ -633,7 +700,7 @@ dependencies = [
[[package]]
name = "gree-controller"
version = "0.11.6"
version = "0.12.0"
dependencies = [
"aes",
"aes-gcm",
@@ -650,7 +717,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"tempfile",
"thiserror 2.0.20",
"tokio",
@@ -658,6 +725,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"url",
"utoipa-swagger-ui",
"uuid",
]
@@ -670,13 +738,19 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
"hashbrown 0.14.5",
]
[[package]]
@@ -730,6 +804,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.11.0"
@@ -918,6 +1001,18 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -1039,6 +1134,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -1312,6 +1417,18 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
@@ -1395,6 +1512,41 @@ dependencies = [
"smallvec",
]
[[package]]
name = "rust-embed"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097"
dependencies = [
"mime_guess",
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.119",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0"
dependencies = [
"sha2 0.11.0",
"walkdir",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -1461,6 +1613,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -1541,7 +1702,7 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1552,7 +1713,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"digest",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -1961,6 +2133,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1973,7 +2151,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"subtle",
]
@@ -2013,6 +2191,52 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "utoipa"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160"
dependencies = [
"indexmap",
"serde",
"serde_json",
"utoipa-gen",
]
[[package]]
name = "utoipa-gen"
version = "5.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "utoipa-swagger-ui"
version = "8.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5c80b4dd79ea382e8374d67dcce22b5c6663fa13a82ad3886441d1bbede5e35"
dependencies = [
"axum",
"mime_guess",
"regex",
"rust-embed",
"serde",
"serde_json",
"utoipa",
"utoipa-swagger-ui-vendored",
"zip",
]
[[package]]
name = "utoipa-swagger-ui-vendored"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d"
[[package]]
name = "uuid"
version = "1.25.0"
@@ -2043,6 +2267,16 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
@@ -2142,6 +2376,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -2392,8 +2635,37 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror 2.0.20",
"zopfli",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "gree-controller"
version = "0.11.6"
version = "0.12.0"
edition = "2021"
authors = ["GREE Controller contributors"]
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
+39 -37
View File
@@ -1,13 +1,14 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
d3c8a734104ca6d68b1a07c41ef6832ca475e3fc257ccbbaa0afc3a7ad85bd17 ./Cargo.lock
31eed008472815e59f3a6b7eaa1f9186c407f49a15d50d77d0aaa136d1c3ca82 ./Cargo.toml
6877eacd3931f7922f3f379d46e8cd9de7a609a65060ce2285c09b9c95f230aa ./Cargo.lock
ebe64386eb553c298994df40f73a56b4980eedcd88a84cb310134070c1753eb3 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
5467c49d65a990966d3778fb928f6c67cbca7be94d2d54022d479821f0457816 ./README.md
92633f07b839dcdeafa96cb3ffdaaa600f65387a9113ed595987e68982fed0d6 ./README.md
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
34a90343baf4d189c58887bc0cf6f65e824614676afc94b5ea8363e9d262ccc6 ./docs/API.md
588657a906ebabbc248509a472aaf7eb8d5fb6549de5065027a75f61afe91708 ./docs/API.md
1b522e1b61643733accf0a45b8b862cfb96e1468343c9819600e263fe67a2b96 ./docs/CODE_AUDIT_0.12.0.md
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
f10b54081d00e6d20aac14d351cad767b874544196628d71631b14125f1e88ba ./docs/openapi.json
3ba861e16d523f962574334ed74d53d6be2669dcb05851578d7a5434e8856f4e ./docs/openapi.json
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
@@ -18,7 +19,7 @@ ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assista
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
b088946ee13497f546363fda022000f0ce7476e78f04558a4af65374cb8c8e94 ./home-assistant/custom_components/gree_controller/manifest.json
9538b4a08e6c76dbd5850f3720f582253fd8e4542871891a2cf5552759fb334a ./home-assistant/custom_components/gree_controller/manifest.json
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assistant/custom_components/gree_controller/sensor.py
@@ -32,7 +33,7 @@ cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./home-assista
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
1e7b1bcf4194abfb8bad0d601268af0547d58541731e98b74a3500d7629d0be0 ./mock/index.html
007c78e0cb404778985980b23bb5f8bbc7a8f04c80015204e24c321970e3b15e ./mock/mock.js
5ae5a29dee689e1ab0f433339d1499eb232cae81749ff083dd5be5c62d72fa0b ./mock/styles.css
c5d515b987d96ebfbc7593e9bac9aff64c8d54420f23cc8df5e2c84b14a69852 ./mock/styles.css
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./mock/theme-init.js
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
@@ -72,7 +73,7 @@ ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/week
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
a35385734b1855dbea6d895075d54b17dce1621797cd65581a9404acff2ce645 ./scripts/FILE_MANIFEST.sha256
e8fc6cb061f27fb81b069de1f08cfa2d3eb26557ec2221abda64b621b8092750 ./scripts/FILE_MANIFEST.sha256
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
@@ -83,33 +84,33 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/inst
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
c714e3cf2fba84ded718d102bd620cf879c1a8f3a5ae8f435be7863d8a0cd8e0 ./scripts/smoke.sh
7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./scripts/verify_flow_logic.py
3256e9b9a34e4ef9b69c27d630dad4a9b17a29afc894f56c65206e7aa78eab21 ./src/api.rs
0790b7b388467d9e75b621188cca473e22d73e01bcfa74e92fa23f92b0695855 ./scripts/verify_flow_logic.py
11a2bca9c4f349fb5fb5090e02607f26bddf692a739fea0384779820935fcea7 ./src/api.rs
825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
63cb241c4536abed402f2a0ad37e7f299343df264eab6a33bb5354c15421e045 ./src/api/automations.rs
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
f1e378922ac2aab93b93b13d2e4cda6fb866de6178429b9658705116263724f8 ./src/api/configuration.rs
6a913e9c6deca6ad8564ebad72f0f22bfe9feb1a0a6d30ebe639e9a6bf9652a6 ./src/api/debug_tokens.rs
e43d25b0b65fe307cce209522ab9618f9385210720ed2c3f24abbbc43d09db45 ./src/api/devices.rs
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
1bbe45c11b176d1595007942ef7723c7954c60fe20cc5aa18c4dbab00b6f09ff ./src/api/flows.rs
b1c0861dfc568f88ec0f15d133d8ac4aacc699f3e6df1cd026e21f9c3814b1fe ./src/api/events.rs
5708667d1d009af3982414614aea6ed98014d11a41c9984c7c89283092963994 ./src/api/flows.rs
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
791c99a2cc3219f9db7f600df48bd2d839dcae3392e937fc7b31dd9c024afd3d ./src/api/house.rs
88e04a59bfedd5e6c54e6a2938b1031964be3a1505db2ca67d6290aa042cbe3b ./src/api/integrations.rs
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
45e34a447ff384d99a3c42c489ca9fa75849c74d4d246caa91c63d927de17396 ./src/api/openapi.rs
50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs
a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs
430f446371489a2b2f1532d589c71f60c98c1a92765ccb5e486b08e2da8f31c3 ./src/api/settings.rs
bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs
f07425bad97da3fdc1a1a430ef8cf58651c6946fbc1fe8adaa48650567469fbe ./src/api/settings.rs
6f53b89c96a99a85c3badd6af169fda9c978630516279b57b756b5468caef7d7 ./src/api/system.rs
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
7eaeb0552a51dc34e964a315cc4024e8373622d7b32746010f89881268ccf389 ./src/config.rs
20e0a7f8170247613fad996d5341d54b31a497a12893f2831d9fddd12583508d ./src/api/zones.rs
3cec9426989034a6a5074aead64e8c0239ae942a96a04910c57d1593129c5bb8 ./src/config.rs
c8b8b4a04c94b9d91f5931057d69ad38344730bffbf68e2f6541b59013441b57 ./src/db.rs
24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs
3eded80d8dbf13729b2ae1f31baa47aa59c01bcfa550c90874031d25c74d2eac ./src/db/configuration.rs
6998f88a6a030ef9a1fa3a46466f6dfb79c6df086411d94c82ecd69fa1690e71 ./src/db/configuration.rs
56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs
d8a2b323e864f2ec38ee15e3023be37a6aa05387f1ddbeedafe1f51556ad8c7d ./src/db/device_history.rs
f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/events_tokens.rs
@@ -118,32 +119,32 @@ f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/event
7e2fbce70f40aef360534ba38c6700163db77cb7288b3bf58728fce72a126435 ./src/db/schedules_automations.rs
35cbb521a8ec456756496b8aee71f9dfebad528a2cf2653ba8b48f6035be0c01 ./src/db/tests.rs
a384ea4042d1d11692b5f8693df1f54850dcc4e80726780b44b2b096bf667b78 ./src/db/zone_history.rs
952ea5906d39ef917ad71edc345f76b29c07c63079644fd5699b12aa254cb35f ./src/engine.rs
c803180a341d27f4e5165add623d6548cb43ffc23fa167f7235ce9cf2423ea68 ./src/engine/automations.rs
fbebb9815bdabbad9ff4e979f8ea9dc3dda9c77790f30a64fa0b392fb1944d00 ./src/engine.rs
ec6034df6667b3ac6ec39851443048a56e87fa861f5846950a5e79fa89ee5a30 ./src/engine/automations.rs
77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs
ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs
750c5c219c7c8a72f51eb6dfc679d21c5821c122861d9cfbaba57485b66a33a4 ./src/engine/control_plan.rs
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
d15148382f0c84bdef36750a10b463378e2de6a0fb70eecadeee2b07b8648681 ./src/engine/groups.rs
c1410aae910b8239e48857df1f0484f08261ad6cf385b596f7c538e920907437 ./src/engine/groups.rs
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs
abf67cb6c9ac393d7f2b51852bcc52cdade02944ac50e71d14c9d9ece5e31a56 ./src/engine/ownership.rs
abf9eb5c2444f540f1601682d4c7f3e86f6742690f6af621999406cd81f6a2e4 ./src/engine/ownership.rs
9c37adf514c96840fd6a5c715c579350fa86d6dcc0655a2aa97d1fd7c7e04299 ./src/engine/polling.rs
3d48608a065aa52a1fd8b9c59356a74d8216ecee3d47f8ff8ef7d3d47170c617 ./src/engine/runtime.rs
ffa72b93502eece187a96adb4ec94aec6048d4a6dc7af66e7c7af0931318787f ./src/engine/schedules.rs
7742046c0067c7cd5a74e2b81f8a8bdef9b76fcacf9b4af0b7eede1b050b9036 ./src/engine/targets.rs
a9188b588b2617ce1547a45d46141f7e5f1bb0027a48675dd86b274f807d0875 ./src/engine/temperature.rs
c2fe371f9245ce299015f0b4e43f6625a207016abf73d2419acb6bd7437f045a ./src/engine/temporary_thermostat.rs
cdf6f444b959ef1dcafe06e4cf48bdff35ddc4f4b34bd95169660fa4a535a1da ./src/engine/tests.rs
8502aed5bc180a1e2601c33c41e125edea30fdea93a870c5237cf85054fbf686 ./src/engine/temporary_thermostat.rs
9da37fb636e2c342c4e86b6e0ecf07a5a6850431b1227cf4b8f02b4d05b02eaa ./src/engine/tests.rs
986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs
900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./src/engine/zone_control.rs
e7ba1c1708865bc49e26d07ad84faa1803c0d4f1c23be5b11f61154de660858c ./src/engine/zone_control.rs
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
9eef7820fcf0c98b08fb1bf5e0a4a244b1dbdfa700d8b930d3ff40379babdada ./src/home_assistant.rs
6f9ef85cd53ba030b477bc3c186f0f7e762674124210eabcb0686f2806eaada1 ./src/influxdb.rs
feb50ecd60b9a9fe9de1f7d8e1e26caab5435d59347468f6266a318f431bc3d3 ./src/influxdb/codec.rs
553b44ec0321e62480059bc2451fd34ec7aabe9fc87fc717daba0c4b55679d53 ./src/influxdb/query.rs
5e4d94f8a010df05bae9acab72c518080d2dba94d054df95b87e492305357222 ./src/influxdb/write.rs
ef8ad176eb2ee6ceb02bbf3d63b65460013475908fced05388b29727af67ef7e ./src/main.rs
c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.rs
c3ff7232a2fa1d29428872a10cfd72ee60a3a1faaa662eff6c2d8c7dffc1ed3a ./src/main.rs
1030e49498f8d595cf8fdee60651e7fec79d78061eb8561f9b29948b28c2f95f ./src/models.rs
67f189c195426c39669178dd92d8602c519806acca7d325665076720f9fee8a9 ./src/models/automation.rs
377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
@@ -151,7 +152,8 @@ c63bd6257386969a763f44d0e4fd7b6b19078ec7a7f46e7ec2d73580d2274cab ./src/models.r
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
552dfc1717ea88f3a23b1fb7705f1dbb0df7792ca9a6ae7ad7bd275eacef27af ./src/models/integrations.rs
268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs
8294749dd3af524925309c51ad641ffd32ff1fd9c164cfa71660aa88c2d13d6e ./src/models/runtime.rs
887eb80a58998e90023e952c1fde67435eb843493387ae64536613b74c084534 ./src/models/settings_api.rs
e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
@@ -180,22 +182,22 @@ b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
f78622afea899a5e904910217eceeeee306d5fc8094bdccc049bd84752780720 ./web/index.html
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
8fb20fdba11df68feb0b096bb5b4c8f936db974aed5d345a0d01455054f31f95 ./web/js/bootstrap.js
dee7c4292889ddd721c220e86fae909f0d662b585e6d9c5d9e2f8fa85d9ed2ad ./web/js/charts.js
f75449450a179f5853b8b08c69dec93205985e0dcbc806d355fd6060237761f6 ./web/js/core.js
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
e797a71616fbdc2b651c0b5174f67a581beeb22c6a8c9441dd728bf444e6162d ./web/js/entities.js
563ce41a849574ddfbed281828f34cdcb498bbd04009056c73a378b84bad822a ./web/js/entities.js
e332b3f811958a938105272c1e8e28d6ca8ba69303001da165b4b0b85727695c ./web/js/events.js
d4aea1d6ec595000eaddca3b6f0e3d4003901365ece7788147a6ed207cffcc78 ./web/js/flows.js
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
a0959f9caf84f1873e3549261de8b1a069f4df8326b17bfe1f8c3de120299875 ./web/js/navigation.js
c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js
2bc9cfa306cdeafff39a7bfc8c5744568e0130d88432351cba8b062178aefce3 ./web/js/realtime.js
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
bdeb55546a1dff5e5bdfde4b114d6330f025df1b62e528bf18a93ad6dd4c3e83 ./web/js/settings-ui.js
aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js
3c4a96bbc73cdfaf2f76798201f88ef43776875dce42f2b75d6a1808cef496d9 ./web/js/settings.js
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
5ae5a29dee689e1ab0f433339d1499eb232cae81749ff083dd5be5c62d72fa0b ./web/styles.css
c5d515b987d96ebfbc7593e9bac9aff64c8d54420f23cc8df5e2c84b14a69852 ./web/styles.css
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
+5 -3
View File
@@ -2,7 +2,7 @@
Self-hosted controller for GREE-compatible air conditioners with a local Web UI, thermostat zones, schedules, Home Assistant integration, history, notifications and a documented HTTP/WebSocket API.
**Current release: 0.11.6**
**Current release: 0.12.0**
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
@@ -306,7 +306,7 @@ For Linux/network issues also use:
## Backup and restore
**Settings → Application → Configuration backup** exports settings, devices, zones, groups, schedules and automations.
**Settings → Application → Configuration backup** exports settings, devices, zones, groups, schedules, automations and Flows.
The export intentionally does **not** contain metric history, event history or generated API-token records. It **does contain** GREE binding keys and configured integration secrets, so store it like a credential file.
@@ -369,7 +369,9 @@ Runtime assets are available at `/lang/index.json` and `/lang/<code>.json`.
## API
The HTTP API is the same backend used by the Web UI. It includes devices, zones, groups, house control, schedules, automations, history, control plan, events, settings, backup/restore, debug, tokens and integration tests.
The HTTP API is the same backend used by the Web UI. It includes devices, zones, groups, house control, schedules, automations, history, control plan, events, functional settings resources, configuration backup/restore, tokens and integration tests.
**Breaking in 0.12.0:** the monolithic `/api/settings`, `/api/debug`, `/api/events/retention` and `/api/settings/{export,import}` routes were removed. Settings now live under `/api/settings/application`, `/gree`, `/history`, `/influxdb`, `/notifications`, `/night`, `/home-assistant` and `/debug`; configuration backup uses `/api/configuration/export` and `/api/configuration/import`.
Start here:
+133 -127
View File
@@ -1,6 +1,6 @@
# GREE Controller API reference
HTTP and WebSocket API for GREE Controller **0.11.6**.
HTTP and WebSocket API for GREE Controller **0.12.0**.
[← Main documentation](../README.md)
@@ -180,19 +180,21 @@ Common statuses:
| GET | `/api/history` | Rich device/zone/HA history. |
| GET | `/api/control-plan` | Current resolved thermostat plan. |
| GET | `/api/events` | Event/debug log. |
| GET | `/api/events/retention` | Current event retention. |
| PUT | `/api/events/retention` | Update retention and prune immediately. |
### Settings, backup and diagnostics
### Settings, configuration and diagnostics
| Method | Endpoint | Description |
| --- | --- | --- |
| GET | `/api/settings` | Public-safe runtime settings. |
| PUT | `/api/settings` | Update runtime settings. |
| GET | `/api/settings/export` | Export full application configuration. |
| POST | `/api/settings/import` | Import/replace application configuration. |
| GET | `/api/debug` | Read debug overlay settings. |
| PUT | `/api/debug` | Update debug overlay settings. |
| GET/PUT | `/api/settings/application` | Application runtime switches. |
| GET/PUT | `/api/settings/gree` | GREE controller, polling, discovery and compressor settings. |
| GET/PUT | `/api/settings/history` | Metric/event retention and compaction settings. |
| GET/PUT | `/api/settings/influxdb` | InfluxDB history settings. |
| GET/PUT | `/api/settings/notifications` | Notification provider and alert settings. |
| GET/PUT | `/api/settings/night` | Night mode settings. |
| GET/PUT | `/api/settings/home-assistant` | Home Assistant, aliases, shared Flow inputs and outdoor assist. |
| GET/PUT | `/api/settings/debug` | Debug overlay/GREE frame settings. |
| GET | `/api/configuration/export` | Export full application configuration. |
| POST | `/api/configuration/import` | Import/replace application configuration. |
| POST | `/api/integrations/home-assistant/test` | Test HA temperature read. |
| POST | `/api/integrations/home-assistant/entity` | Read raw HA entity state/attributes for shared Flow input diagnostics. |
| POST | `/api/integrations/notifications/test` | Send a test notification. |
@@ -228,7 +230,7 @@ Response:
{
"status": "ok",
"name": "gree-controller",
"version": "0.11.6",
"version": "0.12.0",
"uptime_seconds": 1234,
"control_ready": true,
"time": "2026-08-30T06:54:00Z"
@@ -249,10 +251,10 @@ Returns the initial Web UI snapshot:
"schedules": [],
"automations": [],
"access_tokens": [],
"settings": {},
"house": {"mode": "cool"},
"outdoor_temperature": null,
"system": {
"version": "0.11.6",
"version": "0.12.0",
"uptime_seconds": 1234,
"auth_required": false,
"control_ready": true,
@@ -1003,88 +1005,62 @@ Response:
}
```
### `GET /api/events/retention`
```json
{
"days": 30
}
```
### `PUT /api/events/retention`
```json
{
"days": 30
}
```
Value is clamped to `1..3650`; pruning happens immediately. Response includes `days` and number of removed rows.
---
## Runtime settings
## Runtime settings — 0.12.0
### `GET /api/settings`
Version `0.12.0` replaces the monolithic settings document with functional resources. There are no compatibility aliases for the removed `/api/settings`, `/api/debug` or `/api/events/retention` endpoints.
Returns a public-safe settings document. Secrets are blanked and accompanied by `*_configured` booleans where relevant.
Every settings resource supports `GET` and `PUT`. A `PUT` replaces only that functional section; it never requires or overwrites unrelated settings.
Shape:
### `/api/settings/application`
```json
{
"simulator_enabled": false
}
```
### `/api/settings/gree`
```json
{
"controller_id": "gree-controller",
"simulator_enabled": false,
"poll_interval_seconds": 15,
"zone_interval_seconds": 5,
"discovery_timeout_ms": 3000,
"discovery_broadcast": "255.255.255.255:7000",
"house_mode": "cool",
"house_power_enabled": true,
"control_strategy": "setpoint",
"outdoor_assist_enabled": true,
"history_retention_days": 30,
"history_compaction_enabled": true,
"event_log_retention_days": 30,
"suppress_device_beep": false,
"debug": {
"overlay_enabled": false,
"gree_frames": false
},
"night_mode": {
"enabled": false,
"start_time": "22:00",
"end_time": "06:00",
"max_fan_speed": 1,
"force_quiet": true,
"use_native_sleep": true
},
"notifications": {},
"influxdb": {},
"home_assistant": {}
"compressor_protection_enabled": true,
"compressor_protection_seconds": 180
}
```
#### Home Assistant settings
Validation/normalization:
- `controller_id` cannot be empty;
- polling and zone intervals: `2..3600` seconds;
- discovery timeout: `300..30000` ms;
- discovery broadcast: `auto`, `auto:*` or a socket address;
- compressor protection: `30..1800` seconds.
Changing compressor protection clears pending compressor runtime queues before thermostat control continues.
### `/api/settings/history`
```json
{
"url": "http://homeassistant.local:8123",
"token": "",
"token_configured": true,
"default_entity_id": "sensor.room_temperature",
"outdoor_entity_id": "sensor.outdoor_temperature",
"sensor_stale_after_seconds": 300,
"allow_invalid_tls": false,
"sensor_aliases": {
"sensor.room_temperature": "Living room"
}
"retention_days": 30,
"compaction_enabled": true,
"event_retention_days": 30
}
```
`default_entity_id` is primarily the entity used by the Home Assistant connection test when the request does not provide another entity. Thermostat zones use their own configured `ha_entity_id`; this setting does not automatically become a zone temperature source.
Retention values are clamped to `1..3650` days. Updating this section immediately prunes expired event rows. The former `/api/events/retention` endpoint no longer exists.
#### InfluxDB settings
### `/api/settings/influxdb`
`GET` returns a secret-safe view:
```json
{
@@ -1093,31 +1069,27 @@ Shape:
"url": "http://influxdb:8086",
"database": "gree_controller",
"username": "",
"password": "",
"password_configured": false,
"org": "home",
"bucket": "gree_controller",
"token": "",
"token_configured": true,
"history_threshold_days": 30
}
```
Version `1` uses database/optional username/password. Version `2` uses org/bucket/token.
`PUT` uses the same non-secret fields plus optional `password` and `token`. Omitting either field (or sending `null`) preserves the stored secret. Sending an explicit empty string clears it. `history_threshold_days` is clamped to `1..3650`; the complete InfluxDB configuration is validated before persistence.
#### Notification settings
### `/api/settings/notifications`
`GET` returns provider state without secret values:
```json
{
"enabled": true,
"mode": "problems",
"provider": "pushover",
"pushover_app_token": "",
"pushover_user_key": "",
"pushover_configured": true,
"slack_webhook_url": "",
"slack_configured": false,
"discord_webhook_url": "",
"discord_configured": false,
"cooldown_seconds": 300,
"communication_failure_threshold": 3,
@@ -1135,33 +1107,80 @@ Version `1` uses database/optional username/password. Version `2` uses org/bucke
}
```
Modes: `problems`, `important`. Providers: `pushover`, `slack`, `discord`.
`PUT` accepts the same behavioral fields plus optional `pushover_app_token`, `pushover_user_key`, `slack_webhook_url` and `discord_webhook_url`. Omitted/`null` secrets are preserved; an explicit empty string clears them. Modes: `problems`, `important`. Providers: `pushover`, `slack`, `discord`. Cooldown is clamped to `30..86400` seconds, failure threshold to `2..100`, target timeout to `5..1440` minutes.
### `PUT /api/settings`
### `/api/settings/night`
Accepts the complete `RuntimeSettings` document. Important behavior:
```json
{
"enabled": false,
"start_time": "22:00",
"end_time": "06:00",
"max_fan_speed": 1,
"force_quiet": true,
"use_native_sleep": true
}
```
- `house_mode` cannot be changed here; use House Control API. `house_power_enabled` is a legacy compatibility field and is normalized to `true`; global ON/OFF no longer uses a persistent master gate.
- polling interval is clamped `2..3600` seconds.
- zone interval is clamped `2..3600` seconds.
- discovery timeout is clamped `300..30000` ms.
- `control_strategy` is normalized to `setpoint`.
- discovery broadcast must be `auto`, `auto:*` or a valid socket address.
- blank HA token preserves the saved token.
- blank Influx token/password preserve saved secrets.
- blank Pushover/Slack/Discord secret fields preserve saved secrets.
- HA sensor age is clamped `30..86400` seconds.
- notification cooldown: `30..86400` seconds.
- communication failure threshold: `2..100`.
- target timeout: `5..1440` minutes.
- retention windows: `1..3650` days.
- night mode times must be `HH:MM`; max fan is clamped `1..5`.
Times must use `HH:MM`; maximum fan speed is clamped to `1..5`.
Returns the safe public settings form.
### `/api/settings/home-assistant`
### `GET /api/settings/export`
`GET` returns:
Returns configuration format version `1`:
```json
{
"url": "http://homeassistant.local:8123",
"token_configured": true,
"default_entity_id": "sensor.room_temperature",
"outdoor_entity_id": "sensor.outdoor_temperature",
"sensor_stale_after_seconds": 300,
"allow_invalid_tls": false,
"sensor_aliases": {
"sensor.room_temperature": "Living room"
},
"flow_inputs": [],
"outdoor_assist_enabled": true
}
```
`PUT` replaces `token_configured` with optional `token`. Omitted/`null` token preserves the saved token; an explicit empty string clears it. Sensor age is clamped to `30..86400` seconds. URLs, aliases, entity IDs and shared Flow inputs are normalized/validated before persistence. Shared inputs are value sources only; comparison operators and thresholds belong to Flow nodes.
### `/api/settings/debug`
```json
{
"overlay_enabled": true,
"gree_frames": true
}
```
When `overlay_enabled=true`, live HTTP diagnostics can emit `api.request`; `gree_frames=true` enables sanitized `gree.frame` events.
### Settings WebSocket events
Each section has its own event and payload:
```text
settings.application.updated
settings.gree.updated
settings.history.updated
settings.influxdb.updated
settings.notifications.updated
settings.night.updated
settings.home_assistant.updated
settings.debug.updated
```
The generic `settings.updated` and `debug.settings` events were removed in `0.12.0`.
---
## Configuration backup and restore
### `GET /api/configuration/export`
Returns configuration format version `3`:
```text
format_version
@@ -1172,13 +1191,14 @@ zones[]
groups[]
schedules[]
automations[]
flows[]
```
Export includes GREE binding keys and integration credentials. It excludes metric history, event rows and generated API-token records. Treat the export as a secret.
The export contains GREE binding keys and integration credentials. It excludes metric history, event rows and generated API-token records. Treat it as a secret backup.
### `POST /api/settings/import`
### `POST /api/configuration/import`
Accepts exactly the export document. The backend validates IDs/references/schedules/settings, safely stops devices whose ownership is being removed, clears transient ownership/timers/stale live state, replaces configuration, re-polls devices, then re-enables thermostat control.
Accepts only format version `3` and the `setpoint` control strategy used by `0.12.0`. The backend validates IDs/references (including shared Flow inputs against resources inside the backup), Flow draft safety, schedules, settings and ownership relationships; safely powers off devices being detached; clears transient runtime/ownership state; replaces configuration; re-polls imported devices; then resumes thermostat control.
Metric/event history and generated access-token records are preserved.
@@ -1192,27 +1212,6 @@ Response:
---
## Debug API
### `GET /api/debug`
```json
{
"overlay_enabled": true,
"gree_frames": true
}
```
### `PUT /api/debug`
Accepts the same object, persists it and broadcasts `debug.settings`.
When overlay diagnostics are enabled, live HTTP requests generate `api.request` WebSocket events containing method, path, status and duration. When `gree_frames=true`, sanitized GREE protocol events are also sent as `gree.frame`.
The Web UI can display **All**, **Requests** or **GREE** subsets.
---
## Integration tests
### `POST /api/integrations/home-assistant/test`
@@ -1424,9 +1423,16 @@ schedule.template_applied
automation.created
automation.updated
automation.deleted
settings.updated
settings.application.updated
settings.gree.updated
settings.history.updated
settings.influxdb.updated
settings.notifications.updated
settings.night.updated
settings.home_assistant.updated
settings.debug.updated
house.mode_changed
configuration.imported
debug.settings
api.request
gree.frame
log.created
@@ -1545,7 +1551,7 @@ Flow is the source-of-truth representation for visual schedule/automation logic.
Saving an executable Flow validates the DAG and compiles it atomically. If the editor receives HTTP 400 during normal save, it can offer to retry with `draft=true`; draft validation preserves the editable graph while allowing missing actions or unfinished semantic wiring. Drafts never execute, and converting an existing Flow to a draft atomically removes its previously generated outputs. A thermostat action driven only by one weekday block and one time-range block is emitted as a native schedule when its settings are schedule-compatible. More complex graphs are emitted as Flow-triggered automations. Generated schedules and automations expose `flow_id` / `flow_node_id`, use stable names in the form `flow-<stable-unique-id>`, and cannot be edited or deleted through their legacy endpoints; edit the owning Flow instead.
Condition blocks support weekday, time/date ranges, optional 5-field CRON, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability sources, rolling mean/median, oscillation detection, and `shared_input` references. Stateful gate blocks include `stable_for`, `delay`, `state_duration` (`min_seconds`, optional `max_seconds`), `on_change` (`mode: result|value`) and `rate_limit` (`max_count`, `period_seconds`). `on_change` establishes a baseline on its first observation and does not fire immediately. `rate_limit` must feed an action directly and consumes quota only after a successful action execution. Shared inputs are stored in `home_assistant.flow_inputs` and resolve dynamically at evaluation time. Shared inputs store reusable value sources only. They never store a comparison operator or threshold. For comparison-capable source kinds, each `shared_input` Flow node defines its own `operator` and `value`. `PUT /api/settings` rejects shared-input configs that contain an `operator`, and rejects comparison `value` fields for comparison-capable source kinds. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices, climate groups or a generic Home Assistant service. Direct GREE actions map to the existing `DeviceCommand` fields including fan, swing, quiet, turbo, light, air, xfan, health and sleep; ownership rules may suppress fields that would fight an active thermostat. Flow group actions use the existing group controller and support power, house/heat/cool mode, auto/comfort/sleep/away and the existing custom group target.
Condition blocks support weekday, time/date ranges, optional 5-field CRON, application Night mode, outdoor/device/zone temperatures, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability sources, rolling mean/median, oscillation detection, and `shared_input` references. Stateful gate blocks include `stable_for`, `delay`, `state_duration` (`min_seconds`, optional `max_seconds`), `on_change` (`mode: result|value`) and `rate_limit` (`max_count`, `period_seconds`). `on_change` establishes a baseline on its first observation and does not fire immediately. `rate_limit` must feed an action directly and consumes quota only after a successful action execution. Shared inputs are stored in `home_assistant.flow_inputs` and resolve dynamically at evaluation time. Shared inputs store reusable value sources only. They never store a comparison operator or threshold. For comparison-capable source kinds, each `shared_input` Flow node defines its own `operator` and `value`. `PUT /api/settings/home-assistant` rejects shared-input configs that contain an `operator`, and rejects comparison `value` fields for comparison-capable source kinds. Logic blocks support AND, OR and NOT. Action blocks target thermostat zones, GREE devices, climate groups or a generic Home Assistant service. Direct GREE actions map to the existing `DeviceCommand` fields including fan, swing, quiet, turbo, light, air, xfan, health and sleep; ownership rules may suppress fields that would fight an active thermostat. Flow group actions use the existing group controller and support power, house/heat/cool mode, auto/comfort/sleep/away and the existing custom group target.
## Compressor protection queue
+75
View File
@@ -0,0 +1,75 @@
# Code audit — 0.12.0
Scope: settings/configuration API, frontend consumers, configuration import/export, and the largest control handlers touched by the API/runtime ownership changes.
## Breaking API redesign
0.12.0 intentionally removes the previous monolithic/compatibility routes. There are no aliases.
| Removed | Replacement |
| --- | --- |
| `GET/PUT /api/settings` | functional `/api/settings/*` resources |
| `GET/PUT /api/debug` | `GET/PUT /api/settings/debug` |
| `PUT /api/events/retention` | `GET/PUT /api/settings/history` |
| `GET /api/settings/export` | `GET /api/configuration/export` |
| `POST /api/settings/import` | `POST /api/configuration/import` |
Functional settings resources:
- `/api/settings/application`
- `/api/settings/gree`
- `/api/settings/history`
- `/api/settings/influxdb`
- `/api/settings/notifications`
- `/api/settings/night`
- `/api/settings/home-assistant`
- `/api/settings/debug`
The bootstrap payload no longer exposes runtime settings. The Web UI fetches each settings resource explicitly.
## Correctness and security findings addressed
- Settings writes no longer require a full `RuntimeSettings` document, so changing one section cannot overwrite unrelated settings.
- Integration secrets are never returned by settings GET endpoints. Secret-bearing PUT fields are optional: omitted/null preserves the stored value; an explicit empty string clears it.
- Configuration backup/restore was separated from runtime settings into `/api/configuration/*`.
- Configuration format was raised to version `3`; imports accept only version `3`.
- Format-v3 imports reject unsupported control strategies; `0.12.0` accepts only `setpoint`.
- Import validates and normalizes the same GREE/history/InfluxDB/notification/night/Home Assistant constraints used by functional settings endpoints.
- Shared Flow inputs in an imported backup are validated against devices/zones/groups from that backup, not against the configuration currently stored in the database.
- Obsolete `house_power_enabled` and `master_power_enabled` compatibility state was removed from runtime/settings/group responses.
- Settings WebSocket broadcasts are section-specific (`settings.<section>.updated`) instead of the former whole-settings event.
## Refactoring performed
Approximate function lengths are shown to make the audit measurable; line count is only a hotspot signal, not a quality metric by itself.
| Area | Before | After |
| --- | ---: | ---: |
| `engine::control_group` | 292 | 137 |
| `api::apply_zone_control_patch` | 415 | 214 |
| `engine::control_zones` | 666 | 413 |
| monolithic `api::update_settings` | 83 | removed |
| monolithic configuration validator | 155 | removed/split |
| monolithic settings import handler | 98 | removed/split |
New smaller responsibilities include:
- GREE settings normalization and compressor-runtime cleanup;
- InfluxDB and notification update/secret merge helpers;
- Home Assistant alias/entity/input validation;
- configuration header, IDs, flows, schedules and automation validation;
- configuration runtime sanitation, resource locking, detached-device shutdown and reconciliation;
- group patch validation, climate changes, power changes and ownership updates;
- Temporary Quick Thermostat start, target and finish resolution;
- control-cycle outdoor sensor resolution, parallel room sensor reads, temperature-source selection and pre-control state handling.
## Remaining hotspots
`control_zones` remains the largest function because it is the ordered thermostat-cycle orchestrator and contains compressor/setpoint command planning. It was reduced substantially without changing lock ordering or splitting the core state machine across opaque abstractions. Flow evaluation/compiler functions remain domain-heavy but were not changed as part of this API release because they are independent of the settings redesign and already have dedicated logical verification.
## Frontend/documentation
- Web UI settings loading/saving uses only the new functional endpoints.
- Configuration import/export uses `/api/configuration/*`.
- Realtime settings handling uses section-specific events and serializes WebSocket message processing so reconnect bootstrap cannot race later updates.
- `docs/API.md`, `docs/openapi.json`, README and smoke/static Flow checks were updated for 0.12.0.
+1311 -388
View File
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.11.6",
"version": "0.12.0",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
Regular → Executable
View File
+2 -2
View File
@@ -6731,7 +6731,7 @@ textarea:focus { border-color:var(--accent); outline:2px solid color-mix(in srgb
.flow-generated-card .card-footer > small { color:var(--accent); }
/* 0.11.6 production layout guards and in-app chart preview. */
/* 0.12.0 production layout guards and in-app chart preview. */
html,body { max-width:100%; overflow-x:clip; }
main,.view { min-width:0; max-width:100%; }
.flow-info-panel,.flow-info-panel > div { min-width:0; }
@@ -7113,7 +7113,7 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde
}
/* 0.11.6 diagnostics, manual-control safety and technical connection checks. */
/* 0.12.0 diagnostics, manual-control safety and technical connection checks. */
.manual-device-problem {
display: grid;
gap: 4px;
Regular → Executable
View File
+2 -2
View File
@@ -8,6 +8,6 @@ bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./install.sh
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debug.sh
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
c714e3cf2fba84ded718d102bd620cf879c1a8f3a5ae8f435be7863d8a0cd8e0 ./smoke.sh
7e8f558b284362f55f50bd7223a89754a2a4960e746a27035cc61180f4a2da6f ./smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./verify_flow_logic.py
0790b7b388467d9e75b621188cca473e22d73e01bcfa74e92fa23f92b0695855 ./verify_flow_logic.py
Regular → Executable
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+7 -7
View File
@@ -102,32 +102,32 @@ STALE_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type:
[[ "$STALE_STATUS" == "409" ]]
# Shared Flow inputs are source-only; comparisons belong exclusively to Flow references.
curl -fsS "http://127.0.0.1:$PORT/api/settings" >"$TMP/settings-shared-input.json"
curl -fsS "http://127.0.0.1:$PORT/api/settings/home-assistant" >"$TMP/settings-shared-input.json"
python3 - "$TMP/settings-shared-input.json" "$TMP/settings-shared-input-put.json" <<'PYFLOW'
import json,sys
settings=json.load(open(sys.argv[1]))
items=settings.setdefault("home_assistant",{}).setdefault("flow_inputs",[])
items=settings.setdefault("flow_inputs",[])
items=[item for item in items if item.get("id") != "smoke-shared-outdoor"]
items.append({"id":"smoke-shared-outdoor","name":"Smoke outdoor value","kind":"outdoor_temperature","config":{}})
settings["home_assistant"]["flow_inputs"]=items
settings["flow_inputs"]=items
json.dump(settings,open(sys.argv[2],"w"))
PYFLOW
curl -fsS -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-put.json" \
"http://127.0.0.1:$PORT/api/settings" >"$TMP/settings-shared-input-result.json"
"http://127.0.0.1:$PORT/api/settings/home-assistant" >"$TMP/settings-shared-input-result.json"
python3 - "$TMP/settings-shared-input-result.json" <<'PYFLOW'
import json,sys
settings=json.load(open(sys.argv[1]))
item=next(item for item in settings["home_assistant"]["flow_inputs"] if item["id"] == "smoke-shared-outdoor")
item=next(item for item in settings["flow_inputs"] if item["id"] == "smoke-shared-outdoor")
assert item["config"] == {}, item
PYFLOW
python3 - "$TMP/settings-shared-input-put.json" "$TMP/settings-shared-input-invalid.json" <<'PYFLOW'
import json,sys
settings=json.load(open(sys.argv[1]))
item=next(item for item in settings["home_assistant"]["flow_inputs"] if item["id"] == "smoke-shared-outdoor")
item=next(item for item in settings["flow_inputs"] if item["id"] == "smoke-shared-outdoor")
item["config"]={"operator":"lt","value":20}
json.dump(settings,open(sys.argv[2],"w"))
PYFLOW
INVALID_SHARED_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-invalid.json" "http://127.0.0.1:$PORT/api/settings")"
INVALID_SHARED_STATUS="$(curl -sS -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/settings-shared-input-invalid.json" "http://127.0.0.1:$PORT/api/settings/home-assistant")"
[[ "$INVALID_SHARED_STATUS" == "400" ]]
cat >"$TMP/flow-shared-value.json" <<EOF
{"flow":{"name":"Shared value comparisons","enabled":true,"nodes":[{"id":"low","kind":"shared_input","x":20,"y":20,"config":{"input_id":"smoke-shared-outdoor","operator":"lt","value":20}},{"id":"high","kind":"shared_input","x":20,"y":140,"config":{"input_id":"smoke-shared-outdoor","operator":"gt","value":30}},{"id":"either","kind":"logic_or","x":220,"y":80,"config":{}},{"id":"action","kind":"zone_thermostat","x":420,"y":80,"config":{"zone_id":"$ZONE_ID","preset":"comfort","mode":"auto","cooldown_seconds":60}}],"edges":[{"id":"e1","from":"low","to":"either"},{"id":"e2","from":"high","to":"either"},{"id":"e3","from":"either","to":"action"}]},"overrides":{"low":15,"high":15},"log":false}
Regular → Executable
View File
Regular → Executable
+2 -2
View File
@@ -423,7 +423,7 @@ def main():
check('on-change value mode needs one direct source/condition input' in api, 'on-change value-source guard missing')
models=(ROOT/'src/models/flow.rs').read_text()
core=(ROOT/'web/js/core.js').read_text()
settings_api=(ROOT/'src/api/settings.rs').read_text()
configuration_api=(ROOT/'src/api/configuration.rs').read_text()
check('pub draft: bool' in models and '#[serde(default)]' in models, 'Flow draft persistence field missing')
check('fn validate_flow_draft_graph' in api, 'draft structural validator missing')
check(api.count('if input.draft { validate_flow_draft_graph(&input)?; } else { validate_flow_graph(&input)?; }') >= 3, 'draft create/update/import validation split missing')
@@ -431,7 +431,7 @@ def main():
check(api.count('(prepare_draft_flow(flow), vec![], vec![])') >= 3, 'draft save/import may compile executable outputs')
check('flow.compiled_schedule_ids.clear()' in api and 'flow.compiled_automation_ids.clear()' in api, 'draft output clearing missing')
check('"draft": flow.draft' in api, 'draft state missing from Flow export')
check('import contains an executable Flow draft' in settings_api, 'configuration import does not enforce draft safety invariant')
check('import contains an executable Flow draft' in configuration_api, 'configuration import does not enforce draft safety invariant')
check('error.status = response.status' in core, 'frontend API errors do not expose HTTP validation status')
check("error.status !== 400" in js and "draft:true" in js and "enabled:false" in js, 'save-as-draft retry flow missing')
check("if (flow.draft) return toast(tr('flow.draftCannotEnable')" in js, 'draft quick-enable guard missing')
+12 -7
View File
@@ -23,7 +23,7 @@ use crate::{
home_assistant,
influxdb,
notifications,
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GroupControlPatch, ManualDeviceRequest, HaReading, NotificationSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPatch, ZoneReading},
models::{ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup, ConfigurationExport, DebugSettings, Device, DeviceCommand, DevicePatch, DiscoveryRequest, GreeSettings, GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings, HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings, InfluxDbSettingsUpdate, InfluxDbSettingsView, ManualDeviceRequest, NightModeSettings, NotificationSettings, NotificationSettingsUpdate, NotificationSettingsView, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch, ZoneReading},
protocol::merge_discovered,
state::AppState,
};
@@ -98,11 +98,16 @@ pub fn router(state: AppState) -> Router {
.route("/api/history", get(history))
.route("/api/control-plan", get(control_plan))
.route("/api/events", get(events))
.route("/api/events/retention", get(get_event_retention).put(update_event_retention))
.route("/api/settings", get(get_settings).put(update_settings))
.route("/api/settings/export", get(export_settings))
.route("/api/settings/import", post(import_settings))
.route("/api/debug", get(get_debug).put(update_debug))
.route("/api/settings/application", get(get_application_settings).put(update_application_settings))
.route("/api/settings/gree", get(get_gree_settings).put(update_gree_settings))
.route("/api/settings/history", get(get_history_settings).put(update_history_settings))
.route("/api/settings/influxdb", get(get_influxdb_settings).put(update_influxdb_settings))
.route("/api/settings/notifications", get(get_notification_settings).put(update_notification_settings))
.route("/api/settings/night", get(get_night_settings).put(update_night_settings))
.route("/api/settings/home-assistant", get(get_home_assistant_settings).put(update_home_assistant_settings))
.route("/api/settings/debug", get(get_debug_settings).put(update_debug_settings))
.route("/api/configuration/export", get(export_configuration))
.route("/api/configuration/import", post(import_configuration))
.route("/api/access-tokens", get(list_access_tokens).post(create_access_token))
.route("/api/access-tokens/:id", axum::routing::delete(delete_access_token))
.route("/api/integrations/home-assistant/test", post(test_home_assistant))
@@ -186,9 +191,9 @@ include!("api/flows.rs");
include!("api/history.rs");
include!("api/events.rs");
include!("api/settings.rs");
include!("api/configuration.rs");
include!("api/debug_tokens.rs");
include!("api/integrations.rs");
include!("api/middleware.rs");
include!("api/public_settings.rs");
include!("api/websocket.rs");
include!("api/assets.rs");
+521
View File
@@ -0,0 +1,521 @@
struct ConfigurationIds<'a> {
devices: std::collections::HashSet<&'a str>,
zones: std::collections::HashSet<&'a str>,
schedules: std::collections::HashSet<&'a str>,
automations: std::collections::HashSet<&'a str>,
flows: std::collections::HashSet<&'a str>,
}
struct ConfigurationResourceGuards {
_zones: Vec<tokio::sync::OwnedMutexGuard<()>>,
_devices: Vec<tokio::sync::OwnedMutexGuard<()>>,
}
async fn export_configuration(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
let settings = state.settings.read().await.clone();
let mut export = state.db.export_configuration(settings)?;
sanitize_configuration_runtime(&mut export);
Ok(Json(export))
}
fn validate_configuration_header(export: &ConfigurationExport) -> Result<(), AppError> {
if export.format_version != 3 {
return Err(AppError::BadRequest("unsupported configuration export version; version 3 is required by GREE Controller 0.12.x".into()));
}
if export.settings.control_strategy != "setpoint" {
return Err(AppError::BadRequest("import contains an unsupported control strategy".into()));
}
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
}
Ok(())
}
fn collect_configuration_ids(export: &ConfigurationExport) -> Result<ConfigurationIds<'_>, AppError> {
let ids = ConfigurationIds {
devices: export.devices.iter().map(|item| item.id.as_str()).collect(),
zones: export.zones.iter().map(|item| item.id.as_str()).collect(),
schedules: export.schedules.iter().map(|item| item.id.as_str()).collect(),
automations: export.automations.iter().map(|item| item.id.as_str()).collect(),
flows: export.flows.iter().map(|item| item.id.as_str()).collect(),
};
let duplicate_or_empty = ids.devices.len() != export.devices.len()
|| ids.zones.len() != export.zones.len()
|| ids.schedules.len() != export.schedules.len()
|| ids.automations.len() != export.automations.len()
|| ids.flows.len() != export.flows.len()
|| ids.devices.contains("")
|| ids.zones.contains("")
|| ids.schedules.contains("")
|| ids.automations.contains("")
|| ids.flows.contains("");
if duplicate_or_empty {
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
}
Ok(ids)
}
fn validate_configuration_flows(export: &ConfigurationExport) -> Result<(), AppError> {
let draft_flows: std::collections::HashSet<&str> = export.flows.iter()
.filter(|item| item.draft)
.map(|item| item.id.as_str())
.collect();
let executable_draft = export.flows.iter().any(|item| item.draft
&& (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty()))
|| export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|| export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)));
if executable_draft {
return Err(AppError::BadRequest("import contains an executable Flow draft".into()));
}
Ok(())
}
fn validate_configuration_devices_and_zones(
export: &ConfigurationExport,
ids: &ConfigurationIds<'_>,
) -> Result<(), AppError> {
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
if device_macs.len() != export.devices.len() {
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into()));
}
if export.zones.iter().any(|item| !ids.devices.contains(item.device_id.as_str())) {
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
}
let mut zone_devices = std::collections::HashSet::new();
for zone in &export.zones {
if !zone_devices.insert(zone.device_id.as_str()) {
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into()));
}
if !matches!(zone.mode.as_str(), "cool" | "heat") {
return Err(AppError::BadRequest("import contains an invalid zone mode".into()));
}
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") {
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into()));
}
}
Ok(())
}
fn validate_configuration_schedules(
export: &ConfigurationExport,
ids: &ConfigurationIds<'_>,
) -> Result<(), AppError> {
if export.schedules.iter().any(|item| !ids.zones.contains(item.zone_id.as_str())) {
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
}
for item in &export.schedules {
if item.flow_id.as_deref().is_some_and(|flow_id| !ids.flows.contains(flow_id)) {
return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into()));
}
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
}
NaiveTime::parse_from_str(&item.start_time, "%H:%M")
.map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?;
NaiveTime::parse_from_str(&item.end_time, "%H:%M")
.map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?;
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
}
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into()));
}
}
validate_schedule_set(&export.schedules)?;
Ok(())
}
fn validate_configuration_groups<'a>(
export: &'a ConfigurationExport,
ids: &ConfigurationIds<'_>,
) -> Result<std::collections::HashSet<&'a str>, AppError> {
if export.groups.iter().any(|group| {
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
group.id.trim().is_empty()
|| group.zone_ids.is_empty()
|| members.len() != group.zone_ids.len()
|| group.zone_ids.iter().any(|zone_id| !ids.zones.contains(zone_id.as_str()))
}) {
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into()));
}
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
if groups.len() != export.groups.len() {
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
}
Ok(groups)
}
fn validate_configuration_automation_trigger(
item: &Automation,
ids: &ConfigurationIds<'_>,
) -> Result<(), AppError> {
match item.trigger_kind.as_str() {
"temperature_above" | "temperature_below" => {
let Some(trigger_id) = item.trigger_device_id.as_deref() else {
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into()));
};
if !ids.devices.contains(trigger_id) || item.threshold.is_none() {
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into()));
}
}
"time" => {
let at = item.at_time.as_deref()
.ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
NaiveTime::parse_from_str(at, "%H:%M")
.map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
}
"flow" => {
if item.flow_id.as_deref().filter(|id| ids.flows.contains(*id)).is_none() || item.flow_conditions.is_empty() {
return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into()));
}
}
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
}
Ok(())
}
fn validate_configuration_zone_automation(item: &Automation, ids: &ConfigurationIds<'_>) -> Result<(), AppError> {
let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); };
if !ids.zones.contains(zone_id) {
return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into()));
}
if let Some(preset) = item.action_zone_preset.as_deref() {
if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into()));
}
}
if item.action_zone_preset.as_deref() == Some("custom")
&& item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value))
{
return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into()));
}
Ok(())
}
fn validate_configuration_group_automation(
item: &Automation,
groups: &std::collections::HashSet<&str>,
) -> Result<(), AppError> {
let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) else { return Ok(()); };
if !groups.contains(group_id) {
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
}
if let Some(mode) = item.action.mode.as_deref() {
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
}
}
let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom");
if let Some(preset) = item.action_preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") {
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
}
}
if flow_custom_group {
let Some(target) = item.action.target_temperature else {
return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into()));
};
if !(8.0..=30.0).contains(&target) {
return Err(AppError::BadRequest("import contains an invalid Flow group target".into()));
}
} else if item.action.target_temperature.is_some() {
return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into()));
}
if item.action.fan_speed.is_some()
|| item.action.swing_vertical.is_some()
|| item.action.swing_horizontal.is_some()
|| item.action.quiet.is_some()
|| item.action.turbo.is_some()
|| item.action.light.is_some()
|| item.action.air.is_some()
|| item.action.xfan.is_some()
|| item.action.health.is_some()
|| item.action.sleep.is_some()
{
return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into()));
}
if item.action.power.is_none()
&& item.action.mode.is_none()
&& item.action_preset.as_deref().filter(|value| !value.is_empty()).is_none()
{
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
}
Ok(())
}
fn validate_configuration_shared_inputs(
export: &ConfigurationExport,
ids: &ConfigurationIds<'_>,
groups: &std::collections::HashSet<&str>,
) -> Result<(), AppError> {
for item in &export.settings.home_assistant.flow_inputs {
let Some(reference) = shared_input_resource_reference(&item.kind, &item.config)? else { continue; };
let exists = match reference {
SharedInputResourceReference::Device(id) => ids.devices.contains(id.as_str()),
SharedInputResourceReference::Zone(id) => ids.zones.contains(id.as_str()),
SharedInputResourceReference::Group(id) => groups.contains(id.as_str()),
};
if !exists {
return Err(AppError::BadRequest("import contains a shared Flow input referencing a missing resource".into()));
}
}
Ok(())
}
fn validate_configuration_automations(
export: &ConfigurationExport,
ids: &ConfigurationIds<'_>,
groups: &std::collections::HashSet<&str>,
) -> Result<(), AppError> {
for item in &export.automations {
validate_configuration_automation_trigger(item, ids)?;
if item.action_zone_id.as_deref().is_some_and(|value| !value.is_empty()) {
validate_configuration_zone_automation(item, ids)?;
} else if item.action_group_id.as_deref().is_some_and(|value| !value.is_empty()) {
validate_configuration_group_automation(item, groups)?;
} else {
if !ids.devices.contains(item.action_device_id.as_str()) {
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
}
engine::validate_command(&item.action)?;
if item.action.is_empty() {
return Err(AppError::BadRequest("import contains an empty automation action".into()));
}
}
}
Ok(())
}
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
validate_configuration_header(export)?;
validate_configuration_flows(export)?;
let ids = collect_configuration_ids(export)?;
validate_configuration_devices_and_zones(export, &ids)?;
validate_configuration_schedules(export, &ids)?;
let groups = validate_configuration_groups(export, &ids)?;
validate_configuration_shared_inputs(export, &ids, &groups)?;
validate_configuration_automations(export, &ids, &groups)
}
fn sanitize_imported_device(device: &mut Device, now: chrono::DateTime<Utc>) {
device.power = false;
device.mode = "cool".into();
device.target_temperature = 23.0;
device.fan_speed = 0;
device.swing_vertical = false;
device.swing_horizontal = false;
device.quiet = false;
device.turbo = false;
device.light = false;
device.air = false;
device.xfan = false;
device.health = false;
device.sleep = false;
device.current_temperature = None;
device.outdoor_temperature = None;
device.online = false;
device.response_time_ms = None;
device.last_seen = None;
device.last_error = None;
device.communication_failures = 0;
device.updated_at = now;
}
fn sanitize_imported_zone(zone: &mut Zone, now: chrono::DateTime<Utc>) {
zone.device_temperature = None;
zone.external_temperature = None;
zone.current_temperature = None;
zone.control_temperature_source = "device".into();
zone.active_preset = "comfort".into();
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
zone.temporary_quick_thermostat = None;
zone.device_manual_override = false;
zone.device_manual_override_since = None;
zone.device_manual_override_until = None;
zone.device_manual_override_fields.clear();
zone.device_manual_override_baseline = None;
zone.control_owner = "automation".into();
zone.control_source = "automation".into();
zone.control_since = None;
zone.control_resume_at = None;
zone.control_reason = "Imported configuration; runtime ownership reset".into();
zone.last_power_change_at = None;
zone.last_mode_change_at = None;
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.compressor_cancelled_action = None;
zone.effective_mode.clear();
zone.effective_setpoint = None;
zone.device_setpoint = None;
zone.demand = false;
zone.demand_since = None;
zone.target_alerted_at = None;
zone.last_action_at = None;
zone.revision = 0;
zone.updated_at = now;
}
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
let now = Utc::now();
for device in &mut export.devices { sanitize_imported_device(device, now.clone()); }
for zone in &mut export.zones { sanitize_imported_zone(zone, now.clone()); }
for automation in &mut export.automations {
automation.last_fired_at = None;
automation.updated_at = now.clone();
}
}
fn normalize_imported_runtime_settings(settings: &mut RuntimeSettings) -> Result<(), AppError> {
let gree = normalize_gree_settings(gree_settings(settings))?;
settings.controller_id = gree.controller_id;
settings.poll_interval_seconds = gree.poll_interval_seconds;
settings.zone_interval_seconds = gree.zone_interval_seconds;
settings.discovery_timeout_ms = gree.discovery_timeout_ms;
settings.discovery_broadcast = gree.discovery_broadcast;
settings.suppress_device_beep = gree.suppress_device_beep;
settings.compressor_protection_enabled = gree.compressor_protection_enabled;
settings.compressor_protection_seconds = gree.compressor_protection_seconds;
settings.history_retention_days = settings.history_retention_days.clamp(1, 3650);
settings.event_log_retention_days = settings.event_log_retention_days.clamp(1, 3650);
settings.influxdb.history_threshold_days = settings.influxdb.history_threshold_days.clamp(1, 3650);
influxdb::validate(&settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
let current_notifications = settings.notifications.clone();
settings.notifications = apply_notification_update(&current_notifications, NotificationSettingsUpdate {
enabled: current_notifications.enabled,
mode: current_notifications.mode.clone(),
provider: current_notifications.provider.clone(),
pushover_app_token: Some(current_notifications.pushover_app_token.clone()),
pushover_user_key: Some(current_notifications.pushover_user_key.clone()),
slack_webhook_url: Some(current_notifications.slack_webhook_url.clone()),
discord_webhook_url: Some(current_notifications.discord_webhook_url.clone()),
cooldown_seconds: current_notifications.cooldown_seconds,
communication_failure_threshold: current_notifications.communication_failure_threshold,
target_timeout_minutes: current_notifications.target_timeout_minutes,
alert_types: current_notifications.alert_types.clone(),
})?;
settings.home_assistant.sensor_stale_after_seconds = settings.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400);
normalize_sensor_aliases(&mut settings.home_assistant);
normalize_flow_shared_inputs(&mut settings.home_assistant)?;
canonicalize_home_assistant_entities(&mut settings.home_assistant);
validate_home_assistant_url(&settings.home_assistant)?;
validate_night_mode(&mut settings.night_mode)?;
Ok(())
}
fn prepare_configuration_import(export: &mut ConfigurationExport) -> Result<(), AppError> {
normalize_imported_runtime_settings(&mut export.settings)?;
for zone in &mut export.zones {
canonicalize_zone_ha_entity(zone, &export.settings.home_assistant);
}
Ok(())
}
async fn lock_configuration_resources(
state: &AppState,
current_zones: &[Zone],
current_devices: &[Device],
export: &ConfigurationExport,
) -> ConfigurationResourceGuards {
let mut zone_ids: Vec<String> = current_zones.iter().map(|zone| zone.id.clone())
.chain(export.zones.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> = current_devices.iter().map(|device| device.id.clone())
.chain(export.devices.iter().map(|device| device.id.clone()))
.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); }
ConfigurationResourceGuards { _zones: zone_guards, _devices: device_guards }
}
async fn power_off_detached_devices(
state: &AppState,
current_zones: &[Zone],
export: &ConfigurationExport,
) -> Result<(), AppError> {
let imported_zone_map: std::collections::HashMap<String, String> = export.zones.iter()
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
.collect();
let detach_devices: std::collections::HashSet<String> = current_zones.iter()
.filter(|current| imported_zone_map.get(&current.id).map(String::as_str) != Some(current.device_id.as_str()))
.map(|current| current.device_id.clone())
.collect();
for device_id in detach_devices {
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"
}));
}
Ok(())
}
async fn reconcile_imported_devices(state: &AppState, export: &ConfigurationExport) -> Result<(), AppError> {
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
.filter(|zone| {
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
zone.enabled && effective_mode != "off"
})
.map(|zone| zone.device_id.clone())
.collect();
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
if let Err(err) = engine::force_power_off_device_locked(state, &device.id).await {
state.log("error", "configuration.import_reconcile_error", &err.to_string(), json!({"device_id": device.id}));
return Err(err);
}
}
engine::poll_all_locked(state).await?;
Ok(())
}
async fn import_configuration(
State(state): State<AppState>,
Json(mut export): Json<ConfigurationExport>,
) -> Result<Json<Value>, AppError> {
validate_configuration_export(&export)?;
prepare_configuration_import(&mut export)?;
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 _cycle_guard = state.lock_zone_control_cycle().await;
let current_zones = state.db.list_zones()?;
let current_devices = state.db.list_devices()?;
let _resource_guards = lock_configuration_resources(&state, &current_zones, &current_devices, &export).await;
power_off_detached_devices(&state, &current_zones, &export).await?;
sanitize_configuration_runtime(&mut export);
state.initial_device_sync_complete.store(false, Ordering::Release);
state.db.replace_configuration(&export)?;
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = export.settings.clone();
reconcile_imported_devices(&state, &export).await?;
state.initial_device_sync_complete.store(true, Ordering::Release);
state.wake_zone_control();
state.log("info", "configuration.imported", "Application configuration imported", json!({"format_version": export.format_version}));
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
Ok(Json(json!({"ok": true})))
}
-15
View File
@@ -1,18 +1,3 @@
async fn get_debug(State(state): State<AppState>) -> Json<DebugSettings> {
Json(state.settings.read().await.debug.clone())
}
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;
settings.debug = input.clone();
state.db.save_runtime_settings(&settings)?;
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
state.broadcast("debug.settings", serde_json::to_value(&input)?);
Ok(Json(input))
}
#[derive(Debug, Deserialize)]
struct CreateAccessTokenRequest {
name: Option<String>,
-24
View File
@@ -3,27 +3,3 @@ struct EventsQuery { limit: Option<u32> }
async fn events(State(state): State<AppState>, Query(query): Query<EventsQuery>) -> Result<Json<Value>, AppError> {
Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?})))
}
#[derive(Debug, Deserialize)]
struct EventRetentionInput { days: u32 }
async fn get_event_retention(State(state): State<AppState>) -> Json<Value> {
let days = state.settings.read().await.event_log_retention_days;
Json(json!({"days": days}))
}
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;
settings.event_log_retention_days = input.days.clamp(1, 3650);
state.db.save_runtime_settings(&settings)?;
let days = settings.event_log_retention_days;
drop(settings);
let removed = state.db.prune_events(days as i64)?;
state.log("info", "events.retention_updated", "Event log retention updated", json!({"days": days, "removed": removed}));
let public = { let settings = state.settings.read().await; public_settings(&*settings) };
state.broadcast("settings.updated", public);
Ok(Json(json!({"days": days, "removed": removed})))
}
+53 -18
View File
@@ -61,50 +61,85 @@ fn shared_input_comparison_kind(kind: &str) -> bool {
)
}
fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) -> Result<(), AppError> {
match kind {
"outdoor_temperature" | "house_mode" | "night_mode" => {}
enum SharedInputResourceReference {
Device(String),
Zone(String),
Group(String),
}
fn shared_input_resource_reference(kind: &str, config: &Value) -> Result<Option<SharedInputResourceReference>, AppError> {
let reference = match kind {
"outdoor_temperature" | "house_mode" | "night_mode" => None,
"device_temperature" => {
let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device temperature input needs device".into()))?;
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing device".into())); }
let id = flow_string(config, "device_id")
.ok_or_else(|| AppError::BadRequest("shared device temperature input needs device".into()))?;
Some(SharedInputResourceReference::Device(id))
}
"zone_temperature" => {
let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone temperature input needs zone".into()))?;
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing zone".into())); }
let id = flow_string(config, "zone_id")
.ok_or_else(|| AppError::BadRequest("shared zone temperature input needs zone".into()))?;
Some(SharedInputResourceReference::Zone(id))
}
"ha_state" | "ha_numeric" | "ha_available" => {
if flow_string(config, "entity_id").is_none() { return Err(AppError::BadRequest("shared Home Assistant input needs entity_id".into())); }
if flow_string(config, "entity_id").is_none() {
return Err(AppError::BadRequest("shared Home Assistant input needs entity_id".into()));
}
None
}
"ha_attribute" => {
if flow_string(config, "entity_id").is_none() || flow_string(config, "attribute").is_none() {
return Err(AppError::BadRequest("shared Home Assistant attribute input needs entity_id and attribute".into()));
}
None
}
"device_state" => {
let id = flow_string(config, "device_id").ok_or_else(|| AppError::BadRequest("shared device state input needs device".into()))?;
if state.db.get_device(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing device".into())); }
let field = flow_string(config, "field").ok_or_else(|| AppError::BadRequest("shared device state input needs a field".into()))?;
let id = flow_string(config, "device_id")
.ok_or_else(|| AppError::BadRequest("shared device state input needs device".into()))?;
let field = flow_string(config, "field")
.ok_or_else(|| AppError::BadRequest("shared device state input needs a field".into()))?;
if !matches!(field.as_str(), "enabled" | "online" | "power" | "mode" | "fan_speed" | "swing_vertical" | "swing_horizontal" | "quiet" | "turbo" | "light" | "air" | "xfan" | "health" | "sleep") {
return Err(AppError::BadRequest("unsupported shared device state field".into()));
}
Some(SharedInputResourceReference::Device(id))
}
"zone_state" => {
let id = flow_string(config, "zone_id").ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?;
if state.db.get_zone(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing zone".into())); }
let field = flow_string(config, "field").ok_or_else(|| AppError::BadRequest("shared zone state input needs a field".into()))?;
let id = flow_string(config, "zone_id")
.ok_or_else(|| AppError::BadRequest("shared zone state input needs zone".into()))?;
let field = flow_string(config, "field")
.ok_or_else(|| AppError::BadRequest("shared zone state input needs a field".into()))?;
if !matches!(field.as_str(), "enabled" | "mode" | "active_preset" | "demand" | "control_owner" | "device_manual_override" | "local_thermostat_power") {
return Err(AppError::BadRequest("unsupported shared zone state field".into()));
}
Some(SharedInputResourceReference::Zone(id))
}
"group_state" => {
let id = flow_string(config, "group_id").ok_or_else(|| AppError::BadRequest("shared group state input needs group".into()))?;
if state.db.get_group(&id)?.is_none() { return Err(AppError::BadRequest("shared Flow input references a missing group".into())); }
if flow_string(config, "field").as_deref() != Some("power_enabled") { return Err(AppError::BadRequest("unsupported shared group state field".into())); }
let id = flow_string(config, "group_id")
.ok_or_else(|| AppError::BadRequest("shared group state input needs group".into()))?;
if flow_string(config, "field").as_deref() != Some("power_enabled") {
return Err(AppError::BadRequest("unsupported shared group state field".into()));
}
Some(SharedInputResourceReference::Group(id))
}
"constant" => {
if config.get("value").and_then(Value::as_bool).is_none() { return Err(AppError::BadRequest("shared constant input needs a boolean value".into())); }
if config.get("value").and_then(Value::as_bool).is_none() {
return Err(AppError::BadRequest("shared constant input needs a boolean value".into()));
}
None
}
_ => return Err(AppError::BadRequest(format!("unsupported shared Flow input kind: {kind}"))),
};
Ok(reference)
}
fn validate_shared_input_source(kind: &str, config: &Value, state: &AppState) -> Result<(), AppError> {
let Some(reference) = shared_input_resource_reference(kind, config)? else { return Ok(()); };
let exists = match reference {
SharedInputResourceReference::Device(id) => state.db.get_device(&id)?.is_some(),
SharedInputResourceReference::Zone(id) => state.db.get_zone(&id)?.is_some(),
SharedInputResourceReference::Group(id) => state.db.get_group(&id)?.is_some(),
};
if !exists {
return Err(AppError::BadRequest("shared Flow input references a missing resource".into()));
}
Ok(())
}
+5 -26
View File
@@ -34,7 +34,7 @@ async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
engine::rearm_compressor_queue(&mut zone);
if engine::set_house_bulk_thermostat_power(&mut zone, power) { changed += 1; }
engine::refresh_control_ownership(&mut zone, true);
engine::refresh_control_ownership(&mut zone);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
@@ -82,14 +82,13 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
}
let mode = input.mode;
let activate_all = mode != "off";
let payload = {
{
let mut settings = state.settings.write().await;
settings.house_mode = mode.clone();
settings.house_power_enabled = true;
state.db.save_runtime_settings(&settings)?;
public_settings(&settings)
};
state.broadcast("settings.updated", payload.clone());
}
let payload = json!({"mode": mode});
state.broadcast("house.mode_changed", payload.clone());
// House rules do not steal explicit local/group/manual ownership. Free zones follow the
// new mode immediately; scoped manual controls continue independently.
rearm_house_automation_compressor_queues(&state).await?;
@@ -118,15 +117,6 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
// resurrect demand. ON releases that OFF state and records an explicit automatic house-power
// intent for otherwise-free zones. Later explicit local/group/manual actions remain
// independent and can take over only the selected scope.
{
let mut settings = state.settings.write().await;
if !settings.house_power_enabled {
settings.house_power_enabled = true;
state.db.save_runtime_settings(&settings)?;
state.broadcast("settings.updated", public_settings(&settings));
}
}
// Persist the thermostat power intent before touching devices. The cycle lock held by this
// handler guarantees that no setpoint-modulation cycle can race between the marker and OFF.
let changed_zones = set_all_thermostat_power_state(&state, input.power).await?;
@@ -135,9 +125,6 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
let devices = state.db.list_devices()?;
let groups = state.db.list_groups()?;
let settings = state.settings.read().await;
let settings_payload = public_settings(&settings);
drop(settings);
state.log("info", "house.power_all", if input.power {
"Whole-house ON sent; local OFF state released and house thermostat intent armed"
} else {
@@ -154,7 +141,6 @@ async fn update_house_power(State(state): State<AppState>, Json(input): Json<Hou
"one_shot": true,
"devices": devices,
"groups": groups,
"settings": settings_payload,
"failed": failed,
})))
}
@@ -169,13 +155,6 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
}
let settings_payload = {
let mut settings = state.settings.write().await;
settings.house_power_enabled = true;
state.db.save_runtime_settings(&settings)?;
public_settings(&settings)
};
state.broadcast("settings.updated", settings_payload.clone());
// A house profile applies to free house-controlled zones. Explicit local/group/direct
// owners remain higher priority and are not cleared or re-armed by changing house rules.
rearm_house_automation_compressor_queues(&state).await?;
+404 -430
View File
@@ -1,93 +1,330 @@
async fn get_settings(State(state): State<AppState>) -> Json<Value> {
let settings = state.settings.read().await;
Json(public_settings(&*settings))
fn application_settings(settings: &RuntimeSettings) -> ApplicationSettings {
ApplicationSettings { simulator_enabled: settings.simulator_enabled }
}
async fn update_settings(State(state): State<AppState>, Json(mut input): Json<RuntimeSettings>) -> Result<Json<Value>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let old = state.settings.read().await.clone();
if input.house_mode != old.house_mode {
return Err(AppError::BadRequest(
"house_mode must be changed through the House Control API".into(),
));
fn gree_settings(settings: &RuntimeSettings) -> GreeSettings {
GreeSettings {
controller_id: settings.controller_id.clone(),
poll_interval_seconds: settings.poll_interval_seconds,
zone_interval_seconds: settings.zone_interval_seconds,
discovery_timeout_ms: settings.discovery_timeout_ms,
discovery_broadcast: settings.discovery_broadcast.clone(),
suppress_device_beep: settings.suppress_device_beep,
compressor_protection_enabled: settings.compressor_protection_enabled,
compressor_protection_seconds: settings.compressor_protection_seconds,
}
}
fn history_settings(settings: &RuntimeSettings) -> HistorySettings {
HistorySettings {
retention_days: settings.history_retention_days,
compaction_enabled: settings.history_compaction_enabled,
event_retention_days: settings.event_log_retention_days,
}
}
fn influxdb_settings(settings: &RuntimeSettings) -> InfluxDbSettingsView {
InfluxDbSettingsView {
enabled: settings.influxdb.enabled,
version: settings.influxdb.version.clone(),
url: settings.influxdb.url.clone(),
database: settings.influxdb.database.clone(),
username: settings.influxdb.username.clone(),
password_configured: !settings.influxdb.password.trim().is_empty(),
org: settings.influxdb.org.clone(),
bucket: settings.influxdb.bucket.clone(),
token_configured: !settings.influxdb.token.trim().is_empty(),
history_threshold_days: settings.influxdb.history_threshold_days,
}
}
fn notification_settings(settings: &RuntimeSettings) -> NotificationSettingsView {
NotificationSettingsView {
enabled: settings.notifications.enabled,
mode: settings.notifications.mode.clone(),
provider: settings.notifications.provider.clone(),
pushover_configured: !settings.notifications.pushover_app_token.trim().is_empty()
&& !settings.notifications.pushover_user_key.trim().is_empty(),
slack_configured: !settings.notifications.slack_webhook_url.trim().is_empty(),
discord_configured: !settings.notifications.discord_webhook_url.trim().is_empty(),
cooldown_seconds: settings.notifications.cooldown_seconds,
communication_failure_threshold: settings.notifications.communication_failure_threshold,
target_timeout_minutes: settings.notifications.target_timeout_minutes,
alert_types: settings.notifications.alert_types.clone(),
}
}
fn home_assistant_settings(settings: &RuntimeSettings) -> HomeAssistantSettingsView {
HomeAssistantSettingsView {
url: settings.home_assistant.url.clone(),
token_configured: !settings.home_assistant.token.trim().is_empty(),
default_entity_id: settings.home_assistant.default_entity_id.clone(),
outdoor_entity_id: settings.home_assistant.outdoor_entity_id.clone(),
sensor_stale_after_seconds: settings.home_assistant.sensor_stale_after_seconds,
allow_invalid_tls: settings.home_assistant.allow_invalid_tls,
sensor_aliases: settings.home_assistant.sensor_aliases.clone(),
flow_inputs: settings.home_assistant.flow_inputs.clone(),
outdoor_assist_enabled: settings.outdoor_assist_enabled,
}
}
async fn get_application_settings(State(state): State<AppState>) -> Json<ApplicationSettings> {
Json(application_settings(&state.settings.read().await))
}
async fn update_application_settings(
State(state): State<AppState>,
Json(input): Json<ApplicationSettings>,
) -> Result<Json<ApplicationSettings>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let payload = {
let mut settings = state.settings.write().await;
settings.simulator_enabled = input.simulator_enabled;
state.db.save_runtime_settings(&settings)?;
application_settings(&settings)
};
state.log("info", "settings.application.updated", "Application settings updated", json!({"simulator_enabled": payload.simulator_enabled}));
state.broadcast("settings.application.updated", serde_json::to_value(&payload)?);
Ok(Json(payload))
}
async fn get_gree_settings(State(state): State<AppState>) -> Json<GreeSettings> {
Json(gree_settings(&state.settings.read().await))
}
fn normalize_gree_settings(mut input: GreeSettings) -> Result<GreeSettings, AppError> {
input.controller_id = input.controller_id.trim().to_string();
if input.controller_id.is_empty() {
return Err(AppError::BadRequest("controller_id cannot be empty".into()));
}
// Compatibility-only field: global ON/OFF no longer uses a persistent master automation gate.
input.house_power_enabled = true;
input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600);
input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600);
input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000);
if !matches!(input.house_mode.as_str(), "cool" | "heat" | "off") { return Err(AppError::BadRequest("house mode must be cool, heat or off".into())); }
if input.control_strategy != "setpoint" { input.control_strategy = "setpoint".into(); }
input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
if !(input.discovery_broadcast.eq_ignore_ascii_case("auto")
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) {
|| input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:"))
{
input.discovery_broadcast.parse::<std::net::SocketAddr>()
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
}
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
input.home_assistant.sensor_stale_after_seconds = input.home_assistant.sensor_stale_after_seconds.clamp(30, 86_400);
if input.notifications.pushover_app_token.trim().is_empty() { input.notifications.pushover_app_token = old.notifications.pushover_app_token; }
if input.notifications.pushover_user_key.trim().is_empty() { input.notifications.pushover_user_key = old.notifications.pushover_user_key; }
if input.notifications.slack_webhook_url.trim().is_empty() { input.notifications.slack_webhook_url = old.notifications.slack_webhook_url; }
if input.notifications.discord_webhook_url.trim().is_empty() { input.notifications.discord_webhook_url = old.notifications.discord_webhook_url; }
input.notifications.cooldown_seconds = input.notifications.cooldown_seconds.clamp(30, 86_400);
input.notifications.communication_failure_threshold = input.notifications.communication_failure_threshold.clamp(2, 100);
input.notifications.target_timeout_minutes = input.notifications.target_timeout_minutes.clamp(5, 24 * 60);
if !matches!(input.notifications.mode.as_str(), "problems" | "important") { return Err(AppError::BadRequest("notification mode must be problems or important".into())); }
if !matches!(input.notifications.provider.as_str(), "pushover" | "slack" | "discord") { return Err(AppError::BadRequest("unsupported notification provider".into())); }
input.history_retention_days = input.history_retention_days.clamp(1, 3650);
input.event_log_retention_days = input.event_log_retention_days.clamp(1, 3650);
input.compressor_protection_seconds = input.compressor_protection_seconds.clamp(30, 1800);
let compressor_settings_changed = input.compressor_protection_enabled != old.compressor_protection_enabled
|| input.compressor_protection_seconds != old.compressor_protection_seconds;
normalize_sensor_aliases(&mut input);
validate_flow_shared_inputs(&mut input, &state)?;
canonicalize_home_assistant_entities(&mut input);
validate_night_mode(&mut input)?;
input.influxdb.history_threshold_days = input.influxdb.history_threshold_days.clamp(1, 3650);
if input.influxdb.token.trim().is_empty() { input.influxdb.token = old.influxdb.token; }
if input.influxdb.password.trim().is_empty() { input.influxdb.password = old.influxdb.password; }
influxdb::validate(&input.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
if !input.home_assistant.url.trim().is_empty() {
let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".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)?;
canonicalize_saved_zone_entities(&state, &input).await?;
state.debug_gree_frames.store(input.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = input.clone();
if compressor_settings_changed {
// A changed protection policy invalidates old deadlines. Clear transient tasks under
// the same control-cycle exclusion; the next thermostat pass re-evaluates intent
// against the new enabled flag/duration.
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(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
if zone.compressor_pending_action.is_none() && zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none() && zone.lockout_reason.is_none() { continue; }
engine::clear_compressor_pending(&mut zone, true);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
}
state.log("info", "settings.updated", "Settings updated", json!({
"compressor_protection_enabled": input.compressor_protection_enabled,
"compressor_protection_seconds": input.compressor_protection_seconds
}));
state.broadcast("settings.updated", public_settings(&input));
state.wake_zone_control();
Ok(Json(public_settings(&input)))
Ok(input)
}
fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
settings.home_assistant.sensor_aliases = settings.home_assistant.sensor_aliases
async fn clear_compressor_runtime_after_settings_change(state: &AppState) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
for zone_id in zone_ids {
let _zone_guard = state.lock_zone_operation(&zone_id).await;
let Some(mut zone) = state.db.get_zone(&zone_id)? else { continue; };
if zone.compressor_pending_action.is_none()
&& zone.compressor_cancelled_action.is_none()
&& zone.lockout_until.is_none()
&& zone.lockout_reason.is_none()
{
continue;
}
engine::clear_compressor_pending(&mut zone, true);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
}
Ok(())
}
async fn update_gree_settings(
State(state): State<AppState>,
Json(input): Json<GreeSettings>,
) -> Result<Json<GreeSettings>, AppError> {
let input = normalize_gree_settings(input)?;
let _configuration_guard = state.lock_configuration_operation().await;
let _house_guard = state.lock_house_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
let (payload, compressor_changed) = {
let mut settings = state.settings.write().await;
let compressor_changed = settings.compressor_protection_enabled != input.compressor_protection_enabled
|| settings.compressor_protection_seconds != input.compressor_protection_seconds;
settings.controller_id = input.controller_id;
settings.poll_interval_seconds = input.poll_interval_seconds;
settings.zone_interval_seconds = input.zone_interval_seconds;
settings.discovery_timeout_ms = input.discovery_timeout_ms;
settings.discovery_broadcast = input.discovery_broadcast;
settings.suppress_device_beep = input.suppress_device_beep;
settings.compressor_protection_enabled = input.compressor_protection_enabled;
settings.compressor_protection_seconds = input.compressor_protection_seconds;
state.db.save_runtime_settings(&settings)?;
(gree_settings(&settings), compressor_changed)
};
if compressor_changed {
clear_compressor_runtime_after_settings_change(&state).await?;
}
state.log("info", "settings.gree.updated", "GREE settings updated", json!({
"compressor_protection_enabled": payload.compressor_protection_enabled,
"compressor_protection_seconds": payload.compressor_protection_seconds
}));
state.broadcast("settings.gree.updated", serde_json::to_value(&payload)?);
state.wake_zone_control();
Ok(Json(payload))
}
async fn get_history_settings(State(state): State<AppState>) -> Json<HistorySettings> {
Json(history_settings(&state.settings.read().await))
}
async fn update_history_settings(
State(state): State<AppState>,
Json(mut input): Json<HistorySettings>,
) -> Result<Json<HistorySettings>, AppError> {
input.retention_days = input.retention_days.clamp(1, 3650);
input.event_retention_days = input.event_retention_days.clamp(1, 3650);
let _configuration_guard = state.lock_configuration_operation().await;
let payload = {
let mut settings = state.settings.write().await;
settings.history_retention_days = input.retention_days;
settings.history_compaction_enabled = input.compaction_enabled;
settings.event_log_retention_days = input.event_retention_days;
state.db.save_runtime_settings(&settings)?;
history_settings(&settings)
};
let removed = state.db.prune_events(payload.event_retention_days as i64)?;
state.log("info", "settings.history.updated", "History settings updated", json!({
"retention_days": payload.retention_days,
"event_retention_days": payload.event_retention_days,
"event_rows_removed": removed
}));
state.broadcast("settings.history.updated", serde_json::to_value(&payload)?);
Ok(Json(payload))
}
async fn get_influxdb_settings(State(state): State<AppState>) -> Json<InfluxDbSettingsView> {
Json(influxdb_settings(&state.settings.read().await))
}
fn apply_influxdb_update(current: &InfluxDbSettings, input: InfluxDbSettingsUpdate) -> Result<InfluxDbSettings, AppError> {
let mut next = InfluxDbSettings {
enabled: input.enabled,
version: input.version,
url: input.url,
database: input.database,
username: input.username,
password: current.password.clone(),
org: input.org,
bucket: input.bucket,
token: current.token.clone(),
history_threshold_days: input.history_threshold_days.clamp(1, 3650),
};
if let Some(password) = input.password { next.password = password; }
if let Some(token) = input.token { next.token = token; }
influxdb::validate(&next).map_err(|err| AppError::BadRequest(err.to_string()))?;
Ok(next)
}
async fn update_influxdb_settings(
State(state): State<AppState>,
Json(input): Json<InfluxDbSettingsUpdate>,
) -> Result<Json<InfluxDbSettingsView>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let payload = {
let mut settings = state.settings.write().await;
let next = apply_influxdb_update(&settings.influxdb, input)?;
settings.influxdb = next;
state.db.save_runtime_settings(&settings)?;
influxdb_settings(&settings)
};
state.log("info", "settings.influxdb.updated", "InfluxDB settings updated", json!({"enabled": payload.enabled, "version": payload.version}));
state.broadcast("settings.influxdb.updated", serde_json::to_value(&payload)?);
Ok(Json(payload))
}
async fn get_notification_settings(State(state): State<AppState>) -> Json<NotificationSettingsView> {
Json(notification_settings(&state.settings.read().await))
}
fn apply_notification_update(current: &NotificationSettings, mut input: NotificationSettingsUpdate) -> Result<NotificationSettings, AppError> {
input.cooldown_seconds = input.cooldown_seconds.clamp(30, 86_400);
input.communication_failure_threshold = input.communication_failure_threshold.clamp(2, 100);
input.target_timeout_minutes = input.target_timeout_minutes.clamp(5, 24 * 60);
if !matches!(input.mode.as_str(), "problems" | "important") {
return Err(AppError::BadRequest("notification mode must be problems or important".into()));
}
if !matches!(input.provider.as_str(), "pushover" | "slack" | "discord") {
return Err(AppError::BadRequest("unsupported notification provider".into()));
}
let mut next = NotificationSettings {
enabled: input.enabled,
mode: input.mode,
provider: input.provider,
pushover_app_token: current.pushover_app_token.clone(),
pushover_user_key: current.pushover_user_key.clone(),
slack_webhook_url: current.slack_webhook_url.clone(),
discord_webhook_url: current.discord_webhook_url.clone(),
cooldown_seconds: input.cooldown_seconds,
communication_failure_threshold: input.communication_failure_threshold,
target_timeout_minutes: input.target_timeout_minutes,
alert_types: input.alert_types,
};
if let Some(value) = input.pushover_app_token { next.pushover_app_token = value; }
if let Some(value) = input.pushover_user_key { next.pushover_user_key = value; }
if let Some(value) = input.slack_webhook_url { next.slack_webhook_url = value; }
if let Some(value) = input.discord_webhook_url { next.discord_webhook_url = value; }
Ok(next)
}
async fn update_notification_settings(
State(state): State<AppState>,
Json(input): Json<NotificationSettingsUpdate>,
) -> Result<Json<NotificationSettingsView>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
let payload = {
let mut settings = state.settings.write().await;
let next = apply_notification_update(&settings.notifications, input)?;
settings.notifications = next;
state.db.save_runtime_settings(&settings)?;
notification_settings(&settings)
};
state.log("info", "settings.notifications.updated", "Notification settings updated", json!({"enabled": payload.enabled, "provider": payload.provider}));
state.broadcast("settings.notifications.updated", serde_json::to_value(&payload)?);
Ok(Json(payload))
}
async fn get_night_settings(State(state): State<AppState>) -> Json<NightModeSettings> {
Json(state.settings.read().await.night_mode.clone())
}
fn validate_night_mode(settings: &mut NightModeSettings) -> Result<(), AppError> {
NaiveTime::parse_from_str(&settings.start_time, "%H:%M")
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
NaiveTime::parse_from_str(&settings.end_time, "%H:%M")
.map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?;
settings.max_fan_speed = settings.max_fan_speed.clamp(1, 5);
Ok(())
}
async fn update_night_settings(
State(state): State<AppState>,
Json(mut input): Json<NightModeSettings>,
) -> Result<Json<NightModeSettings>, AppError> {
validate_night_mode(&mut input)?;
let _configuration_guard = state.lock_configuration_operation().await;
let _cycle_guard = state.lock_zone_control_cycle().await;
{
let mut settings = state.settings.write().await;
settings.night_mode = input.clone();
state.db.save_runtime_settings(&settings)?;
}
state.log("info", "settings.night.updated", "Night mode settings updated", json!({"enabled": input.enabled}));
state.broadcast("settings.night.updated", serde_json::to_value(&input)?);
state.wake_zone_control();
Ok(Json(input))
}
async fn get_home_assistant_settings(State(state): State<AppState>) -> Json<HomeAssistantSettingsView> {
Json(home_assistant_settings(&state.settings.read().await))
}
fn normalize_sensor_aliases(settings: &mut HomeAssistantSettings) {
settings.sensor_aliases = settings.sensor_aliases
.iter()
.filter_map(|(entity, alias)| {
let entity = entity.trim();
@@ -98,12 +335,12 @@ fn normalize_sensor_aliases(settings: &mut RuntimeSettings) {
.collect();
}
fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState) -> Result<(), AppError> {
fn normalize_flow_shared_inputs(settings: &mut HomeAssistantSettings) -> Result<(), AppError> {
let mut ids = std::collections::HashSet::new();
if settings.home_assistant.flow_inputs.len() > 128 {
if settings.flow_inputs.len() > 128 {
return Err(AppError::BadRequest("too many shared Flow inputs (max 128)".into()));
}
for item in &mut settings.home_assistant.flow_inputs {
for item in &mut settings.flow_inputs {
item.id = item.id.trim().chars().take(120).collect();
item.name = item.name.trim().chars().take(100).collect();
item.kind = item.kind.trim().to_string();
@@ -129,30 +366,47 @@ fn validate_flow_shared_inputs(settings: &mut RuntimeSettings, state: &AppState)
if shared_input_comparison_kind(&item.kind) && item.config.get("value").is_some() {
return Err(AppError::BadRequest("shared Flow inputs are value sources; comparison value belongs to the Flow block".into()));
}
}
Ok(())
}
fn validate_flow_shared_inputs(settings: &mut HomeAssistantSettings, state: &AppState) -> Result<(), AppError> {
normalize_flow_shared_inputs(settings)?;
for item in &settings.flow_inputs {
validate_shared_input_source(&item.kind, &item.config, state)?;
}
Ok(())
}
fn canonicalize_home_assistant_entities(settings: &mut RuntimeSettings) {
let default_entity = settings.home_assistant.default_entity_id.clone();
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&default_entity)) {
settings.home_assistant.default_entity_id = entity_id;
fn canonicalize_home_assistant_entities(settings: &mut HomeAssistantSettings) {
let default_entity = settings.default_entity_id.clone();
if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&default_entity)) {
settings.default_entity_id = entity_id;
}
let outdoor_entity = settings.home_assistant.outdoor_entity_id.clone();
let outdoor_entity = settings.outdoor_entity_id.clone();
if !outdoor_entity.trim().is_empty() {
if let Some(entity_id) = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&outdoor_entity)) {
settings.home_assistant.outdoor_entity_id = entity_id;
if let Some(entity_id) = home_assistant::resolve_entity_id(settings, Some(&outdoor_entity)) {
settings.outdoor_entity_id = entity_id;
}
}
}
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &RuntimeSettings) {
let Some(configured) = zone.ha_entity_id.clone() else { return; };
zone.ha_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(&configured));
fn validate_home_assistant_url(settings: &HomeAssistantSettings) -> Result<(), AppError> {
if settings.url.trim().is_empty() { return Ok(()); }
let parsed = url::Url::parse(&settings.url)
.map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?;
if !matches!(parsed.scheme(), "http" | "https") {
return Err(AppError::BadRequest("Home Assistant URL must use http or https".into()));
}
Ok(())
}
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSettings) -> Result<(), AppError> {
fn canonicalize_zone_ha_entity(zone: &mut Zone, settings: &HomeAssistantSettings) {
let Some(configured) = zone.ha_entity_id.clone() else { return; };
zone.ha_entity_id = home_assistant::resolve_entity_id(settings, Some(&configured));
}
async fn canonicalize_saved_zone_entities(state: &AppState, settings: &HomeAssistantSettings) -> Result<(), AppError> {
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
zone_ids.sort();
zone_ids.dedup();
@@ -172,348 +426,68 @@ async fn canonicalize_saved_zone_entities(state: &AppState, settings: &RuntimeSe
Ok(())
}
fn validate_night_mode(settings: &mut RuntimeSettings) -> Result<(), AppError> {
NaiveTime::parse_from_str(&settings.night_mode.start_time, "%H:%M")
.map_err(|_| AppError::BadRequest("night mode start time must use HH:MM".into()))?;
NaiveTime::parse_from_str(&settings.night_mode.end_time, "%H:%M")
.map_err(|_| AppError::BadRequest("night mode end time must use HH:MM".into()))?;
settings.night_mode.max_fan_speed = settings.night_mode.max_fan_speed.clamp(1, 5);
Ok(())
fn apply_home_assistant_update(
current: &HomeAssistantSettings,
input: HomeAssistantSettingsUpdate,
) -> HomeAssistantSettings {
let mut next = HomeAssistantSettings {
url: input.url,
token: current.token.clone(),
default_entity_id: input.default_entity_id,
outdoor_entity_id: input.outdoor_entity_id,
sensor_stale_after_seconds: input.sensor_stale_after_seconds.clamp(30, 86_400),
allow_invalid_tls: input.allow_invalid_tls,
sensor_aliases: input.sensor_aliases,
flow_inputs: input.flow_inputs,
};
if let Some(token) = input.token { next.token = token; }
next
}
async fn export_settings(State(state): State<AppState>) -> Result<Json<ConfigurationExport>, AppError> {
let settings = state.settings.read().await.clone();
let mut export = state.db.export_configuration(settings)?;
// Backups are configuration snapshots, not a way to resurrect transient ownership,
// timers or a stale physical device state after restore (K9).
sanitize_configuration_runtime(&mut export);
Ok(Json(export))
}
fn validate_configuration_export(export: &ConfigurationExport) -> Result<(), AppError> {
if !matches!(export.format_version, 1 | 2) { return Err(AppError::BadRequest("unsupported configuration export version".into())); }
influxdb::validate(&export.settings.influxdb).map_err(|err| AppError::BadRequest(err.to_string()))?;
if !matches!(export.settings.house_mode.as_str(), "cool" | "heat" | "off") {
return Err(AppError::BadRequest("import contains an invalid house mode".into()));
}
let devices: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.id.as_str()).collect();
let zones: std::collections::HashSet<&str> = export.zones.iter().map(|item| item.id.as_str()).collect();
let schedules: std::collections::HashSet<&str> = export.schedules.iter().map(|item| item.id.as_str()).collect();
let automations: std::collections::HashSet<&str> = export.automations.iter().map(|item| item.id.as_str()).collect();
let flows: std::collections::HashSet<&str> = export.flows.iter().map(|item| item.id.as_str()).collect();
let draft_flows: std::collections::HashSet<&str> = export.flows.iter().filter(|item| item.draft).map(|item| item.id.as_str()).collect();
if export.flows.iter().any(|item| item.draft && (item.enabled || !item.compiled_schedule_ids.is_empty() || !item.compiled_automation_ids.is_empty()))
|| export.schedules.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
|| export.automations.iter().any(|item| item.flow_id.as_deref().is_some_and(|id| draft_flows.contains(id)))
{
return Err(AppError::BadRequest("import contains an executable Flow draft".into()));
}
if devices.len() != export.devices.len() || zones.len() != export.zones.len()
|| schedules.len() != export.schedules.len() || automations.len() != export.automations.len() || flows.len() != export.flows.len()
|| devices.contains("") || zones.contains("") || schedules.contains("") || automations.contains("") || flows.contains("")
{
return Err(AppError::BadRequest("import contains duplicate or empty resource IDs".into()));
}
let device_macs: std::collections::HashSet<&str> = export.devices.iter().map(|item| item.mac.as_str()).collect();
if device_macs.len() != export.devices.len() {
return Err(AppError::BadRequest("import contains duplicate device MAC addresses".into()));
}
if export.zones.iter().any(|item| !devices.contains(item.device_id.as_str())) {
return Err(AppError::BadRequest("import contains a zone referencing a missing device".into()));
}
let mut zone_devices = std::collections::HashSet::new();
for zone in &export.zones {
if !zone_devices.insert(zone.device_id.as_str()) {
return Err(AppError::BadRequest("import assigns one device to more than one thermostat zone".into()));
}
if !matches!(zone.mode.as_str(), "cool" | "heat") {
return Err(AppError::BadRequest("import contains an invalid zone mode".into()));
}
if !matches!(zone.sensor_source.as_str(), "device" | "home_assistant" | "combined") {
return Err(AppError::BadRequest("import contains an invalid zone sensor source".into()));
}
}
if export.schedules.iter().any(|item| !zones.contains(item.zone_id.as_str())) {
return Err(AppError::BadRequest("import contains a schedule referencing a missing zone".into()));
}
for item in &export.schedules {
if item.flow_id.as_deref().is_some_and(|flow_id| !flows.contains(flow_id)) {
return Err(AppError::BadRequest("import contains a Flow-generated schedule referencing a missing Flow".into()));
}
if item.weekdays.is_empty() || item.weekdays.iter().any(|day| !(1..=7).contains(day)) {
return Err(AppError::BadRequest("import contains invalid schedule weekdays".into()));
}
NaiveTime::parse_from_str(&item.start_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule start time".into()))?;
NaiveTime::parse_from_str(&item.end_time, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid schedule end time".into()))?;
if !matches!(item.preset.as_str(), "comfort" | "sleep" | "away" | "custom") {
return Err(AppError::BadRequest("import contains an invalid schedule preset".into()));
}
if item.preset == "custom" && !(8.0..=30.0).contains(&item.setpoint) {
return Err(AppError::BadRequest("import contains an invalid schedule setpoint".into()));
}
}
validate_schedule_set(&export.schedules)?;
if export.groups.iter().any(|group| {
let members: std::collections::HashSet<&str> = group.zone_ids.iter().map(String::as_str).collect();
group.id.trim().is_empty() || group.zone_ids.is_empty() || members.len() != group.zone_ids.len()
|| group.zone_ids.iter().any(|zone_id| !zones.contains(zone_id.as_str()))
}) {
return Err(AppError::BadRequest("import contains an invalid group, duplicate members or a missing zone reference".into()));
}
let groups: std::collections::HashSet<&str> = export.groups.iter().map(|item| item.id.as_str()).collect();
if groups.len() != export.groups.len() {
return Err(AppError::BadRequest("import contains duplicate group IDs".into()));
}
for item in &export.automations {
match item.trigger_kind.as_str() {
"temperature_above" | "temperature_below" => {
let Some(trigger_id) = item.trigger_device_id.as_deref() else {
return Err(AppError::BadRequest("import contains a temperature automation without a trigger device".into()));
};
if !devices.contains(trigger_id) || item.threshold.is_none() {
return Err(AppError::BadRequest("import contains an invalid temperature automation trigger".into()));
}
}
"time" => {
let at = item.at_time.as_deref().ok_or_else(|| AppError::BadRequest("import contains a time automation without at_time".into()))?;
NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("import contains an invalid automation time".into()))?;
}
"flow" => {
if item.flow_id.as_deref().filter(|id| flows.contains(*id)).is_none() || item.flow_conditions.is_empty() {
return Err(AppError::BadRequest("import contains an invalid Flow-generated automation".into()));
}
}
_ => return Err(AppError::BadRequest("import contains an unsupported automation trigger".into())),
}
if let Some(zone_id) = item.action_zone_id.as_deref().filter(|value| !value.is_empty()) {
if !zones.contains(zone_id) { return Err(AppError::BadRequest("import contains a Flow automation referencing a missing zone".into())); }
if let Some(preset) = item.action_zone_preset.as_deref() {
if !matches!(preset, "auto" | "custom" | "comfort" | "sleep" | "away") {
return Err(AppError::BadRequest("import contains an invalid Flow thermostat preset".into()));
}
}
if item.action_zone_preset.as_deref() == Some("custom")
&& item.action.target_temperature.is_some_and(|value| !(8.0..=30.0).contains(&value))
{
return Err(AppError::BadRequest("import contains an invalid Flow thermostat target".into()));
}
} else if let Some(group_id) = item.action_group_id.as_deref().filter(|value| !value.is_empty()) {
if !groups.contains(group_id) {
return Err(AppError::BadRequest("import contains an automation referencing a missing group".into()));
}
if let Some(mode) = item.action.mode.as_deref() {
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
return Err(AppError::BadRequest("import contains an invalid group automation mode".into()));
}
}
let flow_custom_group = item.flow_id.is_some() && item.action_preset.as_deref() == Some("custom");
if let Some(preset) = item.action_preset.as_deref() {
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") && !(flow_custom_group && preset == "custom") {
return Err(AppError::BadRequest("import contains an invalid group automation preset".into()));
}
}
if flow_custom_group {
let Some(target) = item.action.target_temperature else { return Err(AppError::BadRequest("import contains a Flow custom group preset without a target".into())); };
if !(8.0..=30.0).contains(&target) { return Err(AppError::BadRequest("import contains an invalid Flow group target".into())); }
} else if item.action.target_temperature.is_some() {
return Err(AppError::BadRequest("import contains unsupported target temperature in a group automation".into()));
}
if item.action.fan_speed.is_some() || item.action.swing_vertical.is_some() || item.action.swing_horizontal.is_some()
|| item.action.quiet.is_some() || item.action.turbo.is_some() || item.action.light.is_some()
|| item.action.air.is_some() || item.action.xfan.is_some() || item.action.health.is_some() || item.action.sleep.is_some()
{
return Err(AppError::BadRequest("import contains unsupported device fields in a group automation".into()));
}
if item.action.power.is_none() && item.action.mode.is_none() && item.action_preset.as_deref().filter(|v| !v.is_empty()).is_none() {
return Err(AppError::BadRequest("import contains an empty group automation action".into()));
}
} else {
if !devices.contains(item.action_device_id.as_str()) {
return Err(AppError::BadRequest("import contains an automation referencing a missing device".into()));
}
engine::validate_command(&item.action)?;
if item.action.is_empty() {
return Err(AppError::BadRequest("import contains an empty automation action".into()));
}
}
}
Ok(())
}
fn sanitize_configuration_runtime(export: &mut ConfigurationExport) {
let now = Utc::now();
// Do not restore the pre-v0.9.3 persistent global-OFF gate from backups.
export.settings.house_power_enabled = true;
for device in &mut export.devices {
device.power = false;
device.mode = "cool".into();
device.target_temperature = 23.0;
device.fan_speed = 0;
device.swing_vertical = false;
device.swing_horizontal = false;
device.quiet = false;
device.turbo = false;
device.light = false;
device.air = false;
device.xfan = false;
device.health = false;
device.sleep = false;
device.current_temperature = None;
device.outdoor_temperature = None;
device.online = false;
device.response_time_ms = None;
device.last_seen = None;
device.last_error = None;
device.communication_failures = 0;
device.updated_at = now;
}
for zone in &mut export.zones {
zone.device_temperature = None;
zone.external_temperature = None;
zone.current_temperature = None;
zone.control_temperature_source = "device".into();
zone.active_preset = "comfort".into();
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
zone.temporary_quick_thermostat = None;
zone.device_manual_override = false;
zone.device_manual_override_since = None;
zone.device_manual_override_until = None;
zone.device_manual_override_fields.clear();
zone.device_manual_override_baseline = None;
zone.control_owner = "automation".into();
zone.control_source = "automation".into();
zone.control_since = None;
zone.control_resume_at = None;
zone.control_reason = "Imported configuration; runtime ownership reset".into();
zone.last_power_change_at = None;
zone.last_mode_change_at = None;
zone.lockout_until = None;
zone.lockout_reason = None;
zone.compressor_pending_action = None;
zone.compressor_pending_since = None;
zone.compressor_pending_until = None;
zone.compressor_cancelled_action = None;
zone.effective_mode.clear();
zone.effective_setpoint = None;
zone.device_setpoint = None;
zone.demand = false;
zone.demand_since = None;
zone.target_alerted_at = None;
zone.last_action_at = None;
zone.revision = 0;
zone.updated_at = now;
}
for automation in &mut export.automations {
automation.last_fired_at = None;
automation.updated_at = now;
}
}
async fn import_settings(State(state): State<AppState>, Json(mut export): Json<ConfigurationExport>) -> Result<Json<Value>, AppError> {
validate_configuration_export(&export)?;
export.settings.history_retention_days = export.settings.history_retention_days.clamp(1, 3650);
export.settings.event_log_retention_days = export.settings.event_log_retention_days.clamp(1, 3650);
export.settings.compressor_protection_seconds = export.settings.compressor_protection_seconds.clamp(30, 1800);
normalize_sensor_aliases(&mut export.settings);
canonicalize_home_assistant_entities(&mut export.settings);
for zone in &mut export.zones { canonicalize_zone_ha_entity(zone, &export.settings); }
validate_night_mode(&mut export.settings)?;
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 -> thermostat-cycle -> zones -> devices.
async fn update_home_assistant_settings(
State(state): State<AppState>,
Json(input): Json<HomeAssistantSettingsUpdate>,
) -> Result<Json<HomeAssistantSettingsView>, 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 _cycle_guard = state.lock_zone_control_cycle().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
// removed or rewired by the imported configuration. Otherwise an orphaned physical unit
// could keep running after its database owner disappears.
let imported_zone_map: std::collections::HashMap<String, String> = export.zones.iter()
.map(|zone| (zone.id.clone(), zone.device_id.clone()))
.collect();
let mut detach_devices = std::collections::HashSet::new();
for current in &current_zones {
if imported_zone_map.get(&current.id).map(String::as_str) != Some(current.device_id.as_str()) {
detach_devices.insert(current.device_id.clone());
}
}
for device_id in detach_devices {
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.
// Imported devices are reconciled from a fresh poll and current house/zone gates.
sanitize_configuration_runtime(&mut export);
state.initial_device_sync_complete.store(false, Ordering::Release);
state.db.replace_configuration(&export)?;
state.debug_gree_frames.store(export.settings.debug.gree_frames, Ordering::Relaxed);
*state.settings.write().await = export.settings.clone();
let controllable_devices: std::collections::HashSet<String> = export.zones.iter()
.filter(|zone| {
let effective_mode = if zone.inherit_house_mode { export.settings.house_mode.as_str() } else { zone.mode.as_str() };
zone.enabled
&& effective_mode != "off"
})
.map(|zone| zone.device_id.clone())
.collect();
for device in export.devices.iter().filter(|device| device.enabled && !controllable_devices.contains(&device.id)) {
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}));
return Err(err);
}
}
// 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
// imported cache values.
engine::poll_all_locked(&state).await?;
state.initial_device_sync_complete.store(true, Ordering::Release);
let payload = {
let mut settings = state.settings.write().await;
let mut next = apply_home_assistant_update(&settings.home_assistant, input.clone());
normalize_sensor_aliases(&mut next);
validate_flow_shared_inputs(&mut next, &state)?;
canonicalize_home_assistant_entities(&mut next);
validate_home_assistant_url(&next)?;
settings.home_assistant = next.clone();
settings.outdoor_assist_enabled = input.outdoor_assist_enabled;
state.db.save_runtime_settings(&settings)?;
home_assistant_settings(&settings)
};
let saved = state.settings.read().await.home_assistant.clone();
canonicalize_saved_zone_entities(&state, &saved).await?;
state.log("info", "settings.home_assistant.updated", "Home Assistant settings updated", json!({
"configured": payload.token_configured,
"flow_inputs": payload.flow_inputs.len()
}));
state.broadcast("settings.home_assistant.updated", serde_json::to_value(&payload)?);
state.wake_zone_control();
state.log("info", "settings.imported", "Application configuration imported", json!({"format_version": export.format_version}));
state.broadcast("configuration.imported", json!({"at": Utc::now()}));
Ok(Json(json!({"ok": true})))
Ok(Json(payload))
}
async fn get_debug_settings(State(state): State<AppState>) -> Json<DebugSettings> {
Json(state.settings.read().await.debug.clone())
}
async fn update_debug_settings(
State(state): State<AppState>,
Json(input): Json<DebugSettings>,
) -> Result<Json<DebugSettings>, AppError> {
let _configuration_guard = state.lock_configuration_operation().await;
{
let mut settings = state.settings.write().await;
settings.debug = input.clone();
state.db.save_runtime_settings(&settings)?;
}
state.debug_gree_frames.store(input.gree_frames, Ordering::Relaxed);
state.broadcast("settings.debug.updated", serde_json::to_value(&input)?);
Ok(Json(input))
}
+1 -1
View File
@@ -28,7 +28,7 @@ async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
"automations": state.db.list_automations()?,
"flows": state.db.list_flows()?,
"access_tokens": state.db.list_api_tokens()?,
"settings": public_settings(&settings),
"house": {"mode": settings.house_mode},
"outdoor_temperature": *state.outdoor_temperature.read().await,
"system": {
"version": env!("CARGO_PKG_VERSION"),
+236 -203
View File
@@ -252,6 +252,240 @@ async fn update_zone(State(state): State<AppState>, Path(id): Path<String>, Json
Ok(Json(zone))
}
struct TemporaryStartTiming {
start_kind: String,
started_at: chrono::DateTime<Utc>,
activated_at: Option<chrono::DateTime<Utc>>,
editing_active: bool,
}
struct TemporaryTargetSettings {
target: f64,
tolerance: f64,
temperature_operator: String,
is_temperature_condition: bool,
}
struct TemporaryFinishTiming {
duration_seconds: Option<u64>,
hold_seconds: u64,
safety_duration_seconds: Option<u64>,
expires_at: Option<chrono::DateTime<Utc>>,
safety_expires_at: Option<chrono::DateTime<Utc>>,
}
fn resolve_temporary_start(
zone: &Zone,
request: &TemporaryQuickThermostatRequest,
now: chrono::DateTime<Utc>,
) -> Result<TemporaryStartTiming, AppError> {
let existing = zone.temporary_quick_thermostat.as_ref();
let editing_active = engine::temporary_quick_thermostat_is_active(zone, now.clone());
if !matches!(request.start_kind.as_str(), "now" | "delay" | "at") {
return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into()));
}
if editing_active {
let existing = existing.expect("active temporary session must exist");
return Ok(TemporaryStartTiming {
start_kind: existing.start_kind.clone(),
started_at: existing.started_at.clone(),
activated_at: existing.activated_at.clone(),
editing_active: true,
});
}
let started_at = match request.start_kind.as_str() {
"now" => now,
"delay" => {
let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?;
if !(1..=43_200).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into()));
}
now + ChronoDuration::minutes(minutes as i64)
}
"at" => {
let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?;
if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); }
if at > now + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); }
at
}
_ => unreachable!(),
};
Ok(TemporaryStartTiming {
start_kind: request.start_kind.clone(),
started_at,
activated_at: None,
editing_active: false,
})
}
fn resolve_temporary_target(
zone: &Zone,
request: &TemporaryQuickThermostatRequest,
) -> Result<TemporaryTargetSettings, AppError> {
let finish_kind = request.finish_kind.as_str();
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
}
let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)));
if !(8.0..=30.0).contains(&target) {
return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into()));
}
let target = (target * 2.0).round() / 2.0;
let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() };
let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0);
let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3));
if !(0.1..=3.0).contains(&requested_tolerance) {
return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into()));
}
let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within");
if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") {
return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into()));
}
Ok(TemporaryTargetSettings {
target,
tolerance: if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance },
temperature_operator: temperature_operator.to_string(),
is_temperature_condition: matches!(finish_kind, "temperature_reached" | "temperature_stable"),
})
}
fn resolve_temporary_finish(
zone: &Zone,
schedules: &[Schedule],
request: &TemporaryQuickThermostatRequest,
start: &TemporaryStartTiming,
now: chrono::DateTime<Utc>,
is_temperature_condition: bool,
) -> Result<TemporaryFinishTiming, AppError> {
let finish_kind = request.finish_kind.as_str();
let duration_seconds = if finish_kind == "duration" {
let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?;
if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); }
Some(minutes.saturating_mul(60))
} else { None };
let hold_seconds = if finish_kind == "temperature_stable" {
let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?;
if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); }
minutes.saturating_mul(60)
} else { 0 };
let safety_duration_seconds = if is_temperature_condition {
request.max_duration_minutes.map(|minutes| {
if !(1..=14_400).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into()));
}
Ok(minutes.saturating_mul(60))
}).transpose()?
} else { None };
let active_base = start.activated_at.clone().unwrap_or(now.clone());
let expires_at = match finish_kind {
"duration" if start.editing_active => duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64)),
"until" => {
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
let comparison_start = if start.editing_active { now.clone() } else { start.started_at.clone() };
if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); }
if until > comparison_start + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); }
Some(until)
}
"schedule_boundary" => {
let reference = if start.editing_active { chrono::Local::now() } else { start.started_at.clone().with_timezone(&chrono::Local) };
Some(engine::next_schedule_boundary_utc(&zone.id, schedules, reference)
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?)
}
_ => None,
};
let safety_expires_at = if start.editing_active {
safety_duration_seconds.map(|seconds| active_base + ChronoDuration::seconds(seconds as i64))
} else { None };
Ok(TemporaryFinishTiming { duration_seconds, hold_seconds, safety_duration_seconds, expires_at, safety_expires_at })
}
async fn apply_temporary_quick_thermostat_request(
state: &AppState,
zone: &mut Zone,
schedules: &[Schedule],
request: &TemporaryQuickThermostatRequest,
) -> Result<(), AppError> {
let now = Utc::now();
let runtime = state.settings.read().await.clone();
let existing_session = zone.temporary_quick_thermostat.clone();
let start = resolve_temporary_start(zone, request, now.clone())?;
let target = resolve_temporary_target(zone, request)?;
let finish = resolve_temporary_finish(zone, schedules, request, &start, now.clone(), target.is_temperature_condition)?;
let starts_now = start.start_kind == "now";
let immediate_activation = !start.editing_active && starts_now && !zone.device_manual_override;
let restore_zone_enabled = if start.editing_active {
existing_session.as_ref().and_then(|session| session.restore_zone_enabled)
} else if immediate_activation { Some(zone.enabled) } else { None };
let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() };
let active_mode = if start.editing_active {
existing_session.as_ref().and_then(|session| session.active_mode.clone())
} else if immediate_activation { Some(captured_mode.clone()) } else { None };
let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str());
if target.is_temperature_condition
&& ((condition_mode == "heat" && target.temperature_operator == "at_or_below")
|| (condition_mode == "cool" && target.temperature_operator == "at_or_above"))
{
return Err(AppError::BadRequest("temporary thermostat temperature condition conflicts with the active heating/cooling direction".into()));
}
let state_value = if start.editing_active {
if zone.device_manual_override { "paused_manual" } else { "active" }
} else if starts_now && zone.device_manual_override { "paused_manual" } else { "scheduled" };
let underlying_local_power = zone.local_thermostat_power;
let underlying_local_resume_at = zone.local_thermostat_resume_at;
let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled;
let underlying_manual_preset = zone.manual_preset.clone();
let underlying_manual_setpoint = zone.manual_setpoint;
let underlying_manual_override_until = zone.manual_override_until;
if immediate_activation {
zone.local_thermostat_power = Some(true);
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
zone.enabled = true;
zone.manual_setpoint = Some(target.target);
zone.effective_setpoint = Some(target.target);
zone.manual_override_until = None;
} else if start.editing_active {
zone.manual_setpoint = Some(target.target);
zone.effective_setpoint = Some(target.target);
zone.manual_override_until = None;
}
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
start_kind: start.start_kind,
finish_kind: request.finish_kind.clone(),
started_at: start.started_at.clone(),
activated_at: if immediate_activation { Some(now.clone()) } else { start.activated_at.clone() },
state: state_value.into(),
active_mode,
restore_zone_enabled,
restore_local_thermostat_power: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power) } else if immediate_activation { underlying_local_power } else { None },
restore_local_thermostat_resume_at: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at) } else if immediate_activation { underlying_local_resume_at } else { None },
restore_local_thermostat_zone_enabled: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled) } else if immediate_activation { underlying_local_zone_enabled } else { None },
restore_manual_preset: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone()) } else if immediate_activation { underlying_manual_preset } else { None },
restore_manual_setpoint: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_setpoint) } else if immediate_activation { underlying_manual_setpoint } else { None },
restore_manual_override_until: if start.editing_active { existing_session.as_ref().and_then(|session| session.restore_manual_override_until) } else if immediate_activation { underlying_manual_override_until } else { None },
expires_at: if immediate_activation && request.finish_kind == "duration" { finish.duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.expires_at },
duration_seconds: finish.duration_seconds,
safety_duration_seconds: finish.safety_duration_seconds,
temperature_target: Some(target.target),
temperature_operator: target.is_temperature_condition.then(|| target.temperature_operator.clone()),
tolerance_c: target.tolerance,
hold_seconds: finish.hold_seconds,
condition_started_at: None,
condition_last_observed_at: None,
paused_at: if zone.device_manual_override && (start.editing_active || starts_now) {
existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone()))
} else { None },
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_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint),
safety_expires_at: if immediate_activation && target.is_temperature_condition { finish.safety_duration_seconds.map(|seconds| now + ChronoDuration::seconds(seconds as i64)) } else { finish.safety_expires_at },
});
Ok(())
}
async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControlPatch, source: &str) -> Result<Zone, AppError> {
// A quick preset/setpoint derives its resume boundary from schedules. Take the schedule
// lock before the per-zone lock so a concurrent schedule edit cannot leave an override
@@ -310,208 +544,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
}
}
if let Some(request) = patch.temporary_quick_thermostat.as_ref() {
let now = Utc::now();
let runtime = state.settings.read().await.clone();
let existing_session = zone.temporary_quick_thermostat.clone();
let editing_active = engine::temporary_quick_thermostat_is_active(&zone, now);
let requested_start_kind = request.start_kind.as_str();
if !matches!(requested_start_kind, "now" | "delay" | "at") {
return Err(AppError::BadRequest("unsupported temporary thermostat start kind".into()));
}
// Editing an already active session changes only target/finish rules. Its historical
// start and activated_at are preserved, so delay/at sessions cannot be accidentally
// rescheduled or rejected because their original start is now in the past.
let (start_kind, started_at, activated_at) = if editing_active {
let existing = existing_session.as_ref().expect("active temporary session must exist");
(existing.start_kind.clone(), existing.started_at.clone(), existing.activated_at.clone())
} else {
let started_at = match requested_start_kind {
"now" => now.clone(),
"delay" => {
let minutes = request.start_delay_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat start delay is required".into()))?;
if !(1..=43_200).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat start delay must be between 1 minute and 30 days".into()));
}
now.clone() + ChronoDuration::minutes(minutes as i64)
}
"at" => {
let at = request.start_at.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat start time is required".into()))?;
if at <= now { return Err(AppError::BadRequest("temporary thermostat start time must be in the future".into())); }
if at > now.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat start time cannot be more than 30 days away".into())); }
at
}
_ => unreachable!(),
};
(requested_start_kind.to_string(), started_at, None)
};
let finish_kind = request.finish_kind.as_str();
if !matches!(finish_kind, "duration" | "until" | "temperature_reached" | "temperature_stable" | "schedule_boundary") {
return Err(AppError::BadRequest("unsupported temporary thermostat finish kind".into()));
}
let target = request.target_temperature.unwrap_or(zone.manual_setpoint.unwrap_or(zone.effective_setpoint.unwrap_or(zone.setpoint)));
if !(8.0..=30.0).contains(&target) {
return Err(AppError::BadRequest("temporary thermostat target must be between 8 and 30 C".into()));
}
let target = (target * 2.0).round() / 2.0;
let tolerance_mode = if zone.effective_mode.is_empty() { zone.mode.as_str() } else { zone.effective_mode.as_str() };
let min_stable_tolerance = (zone.hysteresis_for_mode(tolerance_mode) / 2.0 + 0.1).min(3.0);
let requested_tolerance = request.tolerance_c.unwrap_or(min_stable_tolerance.max(0.3));
if !(0.1..=3.0).contains(&requested_tolerance) {
return Err(AppError::BadRequest("temporary thermostat tolerance must be between 0.1 and 3 C".into()));
}
let tolerance = if finish_kind == "temperature_stable" { requested_tolerance.max(min_stable_tolerance) } else { requested_tolerance };
let temperature_operator = request.temperature_operator.as_deref().unwrap_or("within");
if !matches!(temperature_operator, "within" | "at_or_below" | "at_or_above") {
return Err(AppError::BadRequest("unsupported temporary thermostat temperature operator".into()));
}
let duration_seconds = if finish_kind == "duration" {
let minutes = request.duration_minutes.ok_or_else(|| AppError::BadRequest("temporary thermostat duration is required".into()))?;
if !(1..=14_400).contains(&minutes) { return Err(AppError::BadRequest("temporary thermostat duration must be between 1 minute and 10 days".into())); }
Some(minutes.saturating_mul(60))
} else { None };
let is_temperature_condition = matches!(finish_kind, "temperature_reached" | "temperature_stable");
let hold_seconds = if finish_kind == "temperature_stable" {
let minutes = request.hold_minutes.ok_or_else(|| AppError::BadRequest("temperature hold time is required".into()))?;
if !(1..=1_440).contains(&minutes) { return Err(AppError::BadRequest("temperature hold time must be between 1 minute and 24 hours".into())); }
minutes.saturating_mul(60)
} else { 0 };
let safety_duration_seconds = if is_temperature_condition {
request.max_duration_minutes.map(|minutes| {
if !(1..=14_400).contains(&minutes) {
return Err(AppError::BadRequest("temporary thermostat safety limit must be between 1 minute and 10 days".into()));
}
Ok(minutes.saturating_mul(60))
}).transpose()?
} else { None };
let active_base = activated_at.clone().unwrap_or(now.clone());
let expires_at = match finish_kind {
"duration" => if editing_active { duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64)) } else { None },
"until" => {
let until = request.until.clone().ok_or_else(|| AppError::BadRequest("temporary thermostat end time is required".into()))?;
let comparison_start = if editing_active { now.clone() } else { started_at.clone() };
if until <= comparison_start { return Err(AppError::BadRequest("temporary thermostat end time must be in the future and after its start".into())); }
if until > comparison_start.clone() + ChronoDuration::days(30) { return Err(AppError::BadRequest("temporary thermostat end time cannot be more than 30 days after start".into())); }
Some(until)
}
"schedule_boundary" => {
let reference = if editing_active { chrono::Local::now() } else { started_at.clone().with_timezone(&chrono::Local) };
Some(engine::next_schedule_boundary_utc(&zone.id, &schedules, reference)
.ok_or_else(|| AppError::BadRequest("this zone has no future schedule transition".into()))?)
}
_ => None,
};
let safety_expires_at = if editing_active {
safety_duration_seconds.map(|seconds| active_base.clone() + ChronoDuration::seconds(seconds as i64))
} else { None };
let starts_now = start_kind == "now";
let immediate_activation = !editing_active && starts_now && !zone.device_manual_override;
let restore_zone_enabled = if editing_active {
existing_session.as_ref().and_then(|session| session.restore_zone_enabled)
} else if immediate_activation {
Some(zone.enabled)
} else {
// Delayed sessions capture this at actual takeover time (H12), not planning time.
None
};
let configured_mode = if zone.inherit_house_mode { runtime.house_mode.as_str() } else { zone.mode.as_str() };
let captured_mode = if configured_mode == "off" { zone.mode.clone() } else { configured_mode.to_string() };
let active_mode = if editing_active {
existing_session.as_ref().and_then(|session| session.active_mode.clone())
} else if immediate_activation {
Some(captured_mode.clone())
} else { None };
let condition_mode = active_mode.as_deref().unwrap_or(captured_mode.as_str());
if is_temperature_condition {
if (condition_mode == "heat" && temperature_operator == "at_or_below")
|| (condition_mode == "cool" && temperature_operator == "at_or_above")
{
return Err(AppError::BadRequest(
"temporary thermostat temperature condition conflicts with the active heating/cooling direction".into(),
));
}
}
let state_value = if editing_active {
if zone.device_manual_override { "paused_manual" } else { "active" }
} else if starts_now && zone.device_manual_override {
"paused_manual"
} else {
"scheduled"
};
let underlying_local_power = zone.local_thermostat_power;
let underlying_local_resume_at = zone.local_thermostat_resume_at;
let underlying_local_zone_enabled = zone.local_thermostat_restore_zone_enabled;
let underlying_manual_preset = zone.manual_preset.clone();
let underlying_manual_setpoint = zone.manual_setpoint;
let underlying_manual_override_until = zone.manual_override_until;
if immediate_activation {
zone.local_thermostat_power = Some(true);
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
zone.enabled = true;
zone.manual_setpoint = Some(target);
zone.effective_setpoint = Some(target);
zone.manual_override_until = None;
} else if editing_active {
// Keep current ownership and update the live target without restarting the session.
zone.manual_setpoint = Some(target);
zone.effective_setpoint = Some(target);
zone.manual_override_until = None;
}
zone.temporary_quick_thermostat = Some(TemporaryQuickThermostat {
start_kind,
finish_kind: finish_kind.into(),
started_at,
activated_at: if immediate_activation { Some(now.clone()) } else { activated_at.clone() },
state: state_value.into(),
active_mode,
restore_zone_enabled,
restore_local_thermostat_power: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_power)
} else if immediate_activation { underlying_local_power } else { None },
restore_local_thermostat_resume_at: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_resume_at)
} else if immediate_activation { underlying_local_resume_at } else { None },
restore_local_thermostat_zone_enabled: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_local_thermostat_zone_enabled)
} else if immediate_activation { underlying_local_zone_enabled } else { None },
restore_manual_preset: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_manual_preset.clone())
} else if immediate_activation { underlying_manual_preset } else { None },
restore_manual_setpoint: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_manual_setpoint)
} else if immediate_activation { underlying_manual_setpoint } else { None },
restore_manual_override_until: if editing_active {
existing_session.as_ref().and_then(|session| session.restore_manual_override_until)
} else if immediate_activation { underlying_manual_override_until } else { None },
expires_at: if immediate_activation && finish_kind == "duration" {
duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
} else { expires_at },
duration_seconds,
safety_duration_seconds,
temperature_target: Some(target),
temperature_operator: is_temperature_condition.then(|| temperature_operator.to_string()),
tolerance_c: tolerance,
hold_seconds,
condition_started_at: None,
condition_last_observed_at: None,
paused_at: if zone.device_manual_override && (editing_active || starts_now) {
existing_session.as_ref().and_then(|session| session.paused_at.clone()).or(Some(now.clone()))
} else { None },
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_setpoint: existing_session.as_ref().and_then(|session| session.deferred_setpoint),
safety_expires_at: if immediate_activation && is_temperature_condition {
safety_duration_seconds.map(|seconds| now.clone() + ChronoDuration::seconds(seconds as i64))
} else { safety_expires_at },
});
apply_temporary_quick_thermostat_request(state, &mut zone, &schedules, request).await?;
}
if let Some(power) = patch.power {
// The neighbouring quick-power control and the explicit Stop button must use the
@@ -632,7 +665,7 @@ async fn apply_zone_control_patch(state: &AppState, id: &str, patch: ZoneControl
let device_override_cleared = if resume_device_automation { engine::reset_device_manual_override(&mut zone) } else { false };
let runtime = state.settings.read().await.clone();
let house_mode = runtime.house_mode.clone();
engine::refresh_control_ownership(&mut zone, true);
engine::refresh_control_ownership(&mut zone);
engine::refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
-1
View File
@@ -53,7 +53,6 @@ impl Config {
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
discovery_broadcast: self.discovery_broadcast.clone(),
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
house_power_enabled: true,
control_strategy: "setpoint".into(),
outdoor_assist_enabled: env_bool("GREE_CONTROLLER_OUTDOOR_ASSIST_ENABLED").unwrap_or(true),
history_retention_days: env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
+1 -1
View File
@@ -1,7 +1,7 @@
impl Db {
pub fn export_configuration(&self, settings: RuntimeSettings) -> Result<ConfigurationExport> {
Ok(ConfigurationExport {
format_version: 2,
format_version: 3,
exported_at: Utc::now(),
settings,
devices: self.list_devices()?,
+1 -1
View File
@@ -7,7 +7,7 @@ use crate::{
error::AppError,
home_assistant,
influxdb,
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
models::{Automation, AutomationPlanRule, ClimateGroup, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
state::{AppState, PendingControllerCommand},
};
+1 -1
View File
@@ -887,7 +887,7 @@ async fn apply_flow_zone_action(state: &AppState, zone_id: &str, preset: Option<
zone.control_reason = "Visual Flow automation".into();
let schedules = state.db.list_schedules()?;
let house_mode = state.settings.read().await.house_mode.clone();
refresh_control_ownership(&mut zone, true);
refresh_control_ownership(&mut zone);
if zone.control_owner == "automation" {
zone.control_source = "automation.flow".into();
zone.control_reason = "Visual Flow automation".into();
+1 -1
View File
@@ -19,7 +19,7 @@ pub async fn build_control_plan(state: &AppState) -> Result<ControlPlan, AppErro
for mut zone in zones {
let device = devices.iter().find(|item| item.id == zone.device_id);
let configured_effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
refresh_control_ownership(&mut zone, true);
refresh_control_ownership(&mut zone);
let configured_effective_mode = configured_effective_mode_owned.as_str();
let target_mode = if configured_effective_mode == "off" { zone.mode.as_str() } else { configured_effective_mode };
let active_for_target = active_schedule_for_zone(&zone, &schedules, now);
+153 -163
View File
@@ -1,4 +1,4 @@
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
fn validate_group_control_patch(patch: &GroupControlPatch) -> Result<Option<f64>, AppError> {
if let Some(mode) = patch.mode.as_deref() {
if !matches!(mode, "house" | "auto" | "cool" | "heat") {
return Err(AppError::BadRequest("group mode must be house, cool or heat".into()));
@@ -20,14 +20,138 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
return Err(AppError::BadRequest("group setpoint requires preset=custom".into()));
}
}
Ok(patch.setpoint.map(|value| (value * 10.0).round() / 10.0))
}
fn defer_group_climate_change(zone: &mut Zone, patch: &GroupControlPatch, custom_setpoint: Option<f64>) {
let Some(session) = zone.temporary_quick_thermostat.as_mut() else { return; };
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 preset != "custom" { session.deferred_setpoint = None; }
}
if let Some(setpoint) = custom_setpoint { session.deferred_setpoint = Some(setpoint); }
}
fn apply_group_climate_change(
zone: &mut Zone,
patch: &GroupControlPatch,
custom_setpoint: Option<f64>,
manual_group_control: bool,
schedules: &[Schedule],
) {
if let Some(mode) = patch.mode.as_deref() {
match mode {
"house" | "auto" => zone.inherit_house_mode = true,
"cool" | "heat" => {
zone.inherit_house_mode = false;
zone.mode = mode.to_string();
}
_ => {}
}
}
if let Some(preset) = patch.preset.as_deref() {
if preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
} else {
zone.manual_preset = Some(preset.to_string());
if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = if manual_group_control {
None
} else {
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 = if manual_group_control {
None
} else {
next_schedule_boundary_utc(&zone.id, schedules, Local::now())
};
}
}
fn apply_group_member_power(
zone: &mut Zone,
requested_power: Option<bool>,
source: &str,
temporary_owns_zone: bool,
group_handback_at: Option<DateTime<Utc>>,
) -> Result<(Option<bool>, bool), Value> {
let Some(power) = requested_power else { return Ok((None, false)); };
let automatic_power_blocked = source == "automation.group"
&& (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone);
if automatic_power_blocked {
return Err(json!({
"scope": "ownership",
"zone_id": zone.id,
"device_id": zone.device_id,
"error": "manual/local thermostat ownership has priority over group automation",
}));
}
if power {
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
return Ok((Some(true), false));
}
zone.local_thermostat_power = Some(false);
zone.local_thermostat_resume_at = group_handback_at;
zone.local_thermostat_restore_zone_enabled = None;
zone.demand = false;
zone.demand_since = None;
zone.effective_mode = "off".into();
zone.device_setpoint = None;
Ok((Some(false), true))
}
fn update_group_member_ownership(
zone: &mut Zone,
group: &ClimateGroup,
temporary_owns_zone: bool,
applied_group_power: Option<bool>,
group_handback_at: Option<DateTime<Utc>>,
) {
if temporary_owns_zone || zone.device_manual_override { return; }
if applied_group_power == Some(false) {
zone.control_owner = "local_thermostat".into();
zone.control_source = "local_thermostat".into();
zone.control_since = Some(Utc::now());
zone.control_resume_at = group_handback_at.clone();
zone.control_reason = if group_handback_at.is_some() {
format!("Group {} powered the thermostat off; automation resumes after {} minutes", group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)
} else {
format!("Group {} powered the thermostat off; group ownership released", group.name)
};
} else if group.power_enabled && zone.local_thermostat_power.is_none() {
zone.control_owner = "automation".into();
zone.control_source = format!("group:{}", group.id);
zone.control_since = Some(Utc::now());
zone.control_resume_at = zone.manual_override_until;
zone.control_reason = format!("Controlled by group {}", group.name);
} else if !group.power_enabled && zone.control_source == format!("group:{}", group.id) {
zone.control_owner = "automation".into();
zone.control_source = "automation".into();
zone.control_since = Some(Utc::now());
zone.control_resume_at = zone.manual_override_until;
zone.control_reason = format!("Group {} ownership released; zone automation resumed", group.name);
}
}
pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControlPatch, source: &str) -> Result<Value, AppError> {
let custom_setpoint = validate_group_control_patch(&patch)?;
let climate_change = patch.mode.is_some() || patch.preset.is_some() || custom_setpoint.is_some();
// 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.
// from racing with whole-house OFF. Lock order stays house -> cycle -> group -> zone -> device.
let _house_guard = state.lock_house_operation().await;
// Group state participates in thermostat arbitration. Exclude an already-running cycle
// while changing group control/profile state so no cycle can act on a stale group snapshot.
// Lock order stays house -> cycle -> group -> zone -> device.
let _cycle_guard = state.lock_zone_control_cycle().await;
let _group_guard = state.lock_group_operation(group_id).await;
let mut group = state.db.get_group(group_id)?
@@ -36,12 +160,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
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);
}
for zone_id in &locked_zone_ids { _zone_guards.push(state.lock_zone_operation(zone_id).await); }
let schedules = state.db.list_schedules()?;
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();
let resulting_control_enabled = patch.power.unwrap_or(group.power_enabled);
if climate_change && !resulting_control_enabled {
if source == "automation.group" {
@@ -53,38 +174,27 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
"zones": [],
"devices": state.db.list_devices()?,
"failed": [],
"master_power_enabled": true,
"suppressed": true,
}));
}
return Err(AppError::BadRequest("enable group control before changing group mode, preset or setpoint".into()));
}
if let Some(power) = patch.power {
group.power_enabled = power;
}
if let Some(power) = patch.power { group.power_enabled = power; }
group.updated_at = Utc::now();
state.db.save_group(&group)?;
state.broadcast("group.updated", serde_json::to_value(&group)?);
// Group power is a scoped bulk power action. OFF powers member thermostats down but
// releases group ownership: members are represented as individually OFF, not as
// "blocked by a disabled group". ON clears that scoped OFF and lets group arbitration
// take ownership again. The whole-house master remains independent.
let manual_group_control = source != "automation.group";
// A manual Group OFF is a temporary bulk pause. All members receive the same
// 15-minute hand-back deadline, so their thermostat automation resumes together.
// Automation-driven group OFF remains persistent until another automation/user action.
let group_handback_at = if manual_group_control && patch.power == Some(false) {
Some(group.updated_at.clone() + chrono::Duration::minutes(LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES))
} else {
None
};
let explicit_group_power = patch.power.is_some() && manual_group_control;
// Applying a manual mode/profile/custom target to an already-enabled group is itself an
// explicit scoped takeover. It must wake members that were left locally OFF by a previous
// group/whole-house OFF; otherwise the UI says "group control" while every unit stays OFF.
let manual_group_activation = manual_group_control && group.power_enabled && climate_change;
let explicit_group_takeover = explicit_group_power || manual_group_activation;
let requested_member_power = patch.power.or(manual_group_activation.then_some(true));
let mut zones = Vec::new();
let mut failed = Vec::new();
@@ -93,15 +203,9 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
let _device_guard = state.lock_device_operation(&zone_snapshot.device_id).await;
let Some(mut zone) = state.db.get_zone(zone_id)? else { continue; };
if patch.power.is_some() || climate_change {
rearm_compressor_queue(&mut zone);
}
if patch.power.is_some() || climate_change { rearm_compressor_queue(&mut zone); }
let mut temporary_owns_zone = temporary_quick_thermostat_is_active(&zone, Utc::now());
// A user/HA group power click is an explicit scoped takeover. End any older direct
// or temporary ownership so all members react consistently to the bulk command.
// Scheduled automations deliberately do not do this: manual/local ownership keeps
// its higher priority and the automation can only affect currently free members.
if explicit_group_takeover {
if zone.temporary_quick_thermostat.is_some() {
if temporary_owns_zone {
@@ -111,158 +215,47 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
}
temporary_owns_zone = false;
}
if zone.device_manual_override {
reset_device_manual_override(&mut zone);
}
if zone.device_manual_override { reset_device_manual_override(&mut zone); }
}
if temporary_owns_zone {
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(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); }
}
defer_group_climate_change(&mut zone, &patch, custom_setpoint);
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!({
"zone_id": zone.id, "group_id": group.id, "source": source
}));
}
} else {
if let Some(mode) = patch.mode.as_deref() {
match mode {
"house" | "auto" => zone.inherit_house_mode = true,
"cool" | "heat" => {
zone.inherit_house_mode = false;
zone.mode = mode.to_string();
}
_ => {}
}
}
if let Some(preset) = patch.preset.as_deref() {
if preset == "auto" {
zone.manual_preset = None;
zone.manual_setpoint = None;
zone.manual_override_until = None;
} else {
zone.manual_preset = Some(preset.to_string());
if preset != "custom" { zone.manual_setpoint = None; }
zone.manual_override_until = if manual_group_control {
None
} else {
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 = if manual_group_control {
None
} else {
next_schedule_boundary_utc(&zone.id, &schedules, Local::now())
};
}
}
// Group power OFF is represented on every member as a local thermostat OFF so the
// physical unit is stopped immediately and group ownership is released. A manual Group
// OFF gets one synchronized 15-minute hand-back deadline for all members; automation
// Group OFF is persistent. Power ON clears the scoped OFF immediately.
let mut force_power_off_after_save = false;
let mut applied_group_power: Option<bool> = None;
let requested_member_power = patch.power.or(manual_group_activation.then_some(true));
if let Some(power) = requested_member_power {
let automatic_power_blocked = source == "automation.group"
&& (zone.device_manual_override || zone.local_thermostat_power.is_some() || temporary_owns_zone);
if automatic_power_blocked {
failed.push(json!({
"scope": "ownership",
"zone_id": zone.id,
"device_id": zone.device_id,
"error": "manual/local thermostat ownership has priority over group automation",
}));
} else if power {
zone.local_thermostat_power = None;
zone.local_thermostat_resume_at = None;
zone.local_thermostat_restore_zone_enabled = None;
applied_group_power = Some(true);
} else {
zone.local_thermostat_power = Some(false);
zone.local_thermostat_resume_at = group_handback_at.clone();
zone.local_thermostat_restore_zone_enabled = None;
zone.demand = false;
zone.demand_since = None;
zone.effective_mode = "off".into();
zone.device_setpoint = None;
force_power_off_after_save = forced_off_devices.insert(zone.device_id.clone());
applied_group_power = Some(false);
}
apply_group_climate_change(&mut zone, &patch, custom_setpoint, manual_group_control, &schedules);
}
if !temporary_owns_zone && !zone.device_manual_override {
if applied_group_power == Some(false) {
zone.control_owner = "local_thermostat".into();
zone.control_source = "local_thermostat".into();
zone.control_since = Some(Utc::now());
zone.control_resume_at = group_handback_at.clone();
zone.control_reason = if group_handback_at.is_some() {
format!("Group {} powered the thermostat off; automation resumes after {} minutes", group.name, LOCAL_THERMOSTAT_RESUME_DELAY_MINUTES)
} else {
format!("Group {} powered the thermostat off; group ownership released", group.name)
};
} else if group.power_enabled && zone.local_thermostat_power.is_none() {
zone.control_owner = "automation".into();
zone.control_source = format!("group:{}", group.id);
zone.control_since = Some(Utc::now());
zone.control_resume_at = zone.manual_override_until;
zone.control_reason = format!("Controlled by group {}", group.name);
} else if !group.power_enabled && zone.control_source == format!("group:{}", group.id) {
zone.control_owner = "automation".into();
zone.control_source = "automation".into();
zone.control_since = Some(Utc::now());
zone.control_resume_at = zone.manual_override_until;
zone.control_reason = format!("Group {} ownership released; zone automation resumed", group.name);
}
}
let (applied_group_power, should_force_off) = match apply_group_member_power(
&mut zone, requested_member_power, source, temporary_owns_zone, group_handback_at.clone(),
) {
Ok(result) => result,
Err(error) => { failed.push(error); (None, false) }
};
update_group_member_ownership(&mut zone, &group, temporary_owns_zone, applied_group_power, group_handback_at);
zone.revision = zone.revision.saturating_add(1);
zone.updated_at = Utc::now();
state.db.save_zone(&zone)?;
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
if force_power_off_after_save {
if should_force_off && forced_off_devices.insert(zone.device_id.clone()) {
if let Err(err) = force_power_off_device_locked(state, &zone.device_id).await {
state.log("error", "group.power_off_error", &err.to_string(), json!({
"group_id": group.id,
"zone_id": zone.id,
"device_id": zone.device_id,
"source": source,
"group_id": group.id, "zone_id": zone.id, "device_id": zone.device_id, "source": source,
}));
failed.push(json!({
"scope": "device",
"zone_id": zone.id,
"device_id": zone.device_id,
"error": err.to_string(),
"scope": "device", "zone_id": zone.id, "device_id": zone.device_id, "error": err.to_string(),
}));
}
}
zones.push(zone);
}
let master_power_enabled = true;
let control_toggled_on = patch.power == Some(true);
let control_enabled = group.power_enabled;
// OFF is already a forced, per-member physical transition performed under zone -> device
// locks above. ON/profile/setpoint changes use the thermostat arbiter so target, hysteresis,
// compressor protection and mode-change safety are applied consistently before returning.
let run_immediately = control_toggled_on || (control_enabled && climate_change);
let run_immediately = patch.power == Some(true) || (group.power_enabled && climate_change);
let zones = if run_immediately {
// Do not just wake the background loop: a group ON/profile/setpoint action is expected
// to arbitrate every member before the HTTP request completes. Release member/domain
// locks first, then run one globally serialized thermostat cycle.
drop(_zone_guards);
drop(_group_guard);
drop(_cycle_guard);
@@ -279,16 +272,13 @@ pub async fn control_group(state: &AppState, group_id: &str, patch: GroupControl
};
state.log("info", source, &format!("Updated group {}", group.name), json!({
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset, "setpoint": custom_setpoint,
"zones": zones.len(), "failed": failed.len(), "master_power_enabled": master_power_enabled,
"group_id": group.id, "power_enabled": group.power_enabled, "mode": patch.mode, "preset": patch.preset,
"setpoint": custom_setpoint, "zones": zones.len(), "failed": failed.len(),
}));
Ok(json!({
"group": group,
"zones": zones,
"devices": state.db.list_devices()?,
"failed": failed,
"master_power_enabled": master_power_enabled,
}))
}
+1 -1
View File
@@ -1,4 +1,4 @@
pub fn refresh_control_ownership(zone: &mut Zone, _house_power_enabled: bool) {
pub fn refresh_control_ownership(zone: &mut Zone) {
let now = Utc::now();
let (owner, source, resume_at, reason) = if zone.device_manual_override {
let source = match zone.control_source.as_str() {
-5
View File
@@ -164,7 +164,6 @@ async fn activate_due_temporary_quick_thermostats(
zones: &mut [Zone],
schedules: &[Schedule],
house_mode: &str,
house_power_enabled: bool,
) -> Result<(), AppError> {
let now = Utc::now();
for zone in zones.iter_mut() {
@@ -184,10 +183,6 @@ async fn activate_due_temporary_quick_thermostats(
continue;
}
if started_at > now { continue; }
if !house_power_enabled {
set_temporary_wait_state(state, zone, "waiting_master", now)?;
continue;
}
if zone.device_manual_override {
set_temporary_wait_state(state, zone, "paused_manual", now)?;
continue;
+5 -5
View File
@@ -552,7 +552,7 @@ mod tests {
let mut zone = test_zone("device");
zone.local_thermostat_power = Some(true);
zone.control_source = "web_thermostat".into();
refresh_control_ownership(&mut zone, false);
refresh_control_ownership(&mut zone);
assert_eq!(zone.control_owner, "local_thermostat");
assert_ne!(zone.control_source, "global");
}
@@ -760,7 +760,7 @@ mod tests {
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
zone.control_source = "automation.device".into();
refresh_control_ownership(&mut zone, true);
refresh_control_ownership(&mut zone);
assert_eq!(zone.control_owner, "automation");
assert_eq!(zone.control_source, "automation.device");
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
@@ -797,7 +797,7 @@ mod tests {
set_local_thermostat_power(&mut zone, false, Utc::now());
assert!(reset_local_thermostat_override(&mut zone));
refresh_control_ownership(&mut zone, true);
refresh_control_ownership(&mut zone);
refresh_zone_runtime_target(&mut zone, &[], "cool");
assert!(zone.local_thermostat_power.is_none());
@@ -910,7 +910,7 @@ mod tests {
let mut zone = test_zone("device");
zone.local_thermostat_power = Some(true);
zone.control_source = "web_thermostat".into();
refresh_control_ownership(&mut zone, true);
refresh_control_ownership(&mut zone);
let owner = zone.control_owner.clone();
let source = zone.control_source.clone();
let until = Utc::now() + chrono::Duration::seconds(180);
@@ -985,7 +985,7 @@ mod tests {
assert!(set_house_bulk_thermostat_power(&mut zone, true));
assert!(zone.local_thermostat_power.is_none());
assert_eq!(zone.control_source, "house_power");
refresh_control_ownership(&mut zone, true);
refresh_control_ownership(&mut zone);
assert_eq!(zone.control_owner, "automation");
assert_eq!(zone.control_source, "house_power");
assert!(zone_has_active_thermostat_intent(&zone, None, Utc::now()));
+276 -258
View File
@@ -30,77 +30,292 @@ pub(crate) fn rearm_compressor_queue(zone: &mut Zone) {
clear_compressor_pending(zone, true);
}
async fn control_zones(state: &AppState) -> Result<()> {
let _cycle_guard = state.lock_zone_control_cycle().await;
let schedules = state.db.list_schedules()?;
let settings = state.settings.read().await.clone();
let mut zone_snapshot = state.db.list_zones()?;
// Local/temporary thermostat ownership is independent from the legacy whole-house master gate
// commands. Expire/activate sessions on their own deadlines.
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode, true).await?;
// Outdoor temperature is deliberately optional. Prefer the configured Home
// Assistant entity, but keep the dashboard/assist useful by falling back to the
// outdoor sensors reported by GREE units when HA is temporarily unavailable.
let device_snapshot = state.db.list_devices()?;
let configured_outdoor = settings.home_assistant.outdoor_entity_id.trim();
let resolved_outdoor = if configured_outdoor.is_empty() {
async fn resolve_cycle_outdoor_temperature(
state: &AppState,
settings: &RuntimeSettings,
devices: &[Device],
) -> Option<f64> {
let configured = settings.home_assistant.outdoor_entity_id.trim();
let resolved = if configured.is_empty() {
None
} else {
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured_outdoor))
home_assistant::resolve_entity_id(&settings.home_assistant, Some(configured))
};
let ha_outdoor_temperature = if let Some(entity_id) = resolved_outdoor.as_deref() {
match home_assistant::read_temperature(&state.http, &settings.home_assistant, Some(entity_id), Some(settings.home_assistant.sensor_stale_after_seconds)).await {
let from_home_assistant = if let Some(entity_id) = resolved.as_deref() {
match home_assistant::read_temperature(
&state.http,
&settings.home_assistant,
Some(entity_id),
Some(settings.home_assistant.sensor_stale_after_seconds),
).await {
Ok(value) => {
record_ha_history(
state,
entity_id,
None,
"outdoor",
value,
settings.poll_interval_seconds,
);
record_ha_history(state, entity_id, None, "outdoor", value, settings.poll_interval_seconds);
Some(value)
}
Err(err) => {
tracing::debug!(configured_entity=%configured_outdoor, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
tracing::debug!(configured_entity=%configured, resolved_entity=%entity_id, error=?err, "outdoor Home Assistant sensor unavailable; trying GREE fallback");
None
}
}
} else {
None
};
let outdoor_temperature = ha_outdoor_temperature.or_else(|| gree_outdoor_temperature(&device_snapshot));
{
let mut current = state.outdoor_temperature.write().await;
if *current != outdoor_temperature {
*current = outdoor_temperature;
state.broadcast("outdoor.updated", json!({"temperature": outdoor_temperature}));
}
let temperature = from_home_assistant.or_else(|| gree_outdoor_temperature(devices));
let mut current = state.outdoor_temperature.write().await;
if *current != temperature {
*current = temperature;
state.broadcast("outdoor.updated", json!({"temperature": temperature}));
}
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
temperature
}
// Read all per-zone Home Assistant sensors concurrently. A down HA instance should cost
// one request timeout per cycle, not one timeout multiplied by the number of zones.
let room_sensor_reads = futures_util::future::join_all(zone_snapshot.iter().filter_map(|zone| {
async fn read_cycle_room_sensors(
state: &AppState,
settings: &RuntimeSettings,
zones: &[Zone],
) -> HashMap<String, (Option<String>, Result<f64, String>)> {
futures_util::future::join_all(zones.iter().filter_map(|zone| {
if !matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { return None; }
let zone_id = zone.id.clone();
let resolved_entity = home_assistant::resolve_entity_id(&settings.home_assistant, zone.ha_entity_id.as_deref());
let http = &state.http;
let ha_settings = &settings.home_assistant;
let stale_after_seconds = effective_sensor_stale_after_seconds(zone.sensor_stale_after_seconds, ha_settings.sensor_stale_after_seconds);
let stale_after_seconds = effective_sensor_stale_after_seconds(
zone.sensor_stale_after_seconds,
ha_settings.sensor_stale_after_seconds,
);
Some(async move {
let result = home_assistant::read_temperature(http, ha_settings, resolved_entity.as_deref(), Some(stale_after_seconds)).await
.map_err(|err| err.to_string());
(zone_id, resolved_entity, result)
})
})).await;
let mut room_sensor_results: HashMap<String, (Option<String>, Result<f64, String>)> = room_sensor_reads.into_iter()
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
.collect();
}))
.await
.into_iter()
.map(|(zone_id, entity_id, result)| (zone_id, (entity_id, result)))
.collect()
}
fn refresh_zone_temperature(
state: &AppState,
settings: &RuntimeSettings,
zone: &mut Zone,
device: &Device,
room_sensor_results: &mut HashMap<String, (Option<String>, Result<f64, String>)>,
) -> (String, bool) {
let previous_source = zone.control_temperature_source.clone();
let device_temperature = if device.enabled && device.online && device.communication_failures == 0 {
device.current_temperature
} else {
None
};
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
match room_sensor_results.remove(&zone.id) {
Some((resolved_entity, Ok(value))) => {
if let Some(entity_id) = resolved_entity.as_deref() {
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
}
Some(value)
}
Some((resolved_entity, Err(err))) => {
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
let kind = if err.contains("Home Assistant sensor is stale:") { "ha.sensor_stale" } else { "ha.sensor_error" };
state.log("warn", kind, &err, json!({
"zone_id": zone.id,
"configured_entity_id": zone.ha_entity_id.as_deref(),
"resolved_entity_id": resolved_entity,
}));
}
None
}
None => None,
}
} else {
None
};
let (temperature, source, discrepancy) = select_zone_temperature(zone, device_temperature, external_temperature);
zone.device_temperature = device_temperature;
zone.external_temperature = external_temperature;
zone.current_temperature = temperature;
zone.control_temperature_source = source;
zone.updated_at = Utc::now();
(previous_source, discrepancy)
}
fn persist_zone_cycle_with_history(
state: &AppState,
zone: &Zone,
cycle_started_at: DateTime<Utc>,
outdoor_temperature: Option<f64>,
poll_interval_seconds: u64,
) -> Result<Zone> {
record_zone_history(state, zone, outdoor_temperature, poll_interval_seconds);
let persisted = persist_zone_cycle(state, zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted)?);
Ok(persisted)
}
async fn handle_zone_pre_control_state(
state: &AppState,
settings: &RuntimeSettings,
schedules: &[Schedule],
temporary_restored_disabled: &[String],
zone: &mut Zone,
device: &Device,
effective_mode: &str,
cycle_started_at: DateTime<Utc>,
outdoor_temperature: Option<f64>,
) -> Result<bool> {
// A queued whole-house ON is a delayed bulk physical action, not thermostat ownership.
if zone.compressor_pending_action.as_deref() == Some("global_power_on") {
let now = Utc::now();
if device.power {
clear_compressor_pending(zone, true);
zone.updated_at = now;
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
return Ok(true);
}
let due = !settings.compressor_protection_enabled
|| zone.compressor_pending_until.as_ref().map(|until| until <= &now).unwrap_or(true);
if due {
let _device_guard = state.lock_device_operation(&zone.device_id).await;
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await {
Ok(updated_device) => {
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); }
clear_compressor_pending(zone, true);
zone.last_action_at = Some(Utc::now());
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
Err(err) => {
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10));
zone.lockout_until = zone.compressor_pending_until.clone();
zone.lockout_reason = Some("global_start_retry".into());
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
}
}
zone.updated_at = Utc::now();
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
return Ok(true);
}
if !zone.enabled {
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
ensure_device_off_after_temporary_disabled_restore(state, zone, device).await;
}
clear_compressor_pending(zone, true);
zone.demand = false;
zone.demand_since = None;
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
return Ok(true);
}
if !device.enabled {
clear_compressor_pending(zone, true);
zone.demand = false;
zone.demand_since = None;
zone.device_setpoint = None;
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
return Ok(true);
}
if zone.device_manual_override {
clear_compressor_pending(zone, true);
let temporary_active = temporary_quick_thermostat_is_active(zone, zone.updated_at.clone());
let pause_started_at = zone.updated_at.clone();
if temporary_active {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); }
session.state = "paused_manual".into();
session.condition_started_at = None;
session.condition_last_observed_at = None;
}
}
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
let active_schedule = active_schedule_for_zone(zone, schedules, Local::now());
let (preset, target) = resolve_zone_target(zone, active_schedule, target_mode);
zone.active_preset = preset;
zone.effective_setpoint = Some(target);
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
zone.demand = false;
zone.demand_since = None;
zone.target_alerted_at = None;
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
return Ok(true);
}
let condition_sample_at = match zone.control_temperature_source.as_str() {
"home_assistant" | "combined" => Some(zone.updated_at.clone()),
_ => device.last_seen.clone(),
};
let max_condition_gap_seconds = settings.poll_interval_seconds
.max(settings.zone_interval_seconds)
.saturating_mul(2)
.saturating_add(5);
let condition_now = zone.updated_at.clone();
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(
zone,
condition_now,
condition_sample_at,
max_condition_gap_seconds,
) {
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
finish_temporary_quick_thermostat(zone, schedules, &settings.house_mode);
let persisted = persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
ensure_device_off_after_temporary_disabled_restore(state, &persisted, device).await;
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
}));
state.wake_zone_control();
return Ok(true);
}
if zone.local_thermostat_power == Some(false) {
zone.effective_mode = "off".into();
zone.demand = false;
zone.demand_since = None;
zone.device_setpoint = None;
if device.online && device.communication_failures == 0 && device.power {
let _device_guard = state.lock_device_operation(&zone.device_id).await;
let latest = state.db.get_zone(&zone.id)?;
if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) {
if let Err(err) = send_command_locked(
state,
&zone.device_id,
DeviceCommand { power: Some(false), ..Default::default() },
).await {
state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
}
}
}
persist_zone_cycle_with_history(state, zone, cycle_started_at, outdoor_temperature, settings.poll_interval_seconds)?;
return Ok(true);
}
Ok(false)
}
async fn control_zones(state: &AppState) -> Result<()> {
let _cycle_guard = state.lock_zone_control_cycle().await;
let schedules = state.db.list_schedules()?;
let settings = state.settings.read().await.clone();
let mut zone_snapshot = state.db.list_zones()?;
// Local/temporary thermostat ownership has its own deadlines. Expire and activate sessions independently.
expire_local_thermostat_overrides(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
let temporary_restored_disabled = expire_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
activate_due_temporary_quick_thermostats(state, &mut zone_snapshot, &schedules, &settings.house_mode).await?;
let device_snapshot = state.db.list_devices()?;
let outdoor_temperature = resolve_cycle_outdoor_temperature(state, &settings, &device_snapshot).await;
let outdoor_assist_temperature = if settings.outdoor_assist_enabled { outdoor_temperature } else { None };
let night_active = night_mode_active(&settings.night_mode, Local::now().time());
let mut room_sensor_results = read_cycle_room_sensors(state, &settings, &zone_snapshot).await;
for zone_snapshot_item in zone_snapshot {
// Every thermostat decision participates in the same zone -> device ordering as
@@ -108,7 +323,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
// interactive change cannot be evaluated from a stale snapshot.
let _zone_guard = state.lock_zone_operation(&zone_snapshot_item.id).await;
let Some(mut zone) = state.db.get_zone(&zone_snapshot_item.id)? else { continue; };
let cycle_started_at = zone.updated_at;
let cycle_started_at = zone.updated_at.clone();
if zone.manual_override_until.map(|until| until <= Utc::now()).unwrap_or(false) {
zone.manual_preset = None;
zone.manual_setpoint = None;
@@ -119,8 +334,7 @@ async fn control_zones(state: &AppState) -> Result<()> {
zone.control_reason = "Group override expired at schedule boundary".into();
}
}
// v0.8.20 makes direct/manual takeover persistent. Normalize any legacy persisted
// boundary from older releases instead of silently returning ownership to schedules.
// Direct/manual takeover stays active until the user explicitly resumes automation.
if zone.device_manual_override && zone.device_manual_override_until.is_some() {
zone.device_manual_override_until = None;
zone.control_resume_at = None;
@@ -142,220 +356,24 @@ async fn control_zones(state: &AppState) -> Result<()> {
// persistent gate: later thermostat/group/manual intent may act independently.
let effective_mode_owned = effective_zone_mode(&zone, &settings.house_mode);
zone.effective_mode = effective_mode_owned.clone();
refresh_control_ownership(&mut zone, true);
refresh_control_ownership(&mut zone);
let effective_mode = effective_mode_owned.as_str();
let previous_source = zone.control_temperature_source.clone();
// Never feed the thermostat a cached GREE temperature after any communication
// failure. External HA sensors may still keep a zone operational when configured.
let device_temperature = if device.enabled && device.online && device.communication_failures == 0 {
device.current_temperature
} else {
None
};
let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") {
match room_sensor_results.remove(&zone.id) {
Some((resolved_entity, Ok(value))) => {
if let Some(entity_id) = resolved_entity.as_deref() {
record_ha_history(state, entity_id, Some(&zone.id), "room", value, settings.poll_interval_seconds);
}
Some(value)
}
Some((resolved_entity, Err(err))) => {
if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") {
let notification_kind = if err.contains("Home Assistant sensor is stale:") {
"ha.sensor_stale"
} else {
"ha.sensor_error"
};
state.log("warn", notification_kind, &err, json!({
"zone_id": zone.id,
"configured_entity_id": zone.ha_entity_id.as_deref(),
"resolved_entity_id": resolved_entity,
}));
}
None
}
None => None,
}
} else {
None
};
let (previous_source, discrepancy) = refresh_zone_temperature(
state, &settings, &mut zone, &device, &mut room_sensor_results,
);
let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature);
zone.device_temperature = device_temperature;
zone.external_temperature = external_temperature;
zone.current_temperature = temperature;
zone.control_temperature_source = control_source;
zone.updated_at = Utc::now();
// A queued whole-house ON is a delayed bulk physical action, not thermostat
// ownership. It must survive local/group/manual state while compressor protection is
// active, then execute once and hand control straight back to the existing owner.
if zone.compressor_pending_action.as_deref() == Some("global_power_on") {
let now = Utc::now();
if device.power {
clear_compressor_pending(&mut zone, true);
zone.updated_at = now;
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
let due = !settings.compressor_protection_enabled
|| zone.compressor_pending_until.map(|until| until <= now).unwrap_or(true);
if due {
let _device_guard = state.lock_device_operation(&zone.device_id).await;
match send_command_locked(state, &zone.device_id, DeviceCommand { power: Some(true), ..Default::default() }).await {
Ok(updated_device) => {
if !device.power && updated_device.power { zone.last_power_change_at = Some(Utc::now()); }
clear_compressor_pending(&mut zone, true);
zone.last_action_at = Some(Utc::now());
state.log("info", "house.power_one_shot_executed", &format!("Executed queued global ON for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
Err(err) => {
// Keep the user-visible task and retry on a bounded deadline instead of
// spinning immediately or silently dropping the requested global start.
zone.compressor_pending_until = Some(Utc::now() + chrono::Duration::seconds(10));
zone.lockout_until = zone.compressor_pending_until;
zone.lockout_reason = Some("global_start_retry".into());
state.log("error", "house.power_one_shot_error", &err.to_string(), json!({
"zone_id": zone.id, "device_id": zone.device_id
}));
}
}
}
zone.updated_at = Utc::now();
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
// A disabled thermostat zone is completely outside normal controller ownership.
// Keep its sensors fresh, but do not let group state, schedules or thermostat
// modulation touch the unit. Manual control from the technical Devices view may
// therefore remain active until the zone is explicitly enabled again.
if !zone.enabled {
if temporary_restored_disabled.iter().any(|zone_id| zone_id == &zone.id) {
ensure_device_off_after_temporary_disabled_restore(state, &zone, &device).await;
}
clear_compressor_pending(&mut zone, true);
zone.demand = false;
zone.demand_since = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
// A technically disabled device is outside thermostat ownership. Do not create
// repeated command errors while keeping any available external sensor data visible.
if !device.enabled {
clear_compressor_pending(&mut zone, true);
zone.demand = false;
zone.demand_since = None;
zone.device_setpoint = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
// A physical/manual takeover has higher priority than thermostat, schedule, group and
// automation control. Continue sensor/history updates, but reflect the unit's real state
// instead of sending corrective frames that would fight the person holding the remote.
if zone.device_manual_override {
// Direct/manual ownership and the thermostat compressor queue are mutually
// exclusive. Clean any stale persisted task before remaining passive.
clear_compressor_pending(&mut zone, true);
// Manual/remote takeover pauses commands, but it must not erase the thermostat's
// selected profile/target. Keep the intended target visible and report the physical
// unit target separately through device_setpoint. This makes Resume/Profile actions
// deterministic and avoids a standby device target (for example 25 C) masquerading
// as the zone's Sleep/Comfort target.
let temporary_active = temporary_quick_thermostat_is_active(&zone, zone.updated_at.clone());
let pause_started_at = zone.updated_at;
if temporary_active {
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
if session.paused_at.is_none() { session.paused_at = Some(pause_started_at); }
session.state = "paused_manual".into();
session.condition_started_at = None;
session.condition_last_observed_at = None;
}
}
let target_mode = if effective_mode == "off" { zone.mode.as_str() } else { effective_mode };
let active_schedule = active_schedule_for_zone(&zone, &schedules, Local::now());
let (preset, target) = resolve_zone_target(&zone, active_schedule, target_mode);
zone.active_preset = preset;
zone.effective_setpoint = Some(target);
// Keep effective_mode's existing meaning during takeover: it reflects the physical
// unit, while effective_setpoint above remains the thermostat intent.
zone.effective_mode = if device.power { device.mode.clone() } else { "off".into() };
zone.device_setpoint = if device.power { Some(device.target_temperature) } else { None };
zone.demand = false;
zone.demand_since = None;
zone.target_alerted_at = None;
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
continue;
}
// Temperature completion belongs to the temporary thermostat only while it truly owns
// the zone. A manual/device takeover above therefore pauses the hold instead of silently
// consuming it. GREE samples use last_seen; HA/combined samples were freshly read in this
// control cycle. A long gap resets continuous-hold evidence after restart/stale sensors.
let condition_sample_at = match zone.control_temperature_source.as_str() {
"home_assistant" | "combined" => Some(zone.updated_at.clone()),
_ => device.last_seen.clone(),
};
let max_condition_gap_seconds = settings.poll_interval_seconds
.max(settings.zone_interval_seconds)
.saturating_mul(2)
.saturating_add(5);
let condition_now = zone.updated_at.clone();
if let Some(reason) = evaluate_temporary_quick_thermostat_condition(
if handle_zone_pre_control_state(
state,
&settings,
&schedules,
&temporary_restored_disabled,
&mut zone,
condition_now,
condition_sample_at,
max_condition_gap_seconds,
) {
let finish_kind = zone.temporary_quick_thermostat.as_ref().map(|item| item.finish_kind.clone()).unwrap_or_default();
finish_temporary_quick_thermostat(&mut zone, &schedules, &settings.house_mode);
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
ensure_device_off_after_temporary_disabled_restore(state, &persisted_zone, &device).await;
state.log("info", "zone.temporary_quick_thermostat_finished", &format!("Temporary Quick Thermostat finished for {}", zone.name), json!({
"zone_id": zone.id, "device_id": zone.device_id, "finish_kind": finish_kind, "reason": reason
}));
state.wake_zone_control();
continue;
}
if zone.local_thermostat_power == Some(false) {
zone.effective_mode = "off".into();
zone.demand = false;
zone.demand_since = None;
zone.device_setpoint = None;
if device.online && device.communication_failures == 0 && device.power {
let _device_guard = state.lock_device_operation(&zone.device_id).await;
let latest = state.db.get_zone(&zone.id)?;
if latest.as_ref().map(|item| item.local_thermostat_power == Some(false) && !item.device_manual_override).unwrap_or(false) {
if let Err(err) = send_command_locked(
state,
&zone.device_id,
DeviceCommand { power: Some(false), ..Default::default() },
).await {
state.log("error", "zone.local_power_error", &err.to_string(), json!({"zone_id": zone.id, "device_id": zone.device_id}));
}
}
}
record_zone_history(state, &zone, outdoor_temperature, settings.poll_interval_seconds);
let persisted_zone = persist_zone_cycle(state, &zone, cycle_started_at)?;
state.broadcast("zone.updated", serde_json::to_value(&persisted_zone)?);
&device,
effective_mode,
cycle_started_at.clone(),
outdoor_temperature,
).await? {
continue;
}
-4
View File
@@ -34,10 +34,6 @@ async fn main() -> Result<()> {
runtime_settings.discovery_broadcast = config.discovery_broadcast.clone();
}
config.apply_runtime_env_overrides(&mut runtime_settings);
// v0.9.4: whole-house ON/OFF is a bulk thermostat action, not a persistent master gate.
// Normalize databases upgraded from older releases so a historical OFF cannot suppress
// thermostats, groups or automations after restart.
runtime_settings.house_power_enabled = true;
db.save_runtime_settings(&runtime_settings)?;
if config.simulate && config.auto_seed && db.count_devices()? == 0 {
+1
View File
@@ -12,5 +12,6 @@ include!("models/flow.rs");
include!("models/automation.rs");
include!("models/history.rs");
include!("models/integrations.rs");
include!("models/settings_api.rs");
include!("models/control_plan.rs");
include!("models/runtime.rs");
-4
View File
@@ -9,10 +9,6 @@ pub struct RuntimeSettings {
/// Global seasonal mode. Zones follow this by default. Values: cool/heat/off; off pauses house-level thermostat control.
#[serde(default = "default_house_mode")]
pub house_mode: String,
/// Legacy compatibility flag. Since v0.9.4 global ON/OFF is a bulk thermostat action, not an
/// automation gate. The controller normalizes this field to true on load/import.
#[serde(default = "default_true")]
pub house_power_enabled: bool,
/// `setpoint` keeps units powered and modulates compressor demand by changing target temperature.
#[serde(default = "default_control_strategy")]
pub control_strategy: String,
+113
View File
@@ -0,0 +1,113 @@
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApplicationSettings {
pub simulator_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GreeSettings {
pub controller_id: String,
pub poll_interval_seconds: u64,
pub zone_interval_seconds: u64,
pub discovery_timeout_ms: u64,
pub discovery_broadcast: String,
pub suppress_device_beep: bool,
pub compressor_protection_enabled: bool,
pub compressor_protection_seconds: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistorySettings {
pub retention_days: u32,
pub compaction_enabled: bool,
pub event_retention_days: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfluxDbSettingsUpdate {
pub enabled: bool,
pub version: String,
pub url: String,
pub database: String,
pub username: String,
#[serde(default)]
pub password: Option<String>,
pub org: String,
pub bucket: String,
#[serde(default)]
pub token: Option<String>,
pub history_threshold_days: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InfluxDbSettingsView {
pub enabled: bool,
pub version: String,
pub url: String,
pub database: String,
pub username: String,
pub password_configured: bool,
pub org: String,
pub bucket: String,
pub token_configured: bool,
pub history_threshold_days: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSettingsUpdate {
pub enabled: bool,
pub mode: String,
pub provider: String,
#[serde(default)]
pub pushover_app_token: Option<String>,
#[serde(default)]
pub pushover_user_key: Option<String>,
#[serde(default)]
pub slack_webhook_url: Option<String>,
#[serde(default)]
pub discord_webhook_url: Option<String>,
pub cooldown_seconds: u64,
pub communication_failure_threshold: u32,
pub target_timeout_minutes: u32,
pub alert_types: NotificationAlertTypes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationSettingsView {
pub enabled: bool,
pub mode: String,
pub provider: String,
pub pushover_configured: bool,
pub slack_configured: bool,
pub discord_configured: bool,
pub cooldown_seconds: u64,
pub communication_failure_threshold: u32,
pub target_timeout_minutes: u32,
pub alert_types: NotificationAlertTypes,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HomeAssistantSettingsUpdate {
pub url: String,
#[serde(default)]
pub token: Option<String>,
pub default_entity_id: String,
pub outdoor_entity_id: String,
pub sensor_stale_after_seconds: u64,
pub allow_invalid_tls: bool,
pub sensor_aliases: BTreeMap<String, String>,
pub flow_inputs: Vec<FlowSharedInput>,
pub outdoor_assist_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HomeAssistantSettingsView {
pub url: String,
pub token_configured: bool,
pub default_entity_id: String,
pub outdoor_entity_id: String,
pub sensor_stale_after_seconds: u64,
pub allow_invalid_tls: bool,
pub sensor_aliases: BTreeMap<String, String>,
pub flow_inputs: Vec<FlowSharedInput>,
pub outdoor_assist_enabled: bool,
}
+4 -4
View File
@@ -2,7 +2,7 @@ async function loadBootstrap(showMessage = false) {
if (app.loading) return;
app.loading = true;
try {
const data = await api('/api/bootstrap');
const [data, sections] = await Promise.all([api('/api/bootstrap'), fetchSettingsSections()]);
app.devices = data.devices || [];
app.zones = data.zones || [];
app.groups = data.groups || [];
@@ -10,9 +10,9 @@ async function loadBootstrap(showMessage = false) {
app.automations = data.automations || [];
app.flows = data.flows || [];
app.accessTokens = data.access_tokens || [];
app.settings = data.settings || null;
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || []));
app.settings = aggregateSettingsSections(sections, data.house?.mode || 'cool');
app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || []));
app.system = data.system || {};
app.systemSnapshotAt = Date.now();
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
-1
View File
@@ -469,7 +469,6 @@ async function sendGroupControl(id, patch) {
if (index >= 0) app.zones[index] = zone; else app.zones.push(zone);
});
(result.devices || []).forEach(updateDevice);
if (typeof result.master_power_enabled === 'boolean' && app.settings) app.settings.house_power_enabled = result.master_power_enabled;
if (patch.preset === 'custom' && patch.setpoint != null) delete app.groupCustomDrafts[id];
renderAll(); scheduleControlPlanLoad();
const failed = Array.isArray(result.failed) ? result.failed.length : 0;
+48 -41
View File
@@ -10,6 +10,52 @@ async function loadLogs() {
} catch (error) { toast(error.message, true); }
}
async function handleWebSocketMessage(event) {
try {
const message = JSON.parse(event.data);
if (message.event === 'bootstrap') {
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || [];
app.flows = data.flows || []; await loadSettingsSections(data.house?.mode || app.settings?.house_mode || 'cool'); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
}
const data = message.data || {};
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.application.updated') { applySettingsSection('application', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderSimulationModeBanner(); renderSystemInfo(); }
else if (message.event === 'settings.gree.updated') { applySettingsSection('gree', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.history.updated') { applySettingsSection('history', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderLogRetention(); }
else if (message.event === 'settings.influxdb.updated') { applySettingsSection('influxdb', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
else if (message.event === 'settings.notifications.updated') { applySettingsSection('notifications', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); }
else if (message.event === 'settings.debug.updated') { applySettingsSection('debug', data); if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if (data.overlay_enabled) loadDebugBacklog(); }
else if (message.event === 'settings.night.updated') { applySettingsSection('night', data); if (!isFormDirty($('#nightModeForm'))) renderNightSettings(); renderHouseClimate(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.home_assistant.updated') {
applySettingsSection('homeAssistant', data);
if (!isFormDirty($('#homeAssistantForm'))) { app.sensorAliases = { ...(data.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || [])); renderHomeAssistantSettings(); }
renderHouseClimate(); if (app.flowDraft) renderFlowEditor(); scheduleControlPlanLoad();
}
else if (message.event === 'house.mode_changed') { app.settings = app.settings || {}; app.settings.house_mode = data.mode || 'cool'; renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); }
else if (message.event === 'gree.frame_received') {
app.system = app.system || {};
app.system.gree_received_frames = Number(data.total || 0);
app.system.gree_received_frames_by_device = { ...(app.system.gree_received_frames_by_device || {}) };
if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0);
renderGreeFrameStats();
renderSystemInfo();
}
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad();
} catch (_) { }
}
function connectWebSocket() {
if (app.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(app.ws.readyState)) return;
clearTimeout(app.wsTimer);
@@ -19,47 +65,8 @@ function connectWebSocket() {
ws.onopen = () => updateConnectionIndicator('connected');
ws.onclose = () => { updateConnectionIndicator('disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); };
ws.onerror = () => updateConnectionIndicator('connectionError');
let messageQueue = Promise.resolve();
ws.onmessage = event => {
try {
const message = JSON.parse(event.data);
if (message.event === 'bootstrap') {
const data = message.data; app.devices = data.devices || []; app.zones = data.zones || []; app.groups = data.groups || []; app.schedules = data.schedules || []; app.automations = data.automations || [];
app.flows = data.flows || []; app.settings = data.settings || app.settings; app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); app.system = data.system || app.system; app.systemSnapshotAt = Date.now(); app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : app.outdoorTemperature; renderAll(); scheduleControlPlanLoad(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); return;
}
const data = message.data || {};
if (['device.updated', 'device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'device.deleted') { app.devices = app.devices.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'devices.discovered') { (data.devices || []).forEach(updateDevice); renderAll(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.updated') { const i = app.zones.findIndex(v => v.id === data.id); if (i >= 0) app.zones[i] = data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); renderDevices(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'zone.deleted') { app.zones = app.zones.filter(v => v.id !== data.id); renderAll(); scheduleControlPlanLoad(); }
else if (['group.updated', 'group.created'].includes(message.event)) { const i = app.groups.findIndex(v => v.id === data.id); if (i >= 0) app.groups[i] = data; else app.groups.push(data); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (message.event === 'group.deleted') { app.groups = app.groups.filter(v => v.id !== data.id); renderGroups(); fillSelects(); scheduleControlPlanLoad(); }
else if (['flow.updated', 'flow.created'].includes(message.event)) { const i = app.flows.findIndex(v => v.id === data.id); if (i >= 0) app.flows[i] = data; else app.flows.push(data); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'flow.deleted') { app.flows = app.flows.filter(v => v.id !== data.id); renderFlows(); scheduleControlPlanLoad(); }
else if (message.event === 'settings.updated') {
const settingsDirty = isFormDirty($('#settingsForm')), nightDirty = isFormDirty($('#nightModeForm')), haDirty = isFormDirty($('#homeAssistantForm'));
app.settings = data;
if (!haDirty) { app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) }; app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || [])); }
if (!settingsDirty) renderSettings();
if (!nightDirty) renderNightSettings();
if (!haDirty) renderHomeAssistantSettings();
renderHouseClimate(); renderSimulationModeBanner(); renderSystemInfo(); renderDebugOverlay(); if (app.settings?.debug?.overlay_enabled) loadDebugBacklog(); scheduleControlPlanLoad();
}
else if (message.event === 'house.power_changed') { renderHouseClimate(); renderGroups(); scheduleControlPlanLoad(); }
else if (message.event === 'debug.settings') { app.settings = app.settings || {}; app.settings.debug = data; if (!isFormDirty($('#settingsForm'))) renderSettings(); renderDebugOverlay(); if (data.overlay_enabled) loadDebugBacklog(); }
else if (message.event === 'outdoor.updated') { app.outdoorTemperature = Number.isFinite(Number(data.temperature)) ? Number(data.temperature) : null; renderHouseClimate(); scheduleControlPlanLoad(); }
else if (message.event === 'gree.frame_received') {
app.system = app.system || {};
app.system.gree_received_frames = Number(data.total || 0);
app.system.gree_received_frames_by_device = { ...(app.system.gree_received_frames_by_device || {}) };
if (data.device_id) app.system.gree_received_frames_by_device[data.device_id] = Number(data.device_count || 0);
renderGreeFrameStats();
renderSystemInfo();
}
else if (message.event === 'gree.frame') { if (app.settings?.debug?.overlay_enabled) debugLine('GREE', `${data.direction || '?'} ${data.protocol_version || ''}`, data.device_name || data.device_id || data.target || '', message.timestamp, data.payload); }
else if (message.event === 'api.request') { if (app.settings?.debug?.overlay_enabled) debugLine('HTTP', `${data.method || '?'} ${data.status || ''}`, `${data.path || ''} · ${data.duration_ms ?? '?'} ms`, message.timestamp); }
else if (message.event === 'log.created') { if (app.settings?.debug?.overlay_enabled) debugLine('API', data.kind || data.level || 'log', data.message || '', message.timestamp, data.metadata); if (app.currentView === 'logs') loadLogs(); }
else if (message.event.startsWith('schedule.') || message.event.startsWith('automation.') || message.event.startsWith('flow.')) scheduleControlPlanLoad();
} catch (_) { }
messageQueue = messageQueue.then(() => handleWebSocketMessage(event)).catch(() => {});
};
}
+175 -115
View File
@@ -1,103 +1,127 @@
const SETTINGS_ENDPOINTS = {
application: '/api/settings/application',
gree: '/api/settings/gree',
history: '/api/settings/history',
influxdb: '/api/settings/influxdb',
notifications: '/api/settings/notifications',
night: '/api/settings/night',
homeAssistant: '/api/settings/home-assistant',
debug: '/api/settings/debug',
};
function currentSettingsBody() {
const settings = app.settings || {};
const influx = settings.influxdb || {};
const ha = settings.home_assistant || {};
async function fetchSettingsSections() {
const [application, gree, history, influxdb, notifications, night, homeAssistant, debug] = await Promise.all([
api(SETTINGS_ENDPOINTS.application),
api(SETTINGS_ENDPOINTS.gree),
api(SETTINGS_ENDPOINTS.history),
api(SETTINGS_ENDPOINTS.influxdb),
api(SETTINGS_ENDPOINTS.notifications),
api(SETTINGS_ENDPOINTS.night),
api(SETTINGS_ENDPOINTS.homeAssistant),
api(SETTINGS_ENDPOINTS.debug),
]);
return { application, gree, history, influxdb, notifications, night, homeAssistant, debug };
}
function aggregateSettingsSections(sections, houseMode = app.settings?.house_mode || 'cool') {
return {
controller_id: settings.controller_id || 'gree-controller',
simulator_enabled: !!settings.simulator_enabled,
poll_interval_seconds: Number(settings.poll_interval_seconds || 15),
zone_interval_seconds: Number(settings.zone_interval_seconds || 5),
discovery_timeout_ms: Number(settings.discovery_timeout_ms || 3000),
discovery_broadcast: settings.discovery_broadcast || '255.255.255.255:7000',
house_mode: settings.house_mode || 'cool',
house_power_enabled: settings.house_power_enabled !== false,
control_strategy: 'setpoint',
outdoor_assist_enabled: !!settings.outdoor_assist_enabled,
history_retention_days: Number(settings.history_retention_days || 30),
history_compaction_enabled: settings.history_compaction_enabled !== false,
event_log_retention_days: Number(settings.event_log_retention_days || 30),
suppress_device_beep: !!settings.suppress_device_beep,
compressor_protection_enabled: settings.compressor_protection_enabled !== false,
compressor_protection_seconds: Number(settings.compressor_protection_seconds || 180),
notifications: {
enabled: !!settings.notifications?.enabled, mode: settings.notifications?.mode || 'problems', provider: settings.notifications?.provider || 'pushover',
pushover_app_token: '', pushover_user_key: '', slack_webhook_url: '', discord_webhook_url: '',
cooldown_seconds: Number(settings.notifications?.cooldown_seconds || 300), communication_failure_threshold: Number(settings.notifications?.communication_failure_threshold || 3), target_timeout_minutes: Number(settings.notifications?.target_timeout_minutes || 60),
alert_types: {
stale_sensor: settings.notifications?.alert_types?.stale_sensor !== false,
sensor_errors: settings.notifications?.alert_types?.sensor_errors !== false,
communication: settings.notifications?.alert_types?.communication !== false,
target_timeout: settings.notifications?.alert_types?.target_timeout !== false,
automation: settings.notifications?.alert_types?.automation !== false,
control_errors: settings.notifications?.alert_types?.control_errors !== false,
important_events: settings.notifications?.alert_types?.important_events !== false,
other: settings.notifications?.alert_types?.other !== false,
},
},
night_mode: {
enabled: !!settings.night_mode?.enabled,
start_time: settings.night_mode?.start_time || '22:00',
end_time: settings.night_mode?.end_time || '06:00',
max_fan_speed: Number(settings.night_mode?.max_fan_speed || 1),
force_quiet: settings.night_mode?.force_quiet !== false,
use_native_sleep: settings.night_mode?.use_native_sleep !== false,
},
influxdb: {
enabled: !!influx.enabled,
version: String(influx.version || '2'),
url: influx.url || '',
database: influx.database || 'gree_controller',
username: influx.username || '',
password: '',
org: influx.org || '',
bucket: influx.bucket || 'gree_controller',
token: '',
history_threshold_days: Number(influx.history_threshold_days || 30),
},
debug: {
overlay_enabled: !!settings.debug?.overlay_enabled,
gree_frames: !!settings.debug?.gree_frames,
},
home_assistant: {
url: ha.url || '',
token: '',
default_entity_id: ha.default_entity_id || '',
outdoor_entity_id: ha.outdoor_entity_id || '',
sensor_stale_after_seconds: Number(ha.sensor_stale_after_seconds || 300),
allow_invalid_tls: !!ha.allow_invalid_tls,
sensor_aliases: { ...(ha.sensor_aliases || {}) },
flow_inputs: JSON.parse(JSON.stringify(ha.flow_inputs || [])),
},
simulator_enabled: !!sections.application.simulator_enabled,
controller_id: sections.gree.controller_id,
poll_interval_seconds: Number(sections.gree.poll_interval_seconds),
zone_interval_seconds: Number(sections.gree.zone_interval_seconds),
discovery_timeout_ms: Number(sections.gree.discovery_timeout_ms),
discovery_broadcast: sections.gree.discovery_broadcast,
suppress_device_beep: !!sections.gree.suppress_device_beep,
compressor_protection_enabled: sections.gree.compressor_protection_enabled !== false,
compressor_protection_seconds: Number(sections.gree.compressor_protection_seconds),
history_retention_days: Number(sections.history.retention_days),
history_compaction_enabled: sections.history.compaction_enabled !== false,
event_log_retention_days: Number(sections.history.event_retention_days),
influxdb: sections.influxdb,
notifications: sections.notifications,
night_mode: sections.night,
home_assistant: sections.homeAssistant,
outdoor_assist_enabled: !!sections.homeAssistant.outdoor_assist_enabled,
debug: sections.debug,
house_mode: houseMode || 'cool',
};
}
function settingsBodyFromForm(form) {
async function loadSettingsSections(houseMode = app.settings?.house_mode || 'cool') {
const sections = await fetchSettingsSections();
app.settings = aggregateSettingsSections(sections, houseMode);
app.sensorAliases = { ...(app.settings.home_assistant?.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings.home_assistant?.flow_inputs || []));
return app.settings;
}
function applySettingsSection(section, data) {
app.settings = app.settings || {};
if (section === 'application') app.settings.simulator_enabled = !!data.simulator_enabled;
else if (section === 'gree') Object.assign(app.settings, data);
else if (section === 'history') {
app.settings.history_retention_days = Number(data.retention_days);
app.settings.history_compaction_enabled = data.compaction_enabled !== false;
app.settings.event_log_retention_days = Number(data.event_retention_days);
} else if (section === 'influxdb') app.settings.influxdb = data;
else if (section === 'notifications') app.settings.notifications = data;
else if (section === 'night') app.settings.night_mode = data;
else if (section === 'homeAssistant') {
app.settings.home_assistant = data;
app.settings.outdoor_assist_enabled = !!data.outdoor_assist_enabled;
} else if (section === 'debug') app.settings.debug = data;
}
function applicationSettingsBodyFromForm(form) {
return { simulator_enabled: form.simulator_enabled.checked };
}
function greeSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = currentSettingsBody();
body.controller_id = raw.controller_id;
body.simulator_enabled = form.simulator_enabled.checked;
body.poll_interval_seconds = Number(raw.poll_interval_seconds);
body.zone_interval_seconds = Number(raw.zone_interval_seconds);
body.discovery_timeout_ms = Number(raw.discovery_timeout_ms);
body.discovery_broadcast = raw.discovery_broadcast;
body.history_retention_days = Number(raw.history_retention_days);
body.history_compaction_enabled = form.history_compaction_enabled.checked;
body.event_log_retention_days = Number(raw.event_log_retention_days);
body.suppress_device_beep = form.suppress_device_beep.checked;
body.compressor_protection_enabled = form.compressor_protection_enabled.checked;
body.compressor_protection_seconds = Math.max(30, Math.min(1800, Math.round(Number(raw.compressor_protection_minutes || 3) * 60)));
body.influxdb = {
enabled: form.influx_enabled.checked, version: raw.influx_version, url: raw.influx_url,
database: raw.influx_database, username: raw.influx_username, password: raw.influx_password,
org: raw.influx_org, bucket: raw.influx_bucket, token: raw.influx_token,
return {
controller_id: raw.controller_id,
poll_interval_seconds: Number(raw.poll_interval_seconds),
zone_interval_seconds: Number(raw.zone_interval_seconds),
discovery_timeout_ms: Number(raw.discovery_timeout_ms),
discovery_broadcast: raw.discovery_broadcast,
suppress_device_beep: form.suppress_device_beep.checked,
compressor_protection_enabled: form.compressor_protection_enabled.checked,
compressor_protection_seconds: Math.max(30, Math.min(1800, Math.round(Number(raw.compressor_protection_minutes || 3) * 60))),
};
}
function historySettingsBodyFromForm(form, eventRetentionDays = null) {
const raw = Object.fromEntries(new FormData(form));
return {
retention_days: Number(raw.history_retention_days),
compaction_enabled: form.history_compaction_enabled.checked,
event_retention_days: eventRetentionDays == null ? Number(raw.event_log_retention_days) : Number(eventRetentionDays),
};
}
function influxDbSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = {
enabled: form.influx_enabled.checked,
version: raw.influx_version,
url: raw.influx_url,
database: raw.influx_database,
username: raw.influx_username,
org: raw.influx_org,
bucket: raw.influx_bucket,
history_threshold_days: Number(raw.influx_threshold_days),
};
body.debug = { overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked };
body.notifications = {
enabled: form.notifications_enabled.checked, mode: raw.notifications_mode, provider: raw.notifications_provider,
pushover_app_token: raw.pushover_app_token || '', pushover_user_key: raw.pushover_user_key || '',
slack_webhook_url: raw.slack_webhook_url || '', discord_webhook_url: raw.discord_webhook_url || '',
if (raw.influx_password) body.password = raw.influx_password;
if (raw.influx_token) body.token = raw.influx_token;
return body;
}
function notificationSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = {
enabled: form.notifications_enabled.checked,
mode: raw.notifications_mode,
provider: raw.notifications_provider,
cooldown_seconds: Number(raw.notification_cooldown_seconds || 300),
communication_failure_threshold: Number(raw.notification_failure_threshold || 3),
target_timeout_minutes: Number(raw.notification_target_timeout || 60),
@@ -112,13 +136,20 @@ function settingsBodyFromForm(form) {
other: form.notification_alert_other.checked,
},
};
if (raw.pushover_app_token) body.pushover_app_token = raw.pushover_app_token;
if (raw.pushover_user_key) body.pushover_user_key = raw.pushover_user_key;
if (raw.slack_webhook_url) body.slack_webhook_url = raw.slack_webhook_url;
if (raw.discord_webhook_url) body.discord_webhook_url = raw.discord_webhook_url;
return body;
}
function debugSettingsBodyFromForm(form) {
return { overlay_enabled: form.debug_overlay_enabled.checked, gree_frames: form.debug_gree_frames.checked };
}
function nightSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = currentSettingsBody();
body.night_mode = {
return {
enabled: form.night_mode_enabled.checked,
start_time: raw.night_mode_start,
end_time: raw.night_mode_end,
@@ -126,30 +157,25 @@ function nightSettingsBodyFromForm(form) {
force_quiet: form.night_mode_force_quiet.checked,
use_native_sleep: form.night_mode_native_sleep.checked,
};
return body;
}
function homeAssistantSettingsBodyFromForm(form) {
const raw = Object.fromEntries(new FormData(form));
const body = currentSettingsBody();
body.outdoor_assist_enabled = form.outdoor_assist_enabled.checked;
body.home_assistant = {
const body = {
url: raw.ha_url,
token: raw.ha_token,
default_entity_id: raw.ha_entity_id,
outdoor_entity_id: raw.ha_outdoor_entity_id,
sensor_stale_after_seconds: Math.max(60, Math.min(86400, Math.round(Number(raw.ha_sensor_stale_after_minutes || 5) * 60))),
allow_invalid_tls: form.ha_allow_invalid_tls.checked,
sensor_aliases: { ...(app.sensorAliases || {}) },
flow_inputs: JSON.parse(JSON.stringify(app.flowSharedInputs || [])),
outdoor_assist_enabled: form.outdoor_assist_enabled.checked,
};
if (raw.ha_token) body.token = raw.ha_token;
return body;
}
async function saveRuntimeSettings(body, notify = true) {
app.settings = await api('/api/settings', { method: 'PUT', body });
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || []));
function refreshSettingsUi() {
renderSettings();
renderNightSettings();
renderHomeAssistantSettings();
@@ -160,23 +186,56 @@ async function saveRuntimeSettings(body, notify = true) {
if (app.flowDraft) renderFlowEditor();
scheduleControlPlanLoad();
if (app.settings?.debug?.overlay_enabled) loadDebugBacklog();
}
async function saveMainSettings(form, notify = true) {
const [application, gree, history, influxdb, notifications, debug] = await Promise.all([
api(SETTINGS_ENDPOINTS.application, { method: 'PUT', body: applicationSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.gree, { method: 'PUT', body: greeSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.influxdb, { method: 'PUT', body: influxDbSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.notifications, { method: 'PUT', body: notificationSettingsBodyFromForm(form) }),
api(SETTINGS_ENDPOINTS.debug, { method: 'PUT', body: debugSettingsBodyFromForm(form) }),
]);
applySettingsSection('application', application);
applySettingsSection('gree', gree);
applySettingsSection('history', history);
applySettingsSection('influxdb', influxdb);
applySettingsSection('notifications', notifications);
applySettingsSection('debug', debug);
refreshSettingsUi();
if (notify) toast(tr('common.saved'));
}
async function saveNightSettings(form, notify = true) {
const data = await api(SETTINGS_ENDPOINTS.night, { method: 'PUT', body: nightSettingsBodyFromForm(form) });
applySettingsSection('night', data);
refreshSettingsUi();
if (notify) toast(tr('common.saved'));
}
async function saveHomeAssistantSettings(form, notify = true) {
const data = await api(SETTINGS_ENDPOINTS.homeAssistant, { method: 'PUT', body: homeAssistantSettingsBodyFromForm(form) });
applySettingsSection('homeAssistant', data);
app.sensorAliases = { ...(data.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(data.flow_inputs || []));
refreshSettingsUi();
if (notify) toast(tr('common.saved'));
return app.settings;
}
$('#settingsForm').addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget;
await runFormTask(form, () => saveRuntimeSettings(settingsBodyFromForm(form), true));
await runFormTask(form, () => saveMainSettings(form, true));
});
$('#nightModeForm')?.addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget;
await runFormTask(form, () => saveRuntimeSettings(nightSettingsBodyFromForm(form), true));
await runFormTask(form, () => saveNightSettings(form, true));
});
$('#homeAssistantForm')?.addEventListener('submit', async event => {
event.preventDefault(); const form = event.currentTarget;
await runFormTask(form, () => saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), true));
await runFormTask(form, () => saveHomeAssistantSettings(form, true));
});
$('#addSensorAlias')?.addEventListener('click', () => {
@@ -206,10 +265,11 @@ document.addEventListener('click', event => {
$('#saveLogRetention')?.addEventListener('click', async () => {
const days = Number($('#logRetentionDays')?.value || 30);
try {
const result = await api('/api/events/retention', { method: 'PUT', body: { days } });
app.settings.event_log_retention_days = result.days;
$('#settingsForm').event_log_retention_days.value = result.days;
renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', { days: result.days }));
const form = $('#settingsForm');
const result = await api(SETTINGS_ENDPOINTS.history, { method: 'PUT', body: historySettingsBodyFromForm(form, days) });
applySettingsSection('history', result);
form.event_log_retention_days.value = result.event_retention_days;
renderLogRetention(); await loadLogs(); toast(tr('logs.retentionSaved', { days: result.event_retention_days }));
} catch (error) { toast(error.message, true); }
});
@@ -246,7 +306,7 @@ $('#haTest').addEventListener('click', async event => {
const idle = button.textContent;
button.textContent = tr('settings.testingHa');
try {
await saveRuntimeSettings(homeAssistantSettingsBodyFromForm(form), false);
await saveHomeAssistantSettings(form, false);
const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null;
const result = await api('/api/integrations/home-assistant/test', { method: 'POST', body: { entity_id: entity } });
markFormClean(form);
@@ -257,11 +317,11 @@ $('#haTest').addEventListener('click', async event => {
$('#exportSettings').addEventListener('click', async () => {
try {
const data = await api('/api/settings/export');
const data = await api('/api/configuration/export');
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `gree-controller-settings-${new Date().toISOString().slice(0, 10)}.json`;
link.download = `gree-controller-configuration-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(link.href);
toast(tr('toast.exported'));
} catch (error) { toast(error.message, true); }
@@ -273,7 +333,7 @@ $('#importSettingsFile').addEventListener('change', async event => {
try {
if (!confirm(tr('settings.importConfirm'))) return;
const body = JSON.parse(await file.text());
await api('/api/settings/import', { method: 'POST', body });
await api('/api/configuration/import', { method: 'POST', body });
app.debugBacklogLoaded = false;
await loadBootstrap();
toast(tr('toast.imported'));
+2 -2
View File
@@ -6731,7 +6731,7 @@ textarea:focus { border-color:var(--accent); outline:2px solid color-mix(in srgb
.flow-generated-card .card-footer > small { color:var(--accent); }
/* 0.11.6 production layout guards and in-app chart preview. */
/* 0.12.0 production layout guards and in-app chart preview. */
html,body { max-width:100%; overflow-x:clip; }
main,.view { min-width:0; max-width:100%; }
.flow-info-panel,.flow-info-panel > div { min-width:0; }
@@ -7113,7 +7113,7 @@ body.chart-fullscreen-open::before { content:""; position:fixed; inset:0; z-inde
}
/* 0.11.6 diagnostics, manual-control safety and technical connection checks. */
/* 0.12.0 diagnostics, manual-control safety and technical connection checks. */
.manual-device-problem {
display: grid;
gap: 4px;