v0.12.0
This commit is contained in:
Generated
+282
-10
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
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
+2
-2
@@ -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
@@ -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
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
+7
-7
@@ -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
Regular → Executable
+2
-2
@@ -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
@@ -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");
|
||||
|
||||
@@ -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(¤t_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(¤t.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, ¤t_zones, ¤t_devices, &export).await;
|
||||
|
||||
power_off_detached_devices(&state, ¤t_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})))
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ¤t_zones {
|
||||
if imported_zone_map.get(¤t.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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,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
@@ -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},
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Vendored
+4
-4
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user