Compare commits
78
Commits
500516b0fd
..
master
+36
-15
@@ -1,22 +1,43 @@
|
||||
# Serwer HTTP
|
||||
GREE_BIND=0.0.0.0:8787
|
||||
GREE_DATABASE=./data/gree-controller.db
|
||||
# HTTP / WebSocket
|
||||
GREE_CONTROLLER_BIND=0.0.0.0:8787
|
||||
GREE_CONTROLLER_DATABASE=./data/gree-controller.db
|
||||
GREE_CONTROLLER_BASE_PATH=
|
||||
RUST_LOG=gree_controller=info,tower_http=info
|
||||
|
||||
# Pusty token = dostęp bez logowania w zaufanej sieci LAN.
|
||||
# Ustaw długi losowy token, gdy interfejs jest dostępny poza zaufanym VLAN-em.
|
||||
GREE_APP_TOKEN=
|
||||
# Authentication
|
||||
# Empty = no login in standalone mode (trusted LAN only).
|
||||
# Set a long random token when the UI/API is reachable outside a trusted LAN/VLAN.
|
||||
GREE_CONTROLLER_APP_TOKEN=
|
||||
|
||||
# Tryb symulacji pozwala uruchomić aplikację bez klimatyzatora.
|
||||
GREE_SIMULATE=true
|
||||
GREE_AUTO_SEED=true
|
||||
GREE_POLL_INTERVAL_SECONDS=15
|
||||
GREE_ZONE_INTERVAL_SECONDS=5
|
||||
GREE_DISCOVERY_TIMEOUT_MS=3000
|
||||
GREE_DISCOVERY_BROADCAST=255.255.255.255:7000
|
||||
# Public Custom Chart links
|
||||
# Leave empty to use the current standalone origin/base path.
|
||||
# Example behind a reverse proxy:
|
||||
# GREE_CONTROLLER_PUBLIC_CHART_BASE_URL=https://gree.example.com
|
||||
GREE_CONTROLLER_PUBLIC_CHART_BASE_URL=
|
||||
|
||||
# Controller / discovery
|
||||
GREE_CONTROLLER_ID=gree-controller
|
||||
GREE_CONTROLLER_SIMULATE=false
|
||||
GREE_CONTROLLER_AUTO_SEED=false
|
||||
GREE_CONTROLLER_POLL_INTERVAL_SECONDS=15
|
||||
GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=5
|
||||
GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS=3000
|
||||
GREE_CONTROLLER_DISCOVERY_BROADCAST=255.255.255.255:7000
|
||||
# Optional interface name or local IP used only for GREE UDP traffic.
|
||||
GREE_CONTROLLER_GREE_INTERFACE=
|
||||
|
||||
# Opcjonalny sensor temperatury z Home Assistant.
|
||||
# Compressor protection
|
||||
GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED=true
|
||||
GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS=180
|
||||
|
||||
# Optional Home Assistant connection for standalone installations.
|
||||
# Not required inside the Home Assistant add-on; the add-on uses Supervisor API.
|
||||
HA_URL=
|
||||
HA_TOKEN=
|
||||
HA_ENTITY_ID=
|
||||
HA_OUTDOOR_ENTITY_ID=
|
||||
HA_SENSOR_STALE_AFTER_SECONDS=300
|
||||
HA_ALLOW_INVALID_TLS=false
|
||||
|
||||
# Do not set these manually in standalone mode:
|
||||
# GREE_CONTROLLER_HA_AUTH=supervisor
|
||||
# SUPERVISOR_TOKEN=...
|
||||
|
||||
Generated
+654
-279
File diff suppressed because it is too large
Load Diff
+29
-26
@@ -1,41 +1,44 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.11.4"
|
||||
version = "0.15.17"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
aes = "0.8"
|
||||
aes-gcm = "0.10"
|
||||
anyhow = "1"
|
||||
axum = { version = "0.7", features = ["macros", "ws"] }
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde", "clock"] }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
dotenvy = "0.15"
|
||||
futures-util = "0.3"
|
||||
libc = "0.2"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["compression-gzip", "cors", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
aes = "=0.9.3"
|
||||
aes-gcm = "=0.11.1"
|
||||
anyhow = "=1.0.104"
|
||||
axum = { version = "=0.8.9", features = ["macros", "ws"] }
|
||||
base64 = "=0.23.1"
|
||||
chrono = { version = "=0.4.45", features = ["serde", "clock"] }
|
||||
clap = { version = "=4.6.7", features = ["derive", "env"] }
|
||||
dotenvy = "=0.15.7"
|
||||
futures-util = "=0.3.34"
|
||||
libc = "=0.2.189"
|
||||
rand = "=0.10.2"
|
||||
reqwest = { version = "=0.13.5", default-features = false, features = ["json", "query", "form", "rustls-no-provider"] }
|
||||
rusqlite = { version = "=0.40.2", features = ["bundled"] }
|
||||
webpki-roots = "=1.0.9"
|
||||
tokio-rustls = { version = "=0.26.5", default-features = false, features = ["ring", "tls12", "logging"] }
|
||||
serde = { version = "=1.0.229", features = ["derive"] }
|
||||
serde_json = "=1.0.151"
|
||||
sha2 = "=0.11.0"
|
||||
thiserror = "=2.0.20"
|
||||
tokio = { version = "=1.53.1", features = ["full"] }
|
||||
tower-http = { version = "=0.7.1", features = ["compression-gzip", "cors", "trace"] }
|
||||
tracing = "=0.1.44"
|
||||
tracing-subscriber = { version = "=0.3.23", features = ["env-filter", "fmt"] }
|
||||
utoipa-swagger-ui = { version = "=9.0.2", default-features = false, features = ["axum", "debug-embed", "vendored"] }
|
||||
url = "=2.5.8"
|
||||
uuid = { version = "=1.26.1", features = ["v4", "serde"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = "1"
|
||||
serde_json = "=1.0.151"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tempfile = "=3.27.0"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
|
||||
+168
-140
@@ -1,38 +1,51 @@
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||
de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
ee9f0a05e85c3ffc081647716020ddfa26dfe128bc955eaed56a3b57cbbb81e6 ./Cargo.lock
|
||||
2cb81c4e01143414768f0592c4e362056ecf04678c465503789b6162fefa700c ./Cargo.toml
|
||||
4051d2de7fa9858545582dc6bb5a411c980a282d0b25aa3766df88597442f94a ./Cargo.lock
|
||||
4bf5b286ffb52b22a32ef3d2d5e4dfaae600c1be01c1cac0b87c7138ecb4a129 ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
a2713e3390c232ccf94e310ba18cb18ac47156ec050260f697fbc4257a9e1239 ./README.md
|
||||
41dfdc6d099b54f87d4dd51f696122b3c88d3bf3ddf420bea8f4bc621ba920b0 ./build.rs
|
||||
14188e547cd09ec63240cee480cf499ca479f300653fdf24dbef90a7175e4ec5 ./docs/API.md
|
||||
2e1e18fd8167dabfe2469c26e85cce62486c7cb6a502c63c6f6b0cd74d5885e0 ./docs/FLOW.md
|
||||
a0893b2a56eb1523f1a72871842e9be2139a5fafba1f51ae942fc407a6e4ca34 ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
||||
71c8058f85fd9ce9f063b7a84dcf2956f23cdbbd02bd4bd99d2fbb9d177c72ad ./home-assistant/custom_components/gree_controller/climate.py
|
||||
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
|
||||
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py
|
||||
ab08fbe40e9bb48ebcbfff98760aaa0c9434b61b705aaaadea0c09c255d99b0e ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||
1168f40e21d3c21341353f254f0b79157133e9625f9cdcd552bf6bcfd444e7c9 ./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
|
||||
338a42662e77f91215150851e55bbd39a5c77f5612cc77f3e037c06a6e6bdadb ./home-assistant/custom_components/gree_controller/switch.py
|
||||
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
|
||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||
9a429e8b766c66061e85ccd535cc1429fe1284d14fcf75daa362fea724e0bf85 ./README.md
|
||||
03d73e4b4c0036d2cc07d8057f25ae8c9d89451eebfb43715ee51aa06edd1c93 ./SOURCE_MANIFEST.sha256
|
||||
29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs
|
||||
3c1fce0f3097e7860f8ccf6a79749af4e7e4bd43e612c6419d91c0e620480506 ./docs/API.md
|
||||
43af7941ec4d427f9db7ab3cc6baf3d182a72dba7a6791f14094fee138324d8d ./docs/FLOW.md
|
||||
3ef573d60de5dcba3fdb4004df53ea170440cbeae2e244a1f6acfcb3255b7c78 ./docs/openapi.json
|
||||
77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example
|
||||
0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile
|
||||
0951696a2c2e66d4d969f8e7f8641184e3012f261583b31074bc38a7a1c0cc1a ./ha-addon/README.md
|
||||
a4596a70e50acfe698a8ae1dcb9bebd04adba95b69781218fca7add82c17703e ./ha-addon/build.sh
|
||||
7dd2534a6f8405e640e28b7cb0e94270cc5f01fad8a972193ec254befe8a96b9 ./ha-addon/home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./ha-addon/home-assistant/custom_components/gree_controller/__init__.py
|
||||
3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./ha-addon/home-assistant/custom_components/gree_controller/api.py
|
||||
da2a0a9390ab75cef150cd281bc329fa409d0d28f51382b36bd8de3a73325c16 ./ha-addon/home-assistant/custom_components/gree_controller/climate.py
|
||||
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./ha-addon/home-assistant/custom_components/gree_controller/config_flow.py
|
||||
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/home-assistant/custom_components/gree_controller/const.py
|
||||
6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py
|
||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png
|
||||
5aea34a2adb03ed46a93199e943d7820e34825658fefc3d7c3eb5267ac9066b0 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
|
||||
c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py
|
||||
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py
|
||||
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py
|
||||
338a42662e77f91215150851e55bbd39a5c77f5612cc77f3e037c06a6e6bdadb ./ha-addon/home-assistant/custom_components/gree_controller/switch.py
|
||||
a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
|
||||
50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
|
||||
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json
|
||||
69d6cf26824f8851cdf076f87c7270d6f87717a2820b68b57a82beed53d2c736 ./ha-addon/repository/gree-controller/CHANGELOG.md
|
||||
50b8ba050c6b4919e4d057f7b38c1e23d3913b5b7c87552626452e9695d37251 ./ha-addon/repository/gree-controller/DOCS.md
|
||||
d3d0e54672751a3400021ffd1fdcc83a9a903be72cff32cdc3607a61aec0a31e ./ha-addon/repository/gree-controller/README.md
|
||||
c591ab21837150e313407a34a5a3758302aaafd91cd5fcebe18bb5358b702433 ./ha-addon/repository/gree-controller/config.yaml
|
||||
3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png
|
||||
0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png
|
||||
2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml
|
||||
e6b93d3701d83a31506617a0e6f44ff32d8c24a52be038c8540c862b13546b50 ./ha-addon/repository/gree-controller/translations/pl.yaml
|
||||
2153b230357aab4766398577e23a7a9ff83bb135ddc86d1b3f2cbfc852efe0a1 ./ha-addon/repository/repository.yaml
|
||||
cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run.sh
|
||||
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
|
||||
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
7414e94d61d2dfd9734ee92a2a1423cae67e91e8733c213098b9bcb6a2885a22 ./lang/en.json
|
||||
1ff6dab25d8a29d971cd10de8d0df1b6173670ba1e6f65c0c669bf6b134bcf23 ./lang/pl.json
|
||||
859875af2aad9929fe6ecfb80244c8d09182345edd99663e54b04910268e902e ./lang/en.json
|
||||
7139d82eb65341e72ad978be3f57efbe04228b2101b19f792d665d146697d24f ./lang/pl.json
|
||||
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
||||
1e7b1bcf4194abfb8bad0d601268af0547d58541731e98b74a3500d7629d0be0 ./mock/index.html
|
||||
007c78e0cb404778985980b23bb5f8bbc7a8f04c80015204e24c321970e3b15e ./mock/mock.js
|
||||
11034c405898fe345b308cfb7da094c1dd995576c138241e19b29b063b51e6ba ./mock/styles.css
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./mock/theme-init.js
|
||||
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
|
||||
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
|
||||
c5276fecc6a33c5de912e5a73b0a829d4e18c18d57b9c0ee49179ef96f133fc7 ./presets/dual_threshold_control.json
|
||||
@@ -70,130 +83,145 @@ fffa18bd989a0dcc1c5458975522517d40af4af40cc1ca30640e79d3db711053 ./presets/unoc
|
||||
ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/weekend_comfort.json
|
||||
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
|
||||
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
|
||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
||||
5a8125836ed0c6e0fd98ea7e24867760771210da9f80c98d8d6ec3bcc6c5b150 ./scripts/FILE_MANIFEST.sha256
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/README.md
|
||||
fd7390d64a2378352c09382accf6efff43903d952d111b22a982f06c5b2a3b4b ./regenerate-sha.sh
|
||||
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
|
||||
5969f2442a8d61ac5b1303f4cd6d8dcfe80596ef5427a513ae3addb126bf0273 ./scripts/api_dev_test.py
|
||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
|
||||
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
|
||||
3fe88e64e43d380c56955f577992ea5b0252c05280f6e9dc3cd0bbcc0bf00896 ./scripts/generate_ha_migration.py
|
||||
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
|
||||
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
|
||||
50cc08e2aeff052a47c68212524f4584e9201cfc535d71803f276cdc223d5216 ./scripts/live_realtime_test.js
|
||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./scripts/smoke.sh
|
||||
bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./scripts/verify_flow_logic.py
|
||||
da16e16dfb330d528d54d2138b07950da701edc46af1ff174a9f6a54090122b3 ./src/api.rs
|
||||
825370f409124a719043200742c01c3441bc65f483108106140d13fe7bbcba91 ./src/api/assets.rs
|
||||
6c34b1294d76b0eba4c56bfd0bf69bdd502de0e40838104f6ddf2db4e922683d ./src/api/auth.rs
|
||||
63cb241c4536abed402f2a0ad37e7f299343df264eab6a33bb5354c15421e045 ./src/api/automations.rs
|
||||
2317b7cd0c2437c128ac4982d64412c8184ecb5e774825761d61aab6e45fae4a ./src/api/debug_tokens.rs
|
||||
4f82206fb48a291bdc643c96d4d6c917d669c7bdf2a35312c0a056dddecc7062 ./src/api/devices.rs
|
||||
417940b467c6f00bbf01cc47eb4479880dee9b2f284a80e3662a6d433dc11b92 ./src/api/events.rs
|
||||
1bbe45c11b176d1595007942ef7723c7954c60fe20cc5aa18c4dbab00b6f09ff ./src/api/flows.rs
|
||||
d096000e94997cf9aaf9d3cc567214a78b8b2da7da57a76e2b3519247af6e592 ./src/api/groups.rs
|
||||
a13d4e217fe3ddaa73873ba6e0d1bc93750cf61764d867ff7372274ed21d9599 ./src/api/history.rs
|
||||
b3ed82de9d885a6325647bff3d0ead6ac46fc74d0250d5e14d75319555ad88d7 ./src/api/house.rs
|
||||
88e04a59bfedd5e6c54e6a2938b1031964be3a1505db2ca67d6290aa042cbe3b ./src/api/integrations.rs
|
||||
bb2a746ecdcc2da5fe54e18b455c7bd19453486dd5c008e71951a2c81d0e7d64 ./src/api/middleware.rs
|
||||
50cfd47e44e22cc802f22f97267c157fa5a6d19b51be921a6ca6b1ea7c9799d1 ./src/api/public_settings.rs
|
||||
a1d1a4ac071493b052e6bf20b03be67cf75726de95649e9a73aeba8d22395a15 ./src/api/schedules.rs
|
||||
430f446371489a2b2f1532d589c71f60c98c1a92765ccb5e486b08e2da8f31c3 ./src/api/settings.rs
|
||||
bc1614b9948b8904d0f678c79cc9fffecb75977fbec951aca1ba087527808836 ./src/api/system.rs
|
||||
681cccb8d09f4ad9c2e6467a4dbe2f9d991125775a5d2bfc9043daf940fecd29 ./src/api/websocket.rs
|
||||
923e7434e8f5789150eb34ffc4d18d25fa701f4a9040bf856d7926016f1936aa ./src/api/zones.rs
|
||||
7eaeb0552a51dc34e964a315cc4024e8373622d7b32746010f89881268ccf389 ./src/config.rs
|
||||
c8b8b4a04c94b9d91f5931057d69ad38344730bffbf68e2f6541b59013441b57 ./src/db.rs
|
||||
24580b279cfcd5388ba584eaba2c9bb729b8aae81e77b781c23fb804c5b5fa5d ./src/db/climate.rs
|
||||
3eded80d8dbf13729b2ae1f31baa47aa59c01bcfa550c90874031d25c74d2eac ./src/db/configuration.rs
|
||||
56b0efe84dbf6bd3ee2397b099220a68966311c187f4d5b53007a1693a5ef327 ./src/db/core_devices.rs
|
||||
d8a2b323e864f2ec38ee15e3023be37a6aa05387f1ddbeedafe1f51556ad8c7d ./src/db/device_history.rs
|
||||
f3bd91d0bdb7699f319b9341d928d3cc3b25f759653fcd2095a5c5f695d08fac ./src/db/events_tokens.rs
|
||||
03d689fb03c4f61629fff8404b6cea7101ba3a5358247b16ad714e0be93e9150 ./src/db/flows.rs
|
||||
59912e70b688daf5c4d22679d643ed3b724c262d62466aeaa9ef74db87808178 ./src/db/ha_history.rs
|
||||
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
|
||||
77931af524e9402fc826050b5fcec422d01b02b625c8ce7f2004f1f6520c9b4d ./src/engine/commands.rs
|
||||
ce7e809765c8ad7dc4819b7f775ba7a632031d4d0e68780cc6ea953f2fd890e7 ./src/engine/control_plan.rs
|
||||
38f42c86c31048780ca414f2c328f363ef803a0edbc1e1ed85503909a10b69ae ./src/engine/deadlines.rs
|
||||
d15148382f0c84bdef36750a10b463378e2de6a0fb70eecadeee2b07b8648681 ./src/engine/groups.rs
|
||||
18a7669318949e533f206d25cf7b63771d7f0f2c0b375a72ff78a2d921baaa61 ./src/engine/history.rs
|
||||
4972c199274647bff47122c367e51bae8dab24d7ec4d652288e227a5a64c2310 ./src/engine/local_thermostat.rs
|
||||
ecab67072e756f5a371864282eb470a17de53016593e2e96540674066262c865 ./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
|
||||
986c5ce1d7d5e79975b0089807408109afdef5c5a173b427b58e34f534faf411 ./src/engine/zone_actions.rs
|
||||
900947200973b8e4490600f33738424a89aadf0b59c0316f4cd46deb997730b6 ./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
|
||||
67f189c195426c39669178dd92d8602c519806acca7d325665076720f9fee8a9 ./src/models/automation.rs
|
||||
377fc9697a70a03ad35fc3701adf4dbe8687760dc90de9f5c532a4f3b612c762 ./src/models/control_plan.rs
|
||||
8228b9b6d9e8b1d13de29ce88500beacd88c8877893b7a3e41a4bd1e237ca36c ./src/models/defaults.rs
|
||||
7cdc99a6f0920baf4ac88fbe7e6f1859e8598a00f00b8c3d84f20bc3a4de35f9 ./src/models/device.rs
|
||||
ea84f5230da97447c60dfb4f8dadd0747c92fd1f7a74d5a65d6fdb6f23cf4b6d ./src/api.rs
|
||||
0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs
|
||||
1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs
|
||||
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
|
||||
fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs
|
||||
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
|
||||
ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs
|
||||
8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs
|
||||
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
|
||||
ca5bc21e051222dcaf0c507e84cb3e60f52a0b612a6056847fa0aee3a4d1a672 ./src/api/flows.rs
|
||||
82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs
|
||||
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
|
||||
b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs
|
||||
655bc8135fbe0b8fa491666942e80817a0eabc7dea694c68c2e5ebb30c8ecf75 ./src/api/house.rs
|
||||
4ed7835ed622806124a805f54ffde7c39854f2447de0141d756d96f22446fb90 ./src/api/integrations.rs
|
||||
7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs
|
||||
9d996d326f2ec192c79fb108a8d2c06efaa1c9634d9e9e54fa65a65524f92a75 ./src/api/openapi.rs
|
||||
b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs
|
||||
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
|
||||
fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs
|
||||
7c974a98c72092886247f67c3a1c62736898d4b279ceb9c4c306690a3c6e4294 ./src/api/system.rs
|
||||
3b0124a76dbc654584bcf186d43f89d20b6e7fd980ce0ffbf5a9e8eced5ec7f6 ./src/api/websocket.rs
|
||||
ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs
|
||||
32fa38d4315682cd3ef3ecdc91e571c2729b5e68b6f2bff965e43de3fdd2aeed ./src/config.rs
|
||||
de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs
|
||||
87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs
|
||||
a8fe3636ac79df191e2f2c9d8c1eec1e5181e048ef8c9036cfa18f84aef1a462 ./src/db/configuration.rs
|
||||
5143803ab470f287a587171f1aab96f60653e84cade523cfe104220079727ffc ./src/db/core_devices.rs
|
||||
4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs
|
||||
7bc79d9d33726fbdd33b485c8c2e839cedbb8fe29fee872611da978ed149d6a0 ./src/db/energy_history.rs
|
||||
96aac6510ddda8c18fd7e29802a937b96e8a74badf504980846c80cd7933a4c6 ./src/db/events_tokens.rs
|
||||
bba6e3fb9f4800ae06d0ca67772b06a8c389d308243306719442d0c1f2623420 ./src/db/flows.rs
|
||||
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
|
||||
4fc007c08304e96818ad1d78997a5b2458e31aa9c48ad68b5be2a007533924bb ./src/db/network_history.rs
|
||||
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
|
||||
28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs
|
||||
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
|
||||
0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs
|
||||
f3cc7d2cbf44180372d987d7502409547eadb1c725bcce6eeb5a26cc4277b83f ./src/engine/automations.rs
|
||||
1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs
|
||||
357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs
|
||||
315116a0dbeeba1af2f72513ea5fcc238da1b5ce9501b039c5188e44976978dc ./src/engine/control_plan.rs
|
||||
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
|
||||
519f67ab805f61afea107215f5df50562fa1669a48334cc015570952e25e773a ./src/engine/energy.rs
|
||||
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
|
||||
4ec11426ef860811348f2bf1b458bc5713fe8a7794fb4c8045dd637652e8e696 ./src/engine/history.rs
|
||||
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
|
||||
f684e6755d023478882d1689833f307f3b4cd2c66abf45f8af8315cee94d36ef ./src/engine/ownership.rs
|
||||
76c1594f31af64c9a34e2555ac015e8719d42273c687b4c85b9c8f306b3c69a2 ./src/engine/polling.rs
|
||||
c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/runtime.rs
|
||||
48a496c3935f886a90b90461c38a7aa31ebc40c6482d6feaeb2be47bc2387625 ./src/engine/schedules.rs
|
||||
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
|
||||
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
|
||||
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
|
||||
bf1a3840135a71ce64148559cce0fbd570ff99d31c8972cdea7447a49f6868f7 ./src/engine/tests.rs
|
||||
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
|
||||
87ab87d434a6462a2c3f25249b42c59ecb2bca6ed700ec73e95e4613349cf16f ./src/engine/zone_control.rs
|
||||
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
|
||||
d681f2aa53c39f37411834c300e9200299f94ee4ea021f44f70a3ea1774bd698 ./src/home_assistant.rs
|
||||
bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs
|
||||
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
|
||||
2d36405912646b30b1a1a37a7a4e18c2104ab22e865b593316a609dda884aec2 ./src/influxdb/query.rs
|
||||
67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs
|
||||
62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs
|
||||
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
|
||||
e7b6e49d369aaf665a8aea2464950fd795957de0ad7528a002e11c55c7983f35 ./src/models/automation.rs
|
||||
a7a35e60318a620d411ec16ab8faecd34410ccba3310f612e89c06904a16539b ./src/models/control_plan.rs
|
||||
06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs
|
||||
698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs
|
||||
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
|
||||
cfbaa853a5185c4be572588577b313a2d618837f7c07a3860d0e8d2cca04ee60 ./src/models/history.rs
|
||||
552dfc1717ea88f3a23b1fb7705f1dbb0df7792ca9a6ae7ad7bd275eacef27af ./src/models/integrations.rs
|
||||
268b4211df93f08423e39374ab9ffe93a7eba647c173168821736af299b36549 ./src/models/runtime.rs
|
||||
e77f8cdcb5fc0e51cdbf2527a69585a2f6f9089e159d1e45ec4a48735ab0e304 ./src/models/temporary_thermostat.rs
|
||||
5b5c80e25ae2aad1b511bd3f0a82b8fbae2440abf8c656ecb90814587d0be137 ./src/models/zone.rs
|
||||
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
|
||||
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||
f1f765331469f4219a551414833c19b6de413114c22fdd0468e1f417659553c9 ./src/protocol/gree.rs
|
||||
c935e441933ba8709432cc3ac33ed868043a89188d0f48ea6fbfe34b1f8936a3 ./src/protocol/gree/binding.rs
|
||||
563ded1758b135eb523f1fa0b1bf55fe5430ef55935b301fd6d79cbb980e1820 ./src/protocol/gree/commands.rs
|
||||
5d4328199e2285016fd1941e909e5c09f7cdf9fd21ab6538c1bf8bc2f4af3ba3 ./src/protocol/gree/core.rs
|
||||
287d0ecd9a3a9df2dd5b838463a3802e59b7c0c20a6bbd24682c8fe453b4e276 ./src/protocol/gree/discovery.rs
|
||||
97284ad37767625cfc2b82a3de5c40f6eecddabe185bd0bcea7cf9b165e017f0 ./src/protocol/gree/merge.rs
|
||||
a96061fbbcadac6c2df2e9708a48dcf819b951eb147f32c7d0d285ac5ae2491b ./src/protocol/gree/network.rs
|
||||
45d0cd0ea40b9255bd811362547f5a667dac6bc7324074726130baf60aa41d2b ./src/protocol/gree/polling.rs
|
||||
35fc903404045129e3046b7997e941b88f98ceb42485cccc872c2e4aa530c75b ./src/protocol/gree/tests.rs
|
||||
55c2d615e76c9e4b08c4067ea734e81d81915144e637f3b51c19bea4fc879137 ./src/protocol/gree/transport.rs
|
||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||
6e44df6bde2f73c282095c80ac17287e5bef77d9fc1d344e440d863dae5f5031 ./src/models/history.rs
|
||||
1fee89f939018bf1f3686c15f25434f01ab96b3239164108db7f348a1f1f8d82 ./src/models/integrations.rs
|
||||
17cac849056b677a3872e600a0d99218fb383ad5ec91cde596562df8e2f47508 ./src/models/runtime.rs
|
||||
41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs
|
||||
c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs
|
||||
bc2818ce8d0dae1edfb1b98e5d1c00b193f03eeaa28e343d3532f6023288738c ./src/models/zone.rs
|
||||
4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs
|
||||
7746bca683809b82529ed152272ae5c0cd3971758a02e076def58f770e7adf10 ./src/protocol/crypto.rs
|
||||
578b9f39110abbeb98323044b6ae1a941733c82021afeb4d7ff78ad4256b5aa5 ./src/protocol/gree.rs
|
||||
2029bad472fdf9f73637be6b7168e9ac79f6bdb55d2bf440d4aa254f3db9659b ./src/protocol/gree/binding.rs
|
||||
91d82c3ecae21615e4edad31b7ea95dca9b578650348666a2b61475b5a644669 ./src/protocol/gree/commands.rs
|
||||
4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs
|
||||
f4e07ca05e037e29d08abb1635f2e6d475522ce517647c422e05b749cd648ad9 ./src/protocol/gree/discovery.rs
|
||||
ff33db623f8f1bf30015b4a00e1f247a99de713da3d297c672e42c0795ec845c ./src/protocol/gree/network.rs
|
||||
1076cb80950be00ca19f9e2370c73040902d1483b0e80c0ca349c7830d38c9f7 ./src/protocol/gree/polling.rs
|
||||
61d588dc055ac82f0f06fceac5236d5b4b7a0c0d2939144957a174bcbf2fa83c ./src/protocol/gree/tests.rs
|
||||
3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs
|
||||
b4e285f499dd787b75259777a14e3a51a1a071c7d176bcf30c1de0933920e0dc ./src/protocol/gree_cloud.rs
|
||||
b4b1bad9cad889dd69681b2afc3e6a09ef19e19b2a1f2900fd417c4b1f55ec2c ./src/protocol/gree_cloud_mqtt.rs
|
||||
5f70211b73215d9ae7af8fc306d15dc0b6cfbf0df9f79e34f1c785957fdf8bd4 ./src/protocol/mod.rs
|
||||
a677ce61d3d0f4cf358055ddbc343e73b452c68daed80798499147370819615e ./src/provider.rs
|
||||
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
|
||||
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
|
||||
3fb50f57d30ac23ef122c45dae4bd96c7cb7184df02d99aef00d9890b66479e2 ./src/queries/entities.rs
|
||||
580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs
|
||||
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
|
||||
c91e4b8b250b30cbe76f313b70c16244e9003bce92e02c82283a1831f91974c5 ./src/queries/maintenance.rs
|
||||
3a49bad74a2f047893e726b5e00a6e833c228ad268bdf50cd60f786ba164ea54 ./src/queries/schema.rs
|
||||
7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs
|
||||
7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs
|
||||
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
|
||||
6ee23fcaa62231d15006db078d4f763d1b67d2365fcc2d736d8a7da8df5ea382 ./src/state.rs
|
||||
d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
||||
1d6fa9e291ebbae40a7a8b06a7cef9dbd0ee06d88ec6b664361c37cb6c1aa281 ./web/css/styles.css
|
||||
183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
4f273e189008df110cd3cad1d323e5f07e2651f805419b427811d89fdd04245f ./web/index.html
|
||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js/README.md
|
||||
21b662914590e720dafefe4dc3dccc91dbeb963cce9ae91f007c835d2d0c1fe5 ./web/js/bootstrap.js
|
||||
dee7c4292889ddd721c220e86fae909f0d662b585e6d9c5d9e2f8fa85d9ed2ad ./web/js/charts.js
|
||||
87064e7d504dc2afbc1aa3a1926fcec0cc80b31e2a6a6d931d7af7fc4f519b20 ./web/js/core.js
|
||||
dfc123885a9b6b5c1d07c12d2d2625ff15aa0844e5cd3736092ccd0a360da39c ./web/js/dashboard.js
|
||||
3f5b9a4d62c994edded0ce24fb9ea45141d935a1928d4f68fb67f8ca287bafe1 ./web/js/entities.js
|
||||
cdd11ab0fac33b7b1749b8db4e30486e9d0258b65fe54fd56c3a520b2b72b2d7 ./web/js/events.js
|
||||
d4aea1d6ec595000eaddca3b6f0e3d4003901365ece7788147a6ed207cffcc78 ./web/js/flows.js
|
||||
f06557c6d338259059b990f8f79c91d68ac8ec1f245c302468f08929b5506901 ./web/js/forms.js
|
||||
dbf36223863ba882c03eccea4516ba8db7acf0c2e72c6c64490cb994f1e20f96 ./web/js/history.js
|
||||
7eaa04992ea828dc89f5eaebf674cbbfd160f53eb8d0d6ef0d4e47bd8c28c435 ./web/js/main.js
|
||||
3dcfc24f238ad1310155707268b60c5a415f2a8977ceba48c7e695b7135d5705 ./web/js/navigation.js
|
||||
c56059693d09e35261dc5dd1940289ffc67c82ea7c547750d57e6c79fe75b05b ./web/js/realtime.js
|
||||
c8c82e88c3715b1bfabf155e36266a4c94f5e2fc03eb39dcdd9d08a1985a997c ./web/js/router.js
|
||||
bdeb55546a1dff5e5bdfde4b114d6330f025df1b62e528bf18a93ad6dd4c3e83 ./web/js/settings-ui.js
|
||||
aa6a2ba22648de44547efd09f41c4335ebe38ed564f4684e942d7827df40f1a2 ./web/js/settings.js
|
||||
43f33fc21bb11df0ea149fe7fbca722a57f93890e4d9bb542ced703c1311207e ./web/index.html
|
||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md
|
||||
e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js
|
||||
5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js
|
||||
07629b331bd498e8bea769d577321bbd12d9b076264242706d4ef16165fb22fa ./web/js-dynamic/core.js
|
||||
5e681095a559fafbeca067024afb3705ba114f01fa562d487a5717d12dac0123 ./web/js-dynamic/dashboard.js
|
||||
930e67432f55e8476513e808edfa9a0809a8da7479307284fcc136082a4129a4 ./web/js-dynamic/entities.js
|
||||
4612c958d7b9ccf89d6d45a11c3bc7e864bbd2ceaccc16fd7d76811ba4c74196 ./web/js-dynamic/events.js
|
||||
43c78a3871cfdb0382081ec30b8d48650450c71b44d006414b161349ac1fa659 ./web/js-dynamic/flows.js
|
||||
3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js
|
||||
92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js
|
||||
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js
|
||||
30cbaebb817d5d416e1a372595b647aae42c14b9cc406c4751d0ced563243b69 ./web/js-dynamic/navigation.js
|
||||
3ff9c027beb8fb033330bf3cfb3496e06fb38c42041f1ffe522c6c7af18e9e30 ./web/js-dynamic/realtime.js
|
||||
1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js
|
||||
c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js
|
||||
bc42b4a0dd0d20239ce22ea1dff09b3f94e8c0e1395e01251a4585a051968a8f ./web/js-dynamic/settings-ui.js
|
||||
672e404470bc6331aa6d658523b221659ec64222af9cc9c17ced978f725790da ./web/js-dynamic/settings.js
|
||||
489ab0208c86f7a2bb0d721106f01fb5d4f39f41e94749b440a6c58e13c4de07 ./web/js/custom-chart.js
|
||||
6ae536b18db756bf5eda9adb1b8a550d5a02bfc80f0b7da27fa146039f22e884 ./web/js/lang-init.js
|
||||
e3fc113d4c349f33f3faa32e7a46c96a85c71643f43f2e488065dc9715e27333 ./web/js/sw.js
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/js/theme-init.js
|
||||
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
||||
11034c405898fe345b308cfb7da094c1dd995576c138241e19b29b063b51e6ba ./web/styles.css
|
||||
ae1b03f30b494f474a781a5d32072f1e73eba2b7c768109fc1cc8028cb6662a4 ./web/sw.js
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
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.4**
|
||||
**Version: 0.15.17**
|
||||
|
||||
Version 0.15.17 hides the Global power emergency STOP while the system is idle, keeps the green return-to-normal action visible whenever STOP is active, and shows the source Flow name for Flow-generated entries in Recently triggered automations.
|
||||
|
||||
> [Full API reference](docs/API.md) — authentication, every endpoint, request bodies, response models, WebSocket events and examples.
|
||||
> Built-in API: `/api/*` and `/ws`; interactive Swagger docs: `/api-docs`; OpenAPI JSON: `/api-docs/openapi.json`.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -25,8 +28,8 @@ The interface is intentionally split by responsibility:
|
||||
|
||||
| Area | Purpose |
|
||||
| --- | --- |
|
||||
| Dashboard | Whole-house summary and Quick Thermostat controls. |
|
||||
| Devices | Discovery, binding and direct/technical GREE device control. |
|
||||
| Dashboard | Whole-house summary, Quick Thermostat and full manual GREE unit control. |
|
||||
| Devices | Technical unit configuration, binding, connection diagnostics and live ping. |
|
||||
| Zones | Thermostat configuration, profiles, sensors and control ownership. |
|
||||
| Groups | Climate group membership plus optional group-level mode/preset/custom-temperature control. Group ON activates member thermostat control; Group OFF powers member units down and releases group ownership so individual thermostats are not left group-blocked. |
|
||||
| Flow | Full-screen visual logic editor. Flow is the source of truth and is automatically compiled into generated schedules and automations. |
|
||||
@@ -122,8 +125,9 @@ Environment values explicitly supplied for supported runtime overrides win over
|
||||
| --- | --- | --- |
|
||||
| `GREE_CONTROLLER_BIND` | `0.0.0.0:8787` | HTTP/WebSocket listen address. |
|
||||
| `GREE_CONTROLLER_DATABASE` | `./data/gree-controller.db` | SQLite database path. |
|
||||
| `GREE_CONTROLLER_APP_TOKEN` | empty | Optional administrator API/Web UI token. Empty means trusted-LAN mode. |
|
||||
| `GREE_CONTROLLER_APP_TOKEN` | empty | Administrator API/Web UI token. Empty means trusted-LAN mode in standalone installs; with HA Supervisor auth active, empty disables direct dashboard/API access on the exposed port. |
|
||||
| `GREE_CONTROLLER_BASE_PATH` | empty | Optional reverse-proxy prefix such as `/gree`. |
|
||||
| `GREE_CONTROLLER_PUBLIC_CHART_BASE_URL` | empty | Optional absolute HTTP(S) base URL used only when generating public Custom Chart links; useful behind a reverse proxy or alternate external port. |
|
||||
| `GREE_CONTROLLER_ID` | `gree-controller` | GREE client/controller identifier. |
|
||||
| `GREE_CONTROLLER_SIMULATE` | `false` | Initial Simulation mode. |
|
||||
| `GREE_CONTROLLER_AUTO_SEED` | `false` | Seed simulated sample data when applicable. |
|
||||
@@ -137,16 +141,74 @@ Environment values explicitly supplied for supported runtime overrides win over
|
||||
|
||||
Additional environment variables cover history retention, debug, night mode, Home Assistant and InfluxDB. See [`.env.example`](.env.example).
|
||||
|
||||
## Local vs GREE Cloud
|
||||
|
||||
Each device has an explicit `connection_type`:
|
||||
|
||||
- **Local** uses the existing GREE LAN protocol over UDP/7000, including discovery, bind, status and commands. It never calls GREE Cloud.
|
||||
- **GREE Cloud** uses the configured account plus MQTT/TLS. It never performs UDP discovery, bind, probe or commands.
|
||||
|
||||
The same physical air conditioner may intentionally be added twice, once as Local and once as GREE Cloud. The controller does not automatically fail over between transports, preventing duplicate commands.
|
||||
|
||||
## GREE Cloud setup
|
||||
|
||||
Open **Settings → GREE Cloud**, enable the provider, select the same region used by the official GREE app, enter the account login/email and password, then use **Test connection**. After a successful test, use **Refresh devices** and explicitly add the units you want to control through Cloud. Credentials are account-level settings; they are not copied into every device.
|
||||
|
||||
The password field is write-only from the UI: a saved password is reported only as configured/not configured. The installation UUID used by the GREE Cloud User-Agent is generated once and persisted.
|
||||
|
||||
## Cloud status
|
||||
|
||||
Cloud devices distinguish `online`, `offline`, `cloud_disconnected`, `authentication_error` and `unknown`. Account/MQTT state is shown separately so an offline air conditioner is not confused with a broker, credential or internet failure. Device diagnostics include safe broker/topic/status information and decrypted/sanitized properties, never authentication tokens or cipher keys.
|
||||
|
||||
## Cloud polling and synchronization
|
||||
|
||||
MQTT push is the normal synchronization path. REST login is reused for the MQTT session and is not repeated for every status request. Polling is used for initial state, recovery, fallback and periodic verification; the configurable Cloud interval is clamped to at least 30 seconds. MQTT reconnect uses bounded backoff and restored subscriptions. A Cloud outage does not switch a device to LAN and does not block Local polling/control.
|
||||
|
||||
## Energy
|
||||
|
||||
Energy can come from either **GREE Cloud** or a cumulative Home Assistant energy sensor. Devices with both sources can select **Auto**, **GREE Cloud** or **Home Assistant**. Home Assistant candidates are limited to `device_class=energy`, `state_class=total|total_increasing` and `Wh`/`kWh` units.
|
||||
|
||||
GREE `ElcAll` is a cumulative counter in tenths of a kWh and is normalized to kWh. Cumulative counters are converted to per-sample consumption deltas before history aggregation. The first sample establishes a baseline; duplicate samples consume zero; a falling/reset counter creates a new baseline; negative consumption is never stored. Energy history is separate from temperature history and can be aggregated hourly, daily, weekly or monthly, with Today, Yesterday, Current month, Previous month and Period total summaries. Older energy samples can be archived to InfluxDB using the same retention policy as other metrics.
|
||||
|
||||
## GREE Cloud security
|
||||
|
||||
Cloud REST and MQTT use TLS with certificate/hostname validation enabled. Passwords, REST tokens, MQTT credentials, Authorization headers and device cipher keys are excluded from normal API responses, Cloud diagnostics and logs. Network, connect and command operations have bounded timeouts; retries/reconnects are rate-limited rather than tight-looped.
|
||||
|
||||
## Limitations
|
||||
|
||||
- GREE Cloud availability and behavior depend on GREE's external services and the selected account region.
|
||||
- The Cloud status protocol does not expose a universal buzzer capability. When **suppress device beep** is enabled, Cloud commands follow `greeclimate` semantics and include `Buzzer_ON_OFF=1`; models that ignore this command-only field simply continue without a separately advertised buzzer capability.
|
||||
- Capability detection is based on discovery/model data plus properties actually returned by the device. Controls are hidden or rejected when support is known to be absent.
|
||||
- There is intentionally no automatic Local↔Cloud fallback.
|
||||
|
||||
## Connecting physical GREE units
|
||||
|
||||
1. Place the controller host on a network that can reach the air-conditioner Wi-Fi modules by UDP.
|
||||
2. Open **Devices** and start discovery.
|
||||
3. Auto protocol mode is recommended; the controller accepts both supported GREE encryption generations.
|
||||
4. Newly discovered devices are bound automatically when possible. Manual bind is also available.
|
||||
3. Auto protocol mode accepts both supported GREE encryption generations. Discovery treats the reply envelope only as transport, uses the inner `ver` field as a V1/V2 hint, and verifies the real generation during bind with fallback. Selecting V1 or V2 keeps discovery and bind locked to that generation.
|
||||
4. Review the discovery results (model, MAC and IP), select the units you want, then choose **Add selected**. Only selected units are persisted and bound.
|
||||
5. Create a thermostat zone for each unit you want the thermostat engine to own.
|
||||
|
||||
Discovery uses UDP broadcast, so routed/VLAN networks must explicitly permit or relay the required traffic.
|
||||
|
||||
### Compatibility with newer GREE firmware
|
||||
|
||||
GREE Controller supports the two commonly reverse-engineered local LAN protocol generations used by compatible units: the older AES-ECB variant and the newer AES-GCM variant. Some AES-GCM-capable modules still answer the common discovery scan through a legacy/plain envelope, so the envelope itself is not used as proof of V1. Auto mode uses discovery metadata as a hint and records the protocol that actually succeeds during bind.
|
||||
|
||||
Some newer GREE Wi-Fi modules and firmware releases have been reported to behave differently: the unit is reachable on the network and continues to work in the official GREE+ application, but does not answer the usual local UDP traffic on port `7000`. Reports include firmware branches such as `2.12` and `3.x`, although firmware numbering differs between Wi-Fi module families and should not be treated as a universal compatibility boundary.
|
||||
|
||||
For such a unit, failure to discover or bind is not necessarily an encryption problem. If the device sends no response at all to UDP/7000, changing between AES-ECB and AES-GCM cannot restore communication. The current evidence suggests that at least some newer modules may rely primarily on an outbound connection from the device to GREE cloud services instead of exposing the legacy local UDP/7000 interface. It is still possible that particular models use another LAN protocol, port or activation handshake; this has not been confirmed.
|
||||
|
||||
A useful diagnostic distinction is:
|
||||
|
||||
- **UDP/7000 replies are present, but bind/decryption fails** — likely a protocol, key or binding issue.
|
||||
- **UDP/7000 replies use an unexpected payload** — potentially a newer local protocol variant.
|
||||
- **The unit has an IP address and works in GREE+, but sends no UDP/7000 reply** — likely not fixable by changing the existing encryption mode; the local LAN API may be disabled or replaced.
|
||||
|
||||
Some newer GREE models can also expose energy-consumption information in GREE+. This is a separate capability and does not prove that the legacy UDP API is available. GREE Controller currently does not read or expose energy/power-consumption telemetry from the unit.
|
||||
|
||||
If a newly manufactured or recently updated unit cannot be discovered, capture its model, Wi-Fi module model, firmware version and GREE traffic diagnostics before reporting the issue. Avoid assuming that a higher firmware number alone identifies the protocol generation.
|
||||
|
||||
### Multi-NIC / dedicated GREE interface
|
||||
|
||||
For a host with separate management and GREE networks, set for example:
|
||||
@@ -166,8 +228,8 @@ The controller will use that interface for GREE UDP traffic while keeping the HT
|
||||
Diagnostics:
|
||||
|
||||
```bash
|
||||
sudo ./scripts/configure-gree-network.sh
|
||||
./scripts/network-debug.sh
|
||||
sudo ./scripts/configure-gree-network.sh eth1
|
||||
./scripts/network-debug.sh eth1
|
||||
```
|
||||
|
||||
The hardened systemd unit allows `AF_NETLINK`, which is required for interface discovery on multi-NIC Linux/LXC systems.
|
||||
@@ -209,7 +271,7 @@ Schedules select profiles or a custom target for selected ISO weekdays (`1=Monda
|
||||
|
||||
The reusable Flow preset library is stored as standalone JSON files in `presets/`. Each file contains its name, description and complete Flow graph, while the UI loads the embedded preset catalog dynamically instead of hard-coding graphs in JavaScript. The library has category tabs, search, local favorites/recent presets, a graph preview and requirement checks before applying a preset.
|
||||
|
||||
Available blocks include weekday/time/date ranges, the application Night mode, outdoor/device/zone temperature, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability checks, a constant diagnostic source, AND/OR/NOT, thermostat actions, direct GREE actions and group actions. The GREE action uses the existing `DeviceCommand` fields (power, mode, target, fan, swing, quiet, turbo, light, air, xfan, health and sleep). Multiple branches can feed an action; the compiled runtime preserves the graph logic.
|
||||
Available blocks include weekday/time/date ranges, the application Night mode, outdoor/device/zone temperature, house/device/zone/group state, arbitrary Home Assistant state/numeric/attribute/availability checks, reusable Shared Inputs, a constant diagnostic source, AND/OR/NOT, thermostat actions, direct GREE actions and group actions. The editor supports keyboard-first block search/creation, save, multi-select, copy/cut/paste/duplicate, delete, movement, `Ctrl/Cmd+Z` undo and canvas zoom/fit with an in-app shortcut reference. Shortcut success feedback stays inside the editor, and right-click exposes context actions for blocks, the canvas and connections. Saved Flows can also be exported directly from the Flow list. Flow exports keep the existing format version and carry definitions of referenced Shared Inputs, so import can create missing sources or safely remap ID collisions. The GREE action uses the existing `DeviceCommand` fields (power, mode, target, fan, swing, quiet, turbo, light, air, xfan, health and sleep). Multiple branches can feed an action; the compiled runtime preserves the graph logic. The editor keeps the Interpretation / Flow cycle details collapsed until requested.
|
||||
|
||||
Quick preset/setpoint overrides normally hand control back at the next schedule boundary. Temporary Quick Thermostat adds explicit start/finish rules such as duration, exact time, temperature reached/stable or next schedule boundary.
|
||||
|
||||
@@ -221,31 +283,36 @@ There are two independent Home Assistant directions.
|
||||
|
||||
### 1. Home Assistant as a temperature source
|
||||
|
||||
Configure **Home Assistant / Sensors** in the Web UI or use:
|
||||
In the Home Assistant add-on, the connection to Home Assistant Core is automatic through the Supervisor API proxy; no Home Assistant URL or Long-Lived Access Token is required.
|
||||
|
||||
When the add-on detects `SUPERVISOR_TOKEN`, Home Assistant ingress remains trusted, but direct Web UI/API access on TCP `8787` requires `app_token`. If `app_token` is empty, direct dashboard/API access is disabled. The Home Assistant add-on intentionally uses `8787` as a fixed internal ingress/application port; it is not a user-configurable Network option. The startup script verifies the Supervisor-reported `ingress_port` and refuses to start on a mismatched manually modified package. History → Custom Charts → Copy link creates a random persisted share token and returns a chart-only URL. The add-on resolves the primary HA host IPv4 through the Supervisor API and uses `http://<HA-IP>:8787` by default; `public_chart_base_url` can override that address for a reverse proxy or non-standard routing. Standalone mode uses the current controller origin/base path and follows `GREE_CONTROLLER_BIND`. Only the generated chart view/data endpoint is public to the network.
|
||||
|
||||
For standalone installations, configure **Home Assistant / Sensors** in the Web UI or use:
|
||||
|
||||
```text
|
||||
HA_URL=
|
||||
HA_TOKEN=
|
||||
HA_ENTITY_ID=
|
||||
HA_OUTDOOR_ENTITY_ID=
|
||||
HA_SENSOR_STALE_AFTER_SECONDS=300
|
||||
HA_ALLOW_INVALID_TLS=false
|
||||
```
|
||||
|
||||
Per zone, `sensor_source` can be:
|
||||
In standalone mode, the Home Assistant connection test checks only `HA_URL` and `HA_TOKEN`; no entity is required. `HA_OUTDOOR_ENTITY_ID` is the optional global outdoor-temperature sensor. Per zone, `sensor_source` can be:
|
||||
|
||||
- `device` — GREE indoor sensor,
|
||||
- `home_assistant` — configured HA room sensor,
|
||||
- `combined` — weighted GREE + HA value.
|
||||
- `home_assistant` — Home Assistant room sensor,
|
||||
- `combined` — weighted GREE + Home Assistant value.
|
||||
|
||||
Stale/unavailable external data falls back to the GREE sensor when possible. The optional outdoor sensor is only an assist signal; it never replaces room temperature.
|
||||
For `home_assistant` and `combined`, each zone must set its own `ha_entity_id` room-temperature sensor. Room sensors are independent from the outdoor-temperature source and stale/unavailable room data falls back to the GREE sensor when possible.
|
||||
|
||||
`HA_OUTDOOR_ENTITY_ID` is the global outdoor-temperature source used by outdoor-temperature assist. A zone may leave `ha_outdoor_entity_id` empty to use that global sensor, or set `ha_outdoor_entity_id` to use a different outdoor sensor only for that zone. If a zone override is unavailable, the controller falls back to the global outdoor source. Global and per-zone outdoor sensors are exposed through the normal alias/history/metric paths and appear in Home Assistant entity suggestions used by Visual Flows.
|
||||
|
||||
### 2. GREE Controller entities inside Home Assistant
|
||||
|
||||
Bundled integration directory:
|
||||
|
||||
```text
|
||||
home-assistant/custom_components/gree_controller/
|
||||
ha-addon/home-assistant/custom_components/gree_controller/
|
||||
```
|
||||
|
||||
Copy `gree_controller` to Home Assistant's `custom_components` directory, restart Home Assistant and add **GREE Controller** from Integrations.
|
||||
@@ -260,7 +327,7 @@ For entity-ID migration tooling:
|
||||
python3 scripts/generate_ha_migration.py --help
|
||||
```
|
||||
|
||||
The generated mapping example is under `home-assistant/generated/`.
|
||||
The generated mapping example is under `ha-addon/home-assistant/generated/`.
|
||||
|
||||
## History and InfluxDB
|
||||
|
||||
@@ -306,7 +373,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,13 +436,24 @@ 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:
|
||||
|
||||
**[docs/API.md — complete API reference](docs/API.md)**
|
||||
|
||||
Public health check:
|
||||
Interactive API documentation is built into the controller:
|
||||
|
||||
- Swagger UI: `http://127.0.0.1:8787/api-docs`
|
||||
- OpenAPI 3.1 JSON: `http://127.0.0.1:8787/api-docs/openapi.json`
|
||||
|
||||
Swagger UI includes endpoint descriptions, authentication schemes, request/response models,
|
||||
examples and documented error responses. If `GREE_CONTROLLER_BASE_PATH` is configured, prefix
|
||||
both paths with that base path.
|
||||
|
||||
Health check (public in standalone mode; in Supervisor mode anonymous only from the Supervisor watchdog, otherwise protected by `GREE_CONTROLLER_APP_TOKEN`):
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8787/api/health
|
||||
@@ -393,16 +471,17 @@ curl -H 'Authorization: Bearer YOUR_TOKEN' \
|
||||
```text
|
||||
src/ Rust backend, GREE protocol and thermostat engine
|
||||
web/ Embedded Web UI / PWA
|
||||
web/js/ Frontend JS source modules bundled by build.rs
|
||||
web/js/ Standalone/static frontend scripts
|
||||
web/js-dynamic/ Application JS modules bundled by build.rs
|
||||
lang/ Runtime language packs
|
||||
home-assistant/ Home Assistant custom integration and migration output
|
||||
ha-addon/ Home Assistant add-on, custom integration and release tooling
|
||||
scripts/ Development, install, update, service and diagnostics
|
||||
systemd/ Production service unit
|
||||
docs/API.md Complete API documentation
|
||||
.env.example Environment reference
|
||||
```
|
||||
|
||||
The application is intentionally self-contained: static assets and language packs are embedded into the binary at build time; SQLite is the default data store; no external frontend build chain is required. `build.rs` concatenates the ordered files from `web/js/` into one generated application bundle and exposes it under a content-hashed URL.
|
||||
The application is intentionally self-contained: static assets and language packs are embedded into the binary at build time; SQLite is the default data store; no external frontend build chain is required. `build.rs` concatenates the ordered files from `web/js-dynamic/` into one generated application bundle and exposes it under a content-hashed URL.
|
||||
|
||||
## Validation before release
|
||||
|
||||
@@ -424,6 +503,6 @@ Flow reuses the existing thermostat, group and device-control domains instead of
|
||||
|
||||
The editor supports optimistic revisions, so saving an older copy from another browser/tab returns HTTP 409 instead of overwriting a newer graph. Flow compilation/replacement is serialized with configuration, automation, schedule and thermostat-cycle locks, and generated schedules/automations plus the Flow source are replaced in one SQLite transaction. Runtime condition evaluation happens before the automation execution lock; after acquiring it the rule is reloaded and stale snapshots are discarded. Same-cycle actions claim their target devices deterministically, and Flow/device thermostat writes take the schedule -> thermostat-cycle -> zone -> device path. Home Assistant read failures fail closed instead of making NOT/neq logic accidentally true.
|
||||
|
||||
Diagnostics include a non-mutating dry-run endpoint/UI with a selectable simulation time, optional per-block sensor/state overrides, a per-node condition trace and an ownership/block reason. Shared Home Assistant Flow inputs can also be tested directly in their editor against the current HA entity state. Flow-scoped execution and dry-run events can be viewed from the editor.
|
||||
Diagnostics include a non-mutating dry-run endpoint/UI with a selectable simulation time, optional per-block sensor/state overrides, a per-node condition trace and an ownership/block reason. The Flow list also exposes a simpler scenario simulator beside the editor button: it uses live values by default and reduces the result to clear will-run / will-not-run / blocked outcomes with readable condition checks. Shared Home Assistant Flow inputs can also be tested directly in their editor against the current HA entity state. Flow-scoped execution and dry-run events can be viewed from the editor.
|
||||
|
||||
Individual Flows can be exported/imported as versioned `gree-controller-flow` JSON. Generated schedules and automations are intentionally excluded from the portable document and are regenerated from the source graph on import. Incomplete Flows can also be saved as disabled drafts; drafts preserve the editor graph but intentionally generate no schedules or automations until completed and saved normally. Shared inputs show which Flows reference them and link directly to those editors. The preset library includes 37 categorized scenarios for comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic.
|
||||
Individual Flows can be exported/imported as versioned `gree-controller-flow` JSON. A saved Flow can be exported directly from its card in the Flow list without opening the editor, and its description can be edited there through a quick modal; the editor export remains available for saved and unsaved drafts. Generated schedules and automations are intentionally excluded from the portable document and are regenerated from the source graph on import. Incomplete Flows can also be saved as disabled drafts; drafts preserve the editor graph but intentionally generate no schedules or automations until completed and saved normally. Shared inputs show which Flows reference them and link directly to those editors. The preset library includes 37 categorized scenarios for comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic.
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
4051d2de7fa9858545582dc6bb5a411c980a282d0b25aa3766df88597442f94a ./Cargo.lock
|
||||
4bf5b286ffb52b22a32ef3d2d5e4dfaae600c1be01c1cac0b87c7138ecb4a129 ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
9a429e8b766c66061e85ccd535cc1429fe1284d14fcf75daa362fea724e0bf85 ./README.md
|
||||
29e9821e5ed216ea3565dd6d8bc0b174ed3cf9fa72c4ccebbfa99e98db3ad8f9 ./build.rs
|
||||
3c1fce0f3097e7860f8ccf6a79749af4e7e4bd43e612c6419d91c0e620480506 ./docs/API.md
|
||||
43af7941ec4d427f9db7ab3cc6baf3d182a72dba7a6791f14094fee138324d8d ./docs/FLOW.md
|
||||
3ef573d60de5dcba3fdb4004df53ea170440cbeae2e244a1f6acfcb3255b7c78 ./docs/openapi.json
|
||||
77b8becf7a2c16a384ea85721d878b76e2d520549cb4455d9aa806e57e5a2ecd ./ha-addon/.env.example
|
||||
0ca6e58170c2b8925194431ebbdb55f0c8b377fec5cd422cd2bd257e2d9328e3 ./ha-addon/Dockerfile
|
||||
0951696a2c2e66d4d969f8e7f8641184e3012f261583b31074bc38a7a1c0cc1a ./ha-addon/README.md
|
||||
a4596a70e50acfe698a8ae1dcb9bebd04adba95b69781218fca7add82c17703e ./ha-addon/build.sh
|
||||
7dd2534a6f8405e640e28b7cb0e94270cc5f01fad8a972193ec254befe8a96b9 ./ha-addon/home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./ha-addon/home-assistant/custom_components/gree_controller/__init__.py
|
||||
3f6ef15ef58456376ac53fde7cace1ef359d6a6f7a64c5b575ad77ea6e55ccc4 ./ha-addon/home-assistant/custom_components/gree_controller/api.py
|
||||
da2a0a9390ab75cef150cd281bc329fa409d0d28f51382b36bd8de3a73325c16 ./ha-addon/home-assistant/custom_components/gree_controller/climate.py
|
||||
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./ha-addon/home-assistant/custom_components/gree_controller/config_flow.py
|
||||
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./ha-addon/home-assistant/custom_components/gree_controller/const.py
|
||||
6509286c7b7de638dcc5d40ce53181e65d1eddc5dc36a13306dc6d87220fedbb ./ha-addon/home-assistant/custom_components/gree_controller/coordinator.py
|
||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./ha-addon/home-assistant/custom_components/gree_controller/entity_map.py
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./ha-addon/home-assistant/custom_components/gree_controller/icon.png
|
||||
5aea34a2adb03ed46a93199e943d7820e34825658fefc3d7c3eb5267ac9066b0 ./ha-addon/home-assistant/custom_components/gree_controller/manifest.json
|
||||
c52a484b671ce738ecc00228a1db19a5d703f69ab27530c2896a5c2ae3b4a96f ./ha-addon/home-assistant/custom_components/gree_controller/number.py
|
||||
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./ha-addon/home-assistant/custom_components/gree_controller/select.py
|
||||
cca65482e36d48035aca178121a378fe7d578d600acff267ae81a6399c0da653 ./ha-addon/home-assistant/custom_components/gree_controller/sensor.py
|
||||
338a42662e77f91215150851e55bbd39a5c77f5612cc77f3e037c06a6e6bdadb ./ha-addon/home-assistant/custom_components/gree_controller/switch.py
|
||||
a745cc458c235158d5d70b6a33d8000fd8b40ff93bfe2f2796af0f4b3a9eef9b ./ha-addon/home-assistant/custom_components/gree_controller/translations/en.json
|
||||
50b7d6fe6329ad4ea6bee9612ab141c2f73f8382fcc2af0bb076a8e8d4dd4465 ./ha-addon/home-assistant/custom_components/gree_controller/translations/pl.json
|
||||
c5fc5c87273d82d2834a0a5a365c821a416413ed509613441e24e4dc3507c4d2 ./ha-addon/home-assistant/generated/gree_controller_entities.example.json
|
||||
69d6cf26824f8851cdf076f87c7270d6f87717a2820b68b57a82beed53d2c736 ./ha-addon/repository/gree-controller/CHANGELOG.md
|
||||
50b8ba050c6b4919e4d057f7b38c1e23d3913b5b7c87552626452e9695d37251 ./ha-addon/repository/gree-controller/DOCS.md
|
||||
d3d0e54672751a3400021ffd1fdcc83a9a903be72cff32cdc3607a61aec0a31e ./ha-addon/repository/gree-controller/README.md
|
||||
c591ab21837150e313407a34a5a3758302aaafd91cd5fcebe18bb5358b702433 ./ha-addon/repository/gree-controller/config.yaml
|
||||
3aae6cdae4c3aaab7786a9575e7093b7e2c5b69288167075f4b7475611691a6b ./ha-addon/repository/gree-controller/topologia-ha-vlan-gree-pl.png
|
||||
0d0b42a7639b128946eec379175436fa1f150294f39d2dfd169077bfa87fd14d ./ha-addon/repository/gree-controller/topology-ha-vlan-gree-en.png
|
||||
2272740b185e9520e948f74b8f45cf98b8b5fd28c93bdee760da626f91fe5c2a ./ha-addon/repository/gree-controller/translations/en.yaml
|
||||
e6b93d3701d83a31506617a0e6f44ff32d8c24a52be038c8540c862b13546b50 ./ha-addon/repository/gree-controller/translations/pl.yaml
|
||||
2153b230357aab4766398577e23a7a9ff83bb135ddc86d1b3f2cbfc852efe0a1 ./ha-addon/repository/repository.yaml
|
||||
cea12bc74c066a588a5ef0ef7bad9d4906bdbcea99f2d275429b0eefc94b4b5f ./ha-addon/run.sh
|
||||
081e27b6e43070d44f865335c30a42a5a29668eaa6a5ea5ab6967ac4447b8e06 ./ha-addon/sync-repository.sh
|
||||
f9e31de3d109ead32bf531c074d46a087df398d62488caee0b46b5e0a1c027f7 ./ha_addon.md
|
||||
253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md
|
||||
859875af2aad9929fe6ecfb80244c8d09182345edd99663e54b04910268e902e ./lang/en.json
|
||||
7139d82eb65341e72ad978be3f57efbe04228b2101b19f792d665d146697d24f ./lang/pl.json
|
||||
d8459024f04ca514bd8e9d6bd3af872fb942fd85a7cdd5583f4a9d28aab6faba ./make_zip.py
|
||||
b14233a8987e53bbbdd6770386ba10fa166ec25c1e098275c173c544b37846fc ./presets/bedroom_window_night.json
|
||||
1960841119c0b673fb2f03621b32237552fd828045304cd221401214b997c910 ./presets/device_resilience.json
|
||||
c5276fecc6a33c5de912e5a73b0a829d4e18c18d57b9c0ee49179ef96f133fc7 ./presets/dual_threshold_control.json
|
||||
aa190f51cb20bd0f3749c080e39b4182d8d843db51d9bf7c2315528f67b45a7e ./presets/energy_price_eco.json
|
||||
19d001dc0703dccf9c9ba3c78d866afafae94591ddb6d0d9e2d8c7703eabeea2 ./presets/frost_guard.json
|
||||
a86718e22f79769b40b363f32ec669bf77974e6a6ade081ed4b90792d5803ef7 ./presets/ha_attribute_mode_guard.json
|
||||
cc0d35fb5ad4bd52f1a8a122745bbda19922119cec48338f391f8c0619dab896 ./presets/ha_boiler_supply_boost.json
|
||||
515b2580221fb38975c7f1ac46f267af7d8ac418efd52d30b40d5ce3e4fae1d2 ./presets/ha_external_heat_source_assist.json
|
||||
07907f47c6c5c8dbd142b161135eae123889fb8845cd846fa7d1b05eda7b0740 ./presets/ha_external_heat_source_off.json
|
||||
349df3ee451da70958529bf6c8e8c09e717a26f69fb56e9d6b73831087055966 ./presets/ha_gas_backup_heat.json
|
||||
6fddede93fcb97913139791e6a75fb62322f425c59365b48bd8bb32a48c4ddee ./presets/ha_gas_heating_boost.json
|
||||
40e6bb0a22e29cc9e32844b90eafffb753df4c368ab55b5112c5bf25593491da ./presets/ha_gas_heating_off.json
|
||||
b6dce178fb517509f31e3ce58f6fa512d038ffcea98fae10dd41844b1d954144 ./presets/ha_gas_heating_reduce.json
|
||||
eb95423754fc798b0b5db01757f0fbe97f07bae9f16a95f5ca51ce379809ab7a ./presets/ha_heating_demand_follow.json
|
||||
0e34f595cebea8662646293b75e7422ac2b01c92fe75f152d71b13f6c8fb53d1 ./presets/ha_thermostat_idle_fallback.json
|
||||
e8946176bf86975c04ef40e82565d7f7a4c171bcf8b245c001121ed8aae938c9 ./presets/ha_window_guard.json
|
||||
a87a919d73003f774becc2d48aab646c21af43e50ceb54577cd37f26606a1674 ./presets/humidity_guard.json
|
||||
e78a5a9c02bc47fa6530aad3803581b10b3a0676705cb41f9d003b4da455aa14 ./presets/mild_weather_eco.json
|
||||
c3c10990e992948c2792bb71fa6606e616abb2a5455e1f11d9209c2f05132b86 ./presets/morning_boost.json
|
||||
664afa80d27130799899e54f5a2152a27955e6e2d8c035c2a594c8da6bd902a9 ./presets/multi_room_group_guard.json
|
||||
7518e0c694208c6ff69209edf8eec605fcf9e9abe8130d092c8c89524d7a24b3 ./presets/nested_guard.json
|
||||
863e557fe9c798a5aaa175f29d71752e148bb8d49910485d738bba00671d1198 ./presets/night_group.json
|
||||
c675cf84430f3c2d058c69dbfea1b5c6fcd82e927d53a301b3e517409a324f9a ./presets/night_quiet.json
|
||||
3b87def6c5fdd7f50cd9e6003bd8e0628b1365e1de8bbc414d5a65d1d7d76298 ./presets/occupancy_weather_matrix.json
|
||||
2360e1939cb46352042bd914a1a125fc98252ea80b68c89cc6ce3f5f197cc464 ./presets/offline_safe_off.json
|
||||
0f00f10c2d11984827738cd2910aa24910acbc8f36e754627a76fd5a0aeef7eb ./presets/overheat_guard.json
|
||||
f118ab957389bc4a47fe8c36b2911fb717a23970ec97d14c37a65f65eb479fc5 ./presets/peak_power_guard.json
|
||||
dfb2f5f382a7f83b7a6fe475a26bae73a4510a46e1e7949a99ac752e39f58b4b ./presets/presence_eco.json
|
||||
8e40d01a07911455c3c6607ba3435488fd6e520bebfc84cbf5eaad481883a4ed ./presets/sensor_availability_guard.json
|
||||
45183d75ce254315cd4032acd42fbb0d505df0e7ab1fefecad931a74db2f43c4 ./presets/sleep_temperature_guard.json
|
||||
7e819f2976ff48d89ccb636d08439d6e9736e7e95837c150dca157fb5bc78cb1 ./presets/smart_demand.json
|
||||
6d16f0a35b023afccd0f2011056ed8b15b5e5d2a3be6a52d122733aac862e85d ./presets/thermostat_enabled_guard.json
|
||||
fffa18bd989a0dcc1c5458975522517d40af4af40cc1ca30640e79d3db711053 ./presets/unoccupied_shutdown.json
|
||||
993b8e8151647cbda6e62312266d37d89178b3a4bb13f7df0950840e1ce647a8 ./presets/weather_comfort.json
|
||||
ae21459a261712bcb8d57594528b1648432e8d03a8a38502234829c0dbfef774 ./presets/weekend_comfort.json
|
||||
fdcd9a5055d08037278b842e7ab69265345c5811f0a06867136c511d140bb191 ./presets/window_available_guard.json
|
||||
804f22123cd3e8db0fac791826c8dd9f758fb866c8e3b5655fb6d25d259dccf1 ./presets/workday_comfort.json
|
||||
fd7390d64a2378352c09382accf6efff43903d952d111b22a982f06c5b2a3b4b ./regenerate-sha.sh
|
||||
c64b1c6deeb24f20af662bfce7fa3683703914987241107ce664fa2f6d1a9dcb ./scripts/README.md
|
||||
5969f2442a8d61ac5b1303f4cd6d8dcfe80596ef5427a513ae3addb126bf0273 ./scripts/api_dev_test.py
|
||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./scripts/common.sh
|
||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh
|
||||
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./scripts/dev.sh
|
||||
3fe88e64e43d380c56955f577992ea5b0252c05280f6e9dc3cd0bbcc0bf00896 ./scripts/generate_ha_migration.py
|
||||
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh
|
||||
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./scripts/install.sh
|
||||
50cc08e2aeff052a47c68212524f4584e9201cfc535d71803f276cdc223d5216 ./scripts/live_realtime_test.js
|
||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh
|
||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./scripts/regenerate-sha.sh
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
ea84f5230da97447c60dfb4f8dadd0747c92fd1f7a74d5a65d6fdb6f23cf4b6d ./src/api.rs
|
||||
0db0d81da4b8161004ddcbed2096812c285ecec9d944771670e6c8edf0775175 ./src/api/assets.rs
|
||||
1139dacbd3f6ff94bf721eb5a60d469016fb935ddcf484d71bfeb249b72c833a ./src/api/auth.rs
|
||||
9e4fc675306e111ed3db7af9822e2925d88109c31b9e309851665a975c0bd83c ./src/api/automations.rs
|
||||
fffad7874465739cd6b508e9d7791387b864fa88420ce0f7e2fc933d129a2751 ./src/api/configuration.rs
|
||||
19bc141286a336ad67e99079496eb0ae5126f986ab28be62b5f97758e5f6969c ./src/api/debug_tokens.rs
|
||||
ca21f6d5a407e2e3532899d53ba4e8682c2fe961a46d8cd870ddc459721513ef ./src/api/device_groups.rs
|
||||
8f92909dc575ea6238f4a003f8bd4056c2752fa3247b9ab226e47f875c80b916 ./src/api/devices.rs
|
||||
4b5b784670ae761a6848b9a33fe3fb391bebd39d65e87936907428711e96ca44 ./src/api/events.rs
|
||||
ca5bc21e051222dcaf0c507e84cb3e60f52a0b612a6056847fa0aee3a4d1a672 ./src/api/flows.rs
|
||||
82c02fa135132ea3ebdb4d88dd1210dc536d22f987151d7f82595ee3d3d03440 ./src/api/gree_cloud.rs
|
||||
b61b0b4f196c5e72e47cf06ca0c14df3cf8eedbdef0242a9d2af33623663489f ./src/api/groups.rs
|
||||
b6d586bc42b166ac05648f3da7d9aeb88efde2e9eaab5642685eb4d9cf15f95a ./src/api/history.rs
|
||||
655bc8135fbe0b8fa491666942e80817a0eabc7dea694c68c2e5ebb30c8ecf75 ./src/api/house.rs
|
||||
4ed7835ed622806124a805f54ffde7c39854f2447de0141d756d96f22446fb90 ./src/api/integrations.rs
|
||||
7658111f7d125db55a6e23f88157339b110b2cd1ed13ceabd8f860b42dba5101 ./src/api/middleware.rs
|
||||
9d996d326f2ec192c79fb108a8d2c06efaa1c9634d9e9e54fa65a65524f92a75 ./src/api/openapi.rs
|
||||
b777e7fbeccef699e11ffd16bb18633843c99afecacce2221280e54b09f6d2da ./src/api/public_settings.rs
|
||||
5b29f2e5aa2c422c5829ef89c7b59cf753883aa7f60661c95ab0f95cf676fa3f ./src/api/schedules.rs
|
||||
fd08162d0f9287700196028ad0ca2aec7c242238056b0a228ca5bdcb319ece4e ./src/api/settings.rs
|
||||
7c974a98c72092886247f67c3a1c62736898d4b279ceb9c4c306690a3c6e4294 ./src/api/system.rs
|
||||
3b0124a76dbc654584bcf186d43f89d20b6e7fd980ce0ffbf5a9e8eced5ec7f6 ./src/api/websocket.rs
|
||||
ddd1bfc8625b66f6e5f3d24b3122ac86c25f89b0af527e3dfce9102551a8f142 ./src/api/zones.rs
|
||||
32fa38d4315682cd3ef3ecdc91e571c2729b5e68b6f2bff965e43de3fdd2aeed ./src/config.rs
|
||||
de0ce7807aebe4c0e325fbef01855e7fc1cfd117529f53f04a416fda3997673a ./src/db.rs
|
||||
87f1614a80addde69b619421187526d7f86f893c0ac18998854f11675d6e7cca ./src/db/climate.rs
|
||||
a8fe3636ac79df191e2f2c9d8c1eec1e5181e048ef8c9036cfa18f84aef1a462 ./src/db/configuration.rs
|
||||
5143803ab470f287a587171f1aab96f60653e84cade523cfe104220079727ffc ./src/db/core_devices.rs
|
||||
4c20289c08b0cb9c3c4324d9dbc6a47c7dd953aff6fd1746f04b6024043d3bc5 ./src/db/device_history.rs
|
||||
7bc79d9d33726fbdd33b485c8c2e839cedbb8fe29fee872611da978ed149d6a0 ./src/db/energy_history.rs
|
||||
96aac6510ddda8c18fd7e29802a937b96e8a74badf504980846c80cd7933a4c6 ./src/db/events_tokens.rs
|
||||
bba6e3fb9f4800ae06d0ca67772b06a8c389d308243306719442d0c1f2623420 ./src/db/flows.rs
|
||||
5f4b567c6bc866d0674b3b0c66f4aeccf561774623f575b872ff25bf015b91c2 ./src/db/ha_history.rs
|
||||
4fc007c08304e96818ad1d78997a5b2458e31aa9c48ad68b5be2a007533924bb ./src/db/network_history.rs
|
||||
d6b9b70a04c774173c97f7426487d5e2932d7713e12ae3fb7f64cf2b4e0b3c10 ./src/db/schedules_automations.rs
|
||||
28ee42d08147b0f154af818065d7d014d74ee8ddd59fdef98115f5c5368056cc ./src/db/tests.rs
|
||||
f90fbb14f29a20b799b3856bdaa22812a9f0d9dfa46693f1341c160dccda8a97 ./src/db/zone_history.rs
|
||||
0e49ea4a52017b0a8501d5101b1906a7a1e8d5158acb0bb65caa43cde2836ec0 ./src/engine.rs
|
||||
f3cc7d2cbf44180372d987d7502409547eadb1c725bcce6eeb5a26cc4277b83f ./src/engine/automations.rs
|
||||
1dccb8779f87dfdbd4ccda3f02ec51273e7be2e34e85166861668685528eca79 ./src/engine/commands.rs
|
||||
357ef92c3cc42b243af4da31ac52bb360d7444ae423f58d7a13bdee79bf0a748 ./src/engine/connectivity.rs
|
||||
315116a0dbeeba1af2f72513ea5fcc238da1b5ce9501b039c5188e44976978dc ./src/engine/control_plan.rs
|
||||
7b48a33343d519a2ff2d81e9d757eb56a0c1beacfc0b7b8ef61f49c2ad363f9a ./src/engine/deadlines.rs
|
||||
519f67ab805f61afea107215f5df50562fa1669a48334cc015570952e25e773a ./src/engine/energy.rs
|
||||
e350aa63f3bb14ca887eaaa7555edbeae00fb584cbcaf552cc972f0c8918f5d8 ./src/engine/groups.rs
|
||||
4ec11426ef860811348f2bf1b458bc5713fe8a7794fb4c8045dd637652e8e696 ./src/engine/history.rs
|
||||
41b7045877771b7c352328998cb45ce2433e7f9631fad3eb1a882ab4897dc31f ./src/engine/local_thermostat.rs
|
||||
f684e6755d023478882d1689833f307f3b4cd2c66abf45f8af8315cee94d36ef ./src/engine/ownership.rs
|
||||
76c1594f31af64c9a34e2555ac015e8719d42273c687b4c85b9c8f306b3c69a2 ./src/engine/polling.rs
|
||||
c73691c3637465ab0f79aecf7f5428d0317799a8ab5329ed564cf57963bbe86e ./src/engine/runtime.rs
|
||||
48a496c3935f886a90b90461c38a7aa31ebc40c6482d6feaeb2be47bc2387625 ./src/engine/schedules.rs
|
||||
b0189f28d0f337dfd3ce0934da7a565e4a5535afc1c242214c26ed584b3b7840 ./src/engine/targets.rs
|
||||
682c8b3956c657b655746cbd19ee97ee2f4cf953bc9de67d6d70a1b14f3dd746 ./src/engine/temperature.rs
|
||||
833b72907375e75c3f42b3921ad70a8e298f5a93743bf0d0c19059aa272462d1 ./src/engine/temporary_thermostat.rs
|
||||
bf1a3840135a71ce64148559cce0fbd570ff99d31c8972cdea7447a49f6868f7 ./src/engine/tests.rs
|
||||
c89ff36c6a67bf4fbf68c43e18aa0a556de2dc69263fd79db376363afc741a4f ./src/engine/zone_actions.rs
|
||||
87ab87d434a6462a2c3f25249b42c59ecb2bca6ed700ec73e95e4613349cf16f ./src/engine/zone_control.rs
|
||||
bfac95cec87f0df6ce562153cd3cfe6731359d703e6c592d2c2f7ba01ea62d99 ./src/error.rs
|
||||
d681f2aa53c39f37411834c300e9200299f94ee4ea021f44f70a3ea1774bd698 ./src/home_assistant.rs
|
||||
bcd14e4d1af3ca580f3b95bcd86164c40f762511e1b8e2501cb8a598a6d8adf4 ./src/influxdb.rs
|
||||
da4068295f37c23222bb13bebac022d88ba7729604a49ad25438c55e45458a49 ./src/influxdb/codec.rs
|
||||
2d36405912646b30b1a1a37a7a4e18c2104ab22e865b593316a609dda884aec2 ./src/influxdb/query.rs
|
||||
67e42a92295480d2f1d364958f184654cfb5d4b77e7e1df47f588bdec18868a0 ./src/influxdb/write.rs
|
||||
62f9da6ef4f5fd92701d0857773f9f6236ef25356f21b9b42236abab6ba7da53 ./src/main.rs
|
||||
23688a5cb68efded89471ce959328d27adff370d451bd9f378a283ab51437e76 ./src/models.rs
|
||||
e7b6e49d369aaf665a8aea2464950fd795957de0ad7528a002e11c55c7983f35 ./src/models/automation.rs
|
||||
a7a35e60318a620d411ec16ab8faecd34410ccba3310f612e89c06904a16539b ./src/models/control_plan.rs
|
||||
06357b352ff6494d83e72796b869573a8b8f36b835985a5900ac571c922b8da9 ./src/models/defaults.rs
|
||||
698dcbeba72037a251c25d07c95833f121716d949f9217f1f9429e8cd40638e5 ./src/models/device.rs
|
||||
aeab5b5acf35d38a54fd1d82e964fe56635ade69c0149ad423df317095d01555 ./src/models/flow.rs
|
||||
6e44df6bde2f73c282095c80ac17287e5bef77d9fc1d344e440d863dae5f5031 ./src/models/history.rs
|
||||
1fee89f939018bf1f3686c15f25434f01ab96b3239164108db7f348a1f1f8d82 ./src/models/integrations.rs
|
||||
17cac849056b677a3872e600a0d99218fb383ad5ec91cde596562df8e2f47508 ./src/models/runtime.rs
|
||||
41b85cff6fd234c8d6ea007b2c3c22df5fee5107dcd57c64dcc94a356ff70c12 ./src/models/settings_api.rs
|
||||
c4de6101de85d9d7e6814b2504b87d9216561e3d9e274054f081f505fb152140 ./src/models/temporary_thermostat.rs
|
||||
bc2818ce8d0dae1edfb1b98e5d1c00b193f03eeaa28e343d3532f6023288738c ./src/models/zone.rs
|
||||
4c1f676eaf0b6da45b4d4b090ae3ac862fb97c1f9e53284a1640767ae9591d8f ./src/notifications.rs
|
||||
7746bca683809b82529ed152272ae5c0cd3971758a02e076def58f770e7adf10 ./src/protocol/crypto.rs
|
||||
578b9f39110abbeb98323044b6ae1a941733c82021afeb4d7ff78ad4256b5aa5 ./src/protocol/gree.rs
|
||||
2029bad472fdf9f73637be6b7168e9ac79f6bdb55d2bf440d4aa254f3db9659b ./src/protocol/gree/binding.rs
|
||||
91d82c3ecae21615e4edad31b7ea95dca9b578650348666a2b61475b5a644669 ./src/protocol/gree/commands.rs
|
||||
4e419ba3b86ca9d154e8c04445cf005965175c54041093b2daad3666d4d852bd ./src/protocol/gree/core.rs
|
||||
f4e07ca05e037e29d08abb1635f2e6d475522ce517647c422e05b749cd648ad9 ./src/protocol/gree/discovery.rs
|
||||
ff33db623f8f1bf30015b4a00e1f247a99de713da3d297c672e42c0795ec845c ./src/protocol/gree/network.rs
|
||||
1076cb80950be00ca19f9e2370c73040902d1483b0e80c0ca349c7830d38c9f7 ./src/protocol/gree/polling.rs
|
||||
61d588dc055ac82f0f06fceac5236d5b4b7a0c0d2939144957a174bcbf2fa83c ./src/protocol/gree/tests.rs
|
||||
3c93e5b254a2a40a07854532cb5fc07e493ad65d9ba2cf28093cff4a4a4e451c ./src/protocol/gree/transport.rs
|
||||
b4e285f499dd787b75259777a14e3a51a1a071c7d176bcf30c1de0933920e0dc ./src/protocol/gree_cloud.rs
|
||||
b4b1bad9cad889dd69681b2afc3e6a09ef19e19b2a1f2900fd417c4b1f55ec2c ./src/protocol/gree_cloud_mqtt.rs
|
||||
5f70211b73215d9ae7af8fc306d15dc0b6cfbf0df9f79e34f1c785957fdf8bd4 ./src/protocol/mod.rs
|
||||
a677ce61d3d0f4cf358055ddbc343e73b452c68daed80798499147370819615e ./src/provider.rs
|
||||
830621fa75265da7288b8135704c0ce27e0687f87d55abf3cdbe40a6e2f3d2b1 ./src/queries.rs
|
||||
7fac65a9dcbcecee09816f5447c7792dc94f736b1efb66ac85629db75f6280be ./src/queries/device_history.rs
|
||||
580917b91441fe9b3276cf0cfc31536ba7954b6a86f4080347b93f9367a5b1ac ./src/queries/entities.rs
|
||||
2c5fba462b72158b04dbaaa53c8536263f8d72a1338a6c4ff06cb28a36f6ae27 ./src/queries/ha_history.rs
|
||||
7e12e8704faa7ac15a312bcc1d714ee1c6f27c1dce0972d4ccd6e5a40c69c884 ./src/queries/maintenance.rs
|
||||
7da8b73a1c8c30e2a12071062d36f5393938fde0b5ffebbe4af1891da40f3233 ./src/queries/schema.rs
|
||||
f13f1be3d5789539eba3fc1b59c14835f8c681eca7634e118fa3763a53aeee2b ./src/queries/zone_history.rs
|
||||
d8a602ed8906d77593bd140315ce1bd56716038e789f53ff31762e9b3cb7220d ./src/state.rs
|
||||
b92a6cb158b494fe145b43c7641e65f6fafff47201d7d76edbec2cfd8b94835c ./systemd/gree-controller.service
|
||||
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
|
||||
1d6fa9e291ebbae40a7a8b06a7cef9dbd0ee06d88ec6b664361c37cb6c1aa281 ./web/css/styles.css
|
||||
183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html
|
||||
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
|
||||
43f33fc21bb11df0ea149fe7fbca722a57f93890e4d9bb542ced703c1311207e ./web/index.html
|
||||
9ba3f6fa05b72aa989139b2a909982571b2a02055052e4c40104f1e81a9aa7eb ./web/js-dynamic/README.md
|
||||
e7318cd92b76e8a0109dc01adc2431474be17d8bb1488cdf6ffb39dc5450909d ./web/js-dynamic/bootstrap.js
|
||||
5d57253a013fd1cb79e4b8701d4be6e394e3f2acbce73dfcd726a86e5878f1a1 ./web/js-dynamic/charts.js
|
||||
07629b331bd498e8bea769d577321bbd12d9b076264242706d4ef16165fb22fa ./web/js-dynamic/core.js
|
||||
5e681095a559fafbeca067024afb3705ba114f01fa562d487a5717d12dac0123 ./web/js-dynamic/dashboard.js
|
||||
930e67432f55e8476513e808edfa9a0809a8da7479307284fcc136082a4129a4 ./web/js-dynamic/entities.js
|
||||
4612c958d7b9ccf89d6d45a11c3bc7e864bbd2ceaccc16fd7d76811ba4c74196 ./web/js-dynamic/events.js
|
||||
43c78a3871cfdb0382081ec30b8d48650450c71b44d006414b161349ac1fa659 ./web/js-dynamic/flows.js
|
||||
3ae714de6b71c068ba665c81a7689f994b1716284e8d937db669ad500f12cf63 ./web/js-dynamic/forms.js
|
||||
92d013bb5d64f64aa8c55380704540a5f72732bde7ccd16554965e401daf50ea ./web/js-dynamic/history.js
|
||||
511c4e7cc4c48fbbdab64558a9d1c27e6759c9b6dca255d11f301b9708e955c7 ./web/js-dynamic/main.js
|
||||
30cbaebb817d5d416e1a372595b647aae42c14b9cc406c4751d0ced563243b69 ./web/js-dynamic/navigation.js
|
||||
3ff9c027beb8fb033330bf3cfb3496e06fb38c42041f1ffe522c6c7af18e9e30 ./web/js-dynamic/realtime.js
|
||||
1b667f0a8bce824b27f56a8d2f44ff8feef9a011289591dc629ee288db70d137 ./web/js-dynamic/router.js
|
||||
c2710fd9c1f2b31878c1e25ceb02d74ae30456c1bbb400e69fcf7619ecc72bc2 ./web/js-dynamic/select-ui.js
|
||||
bc42b4a0dd0d20239ce22ea1dff09b3f94e8c0e1395e01251a4585a051968a8f ./web/js-dynamic/settings-ui.js
|
||||
672e404470bc6331aa6d658523b221659ec64222af9cc9c17ced978f725790da ./web/js-dynamic/settings.js
|
||||
489ab0208c86f7a2bb0d721106f01fb5d4f39f41e94749b440a6c58e13c4de07 ./web/js/custom-chart.js
|
||||
6ae536b18db756bf5eda9adb1b8a550d5a02bfc80f0b7da27fa146039f22e884 ./web/js/lang-init.js
|
||||
e3fc113d4c349f33f3faa32e7a46c96a85c71643f43f2e488065dc9715e27333 ./web/js/sw.js
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/js/theme-init.js
|
||||
6b653e4fe2d4db4ffae6ff8f39b8ce57a10a990dc0000ee4aae51d949afadd52 ./web/manifest.webmanifest
|
||||
@@ -35,6 +35,7 @@ fn content_hash(bytes: &[u8]) -> String {
|
||||
|
||||
const APP_JS_MODULES: &[&str] = &[
|
||||
"core.js",
|
||||
"select-ui.js",
|
||||
"forms.js",
|
||||
"bootstrap.js",
|
||||
"dashboard.js",
|
||||
@@ -52,7 +53,7 @@ const APP_JS_MODULES: &[&str] = &[
|
||||
];
|
||||
|
||||
fn bundle_app_js(web_dir: &Path) -> String {
|
||||
let js_dir = web_dir.join("js");
|
||||
let js_dir = web_dir.join("js-dynamic");
|
||||
println!("cargo:rerun-if-changed={}", js_dir.display());
|
||||
|
||||
let mut bundle = String::new();
|
||||
@@ -77,15 +78,17 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed={}", lang_dir.display());
|
||||
println!("cargo:rerun-if-changed={}", preset_dir.display());
|
||||
|
||||
let theme_init_path = web_dir.join("theme-init.js");
|
||||
let styles_path = web_dir.join("styles.css");
|
||||
let theme_init_path = web_dir.join("js/theme-init.js");
|
||||
let lang_init_path = web_dir.join("js/lang-init.js");
|
||||
let styles_path = web_dir.join("css/styles.css");
|
||||
let index_path = web_dir.join("index.html");
|
||||
let not_found_path = web_dir.join("404.html");
|
||||
let sw_path = web_dir.join("sw.js");
|
||||
let sw_path = web_dir.join("js/sw.js");
|
||||
let manifest_path = web_dir.join("manifest.webmanifest");
|
||||
let favicon_path = web_dir.join("favicon.svg");
|
||||
for path in [
|
||||
&theme_init_path,
|
||||
&lang_init_path,
|
||||
&styles_path,
|
||||
&index_path,
|
||||
¬_found_path,
|
||||
@@ -100,17 +103,22 @@ fn main() {
|
||||
let app_js_hash = content_hash(app_js.as_bytes());
|
||||
let theme_init_bytes = fs::read(&theme_init_path)
|
||||
.unwrap_or_else(|error| panic!("cannot read {}: {error}", theme_init_path.display()));
|
||||
let lang_init_bytes = fs::read(&lang_init_path)
|
||||
.unwrap_or_else(|error| panic!("cannot read {}: {error}", lang_init_path.display()));
|
||||
let styles_bytes = fs::read(&styles_path)
|
||||
.unwrap_or_else(|error| panic!("cannot read {}: {error}", styles_path.display()));
|
||||
let theme_init_hash = content_hash(&theme_init_bytes);
|
||||
let lang_init_hash = content_hash(&lang_init_bytes);
|
||||
let styles_hash = content_hash(&styles_bytes);
|
||||
let app_js_asset_path = format!("/app-{}.js", &app_js_hash[..12]);
|
||||
let theme_init_asset_path = format!("/theme-init-{}.js", &theme_init_hash[..12]);
|
||||
let lang_init_asset_path = format!("/lang-init-{}.js", &lang_init_hash[..12]);
|
||||
let styles_asset_path = format!("/styles-{}.css", &styles_hash[..12]);
|
||||
|
||||
let mut build_hash = fnv1a_update(0xcbf29ce484222325u64, app_js.as_bytes());
|
||||
for path in [
|
||||
&theme_init_path,
|
||||
&lang_init_path,
|
||||
&styles_path,
|
||||
&index_path,
|
||||
¬_found_path,
|
||||
@@ -135,9 +143,10 @@ fn main() {
|
||||
panic!("no language files found in {}", lang_dir.display());
|
||||
}
|
||||
|
||||
let default_language = "en";
|
||||
let mut assets = Vec::new();
|
||||
let mut manifest_languages = Vec::new();
|
||||
let mut has_english = false;
|
||||
let mut has_default_language = false;
|
||||
|
||||
for path in files {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
@@ -185,22 +194,23 @@ fn main() {
|
||||
if !document.get("translations").is_some_and(Value::is_object) {
|
||||
panic!("{filename}: translations must be a JSON object");
|
||||
}
|
||||
if code == "en" {
|
||||
has_english = true;
|
||||
if code == default_language {
|
||||
has_default_language = true;
|
||||
}
|
||||
|
||||
let language_hash = content_hash(source.as_bytes());
|
||||
manifest_languages.push(json!({
|
||||
"code": code,
|
||||
"name": name,
|
||||
"native_name": native_name,
|
||||
"locale": locale,
|
||||
"path": format!("lang/{code}.json")
|
||||
"path": format!("lang/{code}.json?v={}", &language_hash[..12])
|
||||
}));
|
||||
assets.push((code.to_owned(), path));
|
||||
}
|
||||
|
||||
if !has_english {
|
||||
panic!("lang/en.json is required as the default fallback language");
|
||||
if !has_default_language {
|
||||
panic!("lang/{default_language}.json is required as the default fallback language");
|
||||
}
|
||||
|
||||
let mut preset_files: Vec<PathBuf> = fs::read_dir(&preset_dir)
|
||||
@@ -226,7 +236,10 @@ fn main() {
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.expect("UTF-8 preset id");
|
||||
if !stem.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
|
||||
if !stem
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
{
|
||||
panic!("{filename}: filename may only contain ASCII letters, digits, '-' and '_'");
|
||||
}
|
||||
let source = fs::read_to_string(&path)
|
||||
@@ -260,7 +273,9 @@ fn main() {
|
||||
.get("flow")
|
||||
.and_then(Value::as_object)
|
||||
.unwrap_or_else(|| panic!("{filename}: flow must be an object"));
|
||||
if !flow.get("nodes").is_some_and(Value::is_array) || !flow.get("edges").is_some_and(Value::is_array) {
|
||||
if !flow.get("nodes").is_some_and(Value::is_array)
|
||||
|| !flow.get("edges").is_some_and(Value::is_array)
|
||||
{
|
||||
panic!("{filename}: flow.nodes and flow.edges must be arrays");
|
||||
}
|
||||
preset_manifest.push(json!({
|
||||
@@ -272,7 +287,7 @@ fn main() {
|
||||
}
|
||||
|
||||
let manifest_json = serde_json::to_string(&json!({
|
||||
"default": "en",
|
||||
"default": default_language,
|
||||
"languages": manifest_languages
|
||||
}))
|
||||
.expect("serialize language manifest");
|
||||
@@ -293,6 +308,10 @@ fn main() {
|
||||
"pub const THEME_INIT_ASSET_PATH: &str = {:?};\n",
|
||||
theme_init_asset_path
|
||||
));
|
||||
generated.push_str(&format!(
|
||||
"pub const LANG_INIT_ASSET_PATH: &str = {:?};\n",
|
||||
lang_init_asset_path
|
||||
));
|
||||
generated.push_str(&format!(
|
||||
"pub const STYLES_CSS_ASSET_PATH: &str = {:?};\n",
|
||||
styles_asset_path
|
||||
@@ -305,6 +324,10 @@ fn main() {
|
||||
"pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n",
|
||||
manifest_json
|
||||
));
|
||||
generated.push_str(&format!(
|
||||
"pub const DEFAULT_LANGUAGE_CODE: &str = {:?};\n",
|
||||
default_language
|
||||
));
|
||||
generated.push_str("pub const LANGUAGE_ASSETS: &[(&str, &str)] = &[\n");
|
||||
for (code, path) in assets {
|
||||
generated.push_str(&format!(
|
||||
|
||||
+428
-165
@@ -1,6 +1,6 @@
|
||||
# GREE Controller API reference
|
||||
|
||||
HTTP and WebSocket API for GREE Controller **0.11.4**.
|
||||
HTTP and WebSocket API for GREE Controller **0.15.17**.
|
||||
|
||||
[← Main documentation](../README.md)
|
||||
|
||||
@@ -20,31 +20,48 @@ Content-Type: application/json
|
||||
|
||||
If `GREE_CONTROLLER_BASE_PATH=/gree` is configured, every HTTP and WebSocket path below is prefixed with `/gree`.
|
||||
|
||||
Interactive and machine-readable API documentation is served by the application itself:
|
||||
|
||||
```text
|
||||
GET /api-docs Swagger UI
|
||||
GET /api-docs/openapi.json OpenAPI 3.1 document
|
||||
```
|
||||
|
||||
The OpenAPI document contains endpoint descriptions, authentication schemes, parameters,
|
||||
request/response models, examples and common error responses. Its `servers` entry follows the
|
||||
configured `GREE_CONTROLLER_BASE_PATH`, so requests sent from Swagger UI target the current
|
||||
controller instance correctly.
|
||||
|
||||
## Authentication
|
||||
|
||||
There are three access levels.
|
||||
|
||||
### Public
|
||||
|
||||
No token is required for:
|
||||
No administrator token is required for the static UI shell/assets and the generated chart share surface listed below. In Supervisor mode, `/api/health` is anonymous only to the Supervisor watchdog; a direct network request needs the application token.
|
||||
|
||||
```text
|
||||
GET /api/health
|
||||
GET /api-docs
|
||||
GET /api-docs/openapi.json
|
||||
GET /
|
||||
GET /index.html
|
||||
GET /app.js
|
||||
GET /theme-init.js
|
||||
GET /lang-init.js
|
||||
GET /styles.css
|
||||
GET /manifest.webmanifest
|
||||
GET /sw.js
|
||||
GET /favicon.svg
|
||||
GET /lang/index.json
|
||||
GET /lang/{file}
|
||||
GET /charts/custom/{share-token}
|
||||
GET /api/public/charts/custom/{share-token}
|
||||
```
|
||||
|
||||
### Administrator API
|
||||
|
||||
All normal `/api/*` routes are administrator routes. If `GREE_CONTROLLER_APP_TOKEN` is empty, the controller intentionally operates in trusted-LAN mode and these routes do not require authentication.
|
||||
All normal `/api/*` routes are administrator routes. In standalone installations, an empty `GREE_CONTROLLER_APP_TOKEN` keeps trusted-LAN mode. When Home Assistant Supervisor authentication is active, direct requests to the administrator API require `GREE_CONTROLLER_APP_TOKEN`; if it is empty, direct administrator access is disabled. Requests proxied by the trusted Home Assistant ingress are accepted without the application token.
|
||||
|
||||
When an app token is configured, send either:
|
||||
|
||||
@@ -93,7 +110,8 @@ Common statuses:
|
||||
| `401 Unauthorized` | Missing/incorrect token. |
|
||||
| `404 Not Found` | Resource ID does not exist. |
|
||||
| `409 Conflict` | Revision/concurrency conflict. |
|
||||
| `502 Bad Gateway` | GREE/HA/integration communication failure. |
|
||||
| `424 Failed Dependency` | Configured external service/integration (for example Home Assistant, GREE Cloud or a notification provider) could not complete the request. |
|
||||
| `502 Bad Gateway` | Local/LAN GREE device communication failure. |
|
||||
| `500 Internal Server Error` | Unexpected server/storage error. |
|
||||
|
||||
## Endpoint index
|
||||
@@ -103,6 +121,9 @@ Common statuses:
|
||||
| Method | Endpoint | Description |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/health` | Lightweight process/control-engine health. |
|
||||
| POST | `/api/charts/custom/share` | Create a persisted Custom Chart share; administrator/ingress authentication required. |
|
||||
| GET | `/api/public/charts/custom/{token}` | Read-only data for one generated Custom Chart share. |
|
||||
| GET | `/charts/custom/{token}` | Standalone chart-only HTML view. |
|
||||
| GET | `/api/bootstrap` | Complete initial application snapshot. |
|
||||
| GET | `/api/system/info` | Runtime/system diagnostic information. |
|
||||
| GET | `/ws` | Live WebSocket event stream. |
|
||||
@@ -111,13 +132,15 @@ Common statuses:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/discovery` | Discover/bind GREE devices. |
|
||||
| POST | `/api/discovery/scan` | Scan for local GREE devices without adding them. |
|
||||
| POST | `/api/discovery/add` | Add and bind selected scan results. |
|
||||
| GET | `/api/devices` | List devices. |
|
||||
| POST | `/api/devices` | Add a device manually. |
|
||||
| GET | `/api/devices/{id}` | Read a device. |
|
||||
| PATCH | `/api/devices/{id}` | Edit technical device configuration. |
|
||||
| DELETE | `/api/devices/{id}` | Delete a device after safety checks. |
|
||||
| POST | `/api/devices/{id}/bind` | Bind/re-bind a physical unit. |
|
||||
| POST | `/api/devices/{id}/probe` | Minimal non-mutating GREE round-trip diagnostic. |
|
||||
| POST | `/api/devices/{id}/poll` | Poll one unit immediately. |
|
||||
| POST | `/api/devices/{id}/command` | Send a direct/manual device command. |
|
||||
|
||||
@@ -140,6 +163,7 @@ Common statuses:
|
||||
| POST | `/api/groups/{id}/control` | Group control enable/mode/preset/custom-temperature control. |
|
||||
| POST | `/api/house/control` | Set global thermostat mode. |
|
||||
| POST | `/api/house/power` | Bulk ON/OFF for all thermostats and enabled units; no persistent global gate. |
|
||||
| POST | `/api/house/emergency-stop` | Persistently pause/resume automation; activation sends one-shot OFF to enabled units. |
|
||||
| POST | `/api/house/preset` | Set/clear whole-house preset override. |
|
||||
|
||||
### Schedules and automations
|
||||
@@ -165,20 +189,24 @@ 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. |
|
||||
| POST | `/api/integrations/home-assistant/test` | Test HA temperature read. |
|
||||
| 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. |
|
||||
| POST | `/api/integrations/influxdb/test` | Test the supplied InfluxDB connection settings without saving them. |
|
||||
| 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 the active HA authentication path and return one live sample entity reading when available. |
|
||||
| GET | `/api/integrations/home-assistant/entities` | Compact Home Assistant entity catalog used by searchable Shared Flow input suggestions. |
|
||||
| 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. |
|
||||
|
||||
@@ -189,6 +217,7 @@ Common statuses:
|
||||
| GET | `/api/access-tokens` | List generated HA tokens without secrets. |
|
||||
| POST | `/api/access-tokens` | Create restricted HA token. |
|
||||
| DELETE | `/api/access-tokens/{id}` | Revoke token. |
|
||||
| GET | `/api/integrations/home-assistant/snapshot` | Restricted devices + groups + control-plan snapshot in one request. |
|
||||
| GET | `/api/integrations/home-assistant/devices` | Restricted device list. |
|
||||
| POST | `/api/integrations/home-assistant/devices/{id}/command` | Restricted direct device command. |
|
||||
| GET | `/api/integrations/home-assistant/control-plan` | Restricted control plan. |
|
||||
@@ -197,6 +226,7 @@ Common statuses:
|
||||
| POST | `/api/integrations/home-assistant/house/control` | Restricted house mode. |
|
||||
| POST | `/api/integrations/home-assistant/house/preset` | Restricted house preset. |
|
||||
| POST | `/api/integrations/home-assistant/house/power` | Restricted bulk all-thermostat/all-unit power action. |
|
||||
| POST | `/api/integrations/home-assistant/house/emergency-stop` | Restricted persistent emergency automation pause/resume. |
|
||||
| POST | `/api/integrations/home-assistant/zones/{id}/control` | Restricted thermostat-zone control. |
|
||||
|
||||
---
|
||||
@@ -205,7 +235,7 @@ Common statuses:
|
||||
|
||||
### `GET /api/health`
|
||||
|
||||
Public lightweight health check.
|
||||
Lightweight health check. It is public in standalone mode. When Supervisor authentication is active, a request without the application token is accepted only from the Supervisor peer so the add-on watchdog continues to work.
|
||||
|
||||
Response:
|
||||
|
||||
@@ -213,7 +243,7 @@ Response:
|
||||
{
|
||||
"status": "ok",
|
||||
"name": "gree-controller",
|
||||
"version": "0.11.4",
|
||||
"version": "0.15.17",
|
||||
"uptime_seconds": 1234,
|
||||
"control_ready": true,
|
||||
"time": "2026-08-30T06:54:00Z"
|
||||
@@ -233,11 +263,50 @@ Returns the initial Web UI snapshot:
|
||||
"groups": [],
|
||||
"schedules": [],
|
||||
"automations": [],
|
||||
"flows": [],
|
||||
"access_tokens": [],
|
||||
"settings": {},
|
||||
"settings": {
|
||||
"application": {"simulator_enabled": false},
|
||||
"gree": {
|
||||
"controller_id": "gree-controller",
|
||||
"poll_interval_seconds": 10,
|
||||
"zone_interval_seconds": 10,
|
||||
"discovery_timeout_ms": 3000,
|
||||
"discovery_broadcast": "auto",
|
||||
"suppress_device_beep": false,
|
||||
"compressor_protection_enabled": true,
|
||||
"compressor_protection_seconds": 180
|
||||
},
|
||||
"history": {"retention_days": 30, "compaction_enabled": true, "event_retention_days": 30},
|
||||
"influxdb": {
|
||||
"enabled": false, "version": "2", "url": "", "database": "gree_controller",
|
||||
"username": "", "password_configured": false, "org": "", "bucket": "",
|
||||
"token_configured": false, "history_threshold_days": 30
|
||||
},
|
||||
"notifications": {
|
||||
"enabled": false, "mode": "problems", "provider": "pushover",
|
||||
"pushover_configured": false, "slack_configured": false, "discord_configured": false,
|
||||
"cooldown_seconds": 300, "communication_failure_threshold": 3,
|
||||
"target_timeout_minutes": 60, "alert_types": {}
|
||||
},
|
||||
"night": {
|
||||
"enabled": false, "start_time": "22:00", "end_time": "06:00",
|
||||
"max_fan_speed": 1, "force_quiet": true, "use_native_sleep": true
|
||||
},
|
||||
"home_assistant": {
|
||||
"url": "", "auth_mode": "manual", "token_configured": false,
|
||||
"outdoor_entity_id": "", "sensor_stale_after_seconds": 300,
|
||||
"allow_invalid_tls": false, "sensor_aliases": {}, "flow_inputs": [],
|
||||
"outdoor_assist_enabled": true
|
||||
},
|
||||
"debug": {"overlay_enabled": false, "gree_frames": false}
|
||||
},
|
||||
"house": {"mode": "cool"},
|
||||
"outdoor_temperature": null,
|
||||
"control_plan": {"generated_at": "2026-09-04T08:00:00Z", "zones": [], "rules": []},
|
||||
"control_plan_revision": 42,
|
||||
"system": {
|
||||
"version": "0.11.4",
|
||||
"version": "0.15.17",
|
||||
"uptime_seconds": 1234,
|
||||
"auth_required": false,
|
||||
"control_ready": true,
|
||||
@@ -247,6 +316,7 @@ Returns the initial Web UI snapshot:
|
||||
"simulator_count": 0,
|
||||
"bind": "0.0.0.0:8787",
|
||||
"base_path": "/",
|
||||
"public_chart_base_url": "http://192.168.1.20:8787",
|
||||
"gree_interface": "auto",
|
||||
"gree_received_frames": 809,
|
||||
"gree_received_frames_by_device": {}
|
||||
@@ -254,6 +324,16 @@ Returns the initial Web UI snapshot:
|
||||
}
|
||||
```
|
||||
|
||||
`settings` is a single startup snapshot composed from the same response models as the eight `/api/settings/*` GET endpoints. Secret values are never included; only `*_configured` flags are exposed for stored credentials. The split settings endpoints remain the canonical resources for independent reads and updates.
|
||||
|
||||
### Custom Chart share links
|
||||
|
||||
`POST /api/charts/custom/share` is an administrator endpoint that persists a selected Custom Chart definition and returns a random path such as `/charts/custom/chart_<token>`. Only a hash of the share token is stored. The URL does not contain device names or metric selectors.
|
||||
|
||||
`GET /charts/custom/:token` renders only the shared chart, without the dashboard, and lets the viewer switch the history range. `GET /api/public/charts/custom/:token` returns only the series configured for that share and is intentionally unauthenticated; an optional `hours` query parameter changes only the time range, not the shared series definition. Possession of the unguessable share URL is the authorization for this narrow read-only endpoint.
|
||||
|
||||
In the Home Assistant add-on, TCP `8787` is the fixed internal application/ingress port. The startup script validates that Supervisor reports the same `ingress_port`; a manually modified mismatched package fails fast instead of starting partially. For generated links, the add-on discovers the primary Home Assistant host IPv4 through the Supervisor API and publishes `http://<HA-IP>:8787/charts/custom/...` by default. The optional `public_chart_base_url` add-on setting overrides that base for reverse proxies or unusual routing. Standalone installations use their current origin/configured base path and therefore follow any port configured in `GREE_CONTROLLER_BIND`.
|
||||
|
||||
### `GET /api/system/info`
|
||||
|
||||
Returns the `system` diagnostic object independently of the full bootstrap. Useful for monitoring and **Settings → System status**.
|
||||
@@ -283,7 +363,8 @@ A device response contains:
|
||||
| `mode` | string | `auto`, `cool`, `dry`, `fan`, `heat`. |
|
||||
| `target_temperature` | number | Last known unit setpoint. |
|
||||
| `fan_speed` | integer | `0..5`; `0` is Auto. |
|
||||
| `swing_vertical`, `swing_horizontal` | boolean | Swing state. |
|
||||
| `swing_vertical` | integer | Vertical louver (`SwUpDn`): `0` off/default, `1` full range, `2..6` fixed positions, `7..11` partial swing ranges. |
|
||||
| `swing_horizontal` | integer | Horizontal louver (`SwingLfRig`): `0` off/default, `1` full range, `2..6` fixed positions. |
|
||||
| `quiet`, `turbo`, `light`, `air`, `xfan`, `health`, `sleep` | boolean | Optional GREE features. |
|
||||
| `supports_*` | boolean/null | Capability learned from device status. |
|
||||
| `current_temperature` | number/null | GREE indoor temperature. |
|
||||
@@ -296,9 +377,9 @@ A device response contains:
|
||||
| `communication_failures` | integer | Consecutive/recorded communication failure counter. |
|
||||
| `created_at`, `updated_at` | ISO-8601 | Resource timestamps. |
|
||||
|
||||
### `POST /api/discovery`
|
||||
### `POST /api/discovery/scan`
|
||||
|
||||
Request body, all fields optional:
|
||||
Scans without adding or binding anything. Request body fields are optional:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -309,23 +390,53 @@ Request body, all fields optional:
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
`protocol_version` accepts `0` (auto/both), `1` (AES-ECB only) or `2` (AES-GCM only). The UDP scan packet is common to both generations, and newer V2-capable modules may advertise through a legacy/plain discovery envelope. Discovery therefore decodes the reply first and uses the inner `ver` metadata as the protocol hint (`V1.*` -> V1, `V2.*` and newer -> V2). Auto keeps unresolved replies as protocol `0`; explicit V1/V2 scans return only candidates whose hint matches the selected generation.
|
||||
|
||||
- `timeout_ms`: effective range `500..30000` ms.
|
||||
- `protocol_version`: `0` auto/both, `1` AES-ECB only, `2` AES-GCM only.
|
||||
- `passes`: `1..10`.
|
||||
- Missing values use runtime GREE settings.
|
||||
|
||||
Successful discovery merges known devices, tries binding devices that do not have a key, persists results and returns:
|
||||
The response contains candidates with MAC, IP, protocol hint, a `protocol_locked` flag and an `already_added` flag:
|
||||
|
||||
```json
|
||||
{
|
||||
"count": 1,
|
||||
"devices": [],
|
||||
"new_device_ids": ["gree-aabbccddeeff"]
|
||||
"devices": [
|
||||
{
|
||||
"name": "GREE EEFF",
|
||||
"mac": "AABBCCDDEEFF",
|
||||
"ip": "192.168.50.30",
|
||||
"port": 7000,
|
||||
"protocol_version": 1,
|
||||
"protocol_locked": false,
|
||||
"model": "GREE",
|
||||
"firmware": "",
|
||||
"already_added": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/discovery/add`
|
||||
|
||||
Add only selected candidates returned by `/api/discovery/scan`:
|
||||
|
||||
```json
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"name": "Living room",
|
||||
"mac": "AABBCCDDEEFF",
|
||||
"ip": "192.168.50.30",
|
||||
"port": 7000,
|
||||
"protocol_version": 1,
|
||||
"protocol_locked": false,
|
||||
"model": "GREE",
|
||||
"firmware": "",
|
||||
"already_added": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For Auto discovery (`protocol_locked=false`), `protocol_version` is only the preferred bind order. The controller falls back to the other generation if needed and stores the protocol that actually binds successfully. Explicit V1/V2 discovery returns `protocol_locked=true` and binds strictly with the selected protocol. Existing MAC addresses are skipped.
|
||||
|
||||
### `GET /api/devices`
|
||||
|
||||
Returns `Device[]`.
|
||||
@@ -379,6 +490,18 @@ Returns `204`. Deletion is rejected if the device is referenced by an automation
|
||||
|
||||
Performs/repeats GREE binding and returns updated `Device`. Simulated devices return unchanged.
|
||||
|
||||
### `POST /api/devices/{id}/probe`
|
||||
|
||||
Performs a minimal, non-mutating GREE status round-trip and returns `response_time_ms`. It does **not** update device online/error counters, readings, capabilities, thermostat ownership, or persisted device state. A real device must already be bound.
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "gree-aabbccddeeff",
|
||||
"response_time_ms": 18,
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/devices/{id}/poll`
|
||||
|
||||
Immediately polls one unit and returns updated `Device`.
|
||||
@@ -395,8 +518,8 @@ All fields optional; at least one meaningful field should be sent:
|
||||
"mode": "cool",
|
||||
"target_temperature": 22,
|
||||
"fan_speed": 3,
|
||||
"swing_vertical": true,
|
||||
"swing_horizontal": false,
|
||||
"swing_vertical": 2,
|
||||
"swing_horizontal": 1,
|
||||
"quiet": false,
|
||||
"turbo": false,
|
||||
"light": true,
|
||||
@@ -407,6 +530,8 @@ All fields optional; at least one meaningful field should be sent:
|
||||
}
|
||||
```
|
||||
|
||||
Louver command values follow the raw GREE positions. For backward compatibility, legacy booleans are still accepted (`false` = `0`, `true` = `1`).
|
||||
|
||||
Rules:
|
||||
|
||||
- modes: `auto`, `cool`, `dry`, `fan`, `heat`,
|
||||
@@ -416,6 +541,8 @@ Rules:
|
||||
|
||||
The backend sends only properties that differ from the last known device state. Climate-relevant direct commands can create/continue a manual-device takeover for an enabled thermostat zone so automation does not immediately fight the user.
|
||||
|
||||
When the physical device belongs to a disabled thermostat zone, direct web/API control requires `"manual_override": true`. The web UI asks for explicit confirmation before sending that flag. Home Assistant direct-device control remains blocked for disabled zones.
|
||||
|
||||
---
|
||||
|
||||
## Zones
|
||||
@@ -449,6 +576,7 @@ The backend sends only properties that differ from the last known device state.
|
||||
"smart_fan": true,
|
||||
"sensor_source": "combined",
|
||||
"ha_entity_id": "sensor.living_room_temperature",
|
||||
"ha_outdoor_entity_id": "sensor.garden_temperature",
|
||||
"external_sensor_weight": 0.4,
|
||||
"max_sensor_difference": 3.0,
|
||||
"sensor_stale_after_seconds": 300,
|
||||
@@ -466,6 +594,8 @@ Important rules:
|
||||
- `separate_hysteresis`: when `true`, cooling uses `cool_hysteresis` and heating uses `heat_hysteresis`; both use the same `0.1..5.0` °C range.
|
||||
- `standby_offset_c`: bounded thermostat offset.
|
||||
- `sensor_source`: `device`, `home_assistant` or `combined`.
|
||||
- for `home_assistant`/`combined`, `ha_entity_id` is the required Home Assistant room-temperature sensor for that zone.
|
||||
- `ha_outdoor_entity_id` is optional; when empty, the zone uses global Home Assistant `outdoor_entity_id`, and when set it overrides the outdoor-temperature source only for that zone. If the override is unavailable, the global outdoor source is used.
|
||||
- `external_sensor_weight`: `0..1`.
|
||||
- `revision` is used for optimistic concurrency where supplied; stale updates can return `409`.
|
||||
|
||||
@@ -579,11 +709,13 @@ curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: appl
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"preset":"auto"}'
|
||||
|
||||
# Run a 90-minute temporary thermostat
|
||||
# Run a 90-minute temporary thermostat at a decimal logical target
|
||||
curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"temporary_quick_thermostat":{"start_kind":"now","finish_kind":"duration","duration_minutes":90,"target_temperature":23}}'
|
||||
-d '{"temporary_quick_thermostat":{"start_kind":"now","finish_kind":"duration","duration_minutes":90,"target_temperature":24.2}}'
|
||||
```
|
||||
|
||||
Temporary Quick Thermostat targets are normalized to `0.1 °C`. The Web UI accepts both comma and dot decimal input (for example `24,2` and `24.2`). Physical GREE device setpoint rounding is unchanged.
|
||||
|
||||
### `POST /api/zones/{id}/schedule-template`
|
||||
|
||||
Body:
|
||||
@@ -691,7 +823,7 @@ Valid modes: `cool`, `heat`, `off`.
|
||||
- `cool`/`heat` select the house rule used by zones that inherit the global mode and immediately re-run arbitration for free zones. Explicit local/group/direct ownership is preserved.
|
||||
- `off` means **do not perform house-level thermostat control** for inherited free zones. It does not block local thermostats, groups, device-manual control or controller automations.
|
||||
|
||||
Returns public runtime settings.
|
||||
Returns `{ "mode": "cool|heat|off" }`.
|
||||
|
||||
### `POST /api/house/power`
|
||||
|
||||
@@ -711,11 +843,24 @@ Response includes:
|
||||
"one_shot": true,
|
||||
"devices": [],
|
||||
"groups": [],
|
||||
"settings": {},
|
||||
"failed": []
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /api/house/emergency-stop`
|
||||
|
||||
```json
|
||||
{
|
||||
"active": true
|
||||
}
|
||||
```
|
||||
|
||||
`true` persistently pauses thermostat/schedule/automation execution, clears stale compressor-protection tasks, and makes a one-shot best-effort OFF request to every enabled unit. The pause survives controller restarts, but startup does **not** replay OFF commands. Manual/direct device control remains available while automation is paused.
|
||||
|
||||
`false` releases only the automation safety gate. It does not force devices ON; the controller immediately evaluates current schedules, temperatures and ownership again.
|
||||
|
||||
Response includes `active`, `since`, `changed`, `failed`, and `cleared_queues`.
|
||||
|
||||
### `POST /api/house/preset`
|
||||
|
||||
```json
|
||||
@@ -728,6 +873,8 @@ Valid: `auto`, `comfort`, `sleep`, `away`.
|
||||
|
||||
A non-`auto` preset creates overrides for free house-controlled zones and normally expires at each zone's next schedule boundary. `auto` clears those free-zone overrides. Explicit local thermostat, group, temporary thermostat and direct/manual ownership is not overwritten by a house profile action.
|
||||
|
||||
Response contains `preset`, the updated `zones`, current `devices`, and a `failed` array for immediate-control errors. It does not embed settings; settings are available only from the functional `/api/settings/*` resources.
|
||||
|
||||
---
|
||||
|
||||
## Schedules
|
||||
@@ -909,7 +1056,8 @@ When InfluxDB is enabled, older history can be read from Influx and merged with
|
||||
|
||||
### `GET /api/control-plan`
|
||||
|
||||
Returns the resolved machine-readable thermostat plan:
|
||||
Returns the latest materialized machine-readable thermostat plan. The response shape is unchanged; the bundled Web UI receives plan updates primarily through WebSocket and uses this endpoint for fallback/resynchronization.
|
||||
|
||||
|
||||
Top-level fields:
|
||||
|
||||
@@ -974,88 +1122,64 @@ 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.
|
||||
For events that are eligible for external notification, `metadata.notification` records the delivery result. `status` is `sent`, `silent` or `failed`. A `silent` event includes a `reason` such as `alert_type_disabled`, `notifications_disabled`, `mode_filtered` or `cooldown`; the Web UI shows these rows with a **SILENT** badge. Notification-status changes are also emitted as `log.updated` WebSocket events.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
{
|
||||
@@ -1064,31 +1188,41 @@ 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
|
||||
### `POST /api/integrations/influxdb/test`
|
||||
|
||||
Accepts the same body as the InfluxDB settings update, but does not persist it. Blank/omitted secrets reuse the currently stored password/token. A successful test returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"version": "2",
|
||||
"response_time_ms": 18
|
||||
}
|
||||
```
|
||||
|
||||
InfluxDB 1.x tests a lightweight query against the configured database; InfluxDB 2.x tests a lightweight Flux query against the configured organization/bucket.
|
||||
|
||||
### `/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,
|
||||
@@ -1099,6 +1233,7 @@ Version `1` uses database/optional username/password. Version `2` uses org/bucke
|
||||
"communication": true,
|
||||
"target_timeout": true,
|
||||
"automation": true,
|
||||
"sensor_discrepancy": true,
|
||||
"control_errors": true,
|
||||
"important_events": true,
|
||||
"other": true
|
||||
@@ -1106,33 +1241,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. `alert_types.sensor_discrepancy` controls only notifications about GREE vs Home Assistant room-temperature divergence; the safety fallback to the GREE sensor remains active.
|
||||
|
||||
### `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",
|
||||
"auth_mode": "manual",
|
||||
"token_configured": true,
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
`auth_mode` is `manual` for standalone installations and normally `supervisor` for the Home Assistant add-on. The add-on detects the Supervisor environment and runtime `SUPERVISOR_TOKEN`; while Supervisor auth is active, the effective URL is `http://supervisor/core/` and the Web UI hides manual URL/token/TLS fields. `supervisor_detected`, `supervisor_token_detected`, `manual_url`, `manual_token_configured` and `manual_auth_override` describe that state without exposing secrets. If the automatic HA test fails, the UI unlocks the persisted manual URL/token fallback; saving it sets `manual_auth_override=true`. Standalone URL/token behavior is unchanged. Omitted/`null` token preserves the saved manual token; an explicit empty string clears it. `outdoor_entity_id` is the optional global Home Assistant outdoor-temperature sensor. Each zone can set `ha_outdoor_entity_id` to override that source only for the zone; a blank override uses the global sensor. Home Assistant room-temperature sensors remain configured per zone with `ha_entity_id` when `sensor_source` is `home_assistant` or `combined`. Sensor age is clamped to `30..86400` seconds. URLs, aliases, entity IDs and shared Flow inputs are normalized/validated before persistence. Global/per-zone outdoor sensors and per-zone room sensors are exposed through the normal alias and sensor-history/metric surfaces. Shared Flow input HA sources can query `/api/integrations/home-assistant/entities` for live searchable entity suggestions. 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
|
||||
@@ -1143,13 +1325,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.
|
||||
|
||||
@@ -1163,47 +1346,29 @@ 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`
|
||||
|
||||
```json
|
||||
{
|
||||
"entity_id": "sensor.room_temperature"
|
||||
}
|
||||
```
|
||||
|
||||
`entity_id` is optional; controller defaults/aliases are resolved. Response:
|
||||
No request body is required. The endpoint validates the active Home Assistant authentication path and reads the state registry. In the packaged add-on it uses Supervisor auth unless manual fallback is active. A successful response includes one readable entity sample when available, which the Web UI shows in the success toast:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"temperature_c": 23.4,
|
||||
"entity_id": "sensor.room_temperature"
|
||||
"auth_mode": "supervisor",
|
||||
"sample": {
|
||||
"entity_id": "sensor.living_room_temperature",
|
||||
"name": "Living room temperature",
|
||||
"state": "22.4",
|
||||
"unit": "°C"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /api/integrations/home-assistant/entities`
|
||||
|
||||
Returns a compact entity catalog for authenticated Web UI suggestions. Each row contains `entity_id`, friendly `name`, current `state`, `unit` and `device_class`. When HA is not configured it returns `configured: false` with an empty list.
|
||||
|
||||
### `POST /api/integrations/home-assistant/entity`
|
||||
|
||||
Reads the current raw Home Assistant entity document used by the shared-Flow-input diagnostics UI.
|
||||
@@ -1301,6 +1466,10 @@ Returns `Device[]`.
|
||||
|
||||
Accepts `DeviceCommand`. A direct command is rejected if the device belongs to a disabled thermostat zone; re-enable the zone for normal HA/controller ownership or use the administrator technical device endpoint deliberately.
|
||||
|
||||
### `GET /api/integrations/home-assistant/snapshot`
|
||||
|
||||
Returns `devices`, `groups`, `control_plan` and `control_plan_revision` in one restricted request. The bundled Home Assistant coordinator uses this endpoint to reduce its normal refresh from three HTTP requests to one. The existing restricted devices/groups/control-plan endpoints remain available.
|
||||
|
||||
### `GET /api/integrations/home-assistant/control-plan`
|
||||
|
||||
Same payload as administrator `GET /api/control-plan`.
|
||||
@@ -1336,6 +1505,10 @@ Same `{ "preset": "auto|comfort|sleep|away" }` semantics.
|
||||
|
||||
Same `{ "power": true|false }` semantics.
|
||||
|
||||
### `POST /api/integrations/home-assistant/house/emergency-stop`
|
||||
|
||||
Same `{ "active": true|false }` emergency-stop semantics as the administrator endpoint.
|
||||
|
||||
### `POST /api/integrations/home-assistant/zones/{id}/control`
|
||||
|
||||
Same `ZoneControlPatch` thermostat semantics as the normal zone control endpoint. The internal source is recorded as Home Assistant thermostat control.
|
||||
@@ -1372,12 +1545,13 @@ Every server event uses:
|
||||
}
|
||||
```
|
||||
|
||||
The first frame is always `bootstrap` with the same payload as `GET /api/bootstrap`, unless bootstrap generation itself fails.
|
||||
The first frame is always `bootstrap` with the same payload as `GET /api/bootstrap`, including `control_plan` and `control_plan_revision`, unless bootstrap generation itself fails. If the generic event queue is overrun, the server sends another full `bootstrap` to resynchronize the client instead of silently continuing with missed state.
|
||||
|
||||
Common live events include:
|
||||
|
||||
```text
|
||||
bootstrap
|
||||
control_plan.updated
|
||||
device.created
|
||||
device.updated
|
||||
device.deleted
|
||||
@@ -1395,14 +1569,42 @@ schedule.template_applied
|
||||
automation.created
|
||||
automation.updated
|
||||
automation.deleted
|
||||
settings.updated
|
||||
flow.created
|
||||
flow.updated
|
||||
flow.deleted
|
||||
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
|
||||
outdoor.updated
|
||||
configuration.imported
|
||||
debug.settings
|
||||
api.request
|
||||
gree.frame_received
|
||||
gree.frame
|
||||
log.created
|
||||
```
|
||||
|
||||
`control_plan.updated` uses `data.revision` plus `data.plan`. Revisions increase only when the materialized plan changes semantically; `generated_at` alone does not create a new revision.
|
||||
|
||||
`zone.updated` keeps the existing full-zone payload. Regulator passes that only advance the internal `updated_at` heartbeat are deduplicated and do not emit a frame; any actual zone state/sensor/control change still emits the full snapshot. Device polling keeps `device.updated` heartbeats for live `last_seen` UI, but heartbeat-only fields do not invalidate `control-plan`.
|
||||
|
||||
In `0.13.2`, the bundled Web UI mirrors entity CRUD events directly into its local state. Schedule-template replacement performs a targeted schedule resync, configuration import performs a full bootstrap resync, and runtime automation/device-health changes publish their updated snapshots immediately.
|
||||
|
||||
In `0.13.3`, logical thermostat targets use `0.1 °C` precision consistently. Temporary Quick Thermostat accepts comma/dot decimal input in the bundled Web UI; hardware-specific GREE target rounding is unchanged.
|
||||
|
||||
In `0.13.4`, disabled-zone thermostat controls remain visually active because explicit temporary/manual thermostat actions are still allowed while zone automation is disabled. The UI shows this distinction directly on the thermostat card; HTTP and WebSocket contracts are unchanged.
|
||||
|
||||
In `0.13.5`, the same disabled-zone behavior is explained directly beside the zone enabled/disabled setting, so users see before changing it that disabling a zone stops automation but does not block manual thermostat, temporary thermostat or direct manual control. HTTP and WebSocket contracts remain unchanged.
|
||||
|
||||
In `0.13.6`, notification settings add `alert_types.sensor_discrepancy`, allowing GREE vs Home Assistant temperature-difference notifications to be disabled independently from thermostat/group control errors. The sensor fallback behavior itself is unchanged.
|
||||
|
||||
In `0.13.7`, the bootstrap payload also contains all eight redacted settings views. The bundled Web UI applies the same bootstrap mapper for initial HTTP load and WebSocket resynchronization, so a bootstrap frame no longer triggers eight additional settings GET requests.
|
||||
|
||||
Additional engine/integration events may be introduced without changing the envelope.
|
||||
|
||||
`api.request` data:
|
||||
@@ -1448,10 +1650,10 @@ BASE='http://127.0.0.1:8787'
|
||||
AUTH='Authorization: Bearer APP_TOKEN'
|
||||
```
|
||||
|
||||
Discover devices:
|
||||
Scan local devices:
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE/api/discovery" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
curl -X POST "$BASE/api/discovery/scan" -H "$AUTH" -H 'Content-Type: application/json' \
|
||||
-d '{"protocol_version":0,"passes":3}'
|
||||
```
|
||||
|
||||
@@ -1516,7 +1718,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
|
||||
|
||||
@@ -1529,9 +1731,9 @@ Cancellation suppresses the same pending intent until a new explicit thermostat/
|
||||
|
||||
### Flow portability and diagnostics
|
||||
|
||||
`GET /api/flows/:id/export` returns a versioned `gree-controller-flow` JSON document containing only the source graph. Generated schedules/automations are not exported.
|
||||
`GET /api/flows/:id/export` returns a versioned `gree-controller-flow` JSON document containing the source graph plus definitions of every referenced Shared Input. Generated schedules/automations are not exported. The existing envelope may include an optional top-level `shared_inputs` array while leaving the `flow` object unchanged.
|
||||
|
||||
`POST /api/flows/import` accepts either that envelope or a direct Flow source payload, creates a new Flow ID/revision, and preserves draft state. Executable imports are validated and recompiled; draft imports remain disabled with no generated outputs.
|
||||
`POST /api/flows/import` accepts either that envelope or a direct Flow source payload, creates a new Flow ID/revision, and preserves draft state. Executable imports are validated and recompiled; draft imports remain disabled with no generated outputs. When the envelope contains `shared_inputs`, the importer creates missing Shared Inputs automatically. If an imported Shared Input ID collides with a different local definition, an equivalent local source is reused when available; otherwise a new `shared-<uuid>` ID is created and the imported Flow node is remapped. The Flow and newly created Shared Inputs are saved in one database transaction.
|
||||
|
||||
`POST /api/flows/simulate` accepts `{ flow, flow_id?, at?, overrides?, log? }`. `at` is RFC3339. `overrides` maps Flow node IDs to simulated values. The endpoint validates and compiles the graph, evaluates every action and returns per-node traces plus `matched`, `would_execute` and `blocked_reason` (for example disabled zone/device, manual/local/temporary thermostat ownership, thermostat-output conflict or disabled Flow). It never mutates thermostat/device/group/schedule/automation state. Home Assistant read/attribute/parse failures evaluate safely as false and are visible in the trace instead of accidentally satisfying `NOT`/`neq` logic.
|
||||
|
||||
@@ -1540,3 +1742,64 @@ Cancellation suppresses the same pending intent until a new explicit thermostat/
|
||||
Existing Flow updates require `expected_revision`. A mismatched revision returns HTTP 409 to prevent stale editor tabs from overwriting newer graphs. Source Flow plus generated outputs are replaced atomically in one database transaction while configuration/automation/schedule/thermostat-cycle operations are serialized.
|
||||
|
||||
Additional Flow condition blocks are `house_mode`, `device_state`, `zone_state`, `group_state`, `night_mode`, `ha_attribute`, `ha_available`, `constant` and `shared_input`, alongside weekday/time/date, temperature, Home Assistant state/numeric and AND/OR/NOT blocks. `device_state` can inspect enabled/online/power/mode/fan/swing/quiet/turbo/light/air/xfan/health/sleep state. The editor ships 37 categorized editable templates covering comfort, energy, safety, night, reliability, Home Assistant heat-source coordination and advanced multi-branch logic. The template UI adds search, favorites/recent history, a graph preview and runtime requirement checks; shared inputs expose usage links and HA-backed inputs can be tested against live entity state.
|
||||
|
||||
|
||||
|
||||
## Single-function unit automation — 0.15.2
|
||||
|
||||
Visual Flow adds `device_feature_action`, a dedicated action block that targets one GREE unit and sends exactly one `DeviceCommand` field. Supported fields are power, HVAC mode, target temperature, fan speed, vertical/horizontal louver position, Quiet, Turbo, Panel Light, Air, X-Fan, Health and Sleep. The block compiles to the normal Flow-owned `Automation` path, so cooldown, ownership checks and thermostat conflict protection remain unchanged.
|
||||
|
||||
Legacy direct-device automations expose fan speed and the same optional Quiet/Turbo/Panel Light/Air/X-Fan/Health/Sleep fields. Leaving all other fields as `No change` sends only the selected function.
|
||||
|
||||
## Granular louver positions — 0.15.0
|
||||
|
||||
Vertical (`swing_vertical`) and horizontal (`swing_horizontal`) controls now carry the raw GREE louver position instead of only on/off. Vertical supports `0..11`; horizontal supports `0..6`. The first values map to Off, Full range (Auto) and the fixed positions shown by GREE remotes/apps; vertical `7..11` additionally represent partial swing ranges.
|
||||
|
||||
Manual unit control and thermostat cards use position dropdowns. Legacy direct-device automations, `device_action` / `zone_thermostat` Flow actions and `device_state` conditions expose the same positions. The bundled Home Assistant direct and zone climate entities expose granular swing modes while retaining `off` and `on` as the first two modes for compatibility. Louver changes remain auxiliary one-shot commands and do not take thermostat ownership.
|
||||
|
||||
The command API continues accepting legacy boolean swing payloads (`false` = `0`, `true` = `1`) so existing clients can migrate without an immediate break.
|
||||
|
||||
## Swing control across manual, thermostat and automation surfaces — 0.14.2
|
||||
|
||||
Version 0.14.2 introduced independent vertical (`swing_vertical`) and horizontal (`swing_horizontal`) boolean swing controls across direct control, thermostat zones, legacy automations, Visual Flow and Home Assistant zone climates. Version 0.15.0 extends those fields to granular louver positions while preserving boolean command compatibility.
|
||||
|
||||
## Split/multisplit installations and energy comparison — 0.14.1
|
||||
|
||||
Installation configuration endpoints:
|
||||
|
||||
- `GET|POST /api/device-groups`
|
||||
- `GET|PUT|DELETE /api/device-groups/{id}`
|
||||
|
||||
A device group represents one physical split or multisplit installation. It contains one or more indoor `device_ids`, an optional shared GREE Cloud energy device or Home Assistant cumulative-energy entity, and an optional member device used as the shared outdoor-temperature source. One device may belong to only one installation.
|
||||
|
||||
Energy history accepts either a device ID or `group:<installation-id>` as a target. The Web UI can request several targets in parallel and overlay them on one chart. `compare=previous_day|previous_period|previous_year` shifts the requested range for period comparison; `compare=none` disables comparison. Daily buckets are presented as calendar dates.
|
||||
|
||||
When the final registered GREE Cloud device is removed, the controller closes the unused MQTT session. MQTT messages already in flight after removal are ignored while no Cloud devices are registered, preventing repeated stale-device warnings.
|
||||
|
||||
## GREE Cloud and energy — 0.14.0
|
||||
|
||||
Devices now expose `connection_type`, `connection_status`, `capabilities`, Cloud synchronization fields and energy-source metadata through the existing device API. Local and Cloud devices use the same command endpoint; dispatch is selected strictly by `connection_type`.
|
||||
|
||||
Cloud account/discovery/diagnostic endpoints:
|
||||
|
||||
- `GET|PUT /api/settings/gree-cloud`
|
||||
- `POST /api/integrations/gree-cloud/test`
|
||||
- `GET /api/integrations/gree-cloud/devices`
|
||||
- `POST /api/integrations/gree-cloud/devices/{cloud_id}/add`
|
||||
- `GET /api/integrations/gree-cloud/status`
|
||||
- `POST /api/integrations/gree-cloud/reconnect`
|
||||
- `GET /api/devices/{id}/cloud-diagnostics`
|
||||
|
||||
Energy endpoints:
|
||||
|
||||
- `GET /api/integrations/home-assistant/energy-sensors` lists compatible cumulative HA energy sensors. If Home Assistant is not configured, it returns `200` with `configured: false` and an empty `sensors` array; configured dependency failures use HTTP `424`.
|
||||
- `GET /api/history/energy?device_id=...&interval=hourly|daily|weekly|monthly&source=auto|gree_cloud|home_assistant` returns consumption buckets and period summaries in kWh.
|
||||
|
||||
Cloud settings and diagnostics are redacted: passwords, REST/MQTT tokens, Authorization values and device cipher keys are not returned.
|
||||
|
||||
|
||||
## Connectivity history — 0.14.2
|
||||
|
||||
`GET /api/history/network?hours=24&target_id=all` returns connectivity batches for Local/LAN units and, when enabled, controller-level `cloud:rest` and `cloud:mqtt` endpoints. Each reading contains `latency_ms`, `jitter_ms`, `packet_loss_pct`, `sample_count`, `successful_samples`, `target_kind` and `source`.
|
||||
|
||||
Local connectivity sampling is configured through `GET/PUT /api/settings/gree` using `ping_metrics_enabled`, `ping_interval_seconds` and `ping_sample_count`. Direct unit probes are Local/LAN-only. GREE Cloud connectivity sampling is configured through `GET/PUT /api/settings/gree-cloud` using `connectivity_metrics_enabled`, `connectivity_metrics_interval_seconds` and `connectivity_metrics_sample_count`; it is disabled by default and measures REST login response plus MQTT `PINGRESP` round-trip. Connectivity history follows the same SQLite retention/compaction and optional InfluxDB archive policy as other history metrics.
|
||||
|
||||
+10
-9
@@ -4,7 +4,7 @@ Flow is an authoring/orchestration layer. It does not replace the thermostat, sc
|
||||
|
||||
## Compilation
|
||||
|
||||
- `weekday + time_range -> zone_thermostat` with schedule-compatible settings compiles to a native `Schedule`.
|
||||
- `weekday + time_range -> zone_thermostat` with schedule-compatible settings compiles to a native `Schedule`; selecting a vertical or horizontal louver position makes the action compile as an `Automation` so the louver command is not lost.
|
||||
- Richer DAGs compile to native `Automation` rows with `trigger_kind = "flow"`.
|
||||
- Generated rows keep `flow_id` and `flow_node_id`, use stable `flow-<stable-unique-id>` names, and are read-only in the legacy editors.
|
||||
- The Flow source graph plus all of its generated schedule/automation rows are replaced in one SQLite transaction.
|
||||
@@ -14,11 +14,12 @@ Flow is an authoring/orchestration layer. It does not replace the thermostat, sc
|
||||
|
||||
## Existing control domains remain authoritative
|
||||
|
||||
- `zone_thermostat` writes the existing persistent Zone intent and wakes the normal thermostat cycle.
|
||||
- `zone_thermostat` writes the existing persistent Zone intent and wakes the normal thermostat cycle. It can also issue one-shot granular vertical/horizontal louver commands to the assigned unit; these auxiliary fields do not take over thermostat ownership.
|
||||
- `group_action` calls the existing `control_group` path, including custom group targets.
|
||||
- `device_action` uses the existing `DeviceCommand` / automatic-device path.
|
||||
- `device_feature_action` uses the same path but compiles exactly one selected `DeviceCommand` field, leaving all other unit settings untouched.
|
||||
- Thermostat hysteresis, compressor protection, schedule hand-back, manual/local ownership and Temporary Quick Thermostat keep their existing priority.
|
||||
- GREE fan/quiet/sleep and dry/fan HVAC modes are suppressed on thermostat-assigned devices when they would fight the regulator. Device-only features such as light/turbo/swing/air/xfan/health remain available through the normal command path.
|
||||
- GREE fan/quiet/sleep and dry/fan HVAC modes are suppressed on thermostat-assigned devices when they would fight the regulator. Device-only features such as light/turbo/louver/air/xfan/health remain available through the normal command path.
|
||||
|
||||
## Concurrency invariants
|
||||
|
||||
@@ -53,25 +54,25 @@ The Flow editor exposes:
|
||||
- per-node trace,
|
||||
- `would_execute` and ownership/block reason,
|
||||
- Flow-scoped execution/dry-run logs,
|
||||
- import/export of versioned `.flow.json` source graphs.
|
||||
- import/export of versioned `.flow.json` source graphs. Flow exports include the definitions of referenced Shared Inputs; import creates missing inputs automatically and remaps ID collisions without overwriting a different local input.
|
||||
|
||||
Dry-run never mutates thermostats, devices, groups, schedules or automations. For an existing Flow it clones persisted stateful runtime data, so timers, last-observed values, rate-limit history and rolling/oscillation windows are evaluated against the same saved state without writing it back.
|
||||
|
||||
## Blocks
|
||||
|
||||
Condition/source blocks include weekday, time range, date range, optional 5-field CRON triggers, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, rolling mean/median windows, numeric oscillation detection, a diagnostic constant, and `shared_input`. Stateful gates include `stable_for`, bounded `state_duration`, `on_change` (condition-result or raw-value edge), `rate_limit` (maximum successful executions in a rolling period), and `delay`. Their runtime state, together with rolling statistics and oscillation samples, is persisted in the generated automation payload so restarts do not silently reset timing/history. Editing a stateful block resets only that block's incompatible runtime state. Flow state continues to be observed while the action itself is in cooldown, preventing stale continuity/change state. A `rate_limit` block must be placed directly before an action and consumes quota only after a successful execution; suppressed or failed actions do not consume it. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID. Shared inputs store reusable value sources only; operators and comparison values are configured exclusively in each Flow reference. Logic blocks are AND, OR and NOT.
|
||||
Condition/source blocks include weekday, time range, date range, optional 5-field CRON triggers, application Night mode, outdoor/device/zone temperature, house mode, device state, thermostat/zone state, group state, Home Assistant state/numeric/attribute/availability, rolling mean/median windows, numeric oscillation detection, a diagnostic constant, and `shared_input`. Stateful gates include `stable_for`, bounded `state_duration`, `on_change` (condition-result or raw-value edge), `rate_limit` (maximum successful executions in a rolling period), and `delay`. Their runtime state, together with rolling statistics and oscillation samples, is persisted in the generated automation payload so restarts do not silently reset timing/history. Editing a stateful block resets only that block's incompatible runtime state. Flow state continues to be observed while the action itself is in cooldown. `on_change` latches a detected event while its upstream condition remains true and clears it only after a successful downstream action, so cooldown, ownership suppression or same-cycle target arbitration cannot silently consume the edge. Rolling mean/median blocks wait for one complete configured time window before matching and restart that warm-up after their source becomes unavailable. A `rate_limit` block must be placed directly before an action and consumes quota only after a successful execution; suppressed or failed actions do not consume it. Shared inputs are configured centrally in Home Assistant / Sensors and referenced by ID. Shared inputs store reusable value sources only; operators and comparison values are configured exclusively in each Flow reference. Logic blocks are AND, OR and NOT.
|
||||
|
||||
Actions include thermostat zone, GREE device, climate group, and generic Home Assistant service calls (`domain`, `service`, optional `entity_id`, JSON service data). Direct GREE actions expose power, HVAC mode, target temperature, fan speed, vertical/horizontal swing, quiet, turbo, light, air, xfan, health and sleep.
|
||||
Actions include thermostat zone, full GREE device command, single GREE function, climate group, and generic Home Assistant service calls (`domain`, `service`, optional `entity_id`, JSON service data). Direct GREE actions expose power, HVAC mode, target temperature, fan speed, vertical/horizontal louver position, quiet, turbo, light, air, xfan, health and sleep. The single-function block writes exactly one of those fields.
|
||||
|
||||
## Built-in presets
|
||||
|
||||
The editor ships 37 editable presets grouped into seven categories: Comfort, Energy & cost, Protection & safety, Night, Reliability, Home Assistant / heat sources and Advanced. The library UI also provides search, Favorites, Recent, a mini graph preview and requirement checks before applying a preset. The library includes workday/weekend comfort, weather-aware demand, morning boost, presence and energy-price eco modes, peak-power limiting, open-window/humidity/overheat/frost guards, night profiles, sensor/device resilience, dual heating/cooling thresholds, multi-room group control, nested AND/OR/NOT Home Assistant scenarios, and gas-boiler/external-heat-source coordination including off, reduced-target, boost and fallback heating patterns.
|
||||
|
||||
Shared Flow inputs show the Flows that reference them and link directly to those editors. Home Assistant-backed shared inputs can be tested live in the input modal. Shared inputs report the current raw value/attribute. Each Flow reference defines its own comparison without changing the shared source.
|
||||
Shared Flow inputs show the Flows that reference them and link directly to those editors. The Flow editor keeps the natural-language Interpretation / Flow cycle panel collapsed by default on every viewport and exposes it through a disclosure button. Home Assistant-backed shared inputs can be tested live in the input modal. Shared inputs report the current raw value/attribute. Each Flow reference defines its own comparison without changing the shared source. Home Assistant temperature entities remain normal HA entities: room-temperature sensors are configured per zone, while outdoor temperature has one global `outdoor_entity_id` plus an optional per-zone `ha_outdoor_entity_id` override. Their aliases are shared with the rest of the UI, outdoor overrides are recorded in sensor metrics/history, and all configured entities are offered as suggestions in Home Assistant Flow blocks; selecting a zone sensor does not create a separate shared input.
|
||||
|
||||
Presets are data files, not JavaScript definitions. Every preset lives in `presets/<id>.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/<file>.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js/flows.js`.
|
||||
Presets are data files, not JavaScript definitions. Every preset lives in `presets/<id>.json` and contains `id`, `category`, localized `name`, localized `description`, and the complete `flow` graph (`nodes` + `edges`). `build.rs` validates and embeds the directory, exposes `/presets/index.json` plus `/presets/<file>.json`, and the Flow editor loads that library dynamically. Device/zone/group placeholders such as `$zone1`, `$zone2`, `$device1` and `$group1` are resolved when a preset is applied. Adding a preset therefore does not require adding graph-building code to `web/js-dynamic/flows.js`.
|
||||
|
||||
The canvas supports multi-selection (Shift/Ctrl/Cmd), `Ctrl/Cmd+A`, Select all / Clear selection toolbar actions, and dragging the whole selected group while preserving relative positions.
|
||||
The canvas supports drag-box multi-selection, additive Shift/Ctrl/Cmd selection, `Ctrl/Cmd+A`, Select all / Clear selection toolbar actions, and dragging the whole selected group while preserving relative positions. Selected blocks can be duplicated from the toolbar or with `Ctrl/Cmd+D`; `Ctrl/Cmd+C`, `Ctrl/Cmd+X` and `Ctrl/Cmd+V` copy/cut/paste the selected subgraph and preserve connections whose endpoints are both selected. Keyboard-first authoring also supports `A`, `Insert` or `Ctrl/Cmd+K` to open block search, `Enter` to insert the first matching block, `Ctrl/Cmd+S` to save, `Ctrl/Cmd+Z` to undo graph/setting edits, Delete/Backspace to remove selected blocks, Escape to clear selection/cancel a pending connection, arrow keys to move the selection (Shift for 1 px precision), `Ctrl/Cmd+0` to fit, `Ctrl/Cmd++/-` to zoom and `?` to open the built-in shortcut reference. Shortcut success messages are shown as a small status inside the Flow editor instead of global toasts. Right-click opens a context menu: blocks expose copy/cut/duplicate/delete plus paste/add, blank canvas exposes undo/add/paste/select-all/fit, and connections can be removed directly. Context-menu paste on blank canvas places the copied subgraph at the clicked position.
|
||||
|
||||
## Good next stateful blocks
|
||||
|
||||
|
||||
+10009
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
# OCI image
|
||||
REGISTRY=repo.example.com
|
||||
IMAGE_NAME=gree-controller
|
||||
DISTRO=alpine
|
||||
RUST_VERSION=1.98.1
|
||||
BUILD_PLATFORMS=linux/amd64,linux/arm64
|
||||
BUILDER=gree-controller-multiarch
|
||||
BUILDER_CONFIG=
|
||||
|
||||
# Home Assistant distribution repository
|
||||
HA_REPO_URL=https://repo.example.com/gree-controller-ha-addon/
|
||||
HA_REPO_BRANCH=master
|
||||
HA_REPO_PATH=../gree-controller-ha-addon
|
||||
|
||||
# Repository layout
|
||||
ADDON_DIR=gree-controller
|
||||
INTEGRATION_DOMAIN=gree_controller
|
||||
@@ -0,0 +1,126 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
ARG RUST_VERSION=1.98.1
|
||||
ARG XX_VERSION=1.9.0
|
||||
|
||||
# Cross-compilation helpers. Builder stages always run on BUILDPLATFORM, so an
|
||||
# amd64 host can compile arm64 without running rustc/cargo under QEMU.
|
||||
FROM --platform=$BUILDPLATFORM tonistiigi/xx:${XX_VERSION} AS xx
|
||||
|
||||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-trixie AS builder-trixie
|
||||
COPY --from=xx / /
|
||||
WORKDIR /src
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends clang lld llvm file pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Fetch Rust dependencies before TARGETPLATFORM enters the cache chain, so the
|
||||
# same registry/git cache is reused by amd64 and arm64 builds.
|
||||
COPY Cargo.toml ./
|
||||
RUN --mount=type=cache,id=gree-controller-cargo-registry,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=gree-controller-cargo-git,target=/usr/local/cargo/git \
|
||||
mkdir -p src \
|
||||
&& printf 'fn main() {}\n' > src/main.rs \
|
||||
&& cargo generate-lockfile \
|
||||
&& cargo fetch --locked \
|
||||
&& rm -rf src
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETARCH
|
||||
# Prepare the target C sysroot and Rust stdlib in a stable layer. Do not call
|
||||
# the xx-cargo version probe: xx-cargo is a build wrapper and that invocation
|
||||
# makes Cargo receive a target option without a subcommand.
|
||||
RUN xx-apt-get update \
|
||||
&& xx-apt-get install -y --no-install-recommends xx-c-essentials \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rustup target add "$(xx-cargo --print-target-triple)"
|
||||
|
||||
COPY build.rs ./
|
||||
COPY src ./src
|
||||
COPY web ./web
|
||||
COPY lang ./lang
|
||||
COPY presets ./presets
|
||||
COPY docs/openapi.json ./docs/openapi.json
|
||||
RUN --mount=type=cache,id=gree-controller-cargo-registry,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=gree-controller-cargo-git,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,id=gree-controller-target-trixie-${TARGETARCH},target=/src/target \
|
||||
set -eux; \
|
||||
xx-cargo build --locked --release --target-dir /src/target; \
|
||||
binary="/src/target/$(xx-cargo --print-target-triple)/release/gree-controller"; \
|
||||
xx-verify "$binary"; \
|
||||
mkdir -p /out; \
|
||||
cp "$binary" /out/gree-controller
|
||||
|
||||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-alpine AS builder-alpine
|
||||
COPY --from=xx / /
|
||||
WORKDIR /src
|
||||
RUN apk add --no-cache clang lld llvm file pkgconf
|
||||
|
||||
# Keep dependency fetching architecture-independent here as well.
|
||||
COPY Cargo.toml ./
|
||||
RUN --mount=type=cache,id=gree-controller-cargo-registry,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=gree-controller-cargo-git,target=/usr/local/cargo/git \
|
||||
mkdir -p src \
|
||||
&& printf 'fn main() {}\n' > src/main.rs \
|
||||
&& cargo generate-lockfile \
|
||||
&& cargo fetch --locked \
|
||||
&& rm -rf src
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG TARGETARCH
|
||||
RUN xx-apk add --no-cache xx-c-essentials \
|
||||
&& rustup target add "$(xx-cargo --print-target-triple)"
|
||||
|
||||
COPY build.rs ./
|
||||
COPY src ./src
|
||||
COPY web ./web
|
||||
COPY lang ./lang
|
||||
COPY presets ./presets
|
||||
COPY docs/openapi.json ./docs/openapi.json
|
||||
RUN --mount=type=cache,id=gree-controller-cargo-registry,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,id=gree-controller-cargo-git,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,id=gree-controller-target-alpine-${TARGETARCH},target=/src/target \
|
||||
set -eux; \
|
||||
xx-cargo build --locked --release --target-dir /src/target; \
|
||||
binary="/src/target/$(xx-cargo --print-target-triple)/release/gree-controller"; \
|
||||
xx-verify "$binary"; \
|
||||
mkdir -p /out; \
|
||||
cp "$binary" /out/gree-controller
|
||||
|
||||
FROM --platform=$TARGETPLATFORM debian:trixie-slim AS runtime-trixie
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG TARGETARCH
|
||||
LABEL io.hass.version="${BUILD_VERSION}" \
|
||||
io.hass.type="app" \
|
||||
org.opencontainers.image.base.name="debian:trixie-slim" \
|
||||
org.opencontainers.image.description="GREE Controller Home Assistant add-on" \
|
||||
org.opencontainers.image.vendor="MateuszG" \
|
||||
org.opencontainers.image.title="GREE Controller" \
|
||||
org.opencontainers.image.version="${BUILD_VERSION}" \
|
||||
org.opencontainers.image.architecture="${TARGETARCH}"
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates tzdata jq iproute2 curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder-trixie /out/gree-controller /usr/local/bin/gree-controller
|
||||
COPY ha-addon/run.sh /usr/local/bin/run.sh
|
||||
RUN chmod 0755 /usr/local/bin/run.sh /usr/local/bin/gree-controller
|
||||
EXPOSE 8787/tcp
|
||||
ENTRYPOINT ["/usr/local/bin/run.sh"]
|
||||
|
||||
FROM --platform=$TARGETPLATFORM alpine:latest AS runtime-alpine
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG TARGETARCH
|
||||
LABEL io.hass.version="${BUILD_VERSION}" \
|
||||
io.hass.type="app" \
|
||||
org.opencontainers.image.base.name="alpine:latest" \
|
||||
org.opencontainers.image.description="GREE Controller Home Assistant add-on" \
|
||||
org.opencontainers.image.vendor="MateuszG" \
|
||||
org.opencontainers.image.title="GREE Controller" \
|
||||
org.opencontainers.image.version="${BUILD_VERSION}" \
|
||||
org.opencontainers.image.architecture="${TARGETARCH}"
|
||||
RUN apk add --no-cache bash ca-certificates tzdata jq iproute2 curl
|
||||
COPY --from=builder-alpine /out/gree-controller /usr/local/bin/gree-controller
|
||||
COPY ha-addon/run.sh /usr/local/bin/run.sh
|
||||
RUN chmod 0755 /usr/local/bin/run.sh /usr/local/bin/gree-controller
|
||||
EXPOSE 8787/tcp
|
||||
ENTRYPOINT ["/usr/local/bin/run.sh"]
|
||||
@@ -0,0 +1,85 @@
|
||||
# GREE Controller — Home Assistant distribution
|
||||
|
||||
This directory contains the OCI build, Home Assistant add-on repository source, custom integration and repository synchronization tooling.
|
||||
|
||||
The packaged controller includes its HTTP/WebSocket API and built-in Swagger documentation at `/api-docs` (`/api-docs/openapi.json` for OpenAPI 3.1).
|
||||
|
||||
## Configuration
|
||||
|
||||
Create the local build/repository configuration once:
|
||||
|
||||
```bash
|
||||
cd ha-addon
|
||||
cp .env.example .env
|
||||
$EDITOR .env
|
||||
```
|
||||
|
||||
`build.sh` and `sync-repository.sh` read all deployment-specific values from `.env`; `.env` is ignored by Git.
|
||||
|
||||
Important variables:
|
||||
|
||||
```dotenv
|
||||
REGISTRY=repo.example.com
|
||||
IMAGE_NAME=gree-controller
|
||||
DISTRO=alpine
|
||||
RUST_VERSION=1.98.1
|
||||
BUILD_PLATFORMS=linux/amd64,linux/arm64
|
||||
BUILDER=gree-controller-multiarch
|
||||
BUILDER_CONFIG=
|
||||
HA_REPO_URL=https://repo.example.com/gree-controller-ha-addon/
|
||||
HA_REPO_BRANCH=master
|
||||
HA_REPO_PATH=../gree-controller-ha-addon
|
||||
ADDON_DIR=gree-controller
|
||||
INTEGRATION_DOMAIN=gree_controller
|
||||
```
|
||||
|
||||
## OCI image
|
||||
|
||||
```bash
|
||||
./build.sh render
|
||||
./build.sh load amd64
|
||||
./build.sh load aarch64
|
||||
./build.sh push
|
||||
```
|
||||
|
||||
The project version comes only from root `Cargo.toml`. `render` synchronizes it to add-on `config.yaml` and the custom integration `manifest.json`, and sets the OCI image from `.env`. The container build generates `Cargo.lock` from the exact direct dependency versions in `Cargo.toml` before fetching and compiling dependencies.
|
||||
|
||||
For the Home Assistant package, TCP `8787` is an intentional fixed host-network/ingress port contract. `build.sh render` verifies `config.yaml`, `run.sh`, the watchdog URL and Docker image metadata stay consistent. The runtime also compares the Supervisor-reported ingress port before starting. Standalone installations remain free to use any `GREE_CONTROLLER_BIND` port.
|
||||
|
||||
`DISTRO` still selects the target image system: `alpine` builds a musl binary for Alpine and `trixie` builds a GNU/glibc binary for Debian Trixie. Multi-architecture builds continue to run through `build.sh`; the Docker builder cross-compiles Rust on `BUILDPLATFORM` so ARM compilation does not run under QEMU, and BuildKit cache mounts preserve Cargo downloads and target artifacts between builds.
|
||||
|
||||
## Home Assistant repository
|
||||
|
||||
```bash
|
||||
./sync-repository.sh
|
||||
```
|
||||
|
||||
The command updates/clones `HA_REPO_URL`, mirrors the repository and pushes only when content changed. The resulting repository contains:
|
||||
|
||||
```text
|
||||
repository.yaml
|
||||
gree-controller/ # Home Assistant add-on
|
||||
custom_components/gree_controller/ # Home Assistant custom integration
|
||||
```
|
||||
|
||||
The custom integration source is maintained only in:
|
||||
|
||||
```text
|
||||
ha-addon/home-assistant/custom_components/gree_controller/
|
||||
```
|
||||
|
||||
It is added to the separate HA repository during synchronization, so it is not duplicated in this project.
|
||||
|
||||
Adding the repository to the Home Assistant add-on store installs the add-on only. The bundled custom integration can be copied from the same repository to `/config/custom_components/gree_controller/` when required.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
.env.example local deployment configuration template
|
||||
Dockerfile multi-architecture image
|
||||
build.sh metadata + OCI build/push
|
||||
run.sh add-on entrypoint
|
||||
sync-repository.sh separate HA repository synchronization
|
||||
repository/ add-on repository source
|
||||
home-assistant/ custom integration source and migration output
|
||||
```
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ENV_FILE="${HA_ADDON_ENV_FILE:-$SCRIPT_DIR/.env}"
|
||||
|
||||
[[ -f "$ENV_FILE" ]] || {
|
||||
echo "Missing $ENV_FILE. Copy $SCRIPT_DIR/.env.example to $SCRIPT_DIR/.env and edit it." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
required=(REGISTRY IMAGE_NAME DISTRO RUST_VERSION BUILD_PLATFORMS BUILDER HA_REPO_URL ADDON_DIR INTEGRATION_DOMAIN)
|
||||
for name in "${required[@]}"; do
|
||||
[[ -n "${!name:-}" ]] || { echo "Missing $name in $ENV_FILE" >&2; exit 1; }
|
||||
done
|
||||
|
||||
case "$DISTRO" in
|
||||
trixie|alpine) ;;
|
||||
*) echo "DISTRO must be one of: trixie, alpine" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
ACTION="${1:-push}"
|
||||
VERSION="$(awk -F '"' '/^version = "/ {print $2; exit}' "$PROJECT_ROOT/Cargo.toml")"
|
||||
[[ -n "$VERSION" ]] || { echo "Cannot determine version from Cargo.toml" >&2; exit 1; }
|
||||
|
||||
IMAGE_REPO="${REGISTRY%/}/${IMAGE_NAME#/}"
|
||||
IMAGE_TAG="${IMAGE_REPO}:${VERSION}"
|
||||
CONFIG="$SCRIPT_DIR/repository/$ADDON_DIR/config.yaml"
|
||||
INTEGRATION_MANIFEST="$SCRIPT_DIR/home-assistant/custom_components/$INTEGRATION_DOMAIN/manifest.json"
|
||||
REPOSITORY_CONFIG="$SCRIPT_DIR/repository/repository.yaml"
|
||||
RUN_SCRIPT="$SCRIPT_DIR/run.sh"
|
||||
DOCKERFILE="$SCRIPT_DIR/Dockerfile"
|
||||
|
||||
[[ -f "$CONFIG" ]] || { echo "Missing add-on config: $CONFIG" >&2; exit 1; }
|
||||
[[ -f "$INTEGRATION_MANIFEST" ]] || { echo "Missing integration manifest: $INTEGRATION_MANIFEST" >&2; exit 1; }
|
||||
[[ -f "$REPOSITORY_CONFIG" ]] || { echo "Missing repository config: $REPOSITORY_CONFIG" >&2; exit 1; }
|
||||
|
||||
render_metadata() {
|
||||
command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; }
|
||||
python3 - "$CONFIG" "$INTEGRATION_MANIFEST" "$REPOSITORY_CONFIG" "$VERSION" "$IMAGE_REPO" "$HA_REPO_URL" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
config = Path(sys.argv[1])
|
||||
manifest = Path(sys.argv[2])
|
||||
repository = Path(sys.argv[3])
|
||||
version = sys.argv[4]
|
||||
image = sys.argv[5]
|
||||
repo_url = sys.argv[6]
|
||||
|
||||
lines = config.read_text(encoding="utf-8").splitlines()
|
||||
out = []
|
||||
for line in lines:
|
||||
if line.startswith("version:"):
|
||||
out.append(f'version: "{version}"')
|
||||
elif line.startswith("image:"):
|
||||
out.append(f'image: "{image}"')
|
||||
elif line.startswith("url:"):
|
||||
out.append(f'url: "{repo_url}"')
|
||||
else:
|
||||
out.append(line)
|
||||
config.write_text("\n".join(out) + "\n", encoding="utf-8")
|
||||
|
||||
import json
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
data["version"] = version
|
||||
manifest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
repo_lines = repository.read_text(encoding="utf-8").splitlines()
|
||||
repo_out = [f"url: {repo_url}" if line.startswith("url:") else line for line in repo_lines]
|
||||
repository.write_text("\n".join(repo_out) + "\n", encoding="utf-8")
|
||||
PY
|
||||
printf 'Synced version %s; image %s\n' "$VERSION" "$IMAGE_REPO"
|
||||
}
|
||||
|
||||
validate_addon_port_contract() {
|
||||
local ingress_port
|
||||
ingress_port="$(awk '/^ingress_port:/ {print $2; exit}' "$CONFIG")"
|
||||
[[ "$ingress_port" =~ ^[0-9]+$ ]] || { echo "Invalid ingress_port in $CONFIG" >&2; exit 1; }
|
||||
grep -Fqx "readonly HA_HTTP_PORT=${ingress_port}" "$RUN_SCRIPT" || {
|
||||
echo "HA port contract mismatch: ingress_port=${ingress_port}, run.sh differs" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -Fq "watchdog: \"http://[HOST]:[PORT:${ingress_port}]/api/health\"" "$CONFIG" || {
|
||||
echo "HA port contract mismatch: watchdog must use [PORT:${ingress_port}]" >&2
|
||||
exit 1
|
||||
}
|
||||
grep -Fqx "hassio_api: true" "$CONFIG" || {
|
||||
echo "HA add-on contract mismatch: hassio_api must be enabled for Supervisor runtime discovery" >&2
|
||||
exit 1
|
||||
}
|
||||
local expose_count
|
||||
expose_count="$(grep -Ec "^EXPOSE ${ingress_port}/tcp$" "$DOCKERFILE" || true)"
|
||||
(( expose_count >= 1 )) || {
|
||||
echo "HA port contract mismatch: Dockerfile must expose ${ingress_port}/tcp" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
ensure_builder() {
|
||||
command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 1; }
|
||||
docker buildx version >/dev/null 2>&1 || { echo "docker buildx is required" >&2; exit 1; }
|
||||
|
||||
if docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
|
||||
docker buildx use "$BUILDER"
|
||||
else
|
||||
args=(create --name "$BUILDER" --driver docker-container --use)
|
||||
[[ -z "${BUILDER_CONFIG:-}" ]] || args+=(--config "$BUILDER_CONFIG")
|
||||
docker buildx "${args[@]}" >/dev/null
|
||||
fi
|
||||
docker buildx inspect --bootstrap >/dev/null
|
||||
}
|
||||
|
||||
build_push() {
|
||||
ensure_builder
|
||||
docker buildx build \
|
||||
--platform "$BUILD_PLATFORMS" \
|
||||
-f "$SCRIPT_DIR/Dockerfile" \
|
||||
--target "runtime-${DISTRO}" \
|
||||
--build-arg "BUILD_VERSION=$VERSION" \
|
||||
--build-arg "RUST_VERSION=$RUST_VERSION" \
|
||||
-t "$IMAGE_TAG" \
|
||||
--push \
|
||||
"$PROJECT_ROOT"
|
||||
printf 'Published %s (%s, %s)\n' "$IMAGE_TAG" "$BUILD_PLATFORMS" "$DISTRO"
|
||||
}
|
||||
|
||||
build_load() {
|
||||
local arch="${2:-amd64}" platform
|
||||
case "$arch" in
|
||||
amd64) platform="linux/amd64" ;;
|
||||
aarch64|arm64) platform="linux/arm64" ;;
|
||||
*) echo "Usage: $0 load [amd64|aarch64]" >&2; exit 2 ;;
|
||||
esac
|
||||
ensure_builder
|
||||
docker buildx build \
|
||||
--platform "$platform" \
|
||||
-f "$SCRIPT_DIR/Dockerfile" \
|
||||
--target "runtime-${DISTRO}" \
|
||||
--build-arg "BUILD_VERSION=$VERSION" \
|
||||
--build-arg "RUST_VERSION=$RUST_VERSION" \
|
||||
-t "${IMAGE_REPO}:local" \
|
||||
--load \
|
||||
"$PROJECT_ROOT"
|
||||
printf 'Loaded %s:local for %s (%s)\n' "$IMAGE_REPO" "$arch" "$DISTRO"
|
||||
}
|
||||
|
||||
render_metadata
|
||||
validate_addon_port_contract
|
||||
|
||||
case "$ACTION" in
|
||||
render) ;;
|
||||
push) build_push ;;
|
||||
load) build_load "${2:-amd64}" ;;
|
||||
*)
|
||||
echo "Usage: $0 [render|push|load [amd64|aarch64]]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -3,14 +3,16 @@
|
||||
The project includes a custom Home Assistant integration under:
|
||||
|
||||
```text
|
||||
home-assistant/custom_components/gree_controller/
|
||||
ha-addon/home-assistant/custom_components/gree_controller/
|
||||
```
|
||||
|
||||
It creates HA entities for physical units, thermostat zones, whole-house controls and climate groups, while sending every command to the standalone Rust controller. Home Assistant therefore remains a client and UDP/AES GREE communication stays outside HA.
|
||||
|
||||
The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing.
|
||||
The controller exposes the API used by the integration and Web UI under `/api/*` and `/ws`; interactive Swagger documentation is available at `/api-docs`, with OpenAPI JSON at `/api-docs/openapi.json`.
|
||||
|
||||
Version 0.5.0 additionally exposes the controller's automation plan:
|
||||
The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, granular vertical louver positions and granular horizontal louver positions. `off`/`on` remain the first two swing modes for compatibility, followed by fixed positions and the supported vertical partial-swing ranges. The card UI translates those modes using the Home Assistant language while the underlying mode IDs (for example `fixed_upper`) remain unchanged for services, scripts and automations.
|
||||
|
||||
Automation plan:
|
||||
|
||||
- `sensor` for the whole-house plan (house mode, upcoming events and active rules),
|
||||
- one `sensor` per thermostat zone with current demand, active preset/schedule and upcoming transitions,
|
||||
@@ -27,6 +29,8 @@ Copy the directory into your HA configuration:
|
||||
/config/custom_components/gree_controller/
|
||||
```
|
||||
|
||||
`sync-repository.sh` also publishes this integration as `custom_components/gree_controller/` in the same Git repository as the add-on. The add-on itself does not install the integration automatically.
|
||||
|
||||
Before adding the integration, open the standalone controller Web UI and go to **More -> Home Assistant / Sensors -> Create new token**. Copy the generated secret; it is shown only once.
|
||||
|
||||
Restart Home Assistant, then open **Settings -> Devices & services -> Add integration -> GREE Controller** and enter only:
|
||||
@@ -110,21 +114,16 @@ Tokens are used only during validation and are not written to the mapping file.
|
||||
|
||||
## HA as an external temperature source
|
||||
|
||||
This is independent from the custom climate integration. Each Rust controller zone can assign its own HA room-temperature entity, for example:
|
||||
This is independent from the custom climate integration. Home Assistant room-temperature sensors are configured per zone with `ha_entity_id`. The recommended `combined` strategy keeps the GREE sensor as the primary input and uses the selected Home Assistant room sensor as a configurable supporting measurement (40% weight by default). If HA or the selected room entity becomes unavailable, the controller falls back to the corresponding GREE unit, so local control and schedules continue to run.
|
||||
|
||||
```text
|
||||
Living room -> GREE Living Room + sensor.living_room_temperature
|
||||
Bedroom -> GREE Bedroom + sensor.bedroom_temperature
|
||||
```
|
||||
|
||||
The recommended `combined` strategy keeps the GREE sensor as the primary input and uses the room sensor as a configurable supporting measurement (40% weight by default). A zone may also select the room sensor as its preferred source. If HA or that entity becomes unavailable, the controller falls back to the corresponding GREE unit, so local control and schedules continue to run.
|
||||
## Zone climate and optional unit features (0.5.4)
|
||||
Outdoor temperature is separate: `outdoor_entity_id` / `HA_OUTDOOR_ENTITY_ID` is the global Home Assistant outdoor-temperature sensor. A zone may optionally set `ha_outdoor_entity_id` to use a different outdoor sensor for that zone's outdoor-temperature assist. If the override is unavailable, the global outdoor source is used. Global and per-zone outdoor entities participate in aliases, metrics/history and Flow entity suggestions. The Home Assistant connection test validates only the server URL/API token and does not require any entity.
|
||||
## Zone climate and optional unit features
|
||||
|
||||
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
|
||||
|
||||
From version 0.7.9, each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch reports the configured zone state independently from group control. Turning a group OFF powers its member units down and releases group ownership. Members are not blocked merely because the group remains OFF: an individual thermostat can be turned back on independently. Turning the group ON clears that scoped OFF state and immediately resumes group thermostat arbitration. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone. From 0.7.12, every local OFF creates a fresh backend-owned 15-minute hand-back deadline exposed as `local_thermostat_resume_at`. A direct/pilot takeover suspends expiry of that local timer; if the unit is returned to the previous OFF state, the backend re-arms a fresh 15-minute countdown from that moment. The separate zone Enabled switch remains the technical availability switch.
|
||||
Each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch reports the configured zone state independently from group control. Turning a group OFF powers its member units down and releases group ownership. Members are not blocked merely because the group remains OFF: an individual thermostat can be turned back on independently. Turning the group ON clears that scoped OFF state and immediately resumes group thermostat arbitration. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone. From 0.7.12, every local OFF creates a fresh backend-owned 15-minute hand-back deadline exposed as `local_thermostat_resume_at`. A direct/pilot takeover suspends expiry of that local timer; if the unit is returned to the previous OFF state, the backend re-arms a fresh 15-minute countdown from that moment. The separate zone Enabled switch remains the technical availability switch.
|
||||
|
||||
From version 0.6.4, zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
|
||||
Zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
|
||||
|
||||
When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created.
|
||||
|
||||
@@ -140,7 +139,7 @@ The integration also exposes three controller-level entities on the **GREE Contr
|
||||
These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints.
|
||||
|
||||
|
||||
## Climate groups (0.7.3)
|
||||
## Climate groups
|
||||
|
||||
Every group configured in **GREE Controller -> Groups** is published as a separate Home Assistant device. A group does not receive a fake common target temperature because its member zones can legitimately use different profile temperatures. Instead, the group device exposes the controls that exactly match the controller model:
|
||||
|
||||
@@ -153,6 +152,6 @@ If member zones have been changed individually and no longer share one mode or p
|
||||
|
||||
After upgrading the custom integration, restart Home Assistant or reload **Settings -> Devices & services -> GREE Controller**. Also reload the integration after adding/removing/renaming groups so new group devices/entities are created.
|
||||
|
||||
## Command state stability (0.7.6)
|
||||
## Command state stability
|
||||
|
||||
Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. From version 0.7.9 the same guard also covers per-zone enable, HVAC mode, profile and target-temperature commands. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
|
||||
+13
@@ -60,6 +60,19 @@ class GreeControllerClient:
|
||||
"""Return the public controller health payload."""
|
||||
return await self._request("GET", "/api/health")
|
||||
|
||||
async def snapshot(self) -> dict[str, Any]:
|
||||
"""Return devices, groups and the control plan in one restricted request."""
|
||||
data = await self._request("GET", "/api/integrations/home-assistant/snapshot")
|
||||
if not isinstance(data, dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid snapshot payload")
|
||||
if not isinstance(data.get("devices"), list):
|
||||
raise GreeControllerApiError("Controller returned invalid snapshot devices")
|
||||
if not isinstance(data.get("control_plan"), dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid snapshot control plan")
|
||||
if not isinstance(data.get("groups"), list):
|
||||
raise GreeControllerApiError("Controller returned invalid snapshot groups")
|
||||
return data
|
||||
|
||||
async def devices(self) -> list[dict[str, Any]]:
|
||||
"""Return all controller devices."""
|
||||
data = await self._request("GET", "/api/integrations/home-assistant/devices")
|
||||
+94
-12
@@ -39,6 +39,40 @@ HA_TO_MODE = {value: key for key, value in MODE_TO_HA.items()}
|
||||
FAN_TO_NAME = {0: "auto", 1: "low", 2: "medium_low", 3: "medium", 4: "medium_high", 5: "high"}
|
||||
NAME_TO_FAN = {value: key for key, value in FAN_TO_NAME.items()}
|
||||
|
||||
VERTICAL_SWING_TO_VALUE = {
|
||||
SWING_OFF: 0,
|
||||
SWING_ON: 1,
|
||||
"fixed_upper": 2,
|
||||
"fixed_upper_middle": 3,
|
||||
"fixed_middle": 4,
|
||||
"fixed_lower_middle": 5,
|
||||
"fixed_lower": 6,
|
||||
"swing_upper": 7,
|
||||
"swing_upper_middle": 8,
|
||||
"swing_middle": 9,
|
||||
"swing_lower_middle": 10,
|
||||
"swing_lower": 11,
|
||||
}
|
||||
HORIZONTAL_SWING_TO_VALUE = {
|
||||
SWING_OFF: 0,
|
||||
SWING_ON: 1,
|
||||
"fixed_left": 2,
|
||||
"fixed_left_middle": 3,
|
||||
"fixed_middle": 4,
|
||||
"fixed_right_middle": 5,
|
||||
"fixed_right": 6,
|
||||
}
|
||||
VALUE_TO_VERTICAL_SWING = {value: mode for mode, value in VERTICAL_SWING_TO_VALUE.items()}
|
||||
VALUE_TO_HORIZONTAL_SWING = {value: mode for mode, value in HORIZONTAL_SWING_TO_VALUE.items()}
|
||||
|
||||
|
||||
def _louver_mode(raw: Any, modes: dict[int, str]) -> str:
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
value = 0
|
||||
return modes.get(value, SWING_OFF)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -126,14 +160,15 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat
|
||||
"""Home Assistant climate entity controlled through the Rust service."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "direct_control"
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_min_temp = 8.0
|
||||
_attr_max_temp = 30.0
|
||||
_attr_target_temperature_step = 1.0
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.DRY, HVACMode.FAN_ONLY]
|
||||
_attr_fan_modes = list(NAME_TO_FAN)
|
||||
_attr_swing_modes = [SWING_OFF, SWING_ON]
|
||||
_attr_swing_horizontal_modes = [SWING_OFF, SWING_ON]
|
||||
_attr_swing_modes = list(VERTICAL_SWING_TO_VALUE)
|
||||
_attr_swing_horizontal_modes = list(HORIZONTAL_SWING_TO_VALUE)
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
@@ -200,11 +235,11 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str:
|
||||
return SWING_ON if self._device.get("swing_vertical", False) else SWING_OFF
|
||||
return _louver_mode(self._device.get("swing_vertical", 0), VALUE_TO_VERTICAL_SWING)
|
||||
|
||||
@property
|
||||
def swing_horizontal_mode(self) -> str:
|
||||
return SWING_ON if self._device.get("swing_horizontal", False) else SWING_OFF
|
||||
return _louver_mode(self._device.get("swing_horizontal", 0), VALUE_TO_HORIZONTAL_SWING)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
@@ -219,6 +254,8 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat
|
||||
"air": bool(device.get("air", False)),
|
||||
"health": bool(device.get("health", False)),
|
||||
"sleep": bool(device.get("sleep", False)),
|
||||
"vertical_louver_position": int(device.get("swing_vertical", 0) or 0),
|
||||
"horizontal_louver_position": int(device.get("swing_horizontal", 0) or 0),
|
||||
"last_seen": device.get("last_seen"),
|
||||
}
|
||||
|
||||
@@ -250,15 +287,20 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat
|
||||
await self._command({"fan_speed": NAME_TO_FAN[fan_mode]})
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
await self._command({"swing_vertical": swing_mode == SWING_ON})
|
||||
value = VERTICAL_SWING_TO_VALUE.get(swing_mode)
|
||||
if value is not None:
|
||||
await self._command({"swing_vertical": value})
|
||||
|
||||
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
||||
await self._command({"swing_horizontal": swing_horizontal_mode == SWING_ON})
|
||||
value = HORIZONTAL_SWING_TO_VALUE.get(swing_horizontal_mode)
|
||||
if value is not None:
|
||||
await self._command({"swing_horizontal": value})
|
||||
|
||||
class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity):
|
||||
"""Full climate entity for a controller thermostat zone."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "zone_thermostat"
|
||||
_attr_name = "Thermostat"
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_min_temp = 8.0
|
||||
@@ -266,12 +308,8 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
||||
_attr_target_temperature_step = 0.5
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT]
|
||||
_attr_preset_modes = ["auto", "comfort", "sleep", "away"]
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
_attr_swing_modes = list(VERTICAL_SWING_TO_VALUE)
|
||||
_attr_swing_horizontal_modes = list(HORIZONTAL_SWING_TO_VALUE)
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
@@ -285,10 +323,30 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
||||
return zone
|
||||
return {}
|
||||
|
||||
@property
|
||||
def _device(self) -> dict[str, Any]:
|
||||
device_id = str(self._zone.get("device_id") or "").strip()
|
||||
return self.coordinator.data.get(device_id, {}) if device_id else {}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and bool(self._zone)
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ClimateEntityFeature:
|
||||
features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
capabilities = self._device.get("capabilities") or {}
|
||||
if capabilities.get("vertical_swing", True) is not False:
|
||||
features |= ClimateEntityFeature.SWING_MODE
|
||||
if capabilities.get("horizontal_swing", True) is not False:
|
||||
features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
|
||||
return features
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
zone = self._zone
|
||||
@@ -308,6 +366,8 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
value = self._zone.get("target_temperature")
|
||||
if value is None:
|
||||
value = self._zone.get("setpoint")
|
||||
return float(value) if value is not None else None
|
||||
|
||||
@property
|
||||
@@ -323,6 +383,14 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
||||
def preset_mode(self) -> str:
|
||||
return str(self._zone.get("preset_override") or "auto")
|
||||
|
||||
@property
|
||||
def swing_mode(self) -> str:
|
||||
return _louver_mode(self._device.get("swing_vertical", 0), VALUE_TO_VERTICAL_SWING)
|
||||
|
||||
@property
|
||||
def swing_horizontal_mode(self) -> str:
|
||||
return _louver_mode(self._device.get("swing_horizontal", 0), VALUE_TO_HORIZONTAL_SWING)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
zone = self._zone
|
||||
@@ -352,6 +420,8 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
||||
"actual_mode": zone.get("actual_mode"),
|
||||
"actual_setpoint": zone.get("actual_setpoint"),
|
||||
"current_schedule": zone.get("current_schedule_name"),
|
||||
"vertical_louver_position": int(self._device.get("swing_vertical", 0) or 0),
|
||||
"horizontal_louver_position": int(self._device.get("swing_horizontal", 0) or 0),
|
||||
}
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
@@ -378,6 +448,18 @@ class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], Cl
|
||||
return
|
||||
await self.coordinator.async_zone_control(self._zone_id, {"preset": preset_mode})
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
device_id = str(self._zone.get("device_id") or "").strip()
|
||||
value = VERTICAL_SWING_TO_VALUE.get(swing_mode)
|
||||
if device_id and value is not None:
|
||||
await self.coordinator.async_device_command(device_id, {"swing_vertical": value})
|
||||
|
||||
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
||||
device_id = str(self._zone.get("device_id") or "").strip()
|
||||
value = HORIZONTAL_SWING_TO_VALUE.get(swing_horizontal_mode)
|
||||
if device_id and value is not None:
|
||||
await self.coordinator.async_device_command(device_id, {"swing_horizontal": value})
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
await self.coordinator.async_zone_control(self._zone_id, {"power": True})
|
||||
|
||||
+8
-5
@@ -40,11 +40,10 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict]:
|
||||
try:
|
||||
devices, plan, groups = await asyncio.gather(
|
||||
self.client.devices(),
|
||||
self.client.control_plan(),
|
||||
self.client.groups(),
|
||||
)
|
||||
snapshot = await self.client.snapshot()
|
||||
devices = snapshot["devices"]
|
||||
plan = snapshot["control_plan"]
|
||||
groups = snapshot["groups"]
|
||||
except GreeControllerApiError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
self._overlay_pending_zone_controls(plan)
|
||||
@@ -68,6 +67,10 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
|
||||
expected["target_temperature"] = float(int(value + 0.5))
|
||||
if "fan_speed" in expected:
|
||||
expected["fan_speed"] = min(5, max(0, int(expected["fan_speed"])))
|
||||
if "swing_vertical" in expected:
|
||||
expected["swing_vertical"] = min(11, max(0, int(expected["swing_vertical"])))
|
||||
if "swing_horizontal" in expected:
|
||||
expected["swing_horizontal"] = min(6, max(0, int(expected["swing_horizontal"])))
|
||||
return expected
|
||||
|
||||
def _overlay_pending_device_commands(self, devices: dict[str, dict[str, Any]]) -> None:
|
||||
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 9.9 KiB |
+2
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.11.4",
|
||||
"version": "0.15.17",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"single_config_entry": true
|
||||
}
|
||||
}
|
||||
+2
@@ -62,6 +62,8 @@ class GreeControllerZoneTargetNumber(CoordinatorEntity[GreeControllerCoordinator
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
value = self._zone.get("target_temperature")
|
||||
if value is None:
|
||||
value = self._zone.get("setpoint")
|
||||
return float(value) if value is not None else None
|
||||
|
||||
@property
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"title": "GREE Controller",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to GREE Controller",
|
||||
"description": "Connect Home Assistant to the standalone Rust controller. Device commands will be proxied through the controller instead of the built-in GREE integration.",
|
||||
"data": {
|
||||
"url": "Controller URL",
|
||||
"token": "API token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect to GREE Controller",
|
||||
"invalid_auth": "Invalid controller API token"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "GREE Controller is already configured"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"climate": {
|
||||
"direct_control": {
|
||||
"name": "Direct control",
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "Auto",
|
||||
"low": "Low",
|
||||
"medium_low": "Medium-low",
|
||||
"medium": "Medium",
|
||||
"medium_high": "Medium-high",
|
||||
"high": "High"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "Full range (Auto)",
|
||||
"fixed_upper": "Fixed: Top",
|
||||
"fixed_upper_middle": "Fixed: Upper-middle",
|
||||
"fixed_middle": "Fixed: Middle",
|
||||
"fixed_lower_middle": "Fixed: Lower-middle",
|
||||
"fixed_lower": "Fixed: Bottom",
|
||||
"swing_upper": "Swing: Top",
|
||||
"swing_upper_middle": "Swing: Upper-middle",
|
||||
"swing_middle": "Swing: Middle",
|
||||
"swing_lower_middle": "Swing: Lower-middle",
|
||||
"swing_lower": "Swing: Bottom"
|
||||
}
|
||||
},
|
||||
"swing_horizontal_mode": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "Full range (Auto)",
|
||||
"fixed_left": "Fixed: Left",
|
||||
"fixed_left_middle": "Fixed: Left-middle",
|
||||
"fixed_middle": "Fixed: Middle",
|
||||
"fixed_right_middle": "Fixed: Middle-right",
|
||||
"fixed_right": "Fixed: Right"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone_thermostat": {
|
||||
"name": "Thermostat",
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"auto": "Auto schedule",
|
||||
"comfort": "Comfort",
|
||||
"sleep": "Sleep",
|
||||
"away": "Away"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "Full range (Auto)",
|
||||
"fixed_upper": "Fixed: Top",
|
||||
"fixed_upper_middle": "Fixed: Upper-middle",
|
||||
"fixed_middle": "Fixed: Middle",
|
||||
"fixed_lower_middle": "Fixed: Lower-middle",
|
||||
"fixed_lower": "Fixed: Bottom",
|
||||
"swing_upper": "Swing: Top",
|
||||
"swing_upper_middle": "Swing: Upper-middle",
|
||||
"swing_middle": "Swing: Middle",
|
||||
"swing_lower_middle": "Swing: Lower-middle",
|
||||
"swing_lower": "Swing: Bottom"
|
||||
}
|
||||
},
|
||||
"swing_horizontal_mode": {
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"on": "Full range (Auto)",
|
||||
"fixed_left": "Fixed: Left",
|
||||
"fixed_left_middle": "Fixed: Left-middle",
|
||||
"fixed_middle": "Fixed: Middle",
|
||||
"fixed_right_middle": "Fixed: Middle-right",
|
||||
"fixed_right": "Fixed: Right"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"title": "GREE Controller",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Połącz z GREE Controller",
|
||||
"description": "Połącz Home Assistant z niezależnym kontrolerem Rust. Polecenia urządzeń będą przechodziły przez kontroler zamiast wbudowanej integracji GREE.",
|
||||
"data": {
|
||||
"url": "Adres URL kontrolera",
|
||||
"token": "Token API"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nie można połączyć się z GREE Controller",
|
||||
"invalid_auth": "Nieprawidłowy token API kontrolera"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "GREE Controller jest już skonfigurowany"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"climate": {
|
||||
"direct_control": {
|
||||
"name": "Sterowanie bezpośrednie",
|
||||
"state_attributes": {
|
||||
"fan_mode": {
|
||||
"state": {
|
||||
"auto": "Automatyczny",
|
||||
"low": "Niski",
|
||||
"medium_low": "Średnio-niski",
|
||||
"medium": "Średni",
|
||||
"medium_high": "Średnio-wysoki",
|
||||
"high": "Wysoki"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"off": "Wyłączony",
|
||||
"on": "Pełny zakres (Auto)",
|
||||
"fixed_upper": "Stała: Góra",
|
||||
"fixed_upper_middle": "Stała: Środek-góra",
|
||||
"fixed_middle": "Stała: Środek",
|
||||
"fixed_lower_middle": "Stała: Środek-dół",
|
||||
"fixed_lower": "Stała: Dół",
|
||||
"swing_upper": "Ruch: Góra",
|
||||
"swing_upper_middle": "Ruch: Środek-góra",
|
||||
"swing_middle": "Ruch: Środek",
|
||||
"swing_lower_middle": "Ruch: Środek-dół",
|
||||
"swing_lower": "Ruch: Dół"
|
||||
}
|
||||
},
|
||||
"swing_horizontal_mode": {
|
||||
"state": {
|
||||
"off": "Wyłączony",
|
||||
"on": "Pełny zakres (Auto)",
|
||||
"fixed_left": "Stała: Lewo",
|
||||
"fixed_left_middle": "Stała: Lewo-środek",
|
||||
"fixed_middle": "Stała: Środek",
|
||||
"fixed_right_middle": "Stała: Środek-prawo",
|
||||
"fixed_right": "Stała: Prawo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone_thermostat": {
|
||||
"name": "Termostat",
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"auto": "Harmonogram (Auto)",
|
||||
"comfort": "Komfort",
|
||||
"sleep": "Sen",
|
||||
"away": "Poza domem"
|
||||
}
|
||||
},
|
||||
"swing_mode": {
|
||||
"state": {
|
||||
"off": "Wyłączony",
|
||||
"on": "Pełny zakres (Auto)",
|
||||
"fixed_upper": "Stała: Góra",
|
||||
"fixed_upper_middle": "Stała: Środek-góra",
|
||||
"fixed_middle": "Stała: Środek",
|
||||
"fixed_lower_middle": "Stała: Środek-dół",
|
||||
"fixed_lower": "Stała: Dół",
|
||||
"swing_upper": "Ruch: Góra",
|
||||
"swing_upper_middle": "Ruch: Środek-góra",
|
||||
"swing_middle": "Ruch: Środek",
|
||||
"swing_lower_middle": "Ruch: Środek-dół",
|
||||
"swing_lower": "Ruch: Dół"
|
||||
}
|
||||
},
|
||||
"swing_horizontal_mode": {
|
||||
"state": {
|
||||
"off": "Wyłączony",
|
||||
"on": "Pełny zakres (Auto)",
|
||||
"fixed_left": "Stała: Lewo",
|
||||
"fixed_left_middle": "Stała: Lewo-środek",
|
||||
"fixed_middle": "Stała: Środek",
|
||||
"fixed_right_middle": "Stała: Środek-prawo",
|
||||
"fixed_right": "Stała: Prawo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,4 +6,4 @@
|
||||
"device_id": "gree-aabbccddeeff"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
# Changelog
|
||||
|
||||
## 0.15.17
|
||||
|
||||
- Hides Dashboard → Global power Emergency STOP whenever all units are off and no automatic control is currently requesting work; an active STOP always keeps the green return-to-normal action visible.
|
||||
- Improves Control plan → Recently triggered automations so Flow-generated entries show the source Flow name, for example `Generated by Flow: Igor - Heating`, instead of only the generated automation identifier.
|
||||
- Refreshes Emergency STOP visibility immediately from live device and control-plan state.
|
||||
|
||||
## 0.15.16
|
||||
|
||||
- Makes Dashboard → Global power emergency STOP context-aware instead of permanently prominent whenever any zone is enabled.
|
||||
- Keeps the yellow STOP prominent while any unit is powered or automatic thermostat control is currently active.
|
||||
- Hides STOP while the system is idle; an active emergency STOP always remains visible as the green return-to-normal action.
|
||||
- Refreshes the STOP state immediately on device, schedule, automation, Flow and control-plan realtime updates.
|
||||
|
||||
## 0.15.15
|
||||
|
||||
- Adds a persistent emergency STOP to Dashboard → Global power. Activating it stores a restart-resistant safety gate, clears queued automatic compressor actions, pauses schedules/thermostat automation and makes a one-time attempt to power off enabled units; startup does not replay the OFF command.
|
||||
- Adds a green return-to-normal action that releases the safety gate and immediately re-evaluates current conditions without forcing devices ON.
|
||||
- Expands Control plan with a full-width “why the controller is acting this way” section showing per-zone control source/reason and the most recently triggered automations with trigger context.
|
||||
- Keeps emergency-stop state operationally separate from configuration backup/import, and documents the new `POST /api/house/emergency-stop` endpoint in the API/OpenAPI contract.
|
||||
|
||||
## 0.15.14
|
||||
|
||||
- Fixes Home Assistant energy sampling in the packaged add-on when authentication comes from `SUPERVISOR_TOKEN`; background sampling no longer requires persisted manual URL/token fields.
|
||||
- Centralizes Home Assistant readiness detection so Supervisor authentication and standalone manual URL/token authentication use the same runtime check across background jobs and integration endpoints.
|
||||
|
||||
## 0.15.13
|
||||
|
||||
- Flow cards now show an explicit execution type: Schedule, Automation, Schedule + Automation, Draft, or None, based on the compiled Flow output.
|
||||
- Removes whole-card navigation to the Flow editor, so clicking statistics, compile counts, descriptions, and other non-action areas no longer opens the editor accidentally.
|
||||
- Adds a dedicated compact Editor button next to Simulator and gives both actions the same visual treatment.
|
||||
|
||||
## 0.15.12
|
||||
|
||||
- Flow cards now allow editing the description directly from the Flow list without opening the visual editor.
|
||||
- Adds a dedicated simple Flow simulator next to the editor button. It opens in a modal, uses the saved Flow, current live values by default, and allows quick scenario overrides plus simulation date/time.
|
||||
- Simulator results are intentionally simplified to clear “will run / will not run / blocked” outcomes with readable checked conditions and friendly block reasons, while remaining non-mutating.
|
||||
|
||||
## 0.15.11
|
||||
|
||||
- Routine Visual Flow edit confirmations now appear as a compact status inside the editor instead of global toasts for save/copy/cut/paste/duplicate/undo actions.
|
||||
- Adds right-click context menus: selected blocks can be copied, cut, duplicated or deleted; the canvas can add/paste/select-all/fit/undo; and connections can be removed directly.
|
||||
- Context-menu paste can place a copied subgraph at the clicked canvas position while preserving internal connections.
|
||||
- Adds `Ctrl/Cmd+Z` undo with a bounded edit history for block creation/deletion, connections, paste/duplicate/cut, block movement, inspector setting changes, template application, Flow name changes and Enabled state changes.
|
||||
- Updates the built-in shortcut reference and PL/EN labels for the new undo and context-menu workflow.
|
||||
|
||||
## 0.15.10
|
||||
|
||||
- Visual Flow adds keyboard-first authoring: `A`, `Insert` or `Ctrl/Cmd+K` opens block search, and `Enter` inserts the first matching block.
|
||||
- Flow editing now supports `Ctrl/Cmd+S` save, `Ctrl/Cmd+A/C/X/V/D`, Delete/Backspace, Escape, arrow-key movement, precise Shift+arrow movement, `Ctrl/Cmd+0` fit-to-view and `Ctrl/Cmd++/-` zoom.
|
||||
- The Flow editor includes an in-app keyboard shortcut reference available from the toolbar/mobile actions and with `?`.
|
||||
- Every Flow card can now export its `.flow.json` directly from the Flow list without opening the editor; the existing export endpoint/portable format is reused.
|
||||
|
||||
## 0.15.9
|
||||
|
||||
- Visual Flow `on_change` events are now latched while their upstream condition remains true and are acknowledged only after the downstream action executes successfully, so cooldowns, ownership suppression and same-cycle target conflicts no longer silently consume a change event.
|
||||
- Rolling mean/median blocks now require one complete configured time window after their runtime window starts or restarts before they can match; unavailable sources reset the rolling window instead of reusing partial history.
|
||||
- Thermostat action summaries now show explicit power and HVAC mode alongside the preset/target, so `power=false` is displayed as OFF instead of looking like a preset-only Auto action.
|
||||
- The Flow canvas adds drag-box multi-selection, keeps Shift/Ctrl/Cmd additive selection and moves the whole selection together.
|
||||
- Selected Flow blocks can be copied/pasted or duplicated with internal connections preserved; Ctrl/Cmd+C, Ctrl/Cmd+V and Ctrl/Cmd+D are supported.
|
||||
|
||||
## 0.15.8
|
||||
|
||||
- Visual Flow exports now include the Shared Input definitions referenced by the graph.
|
||||
- Flow import creates missing Shared Inputs automatically, reuses equivalent local sources and safely remaps conflicting IDs without overwriting local definitions; Flow and imported inputs are persisted atomically without changing the Flow format version.
|
||||
- The Flow editor keeps the Interpretation / Flow cycle section collapsed by default on desktop, tablet and mobile, with details available through the existing disclosure button.
|
||||
|
||||
## 0.15.7
|
||||
|
||||
- Fixes mixed V1/V2 Local discovery: legacy/plain discovery envelopes are no longer treated as proof that a unit is V1.
|
||||
- Uses the inner GREE `ver` metadata as a discovery protocol hint (`V1.*` -> V1, `V2.*`/newer -> V2), while keeping unknown results in Auto mode.
|
||||
- Auto discovery now verifies the protocol during bind with V1/V2 fallback and persists the generation that actually succeeds; explicit V1/V2 discovery remains locked to the selected protocol.
|
||||
- The discovery review UI shows Auto for unresolved candidates instead of incorrectly labeling them V1.
|
||||
|
||||
## 0.15.6
|
||||
|
||||
- Removes the obsolete add-all `POST /api/discovery` endpoint and its compatibility-only merge path.
|
||||
- Local discovery now has one flow only: `POST /api/discovery/scan` followed by `POST /api/discovery/add`.
|
||||
- Simplifies selected-device binding: `/api/discovery/add` no longer accepts a redundant top-level `protocol_version`; each unit is bound strictly with the protocol detected during scan.
|
||||
- Removes unused discovery merge/export code and the matching obsolete OpenAPI schema.
|
||||
|
||||
## 0.15.5
|
||||
|
||||
- Local discovery no longer adds every detected unit automatically; scan results are reviewed first and only selected units are persisted and bound.
|
||||
- Discovery results show each unit model, MAC address, IP address and detected protocol, with already-added units clearly marked.
|
||||
- Explicit V1 AES-ECB or V2 AES-GCM discovery now strictly filters the other protocol and binds only with the selected protocol; Auto keeps V1/V2 fallback.
|
||||
- Adds `POST /api/discovery/scan` and `POST /api/discovery/add` for the selective local-discovery flow.
|
||||
|
||||
## 0.15.4
|
||||
|
||||
- Updates the Rust dependency stack to current major/minor releases, including Axum 0.8, Reqwest 0.13, Rusqlite 0.40, Rand 0.10, AES 0.9, AES-GCM 0.11, Base64 0.23, SHA-2 0.11, Tower HTTP 0.7 and utoipa-swagger-ui 9.
|
||||
- Migrates Axum route parameters and WebSocket text frames, Rand APIs, RustCrypto AES/AES-GCM APIs and Reqwest TLS configuration for the new dependency versions.
|
||||
- Updates the Home Assistant add-on Rust builder to Rust 1.98.1.
|
||||
- Pins direct Rust dependencies to exact updated releases; container builds generate a fresh lockfile before fetching and compiling with `--locked`.
|
||||
- Uses `ring` as the single Rustls crypto provider across Reqwest and the direct MQTT TLS client, avoiding the Rustls provider ambiguity introduced by Reqwest 0.13.
|
||||
|
||||
## 0.15.3
|
||||
|
||||
- Simplifies the built-in Swagger API documentation: removes the embedded release-history changelog, starts with all endpoint groups collapsed and hides the global Schemas section.
|
||||
- Documents the HTTP/WebSocket API, Swagger UI (`/api-docs`) and OpenAPI document (`/api-docs/openapi.json`) in the main documentation and Home Assistant add-on package.
|
||||
|
||||
## 0.15.2
|
||||
|
||||
- Adds a dedicated Visual Flow action for changing exactly one GREE unit function without modifying unrelated settings.
|
||||
- Legacy device automations now expose fan speed plus Quiet, Turbo, Panel Light, Air, X-Fan, Health and Sleep as optional single-field controls.
|
||||
|
||||
## 0.15.1
|
||||
|
||||
- Web UI now treats temporary WebSocket loss as a reconnecting state, escalates to a connection error only after 15 seconds, and returns cleanly to connected after recovery while preserving the existing HTTP fallback.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
- Adds full vertical and horizontal louver positions to manual unit control and thermostat cards, with compact dropdowns that preserve the existing view layout.
|
||||
- Extends legacy automations, Visual Flow and the bundled Home Assistant climate entities with granular louver positions; legacy boolean swing API payloads remain accepted.
|
||||
- Direct add-on access no longer shows `Invalid access token` on initial page load; that validation message appears only after a token is submitted.
|
||||
- Home Assistant climate cards localize direct-control names, fan modes, thermostat presets and all vertical/horizontal louver positions while keeping stable technical mode IDs for services and automations.
|
||||
|
||||
## 0.14.24
|
||||
|
||||
- Public Custom Charts fetch chart metadata before language assets, so a chart loads only its selected language pack; missing chart IDs fall back to English.
|
||||
- Public Custom Charts support pinch-to-zoom, one-finger horizontal panning while zoomed and per-metric visibility toggles through the legend.
|
||||
- Missing public chart IDs now return `chart id not found`, the favicon is no longer redundantly pre-cached, and invalid Access Tokens show an inline authentication error.
|
||||
|
||||
## 0.14.23
|
||||
|
||||
- Code cleanup and small UX changes.
|
||||
|
||||
## 0.14.22
|
||||
|
||||
- Code cleanup and small UX changes.
|
||||
|
||||
## 0.14.21
|
||||
|
||||
- Code cleanup and small UX changes.
|
||||
|
||||
## 0.14.20
|
||||
|
||||
- Installs the Rust target standard library in a cacheable layer before project sources are copied, so source-only changes do not redownload/reinstall it.
|
||||
- Fetches Cargo dependencies before target-specific setup so amd64/arm64 reuse the same registry/git cache.
|
||||
- Separates compiled `target/` caches by target architecture to avoid multi-platform build contention while retaining incremental dependency reuse.
|
||||
|
||||
## 0.14.19
|
||||
|
||||
- Speeds up multi-architecture OCI builds by running Rust/Cargo on `BUILDPLATFORM` and cross-compiling to amd64/arm64 instead of compiling ARM under QEMU.
|
||||
- Reduces Docker build context and avoids invalidating Rust compilation for unrelated documentation/repository changes; only `docs/openapi.json` is copied because it is embedded by the binary.
|
||||
- Deduplicates `/api/integrations/gree-cloud/status` across the initial HTTP bootstrap and immediate WebSocket bootstrap render while keeping manual Cloud reconnect refreshes forced.
|
||||
|
||||
## 0.14.18
|
||||
|
||||
- Renames the shared segmented navigation class from feature-specific `automation-tabs` to generic `segmented-tabs`.
|
||||
- Generalizes shared configuration layout classes to `config-form`, `form-section`, `form-section-head`, `form-grid`, `sticky-form-actions` and `full-width-form`.
|
||||
- Generalizes reusable chart, enable-toggle and diagram classes used across History/public charts, Zones/Flow and Simulator/Flow editor.
|
||||
- Replaces Flow-palette-specific category modifiers with shared `block-category-*` classes also used by the block library.
|
||||
- Removes all references to the replaced shared class names without changing component behavior or visual styling.
|
||||
|
||||
## 0.14.17
|
||||
|
||||
- Makes History navigation visually identical to the Flow / Schedules / Automations segmented navigation.
|
||||
- Reuses the shared `automation-tabs` component for Overview, Zones, GREE devices, Energy, Pings, HA sensors and Custom chart.
|
||||
- Removes redundant History-only tab CSS, including its separate mobile overrides.
|
||||
|
||||
## 0.14.16
|
||||
|
||||
- Adds an explicit Saved Charts list to History → Custom Chart with load, edit, update and delete actions.
|
||||
- Keeps chart name, selected series and range editable when updating an existing saved chart instead of creating duplicates.
|
||||
- Adds a range selector to standalone public Custom Chart links; the public data endpoint accepts an optional `hours` override while keeping the shared series definition fixed.
|
||||
- Fits the standalone public chart to the viewport and prevents vertical page scrolling, including compact/mobile layouts.
|
||||
|
||||
## 0.14.15
|
||||
|
||||
- Treats TCP `8787` as the fixed internal Home Assistant add-on application/ingress port and validates the Supervisor-reported ingress port at startup.
|
||||
- Makes the add-on build fail when `run.sh`, `ingress_port` and watchdog port metadata diverge.
|
||||
- Uses the Home Assistant Supervisor network API to resolve the primary host IPv4 for generated chart-only share links instead of depending on the browser/ingress hostname.
|
||||
- Adds optional `public_chart_base_url` for reverse proxies, alternate hostnames or non-standard external routing while keeping the public surface limited to generated Custom Chart shares.
|
||||
- Uses Home Assistant's `[PORT:8787]` watchdog placeholder so Supervisor resolves the effective watchdog port from the add-on port contract.
|
||||
- Keeps standalone installations fully port-configurable through `GREE_CONTROLLER_BIND`.
|
||||
|
||||
## 0.14.14
|
||||
|
||||
- Changes History → Custom Charts links to open a standalone chart-only page instead of the full dashboard.
|
||||
- Persists chart shares behind random bearer URLs whose token is stored only as a hash; shared URLs do not expose metric/device selectors.
|
||||
- In Supervisor mode, requires `app_token` for direct dashboard/API/WebSocket access on port `8787`, while trusted HA ingress remains automatic.
|
||||
- Keeps only generated Custom Chart data public to the network; the add-on watchdog may call `/api/health` anonymously only from the Supervisor peer.
|
||||
- Makes the Home Assistant entity picker in `Reusable data -> Shared Flow input` explicit with a separate search box and selected `entity_id` field.
|
||||
- Filters the live Home Assistant entity catalog by friendly name, `entity_id`, current state, unit and device class while typing.
|
||||
- Keeps automatic Supervisor authentication, failed-test manual fallback and standalone Home Assistant behavior unchanged.
|
||||
|
||||
## 0.14.13
|
||||
|
||||
- Shows Home Assistant Supervisor authentication as automatic in the add-on and hides manual URL/token/TLS fields by default.
|
||||
- Unlocks a persisted manual Home Assistant URL/token fallback only after an automatic HA test fails; standalone installations keep their existing manual behavior.
|
||||
- Extends `Test HA` to verify the state registry and show a live sample entity/state in the success toast.
|
||||
- Adds a searchable live Home Assistant entity picker to `Reusable data -> Shared Flow input`, with source-type-aware fields and numeric filtering for numeric HA inputs.
|
||||
- Adds an authenticated compact Home Assistant entity-catalog endpoint for UI suggestions.
|
||||
|
||||
## 0.14.12
|
||||
|
||||
- Home Assistant add-on now uses the Supervisor-managed Home Assistant Core API connection automatically.
|
||||
- Enables `homeassistant_api` for the add-on and authenticates Core API requests with runtime `SUPERVISOR_TOKEN`.
|
||||
- Keeps the Supervisor token out of SQLite and prevents add-on UI edits from replacing stored standalone Home Assistant URL/token settings.
|
||||
- Standalone installations keep the existing manual Home Assistant URL and Long-Lived Access Token behavior unchanged.
|
||||
- Fixes custom select mouse hover/click inside modal dialogs by keeping each dropdown popover inside the same modal top-layer subtree; keyboard behavior is unchanged.
|
||||
|
||||
## 0.14.11
|
||||
|
||||
- Replaces font-dependent Unicode glyphs used as Web UI icons with a shared inline SVG icon set.
|
||||
- Covers navigation, theme/refresh controls, dialogs, menus, Flow editor/statuses, chart zoom/fullscreen controls, thermostat/device +/- controls, custom-select checks/chevrons and event-category icons.
|
||||
- Leaves semantic text symbols such as `°C`, `±`, comparison operators, mathematical multiplication and Flow expression notation as text.
|
||||
- Keeps the 0.14.10 full-width Night mode and Home Assistant / Sensors layout fix.
|
||||
|
||||
## 0.14.10
|
||||
|
||||
- Makes the standalone Night mode and Home Assistant / Sensors forms use the full available page width, matching the other application sections on desktop and responsive layouts.
|
||||
|
||||
## 0.14.9
|
||||
|
||||
- Replaces the font-dependent Unicode power symbol in Manual Control and thermostat quick power controls with an embedded SVG icon for consistent Android/Chrome rendering.
|
||||
- Home Assistant room-sensor stale/error event messages now include the affected `entity_id` and zone name.
|
||||
- Event rendering enriches older `ha.sensor_*` rows with the sensor alias/entity and zone when that context is available in saved metadata.
|
||||
|
||||
## 0.14.8
|
||||
|
||||
- Removes the remaining non-device `502 Bad Gateway` mappings from Home Assistant connection/entity diagnostics and notification tests.
|
||||
- Returns `400 Bad Request` for missing/invalid integration configuration and `424 Failed Dependency` when a configured external service cannot be reached or rejects the request.
|
||||
- Reclassifies background Home Assistant energy-read failures as external-dependency errors.
|
||||
- Keeps `502 Bad Gateway` only for actual Local/LAN GREE device transport failures.
|
||||
- Aligns API/OpenAPI error documentation with the runtime status mapping.
|
||||
|
||||
## 0.14.7
|
||||
|
||||
- Treats an unconfigured Home Assistant integration as an optional unavailable source when listing HA energy sensors instead of emitting a 502 error while opening split/multisplit installation settings.
|
||||
- Maps configured-but-failing Home Assistant energy discovery to the external-dependency response path rather than a device communication 502.
|
||||
- Fixes short custom-select menus near the bottom of dialogs opening far above their trigger by positioning from the menu's rendered height.
|
||||
|
||||
## 0.14.6
|
||||
|
||||
- Fixes custom select duplication when a select is moved in the DOM, including repeated History → Energy refreshes.
|
||||
- Hides the Energy history controls and Refresh action when no energy source is configured, leaving a single empty-state message.
|
||||
- Hardens global custom-select cleanup so dynamically removed controls do not leave stale wrappers or popup menus.
|
||||
|
||||
## 0.14.5
|
||||
|
||||
- Fixed the outdoor-temperature modal chart returning from fullscreen with fullscreen-sized layout remnants.
|
||||
- Added quick links from the outdoor-temperature modal to 7-day, 30-day, yearly and full History views.
|
||||
|
||||
- Replaces native single-choice dropdown UI across the Web UI with one consistent custom select matching the energy chart picker, including language/theme controls and dynamically generated Flow/history forms.
|
||||
- Loads only the active language pack at startup and fetches other language packs when selected.
|
||||
- Makes the dashboard outdoor temperature clickable and opens a modal with its last 24 hours of history.
|
||||
|
||||
## 0.14.4
|
||||
|
||||
- Removes the obsolete Home Assistant connection-test/default entity; HA connection testing uses only the configured URL and token.
|
||||
- `outdoor_entity_id` / `HA_OUTDOOR_ENTITY_ID` is the global Home Assistant outdoor-temperature sensor.
|
||||
- Adds optional per-zone `ha_outdoor_entity_id`; when empty, the zone uses the global outdoor sensor, and when set it overrides the outdoor source only for that zone.
|
||||
- Keeps Home Assistant room temperature sensors per zone via `ha_entity_id`.
|
||||
- Global and per-zone outdoor sensors are available in aliases, metrics/history and Home Assistant entity suggestions used by Visual Flow.
|
||||
|
||||
## 0.14.3
|
||||
|
||||
- Home Assistant connection test now validates the configured server and token without requiring any entity.
|
||||
|
||||
## 0.14.2
|
||||
|
||||
- Improves connectivity controls: Local/LAN ping measurement can be explicitly enabled/disabled in Settings, disabled workers gray out their interval/sample fields, Cloud REST/MQTT measurement settings live in a dedicated block, and History → Pings can hide/show jitter with one button while rendering jitter as a dashed line.
|
||||
- Adds a configurable background connectivity worker for Local/LAN units with measurement interval and sample-count settings.
|
||||
- Adds a History → Connectivity/Pings view with per-endpoint latency, jitter and packet-loss charts plus latest batch summaries.
|
||||
- Stores connectivity readings in SQLite using the same retention/compaction lifecycle as other history metrics and archives them to InfluxDB when enabled.
|
||||
- Keeps direct unit probing Local/LAN-only; GREE Cloud diagnostics instead measure optional REST response time and MQTT PINGRESP round-trip.
|
||||
- Adds opt-in GREE Cloud connectivity metrics with configurable interval/sample count; Cloud measurements are disabled by default.
|
||||
- Adds separate vertical and horizontal swing controls for local units in Manual Control, respecting per-device capabilities.
|
||||
- Adds vertical and horizontal swing controls to thermostat zone cards without switching the zone into manual device ownership.
|
||||
- Adds optional `swing_vertical` and `swing_horizontal` actions to legacy direct-device automations.
|
||||
- Extends Visual Flow so both `device_action` and `zone_thermostat` can control vertical/horizontal swing, and device-state selectors use readable swing labels.
|
||||
- Prevents swing-enabled thermostat Flows from being reduced to native schedules, preserving the auxiliary swing action at runtime.
|
||||
- Exposes vertical and horizontal swing on Home Assistant zone thermostat climate entities when supported by the assigned unit.
|
||||
|
||||
## 0.14.1
|
||||
|
||||
- Adds split and multisplit installation groups in the Devices section, including assignment of indoor units to a shared outdoor installation.
|
||||
- Adds installation-level energy sources so a multisplit can use one cumulative GREE Cloud meter or one Home Assistant energy entity instead of reporting duplicated per-unit consumption.
|
||||
- Shows on each grouped device that its energy is provided by the assigned split/multisplit installation and identifies the configured shared source.
|
||||
- Adds an optional shared outdoor-temperature source for an installation, allowing all member units to use the outdoor temperature reported by one selected unit.
|
||||
- Adds installation groups to configuration export/import, bootstrap/API data and persistence, with validation that prevents duplicate membership and invalid energy/outdoor source references.
|
||||
- Keeps installation configuration consistent when devices are removed, including removing empty groups and resetting a deleted GREE Cloud energy source safely.
|
||||
- Adds multi-select energy charts so several devices and/or installations can be displayed together.
|
||||
- Adds energy period comparison against the previous day, previous equivalent period or previous year.
|
||||
- Fixes daily energy chart labels so daily aggregation is rendered as calendar dates instead of timestamps such as `02:00`.
|
||||
- Unifies GREE Cloud validation/error alerts with the alert style used for device errors and removes redundant styling paths.
|
||||
- Hides empty GREE Cloud status fields such as disabled/disconnected, `0 / 0` and `Unavailable`; status details are shown only when useful data or an actionable error exists.
|
||||
- Preserves and updates the Polish/English translations used by the new installation, energy and Cloud UI, including the simplified Cloud details label (`Details` / `Szczegóły`).
|
||||
- Fixes repeated `GREE Cloud MQTT message does not match a registered device` warnings after deleting all Cloud devices by ignoring in-flight stale MQTT frames when no devices are registered and closing the now-unused MQTT session after the last device is unregistered.
|
||||
- Adds regression coverage ensuring stale MQTT payloads are ignored only when no Cloud devices remain, while unknown-device payloads still fail when registered devices exist.
|
||||
|
||||
## 0.14.0
|
||||
|
||||
- Adds GREE Cloud as a first-class provider while preserving the existing LAN/UDP transport.
|
||||
- Adds MQTT/TLS status push, Cloud commands, reconnect/resubscribe lifecycle and safe Cloud diagnostics.
|
||||
- Adds per-device capabilities and Cloud-specific Devices/Manual Control UI.
|
||||
- Adds GREE Cloud and Home Assistant cumulative-energy sources, delta conversion, energy history, aggregation, charts and InfluxDB archive support.
|
||||
- Keeps Local and Cloud entries independent and never performs automatic transport fallback.
|
||||
|
||||
## 0.13.10
|
||||
|
||||
- Accepts multi-state non-zero values for optional GREE feature flags without dropping an otherwise valid status frame.
|
||||
- Preserves unit-specific active `Quiet` encodings (`1`, `2` or `3`) and reuses them when Quiet is enabled; legacy units still default to `Quiet=1`.
|
||||
- Prevents units reporting `Quiet=2` from being marked offline because of parser validation.
|
||||
|
||||
## 0.13.9
|
||||
|
||||
- Home Assistant add-on with `amd64` + `aarch64` multi-arch OCI image.
|
||||
- Host networking for GREE UDP discovery and multi-interface/VLAN hosts.
|
||||
- Home Assistant ingress, watchdog and cold backup support.
|
||||
- Polish and English documentation consolidated into one `DOCS.md`.
|
||||
@@ -0,0 +1,144 @@
|
||||
# GREE Controller — Home Assistant add-on
|
||||
|
||||
Repozytorium: `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/`
|
||||
Obraz OCI: `zot.linuxiarz.pl/gree-controller:<version>`
|
||||
|
||||
---
|
||||
|
||||
## PL
|
||||
|
||||
### Instalacja
|
||||
|
||||
W Home Assistant dodaj jako własne repozytorium:
|
||||
|
||||
```text
|
||||
https://git.linuxiarz.pl/gru/gree-controller-ha-addon/
|
||||
```
|
||||
|
||||
Odśwież sklep dodatków/aplikacji i zainstaluj **GREE Controller**. `config.yaml` wskazuje gotowy wieloarchitekturowy obraz OCI; jego tag musi być równy polu `version`.
|
||||
|
||||
### Sieć, VLAN i discovery
|
||||
|
||||

|
||||
|
||||
Dodatek celowo używa `host_network: true`, więc korzysta bezpośrednio z interfejsów hosta Home Assistant OS. Nie twórz dla niego Docker `macvlan`.
|
||||
|
||||
Interfejsy hosta:
|
||||
|
||||
```bash
|
||||
ha network info
|
||||
```
|
||||
|
||||
Przykład VLAN 50 na `eth0`, bez dodatkowej bramy domyślnej:
|
||||
|
||||
```bash
|
||||
ha network vlan eth0 50 \
|
||||
--ipv4-method static \
|
||||
--ipv4-address 192.168.50.2/24 \
|
||||
--ipv6-method disabled
|
||||
```
|
||||
|
||||
Dla jednej sieci GREE ustaw `gree_interface` na nazwę interfejsu (np. `eth0.50`) albo jego lokalny IPv4 (np. `192.168.50.2`) oraz `discovery_broadcast` na broadcast tej podsieci, np. `192.168.50.255:7000`.
|
||||
|
||||
Dla wielu bezpośrednio podłączonych podsieci pozostaw `gree_interface` puste. Kontroler dobiera lokalny interfejs najlepiej pasujący do IP znanego urządzenia. Discovery nadal wysyła jeden broadcast na skan, więc każdą podsieć/VLAN skanuj osobno, zmieniając `discovery_broadcast`, albo dodaj znane urządzenia ręcznie.
|
||||
|
||||
Broadcast zwykle nie przechodzi przez router. Sterowanie unicast może działać przez routing/firewall, jeżeli UDP jest dozwolone. Gdy urządzenia są w routowanym VLAN-ie, zapewnij hostowi HA interfejs w tej sieci, relay broadcast UDP albo wykonuj discovery lokalnie dla każdej podsieci.
|
||||
|
||||
### Opcje
|
||||
|
||||
| Opcja | Znaczenie |
|
||||
|---|---|
|
||||
| `gree_interface` | interfejs lub lokalny IPv4; puste = automatyczny dobór trasy |
|
||||
| `discovery_broadcast` | cel discovery UDP, np. `192.168.50.255:7000` |
|
||||
| `simulate` | praca bez fizycznych urządzeń |
|
||||
| `auto_seed` | przykładowe urządzenie w pustej bazie symulatora |
|
||||
| `poll_interval_seconds` | interwał odpytywania urządzeń |
|
||||
| `zone_interval_seconds` | interwał sterowania strefami/termostatem |
|
||||
| `discovery_timeout_ms` | timeout discovery UDP |
|
||||
| `app_token` | token bezpośredniego Web UI/API; w trybie Supervisor pusty = direct access zablokowany |
|
||||
| `public_chart_base_url` | opcjonalny bazowy URL publicznych linków Custom Chart; puste = automatyczne główne IPv4 hosta HA + `:8787` |
|
||||
| `log_level` | `error`, `warn`, `info`, `debug`, `trace` |
|
||||
|
||||
### API i dokumentacja API
|
||||
|
||||
Aplikacja udostępnia HTTP API pod `/api/*` oraz WebSocket pod `/ws`. Interaktywna dokumentacja Swagger jest dostępna pod `/api-docs`, a dokument OpenAPI 3.1 pod `/api-docs/openapi.json`. Te same ścieżki działają przez ingress Home Assistant; bezpośredni dostęp na porcie `8787` podlega regułom `app_token`.
|
||||
|
||||
### Home Assistant API
|
||||
|
||||
Dodatek korzysta automatycznie z wewnętrznego proxy Home Assistant Core (`http://supervisor/core/api/`) i tokenu `SUPERVISOR_TOKEN` przekazywanego przez Supervisor. Gdy token jest wykryty, UI pokazuje automatyczną autoryzację i ukrywa ręczne pola URL/token. Dopiero nieudany `Test HA` odblokowuje awaryjny ręczny fallback. Token Supervisor jest używany tylko w pamięci procesu i nie jest zapisywany w SQLite.
|
||||
|
||||
### Dostęp, dane i bezpieczeństwo
|
||||
|
||||
Usługa używa stałego wewnętrznego portu TCP `8787`; ingress Home Assistant przekazuje Web UI na ten port. Port nie jest opcją użytkownika w zakładce Network. Przy starcie add-on porównuje port raportowany przez Supervisor z kontraktem `8787` i odmawia startu, jeśli ręcznie zmodyfikowana paczka jest niespójna. Gdy wykryty jest `SUPERVISOR_TOKEN`, bezpośredni dashboard/API na `:8787` wymaga `app_token`; przy pustym `app_token` bezpośredni dashboard jest zablokowany. Z sieci bez poświadczeń dostępny jest tylko publiczny widok i endpoint danych pojedynczego Custom Chart (`/charts/custom/<share-token>`, `/api/public/charts/custom/<share-token>`). Token udostępnienia jest losowy, jego skrót jest zapisywany w SQLite, a sam URL nie zawiera nazw urządzeń ani listy metryk. Link kopiowany z History → Custom chart omija HA ingress; domyślnie add-on pobiera główne IPv4 hosta z Supervisor API i tworzy `http://<IP-HA>:8787/charts/custom/...`. `public_chart_base_url` pozwala jawnie wskazać reverse proxy, inną nazwę hosta lub alternatywną trasę. `/api/health` bez tokenu jest akceptowane tylko od peera Supervisora na potrzeby watchdoga; bezpośrednie żądanie sieciowe wymaga `app_token`. Baza jest zapisywana w `/data/gree-controller.db`; konfiguracja używa `backup: cold`, więc dane są objęte backupem dodatku.
|
||||
|
||||
Watchdog sprawdza `/api/health`. Do diagnostyki sieci najpierw sprawdź `ha network info`, poprawność `gree_interface`, broadcast konkretnej podsieci i reguły UDP/firewalla.
|
||||
|
||||
---
|
||||
|
||||
## EN
|
||||
|
||||
### Installation
|
||||
|
||||
Add this custom repository in Home Assistant:
|
||||
|
||||
```text
|
||||
https://git.linuxiarz.pl/gru/gree-controller-ha-addon/
|
||||
```
|
||||
|
||||
Refresh the app/add-on store and install **GREE Controller**. `config.yaml` points to a pre-built multi-architecture OCI image; its tag must match `version`.
|
||||
|
||||
### Networking, VLANs and discovery
|
||||
|
||||

|
||||
|
||||
The add-on intentionally uses `host_network: true`, so it uses the Home Assistant OS host interfaces directly. Do not attach a Docker `macvlan` network to the add-on.
|
||||
|
||||
Inspect host interfaces with:
|
||||
|
||||
```bash
|
||||
ha network info
|
||||
```
|
||||
|
||||
Example VLAN 50 on `eth0`, without adding another default gateway:
|
||||
|
||||
```bash
|
||||
ha network vlan eth0 50 \
|
||||
--ipv4-method static \
|
||||
--ipv4-address 192.168.50.2/24 \
|
||||
--ipv6-method disabled
|
||||
```
|
||||
|
||||
For one GREE subnet, set `gree_interface` to the host interface name (for example `eth0.50`) or its local IPv4 address (for example `192.168.50.2`), and set `discovery_broadcast` to that subnet broadcast, e.g. `192.168.50.255:7000`.
|
||||
|
||||
For multiple directly attached subnets, leave `gree_interface` empty. The controller selects the local interface that best matches a known device IP. Discovery still sends one broadcast per scan, so scan each VLAN/subnet separately by changing `discovery_broadcast`, or add known devices manually.
|
||||
|
||||
Broadcast normally does not cross routers. Unicast control may work through routing/firewall rules when UDP is allowed. For routed GREE VLANs, give the HA host an interface in the VLAN, use a suitable UDP broadcast relay, or perform discovery locally per subnet.
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Meaning |
|
||||
|---|---|
|
||||
| `gree_interface` | interface or local IPv4; empty = automatic route selection |
|
||||
| `discovery_broadcast` | UDP discovery target, e.g. `192.168.50.255:7000` |
|
||||
| `simulate` | run without physical devices |
|
||||
| `auto_seed` | create a sample device in an empty simulator database |
|
||||
| `poll_interval_seconds` | device polling interval |
|
||||
| `zone_interval_seconds` | thermostat/zone control interval |
|
||||
| `discovery_timeout_ms` | UDP discovery timeout |
|
||||
| `app_token` | token for direct Web UI/API access; in Supervisor mode empty = direct access disabled |
|
||||
| `public_chart_base_url` | optional base URL for public Custom Chart links; empty = primary HA host IPv4 + `:8787` automatically |
|
||||
| `log_level` | `error`, `warn`, `info`, `debug`, `trace` |
|
||||
|
||||
### API and API documentation
|
||||
|
||||
The application exposes its HTTP API under `/api/*` and WebSocket under `/ws`. Interactive Swagger documentation is available at `/api-docs`, with the OpenAPI 3.1 document at `/api-docs/openapi.json`. The same paths work through Home Assistant ingress; direct access on port `8787` follows the configured `app_token` rules.
|
||||
|
||||
### Home Assistant API
|
||||
|
||||
The add-on automatically uses the internal Home Assistant Core proxy (`http://supervisor/core/api/`) and the runtime `SUPERVISOR_TOKEN` provided by Supervisor. When the token is detected, the UI shows automatic authorization and hides manual URL/token fields. A failed `Test HA` unlocks the optional manual fallback. The Supervisor token is used only at runtime and is not stored in SQLite.
|
||||
|
||||
### Access, data and security
|
||||
|
||||
The service uses fixed internal TCP port `8787`; Home Assistant ingress proxies the Web UI to that port. The port is not a user-facing Network option. On startup the add-on compares the Supervisor-reported ingress port with the `8787` contract and refuses to start if a manually modified package is inconsistent. When `SUPERVISOR_TOKEN` is detected, direct dashboard/API access on `:8787` requires `app_token`; with an empty `app_token`, direct dashboard access is disabled. From the network, the only unauthenticated application data is the single public Custom Chart view/data endpoint (`/charts/custom/<share-token>`, `/api/public/charts/custom/<share-token>`). The share token is random, only its hash is stored in SQLite, and the URL does not expose device names or metric selectors. Links copied from History → Custom chart bypass HA ingress; by default the add-on obtains the primary host IPv4 from the Supervisor API and builds `http://<HA-IP>:8787/charts/custom/...`. `public_chart_base_url` can explicitly select a reverse proxy, alternate hostname or routing path. `/api/health` is accepted without a token only from the Supervisor peer for the add-on watchdog; direct network requests require `app_token`. The database is stored in `/data/gree-controller.db`; `backup: cold` keeps it in the add-on backup.
|
||||
|
||||
The watchdog checks `/api/health`. For network troubleshooting, verify `ha network info`, `gree_interface`, the selected subnet broadcast, and UDP/firewall rules first.
|
||||
@@ -0,0 +1,17 @@
|
||||
# GREE Controller
|
||||
|
||||
Local GREE HVAC controller packaged as a Home Assistant add-on, with Web UI, HTTP/WebSocket API, built-in Swagger API documentation, UDP discovery/control, `amd64` and `aarch64` support, ingress, and database backup from `/data/gree-controller.db`.
|
||||
|
||||
Version 0.15.17 hides the Global power emergency STOP while the system is idle, keeps the green return-to-normal action visible whenever STOP is active, and shows the source Flow name for Flow-generated entries in Recently triggered automations.
|
||||
|
||||

|
||||
|
||||
For VLAN deployments, configure VLAN interfaces on the Home Assistant OS host. The add-on uses `host_network: true`; with multiple directly attached subnets, `gree_interface` may remain empty. Run broadcast discovery separately for each VLAN/subnet.
|
||||
|
||||
Full documentation: [DOCS.md](./DOCS.md).
|
||||
|
||||
The controller exposes `/api/*` and `/ws`. Interactive API documentation is available at `/api-docs`, with the OpenAPI 3.1 document at `/api-docs/openapi.json`. The same paths work through Home Assistant ingress; direct access on port `8787` follows the configured `app_token` rules.
|
||||
|
||||
The same Git repository also contains the optional `custom_components/gree_controller` integration. It is distributed with the repository but is not installed automatically with the add-on.
|
||||
|
||||
Repository: https://git.linuxiarz.pl/gru/gree-controller-ha-addon/
|
||||
@@ -0,0 +1,45 @@
|
||||
name: "GREE Controller"
|
||||
version: "0.15.17"
|
||||
slug: "gree_controller"
|
||||
description: "Local GREE HVAC controller with Web UI, API, and Home Assistant integration"
|
||||
url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/"
|
||||
arch:
|
||||
- amd64
|
||||
- aarch64
|
||||
image: "zot.linuxiarz.pl/gree-controller"
|
||||
startup: application
|
||||
boot: auto
|
||||
init: false
|
||||
host_network: true
|
||||
homeassistant_api: true
|
||||
hassio_api: true
|
||||
ingress: true
|
||||
ingress_port: 8787
|
||||
ingress_stream: true
|
||||
panel_icon: mdi:air-conditioner
|
||||
panel_title: GREE Controller
|
||||
panel_admin: true
|
||||
watchdog: "http://[HOST]:[PORT:8787]/api/health"
|
||||
backup: cold
|
||||
options:
|
||||
gree_interface: ""
|
||||
discovery_broadcast: "255.255.255.255:7000"
|
||||
simulate: false
|
||||
auto_seed: false
|
||||
poll_interval_seconds: 15
|
||||
zone_interval_seconds: 5
|
||||
discovery_timeout_ms: 3000
|
||||
app_token: ""
|
||||
public_chart_base_url: ""
|
||||
log_level: info
|
||||
schema:
|
||||
gree_interface: str
|
||||
discovery_broadcast: str
|
||||
simulate: bool
|
||||
auto_seed: bool
|
||||
poll_interval_seconds: "int(2,3600)"
|
||||
zone_interval_seconds: "int(2,3600)"
|
||||
discovery_timeout_ms: "int(500,30000)"
|
||||
app_token: password
|
||||
public_chart_base_url: str
|
||||
log_level: "list(error|warn|info|debug|trace)"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,31 @@
|
||||
configuration:
|
||||
gree_interface:
|
||||
name: GREE network interface
|
||||
description: Interface name or local IPv4 address. Leave empty for automatic routing.
|
||||
discovery_broadcast:
|
||||
name: Discovery broadcast
|
||||
description: Broadcast target, for example 192.168.50.255:7000. Use an explicit subnet broadcast for VLANs.
|
||||
simulate:
|
||||
name: Simulation mode
|
||||
description: Run without physical GREE units.
|
||||
auto_seed:
|
||||
name: Seed simulator
|
||||
description: Create a sample simulated unit when the database is empty.
|
||||
poll_interval_seconds:
|
||||
name: Poll interval
|
||||
description: Device polling interval in seconds.
|
||||
zone_interval_seconds:
|
||||
name: Zone interval
|
||||
description: Thermostat control interval in seconds.
|
||||
discovery_timeout_ms:
|
||||
name: Discovery timeout
|
||||
description: UDP discovery timeout in milliseconds.
|
||||
app_token:
|
||||
name: Application token
|
||||
description: Token required for direct dashboard/API access on port 8787. With SUPERVISOR_TOKEN active, an empty token disables direct access; only generated Custom Chart share links remain public.
|
||||
public_chart_base_url:
|
||||
name: Public chart base URL
|
||||
description: Optional override for generated Custom Chart links, for example http://192.168.1.20:8787 or a reverse-proxy URL. Leave empty to use the primary Home Assistant host IPv4 and the fixed add-on port 8787 automatically.
|
||||
log_level:
|
||||
name: Log level
|
||||
description: Controller log verbosity.
|
||||
@@ -0,0 +1,31 @@
|
||||
configuration:
|
||||
gree_interface:
|
||||
name: Interfejs sieci GREE
|
||||
description: Nazwa interfejsu lub lokalny adres IPv4. Puste pole oznacza automatyczny wybór trasy.
|
||||
discovery_broadcast:
|
||||
name: Broadcast discovery
|
||||
description: Adres broadcast, np. 192.168.50.255:7000. Dla VLAN użyj broadcastu konkretnej podsieci.
|
||||
simulate:
|
||||
name: Tryb symulacji
|
||||
description: Uruchomienie bez fizycznych urządzeń GREE.
|
||||
auto_seed:
|
||||
name: Dane testowe symulatora
|
||||
description: Dodaj przykładowe urządzenie, gdy baza jest pusta.
|
||||
poll_interval_seconds:
|
||||
name: Interwał odpytywania
|
||||
description: Odpytywanie urządzeń w sekundach.
|
||||
zone_interval_seconds:
|
||||
name: Interwał stref
|
||||
description: Interwał sterowania termostatem w sekundach.
|
||||
discovery_timeout_ms:
|
||||
name: Timeout discovery
|
||||
description: Timeout wykrywania UDP w milisekundach.
|
||||
app_token:
|
||||
name: Token aplikacji
|
||||
description: Token wymagany do bezpośredniego dostępu do dashboardu/API na porcie 8787. Przy aktywnym SUPERVISOR_TOKEN pusty token blokuje direct access; publiczne pozostają wyłącznie wygenerowane linki Custom Chart.
|
||||
public_chart_base_url:
|
||||
name: Bazowy URL publicznych wykresów
|
||||
description: Opcjonalne nadpisanie adresu generowanych linków Custom Chart, np. http://192.168.1.20:8787 albo adres reverse proxy. Pozostaw puste, aby automatycznie użyć głównego IPv4 hosta Home Assistant i stałego portu add-onu 8787.
|
||||
log_level:
|
||||
name: Poziom logowania
|
||||
description: Szczegółowość logów kontrolera.
|
||||
@@ -0,0 +1,3 @@
|
||||
name: GREE Controller
|
||||
url: https://git.linuxiarz.pl/gru/gree-controller-ha-addon/
|
||||
maintainer: linuxiarz.pl
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
CONFIG=/data/options.json
|
||||
readonly HA_HTTP_PORT=8787
|
||||
SUPERVISOR_API="${SUPERVISOR:-http://supervisor}"
|
||||
|
||||
read_option() {
|
||||
local key="$1" default_value="${2-}"
|
||||
if [[ -f "$CONFIG" ]]; then
|
||||
jq -r --arg key "$key" --arg default "$default_value" \
|
||||
'if has($key) and .[$key] != null then .[$key] else $default end' "$CONFIG"
|
||||
else
|
||||
printf '%s\n' "$default_value"
|
||||
fi
|
||||
}
|
||||
|
||||
supervisor_get() {
|
||||
local path="$1"
|
||||
[[ -n "${SUPERVISOR_TOKEN:-}" ]] || return 1
|
||||
curl --fail --silent --show-error --max-time 5 \
|
||||
-H "Authorization: Bearer ${SUPERVISOR_TOKEN}" \
|
||||
"${SUPERVISOR_API%/}${path}"
|
||||
}
|
||||
|
||||
validate_ingress_port_contract() {
|
||||
local payload ingress_port
|
||||
payload="$(supervisor_get /addons/self/info 2>/dev/null || true)"
|
||||
[[ -n "$payload" ]] || return 0
|
||||
ingress_port="$(jq -r '.data.ingress_port // empty' <<<"$payload" 2>/dev/null || true)"
|
||||
if [[ "$ingress_port" =~ ^[0-9]+$ ]] && (( ingress_port != HA_HTTP_PORT )); then
|
||||
printf 'GREE Controller: refusing to start: HA ingress_port=%s but the add-on HTTP port contract is %s. Reinstall/update the official add-on package instead of changing ingress_port manually.\n' \
|
||||
"$ingress_port" "$HA_HTTP_PORT" >&2
|
||||
exit 78
|
||||
fi
|
||||
}
|
||||
|
||||
detect_primary_host_ipv4() {
|
||||
local payload address
|
||||
payload="$(supervisor_get /network/info 2>/dev/null || true)"
|
||||
[[ -n "$payload" ]] || return 1
|
||||
address="$(jq -r '
|
||||
(.data.interfaces // [])
|
||||
| (if type == "object" then [to_entries[] | (.value + {interface: (.value.interface // .key)})] else . end)
|
||||
| map(select((.enabled // true) == true and (.connected // true) == true))
|
||||
| map(. + {candidate_ipv4: (.ipv4.ip_address // .ip_address // (try .ipv4.address[0] catch empty) // empty)})
|
||||
| map(select(.candidate_ipv4 != ""))
|
||||
| ((map(select((.primary // false) == true)) | .[0]) // .[0] // {})
|
||||
| (.candidate_ipv4 // empty)
|
||||
' <<<"$payload" 2>/dev/null || true)"
|
||||
address="${address%%/*}"
|
||||
[[ "$address" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
|
||||
printf '%s\n' "$address"
|
||||
}
|
||||
|
||||
GREE_INTERFACE="$(read_option gree_interface '')"
|
||||
DISCOVERY_BROADCAST="$(read_option discovery_broadcast '255.255.255.255:7000')"
|
||||
SIMULATE="$(read_option simulate 'false')"
|
||||
AUTO_SEED="$(read_option auto_seed 'false')"
|
||||
POLL_INTERVAL="$(read_option poll_interval_seconds '15')"
|
||||
ZONE_INTERVAL="$(read_option zone_interval_seconds '5')"
|
||||
DISCOVERY_TIMEOUT="$(read_option discovery_timeout_ms '3000')"
|
||||
APP_TOKEN="$(read_option app_token '')"
|
||||
PUBLIC_CHART_BASE_URL="$(read_option public_chart_base_url '')"
|
||||
LOG_LEVEL="$(read_option log_level 'info')"
|
||||
|
||||
validate_ingress_port_contract
|
||||
PUBLIC_CHART_BASE_SOURCE="configured"
|
||||
if [[ -z "$PUBLIC_CHART_BASE_URL" ]]; then
|
||||
PUBLIC_CHART_BASE_SOURCE="browser-fallback"
|
||||
if PRIMARY_HOST_IPV4="$(detect_primary_host_ipv4)"; then
|
||||
PUBLIC_CHART_BASE_URL="http://${PRIMARY_HOST_IPV4}:${HA_HTTP_PORT}"
|
||||
PUBLIC_CHART_BASE_SOURCE="supervisor-primary-ipv4"
|
||||
fi
|
||||
fi
|
||||
|
||||
export GREE_CONTROLLER_BIND="0.0.0.0:${HA_HTTP_PORT}"
|
||||
export GREE_CONTROLLER_DATABASE="/data/gree-controller.db"
|
||||
export GREE_CONTROLLER_GREE_INTERFACE="$GREE_INTERFACE"
|
||||
export GREE_CONTROLLER_DISCOVERY_BROADCAST="$DISCOVERY_BROADCAST"
|
||||
export GREE_CONTROLLER_SIMULATE="$SIMULATE"
|
||||
export GREE_CONTROLLER_AUTO_SEED="$AUTO_SEED"
|
||||
export GREE_CONTROLLER_POLL_INTERVAL_SECONDS="$POLL_INTERVAL"
|
||||
export GREE_CONTROLLER_ZONE_INTERVAL_SECONDS="$ZONE_INTERVAL"
|
||||
export GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS="$DISCOVERY_TIMEOUT"
|
||||
export GREE_CONTROLLER_APP_TOKEN="$APP_TOKEN"
|
||||
export GREE_CONTROLLER_PUBLIC_CHART_BASE_URL="$PUBLIC_CHART_BASE_URL"
|
||||
export GREE_CONTROLLER_HA_AUTH="supervisor"
|
||||
export RUST_LOG="gree_controller=${LOG_LEVEL},tower_http=${LOG_LEVEL}"
|
||||
|
||||
printf 'GREE Controller: interface=%s discovery=%s database=%s http_port=%s public_chart_base_source=%s\n' \
|
||||
"${GREE_INTERFACE:-auto}" "$DISCOVERY_BROADCAST" "$GREE_CONTROLLER_DATABASE" "$HA_HTTP_PORT" \
|
||||
"$PUBLIC_CHART_BASE_SOURCE"
|
||||
exec /usr/local/bin/gree-controller
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ENV_FILE="${HA_ADDON_ENV_FILE:-$SCRIPT_DIR/.env}"
|
||||
|
||||
[[ -f "$ENV_FILE" ]] || {
|
||||
echo "Missing $ENV_FILE. Copy $SCRIPT_DIR/.env.example to $SCRIPT_DIR/.env and edit it." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
required=(HA_REPO_URL HA_REPO_BRANCH HA_REPO_PATH ADDON_DIR INTEGRATION_DOMAIN)
|
||||
for name in "${required[@]}"; do
|
||||
[[ -n "${!name:-}" ]] || { echo "Missing $name in $ENV_FILE" >&2; exit 1; }
|
||||
done
|
||||
|
||||
command -v git >/dev/null 2>&1 || { echo "git is required" >&2; exit 1; }
|
||||
command -v rsync >/dev/null 2>&1 || { echo "rsync is required" >&2; exit 1; }
|
||||
|
||||
if [[ "$HA_REPO_PATH" != /* ]]; then
|
||||
HA_REPO_PATH="$PROJECT_ROOT/$HA_REPO_PATH"
|
||||
fi
|
||||
HA_REPO_PATH="$(realpath -m "$HA_REPO_PATH")"
|
||||
INTEGRATION_SOURCE="$SCRIPT_DIR/home-assistant/custom_components/$INTEGRATION_DOMAIN"
|
||||
|
||||
[[ -d "$SCRIPT_DIR/repository/$ADDON_DIR" ]] || { echo "Missing add-on source" >&2; exit 1; }
|
||||
[[ -d "$INTEGRATION_SOURCE" ]] || { echo "Missing integration source: $INTEGRATION_SOURCE" >&2; exit 1; }
|
||||
|
||||
"$SCRIPT_DIR/build.sh" render
|
||||
|
||||
if [[ ! -e "$HA_REPO_PATH" ]]; then
|
||||
git clone --branch "$HA_REPO_BRANCH" "$HA_REPO_URL" "$HA_REPO_PATH"
|
||||
elif [[ ! -d "$HA_REPO_PATH/.git" ]]; then
|
||||
echo "HA_REPO_PATH exists but is not a Git repository: $HA_REPO_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "$(git -C "$HA_REPO_PATH" status --porcelain)" ]]; then
|
||||
echo "HA repository has uncommitted changes: $HA_REPO_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$HA_REPO_PATH" checkout "$HA_REPO_BRANCH"
|
||||
git -C "$HA_REPO_PATH" pull --ff-only origin "$HA_REPO_BRANCH"
|
||||
|
||||
STAGE="$(mktemp -d)"
|
||||
trap 'rm -rf "$STAGE"' EXIT
|
||||
rsync -a "$SCRIPT_DIR/repository/" "$STAGE/"
|
||||
mkdir -p "$STAGE/custom_components/$INTEGRATION_DOMAIN"
|
||||
rsync -a "$INTEGRATION_SOURCE/" "$STAGE/custom_components/$INTEGRATION_DOMAIN/"
|
||||
rsync -a --delete --exclude='.git/' "$STAGE/" "$HA_REPO_PATH/"
|
||||
|
||||
git -C "$HA_REPO_PATH" add -A
|
||||
if git -C "$HA_REPO_PATH" diff --cached --quiet; then
|
||||
echo "HA repository is already synchronized."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VERSION="$(awk -F '"' '/^version = "/ {print $2; exit}' "$PROJECT_ROOT/Cargo.toml")"
|
||||
[[ -n "$VERSION" ]] || { echo "Cannot determine version from Cargo.toml" >&2; exit 1; }
|
||||
|
||||
git -C "$HA_REPO_PATH" commit -m "GREE Controller ${VERSION}"
|
||||
git -C "$HA_REPO_PATH" push origin "$HA_REPO_BRANCH"
|
||||
printf 'Synchronized add-on + custom integration -> %s (%s)\n' "$HA_REPO_URL" "$HA_REPO_BRANCH"
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# Home Assistant distribution
|
||||
|
||||
All Home Assistant packaging is maintained under [`ha-addon/`](ha-addon/).
|
||||
|
||||
```text
|
||||
ha-addon/
|
||||
├── .env.example
|
||||
├── build.sh
|
||||
├── sync-repository.sh
|
||||
├── repository/ # add-on repository source
|
||||
└── home-assistant/ # custom integration source
|
||||
```
|
||||
|
||||
Initial setup:
|
||||
|
||||
```bash
|
||||
cd ha-addon
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Release:
|
||||
|
||||
```bash
|
||||
./build.sh push
|
||||
./sync-repository.sh
|
||||
```
|
||||
|
||||
`build.sh` reads OCI/build settings from `.env`. `sync-repository.sh` publishes both the add-on and `custom_components/gree_controller` to the separate Home Assistant repository.
|
||||
|
||||
See [`ha-addon/README.md`](ha-addon/README.md) for details.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.9 KiB |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"title": "GREE Controller",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Connect to GREE Controller",
|
||||
"description": "Connect Home Assistant to the standalone Rust controller. Device commands will be proxied through the controller instead of the built-in GREE integration.",
|
||||
"data": {
|
||||
"url": "Controller URL",
|
||||
"token": "API token"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect to GREE Controller",
|
||||
"invalid_auth": "Invalid controller API token"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "GREE Controller is already configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"title": "GREE Controller",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Połącz z GREE Controller",
|
||||
"description": "Połącz Home Assistant z niezależnym kontrolerem Rust. Polecenia urządzeń będą przechodziły przez kontroler zamiast wbudowanej integracji GREE.",
|
||||
"data": {
|
||||
"url": "Adres URL kontrolera",
|
||||
"token": "Token API"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Nie można połączyć się z GREE Controller",
|
||||
"invalid_auth": "Nieprawidłowy token API kontrolera"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "GREE Controller jest już skonfigurowany"
|
||||
}
|
||||
}
|
||||
}
|
||||
+428
-25
@@ -8,6 +8,7 @@
|
||||
"translations": {
|
||||
"meta.description": "Local GREE air conditioner controller",
|
||||
"status.connecting": "Connecting…",
|
||||
"status.reconnecting": "Reconnecting…",
|
||||
"status.connected": "Connected",
|
||||
"status.disconnected": "Disconnected",
|
||||
"status.connectionError": "Connection error",
|
||||
@@ -75,10 +76,42 @@
|
||||
"devices.readDone": "Device state updated",
|
||||
"devices.bound": "Device bound",
|
||||
"devices.added": "Device added",
|
||||
"devices.disabledZoneTechnicalOnly": "This zone is disabled. Manual control of its unit is available only from the Devices view.",
|
||||
"dashboard.manualControlHint": "Direct GREE control: power, temperature, mode, fan and supported unit functions.",
|
||||
"devices.technicalDescription": "Technical configuration and diagnostics only. Current operating settings are available in Manual control.",
|
||||
"devices.technicalUnit": "Technical unit",
|
||||
"devices.address": "Address",
|
||||
"devices.modelFirmware": "Model / firmware",
|
||||
"devices.lastSeen": "Last response",
|
||||
"devices.communicationFailures": "Communication failures",
|
||||
"devices.lastError": "Error",
|
||||
"devices.readStatus": "Read status",
|
||||
"devices.technicalConfig": "Technical configuration",
|
||||
"devices.diagnostics": "Diagnostics",
|
||||
"devices.ping": "Ping",
|
||||
"devices.pingOpen": "Open live ping view",
|
||||
"devices.pingLive": "Live ping",
|
||||
"devices.pingHint": "Measures a minimal GREE round-trip without changing device state, error counters or automation ownership. The chart refreshes while monitoring is enabled.",
|
||||
"devices.pingAll": "Ping all units",
|
||||
"devices.pingStart": "Start",
|
||||
"devices.pingStop": "Stop",
|
||||
"devices.pingCurrent": "Current",
|
||||
"devices.pingAverage": "Average",
|
||||
"devices.pingMin": "Min",
|
||||
"devices.pingMax": "Max",
|
||||
"devices.pingSamples": "Samples",
|
||||
"devices.pingNoSamples": "No samples yet. Start monitoring.",
|
||||
"devices.pingFailed": "No response",
|
||||
"devices.pingResponding": "Responding",
|
||||
"devices.manualDeviceDisabled": "Unit is technically disabled",
|
||||
"devices.manualNoCommunication": "No communication with the unit",
|
||||
"devices.manualCommunicationProblem": "The last communication with the unit failed",
|
||||
"devices.manualDisabledZoneTitle": "Warning: zone is disabled",
|
||||
"devices.manualDisabledZoneWarning": "This unit belongs to disabled zone “{zone}”. Manual control is still available, but the command is sent directly outside that zone automation.",
|
||||
"devices.manualDisabledZoneConfirm": "Zone “{zone}” is disabled. This command will be sent directly to the unit, outside zone automation. Automation will stay disabled until it is enabled / resumed again. Send the command?",
|
||||
"devices.disabledZoneTechnicalOnly": "This zone is disabled. Explicit direct operation requires confirmation in Manual control.",
|
||||
"zones.automation": "Automation",
|
||||
"zones.new": "New zone",
|
||||
"zones.description": "Zones define thermostat logic and sensor configuration. Use Quick thermostats on the Dashboard for current temperature, preset and mode changes.",
|
||||
"zones.description": "Zones define thermostat logic and sensor configuration. Use thermostats on the Dashboard for current temperature, preset and mode changes.",
|
||||
"zones.hysteresis": "Hysteresis °C",
|
||||
"zones.separateHysteresis": "Set heating and cooling separately",
|
||||
"zones.coolHysteresis": "Cooling hysteresis °C",
|
||||
@@ -99,7 +132,10 @@
|
||||
"zones.roomSensorEntity": "Room sensor entity_id",
|
||||
"zones.roomSensorWeight": "Room sensor weight %",
|
||||
"zones.maxDifference": "Max. sensor difference °C",
|
||||
"zones.sensorHelp": "This sensor belongs only to this zone. If it becomes unavailable, the controller automatically falls back to the GREE sensor.",
|
||||
"zones.sensorHelp": "Required when the temperature strategy uses Home Assistant. If it becomes unavailable, the controller falls back to the GREE sensor.",
|
||||
"zones.sensorRequired": "Enter the Home Assistant room temperature entity_id for this zone.",
|
||||
"zones.outdoorSensorEntity": "Outdoor temperature entity_id override",
|
||||
"zones.outdoorSensorHelp": "Leave empty to use the global Outdoor temperature entity_id. Set another entity_id to use a different outdoor sensor only for this zone. It is used by outdoor-temperature assist and recorded in history/metrics.",
|
||||
"zones.greeTemp": "GREE",
|
||||
"zones.externalTemp": "Room",
|
||||
"zones.usedSource": "used",
|
||||
@@ -148,8 +184,18 @@
|
||||
"settings.haTokenKeep": "Leave empty to keep the saved token",
|
||||
"settings.haTokenSaved": "Token saved — leave empty to keep it",
|
||||
"settings.haLongLivedToken": "Long-Lived Access Token",
|
||||
"settings.defaultEntity": "Connection test entity_id",
|
||||
"settings.defaultEntityHint": "This entity is used by the Home Assistant connection test when no other entity is specified. It is not automatically used as a zone temperature source.",
|
||||
"settings.haSupervisorToken": "Token provided automatically by Home Assistant Supervisor",
|
||||
"settings.haSupervisorAutoTitle": "Automatic Supervisor authorization",
|
||||
"settings.haSupervisorAutoHint": "Supervisor token detected. Home Assistant authorization is configured automatically; no URL or Long-Lived Access Token is required.",
|
||||
"settings.haSupervisorVerifiedTitle": "Supervisor authorization verified",
|
||||
"settings.haSupervisorVerifiedHint": "The Home Assistant API test succeeded using the Supervisor token.",
|
||||
"settings.haSupervisorMissingTitle": "Supervisor token not available",
|
||||
"settings.haSupervisorMissingHint": "The add-on environment was detected, but no Supervisor token is available. Run Test HA to unlock manual fallback fields if needed.",
|
||||
"settings.haSupervisorTestFailedTitle": "Automatic HA authorization failed",
|
||||
"settings.haSupervisorTestFailedHint": "Manual Home Assistant URL and token fields are now available as a fallback.",
|
||||
"settings.haManualFallbackTitle": "Manual Home Assistant fallback active",
|
||||
"settings.haManualFallbackHint": "This add-on is using the manually saved Home Assistant URL and token instead of Supervisor authorization.",
|
||||
"settings.haUseSupervisor": "Use Supervisor automatically",
|
||||
"settings.testHa": "Test HA",
|
||||
"settings.systemState": "System status",
|
||||
"settings.version": "Version",
|
||||
@@ -160,6 +206,12 @@
|
||||
"logs.diagnostics": "Diagnostics",
|
||||
"logs.emptyTitle": "No events",
|
||||
"logs.emptyText": "The event log is empty.",
|
||||
"logs.silent": "SILENT",
|
||||
"logs.silentHint": "No notification was sent.",
|
||||
"logs.silentAlertTypeDisabled": "No notification was sent: this alert type is disabled.",
|
||||
"logs.silentNotificationsDisabled": "No notification was sent: notifications are disabled.",
|
||||
"logs.silentModeFiltered": "No notification was sent: the notification mode filtered this event.",
|
||||
"logs.silentCooldown": "No notification was sent: notification cooldown is active.",
|
||||
"common.name": "Name",
|
||||
"common.zone": "Zone",
|
||||
"common.device": "Device",
|
||||
@@ -209,7 +261,8 @@
|
||||
"label.schedule": "schedule",
|
||||
"label.automation": "automation",
|
||||
"toast.found": "Found: {count}",
|
||||
"toast.haTemperature": "Home Assistant: {temperature}°C",
|
||||
"toast.haConnected": "Home Assistant connection OK",
|
||||
"toast.haConnectedSample": "Home Assistant connected. Sample: {entity} = {value}",
|
||||
"day.1": "Mon",
|
||||
"day.2": "Tue",
|
||||
"day.3": "Wed",
|
||||
@@ -236,23 +289,35 @@
|
||||
"toast.tokenRevoked": "Token revoked",
|
||||
"devices.protocolAuto": "Auto (V1 + V2)",
|
||||
"devices.rename": "Name / protocol",
|
||||
"devices.protocolChangeHint": "Changing protocol clears the saved device key and performs a new bind on the next request.",
|
||||
"devices.protocolChangeHint": "Changing protocol clears the saved device key. “Save and check connection” immediately performs the required bind and test.",
|
||||
"devices.saveAndCheck": "Save and check connection",
|
||||
"devices.connectionCheckOk": "Connection works · response {ms} ms",
|
||||
"devices.connectionCheckFailed": "Configuration was saved, but the connection test failed: {error}",
|
||||
"devices.savedAndChecked": "Saved and connection confirmed",
|
||||
"devices.savedCheckFailed": "Configuration saved, but the connection test failed",
|
||||
"discovery.title": "Discover GREE devices",
|
||||
"discovery.help": "Auto searches both AES-ECB and AES-GCM devices. Multiple passes improve discovery when several Wi-Fi modules answer the same broadcast.",
|
||||
"discovery.help": "Auto accepts both AES-ECB and AES-GCM devices, uses discovery metadata only as a protocol hint and verifies the actual protocol during binding. Selecting V1 or V2 keeps discovery and binding locked to that protocol. Multiple passes improve discovery when several Wi-Fi modules answer the same broadcast.",
|
||||
"discovery.protocol": "Discovery protocol",
|
||||
"discovery.passes": "Scan passes",
|
||||
"discovery.timeout": "Total scan time (ms)",
|
||||
"zones.quickHint": "Quick target and mode control",
|
||||
"actions.later": "Later",
|
||||
"discovery.nameDevices": "Name discovered devices",
|
||||
"discovery.nameDevicesHelp": "Give each new unit a friendly room name. The technical model and MAC remain available in diagnostics.",
|
||||
"dashboard.quickThermostats": "Thermostats",
|
||||
"discovery.nameDevices": "Discovered local devices",
|
||||
"discovery.nameDevicesHelp": "Select the units you want to add. MAC address, IP address and the discovery protocol hint are shown for every result; Auto is resolved during binding.",
|
||||
"dashboard.thermostats": "Thermostats",
|
||||
"common.unavailable": "Unavailable",
|
||||
"mode.off": "Off",
|
||||
"house.seasonMode": "Home climate mode",
|
||||
"house.smartThermostat": "Smart thermostat",
|
||||
"house.setpointStrategy": "Heating or cooling selects the automation direction. After resume, a zone with no active schedule, manual target, temporary session or other explicit control stays OFF. “Do not control” leaves devices untouched.",
|
||||
"house.outdoor": "Outdoor",
|
||||
"house.outdoorHistoryOpen": "Show the outdoor temperature chart for the last 24 hours",
|
||||
"house.outdoorHistoryHint": "Outdoor temperature from the last 24 hours.",
|
||||
"house.outdoorHistoryMore": "More metrics and ranges in history",
|
||||
"house.outdoorHistoryFull": "Full history",
|
||||
"history.range7d": "7 days",
|
||||
"history.range30d": "30 days",
|
||||
"history.range1y": "1 year",
|
||||
"house.modeUpdated": "Home climate mode updated",
|
||||
"zones.modePolicy": "Mode policy",
|
||||
"zones.modePolicyHint": "If this zone should use the global Heating/Cooling mode selected in GREE Controller, leave “Follow global mode” unchanged. “Cooling only” or “Heating only” forces a fixed mode only for this zone.",
|
||||
@@ -285,7 +350,8 @@
|
||||
"schedules.chooseZone": "Choose a zone first",
|
||||
"schedules.templateApplied": "Schedule preset applied",
|
||||
"schedules.profile": "Profile",
|
||||
"settings.outdoorEntity": "Outdoor temperature entity_id",
|
||||
"settings.outdoorEntity": "Global outdoor temperature entity_id",
|
||||
"settings.outdoorEntityHint": "Global Home Assistant outdoor temperature sensor. Zones use it by default and may override it with another outdoor sensor in zone settings.",
|
||||
"settings.outdoorAssist": "Use outdoor temperature as smart-control assist",
|
||||
"settings.allowInvalidTls": "Allow invalid/self-signed HTTPS certificate",
|
||||
"settings.allowInvalidTlsHint": "Use only for a trusted local Home Assistant server, for example https://192.168.50.25.",
|
||||
@@ -357,9 +423,13 @@
|
||||
"history.copyLink": "Copy link",
|
||||
"history.series": "series",
|
||||
"history.noSavedCharts": "No saved custom charts yet.",
|
||||
"history.savedCharts": "Saved charts",
|
||||
"history.editingChart": "Editing chart",
|
||||
"history.saveChanges": "Save changes",
|
||||
"history.customChart": "Custom climate chart",
|
||||
"history.customChartHint": "Your selected data sources on one timeline.",
|
||||
"history.chartSaved": "Custom chart saved",
|
||||
"history.chartUpdated": "Custom chart updated",
|
||||
"history.linkCopied": "Chart link copied",
|
||||
"toast.successTitle": "Done",
|
||||
"toast.errorTitle": "Something went wrong",
|
||||
@@ -371,9 +441,33 @@
|
||||
"plan.loading": "Loading current automation plan…",
|
||||
"plan.rules": "Extra automations",
|
||||
"plan.ruleCount": "{count} active rules",
|
||||
"plan.ruleCountPaused": "Rules paused by emergency STOP: {count}",
|
||||
"plan.event": "Change",
|
||||
"plan.groupSummary": "Group zones: {zones} · requesting action: {demand}",
|
||||
"plan.groupOff": "Group off — group units are powered down and group ownership is released.",
|
||||
"plan.reasonsEyebrow": "Control reason",
|
||||
"plan.reasonsTitle": "Why the controller is acting this way",
|
||||
"plan.currentReasons": "Current reasons",
|
||||
"plan.recentTriggers": "Recently triggered automations",
|
||||
"plan.noReasons": "No active control reasons.",
|
||||
"plan.reasonEmergency": "Automation is paused by the emergency stop.",
|
||||
"plan.reasonManual": "Direct manual device control has priority.",
|
||||
"plan.reasonDisabled": "The zone is disabled.",
|
||||
"plan.reasonOffline": "The device is offline or has a communication error.",
|
||||
"plan.reasonNoIntent": "No active schedule or explicit thermostat request.",
|
||||
"plan.reasonLocalOff": "The local thermostat explicitly keeps this zone off.",
|
||||
"plan.reasonLocalOn": "The local thermostat owns this zone.",
|
||||
"plan.reasonHeatDemand": "{current} is below the {target} target — heating demand is active.",
|
||||
"plan.reasonCoolDemand": "{current} is above the {target} target — cooling demand is active.",
|
||||
"plan.reasonTargetSatisfied": "Temperature {current}; target {target} — no extra demand is required.",
|
||||
"plan.reasonSchedule": "Active schedule: {schedule}.",
|
||||
"plan.reasonWaiting": "The controller is waiting for a condition that requires action.",
|
||||
"plan.reasonSourceSchedule": "Schedule: {schedule}",
|
||||
"plan.reasonSourceManual": "Manual control",
|
||||
"plan.reasonSourceThermostat": "Local thermostat",
|
||||
"plan.reasonSourceAutomation": "Automation",
|
||||
"plan.reasonTriggerReading": "{trigger} · now {current}",
|
||||
"plan.reasonLastTriggered": "Last triggered: {when}",
|
||||
"zones.enable": "Enable zone",
|
||||
"zones.disable": "Disable zone",
|
||||
"history.90d": "90 days",
|
||||
@@ -556,6 +650,17 @@
|
||||
"house.powerOnDone": "All thermostats enabled and ON sent to all units",
|
||||
"house.powerOffDone": "All units turned off; thermostats remain locally off",
|
||||
"house.powerPartial": "Could not change power on {count} devices",
|
||||
"house.emergencyStop": "Emergency STOP",
|
||||
"house.emergencyStopHint": "Pauses all automation and makes a one-shot attempt to power off every enabled unit.",
|
||||
"house.emergencyStopIdleHint": "The system is idle and all units are off. Emergency STOP is hidden until automatic control becomes active or a unit starts.",
|
||||
"house.emergencyStopConfirm": "Activate the emergency stop? Automation will remain persistently paused until you resume it manually, and the controller will try to power off all units.",
|
||||
"house.emergencyResume": "Return to normal mode",
|
||||
"house.emergencyResumeHint": "Releases the automation pause. It does not force devices on; the controller evaluates current conditions again.",
|
||||
"house.emergencyActive": "Emergency stop is active",
|
||||
"house.emergencyActiveHint": "The state survives controller restarts. After a restart automation remains blocked, but the OFF command is not replayed.",
|
||||
"house.emergencyStopped": "Emergency stop active — automation paused",
|
||||
"house.emergencyStoppedPartial": "Emergency stop is active, but {count} devices could not be powered off",
|
||||
"house.emergencyResumed": "Normal automation mode restored",
|
||||
"preset.manual": "Manual",
|
||||
"nav.groups": "Groups",
|
||||
"groups.eyebrow": "Group control",
|
||||
@@ -582,6 +687,9 @@
|
||||
"groups.noZones": "Create thermostat zones before creating a group.",
|
||||
"automations.targetType": "Target type",
|
||||
"automations.groupHint": "For a group, Auto means follow the whole-house heating/cooling mode. Group profiles are applied to all member zones.",
|
||||
"automations.singleDeviceFunctions": "Single device functions",
|
||||
"automations.deviceFunction": "Unit function",
|
||||
"automations.singleDeviceFunctionsHint": "Leave the other fields as No change to modify only the selected unit function. Fan, Quiet and Sleep still follow the existing thermostat ownership rules.",
|
||||
"simulation.openTab": "Open simulator only in new tab",
|
||||
"simulation.fullscreen": "Full screen",
|
||||
"simulation.exitFullscreen": "Exit full screen",
|
||||
@@ -594,7 +702,7 @@
|
||||
"groups.chooseMember": "Select at least one thermostat zone.",
|
||||
"zones.configuration": "Zone configuration",
|
||||
"zones.currentStatus": "Current status",
|
||||
"zones.controlOnDashboard": "Current control is available in Quick thermostats on the Dashboard.",
|
||||
"zones.controlOnDashboard": "Current control is available in thermostats on the Dashboard.",
|
||||
"zones.controlNow": "Control",
|
||||
"zones.noGroup": "no group",
|
||||
"zones.coolOnly": "Cooling only",
|
||||
@@ -631,12 +739,12 @@
|
||||
"zones.lockoutUntil": "lockout until",
|
||||
"settings.haSensorStaleAfterMinutes": "Maximum HA sensor reading age (min)",
|
||||
"settings.haSensorStaleAfterHint": "If the HA sensor is not updated within this time, the reading is treated as stale. The thermostat falls back to the GREE sensor when available.",
|
||||
"zones.quickThermostat": "Quick thermostat",
|
||||
"zones.quickThermostat": "Thermostat",
|
||||
"zones.temporaryTitle": "Temporary mode",
|
||||
"zones.temporaryActive": "Temporary Quick Thermostat active",
|
||||
"zones.temporaryScheduled": "Temporary Quick Thermostat scheduled",
|
||||
"zones.temporaryActive": "Temporary thermostat active",
|
||||
"zones.temporaryScheduled": "Temporary thermostat scheduled",
|
||||
"zones.temporaryStop": "Finish and return to automation",
|
||||
"zones.temporaryTarget": "Quick thermostat temperature",
|
||||
"zones.temporaryTarget": "Thermostat temperature",
|
||||
"zones.temporaryStartRule": "When to start",
|
||||
"zones.temporaryStartNow": "Immediately",
|
||||
"zones.temporaryStartDelay": "After a delay",
|
||||
@@ -659,11 +767,11 @@
|
||||
"zones.temporaryHoldMinutes": "Condition must hold for (min)",
|
||||
"zones.temporarySafetyLimit": "Safety limit (min, optional)",
|
||||
"zones.temporaryStableHint": "If temperature leaves the condition, the hold countdown resets and starts again after the condition is met again.",
|
||||
"zones.temporaryScheduleHint": "Quick Thermostat will finish at the next change of the active schedule entry for this zone.",
|
||||
"zones.temporaryScheduleHint": "Thermostat will finish at the next change of the active schedule entry for this zone.",
|
||||
"zones.temporaryStart": "Start temporarily",
|
||||
"zones.temporaryUpdate": "Update rules",
|
||||
"zones.temporaryShort": "Timed",
|
||||
"zones.temporaryStartHint": "Run Quick Thermostat temporarily or until a condition is met.",
|
||||
"zones.temporaryStartHint": "Run thermostat temporarily or until a condition is met.",
|
||||
"zones.temporaryOverride": "Temporary · {time}",
|
||||
"zones.temporaryWithinShort": "{target}°C ±{tolerance}°C",
|
||||
"zones.temporaryAtOrBelowShort": "≤ {target}°C · tol. {tolerance}°C",
|
||||
@@ -674,16 +782,19 @@
|
||||
"zones.temporaryUntilScheduleStatus": "Until the next schedule transition",
|
||||
"zones.temporaryTimeStatus": "Until automatic return to automation",
|
||||
"zones.temporaryScheduledStatus": "Scheduled start: {time}. Normal automation continues unchanged until then.",
|
||||
"zones.temporaryStartingStatus": "Starting Quick Thermostat…",
|
||||
"zones.temporaryStartingStatus": "Starting thermostat…",
|
||||
"zones.temporaryWaitingMaster": "The session is waiting for MASTER to be enabled and is not consuming active runtime.",
|
||||
"zones.temporaryPausedManual": "The session is paused by manual/remote control. Active runtime and condition hold time are not advancing.",
|
||||
"zones.temporaryStopped": "Temporary mode finished — automation resumed control",
|
||||
"zones.temporaryStarted": "Temporary Quick Thermostat started",
|
||||
"zones.temporaryScheduledToast": "Temporary Quick Thermostat has been scheduled",
|
||||
"zones.temporaryStarted": "Temporary thermostat started",
|
||||
"zones.temporaryScheduledToast": "Temporary thermostat has been scheduled",
|
||||
"zones.temporaryInvalidStartAt": "Enter a valid start date and time",
|
||||
"zones.temporaryInvalidUntil": "Enter a valid finish date and time",
|
||||
"zones.temporaryEndAfterStart": "The finish time must be after Quick Thermostat starts",
|
||||
"zones.temporaryEndAfterStart": "The finish time must be after termostat starts",
|
||||
"zones.automationToggleHint": "Enables or disables thermostat automation for this zone. This is not temporary control.",
|
||||
"zones.enabledAutomationDescription": "Disabling the zone stops automation. Manual thermostat, temporary thermostat and direct manual control remain available.",
|
||||
"zones.disabledManualControlTitle": "Zone is disabled",
|
||||
"zones.disabledManualControlHint": "Zone automation is disabled. Temporary thermostat, manual thermostat and direct manual control remain available.",
|
||||
"dashboard.sections": "Dashboard sections",
|
||||
"dashboard.tabMain": "Main",
|
||||
"dashboard.tabThermostats": "Thermostats",
|
||||
@@ -696,6 +807,7 @@
|
||||
"notifications.alertCommunication": "Device communication problems",
|
||||
"notifications.alertTargetTimeout": "Target temperature timeout",
|
||||
"notifications.alertAutomation": "Automation errors and conflicts",
|
||||
"notifications.alertSensorDiscrepancy": "GREE vs Home Assistant temperature difference",
|
||||
"notifications.alertControlErrors": "Thermostat and group control errors",
|
||||
"notifications.alertImportantEvents": "Important state changes",
|
||||
"notifications.alertOther": "Other warnings and errors",
|
||||
@@ -824,6 +936,53 @@
|
||||
"flow.defaultDescription": "Visual control logic",
|
||||
"flow.compilesAutomatically": "Automatically translated into schedules and automations",
|
||||
"flow.openEditor": "Open editor",
|
||||
"flow.editorButton": "Editor",
|
||||
"flow.executionType": "Execution type:",
|
||||
"flow.executionSchedule": "Schedule",
|
||||
"flow.executionAutomation": "Automation",
|
||||
"flow.executionMixed": "Schedule + automation",
|
||||
"flow.executionNone": "None",
|
||||
"flow.executionDraft": "Draft — none",
|
||||
"flow.editDescription": "Edit description",
|
||||
"flow.description": "Description",
|
||||
"flow.descriptionPlaceholder": "Describe what this Flow does…",
|
||||
"flow.descriptionQuickHint": "Only the description is changed. You do not need to open the Flow editor.",
|
||||
"flow.descriptionSaved": "Flow description saved.",
|
||||
"flow.quickEdit": "Quick edit",
|
||||
"flow.simpleSimulator": "Simulator",
|
||||
"flow.simpleSimulatorEyebrow": "Simple Flow preview",
|
||||
"flow.simpleSimulatorHint": "Set a situation and see whether each Flow action would run.",
|
||||
"flow.simpleSimulatorSafe": "Preview only — nothing is changed",
|
||||
"flow.simulatorDraftHint": "Save this Flow as a complete Flow before using the simulator.",
|
||||
"flow.simpleScenario": "Situation to check",
|
||||
"flow.simpleScenarioHint": "Leave values unchanged to use the current application and Home Assistant state.",
|
||||
"flow.simpleCheck": "Check what happens",
|
||||
"flow.simpleResult": "What will happen?",
|
||||
"flow.simpleEffect": "Effect",
|
||||
"flow.simpleWillRun": "Will run",
|
||||
"flow.simpleWillNotRun": "Will not run",
|
||||
"flow.simpleBlocked": "Blocked",
|
||||
"flow.simpleWillRunHint": "The conditions match and this action can run in this situation.",
|
||||
"flow.simpleWillNotRunHint": "At least one required condition does not match in this situation.",
|
||||
"flow.simpleBlockedHint": "The conditions match, but execution is blocked: {reason}.",
|
||||
"flow.simpleConditions": "Checked conditions",
|
||||
"flow.simpleCurrentValue": "Value: {value}",
|
||||
"flow.simpleNoData": "no data",
|
||||
"flow.simpleNoActions": "No actions in this Flow",
|
||||
"flow.simpleNoActionsHint": "Add an action in the Flow editor to simulate an effect.",
|
||||
"flow.simpleBlockedFlowDisabled": "the Flow is disabled",
|
||||
"flow.simpleBlockedMissingZone": "the target zone does not exist",
|
||||
"flow.simpleBlockedMissingDevice": "the target device does not exist",
|
||||
"flow.simpleBlockedMissingGroup": "the target group does not exist",
|
||||
"flow.simpleBlockedManual": "manual device control is active",
|
||||
"flow.simpleBlockedLocalThermostat": "local thermostat override is active",
|
||||
"flow.simpleBlockedTemporary": "Temporary Quick Thermostat is active",
|
||||
"flow.simpleBlockedZoneDisabled": "the target zone is disabled",
|
||||
"flow.simpleBlockedDeviceDisabled": "the target device is disabled",
|
||||
"flow.simpleBlockedThermostatConflict": "the action conflicts with thermostat control",
|
||||
"flow.simpleBlockedGroupDisabled": "group control is disabled",
|
||||
"flow.simpleBlockedUnsupported": "this action is not supported by the simulator",
|
||||
"flow.simpleBlockedUnknown": "another control rule blocks the action",
|
||||
"flow.emptyTitle": "No Flows",
|
||||
"flow.emptyText": "Create your first Flow and build the logic from blocks.",
|
||||
"flow.editorEyebrow": "Flow editor",
|
||||
@@ -836,6 +995,7 @@
|
||||
"flow.emptyCanvas": "Start by adding blocks",
|
||||
"flow.emptyCanvasHint": "Put conditions on the left and actions on the right.",
|
||||
"flow.interpretation": "Interpretation",
|
||||
"flow.previewDetails": "Interpretation and Flow cycle",
|
||||
"flow.selectBlock": "Select a block",
|
||||
"flow.selectBlockHint": "Its settings will appear here.",
|
||||
"flow.newDefaultName": "New logic",
|
||||
@@ -877,6 +1037,7 @@
|
||||
"flow.node.not": "NOT",
|
||||
"flow.node.thermostat": "Thermostat",
|
||||
"flow.node.greeDevice": "GREE device",
|
||||
"flow.node.greeFeature": "Single GREE function",
|
||||
"flow.node.group": "Group",
|
||||
"common.yes": "Yes",
|
||||
"common.no": "No",
|
||||
@@ -926,7 +1087,9 @@
|
||||
"flow.available": "available",
|
||||
"flow.haAvailableHint": "This condition is true only when the Home Assistant entity returns a state other than unknown/unavailable. Connection errors fail closed as false. Useful as a guard before NOT and safety logic.",
|
||||
"flow.deviceOptions": "Advanced GREE options",
|
||||
"flow.deviceOptionsHint": "These fields use the existing DeviceCommand. Fan/quiet/sleep and dry/fan HVAC modes are blocked on a thermostat-assigned device when they would fight the regulator; use the Thermostat action there instead.",
|
||||
"flow.deviceOptionsHint": "These fields use the existing DeviceCommand. Louver positions are one-shot auxiliary settings; fan/quiet/sleep and dry/fan HVAC modes are blocked on a thermostat-assigned device when they would fight the regulator; use the Thermostat action there instead.",
|
||||
"flow.deviceFeature": "Unit function",
|
||||
"flow.deviceFeatureHint": "This action sends exactly one DeviceCommand field. All other unit settings remain unchanged.",
|
||||
"settings.haConnection": "Home Assistant connection",
|
||||
"settings.haConnectionHint": "Server address and credentials used by all Home Assistant features.",
|
||||
"settings.haThermostatSources": "Thermostat temperature sources",
|
||||
@@ -938,6 +1101,10 @@
|
||||
"flow.selectAll": "Select all",
|
||||
"flow.clearSelection": "Clear selection",
|
||||
"flow.selectedCount": "Selected: {count}",
|
||||
"flow.duplicateSelection": "Duplicate",
|
||||
"flow.blocksCopied": "Copied blocks: {count}",
|
||||
"flow.blocksPasted": "Pasted blocks: {count}",
|
||||
"flow.blocksDuplicated": "Duplicated blocks: {count}",
|
||||
"flow.node.sharedInput": "Shared input",
|
||||
"flow.sharedInputMissing": "Shared input missing",
|
||||
"flow.sharedInputTitle": "Shared Flow input",
|
||||
@@ -980,6 +1147,11 @@
|
||||
"flow.sharedInputTestValueRead": "Value read successfully",
|
||||
"flow.sharedInputTestAvailable": "Entity available",
|
||||
"flow.sharedInputTestUnavailable": "Entity unavailable",
|
||||
"flow.haEntitySearchHint": "Search Home Assistant entities by name or entity_id.",
|
||||
"flow.haEntitySearchLoading": "Loading Home Assistant entities…",
|
||||
"flow.haEntitySearchConnect": "Connect and test Home Assistant to browse entity suggestions.",
|
||||
"flow.haEntitySearchCount": "{count} Home Assistant entities available",
|
||||
"flow.haEntitySearchEmpty": "No matching entities",
|
||||
"flow.sharedInputUsedBy": "Used in {count} Flows",
|
||||
"flow.sharedInputUnused": "Not used in any Flow",
|
||||
"flow.openReferencedFlow": "Open Flow “{name}”",
|
||||
@@ -1129,6 +1301,237 @@
|
||||
"flow.expandSettings": "Expand block settings",
|
||||
"flow.notSavedYet": "Not saved yet",
|
||||
"schedules.enabledState": "Enabled",
|
||||
"schedules.disabledState": "Disabled"
|
||||
"schedules.disabledState": "Disabled",
|
||||
"devices.cloudUnit": "Cloud unit",
|
||||
"devices.connection": "Connection",
|
||||
"devices.transport": "Transport",
|
||||
"devices.cloudDeviceId": "Cloud Device ID",
|
||||
"devices.lastSync": "Last synchronization",
|
||||
"devices.cloudDetails": "Details",
|
||||
"devices.cloudDiagnostics": "Cloud diagnostics",
|
||||
"devices.cloudDiagnosticsHint": "Decrypted and sanitized protocol data. Passwords, tokens and cipher keys are never included.",
|
||||
"energy.title": "Energy",
|
||||
"energy.source": "Energy source",
|
||||
"energy.sourceHint": "Choose the cumulative energy source used for charts and period totals.",
|
||||
"energy.auto": "Auto",
|
||||
"energy.haSensor": "Home Assistant energy sensor",
|
||||
"energy.noHaSensor": "No Home Assistant energy sensor selected.",
|
||||
"energy.today": "Today",
|
||||
"energy.yesterday": "Yesterday",
|
||||
"energy.currentMonth": "Current month",
|
||||
"energy.previousMonth": "Previous month",
|
||||
"energy.periodTotal": "Period total",
|
||||
"energy.noData": "No energy source is configured for the selected devices or installations.",
|
||||
"energy.hourly": "Hourly",
|
||||
"energy.daily": "Daily",
|
||||
"energy.monthly": "Monthly",
|
||||
"settings.cloudAccountStatus": "Account status",
|
||||
"settings.cloudMqttStatus": "MQTT status",
|
||||
"settings.cloudReconnect": "Reconnect",
|
||||
"settings.cloudPushHint": "MQTT push is primary. Polling is used only for initial state, recovery and periodic verification.",
|
||||
"history.bucket": "Aggregation",
|
||||
"energy.chartHint": "Period consumption calculated from cumulative meter deltas. Raw cumulative values are never plotted as consumption.",
|
||||
"devices.localCloud": "Local / GREE Cloud",
|
||||
"devices.discoverLocal": "Discover Local",
|
||||
"devices.addLocalManual": "Add Local manually",
|
||||
"devices.cloudAdded": "GREE Cloud device added",
|
||||
"settings.cloudAccountTitle": "GREE Cloud account",
|
||||
"settings.cloudAccountHint": "REST login and device discovery use the selected regional GREE service. The password is never returned by the API after it is saved.",
|
||||
"settings.cloudEnable": "Enable GREE Cloud",
|
||||
"settings.cloudRegion": "Region",
|
||||
"settings.cloudPolling": "Cloud fallback polling interval (s)",
|
||||
"settings.cloudLogin": "Login / email",
|
||||
"settings.cloudPassword": "Password",
|
||||
"settings.cloudPasswordPlaceholder": "Leave empty to keep saved secret",
|
||||
"settings.cloudInstallationId": "Installation ID",
|
||||
"settings.cloudLastContact": "Last successful contact",
|
||||
"settings.cloudTest": "Test connection",
|
||||
"settings.cloudRefreshDevices": "Refresh devices",
|
||||
"settings.cloudConnected": "GREE Cloud connected · {count} device(s)",
|
||||
"settings.cloudConnectionFailed": "GREE Cloud connection failed",
|
||||
"devices.cloudDiscoveryTitle": "Devices on account",
|
||||
"devices.cloudDiscoveryHint": "Devices are loaded from the saved GREE Cloud account. Nothing is added automatically; choose the units you want to register.",
|
||||
"devices.cloudDiscoveryEmpty": "No GREE Cloud devices found",
|
||||
"devices.cloudDiscoveryEmptyHint": "Check the account, region and connection.",
|
||||
"devices.cloudAlreadyAdded": "Added",
|
||||
"status.cloudDisconnected": "Cloud disconnected",
|
||||
"status.authenticationError": "Authentication error",
|
||||
"status.unknown": "Unknown",
|
||||
"dashboard.quickThermostats": "Thermostats",
|
||||
"zones.thermostat": "Thermostat",
|
||||
"settings.debugCloudRequests": "Trace GREE Cloud API requests",
|
||||
"settings.debugCloudMqtt": "Trace GREE Cloud MQTT",
|
||||
"debug.cloud": "Cloud API",
|
||||
"debug.mqtt": "MQTT",
|
||||
"debug.liveSources": "Live protocol sources",
|
||||
"debug.emptyCloud": "No GREE Cloud API activity captured yet.",
|
||||
"debug.emptyMqtt": "No GREE Cloud MQTT activity captured yet.",
|
||||
"settings.cloudDevicesOnline": "Devices online",
|
||||
"settings.cloudResponseTime": "Device response time",
|
||||
"settings.cloudLastDeviceResponse": "Last device response",
|
||||
"settings.cloudLastMqttMessage": "Last MQTT activity",
|
||||
"settings.cloudConnectedSince": "MQTT connected since",
|
||||
"settings.cloudBroker": "Broker",
|
||||
"settings.cloudTraffic": "Requests / responses / timeouts",
|
||||
"devices.model": "Model",
|
||||
"devices.firmware": "Firmware",
|
||||
"devices.lastResponse": "Last response",
|
||||
"devices.noCloudSyncYet": "No successful synchronization yet",
|
||||
"devices.noCloudResponseYet": "No successful device response yet",
|
||||
"devices.cloudAuthenticationProblem": "GREE Cloud authentication error",
|
||||
"devices.cloudTransportProblem": "GREE Cloud connection is unavailable",
|
||||
"devices.cloudDeviceOffline": "GREE Cloud is connected, but the unit is not responding",
|
||||
"settings.cloudRestResponseTime": "REST response time",
|
||||
"common.pending": "Pending",
|
||||
"devices.installations": "Split / multisplit installations",
|
||||
"devices.installationsHint": "Group indoor units that share one outdoor unit. Energy and outdoor temperature can use one shared source.",
|
||||
"devices.installation": "Installation",
|
||||
"devices.installationName": "Installation name",
|
||||
"devices.installationType": "Installation type",
|
||||
"devices.split": "Split",
|
||||
"devices.multisplit": "Multisplit",
|
||||
"devices.installationDevices": "Indoor units",
|
||||
"devices.energySourceDevice": "GREE Cloud energy source",
|
||||
"devices.outdoorSourceDevice": "Outdoor temperature source",
|
||||
"devices.noSharedOutdoor": "No shared source",
|
||||
"devices.installationSaved": "Installation saved.",
|
||||
"devices.installationDeleted": "Installation deleted.",
|
||||
"devices.newInstallation": "New installation",
|
||||
"devices.editInstallation": "Edit installation",
|
||||
"devices.groupedEnergyNote": "This unit belongs to {name}. Energy is reported at installation level: {source}.",
|
||||
"energy.targets": "Devices / installations",
|
||||
"energy.weekly": "Weekly",
|
||||
"energy.compare": "Compare",
|
||||
"energy.compareNone": "No comparison",
|
||||
"energy.comparePreviousDay": "Previous day",
|
||||
"energy.comparePreviousPeriod": "Previous period",
|
||||
"energy.comparePreviousYear": "Year earlier",
|
||||
"energy.currentPeriod": "Current period",
|
||||
"energy.comparisonPeriod": "Comparison period",
|
||||
"energy.groupSource": "Group source",
|
||||
"energy.selectAtLeastOne": "Select at least one device or installation.",
|
||||
"energy.multiselectHint": "Select up to 8 items.",
|
||||
"energy.totalMeter": "Total consumption",
|
||||
"energy.lastReading": "Last reading",
|
||||
"devices.sharedOutdoorMetric": "Shared outdoor temperature",
|
||||
"history.sharedOutdoor": "shared outdoor temperature",
|
||||
"energy.chooseTargets": "Choose devices / installations",
|
||||
"energy.selectedCount": "Selected: {count}",
|
||||
"energy.maxTargets": "You can select up to 8 devices or installations.",
|
||||
"energy.period": "Data period",
|
||||
"energy.periodHint": "Time range used for energy bars and totals.",
|
||||
"devices.swingVertical": "Vertical louver",
|
||||
"devices.swingHorizontal": "Horizontal louver",
|
||||
"louver.off": "Off",
|
||||
"louver.fullRangeAuto": "Full range (Auto)",
|
||||
"louver.advancedSwingRanges": "Additional swing ranges",
|
||||
"louver.vertical.fixedTop": "Fixed: Top",
|
||||
"louver.vertical.fixedUpperMiddle": "Fixed: Upper-middle",
|
||||
"louver.vertical.fixedMiddle": "Fixed: Middle",
|
||||
"louver.vertical.fixedLowerMiddle": "Fixed: Lower-middle",
|
||||
"louver.vertical.fixedBottom": "Fixed: Bottom",
|
||||
"louver.vertical.swingTop": "Swing: Top",
|
||||
"louver.vertical.swingUpperMiddle": "Swing: Upper-middle",
|
||||
"louver.vertical.swingMiddle": "Swing: Middle",
|
||||
"louver.vertical.swingLowerMiddle": "Swing: Lower-middle",
|
||||
"louver.vertical.swingBottom": "Swing: Bottom",
|
||||
"louver.horizontal.fixedLeft": "Fixed: Left",
|
||||
"louver.horizontal.fixedLeftMiddle": "Fixed: Left-middle",
|
||||
"louver.horizontal.fixedMiddle": "Fixed: Middle",
|
||||
"louver.horizontal.fixedRightMiddle": "Fixed: Right-middle",
|
||||
"louver.horizontal.fixedRight": "Fixed: Right",
|
||||
"flow.thermostatDeviceOptions": "Unit louver control",
|
||||
"flow.thermostatDeviceOptionsHint": "Vertical and horizontal louver positions are sent to the unit independently and do not change thermostat logic.",
|
||||
"history.tabNetwork": "Pings",
|
||||
"history.networkTarget": "Endpoint",
|
||||
"history.allNetworkTargets": "All endpoints",
|
||||
"history.networkLatency": "Latency",
|
||||
"history.networkJitter": "Jitter",
|
||||
"history.networkLoss": "Packet loss",
|
||||
"history.networkLatencyJitter": "Latency and jitter",
|
||||
"history.networkLatencyJitterHint": "Round-trip response time and jitter measured over time.",
|
||||
"history.networkLossHint": "Packet loss percentage for each measurement batch.",
|
||||
"history.networkNoData": "No connectivity metrics yet",
|
||||
"history.networkNoDataHint": "Enable connectivity metrics in Settings and wait for the first measurement cycle.",
|
||||
"settings.connectivityTitle": "Connectivity metrics",
|
||||
"settings.connectivityHint": "Periodic GREE UDP round-trip measurements for Local/LAN units. Results are stored in History.",
|
||||
"settings.connectivityEnable": "Enable local unit ping measurements",
|
||||
"settings.connectivityInterval": "Measurement interval (s)",
|
||||
"settings.connectivitySamples": "Samples per measurement",
|
||||
"settings.connectivityLocalOnly": "Only Local/LAN installations are probed. Cloud devices are not pinged directly.",
|
||||
"settings.cloudConnectivityEnable": "Measure Cloud REST/MQTT connectivity",
|
||||
"settings.cloudConnectivityHint": "Optional and disabled by default. Measures REST login response and MQTT PINGRESP round-trip.",
|
||||
"settings.cloudConnectivityTitle": "GREE Cloud connectivity metrics",
|
||||
"settings.cloudConnectivityHistoryHint": "REST and MQTT measurements are stored in History → Pings together with Local/LAN measurements.",
|
||||
"history.networkJitterOn": "Jitter: on",
|
||||
"history.networkJitterOff": "Jitter: off",
|
||||
"history.networkJitterToggleHint": "Show or hide jitter on the latency chart.",
|
||||
"flow.haEntitySearchLabel": "Search Home Assistant entities",
|
||||
"flow.haEntitySearchPlaceholder": "Type a name, entity_id or state…",
|
||||
"flow.haEntitySelectedLabel": "Selected entity (entity_id)",
|
||||
"flow.haEntitySearchMatches": "{count} of {total} Home Assistant entities match",
|
||||
"settings.influxTest": "Test InfluxDB connection",
|
||||
"settings.influxTesting": "Testing InfluxDB…",
|
||||
"settings.influxTestSuccess": "Connection successful ({ms} ms)",
|
||||
"publicChart.title": "Custom chart",
|
||||
"publicChart.loading": "Loading…",
|
||||
"publicChart.invalidLink": "Invalid chart link.",
|
||||
"publicChart.loadFailed": "Unable to load chart: {error}",
|
||||
"publicChart.rangeAria": "Chart range",
|
||||
"publicChart.periodHint": "Period: {hours} h",
|
||||
"publicChart.hoursShort": "{hours} h",
|
||||
"publicChart.noData": "No data",
|
||||
"publicChart.field.deviceIndoor": "Indoor temperature",
|
||||
"publicChart.field.deviceOutdoor": "GREE outdoor temperature",
|
||||
"publicChart.field.deviceTarget": "Device target",
|
||||
"publicChart.field.sharedOutdoor": "Shared outdoor temperature",
|
||||
"publicChart.field.zoneControl": "Control temperature",
|
||||
"publicChart.field.greeSensor": "GREE sensor",
|
||||
"publicChart.field.roomSensor": "Room sensor",
|
||||
"publicChart.field.comfortTarget": "Comfort target",
|
||||
"publicChart.field.deviceSetpoint": "Device setpoint",
|
||||
"publicChart.field.outdoor": "Outdoor temperature",
|
||||
"publicChart.field.temperature": "Temperature",
|
||||
"flow.invalidPresetFilename": "Invalid preset filename.",
|
||||
"flow.invalidPresetFile": "{filename}: invalid preset.",
|
||||
"flow.presetNotFound": "Preset not found.",
|
||||
"discovery.addSelected": "Add selected",
|
||||
"discovery.selectAtLeastOne": "Select at least one device to add.",
|
||||
"discovery.addedCount": "Added: {count}",
|
||||
"discovery.alreadyAdded": "Already added",
|
||||
"discovery.empty": "No local devices found",
|
||||
"discovery.emptyHint": "Try a longer scan, another protocol, or verify that the unit answers on UDP port 7000.",
|
||||
"flow.shortcuts": "Shortcuts",
|
||||
"flow.shortcutsHint": "Shortcuts work while focus is outside form fields.",
|
||||
"flow.shortcutAdd": "Add / search for a block",
|
||||
"flow.shortcutAddFirst": "Add the first search result",
|
||||
"flow.shortcutSave": "Save Flow",
|
||||
"flow.shortcutSelectAll": "Select all blocks",
|
||||
"flow.shortcutCopy": "Copy selected blocks",
|
||||
"flow.shortcutPaste": "Paste blocks",
|
||||
"flow.shortcutDuplicate": "Duplicate selection",
|
||||
"flow.shortcutDelete": "Delete selected blocks",
|
||||
"flow.shortcutMove": "Move selection by 10 px",
|
||||
"flow.shortcutMoveFine": "Move selection precisely by 1 px",
|
||||
"flow.shortcutClear": "Clear selection / cancel connection",
|
||||
"flow.shortcutFit": "Fit the whole Flow to the view",
|
||||
"flow.shortcutZoom": "Zoom in / out",
|
||||
"flow.shortcutHelp": "Show this shortcut list",
|
||||
"flow.shortcutCut": "Cut selected blocks",
|
||||
"flow.blocksCut": "Cut blocks: {count}",
|
||||
"flow.shortcutUndo": "Undo the last change",
|
||||
"flow.undoDone": "Last change undone",
|
||||
"flow.nothingToUndo": "Nothing to undo",
|
||||
"flow.contextUndo": "Undo",
|
||||
"flow.contextAddBlock": "Add block here",
|
||||
"flow.contextPaste": "Paste",
|
||||
"flow.contextPasteHere": "Paste here",
|
||||
"flow.contextCopy": "Copy selection ({count})",
|
||||
"flow.contextCut": "Cut selection ({count})",
|
||||
"flow.contextDuplicate": "Duplicate selection ({count})",
|
||||
"flow.contextDelete": "Delete selection ({count})",
|
||||
"flow.contextDeleteConnection": "Delete connection",
|
||||
"flow.rightClickKey": "Right click",
|
||||
"flow.shortcutContextMenu": "Flow context menu"
|
||||
}
|
||||
}
|
||||
|
||||
+428
-25
@@ -8,6 +8,7 @@
|
||||
"translations": {
|
||||
"meta.description": "Lokalny sterownik klimatyzatorów GREE",
|
||||
"status.connecting": "Łączenie…",
|
||||
"status.reconnecting": "Ponowne łączenie…",
|
||||
"status.connected": "Połączono",
|
||||
"status.disconnected": "Rozłączono",
|
||||
"status.connectionError": "Błąd połączenia",
|
||||
@@ -75,10 +76,42 @@
|
||||
"devices.readDone": "Odczyt zakończony",
|
||||
"devices.bound": "Urządzenie powiązane",
|
||||
"devices.added": "Urządzenie dodane",
|
||||
"devices.disabledZoneTechnicalOnly": "Strefa jest wyłączona. Ręczne sterowanie tą jednostką jest dostępne tylko w zakładce Urządzenia.",
|
||||
"dashboard.manualControlHint": "Bezpośrednie sterowanie GREE: zasilanie, temperatura, tryb, nawiew i obsługiwane funkcje jednostki.",
|
||||
"devices.technicalDescription": "Wyłącznie konfiguracja techniczna i diagnostyka jednostek. Bieżące nastawy znajdują się w Sterowaniu ręcznym.",
|
||||
"devices.technicalUnit": "Jednostka techniczna",
|
||||
"devices.address": "Adres",
|
||||
"devices.modelFirmware": "Model / firmware",
|
||||
"devices.lastSeen": "Ostatnia odpowiedź",
|
||||
"devices.communicationFailures": "Błędy komunikacji",
|
||||
"devices.lastError": "Błąd",
|
||||
"devices.readStatus": "Odczytaj status",
|
||||
"devices.technicalConfig": "Konfiguracja techniczna",
|
||||
"devices.diagnostics": "Diagnostyka",
|
||||
"devices.ping": "Ping",
|
||||
"devices.pingOpen": "Otwórz podgląd pingu",
|
||||
"devices.pingLive": "Ping na żywo",
|
||||
"devices.pingHint": "Pomiar wykonuje minimalne zapytanie GREE bez zmiany stanu urządzenia, liczników błędów ani własności automatyki. Wykres odświeża się, gdy monitoring jest włączony.",
|
||||
"devices.pingAll": "Pinguj wszystkie jednostki",
|
||||
"devices.pingStart": "Uruchom",
|
||||
"devices.pingStop": "Zatrzymaj",
|
||||
"devices.pingCurrent": "Teraz",
|
||||
"devices.pingAverage": "Średnio",
|
||||
"devices.pingMin": "Min.",
|
||||
"devices.pingMax": "Maks.",
|
||||
"devices.pingSamples": "Próbki",
|
||||
"devices.pingNoSamples": "Brak próbek. Uruchom monitoring.",
|
||||
"devices.pingFailed": "Brak odpowiedzi",
|
||||
"devices.pingResponding": "Odpowiada",
|
||||
"devices.manualDeviceDisabled": "Jednostka jest wyłączona technicznie",
|
||||
"devices.manualNoCommunication": "Brak komunikacji z jednostką",
|
||||
"devices.manualCommunicationProblem": "Ostatnia komunikacja z jednostką nie powiodła się",
|
||||
"devices.manualDisabledZoneTitle": "Uwaga: strefa jest wyłączona",
|
||||
"devices.manualDisabledZoneWarning": "Jednostka należy do wyłączonej strefy „{zone}”. Sterowanie ręczne nadal jest dostępne, ale polecenie zostanie wysłane bezpośrednio poza automatyką tej strefy.",
|
||||
"devices.manualDisabledZoneConfirm": "Strefa „{zone}” jest wyłączona. Ta komenda zostanie wysłana bezpośrednio do jednostki, poza automatyką strefy. Automatyka pozostanie wyłączona do jej ponownego włączenia / wznowienia. Wysłać polecenie?",
|
||||
"devices.disabledZoneTechnicalOnly": "Strefa jest wyłączona. Bezpośrednie sterowanie wymaga świadomego potwierdzenia w Sterowaniu ręcznym.",
|
||||
"zones.automation": "Automatyka",
|
||||
"zones.new": "Nowa strefa",
|
||||
"zones.description": "Strefy definiują logikę termostatu i konfigurację czujników. Bieżącą temperaturę, profil i tryb zmieniaj w „Szybkich termostatach” na Pulpicie.",
|
||||
"zones.description": "Strefy definiują logikę termostatu i konfigurację czujników. Bieżącą temperaturę, profil i tryb zmieniaj w „Termostatach” na Pulpicie.",
|
||||
"zones.hysteresis": "Histereza °C",
|
||||
"zones.separateHysteresis": "Ustaw osobno dla grzania i chłodzenia",
|
||||
"zones.coolHysteresis": "Histereza chłodzenia °C",
|
||||
@@ -96,10 +129,13 @@
|
||||
"zones.demand": "Żądanie",
|
||||
"zones.emptyTitle": "Brak stref",
|
||||
"zones.emptyText": "Dodaj strefę, aby sterować temperaturą automatycznie.",
|
||||
"zones.roomSensorEntity": "entity_id czujnika pokojowego",
|
||||
"zones.roomSensorEntity": "entity_id czujnika temperatury pokojowej",
|
||||
"zones.roomSensorWeight": "Waga czujnika pokojowego %",
|
||||
"zones.maxDifference": "Maks. różnica czujników °C",
|
||||
"zones.sensorHelp": "Ten czujnik jest przypisany tylko do tej strefy. Gdy przestanie być dostępny, kontroler automatycznie wróci do sensora GREE.",
|
||||
"zones.sensorHelp": "Wymagany, gdy strategia temperatury używa Home Assistant. Gdy czujnik będzie niedostępny, kontroler wróci do sensora GREE.",
|
||||
"zones.sensorRequired": "Podaj entity_id czujnika temperatury pokojowej z Home Assistant dla tej strefy.",
|
||||
"zones.outdoorSensorEntity": "Nadpisanie entity_id temperatury zewnętrznej",
|
||||
"zones.outdoorSensorHelp": "Pozostaw puste, aby użyć globalnego Outdoor temperature entity_id. Wybierz inne entity_id, aby tylko ta strefa używała innego czujnika zewnętrznego. Czujnik jest używany przez wspomaganie temperaturą zewnętrzną oraz zapisywany w historii i metrykach.",
|
||||
"zones.greeTemp": "GREE",
|
||||
"zones.externalTemp": "Pokój",
|
||||
"zones.usedSource": "użyte",
|
||||
@@ -148,8 +184,18 @@
|
||||
"settings.haTokenKeep": "Pozostaw puste, aby zachować zapisany token",
|
||||
"settings.haTokenSaved": "Token zapisany — pozostaw puste",
|
||||
"settings.haLongLivedToken": "Długotrwały token dostępu",
|
||||
"settings.defaultEntity": "Entity_id do testu połączenia",
|
||||
"settings.defaultEntityHint": "Ta encja jest używana przez test połączenia z Home Assistant, gdy nie wskazano innej encji. Nie jest automatycznie źródłem temperatury strefy.",
|
||||
"settings.haSupervisorToken": "Token dostarczany automatycznie przez Home Assistant Supervisor",
|
||||
"settings.haSupervisorAutoTitle": "Automatyczna autoryzacja przez Supervisor",
|
||||
"settings.haSupervisorAutoHint": "Wykryto token Supervisor. Autoryzacja Home Assistant jest skonfigurowana automatycznie — nie trzeba podawać URL ani Long-Lived Access Token.",
|
||||
"settings.haSupervisorVerifiedTitle": "Autoryzacja Supervisor zweryfikowana",
|
||||
"settings.haSupervisorVerifiedHint": "Test API Home Assistant zakończył się powodzeniem z użyciem tokenu Supervisor.",
|
||||
"settings.haSupervisorMissingTitle": "Brak tokenu Supervisor",
|
||||
"settings.haSupervisorMissingHint": "Wykryto środowisko dodatku, ale token Supervisor nie jest dostępny. Uruchom Test HA, aby w razie potrzeby odblokować ręczne pola awaryjne.",
|
||||
"settings.haSupervisorTestFailedTitle": "Automatyczna autoryzacja HA nie powiodła się",
|
||||
"settings.haSupervisorTestFailedHint": "Udostępniono awaryjne pola do ręcznego podania adresu Home Assistant i tokenu.",
|
||||
"settings.haManualFallbackTitle": "Aktywny ręczny fallback Home Assistant",
|
||||
"settings.haManualFallbackHint": "Dodatek używa ręcznie zapisanego adresu i tokenu Home Assistant zamiast autoryzacji Supervisor.",
|
||||
"settings.haUseSupervisor": "Użyj automatycznie Supervisor",
|
||||
"settings.testHa": "Testuj HA",
|
||||
"settings.systemState": "Stan systemu",
|
||||
"settings.version": "Wersja",
|
||||
@@ -160,6 +206,12 @@
|
||||
"logs.diagnostics": "Diagnostyka",
|
||||
"logs.emptyTitle": "Brak zdarzeń",
|
||||
"logs.emptyText": "Log jest pusty.",
|
||||
"logs.silent": "SILENT",
|
||||
"logs.silentHint": "Powiadomienie nie zostało wysłane.",
|
||||
"logs.silentAlertTypeDisabled": "Powiadomienie nie zostało wysłane: ten typ alertu jest wyłączony.",
|
||||
"logs.silentNotificationsDisabled": "Powiadomienie nie zostało wysłane: powiadomienia są wyłączone.",
|
||||
"logs.silentModeFiltered": "Powiadomienie nie zostało wysłane: zdarzenie odfiltrował tryb powiadomień.",
|
||||
"logs.silentCooldown": "Powiadomienie nie zostało wysłane: aktywny jest cooldown.",
|
||||
"common.name": "Nazwa",
|
||||
"common.zone": "Strefa",
|
||||
"common.device": "Urządzenie",
|
||||
@@ -209,7 +261,8 @@
|
||||
"label.schedule": "harmonogram",
|
||||
"label.automation": "automatyzację",
|
||||
"toast.found": "Znaleziono: {count}",
|
||||
"toast.haTemperature": "Home Assistant: {temperature}°C",
|
||||
"toast.haConnected": "Połączenie z Home Assistant działa",
|
||||
"toast.haConnectedSample": "Połączono z Home Assistant. Przykładowy odczyt: {entity} = {value}",
|
||||
"day.1": "Pn",
|
||||
"day.2": "Wt",
|
||||
"day.3": "Śr",
|
||||
@@ -236,16 +289,21 @@
|
||||
"toast.tokenRevoked": "Token unieważniony",
|
||||
"devices.protocolAuto": "Auto (V1 + V2)",
|
||||
"devices.rename": "Nazwa / protokół",
|
||||
"devices.protocolChangeHint": "Zmiana protokołu usuwa zapisany klucz urządzenia i wykona ponowny bind przy następnym żądaniu.",
|
||||
"devices.protocolChangeHint": "Zmiana protokołu usuwa zapisany klucz urządzenia. Opcja „Zapisz i sprawdź połączenie” od razu wykona wymagany bind i test.",
|
||||
"devices.saveAndCheck": "Zapisz i sprawdź połączenie",
|
||||
"devices.connectionCheckOk": "Połączenie działa · odpowiedź {ms} ms",
|
||||
"devices.connectionCheckFailed": "Konfiguracja została zapisana, ale test połączenia nie powiódł się: {error}",
|
||||
"devices.savedAndChecked": "Zapisano i potwierdzono połączenie",
|
||||
"devices.savedCheckFailed": "Zapisano konfigurację, ale test połączenia nie powiódł się",
|
||||
"discovery.title": "Wykrywanie urządzeń GREE",
|
||||
"discovery.help": "Auto wyszukuje urządzenia AES-ECB i AES-GCM. Kilka przebiegów zwiększa skuteczność, gdy wiele modułów Wi-Fi odpowiada na ten sam broadcast.",
|
||||
"discovery.help": "Auto akceptuje urządzenia AES-ECB i AES-GCM, traktuje dane discovery tylko jako wskazówkę protokołu i potwierdza właściwy protokół podczas bindowania. Wybranie V1 lub V2 blokuje wyszukiwanie i bindowanie do tego protokołu. Kilka przebiegów pomaga, gdy wiele modułów Wi-Fi odpowiada na ten sam broadcast.",
|
||||
"discovery.protocol": "Protokół wykrywania",
|
||||
"discovery.passes": "Liczba przebiegów",
|
||||
"discovery.timeout": "Łączny czas wyszukiwania (ms)",
|
||||
"zones.quickHint": "Szybka zmiana celu i trybu",
|
||||
"actions.later": "Później",
|
||||
"discovery.nameDevices": "Nazwij znalezione urządzenia",
|
||||
"discovery.nameDevicesHelp": "Nadaj każdej nowej jednostce przyjazną nazwę pokoju. Model techniczny i MAC pozostaną dostępne w diagnostyce.",
|
||||
"discovery.nameDevices": "Wyszukane urządzenia lokalne",
|
||||
"discovery.nameDevicesHelp": "Wybierz jednostki, które chcesz dodać. Dla każdego wyniku widoczny jest adres MAC, adres IP i wskazówka protokołu z discovery; Auto zostanie rozstrzygnięte podczas bindowania.",
|
||||
"dashboard.quickThermostats": "Termostaty",
|
||||
"common.unavailable": "Niedostępne",
|
||||
"mode.off": "Wyłączone",
|
||||
@@ -253,6 +311,13 @@
|
||||
"house.smartThermostat": "Inteligentny termostat",
|
||||
"house.setpointStrategy": "Grzanie lub chłodzenie określa kierunek pracy automatyki. Po wznowieniu strefa bez aktywnego harmonogramu, ręcznego celu, trybu czasowego lub innego jawnego sterowania pozostaje OFF. „Nie steruj” nie ingeruje w urządzenia.",
|
||||
"house.outdoor": "Na zewnątrz",
|
||||
"house.outdoorHistoryOpen": "Pokaż wykres temperatury zewnętrznej z ostatnich 24 godzin",
|
||||
"house.outdoorHistoryHint": "Temperatura zewnętrzna z ostatnich 24 godzin.",
|
||||
"house.outdoorHistoryMore": "Więcej metryk i zakresów w historii",
|
||||
"house.outdoorHistoryFull": "Pełna historia",
|
||||
"history.range7d": "7 dni",
|
||||
"history.range30d": "30 dni",
|
||||
"history.range1y": "1 rok",
|
||||
"house.modeUpdated": "Zmieniono tryb klimatu domu",
|
||||
"zones.modePolicy": "Polityka trybu",
|
||||
"zones.modePolicyHint": "Jeśli strefa ma korzystać z globalnego trybu Grzanie/Chłodzenie ustawionego w GREE Controller, zostaw „Dziedzicz tryb globalny” i nie zmieniaj tej opcji. „Tylko chłodzenie” lub „Tylko grzanie” wymusza stały tryb tylko dla tej strefy.",
|
||||
@@ -285,7 +350,8 @@
|
||||
"schedules.chooseZone": "Najpierw wybierz strefę",
|
||||
"schedules.templateApplied": "Zastosowano preset harmonogramu",
|
||||
"schedules.profile": "Profil",
|
||||
"settings.outdoorEntity": "entity_id temperatury zewnętrznej",
|
||||
"settings.outdoorEntity": "Globalny entity_id temperatury zewnętrznej",
|
||||
"settings.outdoorEntityHint": "Globalny czujnik temperatury zewnętrznej z Home Assistant. Strefy używają go domyślnie i mogą wskazać własny czujnik zewnętrzny w konfiguracji strefy.",
|
||||
"settings.outdoorAssist": "Używaj temperatury zewnętrznej jako wsparcia inteligentnego sterowania",
|
||||
"settings.allowInvalidTls": "Zezwól na nieważny/samopodpisany certyfikat HTTPS",
|
||||
"settings.allowInvalidTlsHint": "Używaj tylko dla zaufanego lokalnego serwera Home Assistant, np. https://192.168.50.25.",
|
||||
@@ -357,9 +423,13 @@
|
||||
"history.copyLink": "Kopiuj link",
|
||||
"history.series": "serie",
|
||||
"history.noSavedCharts": "Brak zapisanych własnych wykresów.",
|
||||
"history.savedCharts": "Zapisane wykresy",
|
||||
"history.editingChart": "Edytujesz wykres",
|
||||
"history.saveChanges": "Zapisz zmiany",
|
||||
"history.customChart": "Własny wykres klimatu",
|
||||
"history.customChartHint": "Wybrane źródła danych na wspólnej osi czasu.",
|
||||
"history.chartSaved": "Własny wykres zapisany",
|
||||
"history.chartUpdated": "Własny wykres zaktualizowany",
|
||||
"history.linkCopied": "Link do wykresu skopiowany",
|
||||
"toast.successTitle": "Gotowe",
|
||||
"toast.errorTitle": "Wystąpił błąd",
|
||||
@@ -371,9 +441,33 @@
|
||||
"plan.loading": "Wczytywanie aktualnego planu automatyki…",
|
||||
"plan.rules": "Dodatkowe automatyzacje",
|
||||
"plan.ruleCount": "Aktywne reguły: {count}",
|
||||
"plan.ruleCountPaused": "Reguły wstrzymane przez STOP: {count}",
|
||||
"plan.event": "Zmiana",
|
||||
"plan.groupSummary": "Strefy w grupie: {zones} · żądające działania: {demand}",
|
||||
"plan.groupOff": "Grupa wyłączona — jednostki grupy są wyłączone, a blokada grupowa jest zwolniona.",
|
||||
"plan.reasonsEyebrow": "Powód sterowania",
|
||||
"plan.reasonsTitle": "Dlaczego sterownik działa właśnie tak",
|
||||
"plan.currentReasons": "Bieżące powody",
|
||||
"plan.recentTriggers": "Ostatnio wyzwolone automatyzacje",
|
||||
"plan.noReasons": "Brak aktywnych powodów sterowania.",
|
||||
"plan.reasonEmergency": "Automatyka wstrzymana przez STOP awaryjny.",
|
||||
"plan.reasonManual": "Ręczne sterowanie urządzeniem ma pierwszeństwo.",
|
||||
"plan.reasonDisabled": "Strefa jest wyłączona.",
|
||||
"plan.reasonOffline": "Urządzenie jest offline lub ma błąd komunikacji.",
|
||||
"plan.reasonNoIntent": "Brak aktywnego harmonogramu lub jawnego żądania termostatu.",
|
||||
"plan.reasonLocalOff": "Lokalny termostat jawnie utrzymuje strefę wyłączoną.",
|
||||
"plan.reasonLocalOn": "Lokalny termostat przejął sterowanie strefą.",
|
||||
"plan.reasonHeatDemand": "{current} jest poniżej celu {target} — aktywne żądanie grzania.",
|
||||
"plan.reasonCoolDemand": "{current} jest powyżej celu {target} — aktywne żądanie chłodzenia.",
|
||||
"plan.reasonTargetSatisfied": "Temperatura {current}; cel {target} — brak potrzeby zwiększania mocy.",
|
||||
"plan.reasonSchedule": "Aktywny harmonogram: {schedule}.",
|
||||
"plan.reasonWaiting": "Sterownik czeka na warunek wymagający działania.",
|
||||
"plan.reasonSourceSchedule": "Harmonogram: {schedule}",
|
||||
"plan.reasonSourceManual": "Sterowanie ręczne",
|
||||
"plan.reasonSourceThermostat": "Termostat lokalny",
|
||||
"plan.reasonSourceAutomation": "Automatyka",
|
||||
"plan.reasonTriggerReading": "{trigger} · teraz {current}",
|
||||
"plan.reasonLastTriggered": "Ostatnie wyzwolenie: {when}",
|
||||
"zones.enable": "Włącz strefę",
|
||||
"zones.disable": "Wyłącz strefę",
|
||||
"history.90d": "90 dni",
|
||||
@@ -556,6 +650,17 @@
|
||||
"house.powerOnDone": "Włączono wszystkie termostaty i wysłano polecenie włączenia jednostek",
|
||||
"house.powerOffDone": "Wyłączono wszystkie jednostki; termostaty pozostają lokalnie wyłączone",
|
||||
"house.powerPartial": "Nie udało się zmienić zasilania {count} urządzeń",
|
||||
"house.emergencyStop": "STOP awaryjny",
|
||||
"house.emergencyStopHint": "Wstrzymuje całą automatykę i jednorazowo próbuje wyłączyć wszystkie aktywne jednostki.",
|
||||
"house.emergencyStopIdleHint": "System jest bezczynny i wszystkie jednostki są wyłączone. STOP awaryjny pozostaje ukryty do chwili aktywnego sterowania lub uruchomienia jednostki.",
|
||||
"house.emergencyStopConfirm": "Włączyć STOP awaryjny? Automatyka zostanie trwale wstrzymana do ręcznego wznowienia, a sterownik spróbuje wyłączyć wszystkie jednostki.",
|
||||
"house.emergencyResume": "Powrót do normalnego trybu",
|
||||
"house.emergencyResumeHint": "Odblokowuje automatykę. Nie wymusza włączenia urządzeń; sterownik ponownie oceni aktualne warunki.",
|
||||
"house.emergencyActive": "STOP awaryjny jest aktywny",
|
||||
"house.emergencyActiveHint": "Stan przetrwa restart kontrolera. Po restarcie automatyka nadal pozostaje zablokowana, ale polecenie OFF nie jest wysyłane ponownie.",
|
||||
"house.emergencyStopped": "STOP awaryjny aktywny — automatyka wstrzymana",
|
||||
"house.emergencyStoppedPartial": "STOP awaryjny aktywny, ale nie udało się wyłączyć {count} urządzeń",
|
||||
"house.emergencyResumed": "Przywrócono normalny tryb automatyki",
|
||||
"preset.manual": "Ręcznie",
|
||||
"nav.groups": "Grupy",
|
||||
"groups.eyebrow": "Sterowanie grupami",
|
||||
@@ -582,6 +687,9 @@
|
||||
"groups.noZones": "Najpierw utwórz strefy termostatów.",
|
||||
"automations.targetType": "Typ celu",
|
||||
"automations.groupHint": "Dla grupy Auto oznacza dziedziczenie globalnego trybu grzania/chłodzenia. Profil grupy jest stosowany do wszystkich stref w grupie.",
|
||||
"automations.singleDeviceFunctions": "Pojedyncze funkcje urządzenia",
|
||||
"automations.deviceFunction": "Funkcja jednostki",
|
||||
"automations.singleDeviceFunctionsHint": "Pozostaw pozostałe pola jako Bez zmian, aby zmienić wyłącznie wybraną funkcję jednostki. Fan, Quiet i Sleep nadal respektują istniejące zasady właściciela termostatu.",
|
||||
"simulation.openTab": "Otwórz sam symulator w nowej karcie",
|
||||
"simulation.fullscreen": "Pełny ekran",
|
||||
"simulation.exitFullscreen": "Wyjdź z pełnego ekranu",
|
||||
@@ -594,7 +702,7 @@
|
||||
"groups.chooseMember": "Wybierz co najmniej jedną strefę termostatu.",
|
||||
"zones.configuration": "Konfiguracja strefy",
|
||||
"zones.currentStatus": "Stan bieżący",
|
||||
"zones.controlOnDashboard": "Bieżące sterowanie znajduje się w Szybkich termostatach na Pulpicie.",
|
||||
"zones.controlOnDashboard": "Bieżące sterowanie znajduje się w termostatach na Pulpicie.",
|
||||
"zones.controlNow": "Steruj",
|
||||
"zones.noGroup": "bez grupy",
|
||||
"zones.coolOnly": "Tylko chłodzenie",
|
||||
@@ -631,12 +739,12 @@
|
||||
"zones.lockoutUntil": "blokada do",
|
||||
"settings.haSensorStaleAfterMinutes": "Maksymalny wiek odczytu sensora HA (min)",
|
||||
"settings.haSensorStaleAfterHint": "Jeśli sensor HA nie zaktualizuje się przez ten czas, odczyt zostanie uznany za nieaktualny. Termostat użyje czujnika GREE jako awaryjnego źródła, jeśli jest dostępny.",
|
||||
"zones.quickThermostat": "Szybki termostat",
|
||||
"zones.thermostat": "Termostat",
|
||||
"zones.temporaryTitle": "Tryb czasowy",
|
||||
"zones.temporaryActive": "Aktywny szybki termostat",
|
||||
"zones.temporaryScheduled": "Zaplanowany szybki termostat",
|
||||
"zones.temporaryActive": "Aktywny termostat",
|
||||
"zones.temporaryScheduled": "Zaplanowany termostat",
|
||||
"zones.temporaryStop": "Zakończ i wróć do automatyki",
|
||||
"zones.temporaryTarget": "Temperatura szybkiego termostatu",
|
||||
"zones.temporaryTarget": "Temperatura termostatu",
|
||||
"zones.temporaryStartRule": "Kiedy uruchomić",
|
||||
"zones.temporaryStartNow": "Od razu",
|
||||
"zones.temporaryStartDelay": "Z opóźnieniem",
|
||||
@@ -659,11 +767,11 @@
|
||||
"zones.temporaryHoldMinutes": "Warunek musi trwać (min)",
|
||||
"zones.temporarySafetyLimit": "Limit bezpieczeństwa (min, opcjonalnie)",
|
||||
"zones.temporaryStableHint": "Jeśli temperatura wyjdzie poza warunek, licznik utrzymania zeruje się i zaczyna od nowa po ponownym spełnieniu warunku.",
|
||||
"zones.temporaryScheduleHint": "Szybki termostat zakończy się przy najbliższej zmianie aktywnego wpisu harmonogramu tej strefy.",
|
||||
"zones.temporaryScheduleHint": "Termostat zakończy się przy najbliższej zmianie aktywnego wpisu harmonogramu tej strefy.",
|
||||
"zones.temporaryStart": "Włącz czasowo",
|
||||
"zones.temporaryUpdate": "Zmień zasady",
|
||||
"zones.temporaryShort": "Czasowo",
|
||||
"zones.temporaryStartHint": "Włącz szybki termostat czasowo lub do spełnienia warunku.",
|
||||
"zones.temporaryStartHint": "Włącz termostat czasowo lub do spełnienia warunku.",
|
||||
"zones.temporaryOverride": "Czasowo · {time}",
|
||||
"zones.temporaryWithinShort": "{target}°C ±{tolerance}°C",
|
||||
"zones.temporaryAtOrBelowShort": "≤ {target}°C · tol. {tolerance}°C",
|
||||
@@ -674,16 +782,19 @@
|
||||
"zones.temporaryUntilScheduleStatus": "Do następnej zmiany harmonogramu",
|
||||
"zones.temporaryTimeStatus": "Do automatycznego powrotu do automatyki",
|
||||
"zones.temporaryScheduledStatus": "Zaplanowany start: {time}. Do tego czasu normalna automatyka działa bez zmian.",
|
||||
"zones.temporaryStartingStatus": "Uruchamianie szybkiego termostatu…",
|
||||
"zones.temporaryStartingStatus": "Uruchamianie termostatu…",
|
||||
"zones.temporaryWaitingMaster": "Sesja czeka na włączenie MASTER — nie zużywa czasu aktywnej pracy.",
|
||||
"zones.temporaryPausedManual": "Sesja wstrzymana przez sterowanie ręczne/pilot. Czas aktywnej pracy i licznik warunku nie biegną.",
|
||||
"zones.temporaryStopped": "Tryb czasowy zakończony — automatyka przejęła sterowanie",
|
||||
"zones.temporaryStarted": "Tryb czasowy szybkiego termostatu uruchomiony",
|
||||
"zones.temporaryScheduledToast": "Tryb czasowy szybkiego termostatu został zaplanowany",
|
||||
"zones.temporaryStarted": "Tryb czasowy termostatu uruchomiony",
|
||||
"zones.temporaryScheduledToast": "Tryb czasowy termostatu został zaplanowany",
|
||||
"zones.temporaryInvalidStartAt": "Podaj prawidłową datę i godzinę uruchomienia",
|
||||
"zones.temporaryInvalidUntil": "Podaj prawidłową datę i godzinę zakończenia",
|
||||
"zones.temporaryEndAfterStart": "Czas zakończenia musi przypadać po uruchomieniu szybkiego termostatu",
|
||||
"zones.temporaryEndAfterStart": "Czas zakończenia musi przypadać po uruchomieniu termostatu",
|
||||
"zones.automationToggleHint": "Włącza lub wyłącza automatykę termostatu tej strefy. Nie jest to chwilowe sterowanie.",
|
||||
"zones.enabledAutomationDescription": "Wyłączenie strefy zatrzymuje automatykę. Termostat ręczny, termostat czasowy i bezpośrednie sterowanie ręczne nadal działają.",
|
||||
"zones.disabledManualControlTitle": "Strefa jest wyłączona",
|
||||
"zones.disabledManualControlHint": "Automatyka strefy jest wyłączona. Termostat czasowy, termostat ręczny i bezpośrednie sterowanie ręczne nadal działają.",
|
||||
"dashboard.sections": "Sekcje pulpitu",
|
||||
"dashboard.tabMain": "Główna",
|
||||
"dashboard.tabThermostats": "Termostaty",
|
||||
@@ -696,6 +807,7 @@
|
||||
"notifications.alertCommunication": "Problemy komunikacji z urządzeniami",
|
||||
"notifications.alertTargetTimeout": "Nieosiągnięcie temperatury w limicie czasu",
|
||||
"notifications.alertAutomation": "Błędy i konflikty automatyzacji",
|
||||
"notifications.alertSensorDiscrepancy": "Różnica temperatury GREE względem Home Assistant",
|
||||
"notifications.alertControlErrors": "Błędy sterowania termostatami i grupami",
|
||||
"notifications.alertImportantEvents": "Ważne zmiany stanu",
|
||||
"notifications.alertOther": "Pozostałe ostrzeżenia i błędy",
|
||||
@@ -824,6 +936,53 @@
|
||||
"flow.defaultDescription": "Wizualna logika sterowania",
|
||||
"flow.compilesAutomatically": "Automatycznie tłumaczone na harmonogramy i automatyzacje",
|
||||
"flow.openEditor": "Otwórz edytor",
|
||||
"flow.editorButton": "Edytor",
|
||||
"flow.executionType": "Typ wykonania:",
|
||||
"flow.executionSchedule": "Harmonogram",
|
||||
"flow.executionAutomation": "Automatyzacja",
|
||||
"flow.executionMixed": "Harmonogram + automatyzacja",
|
||||
"flow.executionNone": "Brak",
|
||||
"flow.executionDraft": "Szkic — brak",
|
||||
"flow.editDescription": "Edytuj opis",
|
||||
"flow.description": "Opis",
|
||||
"flow.descriptionPlaceholder": "Opisz krótko, co robi ten Flow…",
|
||||
"flow.descriptionQuickHint": "Zmieni się tylko opis. Nie musisz otwierać edytora Flow.",
|
||||
"flow.descriptionSaved": "Opis Flow zapisany.",
|
||||
"flow.quickEdit": "Szybka edycja",
|
||||
"flow.simpleSimulator": "Symulator",
|
||||
"flow.simpleSimulatorEyebrow": "Prosty podgląd Flow",
|
||||
"flow.simpleSimulatorHint": "Ustaw sytuację i sprawdź, czy poszczególne akcje Flow się wykonają.",
|
||||
"flow.simpleSimulatorSafe": "Tylko podgląd — niczego nie zmienia",
|
||||
"flow.simulatorDraftHint": "Najpierw zapisz ten Flow jako kompletny, aby użyć symulatora.",
|
||||
"flow.simpleScenario": "Sytuacja do sprawdzenia",
|
||||
"flow.simpleScenarioHint": "Zostaw wartości bez zmian, aby użyć aktualnego stanu aplikacji i Home Assistant.",
|
||||
"flow.simpleCheck": "Sprawdź, co się stanie",
|
||||
"flow.simpleResult": "Co się wydarzy?",
|
||||
"flow.simpleEffect": "Efekt",
|
||||
"flow.simpleWillRun": "Wykona się",
|
||||
"flow.simpleWillNotRun": "Nie wykona się",
|
||||
"flow.simpleBlocked": "Zablokowana",
|
||||
"flow.simpleWillRunHint": "Warunki są spełnione i ta akcja może się wykonać w tej sytuacji.",
|
||||
"flow.simpleWillNotRunHint": "Co najmniej jeden wymagany warunek nie jest spełniony w tej sytuacji.",
|
||||
"flow.simpleBlockedHint": "Warunki są spełnione, ale wykonanie blokuje: {reason}.",
|
||||
"flow.simpleConditions": "Sprawdzone warunki",
|
||||
"flow.simpleCurrentValue": "Wartość: {value}",
|
||||
"flow.simpleNoData": "brak danych",
|
||||
"flow.simpleNoActions": "Ten Flow nie ma akcji",
|
||||
"flow.simpleNoActionsHint": "Dodaj akcję w edytorze Flow, aby zasymulować efekt.",
|
||||
"flow.simpleBlockedFlowDisabled": "Flow jest wyłączony",
|
||||
"flow.simpleBlockedMissingZone": "docelowa strefa nie istnieje",
|
||||
"flow.simpleBlockedMissingDevice": "docelowe urządzenie nie istnieje",
|
||||
"flow.simpleBlockedMissingGroup": "docelowa grupa nie istnieje",
|
||||
"flow.simpleBlockedManual": "aktywne jest ręczne sterowanie urządzeniem",
|
||||
"flow.simpleBlockedLocalThermostat": "aktywny jest lokalny override termostatu",
|
||||
"flow.simpleBlockedTemporary": "aktywny jest Tymczasowy szybki termostat",
|
||||
"flow.simpleBlockedZoneDisabled": "docelowa strefa jest wyłączona",
|
||||
"flow.simpleBlockedDeviceDisabled": "docelowe urządzenie jest wyłączone",
|
||||
"flow.simpleBlockedThermostatConflict": "akcja koliduje ze sterowaniem termostatu",
|
||||
"flow.simpleBlockedGroupDisabled": "sterowanie grupą jest wyłączone",
|
||||
"flow.simpleBlockedUnsupported": "symulator nie obsługuje tej akcji",
|
||||
"flow.simpleBlockedUnknown": "inna reguła sterowania blokuje akcję",
|
||||
"flow.emptyTitle": "Brak Flow",
|
||||
"flow.emptyText": "Utwórz pierwszy przepływ i ułóż logikę z bloków.",
|
||||
"flow.editorEyebrow": "Edytor Flow",
|
||||
@@ -836,6 +995,7 @@
|
||||
"flow.emptyCanvas": "Zacznij od dodania bloków",
|
||||
"flow.emptyCanvasHint": "Warunki układaj po lewej, akcje po prawej.",
|
||||
"flow.interpretation": "Interpretacja",
|
||||
"flow.previewDetails": "Interpretacja i cykl Flow",
|
||||
"flow.selectBlock": "Wybierz blok",
|
||||
"flow.selectBlockHint": "Tutaj pojawią się jego ustawienia.",
|
||||
"flow.newDefaultName": "Nowa logika",
|
||||
@@ -877,6 +1037,7 @@
|
||||
"flow.node.not": "NOT",
|
||||
"flow.node.thermostat": "Termostat",
|
||||
"flow.node.greeDevice": "Urządzenie GREE",
|
||||
"flow.node.greeFeature": "Pojedyncza funkcja GREE",
|
||||
"flow.node.group": "Grupa",
|
||||
"common.yes": "Tak",
|
||||
"common.no": "Nie",
|
||||
@@ -926,7 +1087,9 @@
|
||||
"flow.available": "dostępna",
|
||||
"flow.haAvailableHint": "Warunek jest prawdziwy tylko wtedy, gdy encja Home Assistant odpowiada stanem innym niż unknown/unavailable. Błąd połączenia jest traktowany bezpiecznie jako false. Przydatne jako bramka przed NOT i logiką bezpieczeństwa.",
|
||||
"flow.deviceOptions": "Zaawansowane opcje GREE",
|
||||
"flow.deviceOptionsHint": "Te pola używają istniejącego DeviceCommand. Fan/quiet/sleep oraz tryby dry/fan są blokowane dla urządzenia przypisanego do termostatu, gdy mogłyby walczyć z regulatorem; użyj wtedy akcji Termostat.",
|
||||
"flow.deviceOptionsHint": "Te pola używają istniejącego DeviceCommand. Pozycje żaluzji są jednorazowymi ustawieniami pomocniczymi; fan/quiet/sleep oraz tryby dry/fan są blokowane dla urządzenia przypisanego do termostatu, gdy mogłyby walczyć z regulatorem; użyj wtedy akcji Termostat.",
|
||||
"flow.deviceFeature": "Funkcja jednostki",
|
||||
"flow.deviceFeatureHint": "Ta akcja wysyła dokładnie jedno pole DeviceCommand. Pozostałe ustawienia jednostki nie są zmieniane.",
|
||||
"settings.haConnection": "Połączenie z Home Assistant",
|
||||
"settings.haConnectionHint": "Adres serwera i dane dostępu używane przez wszystkie funkcje Home Assistant.",
|
||||
"settings.haThermostatSources": "Źródła temperatury sterowania",
|
||||
@@ -938,6 +1101,10 @@
|
||||
"flow.selectAll": "Zaznacz wszystko",
|
||||
"flow.clearSelection": "Wyczyść zaznaczenie",
|
||||
"flow.selectedCount": "Zaznaczono: {count}",
|
||||
"flow.duplicateSelection": "Duplikuj",
|
||||
"flow.blocksCopied": "Skopiowano bloki: {count}",
|
||||
"flow.blocksPasted": "Wklejono bloki: {count}",
|
||||
"flow.blocksDuplicated": "Zduplikowano bloki: {count}",
|
||||
"flow.node.sharedInput": "Wspólne wejście",
|
||||
"flow.sharedInputMissing": "Brak wspólnego wejścia",
|
||||
"flow.sharedInputTitle": "Wspólne wejście Flow",
|
||||
@@ -980,6 +1147,11 @@
|
||||
"flow.sharedInputTestValueRead": "Wartość odczytana poprawnie",
|
||||
"flow.sharedInputTestAvailable": "Encja dostępna",
|
||||
"flow.sharedInputTestUnavailable": "Encja niedostępna",
|
||||
"flow.haEntitySearchHint": "Wyszukaj encję Home Assistant po nazwie lub entity_id.",
|
||||
"flow.haEntitySearchLoading": "Wczytywanie encji Home Assistant…",
|
||||
"flow.haEntitySearchConnect": "Połącz i przetestuj Home Assistant, aby przeglądać propozycje encji.",
|
||||
"flow.haEntitySearchCount": "Dostępnych encji Home Assistant: {count}",
|
||||
"flow.haEntitySearchEmpty": "Brak pasujących encji",
|
||||
"flow.sharedInputUsedBy": "Używane w {count} Flow",
|
||||
"flow.sharedInputUnused": "Nieużywane w żadnym Flow",
|
||||
"flow.openReferencedFlow": "Otwórz Flow „{name}”",
|
||||
@@ -1129,6 +1301,237 @@
|
||||
"flow.expandSettings": "Rozwiń ustawienia bloku",
|
||||
"flow.notSavedYet": "Jeszcze nie zapisano",
|
||||
"schedules.enabledState": "Włączony",
|
||||
"schedules.disabledState": "Wyłączony"
|
||||
"schedules.disabledState": "Wyłączony",
|
||||
"devices.cloudUnit": "Jednostka Cloud",
|
||||
"devices.connection": "Połączenie",
|
||||
"devices.transport": "Transport",
|
||||
"devices.cloudDeviceId": "Cloud Device ID",
|
||||
"devices.lastSync": "Ostatnia synchronizacja",
|
||||
"devices.cloudDetails": "Szczegóły",
|
||||
"devices.cloudDiagnostics": "Diagnostyka Cloud",
|
||||
"devices.cloudDiagnosticsHint": "Odszyfrowane i zsanityzowane dane protokołu. Hasła, tokeny i klucze szyfrujące nigdy nie są tu zwracane.",
|
||||
"energy.title": "Energia",
|
||||
"energy.source": "Źródło energii",
|
||||
"energy.sourceHint": "Wybierz narastający licznik energii używany do wykresów i sum okresowych.",
|
||||
"energy.auto": "Auto",
|
||||
"energy.haSensor": "Sensor energii Home Assistant",
|
||||
"energy.noHaSensor": "Nie wybrano sensora energii Home Assistant.",
|
||||
"energy.today": "Dzisiaj",
|
||||
"energy.yesterday": "Wczoraj",
|
||||
"energy.currentMonth": "Bieżący miesiąc",
|
||||
"energy.previousMonth": "Poprzedni miesiąc",
|
||||
"energy.periodTotal": "Suma okresu",
|
||||
"energy.noData": "Dla wybranych urządzeń lub instalacji nie skonfigurowano źródła energii.",
|
||||
"energy.hourly": "Godzinowo",
|
||||
"energy.daily": "Dziennie",
|
||||
"energy.monthly": "Miesięcznie",
|
||||
"settings.cloudAccountStatus": "Status konta",
|
||||
"settings.cloudMqttStatus": "Status MQTT",
|
||||
"settings.cloudReconnect": "Połącz ponownie",
|
||||
"settings.cloudPushHint": "Podstawą synchronizacji jest push MQTT. Polling służy tylko do stanu początkowego, odzyskiwania połączenia i okresowej weryfikacji.",
|
||||
"history.bucket": "Agregacja",
|
||||
"energy.chartHint": "Zużycie okresowe obliczone z przyrostów licznika narastającego. Surowa wartość licznika nigdy nie jest rysowana jako zużycie.",
|
||||
"devices.localCloud": "Local / GREE Cloud",
|
||||
"devices.discoverLocal": "Wykryj Local",
|
||||
"devices.addLocalManual": "Dodaj Local ręcznie",
|
||||
"devices.cloudAdded": "Dodano urządzenie GREE Cloud",
|
||||
"settings.cloudAccountTitle": "Konto GREE Cloud",
|
||||
"settings.cloudAccountHint": "Logowanie REST i wykrywanie urządzeń korzystają z regionalnej usługi GREE. Zapisane hasło nie jest ponownie zwracane przez API.",
|
||||
"settings.cloudEnable": "Włącz GREE Cloud",
|
||||
"settings.cloudRegion": "Region",
|
||||
"settings.cloudPolling": "Interwał awaryjnego pollingu Cloud (s)",
|
||||
"settings.cloudLogin": "Login / e-mail",
|
||||
"settings.cloudPassword": "Hasło",
|
||||
"settings.cloudPasswordPlaceholder": "Zostaw puste, aby zachować zapisany sekret",
|
||||
"settings.cloudInstallationId": "ID instalacji",
|
||||
"settings.cloudLastContact": "Ostatni udany kontakt",
|
||||
"settings.cloudTest": "Test połączenia",
|
||||
"settings.cloudRefreshDevices": "Odśwież urządzenia",
|
||||
"settings.cloudConnected": "GREE Cloud połączony · {count} urządzeń",
|
||||
"settings.cloudConnectionFailed": "Połączenie GREE Cloud nie powiodło się",
|
||||
"devices.cloudDiscoveryTitle": "Urządzenia na koncie",
|
||||
"devices.cloudDiscoveryHint": "Urządzenia są pobierane z zapisanego konta GREE Cloud. Nic nie jest dodawane automatycznie — wybierz jednostki, które chcesz zarejestrować.",
|
||||
"devices.cloudDiscoveryEmpty": "Nie znaleziono urządzeń GREE Cloud",
|
||||
"devices.cloudDiscoveryEmptyHint": "Sprawdź konto, region i połączenie.",
|
||||
"devices.cloudAlreadyAdded": "Dodano",
|
||||
"status.cloudDisconnected": "Cloud rozłączony",
|
||||
"status.authenticationError": "Błąd uwierzytelnienia",
|
||||
"status.unknown": "Nieznany",
|
||||
"dashboard.thermostats": "Termostaty",
|
||||
"zones.quickThermostat": "Termostat",
|
||||
"settings.debugCloudRequests": "Śledź requesty API GREE Cloud",
|
||||
"settings.debugCloudMqtt": "Śledź MQTT GREE Cloud",
|
||||
"debug.cloud": "Cloud API",
|
||||
"debug.mqtt": "MQTT",
|
||||
"debug.liveSources": "Źródła protokołu na żywo",
|
||||
"debug.emptyCloud": "Brak przechwyconej aktywności API GREE Cloud.",
|
||||
"debug.emptyMqtt": "Brak przechwyconej aktywności MQTT GREE Cloud.",
|
||||
"settings.cloudDevicesOnline": "Urządzenia online",
|
||||
"settings.cloudResponseTime": "Czas odpowiedzi urządzenia",
|
||||
"settings.cloudLastDeviceResponse": "Ostatnia odpowiedź urządzenia",
|
||||
"settings.cloudLastMqttMessage": "Ostatnia aktywność MQTT",
|
||||
"settings.cloudConnectedSince": "MQTT połączony od",
|
||||
"settings.cloudBroker": "Broker",
|
||||
"settings.cloudTraffic": "Requesty / odpowiedzi / timeouty",
|
||||
"devices.model": "Model",
|
||||
"devices.firmware": "Firmware",
|
||||
"devices.lastResponse": "Ostatnia odpowiedź",
|
||||
"devices.noCloudSyncYet": "Brak udanej synchronizacji",
|
||||
"devices.noCloudResponseYet": "Brak udanej odpowiedzi urządzenia",
|
||||
"devices.cloudAuthenticationProblem": "Błąd uwierzytelniania GREE Cloud",
|
||||
"devices.cloudTransportProblem": "Połączenie z GREE Cloud jest niedostępne",
|
||||
"devices.cloudDeviceOffline": "GREE Cloud jest połączony, ale urządzenie nie odpowiada",
|
||||
"settings.cloudRestResponseTime": "Czas odpowiedzi REST",
|
||||
"common.pending": "Oczekiwanie",
|
||||
"devices.installations": "Instalacje split / multisplit",
|
||||
"devices.installationsHint": "Grupuj jednostki wewnętrzne współdzielące jedną jednostkę zewnętrzną. Energia i temperatura zewnętrzna mogą korzystać z jednego wspólnego źródła.",
|
||||
"devices.installation": "Instalacja",
|
||||
"devices.installationName": "Nazwa instalacji",
|
||||
"devices.installationType": "Typ instalacji",
|
||||
"devices.split": "Split",
|
||||
"devices.multisplit": "Multisplit",
|
||||
"devices.installationDevices": "Jednostki wewnętrzne",
|
||||
"devices.energySourceDevice": "Źródło energii GREE Cloud",
|
||||
"devices.outdoorSourceDevice": "Źródło temperatury zewnętrznej",
|
||||
"devices.noSharedOutdoor": "Brak wspólnego źródła",
|
||||
"devices.installationSaved": "Zapisano instalację.",
|
||||
"devices.installationDeleted": "Usunięto instalację.",
|
||||
"devices.newInstallation": "Nowa instalacja",
|
||||
"devices.editInstallation": "Edytuj instalację",
|
||||
"devices.groupedEnergyNote": "Ta jednostka należy do instalacji {name}. Energia jest raportowana na poziomie instalacji: {source}.",
|
||||
"energy.targets": "Urządzenia / instalacje",
|
||||
"energy.weekly": "Tygodniowo",
|
||||
"energy.compare": "Porównaj",
|
||||
"energy.compareNone": "Bez porównania",
|
||||
"energy.comparePreviousDay": "Poprzedni dzień",
|
||||
"energy.comparePreviousPeriod": "Poprzedni okres",
|
||||
"energy.comparePreviousYear": "Rok wcześniej",
|
||||
"energy.currentPeriod": "Bieżący okres",
|
||||
"energy.comparisonPeriod": "Okres porównawczy",
|
||||
"energy.groupSource": "Źródło grupy",
|
||||
"energy.selectAtLeastOne": "Wybierz co najmniej jedno urządzenie lub instalację.",
|
||||
"energy.multiselectHint": "Wybierz do 8 pozycji.",
|
||||
"energy.totalMeter": "Całkowite zużycie",
|
||||
"energy.lastReading": "Ostatni odczyt",
|
||||
"devices.sharedOutdoorMetric": "Wspólna temperatura zewnętrzna",
|
||||
"history.sharedOutdoor": "wspólna temperatura zewnętrzna",
|
||||
"energy.chooseTargets": "Wybierz urządzenia / instalacje",
|
||||
"energy.selectedCount": "Wybrano: {count}",
|
||||
"energy.maxTargets": "Możesz wybrać maksymalnie 8 urządzeń lub instalacji.",
|
||||
"energy.period": "Okres danych",
|
||||
"energy.periodHint": "Zakres czasu używany do słupków i sum energii.",
|
||||
"devices.swingVertical": "Żaluzja pionowa",
|
||||
"devices.swingHorizontal": "Żaluzja pozioma",
|
||||
"louver.off": "Wyłączony",
|
||||
"louver.fullRangeAuto": "Pełny zakres (Auto)",
|
||||
"louver.advancedSwingRanges": "Dodatkowe zakresy ruchu",
|
||||
"louver.vertical.fixedTop": "Stała: Góra",
|
||||
"louver.vertical.fixedUpperMiddle": "Stała: Środek-góra",
|
||||
"louver.vertical.fixedMiddle": "Stała: Środek",
|
||||
"louver.vertical.fixedLowerMiddle": "Stała: Środek-dół",
|
||||
"louver.vertical.fixedBottom": "Stała: Dół",
|
||||
"louver.vertical.swingTop": "Ruch: Góra",
|
||||
"louver.vertical.swingUpperMiddle": "Ruch: Środek-góra",
|
||||
"louver.vertical.swingMiddle": "Ruch: Środek",
|
||||
"louver.vertical.swingLowerMiddle": "Ruch: Środek-dół",
|
||||
"louver.vertical.swingBottom": "Ruch: Dół",
|
||||
"louver.horizontal.fixedLeft": "Stała: Lewo",
|
||||
"louver.horizontal.fixedLeftMiddle": "Stała: Lewo-środek",
|
||||
"louver.horizontal.fixedMiddle": "Stała: Środek",
|
||||
"louver.horizontal.fixedRightMiddle": "Stała: Środek-prawo",
|
||||
"louver.horizontal.fixedRight": "Stała: Prawo",
|
||||
"flow.thermostatDeviceOptions": "Sterowanie żaluzjami jednostki",
|
||||
"flow.thermostatDeviceOptionsHint": "Pozycje żaluzji pionowej i poziomej są wysyłane do jednostki niezależnie i nie zmieniają logiki termostatu.",
|
||||
"history.tabNetwork": "Pingi",
|
||||
"history.networkTarget": "Punkt pomiaru",
|
||||
"history.allNetworkTargets": "Wszystkie punkty",
|
||||
"history.networkLatency": "Opóźnienie",
|
||||
"history.networkJitter": "Jitter",
|
||||
"history.networkLoss": "Straty pakietów",
|
||||
"history.networkLatencyJitter": "Opóźnienie i jitter",
|
||||
"history.networkLatencyJitterHint": "Czas odpowiedzi round-trip i jitter mierzone w czasie.",
|
||||
"history.networkLossHint": "Procent strat dla każdej serii próbek.",
|
||||
"history.networkNoData": "Brak metryk łączności",
|
||||
"history.networkNoDataHint": "Włącz metryki łączności w Ustawieniach i poczekaj na pierwszy cykl pomiarowy.",
|
||||
"settings.connectivityTitle": "Metryki łączności",
|
||||
"settings.connectivityHint": "Okresowe pomiary odpowiedzi GREE UDP dla jednostek Local/LAN. Wyniki są zapisywane w Historii.",
|
||||
"settings.connectivityEnable": "Włącz badanie pingów jednostek lokalnych",
|
||||
"settings.connectivityInterval": "Interwał pomiaru (s)",
|
||||
"settings.connectivitySamples": "Liczba próbek na pomiar",
|
||||
"settings.connectivityLocalOnly": "Pingowane są tylko instalacje Local/LAN. Jednostki Cloud nie są pingowane bezpośrednio.",
|
||||
"settings.cloudConnectivityEnable": "Mierz łączność GREE Cloud REST/MQTT",
|
||||
"settings.cloudConnectivityHint": "Opcjonalne i domyślnie wyłączone. Mierzy czas odpowiedzi logowania REST oraz round-trip MQTT PINGRESP.",
|
||||
"settings.cloudConnectivityTitle": "Metryki łączności GREE Cloud",
|
||||
"settings.cloudConnectivityHistoryHint": "Pomiary REST i MQTT są zapisywane w Historia → Pingi razem z pomiarami Local/LAN.",
|
||||
"history.networkJitterOn": "Jitter: włączony",
|
||||
"history.networkJitterOff": "Jitter: wyłączony",
|
||||
"history.networkJitterToggleHint": "Pokaż lub ukryj jitter na wykresie opóźnienia.",
|
||||
"flow.haEntitySearchLabel": "Szukaj encji Home Assistant",
|
||||
"flow.haEntitySearchPlaceholder": "Wpisz nazwę, entity_id lub stan…",
|
||||
"flow.haEntitySelectedLabel": "Wybrana encja (entity_id)",
|
||||
"flow.haEntitySearchMatches": "Pasujących: {count} z {total} encji Home Assistant",
|
||||
"settings.influxTest": "Testuj połączenie z InfluxDB",
|
||||
"settings.influxTesting": "Testowanie InfluxDB…",
|
||||
"settings.influxTestSuccess": "Połączenie działa ({ms} ms)",
|
||||
"publicChart.title": "Wykres niestandardowy",
|
||||
"publicChart.loading": "Wczytywanie…",
|
||||
"publicChart.invalidLink": "Nieprawidłowy link do wykresu.",
|
||||
"publicChart.loadFailed": "Nie udało się wczytać wykresu: {error}",
|
||||
"publicChart.rangeAria": "Zakres wykresu",
|
||||
"publicChart.periodHint": "Okres: {hours} h",
|
||||
"publicChart.hoursShort": "{hours} h",
|
||||
"publicChart.noData": "Brak danych",
|
||||
"publicChart.field.deviceIndoor": "Temperatura wewnętrzna",
|
||||
"publicChart.field.deviceOutdoor": "Temperatura zewnętrzna GREE",
|
||||
"publicChart.field.deviceTarget": "Temperatura zadana urządzenia",
|
||||
"publicChart.field.sharedOutdoor": "Wspólna temperatura zewnętrzna",
|
||||
"publicChart.field.zoneControl": "Temperatura sterująca",
|
||||
"publicChart.field.greeSensor": "Czujnik GREE",
|
||||
"publicChart.field.roomSensor": "Czujnik pomieszczenia",
|
||||
"publicChart.field.comfortTarget": "Temperatura docelowa",
|
||||
"publicChart.field.deviceSetpoint": "Nastawa urządzenia",
|
||||
"publicChart.field.outdoor": "Temperatura zewnętrzna",
|
||||
"publicChart.field.temperature": "Temperatura",
|
||||
"flow.invalidPresetFilename": "Nieprawidłowa nazwa pliku presetu.",
|
||||
"flow.invalidPresetFile": "{filename}: nieprawidłowy preset.",
|
||||
"flow.presetNotFound": "Nie znaleziono presetu.",
|
||||
"discovery.addSelected": "Dodaj wybrane",
|
||||
"discovery.selectAtLeastOne": "Wybierz co najmniej jedno urządzenie do dodania.",
|
||||
"discovery.addedCount": "Dodano: {count}",
|
||||
"discovery.alreadyAdded": "Już dodane",
|
||||
"discovery.empty": "Nie znaleziono urządzeń lokalnych",
|
||||
"discovery.emptyHint": "Spróbuj dłuższego skanowania, innego protokołu albo sprawdź, czy jednostka odpowiada na UDP 7000.",
|
||||
"flow.shortcuts": "Skróty",
|
||||
"flow.shortcutsHint": "Skróty działają, gdy kursor nie znajduje się w polu formularza.",
|
||||
"flow.shortcutAdd": "Dodaj / wyszukaj blok",
|
||||
"flow.shortcutAddFirst": "Dodaj pierwszy wynik wyszukiwania",
|
||||
"flow.shortcutSave": "Zapisz Flow",
|
||||
"flow.shortcutSelectAll": "Zaznacz wszystkie bloki",
|
||||
"flow.shortcutCopy": "Kopiuj zaznaczone bloki",
|
||||
"flow.shortcutPaste": "Wklej bloki",
|
||||
"flow.shortcutDuplicate": "Duplikuj zaznaczenie",
|
||||
"flow.shortcutDelete": "Usuń zaznaczone bloki",
|
||||
"flow.shortcutMove": "Przesuń zaznaczenie o 10 px",
|
||||
"flow.shortcutMoveFine": "Przesuń precyzyjnie o 1 px",
|
||||
"flow.shortcutClear": "Wyczyść zaznaczenie / anuluj łączenie",
|
||||
"flow.shortcutFit": "Dopasuj cały Flow do widoku",
|
||||
"flow.shortcutZoom": "Powiększ / pomniejsz",
|
||||
"flow.shortcutHelp": "Pokaż tę listę skrótów",
|
||||
"flow.shortcutCut": "Wytnij zaznaczone bloki",
|
||||
"flow.blocksCut": "Wycięto bloki: {count}",
|
||||
"flow.shortcutUndo": "Cofnij ostatnią zmianę",
|
||||
"flow.undoDone": "Cofnięto ostatnią zmianę",
|
||||
"flow.nothingToUndo": "Brak zmian do cofnięcia",
|
||||
"flow.contextUndo": "Cofnij",
|
||||
"flow.contextAddBlock": "Dodaj blok tutaj",
|
||||
"flow.contextPaste": "Wklej",
|
||||
"flow.contextPasteHere": "Wklej tutaj",
|
||||
"flow.contextCopy": "Kopiuj zaznaczenie ({count})",
|
||||
"flow.contextCut": "Wytnij zaznaczenie ({count})",
|
||||
"flow.contextDuplicate": "Duplikuj zaznaczenie ({count})",
|
||||
"flow.contextDelete": "Usuń zaznaczenie ({count})",
|
||||
"flow.contextDeleteConnection": "Usuń połączenie",
|
||||
"flow.rightClickKey": "Prawy przycisk",
|
||||
"flow.shortcutContextMenu": "Menu kontekstowe Flow"
|
||||
}
|
||||
}
|
||||
|
||||
-1302
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
document.addEventListener('DOMContentLoaded',()=>{
|
||||
const q=new URLSearchParams(location.search);
|
||||
document.documentElement.dataset.theme='dark';
|
||||
const view=q.get('view')||'dashboard';
|
||||
document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.dataset.view===view));
|
||||
if(view==='dashboard'){
|
||||
document.querySelector('#summaryText').textContent='3 urządzenia online · 2 aktywne termostaty';
|
||||
document.querySelector('#metrics').innerHTML='<span>Online <b>3/3</b></span><span>Aktywne <b>2</b></span>';
|
||||
document.querySelector('#heroTemperature').innerHTML='21.4<small>°C</small>';
|
||||
document.querySelector('#houseClimate').innerHTML='<div class="house-climate-head"><div><h3>Tryb domu</h3><p>Wspólne ustawienia dla stref.</p></div><span class="outside-pill">Na zewnątrz 12.8°C</span></div><div class="house-mode-row"><button class="active">Grzanie</button><button>Chłodzenie</button></div><div class="house-preset-row"><button>Auto</button><button class="active">Komfort</button><button>Sen</button><button>Poza domem</button></div>';
|
||||
document.querySelector('#controlPlan').innerHTML='<article class="list-card"><div class="list-card-head"><div><h3>Salon</h3><p>Utrzymuj 21.5°C do 22:30</p></div><span class="badge">Aktywne</span></div></article><article class="list-card"><div class="list-card-head"><div><h3>Sypialnia</h3><p>Tryb nocny od 22:30</p></div><span class="badge">za 2h</span></div></article>';
|
||||
}
|
||||
if(view==='flows'){
|
||||
document.querySelector('#flowListCount').textContent='3 flow';
|
||||
document.querySelector('#flowList').innerHTML=`<article class="list-card flow-card"><div class="list-card-head"><div><h3>Okno w salonie</h3><p>Wyłącza grzanie, gdy okno jest otwarte.</p></div><button class="zone-enable-toggle active"><span>✓</span>Włączone</button></div><div class="card-stats"><div class="card-stat"><small>Bloki</small><strong>5</strong></div><div class="card-stat"><small>Harmonogramy</small><strong>0</strong></div><div class="card-stat"><small>Automatyzacje</small><strong>1</strong></div></div><div class="card-footer"><small>1 automatyzacja</small><div class="card-menu"><button>Otwórz edytor</button><button class="danger">Usuń</button></div></div></article><article class="list-card flow-card is-disabled"><div class="list-card-head"><div><h3>Tani prąd</h3><p>Podbija temperaturę przy niskiej cenie energii.</p></div><button class="zone-enable-toggle"><span>○</span>Wyłączone</button></div><div class="card-stats"><div class="card-stat"><small>Bloki</small><strong>7</strong></div><div class="card-stat"><small>Harmonogramy</small><strong>1</strong></div><div class="card-stat"><small>Automatyzacje</small><strong>2</strong></div></div><div class="card-footer"><small>1 harmonogram · 2 automatyzacje</small><div class="card-menu"><button>Otwórz edytor</button><button class="danger">Usuń</button></div></div></article>`;
|
||||
}
|
||||
if(q.get('editor')==='1'){
|
||||
document.querySelector('#flowEditor').hidden=false; document.body.classList.add('flow-editor-open');
|
||||
document.querySelector('#flowName').hidden=true; document.querySelector('#flowNameView').hidden=false; document.querySelector('#flowNameText').textContent='Okno w salonie';
|
||||
document.querySelector('#flowCompileStatus').textContent='0 harm. · 1 autom.'; document.querySelector('#flowSaveStatus').textContent='Zapisano';
|
||||
document.querySelector('#flowInterpretation').textContent='Jeśli okno w salonie jest otwarte przez 30 s, wyłącz termostat Salon.';
|
||||
document.querySelector('#flowRuntimeSummary').textContent='Ostatnie wykonanie: 07:12 · wynik: prawda';
|
||||
document.querySelector('#flowEmptyHint').style.display='none';
|
||||
document.querySelector('#flowNodes').innerHTML=`<div class="flow-node flow-node-sensor selected" style="left:60px;top:80px"><div class="flow-node-head">Wspólne wejście <button>×</button></div><div class="flow-node-body">Okno w salonie<br><div class="flow-node-current"><span>Teraz</span><strong>otwarte</strong></div></div><button class="flow-port flow-port-out"></button></div><div class="flow-node flow-node-timeop" style="left:300px;top:160px"><button class="flow-port flow-port-in"></button><div class="flow-node-head">Utrzymuje się przez… <button>×</button></div><div class="flow-node-body">30 sekund</div><button class="flow-port flow-port-out"></button></div><div class="flow-node flow-node-action" style="left:550px;top:95px"><button class="flow-port flow-port-in"></button><div class="flow-node-head">Termostat <button>×</button></div><div class="flow-node-body">Salon · wyłącz</div></div>`;
|
||||
document.querySelector('#flowInspector').classList.add('has-selection');
|
||||
document.querySelector('#flowInspector').innerHTML='<div class="flow-inspector-head"><div><span class="eyebrow">Ustawienia bloku</span><h3>Wspólne wejście</h3></div><div class="flow-inspector-head-actions"><button class="flow-inspector-close">×</button></div></div><label><span>Źródło</span><select><option>Okno w salonie</option></select></label><p class="field-note">Czujnik używany tylko we Flow. Nie jest zapisywany do metryk.</p>';
|
||||
}
|
||||
});
|
||||
Regular → Executable
+26
-7
@@ -3,18 +3,37 @@ set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
MANIFEST="FILE_MANIFEST.sha256"
|
||||
TMP="${MANIFEST}.tmp"
|
||||
SOURCE_MANIFEST="SOURCE_MANIFEST.sha256"
|
||||
FILE_MANIFEST="FILE_MANIFEST.sha256"
|
||||
SOURCE_TMP="${SOURCE_MANIFEST}.tmp"
|
||||
FILE_TMP="${FILE_MANIFEST}.tmp"
|
||||
|
||||
find . -type f \
|
||||
! -path "./${MANIFEST}" \
|
||||
! -path "./${TMP}" \
|
||||
! -path "./${SOURCE_MANIFEST}" \
|
||||
! -path "./${SOURCE_TMP}" \
|
||||
! -path "./${FILE_MANIFEST}" \
|
||||
! -path "./${FILE_TMP}" \
|
||||
! -path "./.git/*" \
|
||||
! -path "./target/*" \
|
||||
! -name '*.pyc' \
|
||||
! -path '*/__pycache__/*' \
|
||||
-print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum > "$TMP"
|
||||
| xargs -0 sha256sum > "$SOURCE_TMP"
|
||||
|
||||
mv "$TMP" "$MANIFEST"
|
||||
mv "$SOURCE_TMP" "$SOURCE_MANIFEST"
|
||||
sha256sum -c "$SOURCE_MANIFEST"
|
||||
|
||||
sha256sum -c "$MANIFEST"
|
||||
find . -type f \
|
||||
! -path "./${FILE_MANIFEST}" \
|
||||
! -path "./${FILE_TMP}" \
|
||||
! -path "./.git/*" \
|
||||
! -path "./target/*" \
|
||||
! -name '*.pyc' \
|
||||
! -path '*/__pycache__/*' \
|
||||
-print0 \
|
||||
| sort -z \
|
||||
| xargs -0 sha256sum > "$FILE_TMP"
|
||||
|
||||
mv "$FILE_TMP" "$FILE_MANIFEST"
|
||||
sha256sum -c "$FILE_MANIFEST"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./README.md
|
||||
5bc736c7bc76ca80aaa406bb171d2aa91baf4c3aa8695dce0e09b888b6ab3146 ./common.sh
|
||||
6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./configure-gree-network.sh
|
||||
1d6e14e26e49aa9d3527f30a23668bf8d9c48b67e6628ef686c3155c012155de ./dev.sh
|
||||
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./generate_ha_migration.py
|
||||
e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./install-lxc.sh
|
||||
bb7cd2c5b27c9dceec1d1d2846fbad9600df9c08e0ad07d13fcde97af533091c ./install.sh
|
||||
e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./network-debug.sh
|
||||
01952aa92b217f8eae2493b88870e2dec595100cd15c4d561ff11ae2b936c46f ./regenerate-sha.sh
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./service.sh
|
||||
fec8b0362e763bbc115b5cc5c56f9ebe815730cee161632d81c5dc0ee1475f80 ./smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./update.sh
|
||||
4877f9e8217b6a77fb416722c3778373edac874ed3a583bb016ea76d4ffee7d4 ./verify_flow_logic.py
|
||||
@@ -7,6 +7,7 @@ All operator-facing scripts live in this directory.
|
||||
- `service.sh` — start, stop, restart, status, logs and health helper.
|
||||
- `dev.sh` — development build/run/check workflow.
|
||||
- `smoke.sh` — HTTP/API smoke test used by `dev.sh --check`.
|
||||
- `live_realtime_test.js` — Node-based regression test for WebSocket live-state updates, control-plan push/fallback and reconnect behavior.
|
||||
- `generate_ha_migration.py` — legacy/manual Home Assistant entity mapping helper.
|
||||
- `install-lxc.sh` — compatibility alias for `install.sh`.
|
||||
- `common.sh` — shared shell functions; normally not executed directly.
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Developer smoke/integration tests for GREE Controller API 0.15.17.
|
||||
|
||||
Default mode is read-only and safe to run against a real controller.
|
||||
Use --settings-write to additionally round-trip all split settings resources and
|
||||
exercise selected validation failures. This writes the same settings back and
|
||||
therefore creates settings/events and can trigger normal settings side effects.
|
||||
|
||||
No third-party dependencies are required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
|
||||
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "")
|
||||
DEFAULT_EXPECTED_VERSION = "0.15.17"
|
||||
|
||||
SETTINGS_PATHS: dict[str, set[str]] = {
|
||||
"/api/settings/application": {"simulator_enabled"},
|
||||
"/api/settings/gree": {
|
||||
"controller_id",
|
||||
"poll_interval_seconds",
|
||||
"zone_interval_seconds",
|
||||
"discovery_timeout_ms",
|
||||
"discovery_broadcast",
|
||||
"ping_metrics_enabled",
|
||||
"ping_interval_seconds",
|
||||
"ping_sample_count",
|
||||
"suppress_device_beep",
|
||||
"compressor_protection_enabled",
|
||||
"compressor_protection_seconds",
|
||||
},
|
||||
"/api/settings/gree-cloud": {
|
||||
"enabled",
|
||||
"region",
|
||||
"username",
|
||||
"password_configured",
|
||||
"polling_interval_seconds",
|
||||
"connectivity_metrics_enabled",
|
||||
"connectivity_metrics_interval_seconds",
|
||||
"connectivity_metrics_sample_count",
|
||||
"installation_id",
|
||||
"account_id",
|
||||
"last_successful_contact",
|
||||
"last_rest_response_time_ms",
|
||||
},
|
||||
"/api/settings/history": {"retention_days", "compaction_enabled", "event_retention_days"},
|
||||
"/api/settings/influxdb": {
|
||||
"enabled",
|
||||
"version",
|
||||
"url",
|
||||
"database",
|
||||
"username",
|
||||
"password_configured",
|
||||
"org",
|
||||
"bucket",
|
||||
"token_configured",
|
||||
"history_threshold_days",
|
||||
},
|
||||
"/api/settings/notifications": {
|
||||
"enabled",
|
||||
"mode",
|
||||
"provider",
|
||||
"pushover_configured",
|
||||
"slack_configured",
|
||||
"discord_configured",
|
||||
"cooldown_seconds",
|
||||
"communication_failure_threshold",
|
||||
"target_timeout_minutes",
|
||||
"alert_types",
|
||||
},
|
||||
"/api/settings/night": {
|
||||
"enabled", "start_time", "end_time", "max_fan_speed", "force_quiet", "use_native_sleep"
|
||||
},
|
||||
"/api/settings/home-assistant": {
|
||||
"url",
|
||||
"token_configured",
|
||||
"outdoor_entity_id",
|
||||
"sensor_stale_after_seconds",
|
||||
"allow_invalid_tls",
|
||||
"sensor_aliases",
|
||||
"flow_inputs",
|
||||
"outdoor_assist_enabled",
|
||||
},
|
||||
"/api/settings/debug": {"overlay_enabled", "gree_frames"},
|
||||
}
|
||||
|
||||
REMOVED_0120_PATHS = [
|
||||
"/api/settings",
|
||||
"/api/debug",
|
||||
"/api/events/retention",
|
||||
"/api/settings/export",
|
||||
"/api/settings/import",
|
||||
]
|
||||
|
||||
SAFE_GET_PATHS = [
|
||||
"/api/bootstrap",
|
||||
"/api/system/info",
|
||||
"/api/devices",
|
||||
"/api/zones",
|
||||
"/api/groups",
|
||||
"/api/schedules",
|
||||
"/api/automations",
|
||||
"/api/flows",
|
||||
"/api/readings",
|
||||
"/api/history",
|
||||
"/api/history/network",
|
||||
"/api/control-plan",
|
||||
"/api/events",
|
||||
"/api/access-tokens",
|
||||
"/api/configuration/export",
|
||||
]
|
||||
|
||||
# These are administrator-authenticated restricted HA reads. They are safe but
|
||||
# can legitimately fail if the server is configured to forbid this surface.
|
||||
HA_SAFE_GET_PATHS = [
|
||||
"/api/integrations/home-assistant/devices",
|
||||
"/api/integrations/home-assistant/control-plan",
|
||||
"/api/integrations/home-assistant/groups",
|
||||
"/api/integrations/home-assistant/snapshot",
|
||||
]
|
||||
|
||||
DETAIL_COLLECTIONS = [
|
||||
("/api/devices", "/api/devices/{id}"),
|
||||
("/api/zones", "/api/zones/{id}"),
|
||||
("/api/groups", "/api/groups/{id}"),
|
||||
("/api/schedules", "/api/schedules/{id}"),
|
||||
("/api/automations", "/api/automations/{id}"),
|
||||
("/api/flows", "/api/flows/{id}"),
|
||||
]
|
||||
|
||||
EXPECTED_OPENAPI_PATHS = set(SETTINGS_PATHS) | {
|
||||
"/api/history/network",
|
||||
"/api/configuration/export",
|
||||
"/api/configuration/import",
|
||||
"/api/integrations/home-assistant/snapshot",
|
||||
}
|
||||
|
||||
BOOTSTRAP_SETTINGS_SECTIONS = {
|
||||
"application", "gree", "gree_cloud", "history", "influxdb", "notifications", "night", "home_assistant", "debug"
|
||||
}
|
||||
|
||||
|
||||
class TestFailure(AssertionError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class HttpResponse:
|
||||
status: int
|
||||
headers: dict[str, str]
|
||||
body: bytes
|
||||
elapsed_ms: float
|
||||
|
||||
def text(self) -> str:
|
||||
return self.body.decode("utf-8", errors="replace")
|
||||
|
||||
def json(self) -> Any:
|
||||
try:
|
||||
return json.loads(self.text())
|
||||
except json.JSONDecodeError as exc:
|
||||
raise TestFailure(f"response is not valid JSON: {exc}; body={self.text()[:300]!r}") from exc
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base_url: str, token: str, timeout: float, insecure: bool) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token.strip()
|
||||
self.timeout = timeout
|
||||
self.ssl_context: Optional[ssl.SSLContext] = None
|
||||
if insecure:
|
||||
self.ssl_context = ssl._create_unverified_context() # noqa: SLF001 - explicit developer option
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
return self.base_url + path
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: Any = None,
|
||||
*,
|
||||
auth: bool = True,
|
||||
headers: Optional[dict[str, str]] = None,
|
||||
) -> HttpResponse:
|
||||
req_headers = {"Accept": "application/json"}
|
||||
if auth and self.token:
|
||||
req_headers["Authorization"] = f"Bearer {self.token}"
|
||||
if headers:
|
||||
req_headers.update(headers)
|
||||
|
||||
data: Optional[bytes] = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
|
||||
req = urllib.request.Request(
|
||||
self._url(path),
|
||||
data=data,
|
||||
headers=req_headers,
|
||||
method=method.upper(),
|
||||
)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self.timeout, context=self.ssl_context) as resp:
|
||||
body = resp.read()
|
||||
status = resp.getcode()
|
||||
response_headers = {k.lower(): v for k, v in resp.headers.items()}
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read()
|
||||
status = exc.code
|
||||
response_headers = {k.lower(): v for k, v in exc.headers.items()}
|
||||
except urllib.error.URLError as exc:
|
||||
raise TestFailure(f"cannot connect to {self._url(path)}: {exc.reason}") from exc
|
||||
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
||||
return HttpResponse(status, response_headers, body, elapsed_ms)
|
||||
|
||||
def get(self, path: str, *, auth: bool = True) -> HttpResponse:
|
||||
return self.request("GET", path, auth=auth)
|
||||
|
||||
def put(self, path: str, payload: Any) -> HttpResponse:
|
||||
return self.request("PUT", path, payload)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
name: str
|
||||
status: str
|
||||
detail: str = ""
|
||||
elapsed_ms: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class Runner:
|
||||
client: ApiClient
|
||||
verbose: bool = False
|
||||
results: list[Result] = field(default_factory=list)
|
||||
|
||||
def run(self, name: str, fn: Callable[[], Optional[str]]) -> None:
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
detail = fn() or ""
|
||||
status = "PASS"
|
||||
except SkipTest as exc:
|
||||
status = "SKIP"
|
||||
detail = str(exc)
|
||||
except Exception as exc: # deliberate: one failed test must not stop the suite
|
||||
status = "FAIL"
|
||||
detail = str(exc)
|
||||
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
||||
self.results.append(Result(name, status, detail, elapsed_ms))
|
||||
marker = {"PASS": "+", "FAIL": "!", "SKIP": "-"}[status]
|
||||
suffix = f" - {detail}" if detail and (self.verbose or status != "PASS") else ""
|
||||
print(f"[{marker}] {status:<4} {name} ({elapsed_ms:.0f} ms){suffix}")
|
||||
|
||||
def summary(self) -> int:
|
||||
counts = {name: sum(r.status == name for r in self.results) for name in ("PASS", "FAIL", "SKIP")}
|
||||
print("\nSummary: " + ", ".join(f"{k}={v}" for k, v in counts.items()))
|
||||
if counts["FAIL"]:
|
||||
print("\nFailures:")
|
||||
for result in self.results:
|
||||
if result.status == "FAIL":
|
||||
print(f" - {result.name}: {result.detail}")
|
||||
return 1 if counts["FAIL"] else 0
|
||||
|
||||
|
||||
class SkipTest(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def assert_status(resp: HttpResponse, *expected: int) -> None:
|
||||
if resp.status not in expected:
|
||||
body = resp.text().strip().replace("\n", " ")[:500]
|
||||
raise TestFailure(f"HTTP {resp.status}, expected {expected}; body={body!r}")
|
||||
|
||||
|
||||
def assert_json_object(resp: HttpResponse) -> dict[str, Any]:
|
||||
value = resp.json()
|
||||
if not isinstance(value, dict):
|
||||
raise TestFailure(f"expected JSON object, got {type(value).__name__}")
|
||||
return value
|
||||
|
||||
|
||||
def assert_json_collection(resp: HttpResponse) -> Any:
|
||||
value = resp.json()
|
||||
if not isinstance(value, (list, dict)):
|
||||
raise TestFailure(f"expected JSON collection/object, got {type(value).__name__}")
|
||||
return value
|
||||
|
||||
|
||||
def first_item(value: Any) -> Optional[dict[str, Any]]:
|
||||
if isinstance(value, list):
|
||||
return value[0] if value and isinstance(value[0], dict) else None
|
||||
if isinstance(value, dict):
|
||||
# Handle APIs that wrap arrays in a named property.
|
||||
for candidate in value.values():
|
||||
if isinstance(candidate, list) and candidate and isinstance(candidate[0], dict):
|
||||
return candidate[0]
|
||||
return None
|
||||
|
||||
|
||||
def convert_settings_view_to_update(path: str, view: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build a PUT payload from a GET view without modifying stored secrets."""
|
||||
payload = copy.deepcopy(view)
|
||||
if path == "/api/settings/influxdb":
|
||||
payload.pop("password_configured", None)
|
||||
payload.pop("token_configured", None)
|
||||
# Omitted Option fields preserve existing secrets.
|
||||
elif path == "/api/settings/gree-cloud":
|
||||
payload.pop("password_configured", None)
|
||||
payload.pop("installation_id", None)
|
||||
payload.pop("account_id", None)
|
||||
payload.pop("last_successful_contact", None)
|
||||
payload.pop("last_rest_response_time_ms", None)
|
||||
elif path == "/api/settings/notifications":
|
||||
payload.pop("pushover_configured", None)
|
||||
payload.pop("slack_configured", None)
|
||||
payload.pop("discord_configured", None)
|
||||
elif path == "/api/settings/home-assistant":
|
||||
payload.pop("token_configured", None)
|
||||
return payload
|
||||
|
||||
|
||||
def comparable_settings_view(path: str, view: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize a settings view for equality checks after a no-op PUT."""
|
||||
return copy.deepcopy(view)
|
||||
|
||||
|
||||
def test_health(client: ApiClient, expected_version: str) -> str:
|
||||
resp = client.get("/api/health", auth=False)
|
||||
assert_status(resp, 200)
|
||||
data = assert_json_object(resp)
|
||||
if data.get("status") != "ok":
|
||||
raise TestFailure(f"health.status={data.get('status')!r}, expected 'ok'")
|
||||
if expected_version and data.get("version") != expected_version:
|
||||
raise TestFailure(f"health.version={data.get('version')!r}, expected {expected_version!r}")
|
||||
return f"version={data.get('version')}, control_ready={data.get('control_ready')}"
|
||||
|
||||
|
||||
def test_openapi(client: ApiClient, expected_version: str) -> str:
|
||||
resp = client.get("/api-docs/openapi.json", auth=False)
|
||||
assert_status(resp, 200)
|
||||
spec = assert_json_object(resp)
|
||||
paths = spec.get("paths")
|
||||
if not isinstance(paths, dict):
|
||||
raise TestFailure("OpenAPI has no paths object")
|
||||
missing = sorted(EXPECTED_OPENAPI_PATHS - set(paths))
|
||||
if missing:
|
||||
raise TestFailure(f"OpenAPI missing required paths: {missing}")
|
||||
forbidden = sorted(set(REMOVED_0120_PATHS) & set(paths))
|
||||
if forbidden:
|
||||
raise TestFailure(f"OpenAPI still exposes removed paths: {forbidden}")
|
||||
version = ((spec.get("info") or {}).get("version"))
|
||||
if expected_version and version != expected_version:
|
||||
raise TestFailure(f"OpenAPI info.version={version!r}, expected {expected_version!r}")
|
||||
|
||||
external_dependency_operations = [
|
||||
("/api/integrations/home-assistant/test", "post"),
|
||||
("/api/integrations/home-assistant/entity", "post"),
|
||||
("/api/integrations/notifications/test", "post"),
|
||||
("/api/integrations/gree-cloud/reconnect", "post"),
|
||||
]
|
||||
for path, method in external_dependency_operations:
|
||||
responses = (((paths.get(path) or {}).get(method) or {}).get("responses") or {})
|
||||
if "502" in responses:
|
||||
raise TestFailure(f"{method.upper()} {path} must not document external dependency failures as 502")
|
||||
if "424" not in responses:
|
||||
raise TestFailure(f"{method.upper()} {path} is missing 424 Failed Dependency")
|
||||
|
||||
return f"paths={len(paths)}, version={version}"
|
||||
|
||||
|
||||
def test_bootstrap_contract(client: ApiClient) -> str:
|
||||
resp = client.get("/api/bootstrap")
|
||||
assert_status(resp, 200)
|
||||
data = assert_json_object(resp)
|
||||
required = {
|
||||
"devices", "zones", "groups", "schedules", "automations", "flows",
|
||||
"access_tokens", "settings", "house", "outdoor_temperature",
|
||||
"control_plan", "control_plan_revision", "system",
|
||||
}
|
||||
missing = sorted(required - set(data))
|
||||
if missing:
|
||||
raise TestFailure(f"bootstrap missing fields: {missing}")
|
||||
settings = data.get("settings")
|
||||
if not isinstance(settings, dict):
|
||||
raise TestFailure("bootstrap.settings is not an object")
|
||||
missing_sections = sorted(BOOTSTRAP_SETTINGS_SECTIONS - set(settings))
|
||||
if missing_sections:
|
||||
raise TestFailure(f"bootstrap.settings missing sections: {missing_sections}")
|
||||
forbidden = {
|
||||
"influxdb": {"password", "token"},
|
||||
"gree_cloud": {"password"},
|
||||
"home_assistant": {"token"},
|
||||
"notifications": {"pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"},
|
||||
}
|
||||
for section, fields in forbidden.items():
|
||||
leaked = sorted(fields & set(settings.get(section, {})))
|
||||
if leaked:
|
||||
raise TestFailure(f"bootstrap.settings.{section} leaks secret fields: {leaked}")
|
||||
return f"settings_sections={len(BOOTSTRAP_SETTINGS_SECTIONS)}, control_plan_revision={data.get('control_plan_revision')}"
|
||||
|
||||
|
||||
def test_protected_auth(client: ApiClient) -> str:
|
||||
if not client.token:
|
||||
raise SkipTest("no token supplied; controller may be in trusted-LAN mode")
|
||||
resp = client.get("/api/bootstrap", auth=False)
|
||||
assert_status(resp, 401)
|
||||
return "protected endpoint rejects missing token"
|
||||
|
||||
|
||||
def test_safe_get(client: ApiClient, path: str) -> str:
|
||||
resp = client.get(path)
|
||||
assert_status(resp, 200)
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
if "json" not in content_type.lower():
|
||||
# Export is still expected to be JSON, but tolerate servers that return octet-stream attachment.
|
||||
if path != "/api/configuration/export":
|
||||
raise TestFailure(f"unexpected content-type {content_type!r}")
|
||||
data = resp.json()
|
||||
if path == "/api/configuration/export":
|
||||
if not isinstance(data, dict):
|
||||
raise TestFailure("configuration export is not a JSON object")
|
||||
fmt = data.get("format_version")
|
||||
if fmt != 3:
|
||||
raise TestFailure(f"configuration export format_version={fmt!r}, expected 3")
|
||||
return f"backup format_version={fmt}"
|
||||
if not isinstance(data, (list, dict)):
|
||||
raise TestFailure(f"unexpected JSON type {type(data).__name__}")
|
||||
if isinstance(data, list):
|
||||
return f"items={len(data)}"
|
||||
return f"keys={len(data)}"
|
||||
|
||||
|
||||
def test_ha_safe_get(client: ApiClient, path: str) -> str:
|
||||
resp = client.get(path)
|
||||
if not client.token:
|
||||
# Restricted HA API always requires a token, even when the normal admin
|
||||
# API runs in trusted-LAN mode.
|
||||
assert_status(resp, 401)
|
||||
return "restricted HA auth enforced (no token supplied)"
|
||||
assert_status(resp, 200)
|
||||
data = resp.json()
|
||||
if path == "/api/integrations/home-assistant/snapshot":
|
||||
if not isinstance(data, dict):
|
||||
raise TestFailure(f"snapshot is not an object: {type(data).__name__}")
|
||||
required = {"devices", "control_plan", "control_plan_revision", "groups"}
|
||||
missing = sorted(required - set(data))
|
||||
if missing:
|
||||
raise TestFailure(f"snapshot missing keys: {missing}")
|
||||
if not isinstance(data["devices"], list) or not isinstance(data["groups"], list):
|
||||
raise TestFailure("snapshot devices/groups must be arrays")
|
||||
if not isinstance(data["control_plan"], dict):
|
||||
raise TestFailure("snapshot control_plan must be an object")
|
||||
if not isinstance(data["control_plan_revision"], int):
|
||||
raise TestFailure("snapshot control_plan_revision must be an integer")
|
||||
return f"devices={len(data['devices'])}, groups={len(data['groups'])}, revision={data['control_plan_revision']}"
|
||||
if not isinstance(data, (list, dict)):
|
||||
raise TestFailure(f"unexpected JSON type {type(data).__name__}")
|
||||
if isinstance(data, list):
|
||||
return f"items={len(data)}"
|
||||
return f"keys={len(data)}"
|
||||
|
||||
|
||||
def test_settings_get(client: ApiClient, path: str, required: set[str]) -> str:
|
||||
resp = client.get(path)
|
||||
assert_status(resp, 200)
|
||||
data = assert_json_object(resp)
|
||||
missing = sorted(required - set(data))
|
||||
if missing:
|
||||
raise TestFailure(f"missing keys: {missing}")
|
||||
# Secret values must never be returned by split GETs.
|
||||
forbidden_secret_keys = {
|
||||
"/api/settings/influxdb": {"password", "token"},
|
||||
"/api/settings/gree-cloud": {"password"},
|
||||
"/api/settings/notifications": {
|
||||
"pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"
|
||||
},
|
||||
"/api/settings/home-assistant": {"token"},
|
||||
}.get(path, set())
|
||||
leaked = sorted(forbidden_secret_keys & set(data))
|
||||
if leaked:
|
||||
raise TestFailure(f"secret fields leaked in GET response: {leaked}")
|
||||
return f"keys={len(data)}"
|
||||
|
||||
|
||||
def test_removed_path(client: ApiClient, path: str) -> str:
|
||||
# GET is enough to prove there is no compatibility alias. Some removed write-only
|
||||
# paths may return 404 or 405 depending on router fallback/method handling.
|
||||
resp = client.get(path)
|
||||
if resp.status not in (404, 405):
|
||||
raise TestFailure(f"removed endpoint still responds with HTTP {resp.status}")
|
||||
return f"HTTP {resp.status}"
|
||||
|
||||
|
||||
def test_detail_endpoint(client: ApiClient, collection_path: str, detail_template: str) -> str:
|
||||
resp = client.get(collection_path)
|
||||
assert_status(resp, 200)
|
||||
collection = assert_json_collection(resp)
|
||||
item = first_item(collection)
|
||||
if item is None:
|
||||
raise SkipTest("collection is empty")
|
||||
item_id = item.get("id")
|
||||
if not isinstance(item_id, str) or not item_id:
|
||||
raise SkipTest("first item has no string id")
|
||||
path = detail_template.replace("{id}", urllib.parse.quote(item_id, safe=""))
|
||||
detail = client.get(path)
|
||||
assert_status(detail, 200)
|
||||
assert_json_object(detail)
|
||||
return f"id={item_id}"
|
||||
|
||||
|
||||
def test_flow_read_subresources(client: ApiClient) -> str:
|
||||
resp = client.get("/api/flows")
|
||||
assert_status(resp, 200)
|
||||
item = first_item(assert_json_collection(resp))
|
||||
if item is None or not isinstance(item.get("id"), str):
|
||||
raise SkipTest("no Flow available")
|
||||
flow_id = urllib.parse.quote(item["id"], safe="")
|
||||
export_resp = client.get(f"/api/flows/{flow_id}/export")
|
||||
assert_status(export_resp, 200)
|
||||
exported = assert_json_object(export_resp)
|
||||
if exported.get("format") != "gree-controller-flow":
|
||||
raise AssertionError(f"unexpected Flow export format: {exported.get('format')!r}")
|
||||
if exported.get("version") != 1:
|
||||
raise AssertionError(f"unexpected Flow export version: {exported.get('version')!r}")
|
||||
if "shared_inputs" in exported and not isinstance(exported["shared_inputs"], list):
|
||||
raise AssertionError("Flow export shared_inputs must be an array")
|
||||
logs_resp = client.get(f"/api/flows/{flow_id}/logs")
|
||||
assert_status(logs_resp, 200)
|
||||
logs_resp.json()
|
||||
return f"id={item['id']}"
|
||||
|
||||
|
||||
def test_settings_roundtrip(client: ApiClient, path: str) -> str:
|
||||
before_resp = client.get(path)
|
||||
assert_status(before_resp, 200)
|
||||
before = assert_json_object(before_resp)
|
||||
payload = convert_settings_view_to_update(path, before)
|
||||
|
||||
put_resp = client.put(path, payload)
|
||||
assert_status(put_resp, 200)
|
||||
put_view = assert_json_object(put_resp)
|
||||
|
||||
after_resp = client.get(path)
|
||||
assert_status(after_resp, 200)
|
||||
after = assert_json_object(after_resp)
|
||||
|
||||
if comparable_settings_view(path, put_view) != comparable_settings_view(path, after):
|
||||
raise TestFailure(f"PUT response differs from following GET: put={put_view!r}, get={after!r}")
|
||||
|
||||
# No-op write should preserve the observable GET view, including *_configured flags.
|
||||
if comparable_settings_view(path, before) != comparable_settings_view(path, after):
|
||||
raise TestFailure(f"settings changed after no-op round-trip: before={before!r}, after={after!r}")
|
||||
return "GET -> PUT(no-op) -> GET preserved view"
|
||||
|
||||
|
||||
def expect_validation_error(client: ApiClient, path: str, mutate: Callable[[dict[str, Any]], None]) -> str:
|
||||
before_resp = client.get(path)
|
||||
assert_status(before_resp, 200)
|
||||
before = assert_json_object(before_resp)
|
||||
payload = convert_settings_view_to_update(path, before)
|
||||
mutate(payload)
|
||||
resp = client.put(path, payload)
|
||||
assert_status(resp, 400)
|
||||
error = assert_json_object(resp)
|
||||
if not isinstance(error.get("error"), str) or not error["error"]:
|
||||
raise TestFailure(f"400 response missing string error: {error!r}")
|
||||
after_resp = client.get(path)
|
||||
assert_status(after_resp, 200)
|
||||
after = assert_json_object(after_resp)
|
||||
if before != after:
|
||||
raise TestFailure("settings changed despite rejected request")
|
||||
return error["error"][:120]
|
||||
|
||||
|
||||
def run_suite(args: argparse.Namespace) -> int:
|
||||
client = ApiClient(args.base_url, args.token, args.timeout, args.insecure)
|
||||
runner = Runner(client, verbose=args.verbose)
|
||||
|
||||
print(f"GREE Controller API test: {client.base_url}")
|
||||
print(f"Expected version: {args.expected_version or '(any)'}")
|
||||
print(f"Auth token: {'yes' if args.token else 'no'}")
|
||||
print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n")
|
||||
|
||||
runner.run("public health", lambda: test_health(client, args.expected_version))
|
||||
runner.run("OpenAPI 0.15.17 contract", lambda: test_openapi(client, args.expected_version))
|
||||
runner.run("bootstrap snapshot contract", lambda: test_bootstrap_contract(client))
|
||||
runner.run("protected API requires auth", lambda: test_protected_auth(client))
|
||||
|
||||
for path in SAFE_GET_PATHS:
|
||||
runner.run(f"GET {path}", lambda path=path: test_safe_get(client, path))
|
||||
|
||||
for path in HA_SAFE_GET_PATHS:
|
||||
runner.run(f"GET {path}", lambda path=path: test_ha_safe_get(client, path))
|
||||
|
||||
for path, required in SETTINGS_PATHS.items():
|
||||
runner.run(f"split settings GET {path}", lambda path=path, required=required: test_settings_get(client, path, required))
|
||||
|
||||
for path in REMOVED_0120_PATHS:
|
||||
runner.run(f"removed route {path}", lambda path=path: test_removed_path(client, path))
|
||||
|
||||
for collection, detail in DETAIL_COLLECTIONS:
|
||||
runner.run(
|
||||
f"detail read {detail}",
|
||||
lambda collection=collection, detail=detail: test_detail_endpoint(client, collection, detail),
|
||||
)
|
||||
runner.run("Flow export/logs read", lambda: test_flow_read_subresources(client))
|
||||
|
||||
if args.settings_write:
|
||||
print("\n--- settings write/validation tests ---")
|
||||
for path in SETTINGS_PATHS:
|
||||
runner.run(f"round-trip PUT {path}", lambda path=path: test_settings_roundtrip(client, path))
|
||||
|
||||
runner.run(
|
||||
"validation: empty GREE controller_id -> 400",
|
||||
lambda: expect_validation_error(client, "/api/settings/gree", lambda p: p.__setitem__("controller_id", "")),
|
||||
)
|
||||
runner.run(
|
||||
"validation: invalid night start_time -> 400",
|
||||
lambda: expect_validation_error(client, "/api/settings/night", lambda p: p.__setitem__("start_time", "99:99")),
|
||||
)
|
||||
runner.run(
|
||||
"validation: invalid notification provider -> 400",
|
||||
lambda: expect_validation_error(
|
||||
client, "/api/settings/notifications", lambda p: p.__setitem__("provider", "invalid-provider")
|
||||
),
|
||||
)
|
||||
runner.run(
|
||||
"validation: invalid HA URL scheme -> 400",
|
||||
lambda: expect_validation_error(
|
||||
client, "/api/settings/home-assistant", lambda p: p.__setitem__("url", "ftp://invalid.local")
|
||||
),
|
||||
)
|
||||
|
||||
exit_code = runner.summary()
|
||||
|
||||
if args.report_json:
|
||||
report_path = Path(args.report_json)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report = {
|
||||
"base_url": client.base_url,
|
||||
"expected_version": args.expected_version,
|
||||
"settings_write": bool(args.settings_write),
|
||||
"exit_code": exit_code,
|
||||
"results": [r.__dict__ for r in runner.results],
|
||||
}
|
||||
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
print(f"JSON report: {report_path}")
|
||||
|
||||
return exit_code
|
||||
|
||||
|
||||
def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Developer smoke/integration tests for GREE Controller API 0.14.18.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""Examples:
|
||||
python3 scripts/api_dev_test.py
|
||||
python3 scripts/api_dev_test.py --base-url http://192.168.1.50:8787
|
||||
GREE_API_TOKEN=secret python3 scripts/api_dev_test.py --settings-write
|
||||
python3 scripts/api_dev_test.py --settings-write --report-json /tmp/api-report.json
|
||||
|
||||
Default mode performs only reads. --settings-write re-saves the current settings
|
||||
and intentionally generates normal settings events/side effects. It does NOT send
|
||||
house/device/zone control commands.
|
||||
""",
|
||||
)
|
||||
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help=f"API base URL (default: {DEFAULT_BASE_URL})")
|
||||
parser.add_argument("--token", default=DEFAULT_TOKEN, help="administrator app token; or set GREE_API_TOKEN")
|
||||
parser.add_argument(
|
||||
"--expected-version",
|
||||
default=DEFAULT_EXPECTED_VERSION,
|
||||
help=f"expected health/OpenAPI version; use empty string to disable (default: {DEFAULT_EXPECTED_VERSION})",
|
||||
)
|
||||
parser.add_argument("--timeout", type=float, default=8.0, help="HTTP timeout in seconds (default: 8)")
|
||||
parser.add_argument("--insecure", action="store_true", help="disable TLS certificate verification for HTTPS dev instances")
|
||||
parser.add_argument(
|
||||
"--settings-write",
|
||||
action="store_true",
|
||||
help="round-trip all 8 settings PUT endpoints and test selected 400 validation paths",
|
||||
)
|
||||
parser.add_argument("--report-json", help="write machine-readable test results to this JSON file")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="show PASS details")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return run_suite(parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Regular → Executable
@@ -79,7 +79,7 @@ def main() -> int:
|
||||
parser.add_argument("--controller-token", default="", help="GREE Controller Home Assistant access token")
|
||||
parser.add_argument("--ha-url", help="Optional Home Assistant URL used to validate source entities")
|
||||
parser.add_argument("--ha-token", default="", help="Optional Home Assistant Long-Lived Access Token")
|
||||
parser.add_argument("--output", default="home-assistant/generated/gree_controller_entities.json", help="Output mapping file")
|
||||
parser.add_argument("--output", default="ha-addon/home-assistant/generated/gree_controller_entities.json", help="Output mapping file")
|
||||
args = parser.parse_args()
|
||||
|
||||
mappings: list[tuple[str, str]] = list(args.map)
|
||||
|
||||
Executable
+289
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const timers = [];
|
||||
let timerId = 0;
|
||||
let apiCalls = [];
|
||||
let apiResponder = async requestPath => requestPath === '/api/schedules' ? [] : {};
|
||||
const calls = {};
|
||||
const hit = name => () => { calls[name] = (calls[name] || 0) + 1; };
|
||||
|
||||
class FakeWebSocket {
|
||||
static OPEN = 1;
|
||||
static CONNECTING = 0;
|
||||
static CLOSED = 3;
|
||||
static instances = [];
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = FakeWebSocket.CONNECTING;
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
const app = {
|
||||
devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], accessTokens: [],
|
||||
settings: { house_mode: 'cool', debug: { overlay_enabled: false }, home_assistant: {} },
|
||||
system: {}, outdoorTemperature: null, controlPlan: null, controlPlanRevision: null,
|
||||
controlPlanPushReady: false, controlPlanTimer: null, currentView: 'dashboard',
|
||||
sensorAliases: {}, flowSharedInputs: [], flowDraft: null, ws: null, wsTimer: null,
|
||||
token: '', debugBacklogLoaded: false,
|
||||
};
|
||||
|
||||
const context = {
|
||||
console, JSON, Number, Date, Intl, Promise, app, WebSocket: FakeWebSocket,
|
||||
location: { protocol: 'http:', host: 'controller.test' },
|
||||
setTimeout: (fn, delay) => {
|
||||
const id = ++timerId;
|
||||
timers.push({ id, fn, delay, cleared: false });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: id => {
|
||||
const timer = timers.find(item => item.id === id);
|
||||
if (timer) timer.cleared = true;
|
||||
},
|
||||
api: async (...args) => {
|
||||
apiCalls.push(args);
|
||||
return apiResponder(...args);
|
||||
},
|
||||
withBase: value => value,
|
||||
$: () => null,
|
||||
isFormDirty: () => false,
|
||||
applySettingsSection: (name, data) => { app.settings[name] = data; },
|
||||
loadDebugBacklog: hit('loadDebugBacklog'),
|
||||
loadBootstrap: async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; },
|
||||
updateConnectionIndicator: status => { calls[`connection:${status}`] = (calls[`connection:${status}`] || 0) + 1; },
|
||||
updateDevice: device => {
|
||||
const index = app.devices.findIndex(item => item.id === device.id);
|
||||
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
|
||||
},
|
||||
toast: () => {}, tr: key => key, esc: value => String(value), locale: () => 'en-GB',
|
||||
logCategory: () => 'test', debugLine: hit('debugLine'),
|
||||
};
|
||||
|
||||
const renderNames = [
|
||||
'renderAll', 'renderSummary', 'renderDevices', 'renderGroups', 'renderHouseClimate',
|
||||
'renderZones', 'renderFlows', 'renderSchedules', 'renderAutomations', 'renderSettings',
|
||||
'renderSimulationModeBanner', 'renderSystemInfo', 'renderLogRetention', 'renderNightSettings',
|
||||
'renderHomeAssistantSettings', 'renderDebugOverlay', 'renderFlowEditor', 'renderGreeFrameStats',
|
||||
'renderControlPlan', 'renderSimulationPage', 'fillSelects',
|
||||
];
|
||||
for (const name of renderNames) context[name] = hit(name);
|
||||
|
||||
vm.createContext(context);
|
||||
vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/dashboard.js'), 'utf8'), context, { filename: 'dashboard.js' });
|
||||
vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/bootstrap.js'), 'utf8'), context, { filename: 'bootstrap.js' });
|
||||
vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/realtime.js'), 'utf8'), context, { filename: 'realtime.js' });
|
||||
// Source files define DOM-heavy renderers. Replace those implementations for this state-machine test.
|
||||
for (const name of renderNames) context[name] = hit(name);
|
||||
context.updateDevice = device => {
|
||||
const index = app.devices.findIndex(item => item.id === device.id);
|
||||
if (index >= 0) app.devices[index] = device; else app.devices.push(device);
|
||||
};
|
||||
context.applySettingsSection = (name, data) => { app.settings[name] = data; };
|
||||
context.loadDebugBacklog = hit('loadDebugBacklog');
|
||||
context.loadBootstrap = async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; };
|
||||
context.isFormDirty = () => false;
|
||||
context.updateConnectionIndicator = status => { calls[`connection:${status}`] = (calls[`connection:${status}`] || 0) + 1; };
|
||||
context.withBase = value => value;
|
||||
|
||||
async function send(event, data) {
|
||||
await context.handleWebSocketMessage({
|
||||
data: JSON.stringify({ event, timestamp: new Date().toISOString(), data }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function testBootstrapReloadQueue() {
|
||||
const queuedTimers = [];
|
||||
let firstResolve;
|
||||
let bootstrapCalls = 0;
|
||||
const firstResponse = new Promise(resolve => { firstResolve = resolve; });
|
||||
const c = {
|
||||
console, Promise, Date,
|
||||
app: { loading: false, bootstrapReloadPending: false, controlPlan: null, controlPlanRevision: null, settings: {}, sensorAliases: {}, flowSharedInputs: {}, system: {}, debugBacklogLoaded: false },
|
||||
api: async requestPath => {
|
||||
assert.equal(requestPath, '/api/bootstrap');
|
||||
bootstrapCalls += 1;
|
||||
if (bootstrapCalls === 1) return firstResponse;
|
||||
return { devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], settings: {}, house: { mode: 'cool' }, system: {}, control_plan: { marker: 'second' }, control_plan_revision: 2 };
|
||||
},
|
||||
renderAll: () => {}, scheduleControlPlanLoad: () => {}, loadDebugBacklog: () => {}, toast: () => {}, tr: key => key,
|
||||
$: () => ({ open: false }), connectWebSocket: () => {},
|
||||
setTimeout: (fn, delay) => { queuedTimers.push({ fn, delay }); return queuedTimers.length; },
|
||||
};
|
||||
vm.createContext(c);
|
||||
vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/bootstrap.js'), 'utf8'), c, { filename: 'bootstrap.js' });
|
||||
const first = c.loadBootstrap();
|
||||
assert.equal(c.app.loading, true);
|
||||
await c.loadBootstrap();
|
||||
assert.equal(c.app.bootstrapReloadPending, true, 'a bootstrap request during loading must be queued');
|
||||
firstResolve({ devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], settings: {}, house: { mode: 'cool' }, system: {}, control_plan: { marker: 'first' }, control_plan_revision: 1 });
|
||||
await first;
|
||||
assert.equal(c.app.outdoorTemperature, null, 'null/missing outdoor temperature must stay unknown, not become 0 C');
|
||||
assert.equal(c.app.bootstrapReloadPending, false);
|
||||
assert.equal(queuedTimers.length, 1);
|
||||
assert.equal(queuedTimers[0].delay, 0);
|
||||
queuedTimers[0].fn();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(bootstrapCalls, 2, 'queued bootstrap reload must run after the first request finishes');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await testBootstrapReloadQueue();
|
||||
const apiCallsBeforeBootstrap = apiCalls.length;
|
||||
await send('bootstrap', {
|
||||
devices: [{ id: 'd1', power: false }],
|
||||
zones: [{ id: 'z1', demand: false }],
|
||||
groups: [{ id: 'g1', name: 'G1' }],
|
||||
schedules: [{ id: 's1', name: 'S1' }],
|
||||
automations: [{ id: 'a1', name: 'A1' }],
|
||||
flows: [{ id: 'f1', name: 'F1' }],
|
||||
access_tokens: [{ id: 't1', name: 'HA', token_prefix: 'abc', created_at: '2026-09-07T08:00:00Z' }],
|
||||
settings: {
|
||||
application: { simulator_enabled: true },
|
||||
gree: { controller_id: 'test-controller' },
|
||||
history: { retention_days: 30 }, influxdb: {}, notifications: {}, night: {},
|
||||
home_assistant: { sensor_aliases: { 'sensor.bootstrap': 'Bootstrap' }, flow_inputs: [], outdoor_assist_enabled: true },
|
||||
debug: { overlay_enabled: false },
|
||||
},
|
||||
house: { mode: 'heat' }, system: { ok: true }, outdoor_temperature: 10,
|
||||
control_plan: { marker: 'p1' }, control_plan_revision: 1,
|
||||
});
|
||||
assert.equal(apiCalls.length, apiCallsBeforeBootstrap, 'WebSocket bootstrap must not fetch split settings endpoints');
|
||||
assert.equal(app.accessTokens[0].id, 't1', 'WebSocket bootstrap must refresh access-token metadata');
|
||||
assert.equal(app.settings.controller_id, 'test-controller');
|
||||
assert.equal(app.sensorAliases['sensor.bootstrap'], 'Bootstrap');
|
||||
assert.equal(app.controlPlan.marker, 'p1');
|
||||
assert.equal(app.controlPlanRevision, 1);
|
||||
assert.equal(app.controlPlanPushReady, true);
|
||||
|
||||
await send('control_plan.updated', { revision: 2, plan: { marker: 'p2' } });
|
||||
await send('control_plan.updated', { revision: 1, plan: { marker: 'stale' } });
|
||||
assert.equal(app.controlPlan.marker, 'p2', 'stale control-plan revision must be ignored');
|
||||
|
||||
app.ws = { readyState: FakeWebSocket.OPEN };
|
||||
const timerCountWhilePushReady = timers.length;
|
||||
|
||||
await send('device.updated', { id: 'd1', power: true, online: true });
|
||||
assert.equal(app.devices[0].power, true);
|
||||
await send('device.created', { id: 'd2', power: false });
|
||||
assert(app.devices.some(item => item.id === 'd2'));
|
||||
await send('devices.discovered', { devices: [{ id: 'd3', power: false }] });
|
||||
assert(app.devices.some(item => item.id === 'd3'));
|
||||
await send('device.deleted', { id: 'd2' });
|
||||
assert(!app.devices.some(item => item.id === 'd2'));
|
||||
|
||||
await send('zone.updated', { id: 'z1', demand: true, current_temperature: 22.1 });
|
||||
assert.equal(app.zones[0].demand, true);
|
||||
await send('zone.created', { id: 'z2', demand: false });
|
||||
assert(app.zones.some(item => item.id === 'z2'));
|
||||
await send('zone.deleted', { id: 'z2' });
|
||||
assert(!app.zones.some(item => item.id === 'z2'));
|
||||
|
||||
await send('group.updated', { id: 'g1', name: 'G1 updated' });
|
||||
assert.equal(app.groups[0].name, 'G1 updated');
|
||||
await send('group.created', { id: 'g2', name: 'G2' });
|
||||
assert(app.groups.some(item => item.id === 'g2'));
|
||||
await send('group.deleted', { id: 'g2' });
|
||||
assert(!app.groups.some(item => item.id === 'g2'));
|
||||
|
||||
await send('schedule.updated', { id: 's1', name: 'S1 updated' });
|
||||
assert.equal(app.schedules[0].name, 'S1 updated');
|
||||
await send('schedule.created', { id: 's2', name: 'S2' });
|
||||
assert(app.schedules.some(item => item.id === 's2'));
|
||||
await send('schedule.deleted', { id: 's2' });
|
||||
assert(!app.schedules.some(item => item.id === 's2'));
|
||||
apiResponder = async requestPath => requestPath === '/api/schedules' ? [{ id: 's3', name: 'Template' }] : {};
|
||||
await send('schedule.template_applied', { zone_id: 'z1', template: 'family', count: 2 });
|
||||
assert.equal(app.schedules[0].id, 's3');
|
||||
|
||||
await send('automation.updated', { id: 'a1', name: 'A1 updated', last_fired_at: '2026-09-04T09:00:00Z' });
|
||||
assert.equal(app.automations[0].name, 'A1 updated');
|
||||
await send('automation.created', { id: 'a2', name: 'A2' });
|
||||
assert(app.automations.some(item => item.id === 'a2'));
|
||||
await send('automation.deleted', { id: 'a2' });
|
||||
assert(!app.automations.some(item => item.id === 'a2'));
|
||||
|
||||
await send('flow.updated', { id: 'f1', name: 'F1 updated' });
|
||||
assert.equal(app.flows[0].name, 'F1 updated');
|
||||
await send('flow.created', { id: 'f2', name: 'F2' });
|
||||
assert(app.flows.some(item => item.id === 'f2'));
|
||||
await send('flow.deleted', { id: 'f2' });
|
||||
assert(!app.flows.some(item => item.id === 'f2'));
|
||||
|
||||
await send('settings.application.updated', { poll_interval_seconds: 15 });
|
||||
await send('settings.gree.updated', { command_timeout_ms: 1000 });
|
||||
await send('settings.history.updated', { retention_days: 30 });
|
||||
await send('settings.influxdb.updated', { enabled: false });
|
||||
await send('settings.notifications.updated', { enabled: false });
|
||||
await send('settings.debug.updated', { overlay_enabled: false });
|
||||
await send('settings.night.updated', { enabled: true, start_time: '22:00', end_time: '06:00' });
|
||||
await send('settings.home_assistant.updated', { sensor_aliases: { 'sensor.room': 'Room' }, flow_inputs: [] });
|
||||
assert.equal(app.settings.night.enabled, true);
|
||||
assert.equal(app.sensorAliases['sensor.room'], 'Room');
|
||||
|
||||
await send('house.mode_changed', { mode: 'off' });
|
||||
assert.equal(app.settings.house_mode, 'off');
|
||||
await send('outdoor.updated', { temperature: 12.3 });
|
||||
assert.equal(app.outdoorTemperature, 12.3);
|
||||
|
||||
await send('gree.frame_received', { total: 5, device_id: 'd1', device_count: 3 });
|
||||
assert.equal(app.system.gree_received_frames, 5);
|
||||
assert.equal(app.system.gree_received_frames_by_device.d1, 3);
|
||||
app.settings.debug = { overlay_enabled: true };
|
||||
await send('gree.frame', { direction: 'rx', payload: {} });
|
||||
await send('api.request', { method: 'GET', status: 200, path: '/api/health', duration_ms: 1 });
|
||||
await send('log.created', { level: 'info', kind: 'test', message: 'ok', metadata: {} });
|
||||
|
||||
await send('configuration.imported', { at: new Date().toISOString() });
|
||||
assert.equal(calls.loadBootstrap, 1, 'configuration import must full-resync connected UIs');
|
||||
|
||||
// With a valid pushed control plan, ordinary state events must not re-enable HTTP control-plan polling.
|
||||
assert.equal(timers.length, timerCountWhilePushReady, 'push-ready live events must not schedule control-plan HTTP fallback');
|
||||
|
||||
// When push is unavailable, state changes must schedule the existing HTTP fallback.
|
||||
app.controlPlanPushReady = false;
|
||||
app.ws = { readyState: FakeWebSocket.CLOSED };
|
||||
const fallbackTimerCount = timers.length;
|
||||
await send('zone.updated', { id: 'z1', demand: false });
|
||||
assert(timers.length > fallbackTimerCount);
|
||||
assert.equal(timers[timers.length - 1].delay, 180);
|
||||
|
||||
// An HTTP fallback started while disconnected must never overwrite a newer pushed revision after reconnect.
|
||||
let resolveApi;
|
||||
apiResponder = () => new Promise(resolve => { resolveApi = resolve; });
|
||||
const pendingHttp = context.loadControlPlan();
|
||||
app.ws = { readyState: FakeWebSocket.OPEN };
|
||||
await send('control_plan.updated', { revision: 3, plan: { marker: 'p3' } });
|
||||
resolveApi({ marker: 'old-http' });
|
||||
await pendingHttp;
|
||||
assert.equal(app.controlPlan.marker, 'p3');
|
||||
|
||||
// Connection lifecycle still switches to fallback and schedules reconnect.
|
||||
app.ws = null;
|
||||
app.controlPlanPushReady = true;
|
||||
context.connectWebSocket();
|
||||
const socket = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
||||
assert.equal(socket.url, 'ws://controller.test/ws');
|
||||
socket.readyState = FakeWebSocket.OPEN;
|
||||
socket.onopen();
|
||||
assert.equal(calls['connection:connected'], 1);
|
||||
const beforeCloseTimers = timers.length;
|
||||
socket.readyState = FakeWebSocket.CLOSED;
|
||||
socket.onclose();
|
||||
assert.equal(app.controlPlanPushReady, false);
|
||||
assert(timers.length >= beforeCloseTimers + 2, 'close must schedule fallback and reconnect');
|
||||
|
||||
console.log(`Live realtime test OK (${apiCalls.length} API fallback/resync calls observed)`);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error.stack || error);
|
||||
process.exit(1);
|
||||
});
|
||||
Regular → Executable
+90
-7
@@ -33,14 +33,73 @@ for _ in $(seq 1 80); do
|
||||
done
|
||||
grep -q '"status":"ok"' "$TMP/health.json"
|
||||
|
||||
curl -fsS "http://127.0.0.1:$PORT/api-docs/openapi.json" >"$TMP/openapi.json"
|
||||
python3 - "$TMP/openapi.json" <<'PYOPENAPI'
|
||||
import json, sys
|
||||
|
||||
doc = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert doc["openapi"] == "3.1.0"
|
||||
assert "/api/zones/{id}/control" in doc["paths"]
|
||||
assert "BearerToken" in doc["components"]["securitySchemes"]
|
||||
assert doc["servers"][0]["url"] == "/"
|
||||
PYOPENAPI
|
||||
curl -fsSL "http://127.0.0.1:$PORT/api-docs" >"$TMP/swagger.html"
|
||||
grep -qi 'swagger-ui' "$TMP/swagger.html"
|
||||
|
||||
curl -fsS "http://127.0.0.1:$PORT/api/bootstrap" >"$TMP/bootstrap.json"
|
||||
grep -q 'sim-salon' "$TMP/bootstrap.json"
|
||||
python3 - "$TMP/bootstrap.json" "$PORT" <<'PYBOOTSTRAP'
|
||||
import json, sys, urllib.request
|
||||
|
||||
bootstrap = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
port = sys.argv[2]
|
||||
settings = bootstrap.get("settings")
|
||||
assert isinstance(settings, dict), "bootstrap.settings missing"
|
||||
paths = {
|
||||
"application": "/api/settings/application",
|
||||
"gree": "/api/settings/gree",
|
||||
"gree_cloud": "/api/settings/gree-cloud",
|
||||
"history": "/api/settings/history",
|
||||
"influxdb": "/api/settings/influxdb",
|
||||
"notifications": "/api/settings/notifications",
|
||||
"night": "/api/settings/night",
|
||||
"home_assistant": "/api/settings/home-assistant",
|
||||
"debug": "/api/settings/debug",
|
||||
}
|
||||
assert set(paths) <= set(settings), f"bootstrap settings sections missing: {set(paths) - set(settings)}"
|
||||
for section, path in paths.items():
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}") as response:
|
||||
standalone = json.load(response)
|
||||
assert settings[section] == standalone, f"bootstrap settings mismatch for {section}"
|
||||
|
||||
assert "password" not in settings["influxdb"]
|
||||
assert "token" not in settings["influxdb"]
|
||||
assert "password" not in settings["gree_cloud"]
|
||||
assert "token" not in settings["home_assistant"]
|
||||
for secret in ("pushover_app_token", "pushover_user_key", "slack_webhook_url", "discord_webhook_url"):
|
||||
assert secret not in settings["notifications"], f"secret field leaked: {secret}"
|
||||
PYBOOTSTRAP
|
||||
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"power":true,"mode":"cool","target_temperature":22}' \
|
||||
"http://127.0.0.1:$PORT/api/devices/sim-salon/command" >"$TMP/command.json"
|
||||
grep -q '"power":true' "$TMP/command.json"
|
||||
|
||||
curl -fsS -X POST \
|
||||
"http://127.0.0.1:$PORT/api/devices/sim-salon/probe" >"$TMP/probe.json"
|
||||
grep -q '"ok":true' "$TMP/probe.json"
|
||||
grep -q '"response_time_ms":0' "$TMP/probe.json"
|
||||
|
||||
curl -fsS "http://127.0.0.1:$PORT/api/history/network?hours=24&limit=100" >"$TMP/network-history.json"
|
||||
python3 - "$TMP/network-history.json" <<'PYNETWORK'
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert isinstance(data.get("readings"), list)
|
||||
assert isinstance(data.get("targets"), list)
|
||||
assert isinstance(data.get("bucket_seconds"), int) and data["bucket_seconds"] > 0
|
||||
assert isinstance(data.get("storage"), str) and data["storage"]
|
||||
PYNETWORK
|
||||
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"name":"Test","device_id":"sim-salon","enabled":true,"mode":"cool","setpoint":23,"hysteresis":0.6,"min_on_seconds":0,"min_off_seconds":0,"sensor_source":"device"}' \
|
||||
"http://127.0.0.1:$PORT/api/zones" >"$TMP/zone.json"
|
||||
@@ -84,32 +143,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}
|
||||
@@ -181,4 +240,28 @@ curl -fsS -X POST -H "Authorization: Bearer $HA_ACCESS_TOKEN" -H 'Content-Type:
|
||||
"http://127.0.0.1:$PORT/api/integrations/home-assistant/devices/sim-salon/command" >"$TMP/ha-command.json"
|
||||
grep -q '"power":false' "$TMP/ha-command.json"
|
||||
|
||||
# Disabled thermostat zones require an explicit direct-control override on the admin API,
|
||||
# while the restricted Home Assistant direct-device endpoint stays blocked.
|
||||
curl -fsS "http://127.0.0.1:$PORT/api/zones/$ZONE_ID" >"$TMP/zone-before-disable.json"
|
||||
python3 - "$TMP/zone-before-disable.json" "$TMP/zone-disable.json" <<'PYZONE'
|
||||
import json,sys
|
||||
zone=json.load(open(sys.argv[1]))
|
||||
zone["enabled"]=False
|
||||
json.dump(zone,open(sys.argv[2],"w"))
|
||||
PYZONE
|
||||
curl -fsS -X PUT -H 'Content-Type: application/json' --data-binary @"$TMP/zone-disable.json" \
|
||||
"http://127.0.0.1:$PORT/api/zones/$ZONE_ID" >"$TMP/zone-disabled.json"
|
||||
grep -q '"enabled":false' "$TMP/zone-disabled.json"
|
||||
BLOCKED_STATUS="$(curl -sS -o "$TMP/blocked-manual.json" -w '%{http_code}' -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"power":true}' "http://127.0.0.1:$PORT/api/devices/sim-salon/command")"
|
||||
[[ "$BLOCKED_STATUS" == "400" ]]
|
||||
grep -q 'manual_override=true' "$TMP/blocked-manual.json"
|
||||
curl -fsS -X POST -H 'Content-Type: application/json' \
|
||||
-d '{"power":true,"manual_override":true}' \
|
||||
"http://127.0.0.1:$PORT/api/devices/sim-salon/command" >"$TMP/override-manual.json"
|
||||
grep -q '"power":true' "$TMP/override-manual.json"
|
||||
HA_BLOCKED_STATUS="$(curl -sS -o "$TMP/ha-blocked-manual.json" -w '%{http_code}' -X POST -H "Authorization: Bearer $HA_ACCESS_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"power":false}' "http://127.0.0.1:$PORT/api/integrations/home-assistant/devices/sim-salon/command")"
|
||||
[[ "$HA_BLOCKED_STATUS" == "400" ]]
|
||||
|
||||
echo "Smoke test OK (port $PORT)"
|
||||
|
||||
@@ -1,473 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
CONDITIONS = {
|
||||
'weekday','time_range','date_range','cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates',
|
||||
'outdoor_temperature','device_temperature','zone_temperature','ha_state','ha_numeric','ha_attribute',
|
||||
'ha_available','house_mode','device_state','zone_state','group_state','night_mode','constant','shared_input'
|
||||
}
|
||||
LOGIC = {'logic_and','logic_or','logic_not'}
|
||||
ACTIONS = {'zone_thermostat','device_action','group_action','ha_service_action'}
|
||||
ALL = CONDITIONS | LOGIC | ACTIONS
|
||||
|
||||
passed = 0
|
||||
|
||||
def check(value, message):
|
||||
global passed
|
||||
if not value:
|
||||
raise AssertionError(message)
|
||||
passed += 1
|
||||
|
||||
|
||||
def cron_field_valid(field: str, lo: int, hi: int, allow_seven=False):
|
||||
upper = 7 if allow_seven else hi
|
||||
if not field.strip(): return False
|
||||
for part in field.split(','):
|
||||
part = part.strip()
|
||||
if part == '*': continue
|
||||
if part.startswith('*/'):
|
||||
try: step = int(part[2:])
|
||||
except ValueError: return False
|
||||
if not (0 < step <= hi - lo + 1): return False
|
||||
continue
|
||||
if '-' in part:
|
||||
try: a,b = map(int, part.split('-',1))
|
||||
except ValueError: return False
|
||||
if not (lo <= a <= b <= upper): return False
|
||||
continue
|
||||
try: v = int(part)
|
||||
except ValueError: return False
|
||||
if not (lo <= v <= upper): return False
|
||||
return True
|
||||
|
||||
|
||||
def cron_valid(expr: str):
|
||||
f=expr.split()
|
||||
return len(f)==5 and cron_field_valid(f[0],0,59) and cron_field_valid(f[1],0,23) and cron_field_valid(f[2],1,31) and cron_field_valid(f[3],1,12) and cron_field_valid(f[4],0,6,True)
|
||||
|
||||
|
||||
def cron_part_match(part, value, lo, hi):
|
||||
if part == '*': return True
|
||||
if part.startswith('*/'):
|
||||
step=int(part[2:]); return lo <= value <= hi and (value-lo)%step==0
|
||||
if '-' in part:
|
||||
a,b=map(int,part.split('-',1)); return a <= value <= b and a >= lo and b <= hi
|
||||
v=int(part); return v==value and lo <= v <= hi
|
||||
|
||||
|
||||
def cron_field_match(field,value,lo,hi):
|
||||
return any(cron_part_match(p.strip(),value,lo,hi) for p in field.split(','))
|
||||
|
||||
|
||||
def cron_matches(expr: str, dt: datetime):
|
||||
if not cron_valid(expr): return False
|
||||
f=expr.split(); # Python Monday=0; backend Sunday=0
|
||||
weekday=(dt.weekday()+1)%7
|
||||
return (cron_field_match(f[0],dt.minute,0,59) and cron_field_match(f[1],dt.hour,0,23)
|
||||
and cron_field_match(f[2],dt.day,1,31) and cron_field_match(f[3],dt.month,1,12)
|
||||
and (cron_field_match(f[4],weekday,0,7) or (weekday==0 and cron_field_match(f[4],7,0,7))))
|
||||
|
||||
@dataclass
|
||||
class Runtime:
|
||||
since: datetime|None=None
|
||||
samples: list[tuple[datetime,float]]=field(default_factory=list)
|
||||
last_value: object|None=None
|
||||
|
||||
def timed_gate(rt: Runtime, input_value: bool, seconds: int, now: datetime):
|
||||
if not input_value:
|
||||
rt.since=None; return False
|
||||
if rt.since is None: rt.since=now
|
||||
return (now-rt.since).total_seconds() >= seconds
|
||||
|
||||
def state_duration(rt: Runtime, input_value: bool, minimum: int, maximum: int|None, now: datetime):
|
||||
if not input_value:
|
||||
rt.since=None; return False,0
|
||||
if rt.since is None: rt.since=now
|
||||
elapsed=max(0,int((now-rt.since).total_seconds()))
|
||||
return elapsed >= minimum and (maximum is None or elapsed <= maximum), elapsed
|
||||
|
||||
def changed(rt: Runtime, current):
|
||||
result=rt.last_value is not None and rt.last_value != current
|
||||
rt.last_value=current
|
||||
return result
|
||||
|
||||
def rate_limit_status(rt: Runtime, maximum: int, period: int, now: datetime):
|
||||
cutoff=now-timedelta(seconds=period)
|
||||
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
|
||||
return len(rt.samples) < maximum, len(rt.samples)
|
||||
|
||||
def rate_limit_record(rt: Runtime, period: int, now: datetime):
|
||||
cutoff=now-timedelta(seconds=period)
|
||||
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
|
||||
rt.samples.append((now,1.0))
|
||||
|
||||
def rolling(rt: Runtime, sample: float, window: int, statistic: str, now: datetime):
|
||||
cutoff=now-timedelta(seconds=window)
|
||||
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
|
||||
rt.samples.append((now,sample))
|
||||
vals=[v for _,v in rt.samples]
|
||||
if statistic == 'median':
|
||||
vals=sorted(vals); mid=len(vals)//2
|
||||
return (vals[mid-1]+vals[mid])/2 if len(vals)%2==0 else vals[mid]
|
||||
return sum(vals)/len(vals)
|
||||
|
||||
def oscillation_metrics(values):
|
||||
if len(values)<3: return None
|
||||
span=max(values)-min(values)
|
||||
prev=0; changes=0
|
||||
for a,b in zip(values,values[1:]):
|
||||
d=b-a; sign=1 if d>1e-6 else -1 if d<-1e-6 else 0
|
||||
if not sign: continue
|
||||
if prev and sign != prev: changes += 1
|
||||
prev=sign
|
||||
return span,changes
|
||||
|
||||
def oscillates(rt: Runtime, sample: float, window: int, min_span: float, min_changes: int, now: datetime):
|
||||
cutoff=now-timedelta(seconds=window)
|
||||
rt.samples=[x for x in rt.samples if x[0] >= cutoff]
|
||||
rt.samples.append((now,sample))
|
||||
metrics=oscillation_metrics([v for _,v in rt.samples])
|
||||
return bool(metrics and metrics[0] >= min_span and metrics[1] >= min_changes)
|
||||
|
||||
|
||||
NUMERIC_OPS = {'lt','lte','gt','gte','eq','neq'}
|
||||
TEXT_OPS = {'eq','neq'}
|
||||
DEVICE_STATE_FIELDS = {'enabled','online','power','mode','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep'}
|
||||
ZONE_STATE_FIELDS = {'enabled','mode','active_preset','demand','control_owner','device_manual_override','local_thermostat_power'}
|
||||
THERMOSTAT_PRESETS = {'auto','comfort','sleep','away','custom'}
|
||||
THERMOSTAT_MODES = {'auto','heat','cool'}
|
||||
GROUP_PRESETS = {'auto','comfort','sleep','away','custom'}
|
||||
GROUP_MODES = {'auto','house','cool','heat'}
|
||||
ROLLING_SOURCES = {'outdoor_temperature','device_temperature','zone_temperature','ha_numeric'}
|
||||
|
||||
def nonempty(value):
|
||||
return isinstance(value,str) and bool(value.strip())
|
||||
|
||||
def finite_number(value):
|
||||
return isinstance(value,(int,float)) and not isinstance(value,bool) and math.isfinite(float(value))
|
||||
|
||||
def optional_text(value):
|
||||
return value.strip() if isinstance(value,str) and value.strip() else None
|
||||
|
||||
def validate_numeric_comparison(config, name):
|
||||
check(config.get('operator','lt') in NUMERIC_OPS, f'{name}: unsupported numeric operator')
|
||||
check(finite_number(config.get('value')), f'{name}: numeric comparison needs finite value')
|
||||
|
||||
def validate_text_comparison_config(config, name):
|
||||
check(config.get('operator','eq') in TEXT_OPS, f'{name}: unsupported text operator')
|
||||
check('value' in config, f'{name}: state comparison needs value')
|
||||
|
||||
def validate_source_config(config, name):
|
||||
source=config.get('source')
|
||||
check(source in ROLLING_SOURCES, f'{name}: unsupported statistic source')
|
||||
if source == 'device_temperature': check(nonempty(config.get('device_id')), f'{name}: device source needs device_id')
|
||||
if source == 'zone_temperature': check(nonempty(config.get('zone_id')), f'{name}: zone source needs zone_id')
|
||||
if source == 'ha_numeric': check(nonempty(config.get('entity_id')), f'{name}: HA source needs entity_id')
|
||||
|
||||
def validate_node_config(node, name):
|
||||
kind=node['kind']; c=node.get('config',{})
|
||||
check(isinstance(c,dict), f'{name}/{node.get("id")}: config is not an object')
|
||||
tag=f'{name}/{node.get("id")}:{kind}'
|
||||
if kind == 'weekday':
|
||||
days=c.get('days'); check(isinstance(days,list) and bool(days), f'{tag}: weekdays missing')
|
||||
check(all(isinstance(d,int) and not isinstance(d,bool) and 1 <= d <= 7 for d in days), f'{tag}: invalid weekday')
|
||||
elif kind == 'time_range':
|
||||
for key in ('start','end'):
|
||||
try: datetime.strptime(c.get(key,''),'%H:%M')
|
||||
except (TypeError,ValueError): check(False, f'{tag}: invalid {key} time')
|
||||
else: check(True, f'{tag}: valid {key} time')
|
||||
elif kind == 'date_range':
|
||||
try:
|
||||
start=datetime.strptime(c.get('start',''),'%Y-%m-%d').date(); end=datetime.strptime(c.get('end',''),'%Y-%m-%d').date()
|
||||
except (TypeError,ValueError):
|
||||
check(False, f'{tag}: invalid date range')
|
||||
else:
|
||||
check(start <= end, f'{tag}: reversed date range')
|
||||
elif kind == 'cron_trigger':
|
||||
check(nonempty(c.get('expression')) and cron_valid(c['expression']), f'{tag}: invalid cron')
|
||||
elif kind in {'stable_for','delay'}:
|
||||
seconds=c.get('seconds'); check(isinstance(seconds,int) and not isinstance(seconds,bool) and 1 <= seconds <= 604800, f'{tag}: invalid duration')
|
||||
elif kind == 'state_duration':
|
||||
minimum=c.get('min_seconds',0); maximum=c.get('max_seconds')
|
||||
check(isinstance(minimum,int) and not isinstance(minimum,bool) and 0 <= minimum <= 604800, f'{tag}: invalid min duration')
|
||||
check(maximum is None or (isinstance(maximum,int) and not isinstance(maximum,bool) and minimum <= maximum <= 604800), f'{tag}: invalid max duration')
|
||||
check(minimum > 0 or maximum is not None, f'{tag}: empty duration range')
|
||||
elif kind == 'on_change':
|
||||
check(c.get('mode','result') in {'result','value'}, f'{tag}: invalid change mode')
|
||||
elif kind == 'rate_limit':
|
||||
count=c.get('max_count'); period=c.get('period_seconds')
|
||||
check(isinstance(count,int) and not isinstance(count,bool) and 1 <= count <= 1000, f'{tag}: invalid max count')
|
||||
check(isinstance(period,int) and not isinstance(period,bool) and 1 <= period <= 2678400, f'{tag}: invalid rate period')
|
||||
elif kind == 'rolling_stat':
|
||||
validate_source_config(c,tag)
|
||||
window=c.get('window_seconds'); check(isinstance(window,int) and not isinstance(window,bool) and 10 <= window <= 604800, f'{tag}: invalid window')
|
||||
check(c.get('statistic') in {'mean','median'}, f'{tag}: invalid statistic')
|
||||
validate_numeric_comparison(c,tag)
|
||||
elif kind == 'oscillates':
|
||||
validate_source_config(c,tag)
|
||||
window=c.get('window_seconds'); check(isinstance(window,int) and not isinstance(window,bool) and 10 <= window <= 604800, f'{tag}: invalid window')
|
||||
check(finite_number(c.get('min_span')) and float(c['min_span']) > 0, f'{tag}: min_span must be positive')
|
||||
changes=c.get('min_direction_changes'); check(isinstance(changes,int) and not isinstance(changes,bool) and 1 <= changes <= 1000, f'{tag}: invalid direction-change count')
|
||||
elif kind == 'outdoor_temperature':
|
||||
validate_numeric_comparison(c,tag)
|
||||
elif kind == 'device_temperature':
|
||||
check(nonempty(c.get('device_id')), f'{tag}: device_id missing'); validate_numeric_comparison(c,tag)
|
||||
elif kind == 'zone_temperature':
|
||||
check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing'); validate_numeric_comparison(c,tag)
|
||||
elif kind == 'ha_state':
|
||||
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing'); validate_text_comparison_config(c,tag)
|
||||
elif kind == 'ha_numeric':
|
||||
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing'); validate_numeric_comparison(c,tag)
|
||||
elif kind == 'ha_attribute':
|
||||
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing')
|
||||
check(nonempty(c.get('attribute')), f'{tag}: attribute missing')
|
||||
check(c.get('operator','eq') in NUMERIC_OPS, f'{tag}: invalid attribute operator')
|
||||
check('value' in c, f'{tag}: attribute value missing')
|
||||
elif kind == 'ha_available':
|
||||
check(nonempty(c.get('entity_id')), f'{tag}: entity_id missing')
|
||||
elif kind == 'house_mode':
|
||||
check(c.get('value') in {'cool','heat','off'}, f'{tag}: invalid house mode'); validate_text_comparison_config(c,tag)
|
||||
elif kind == 'device_state':
|
||||
check(nonempty(c.get('device_id')), f'{tag}: device_id missing')
|
||||
check(c.get('field') in DEVICE_STATE_FIELDS, f'{tag}: invalid device field'); validate_text_comparison_config(c,tag)
|
||||
elif kind == 'zone_state':
|
||||
check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing')
|
||||
check(c.get('field') in ZONE_STATE_FIELDS, f'{tag}: invalid zone field'); validate_text_comparison_config(c,tag)
|
||||
elif kind == 'group_state':
|
||||
check(nonempty(c.get('group_id')), f'{tag}: group_id missing')
|
||||
check(c.get('field') == 'power_enabled', f'{tag}: invalid group field'); validate_text_comparison_config(c,tag)
|
||||
elif kind == 'constant':
|
||||
check(isinstance(c.get('value'),bool), f'{tag}: constant needs boolean')
|
||||
elif kind == 'shared_input':
|
||||
check(nonempty(c.get('input_id')), f'{tag}: input_id missing')
|
||||
elif kind == 'zone_thermostat':
|
||||
check(nonempty(c.get('zone_id')), f'{tag}: zone_id missing')
|
||||
preset=optional_text(c.get('preset')) or 'comfort'; check(preset in THERMOSTAT_PRESETS, f'{tag}: invalid preset')
|
||||
mode=optional_text(c.get('mode')); check(mode is None or mode in THERMOSTAT_MODES, f'{tag}: invalid mode')
|
||||
if preset == 'custom': check(finite_number(c.get('setpoint')) and 8 <= float(c['setpoint']) <= 30, f'{tag}: invalid custom target')
|
||||
if 'power' in c and c['power'] is not None: check(isinstance(c['power'],bool), f'{tag}: power is not boolean')
|
||||
if 'cooldown_seconds' in c: check(isinstance(c['cooldown_seconds'],int) and c['cooldown_seconds'] >= 0, f'{tag}: invalid cooldown')
|
||||
elif kind == 'device_action':
|
||||
check(nonempty(c.get('device_id')), f'{tag}: device_id missing')
|
||||
fields=('power','mode','target_temperature','fan_speed','swing_vertical','swing_horizontal','quiet','turbo','light','air','xfan','health','sleep')
|
||||
check(any(c.get(field) is not None for field in fields), f'{tag}: empty device action')
|
||||
elif kind == 'group_action':
|
||||
check(nonempty(c.get('group_id')), f'{tag}: group_id missing')
|
||||
mode=optional_text(c.get('mode')); preset=optional_text(c.get('preset'))
|
||||
check(mode is None or mode in GROUP_MODES, f'{tag}: invalid group mode')
|
||||
check(preset is None or preset in GROUP_PRESETS, f'{tag}: invalid group preset')
|
||||
check(c.get('power') is not None or mode is not None or preset is not None, f'{tag}: empty group action')
|
||||
if preset == 'custom': check(finite_number(c.get('setpoint')) and 8 <= float(c['setpoint']) <= 30, f'{tag}: invalid custom target')
|
||||
elif kind == 'ha_service_action':
|
||||
check(nonempty(c.get('domain')), f'{tag}: domain missing'); check(nonempty(c.get('service')), f'{tag}: service missing')
|
||||
# Empty entity_id is intentionally treated as omitted by backend flow_string().
|
||||
check(isinstance(c.get('data',{}),dict), f'{tag}: HA service data must be object')
|
||||
elif kind in LOGIC or kind == 'night_mode':
|
||||
pass
|
||||
|
||||
def flow_action_truth_table(flow, action_id):
|
||||
by={n['id']:n for n in flow.get('nodes',[])}
|
||||
incoming={}
|
||||
for edge in flow.get('edges',[]): incoming.setdefault(edge['to'],[]).append(edge['from'])
|
||||
ancestors=set(); stack=list(incoming.get(action_id,[]))
|
||||
while stack:
|
||||
node_id=stack.pop()
|
||||
if node_id in ancestors: continue
|
||||
ancestors.add(node_id); stack.extend(incoming.get(node_id,[]))
|
||||
primitive=[node_id for node_id in ancestors if by[node_id]['kind'] not in LOGIC and by[node_id]['kind'] not in {'stable_for','delay','state_duration','on_change','rate_limit'}]
|
||||
check(len(primitive) <= 16, f'action {action_id}: truth table unexpectedly large')
|
||||
|
||||
def eval_node(node_id, assigned, memo):
|
||||
if node_id in memo: return memo[node_id]
|
||||
node=by[node_id]; kind=node['kind']; ins=incoming.get(node_id,[])
|
||||
if kind == 'logic_and': value=bool(ins) and all(eval_node(x,assigned,memo) for x in ins)
|
||||
elif kind == 'logic_or': value=bool(ins) and any(eval_node(x,assigned,memo) for x in ins)
|
||||
elif kind == 'logic_not': value=len(ins)==1 and not eval_node(ins[0],assigned,memo)
|
||||
elif kind in {'stable_for','delay','state_duration','on_change','rate_limit'}: value=len(ins)==1 and eval_node(ins[0],assigned,memo)
|
||||
else:
|
||||
predecessors=all(eval_node(x,assigned,memo) for x in ins)
|
||||
value=predecessors and assigned.get(node_id,False)
|
||||
memo[node_id]=value; return value
|
||||
|
||||
outcomes=[]
|
||||
for mask in range(1 << len(primitive)):
|
||||
assigned={node_id: bool(mask & (1 << index)) for index,node_id in enumerate(primitive)}
|
||||
memo={}
|
||||
outcomes.append(bool(incoming.get(action_id)) and all(eval_node(x,assigned,memo) for x in incoming.get(action_id,[])))
|
||||
return outcomes
|
||||
|
||||
def validate_graph(flow, name):
|
||||
nodes=flow.get('nodes',[]); edges=flow.get('edges',[])
|
||||
check(bool(nodes), f'{name}: no nodes')
|
||||
ids=[n.get('id') for n in nodes]
|
||||
check(all(ids) and len(ids)==len(set(ids)), f'{name}: duplicate/empty node ids')
|
||||
by={n['id']:n for n in nodes}
|
||||
check(all(n.get('kind') in ALL for n in nodes), f'{name}: unsupported node kind')
|
||||
check(any(n['kind'] in ACTIONS for n in nodes), f'{name}: no action')
|
||||
edge_ids=[e.get('id') for e in edges]
|
||||
check(all(edge_ids) and len(edge_ids)==len(set(edge_ids)), f'{name}: duplicate/empty edge ids')
|
||||
pairs=[]
|
||||
incoming={}
|
||||
outgoing={}
|
||||
for e in edges:
|
||||
a,b=e.get('from'),e.get('to')
|
||||
check(a in by and b in by and a != b, f'{name}: invalid edge endpoint')
|
||||
check(by[a]['kind'] not in ACTIONS, f'{name}: action has outgoing edge')
|
||||
pairs.append((a,b)); incoming.setdefault(b,[]).append(a); outgoing.setdefault(a,[]).append(b)
|
||||
check(len(pairs)==len(set(pairs)), f'{name}: duplicate connections')
|
||||
# DAG
|
||||
temp=set(); done=set()
|
||||
def visit(x):
|
||||
if x in done: return
|
||||
check(x not in temp, f'{name}: cycle')
|
||||
temp.add(x)
|
||||
for y in outgoing.get(x,[]): visit(y)
|
||||
temp.remove(x); done.add(x)
|
||||
for x in ids: visit(x)
|
||||
for n in nodes:
|
||||
validate_node_config(n, name)
|
||||
ins=incoming.get(n['id'],[])
|
||||
if n['kind']=='logic_not': check(len(ins)==1, f'{name}: NOT arity')
|
||||
if n['kind'] in {'logic_and','logic_or'}: check(len(ins)>=1, f'{name}: logic arity')
|
||||
if n['kind'] in {'stable_for','delay','state_duration','on_change','rate_limit'}: check(len(ins)==1, f'{name}: stateful gate arity')
|
||||
if n['kind'] in ACTIONS: check(len(ins)>=1, f'{name}: action without condition')
|
||||
if n['kind'] == 'rate_limit':
|
||||
targets=[by[target] for target in outgoing.get(n['id'],[]) if target in by]
|
||||
check(bool(targets) and all(target['kind'] in ACTIONS for target in targets), f'{name}: rate limit must be directly before action')
|
||||
if n['kind'] == 'on_change' and n.get('config',{}).get('mode','result') == 'value':
|
||||
sources=[by[source] for source in incoming.get(n['id'],[]) if source in by]
|
||||
blocked=LOGIC | {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'}
|
||||
check(len(sources)==1 and sources[0]['kind'] not in blocked, f'{name}: value-change mode needs direct source input')
|
||||
for action in (n for n in nodes if n['kind'] in ACTIONS):
|
||||
outcomes=flow_action_truth_table(flow, action['id'])
|
||||
check(any(outcomes), f'{name}/{action["id"]}: action condition graph can never become true')
|
||||
return len(nodes),len(edges)
|
||||
|
||||
|
||||
def main():
|
||||
# Deterministic backend semantics.
|
||||
check(cron_valid('*/5 * * * *'), 'cron */5 invalid')
|
||||
check(cron_valid('0,15,30,45 6-18 * * 1-5'), 'cron list/range invalid')
|
||||
for bad in ('* * * *','*/0 * * * *','61 * * * *','* 24 * * *','* * 0 * *','* * * 13 *','* * * * 8','*/100 * * * *'):
|
||||
check(not cron_valid(bad), f'cron accepted invalid: {bad}')
|
||||
monday=datetime(2026,9,7,10,15)
|
||||
check(cron_matches('15 10 * * 1', monday), 'cron exact weekday mismatch')
|
||||
check(cron_matches('15 10 * * 1-7', monday), 'cron weekday range containing 7 mismatch')
|
||||
check(not cron_matches('16 10 * * 1', monday), 'cron false positive')
|
||||
sunday=datetime(2026,9,6,8,0)
|
||||
check(cron_matches('0 8 * * 0', sunday) and cron_matches('0 8 * * 7', sunday) and cron_matches('0 8 * * 1-7', sunday), 'cron Sunday 0/7/range mismatch')
|
||||
|
||||
base=datetime(2026,9,2,12,0,0)
|
||||
rt=Runtime(); check(not timed_gate(rt,True,30,base),'stable fired immediately'); check(not timed_gate(rt,True,30,base+timedelta(seconds=29)),'stable fired early'); check(timed_gate(rt,True,30,base+timedelta(seconds=30)),'stable did not fire'); check(not timed_gate(rt,False,30,base+timedelta(seconds=31)),'stable did not reset'); check(not timed_gate(rt,True,30,base+timedelta(seconds=32)),'stable restart should wait')
|
||||
rt=Runtime(); check(not timed_gate(rt,True,3,base),'delay immediate'); check(timed_gate(rt,True,3,base+timedelta(seconds=3)),'delay did not pass after wait')
|
||||
|
||||
rt=Runtime(); check(state_duration(rt,True,10,30,base)==(False,0),'state duration immediate'); check(state_duration(rt,True,10,30,base+timedelta(seconds=10))==(True,10),'state duration minimum'); check(state_duration(rt,True,10,30,base+timedelta(seconds=30))==(True,30),'state duration maximum boundary'); check(state_duration(rt,True,10,30,base+timedelta(seconds=31))==(False,31),'state duration exceeded maximum'); check(state_duration(rt,False,10,30,base+timedelta(seconds=32))==(False,0) and rt.since is None,'state duration reset')
|
||||
rt=Runtime(); check(not changed(rt,'off'),'change fired on first observation'); check(not changed(rt,'off'),'change fired without change'); check(changed(rt,'on'),'change not detected'); check(not changed(rt,'on'),'change repeated without new edge')
|
||||
rt=Runtime(); rate_limit_record(rt,60,base); rate_limit_record(rt,60,base+timedelta(seconds=10)); check(rate_limit_status(rt,2,60,base+timedelta(seconds=20))==(False,2),'rate limit did not block'); check(rate_limit_status(rt,2,60,base+timedelta(seconds=61))==(True,1),'rate limit did not prune rolling window')
|
||||
|
||||
synthetic={
|
||||
'nodes':[
|
||||
{'id':'source','kind':'constant','config':{'value':True}},
|
||||
{'id':'duration','kind':'state_duration','config':{'min_seconds':10,'max_seconds':60}},
|
||||
{'id':'change','kind':'on_change','config':{'mode':'result'}},
|
||||
{'id':'limit','kind':'rate_limit','config':{'max_count':2,'period_seconds':3600}},
|
||||
{'id':'action','kind':'ha_service_action','config':{'domain':'switch','service':'turn_on','entity_id':'switch.test','data':{}}},
|
||||
],
|
||||
'edges':[
|
||||
{'id':'e1','from':'source','to':'duration'}, {'id':'e2','from':'duration','to':'change'},
|
||||
{'id':'e3','from':'change','to':'limit'}, {'id':'e4','from':'limit','to':'action'},
|
||||
]
|
||||
}
|
||||
check(validate_graph(synthetic,'synthetic-new-stateful-flow')==(5,4),'new stateful flow graph validation failed')
|
||||
|
||||
rt=Runtime(); check(math.isclose(rolling(rt,10,60,'mean',base),10),'mean one'); check(math.isclose(rolling(rt,20,60,'mean',base+timedelta(seconds=10)),15),'mean two'); check(math.isclose(rolling(rt,30,60,'median',base+timedelta(seconds=20)),20),'median odd'); check(math.isclose(rolling(rt,40,60,'median',base+timedelta(seconds=30)),25),'median even'); check(math.isclose(rolling(rt,100,60,'mean',base+timedelta(seconds=100)),100),'window prune')
|
||||
rt=Runtime();
|
||||
for i,v in enumerate((20,22,19,23,20)):
|
||||
result=oscillates(rt,v,300,3,2,base+timedelta(seconds=i*10))
|
||||
check(result,'oscillation not detected')
|
||||
rt=Runtime();
|
||||
for i,v in enumerate((20,21,22,23,24)):
|
||||
result=oscillates(rt,v,300,3,2,base+timedelta(seconds=i*10))
|
||||
check(not result,'monotonic trend detected as oscillation')
|
||||
|
||||
# Static backend/frontend wiring checks.
|
||||
engine=(ROOT/'src/engine/automations.rs').read_text()
|
||||
api=(ROOT/'src/api/flows.rs').read_text()
|
||||
js=(ROOT/'web/js/flows.js').read_text()
|
||||
html=(ROOT/'web/index.html').read_text()
|
||||
for kind in ('cron_trigger','stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'):
|
||||
check(f'"{kind}"' in api and f'"{kind}"' in engine, f'{kind}: backend wiring missing')
|
||||
check(kind in js and f'data-flow-add="{kind}"' in html, f'{kind}: UI wiring missing')
|
||||
meta_block=js.split('const FLOW_NODE_META = Object.freeze({',1)[1].split('});',1)[0]
|
||||
meta_kinds=set(re.findall(r'^\s*([a-z_]+):\s*\{', meta_block, re.M))
|
||||
palette_kinds=set(re.findall(r'data-flow-add="([a-z_]+)"', html))
|
||||
check(meta_kinds == ALL, f'UI metadata/backend kind mismatch: missing={ALL-meta_kinds}, extra={meta_kinds-ALL}')
|
||||
check(palette_kinds == ALL, f'palette/backend kind mismatch: missing={ALL-palette_kinds}, extra={palette_kinds-ALL}')
|
||||
check('"ha_service_action" => Ok(None)' in api, 'HA action missing dry-run support')
|
||||
check('home_assistant::call_service' in engine and 'action_ha_domain' in engine, 'HA action runtime missing')
|
||||
check('format!("ha:{entity_id}")' in engine, 'HA target conflict claim missing')
|
||||
check('Some(&mut runtime)' in api, 'dry-run does not use stateful runtime')
|
||||
check('conditions.iter().any(|condition| condition.kind == "cron_trigger")' in engine and 'last.minute() == now.minute()' in engine, 'CRON same-minute duplicate guard missing')
|
||||
check('previous.config == current.config' in api and 'runtime.retain' in api, 'runtime reset-on-edit protection missing')
|
||||
check('flow_record_rate_limited_execution' in engine and 'Ok(true)' in engine, 'rate limit is not recorded after successful execution')
|
||||
check('if !item.enabled { continue; }' in engine and 'if !ready || !should_fire { continue; }' in engine, 'Flow runtime may be skipped during action cooldown')
|
||||
check('rate-limit block must be placed directly before an action' in api, 'rate-limit placement guard missing')
|
||||
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()
|
||||
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')
|
||||
check('enabled: if draft { false } else { input.enabled }' in api, 'draft does not force Flow disabled')
|
||||
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('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')
|
||||
check("draft: app.flowDraft.draft === true" in js, 'draft portability state missing from source payload')
|
||||
runtime_leaf=engine.split('async fn flow_leaf_observation',1)[1].split('pub async fn evaluate_flow_conditions_trace',1)[0]
|
||||
for kind in CONDITIONS - {'stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates'}:
|
||||
check(f'\"{kind}\" =>' in runtime_leaf or (kind == 'shared_input' and 'condition.kind == \"shared_input\"' in runtime_leaf), f'{kind}: runtime leaf implementation missing')
|
||||
evaluator=engine.split('pub async fn evaluate_flow_conditions_trace',1)[1].split('async fn flow_conditions_match',1)[0]
|
||||
for kind in ('stable_for','delay','state_duration','on_change','rate_limit','rolling_stat','oscillates','logic_and','logic_or','logic_not'):
|
||||
check(f'\"{kind}\"' in evaluator, f'{kind}: evaluator implementation missing')
|
||||
for category in ('trigger','time','timeop','sensor','logic','action','haaction'):
|
||||
check(f'flow-palette-{category}' in html, f'palette category {category} missing')
|
||||
css=(ROOT/'web/styles.css').read_text()
|
||||
for marker in ('.flow-palette-group button', 'min-height:30px', 'padding:6px 8px', '.flow-palette-timeop', '.flow-palette-haaction', '.flow-editor-title input:hover', '.flow-editor-title input:focus', '.flow-draft-badge'):
|
||||
check(marker in css, f'compact/color Flow CSS missing: {marker}')
|
||||
|
||||
# Translation pack correctness: all new keys must be inside translations, not root.
|
||||
for lang in ('pl','en'):
|
||||
pack=json.loads((ROOT/f'lang/{lang}.json').read_text())
|
||||
check(not any(k.startswith('flow.') for k in pack), f'{lang}: flow translations leaked to root')
|
||||
for key in ('flow.triggers','flow.timeOps','flow.haActions','flow.node.cronTrigger','flow.node.stableFor','flow.node.stateDuration','flow.node.onChange','flow.node.rateLimit','flow.node.delay','flow.node.rollingStat','flow.node.oscillates','flow.node.haServiceAction','flow.draft','flow.draftStatus','flow.draftNoExecution','flow.draftDisabledHint','flow.draftCannotEnable','flow.saveAsDraftConfirm','flow.savedAsDraft'):
|
||||
check(bool(pack.get('translations',{}).get(key)), f'{lang}: missing {key}')
|
||||
title_keys=set(re.findall(r"titleKey: '([^']+)'", meta_block))
|
||||
missing_titles=sorted(key for key in title_keys if not pack.get('translations',{}).get(key))
|
||||
check(not missing_titles, f'{lang}: missing Flow node title translations: {missing_titles}')
|
||||
referenced_flow_keys=set(re.findall(r'''(?:data-i18n(?:-[a-z]+)?=|tr\()\s*[\"'](flow\.[A-Za-z0-9_.]+)[\"']''', js + '\n' + html))
|
||||
missing_referenced=sorted(key for key in referenced_flow_keys if key not in pack.get('translations',{}))
|
||||
check(not missing_referenced, f'{lang}: missing referenced Flow translations: {missing_referenced}')
|
||||
|
||||
# Regression pass over every bundled Flow preset.
|
||||
preset_files=sorted((ROOT/'presets').glob('*.json'))
|
||||
check(len(preset_files) >= 30, 'unexpectedly small preset library')
|
||||
nodes=edges=0
|
||||
for f in preset_files:
|
||||
data=json.loads(f.read_text())
|
||||
n,e=validate_graph(data['flow'],f.name); nodes+=n; edges+=e
|
||||
print(f'PASS: {passed} logical assertions; {len(preset_files)} presets; {nodes} nodes; {edges} edges')
|
||||
|
||||
if __name__=='__main__': main()
|
||||
+270
-70
@@ -1,7 +1,29 @@
|
||||
use std::{net::IpAddr, sync::atomic::Ordering, time::{Duration, Instant}};
|
||||
use crate::{
|
||||
engine,
|
||||
error::AppError,
|
||||
home_assistant, influxdb,
|
||||
models::{
|
||||
AddDiscoveredDevicesRequest, ApiTokenInfo, ApplicationSettings, Automation, ClimateGroup,
|
||||
ConfigurationExport, ConnectionStatus, ConnectionType, DebugSettings, Device, DeviceCommand, DeviceGroup,
|
||||
DeviceGroupKind, DevicePatch, DiscoveryRequest, EnergyReading, EnergySourcePreference,
|
||||
Flow, GreeCloudSettings, GreeCloudSettingsUpdate, GreeCloudSettingsView, GreeSettings,
|
||||
GroupControlPatch, HaReading, HistorySettings, HomeAssistantSettings,
|
||||
HomeAssistantSettingsUpdate, HomeAssistantSettingsView, InfluxDbSettings,
|
||||
InfluxDbSettingsUpdate, InfluxDbSettingsView, LocalDiscoveryCandidate, ManualDeviceRequest, NetworkReading,
|
||||
NightModeSettings, NotificationSettings, NotificationSettingsUpdate,
|
||||
NotificationSettingsView, Reading, RuntimeSettings, Schedule, SettingsSnapshot,
|
||||
TemporaryQuickThermostat, TemporaryQuickThermostatRequest, Zone, ZoneControlPatch,
|
||||
ZoneReading,
|
||||
},
|
||||
notifications,
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}},
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
ConnectInfo, Path, Query, Request, State, WebSocketUpgrade,
|
||||
},
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{Redirect, Response},
|
||||
@@ -11,30 +33,29 @@ use axum::{
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{Duration as ChronoDuration, NaiveTime, Utc};
|
||||
use futures_util::StreamExt;
|
||||
use rand::{rngs::OsRng, RngCore};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
sync::atomic::Ordering,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tower_http::{compression::CompressionLayer, trace::TraceLayer};
|
||||
use uuid::Uuid;
|
||||
use crate::{
|
||||
engine,
|
||||
error::AppError,
|
||||
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},
|
||||
protocol::merge_discovered,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
mod openapi;
|
||||
|
||||
const INDEX_HTML: &str = include_str!("../web/index.html");
|
||||
const CUSTOM_CHART_HTML: &str = include_str!("../web/custom-chart.html");
|
||||
const CUSTOM_CHART_JS: &str = include_str!("../web/js/custom-chart.js");
|
||||
const NOT_FOUND_HTML: &str = include_str!("../web/404.html");
|
||||
const APP_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/app.bundle.js"));
|
||||
const THEME_INIT_JS: &str = include_str!("../web/theme-init.js");
|
||||
const STYLES_CSS: &str = include_str!("../web/styles.css");
|
||||
const THEME_INIT_JS: &str = include_str!("../web/js/theme-init.js");
|
||||
const LANG_INIT_JS: &str = include_str!("../web/js/lang-init.js");
|
||||
const STYLES_CSS: &str = include_str!("../web/css/styles.css");
|
||||
const MANIFEST: &str = include_str!("../web/manifest.webmanifest");
|
||||
const SERVICE_WORKER: &str = include_str!("../web/sw.js");
|
||||
const SERVICE_WORKER: &str = include_str!("../web/js/sw.js");
|
||||
const FAVICON: &str = include_str!("../web/favicon.svg");
|
||||
include!(concat!(env!("OUT_DIR"), "/languages.rs"));
|
||||
|
||||
@@ -55,6 +76,8 @@ const SPA_ROUTES: &[&str] = &[
|
||||
"/history/overview",
|
||||
"/history/zones",
|
||||
"/history/devices",
|
||||
"/history/energy",
|
||||
"/history/network",
|
||||
"/history/sensors",
|
||||
"/history/custom",
|
||||
];
|
||||
@@ -63,81 +86,252 @@ pub fn router(state: AppState) -> Router {
|
||||
let protected = Router::new()
|
||||
.route("/api/bootstrap", get(bootstrap))
|
||||
.route("/api/system/info", get(system_info))
|
||||
.route("/api/discovery", post(discover))
|
||||
.route("/api/discovery/scan", post(scan_discovery))
|
||||
.route("/api/discovery/add", post(add_discovered_devices))
|
||||
.route("/api/devices", get(list_devices).post(add_device))
|
||||
.route("/api/devices/:id", get(get_device).patch(patch_device).delete(delete_device))
|
||||
.route("/api/devices/:id/bind", post(bind_device))
|
||||
.route("/api/devices/:id/poll", post(poll_device))
|
||||
.route("/api/devices/:id/command", post(command_device))
|
||||
.route(
|
||||
"/api/devices/{id}",
|
||||
get(get_device).patch(patch_device).delete(delete_device),
|
||||
)
|
||||
.route("/api/devices/{id}/bind", post(bind_device))
|
||||
.route("/api/devices/{id}/poll", post(poll_device))
|
||||
.route("/api/devices/{id}/probe", post(probe_device))
|
||||
.route("/api/devices/{id}/command", post(command_device))
|
||||
.route(
|
||||
"/api/device-groups",
|
||||
get(list_device_groups).post(create_device_group),
|
||||
)
|
||||
.route(
|
||||
"/api/device-groups/{id}",
|
||||
get(get_device_group)
|
||||
.put(update_device_group)
|
||||
.delete(delete_device_group),
|
||||
)
|
||||
.route("/api/zones", get(list_zones).post(create_zone))
|
||||
.route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone))
|
||||
.route("/api/zones/:id/control", post(update_zone_control))
|
||||
.route("/api/zones/:id/compressor-queue/cancel", post(cancel_zone_compressor_queue))
|
||||
.route("/api/compressor-queue/cancel-all", post(cancel_all_compressor_queues))
|
||||
.route("/api/zones/:id/schedule-template", post(apply_schedule_template))
|
||||
.route(
|
||||
"/api/zones/{id}",
|
||||
get(get_zone).put(update_zone).delete(delete_zone),
|
||||
)
|
||||
.route("/api/zones/{id}/control", post(update_zone_control))
|
||||
.route(
|
||||
"/api/zones/{id}/compressor-queue/cancel",
|
||||
post(cancel_zone_compressor_queue),
|
||||
)
|
||||
.route(
|
||||
"/api/compressor-queue/cancel-all",
|
||||
post(cancel_all_compressor_queues),
|
||||
)
|
||||
.route(
|
||||
"/api/zones/{id}/schedule-template",
|
||||
post(apply_schedule_template),
|
||||
)
|
||||
.route("/api/groups", get(list_groups).post(create_group))
|
||||
.route("/api/groups/:id", get(get_group).put(update_group).delete(delete_group))
|
||||
.route("/api/groups/:id/control", post(update_group_control))
|
||||
.route(
|
||||
"/api/groups/{id}",
|
||||
get(get_group).put(update_group).delete(delete_group),
|
||||
)
|
||||
.route("/api/groups/{id}/control", post(update_group_control))
|
||||
.route("/api/house/control", post(update_house_control))
|
||||
.route("/api/house/power", post(update_house_power))
|
||||
.route("/api/house/emergency-stop", post(update_house_emergency_stop))
|
||||
.route("/api/house/preset", post(update_house_preset))
|
||||
.route("/api/schedules", get(list_schedules).post(create_schedule))
|
||||
.route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule))
|
||||
.route("/api/automations", get(list_automations).post(create_automation))
|
||||
.route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation))
|
||||
.route(
|
||||
"/api/schedules/{id}",
|
||||
get(get_schedule)
|
||||
.put(update_schedule)
|
||||
.delete(delete_schedule),
|
||||
)
|
||||
.route(
|
||||
"/api/automations",
|
||||
get(list_automations).post(create_automation),
|
||||
)
|
||||
.route(
|
||||
"/api/automations/{id}",
|
||||
get(get_automation)
|
||||
.put(update_automation)
|
||||
.delete(delete_automation),
|
||||
)
|
||||
.route("/api/flows", get(list_flows).post(create_flow))
|
||||
.route("/api/flows/import", post(import_flow))
|
||||
.route("/api/flows/simulate", post(simulate_flow))
|
||||
.route("/api/flows/:id/export", get(export_flow))
|
||||
.route("/api/flows/:id/logs", get(flow_logs))
|
||||
.route("/api/flows/:id", get(get_flow).put(update_flow).delete(delete_flow))
|
||||
.route("/api/flows/{id}/export", get(export_flow))
|
||||
.route("/api/flows/{id}/logs", get(flow_logs))
|
||||
.route(
|
||||
"/api/flows/{id}",
|
||||
get(get_flow).put(update_flow).delete(delete_flow),
|
||||
)
|
||||
.route("/api/readings", get(readings))
|
||||
.route("/api/history", get(history))
|
||||
.route("/api/charts/custom/share", post(create_public_custom_chart))
|
||||
.route("/api/history/energy", get(energy_history))
|
||||
.route("/api/history/network", get(network_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/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))
|
||||
.route("/api/integrations/home-assistant/entity", post(inspect_home_assistant_entity))
|
||||
.route("/api/integrations/notifications/test", post(test_notifications))
|
||||
.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/gree-cloud",
|
||||
get(get_gree_cloud_settings).put(update_gree_cloud_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/integrations/influxdb/test",
|
||||
post(test_influxdb_connection),
|
||||
)
|
||||
.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/gree-cloud/test", post(test_gree_cloud))
|
||||
.route(
|
||||
"/api/integrations/gree-cloud/devices",
|
||||
get(discover_gree_cloud_devices),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/gree-cloud/devices/{cloud_id}/add",
|
||||
post(add_gree_cloud_device),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/gree-cloud/status",
|
||||
get(gree_cloud_status),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/gree-cloud/reconnect",
|
||||
post(reconnect_gree_cloud),
|
||||
)
|
||||
.route(
|
||||
"/api/devices/{id}/cloud-diagnostics",
|
||||
get(cloud_device_diagnostics),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/test",
|
||||
post(test_home_assistant),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/entity",
|
||||
post(inspect_home_assistant_entity),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/entities",
|
||||
get(list_home_assistant_entities),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/energy-sensors",
|
||||
get(list_home_assistant_energy_sensors),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/notifications/test",
|
||||
post(test_notifications),
|
||||
)
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth));
|
||||
|
||||
let home_assistant_api = Router::new()
|
||||
.route("/api/integrations/home-assistant/devices", get(list_devices))
|
||||
.route("/api/integrations/home-assistant/devices/:id/command", post(command_home_assistant_device))
|
||||
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
|
||||
.route("/api/integrations/home-assistant/groups", get(list_home_assistant_groups))
|
||||
.route("/api/integrations/home-assistant/groups/:id/control", post(update_home_assistant_group_control))
|
||||
.route("/api/integrations/home-assistant/house/control", post(update_house_control))
|
||||
.route("/api/integrations/home-assistant/house/preset", post(update_house_preset))
|
||||
.route("/api/integrations/home-assistant/house/power", post(update_house_power))
|
||||
.route("/api/integrations/home-assistant/zones/:id/control", post(update_home_assistant_zone_control))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth));
|
||||
.route(
|
||||
"/api/integrations/home-assistant/snapshot",
|
||||
get(home_assistant_snapshot),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/devices",
|
||||
get(list_devices),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/devices/{id}/command",
|
||||
post(command_home_assistant_device),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/control-plan",
|
||||
get(control_plan),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/groups",
|
||||
get(list_home_assistant_groups),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/groups/{id}/control",
|
||||
post(update_home_assistant_group_control),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/house/control",
|
||||
post(update_house_control),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/house/preset",
|
||||
post(update_house_preset),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/house/power",
|
||||
post(update_house_power),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/house/emergency-stop",
|
||||
post(update_house_emergency_stop),
|
||||
)
|
||||
.route(
|
||||
"/api/integrations/home-assistant/zones/{id}/control",
|
||||
post(update_home_assistant_zone_control),
|
||||
)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
home_assistant_auth,
|
||||
));
|
||||
|
||||
let mut app = Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/public/charts/custom/{token}", get(public_custom_chart))
|
||||
.route("/charts/custom/{token}", get(custom_chart_page))
|
||||
.route("/custom-chart.js", get(custom_chart_js))
|
||||
.route("/ws", get(websocket))
|
||||
.route("/", get(index))
|
||||
.route("/index.html", get(index))
|
||||
.route(APP_JS_ASSET_PATH, get(app_js))
|
||||
.route(THEME_INIT_ASSET_PATH, get(theme_init_js))
|
||||
.route(LANG_INIT_ASSET_PATH, get(lang_init_js))
|
||||
.route(STYLES_CSS_ASSET_PATH, get(styles_css))
|
||||
.route("/app.js", get(app_js_legacy))
|
||||
.route("/theme-init.js", get(theme_init_js_legacy))
|
||||
.route("/styles.css", get(styles_css_legacy))
|
||||
.route("/manifest.webmanifest", get(manifest))
|
||||
.route("/sw.js", get(service_worker))
|
||||
.route("/favicon.svg", get(favicon))
|
||||
.route("/flows/:id", get(index))
|
||||
.route("/flows/{id}", get(index))
|
||||
.route("/lang/index.json", get(language_index))
|
||||
.route("/lang/:file", get(language_file))
|
||||
.route("/lang/{file}", get(language_file))
|
||||
.route("/presets/index.json", get(preset_index))
|
||||
.route("/presets/:file", get(preset_file))
|
||||
.route("/presets/{file}", get(preset_file))
|
||||
.merge(openapi::swagger_ui(&state.config.base_path))
|
||||
.merge(protected)
|
||||
.merge(home_assistant_api);
|
||||
|
||||
@@ -152,27 +346,32 @@ pub fn router(state: AppState) -> Router {
|
||||
let base = state.config.base_path.clone();
|
||||
let redirect_to = format!("{base}/");
|
||||
Router::new()
|
||||
.route(&base, get(move || {
|
||||
let redirect_to = redirect_to.clone();
|
||||
async move { Redirect::permanent(&redirect_to) }
|
||||
}))
|
||||
.route(
|
||||
&base,
|
||||
get(move || {
|
||||
let redirect_to = redirect_to.clone();
|
||||
async move { Redirect::permanent(&redirect_to) }
|
||||
}),
|
||||
)
|
||||
.nest(&base, app)
|
||||
.fallback(not_found)
|
||||
};
|
||||
|
||||
app
|
||||
.layer(CompressionLayer::new())
|
||||
app.layer(CompressionLayer::new())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(middleware::from_fn(security_headers))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), debug_api_requests))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
debug_api_requests,
|
||||
))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
|
||||
// Functional source split intentionally keeps items in the existing module namespace.
|
||||
include!("api/auth.rs");
|
||||
include!("api/system.rs");
|
||||
include!("api/devices.rs");
|
||||
include!("api/device_groups.rs");
|
||||
include!("api/zones.rs");
|
||||
include!("api/groups.rs");
|
||||
include!("api/house.rs");
|
||||
@@ -182,9 +381,10 @@ 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/gree_cloud.rs");
|
||||
include!("api/middleware.rs");
|
||||
include!("api/public_settings.rs");
|
||||
include!("api/websocket.rs");
|
||||
include!("api/assets.rs");
|
||||
|
||||
+182
-32
@@ -1,15 +1,62 @@
|
||||
async fn not_found(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let base = request_base_path(&state, &headers);
|
||||
let home = if base.is_empty() { "/".to_owned() } else { format!("{base}/") };
|
||||
let home = if base.is_empty() {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
format!("{base}/")
|
||||
};
|
||||
let body = NOT_FOUND_HTML
|
||||
.replace("__GREE_BASE_PATH__", &base)
|
||||
.replace("__GREE_HOME_PATH__", &home)
|
||||
.replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}"))
|
||||
.replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}"));
|
||||
.replace(
|
||||
"__GREE_THEME_INIT_ASSET__",
|
||||
&format!("{base}{THEME_INIT_ASSET_PATH}"),
|
||||
)
|
||||
.replace(
|
||||
"__GREE_STYLES_ASSET__",
|
||||
&format!("{base}{STYLES_CSS_ASSET_PATH}"),
|
||||
);
|
||||
let mut response = Response::new(Body::from(body));
|
||||
*response.status_mut() = StatusCode::NOT_FOUND;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate"));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-store, no-cache"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
async fn custom_chart_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let base = request_base_path(&state, &headers);
|
||||
let body = CUSTOM_CHART_HTML
|
||||
.replace("__GREE_BASE_PATH__", &base)
|
||||
.replace(
|
||||
"__GREE_THEME_INIT_ASSET__",
|
||||
&format!("{base}{THEME_INIT_ASSET_PATH}"),
|
||||
)
|
||||
.replace(
|
||||
"__GREE_LANG_INIT_ASSET__",
|
||||
&format!("{base}{LANG_INIT_ASSET_PATH}"),
|
||||
)
|
||||
.replace(
|
||||
"__GREE_STYLES_ASSET__",
|
||||
&format!("{base}{STYLES_CSS_ASSET_PATH}"),
|
||||
)
|
||||
.replace(
|
||||
"__GREE_CUSTOM_CHART_ASSET__",
|
||||
&format!("{base}/custom-chart.js"),
|
||||
);
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
@@ -18,11 +65,27 @@ async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let body = INDEX_HTML
|
||||
.replace("__GREE_BASE_PATH__", &base)
|
||||
.replace("__GREE_APP_ASSET__", &format!("{base}{APP_JS_ASSET_PATH}"))
|
||||
.replace("__GREE_THEME_INIT_ASSET__", &format!("{base}{THEME_INIT_ASSET_PATH}"))
|
||||
.replace("__GREE_STYLES_ASSET__", &format!("{base}{STYLES_CSS_ASSET_PATH}"));
|
||||
.replace(
|
||||
"__GREE_THEME_INIT_ASSET__",
|
||||
&format!("{base}{THEME_INIT_ASSET_PATH}"),
|
||||
)
|
||||
.replace(
|
||||
"__GREE_LANG_INIT_ASSET__",
|
||||
&format!("{base}{LANG_INIT_ASSET_PATH}"),
|
||||
)
|
||||
.replace(
|
||||
"__GREE_STYLES_ASSET__",
|
||||
&format!("{base}{STYLES_CSS_ASSET_PATH}"),
|
||||
);
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static("private, no-store, no-cache, must-revalidate"));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, no-store, no-cache"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
@@ -35,42 +98,114 @@ fn request_base_path(state: &AppState, headers: &HeaderMap) -> String {
|
||||
}
|
||||
|
||||
fn forwarded_prefix(headers: &HeaderMap) -> Option<String> {
|
||||
let raw = headers.get("x-forwarded-prefix")?.to_str().ok()?.split(',').next()?.trim();
|
||||
if raw.is_empty() || raw == "/" { return Some(String::new()); }
|
||||
if raw.contains('?') || raw.contains('#') || raw.split('/').any(|part| matches!(part, "." | "..")) { return None; }
|
||||
Some(format!("/{}", raw.trim_matches('/')))
|
||||
// Home Assistant ingress uses X-Ingress-Path. Generic reverse proxies
|
||||
// commonly use X-Forwarded-Prefix, so support both with HA taking
|
||||
// precedence when both are present.
|
||||
for header in ["x-ingress-path", "x-forwarded-prefix"] {
|
||||
let Some(raw) = headers.get(header).and_then(|value| value.to_str().ok()) else {
|
||||
continue;
|
||||
};
|
||||
let raw = raw.split(',').next().unwrap_or_default().trim();
|
||||
if raw.is_empty() || raw == "/" {
|
||||
return Some(String::new());
|
||||
}
|
||||
if raw.contains('?')
|
||||
|| raw.contains('#')
|
||||
|| raw.split('/').any(|part| matches!(part, "." | ".."))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Some(format!("/{}", raw.trim_matches('/')));
|
||||
}
|
||||
None
|
||||
}
|
||||
async fn custom_chart_js() -> Response {
|
||||
static_response(
|
||||
CUSTOM_CHART_JS,
|
||||
"application/javascript; charset=utf-8",
|
||||
"public, max-age=300",
|
||||
)
|
||||
}
|
||||
async fn app_js() -> Response {
|
||||
static_response(
|
||||
APP_JS,
|
||||
"application/javascript; charset=utf-8",
|
||||
"public, max-age=31536000, immutable",
|
||||
)
|
||||
}
|
||||
async fn theme_init_js() -> Response {
|
||||
static_response(
|
||||
THEME_INIT_JS,
|
||||
"application/javascript; charset=utf-8",
|
||||
"public, max-age=31536000, immutable",
|
||||
)
|
||||
}
|
||||
async fn lang_init_js() -> Response {
|
||||
static_response(
|
||||
LANG_INIT_JS,
|
||||
"application/javascript; charset=utf-8",
|
||||
"public, max-age=31536000, immutable",
|
||||
)
|
||||
}
|
||||
async fn styles_css() -> Response {
|
||||
static_response(
|
||||
STYLES_CSS,
|
||||
"text/css; charset=utf-8",
|
||||
"public, max-age=31536000, immutable",
|
||||
)
|
||||
}
|
||||
async fn manifest() -> Response {
|
||||
static_response(
|
||||
MANIFEST,
|
||||
"application/manifest+json",
|
||||
"public, max-age=3600",
|
||||
)
|
||||
}
|
||||
async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") }
|
||||
async fn app_js_legacy() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") }
|
||||
async fn theme_init_js() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "public, max-age=31536000, immutable") }
|
||||
async fn theme_init_js_legacy() -> Response { static_response(THEME_INIT_JS, "application/javascript; charset=utf-8", "no-cache") }
|
||||
async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "public, max-age=31536000, immutable") }
|
||||
async fn styles_css_legacy() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") }
|
||||
async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") }
|
||||
async fn service_worker() -> Response {
|
||||
let body = SERVICE_WORKER
|
||||
.replace("__GREE_ASSET_CACHE__", ASSET_BUILD_ID)
|
||||
.replace("__GREE_APP_ASSET__", APP_JS_ASSET_PATH)
|
||||
.replace("__GREE_THEME_INIT_ASSET__", THEME_INIT_ASSET_PATH)
|
||||
.replace("__GREE_LANG_INIT_ASSET__", LANG_INIT_ASSET_PATH)
|
||||
.replace("__GREE_STYLES_ASSET__", STYLES_CSS_ASSET_PATH);
|
||||
owned_response(body, "application/javascript; charset=utf-8", "no-cache")
|
||||
}
|
||||
async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") }
|
||||
async fn favicon() -> Response {
|
||||
static_response(FAVICON, "image/svg+xml", "public, max-age=86400")
|
||||
}
|
||||
async fn language_index() -> Response {
|
||||
static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache")
|
||||
static_response(
|
||||
LANGUAGE_MANIFEST_JSON,
|
||||
"application/json; charset=utf-8",
|
||||
"no-cache",
|
||||
)
|
||||
}
|
||||
async fn language_file(Path(file): Path<String>) -> Response {
|
||||
let code = file.strip_suffix(".json").unwrap_or(&file);
|
||||
if let Some((_, body)) = LANGUAGE_ASSETS.iter().find(|(language, _)| *language == code) {
|
||||
return static_response(*body, "application/json; charset=utf-8", "no-cache");
|
||||
if let Some((_, body)) = LANGUAGE_ASSETS
|
||||
.iter()
|
||||
.find(|(language, _)| *language == code)
|
||||
{
|
||||
return static_response(
|
||||
*body,
|
||||
"application/json; charset=utf-8",
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
}
|
||||
let mut response = Response::new(Body::from("Language not found"));
|
||||
*response.status_mut() = StatusCode::NOT_FOUND;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
async fn preset_index() -> Response {
|
||||
static_response(PRESET_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache")
|
||||
static_response(
|
||||
PRESET_MANIFEST_JSON,
|
||||
"application/json; charset=utf-8",
|
||||
"no-cache, no-store",
|
||||
)
|
||||
}
|
||||
async fn preset_file(Path(file): Path<String>) -> Response {
|
||||
if let Some((_, body)) = PRESET_ASSETS.iter().find(|(filename, _)| *filename == file) {
|
||||
@@ -78,19 +213,34 @@ async fn preset_file(Path(file): Path<String>) -> Response {
|
||||
}
|
||||
let mut response = Response::new(Body::from("Preset not found"));
|
||||
*response.status_mut() = StatusCode::NOT_FOUND;
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
fn static_response(body: &'static str, content_type: &'static str, cache: &'static str) -> Response {
|
||||
fn static_response(
|
||||
body: &'static str,
|
||||
content_type: &'static str,
|
||||
cache: &'static str,
|
||||
) -> Response {
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
}
|
||||
|
||||
fn owned_response(body: String, content_type: &'static str, cache: &'static str) -> Response {
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
|
||||
response
|
||||
}
|
||||
|
||||
+53
-13
@@ -1,4 +1,8 @@
|
||||
async fn debug_api_requests(State(state): State<AppState>, request: Request, next: Next) -> Response {
|
||||
async fn debug_api_requests(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if !state.settings.read().await.debug.overlay_enabled {
|
||||
return next.run(request).await;
|
||||
}
|
||||
@@ -6,17 +10,48 @@ async fn debug_api_requests(State(state): State<AppState>, request: Request, nex
|
||||
let path = request.uri().path().to_string();
|
||||
let started = Instant::now();
|
||||
let response = next.run(request).await;
|
||||
state.broadcast("api.request", json!({
|
||||
"method": method.as_str(),
|
||||
"path": path,
|
||||
"status": response.status().as_u16(),
|
||||
"duration_ms": started.elapsed().as_millis(),
|
||||
}));
|
||||
state.broadcast(
|
||||
"api.request",
|
||||
json!({
|
||||
"method": method.as_str(),
|
||||
"path": path,
|
||||
"status": response.status().as_u16(),
|
||||
"duration_ms": started.elapsed().as_millis(),
|
||||
}),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
async fn auth(State(state): State<AppState>, request: Request, next: Next) -> Result<Response, AppError> {
|
||||
fn trusted_home_assistant_ingress_parts(headers: &HeaderMap, peer: IpAddr) -> bool {
|
||||
let ingress_request = headers
|
||||
.get("x-ingress-path")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.is_some_and(|path| path.starts_with("/api/hassio_ingress/"));
|
||||
ingress_request && home_assistant::is_supervisor_ingress_peer(peer)
|
||||
}
|
||||
|
||||
fn trusted_home_assistant_ingress(request: &Request) -> bool {
|
||||
request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|info| trusted_home_assistant_ingress_parts(request.headers(), info.0.ip()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn auth(
|
||||
State(state): State<AppState>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
if trusted_home_assistant_ingress(&request) {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
|
||||
let expected = state.config.app_token.trim();
|
||||
if home_assistant::supervisor_token_detected() && expected.is_empty() {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
if expected.is_empty() {
|
||||
return Ok(next.run(request).await);
|
||||
}
|
||||
@@ -44,10 +79,17 @@ async fn home_assistant_auth(
|
||||
}
|
||||
|
||||
fn request_token(request: &Request) -> Option<String> {
|
||||
request.headers().get(header::AUTHORIZATION)
|
||||
request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "))
|
||||
.or_else(|| request.headers().get("x-api-token").and_then(|value| value.to_str().ok()))
|
||||
.or_else(|| {
|
||||
request
|
||||
.headers()
|
||||
.get("x-api-token")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
})
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
@@ -57,8 +99,6 @@ fn hash_token(token: &str) -> String {
|
||||
|
||||
fn generate_access_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
let mut rng = OsRng;
|
||||
rng.fill_bytes(&mut bytes);
|
||||
rand::fill(&mut bytes);
|
||||
format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
|
||||
+213
-53
@@ -21,38 +21,75 @@ struct AutomationInput {
|
||||
#[serde(default = "automation_cooldown")]
|
||||
cooldown_seconds: u64,
|
||||
}
|
||||
fn automation_cooldown() -> u64 { 300 }
|
||||
fn automation_cooldown() -> u64 {
|
||||
300
|
||||
}
|
||||
impl AutomationInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("automation name is required".into())); }
|
||||
if self.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("automation name is required".into()));
|
||||
}
|
||||
match self.trigger_kind.as_str() {
|
||||
"temperature_above" | "temperature_below" => {
|
||||
if self.trigger_device_id.as_deref().unwrap_or_default().is_empty() || self.threshold.is_none() {
|
||||
return Err(AppError::BadRequest("temperature trigger needs device and threshold".into()));
|
||||
if self
|
||||
.trigger_device_id
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
|| self.threshold.is_none()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"temperature trigger needs device and threshold".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
"time" => {
|
||||
let at = self.at_time.as_deref().ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("invalid automation time".into()))?;
|
||||
let at = self
|
||||
.at_time
|
||||
.as_deref()
|
||||
.ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(at, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("invalid automation time".into()))?;
|
||||
}
|
||||
_ => {
|
||||
return Err(AppError::BadRequest(
|
||||
"unsupported automation trigger".into(),
|
||||
))
|
||||
}
|
||||
_ => return Err(AppError::BadRequest("unsupported automation trigger".into())),
|
||||
}
|
||||
let action_group_id = self.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty());
|
||||
let action_group_id = self
|
||||
.action_group_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if action_group_id.is_none() && self.action_device_id.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("automation action needs a device or group".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation action needs a device or group".into(),
|
||||
));
|
||||
}
|
||||
if let Some(preset) = self.action_preset.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(preset) = self
|
||||
.action_preset
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if action_group_id.is_none() {
|
||||
return Err(AppError::BadRequest("automation preset actions require a group target".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation preset actions require a group target".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(preset, "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("unsupported group automation preset".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"unsupported group automation preset".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if action_group_id.is_some() {
|
||||
if let Some(mode) = self.action.mode.as_deref() {
|
||||
if !matches!(mode, "auto" | "house" | "cool" | "heat") {
|
||||
return Err(AppError::BadRequest("group automation mode must be house, cool or heat".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"group automation mode must be house, cool or heat".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.action.target_temperature.is_some()
|
||||
@@ -67,48 +104,121 @@ impl AutomationInput {
|
||||
|| self.action.health.is_some()
|
||||
|| self.action.sleep.is_some()
|
||||
{
|
||||
return Err(AppError::BadRequest("group automations support only power, heat/cool/house mode and a group preset".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"group automations support only power, heat/cool/house mode and a group preset"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if self.action.power.is_none() && self.action.mode.is_none() && self.action_preset.as_deref().map(str::trim).filter(|v| !v.is_empty()).is_none() {
|
||||
return Err(AppError::BadRequest("group automation action cannot be empty".into()));
|
||||
if self.action.power.is_none()
|
||||
&& self.action.mode.is_none()
|
||||
&& self
|
||||
.action_preset
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"group automation action cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
engine::validate_command(&self.action)?;
|
||||
if self.action.is_empty() {
|
||||
return Err(AppError::BadRequest("automation action cannot be empty".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation action cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn into_automation(self, id: String, created_at: chrono::DateTime<Utc>, last_fired_at: Option<chrono::DateTime<Utc>>) -> Automation {
|
||||
Automation { id, name: self.name.trim().into(), enabled: self.enabled,
|
||||
trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id.trim().to_string(),
|
||||
action_group_id: self.action_group_id.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
action_preset: self.action_preset.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()),
|
||||
action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at,
|
||||
action_zone_id: None, action_zone_preset: None, action_ha_domain: None, action_ha_service: None, action_ha_entity_id: None, action_ha_data: Value::Null, flow_conditions: vec![], flow_id: None, flow_node_id: None, flow_runtime: Default::default(),
|
||||
created_at, updated_at: Utc::now() }
|
||||
}
|
||||
}
|
||||
fn validate_automation_references(state: &AppState, input: &AutomationInput) -> Result<(), AppError> {
|
||||
if matches!(input.trigger_kind.as_str(), "temperature_above" | "temperature_below") {
|
||||
let trigger_id = input.trigger_device_id.as_deref().map(str::trim).unwrap_or_default();
|
||||
if state.db.get_device(trigger_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation trigger device does not exist".into()));
|
||||
fn into_automation(
|
||||
self,
|
||||
id: String,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
last_fired_at: Option<chrono::DateTime<Utc>>,
|
||||
) -> Automation {
|
||||
Automation {
|
||||
id,
|
||||
name: self.name.trim().into(),
|
||||
enabled: self.enabled,
|
||||
trigger_kind: self.trigger_kind,
|
||||
trigger_device_id: self
|
||||
.trigger_device_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
threshold: self.threshold,
|
||||
at_time: self.at_time,
|
||||
action_device_id: self.action_device_id.trim().to_string(),
|
||||
action_group_id: self
|
||||
.action_group_id
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
action_preset: self
|
||||
.action_preset
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
action: self.action,
|
||||
cooldown_seconds: self.cooldown_seconds.max(30),
|
||||
last_fired_at,
|
||||
action_zone_id: None,
|
||||
action_zone_preset: None,
|
||||
action_ha_domain: None,
|
||||
action_ha_service: None,
|
||||
action_ha_entity_id: None,
|
||||
action_ha_data: Value::Null,
|
||||
flow_conditions: vec![],
|
||||
flow_id: None,
|
||||
flow_node_id: None,
|
||||
flow_runtime: Default::default(),
|
||||
created_at,
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
if let Some(group_id) = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
}
|
||||
fn validate_automation_references(
|
||||
state: &AppState,
|
||||
input: &AutomationInput,
|
||||
) -> Result<(), AppError> {
|
||||
if matches!(
|
||||
input.trigger_kind.as_str(),
|
||||
"temperature_above" | "temperature_below"
|
||||
) {
|
||||
let trigger_id = input
|
||||
.trigger_device_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if state.db.get_device(trigger_id)?.is_none() {
|
||||
return Err(AppError::BadRequest(
|
||||
"automation trigger device does not exist".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(group_id) = input
|
||||
.action_group_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if state.db.get_group(group_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action group does not exist".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation action group does not exist".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let device_id = input.action_device_id.trim();
|
||||
if state.db.get_device(device_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("automation action device does not exist".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"automation action device does not exist".into(),
|
||||
));
|
||||
}
|
||||
if engine::automation_action_conflicts_with_thermostat(&input.action)
|
||||
&& state.db.list_zones()?.iter().any(|zone| zone.enabled && zone.device_id == device_id)
|
||||
&& state
|
||||
.db
|
||||
.list_zones()?
|
||||
.iter()
|
||||
.any(|zone| zone.enabled && zone.device_id == device_id)
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"direct fan/quiet/sleep automation conflicts with an enabled thermostat zone; use thermostat/group policy instead".into(),
|
||||
@@ -118,47 +228,97 @@ fn validate_automation_references(state: &AppState, input: &AutomationInput) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_automations(State(state): State<AppState>) -> Result<Json<Vec<Automation>>, AppError> { Ok(Json(state.db.list_automations()?)) }
|
||||
async fn get_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Automation>, AppError> {
|
||||
state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}")))
|
||||
async fn list_automations(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Automation>>, AppError> {
|
||||
Ok(Json(state.db.list_automations()?))
|
||||
}
|
||||
async fn create_automation(State(state): State<AppState>, Json(input): Json<AutomationInput>) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||
async fn get_automation(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Automation>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_automation(&id)?
|
||||
.map(Json)
|
||||
.ok_or_else(|| AppError::NotFound(format!("automation {id}")))
|
||||
}
|
||||
async fn create_automation(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<AutomationInput>,
|
||||
) -> Result<(StatusCode, Json<Automation>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
input.validate()?;
|
||||
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
||||
let action_group_id = input
|
||||
.action_group_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
|
||||
Some(state.lock_group_operation(group_id).await)
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.created", serde_json::to_value(&item)?);
|
||||
Ok((StatusCode::CREATED, Json(item)))
|
||||
}
|
||||
async fn update_automation(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<AutomationInput>) -> Result<Json<Automation>, AppError> {
|
||||
async fn update_automation(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<AutomationInput>,
|
||||
) -> Result<Json<Automation>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
input.validate()?;
|
||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; edit it in the Flow editor".into())); }
|
||||
let action_group_id = input.action_group_id.as_deref().map(str::trim).filter(|value| !value.is_empty()).map(str::to_string);
|
||||
let existing = state
|
||||
.db
|
||||
.get_automation(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if existing.flow_id.is_some() {
|
||||
return Err(AppError::BadRequest(
|
||||
"this automation is generated by Flow; edit it in the Flow editor".into(),
|
||||
));
|
||||
}
|
||||
let action_group_id = input
|
||||
.action_group_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
let _group_guard = if let Some(group_id) = action_group_id.as_deref() {
|
||||
Some(state.lock_group_operation(group_id).await)
|
||||
} else { None };
|
||||
} else {
|
||||
None
|
||||
};
|
||||
validate_automation_references(&state, &input)?;
|
||||
let item = input.into_automation(id, existing.created_at, existing.last_fired_at);
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
||||
Ok(Json(item))
|
||||
}
|
||||
async fn delete_automation(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
async fn delete_automation(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this automation is generated by Flow; delete it from the Flow editor".into())); }
|
||||
if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); }
|
||||
let existing = state
|
||||
.db
|
||||
.get_automation(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?;
|
||||
if existing.flow_id.is_some() {
|
||||
return Err(AppError::BadRequest(
|
||||
"this automation is generated by Flow; delete it from the Flow editor".into(),
|
||||
));
|
||||
}
|
||||
if !state.db.delete_automation(&id)? {
|
||||
return Err(AppError::NotFound(format!("automation {id}")));
|
||||
}
|
||||
state.broadcast("automation.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+19
-21
@@ -1,24 +1,11 @@
|
||||
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>,
|
||||
}
|
||||
|
||||
async fn list_access_tokens(State(state): State<AppState>) -> Result<Json<Vec<ApiTokenInfo>>, AppError> {
|
||||
async fn list_access_tokens(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<ApiTokenInfo>>, AppError> {
|
||||
Ok(Json(state.db.list_api_tokens()?))
|
||||
}
|
||||
|
||||
@@ -26,9 +13,15 @@ async fn create_access_token(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<CreateAccessTokenRequest>,
|
||||
) -> Result<(StatusCode, Json<Value>), AppError> {
|
||||
let name = input.name.unwrap_or_else(|| "Home Assistant".into()).trim().to_string();
|
||||
let name = input
|
||||
.name
|
||||
.unwrap_or_else(|| "Home Assistant".into())
|
||||
.trim()
|
||||
.to_string();
|
||||
if name.is_empty() || name.len() > 80 {
|
||||
return Err(AppError::BadRequest("token name must contain 1 to 80 characters".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"token name must contain 1 to 80 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let secret = generate_access_token();
|
||||
@@ -45,10 +38,16 @@ async fn create_access_token(
|
||||
"Created a Home Assistant access token",
|
||||
json!({"token_id": item.id.clone(), "name": item.name.clone()}),
|
||||
);
|
||||
Ok((StatusCode::CREATED, Json(json!({"token": secret, "item": item}))))
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(json!({"token": secret, "item": item})),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_access_token(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
async fn delete_access_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
if !state.db.delete_api_token(&id)? {
|
||||
return Err(AppError::NotFound(format!("access token {id}")));
|
||||
}
|
||||
@@ -60,4 +59,3 @@ async fn delete_access_token(State(state): State<AppState>, Path(id): Path<Strin
|
||||
);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeviceGroupInput {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
kind: DeviceGroupKind,
|
||||
#[serde(default)]
|
||||
device_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
energy_source: EnergySourcePreference,
|
||||
#[serde(default)]
|
||||
energy_device_id: Option<String>,
|
||||
#[serde(default)]
|
||||
ha_energy_entity_id: Option<String>,
|
||||
#[serde(default)]
|
||||
ha_energy_unit: Option<String>,
|
||||
#[serde(default)]
|
||||
ha_energy_device_class: Option<String>,
|
||||
#[serde(default)]
|
||||
ha_energy_state_class: Option<String>,
|
||||
#[serde(default)]
|
||||
outdoor_temperature_device_id: Option<String>,
|
||||
}
|
||||
|
||||
fn normalize_optional(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
}
|
||||
|
||||
fn validate_device_group_input(
|
||||
state: &AppState,
|
||||
input: &DeviceGroupInput,
|
||||
editing_id: Option<&str>,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
if input.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("installation name is required".into()));
|
||||
}
|
||||
let mut device_ids = input
|
||||
.device_ids
|
||||
.iter()
|
||||
.map(|id| id.trim().to_string())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
device_ids.sort();
|
||||
device_ids.dedup();
|
||||
if device_ids.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"installation must contain at least one device".into(),
|
||||
));
|
||||
}
|
||||
if input.kind == DeviceGroupKind::Split && device_ids.len() != 1 {
|
||||
return Err(AppError::BadRequest(
|
||||
"split installation must contain exactly one device".into(),
|
||||
));
|
||||
}
|
||||
for device_id in &device_ids {
|
||||
if state.db.get_device(device_id)?.is_none() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"installation references missing device {device_id}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for existing in state.db.list_device_groups()? {
|
||||
if editing_id == Some(existing.id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(device_id) = device_ids
|
||||
.iter()
|
||||
.find(|id| existing.device_ids.iter().any(|other| other == *id))
|
||||
{
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"device {device_id} already belongs to installation '{}'",
|
||||
existing.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
let energy_device_id = normalize_optional(input.energy_device_id.clone());
|
||||
if let Some(ref id) = energy_device_id {
|
||||
if !device_ids.iter().any(|device_id| device_id == id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"energy source device must belong to this installation".into(),
|
||||
));
|
||||
}
|
||||
let device = state
|
||||
.db
|
||||
.get_device(id)?
|
||||
.ok_or_else(|| AppError::BadRequest("energy source device does not exist".into()))?;
|
||||
if device.connection_type != ConnectionType::GreeCloud || !device.capabilities.energy_meter
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"selected device does not expose GREE Cloud energy".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if input.energy_source == EnergySourcePreference::GreeCloud && energy_device_id.is_none() {
|
||||
return Err(AppError::BadRequest(
|
||||
"select a GREE Cloud energy source device".into(),
|
||||
));
|
||||
}
|
||||
let entity = normalize_optional(input.ha_energy_entity_id.clone());
|
||||
if input.energy_source == EnergySourcePreference::HomeAssistant && entity.is_none() {
|
||||
return Err(AppError::BadRequest(
|
||||
"select a Home Assistant cumulative energy sensor".into(),
|
||||
));
|
||||
}
|
||||
if entity.is_some() {
|
||||
if input.ha_energy_device_class.as_deref() != Some("energy") {
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must have device_class=energy".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
input.ha_energy_state_class.as_deref(),
|
||||
Some("total" | "total_increasing")
|
||||
) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must have state_class=total or total_increasing"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
input
|
||||
.ha_energy_unit
|
||||
.as_deref()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("wh" | "kwh")
|
||||
) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must use Wh or kWh".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(id) = normalize_optional(input.outdoor_temperature_device_id.clone()) {
|
||||
if !device_ids.iter().any(|device_id| device_id == &id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"outdoor temperature source must belong to this installation".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(device_ids)
|
||||
}
|
||||
|
||||
fn device_group_from_input(
|
||||
id: String,
|
||||
existing: Option<DeviceGroup>,
|
||||
input: DeviceGroupInput,
|
||||
device_ids: Vec<String>,
|
||||
) -> DeviceGroup {
|
||||
let now = Utc::now();
|
||||
DeviceGroup {
|
||||
id,
|
||||
name: input.name.trim().to_string(),
|
||||
kind: input.kind,
|
||||
device_ids,
|
||||
energy_source: input.energy_source,
|
||||
energy_device_id: normalize_optional(input.energy_device_id),
|
||||
ha_energy_entity_id: normalize_optional(input.ha_energy_entity_id),
|
||||
ha_energy_unit: normalize_optional(input.ha_energy_unit),
|
||||
ha_energy_device_class: normalize_optional(input.ha_energy_device_class),
|
||||
ha_energy_state_class: normalize_optional(input.ha_energy_state_class),
|
||||
outdoor_temperature_device_id: normalize_optional(input.outdoor_temperature_device_id),
|
||||
created_at: existing.as_ref().map(|item| item.created_at).unwrap_or(now),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_device_groups(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<DeviceGroup>>, AppError> {
|
||||
Ok(Json(state.db.list_device_groups()?))
|
||||
}
|
||||
|
||||
async fn get_device_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<DeviceGroup>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_device_group(&id)?
|
||||
.map(Json)
|
||||
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))
|
||||
}
|
||||
|
||||
async fn create_device_group(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<DeviceGroupInput>,
|
||||
) -> Result<(StatusCode, Json<DeviceGroup>), AppError> {
|
||||
let _guard = state.lock_configuration_operation().await;
|
||||
let device_ids = validate_device_group_input(&state, &input, None)?;
|
||||
let group = device_group_from_input(Uuid::new_v4().to_string(), None, input, device_ids);
|
||||
state.db.save_device_group(&group)?;
|
||||
state.broadcast("device_group.created", serde_json::to_value(&group)?);
|
||||
Ok((StatusCode::CREATED, Json(group)))
|
||||
}
|
||||
|
||||
async fn update_device_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<DeviceGroupInput>,
|
||||
) -> Result<Json<DeviceGroup>, AppError> {
|
||||
let _guard = state.lock_configuration_operation().await;
|
||||
let existing = state
|
||||
.db
|
||||
.get_device_group(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device group {id}")))?;
|
||||
let device_ids = validate_device_group_input(&state, &input, Some(&id))?;
|
||||
let group = device_group_from_input(id, Some(existing), input, device_ids);
|
||||
state.db.save_device_group(&group)?;
|
||||
state.broadcast("device_group.updated", serde_json::to_value(&group)?);
|
||||
Ok(Json(group))
|
||||
}
|
||||
|
||||
async fn delete_device_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let _guard = state.lock_configuration_operation().await;
|
||||
if !state.db.delete_device_group(&id)? {
|
||||
return Err(AppError::NotFound(format!("device group {id}")));
|
||||
}
|
||||
state.broadcast("device_group.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
+598
-105
@@ -1,86 +1,88 @@
|
||||
async fn discover(State(state): State<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000);
|
||||
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
|
||||
let protocol_version = request.protocol_version.unwrap_or(0).min(2);
|
||||
let passes = request.passes.unwrap_or(3).clamp(1, 10);
|
||||
let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms), protocol_version, passes).await
|
||||
.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
let mut saved = Vec::new();
|
||||
let mut new_device_ids = Vec::new();
|
||||
for item in discovered {
|
||||
let existing = state.db.get_device_by_mac(&item.mac)?;
|
||||
let is_new = existing.is_none();
|
||||
let mut merged = merge_discovered(existing, item);
|
||||
let _device_guard = state.lock_device_operation(&merged.id).await;
|
||||
// A poll/command may have updated the same known device between discovery and
|
||||
// acquiring its operation lock. Re-merge against the freshest persisted state.
|
||||
if !is_new {
|
||||
if let Some(current) = state.db.get_device(&merged.id)? {
|
||||
merged = merge_discovered(Some(current), merged);
|
||||
}
|
||||
}
|
||||
// Bind right after discovery. GREE modules can have a short bind window;
|
||||
// bind() also refreshes it with a direct scan before the handshake.
|
||||
if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(&merged).await {
|
||||
Ok(bound) => {
|
||||
merged.key = Some(bound.key);
|
||||
merged.protocol_version = bound.protocol_version;
|
||||
merged.communication_failures = 0;
|
||||
merged.last_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
merged.last_error = Some(format!("discovered, bind pending: {err}"));
|
||||
state.log("warn", "device.bind_after_discovery", &format!("{}: {err}", merged.name), json!({"device_id": merged.id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
state.db.save_device(&merged)?;
|
||||
if is_new { new_device_ids.push(merged.id.clone()); }
|
||||
saved.push(merged);
|
||||
}
|
||||
state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()}));
|
||||
state.broadcast("devices.discovered", json!({"devices": saved}));
|
||||
Ok(Json(json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids})))
|
||||
fn normalize_local_discovery_mac(value: &str) -> String {
|
||||
value.replace([':', '-'], "").trim().to_ascii_uppercase()
|
||||
}
|
||||
|
||||
async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>, AppError> {
|
||||
Ok(Json(state.db.list_devices()?))
|
||||
fn local_discovery_candidate(
|
||||
device: &Device,
|
||||
already_added: bool,
|
||||
protocol_locked: bool,
|
||||
) -> LocalDiscoveryCandidate {
|
||||
LocalDiscoveryCandidate {
|
||||
name: device.name.clone(),
|
||||
mac: device.mac.clone(),
|
||||
ip: device.ip.clone(),
|
||||
port: device.port,
|
||||
protocol_version: device.protocol_version,
|
||||
protocol_locked,
|
||||
model: device.model.clone(),
|
||||
firmware: device.firmware.clone(),
|
||||
already_added,
|
||||
}
|
||||
}
|
||||
|
||||
async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDeviceRequest>) -> Result<(StatusCode, Json<Device>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name, mac and ip are required".into()));
|
||||
fn device_from_local_discovery(candidate: LocalDiscoveryCandidate) -> Result<Device, AppError> {
|
||||
let mac = normalize_local_discovery_mac(&candidate.mac);
|
||||
if mac.is_empty() {
|
||||
return Err(AppError::BadRequest("discovered device MAC is required".into()));
|
||||
}
|
||||
input.ip.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?;
|
||||
if state.db.get_device_by_mac(&input.mac)?.is_some() {
|
||||
return Err(AppError::BadRequest("a device with this MAC already exists".into()));
|
||||
candidate
|
||||
.ip
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| AppError::BadRequest(format!("invalid IP address for {mac}")))?;
|
||||
if candidate.protocol_version > 2 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"invalid protocol version for {mac}"
|
||||
)));
|
||||
}
|
||||
if candidate.protocol_locked && candidate.protocol_version == 0 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"locked discovery protocol is missing for {mac}"
|
||||
)));
|
||||
}
|
||||
|
||||
let model = candidate.model.trim().to_string();
|
||||
let fallback_model = if model.is_empty() { "GREE" } else { &model };
|
||||
let suffix = mac
|
||||
.chars()
|
||||
.rev()
|
||||
.take(4)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
let name = if candidate.name.trim().is_empty() {
|
||||
format!("{fallback_model} {suffix}")
|
||||
} else {
|
||||
candidate.name.trim().to_string()
|
||||
};
|
||||
let now = Utc::now();
|
||||
let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase();
|
||||
let device = Device {
|
||||
id: format!("gree-{}", normalized_mac.to_ascii_lowercase()),
|
||||
mac: normalized_mac,
|
||||
name: input.name.trim().to_string(),
|
||||
ip: input.ip,
|
||||
port: input.port,
|
||||
protocol_version: input.protocol_version.min(2),
|
||||
model: String::new(),
|
||||
firmware: String::new(),
|
||||
key: input.key.filter(|v| !v.trim().is_empty()),
|
||||
|
||||
Ok(Device {
|
||||
id: format!("gree-{}", mac.to_ascii_lowercase()),
|
||||
mac,
|
||||
name,
|
||||
connection_type: ConnectionType::Local,
|
||||
connection_status: ConnectionStatus::Unknown,
|
||||
cloud_device_id: None,
|
||||
cloud_parent_mac: None,
|
||||
cloud_account_id: None,
|
||||
ip: candidate.ip,
|
||||
port: if candidate.port == 0 { 7000 } else { candidate.port },
|
||||
protocol_version: candidate.protocol_version,
|
||||
model,
|
||||
firmware: candidate.firmware.trim().to_string(),
|
||||
key: None,
|
||||
cid: Some("app".into()),
|
||||
enabled: true,
|
||||
simulated: input.simulated,
|
||||
simulated: false,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 24.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: false,
|
||||
swing_horizontal: false,
|
||||
swing_vertical: 0,
|
||||
swing_horizontal: 0,
|
||||
quiet: false,
|
||||
quiet_wire_value: None,
|
||||
turbo: false,
|
||||
light: true,
|
||||
air: false,
|
||||
@@ -94,6 +96,243 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
|
||||
supports_xfan: None,
|
||||
supports_health: None,
|
||||
supports_sleep: None,
|
||||
supports_buzzer_control: None,
|
||||
supports_energy_meter: None,
|
||||
total_energy_kwh: None,
|
||||
compressor_frequency_hz: None,
|
||||
last_cloud_sync: None,
|
||||
current_temperature: None,
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
online: true,
|
||||
response_time_ms: None,
|
||||
last_seen: Some(now),
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
pending_command: false,
|
||||
capabilities: crate::models::DeviceCapabilities::default(),
|
||||
energy_source: EnergySourcePreference::Auto,
|
||||
ha_energy_entity_id: None,
|
||||
ha_energy_unit: None,
|
||||
ha_energy_device_class: None,
|
||||
ha_energy_state_class: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_local_discovery(
|
||||
state: &AppState,
|
||||
request: DiscoveryRequest,
|
||||
) -> Result<(u8, u8, Vec<Device>), AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let timeout_ms = request
|
||||
.timeout_ms
|
||||
.unwrap_or(settings.discovery_timeout_ms)
|
||||
.clamp(500, 30_000);
|
||||
let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast);
|
||||
let protocol_version = request.protocol_version.unwrap_or(0);
|
||||
if protocol_version > 2 {
|
||||
return Err(AppError::BadRequest("protocol_version must be 0, 1 or 2".into()));
|
||||
}
|
||||
let passes = request.passes.unwrap_or(3).clamp(1, 10);
|
||||
let discovered = state
|
||||
.providers
|
||||
.local()
|
||||
.client()
|
||||
.discover(
|
||||
&broadcast,
|
||||
Duration::from_millis(timeout_ms),
|
||||
protocol_version,
|
||||
passes,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
Ok((protocol_version, passes, discovered))
|
||||
}
|
||||
|
||||
/// Scan for local GREE units without persisting or binding them.
|
||||
async fn scan_discovery(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<DiscoveryRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let (protocol_version, passes, discovered) = run_local_discovery(&state, request).await?;
|
||||
let mut candidates = Vec::with_capacity(discovered.len());
|
||||
for device in discovered {
|
||||
let mac = normalize_local_discovery_mac(&device.mac);
|
||||
let already_added = state.db.get_device_by_mac(&mac)?.is_some();
|
||||
candidates.push(local_discovery_candidate(
|
||||
&device,
|
||||
already_added,
|
||||
protocol_version != 0,
|
||||
));
|
||||
}
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
"discovery.scan_complete",
|
||||
&format!("Discovery scan found {} device(s)", candidates.len()),
|
||||
json!({
|
||||
"count": candidates.len(),
|
||||
"protocol_version": protocol_version,
|
||||
"passes": passes,
|
||||
"persisted": false,
|
||||
}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"count": candidates.len(),
|
||||
"devices": candidates,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn add_discovered_devices(
|
||||
State(state): State<AppState>,
|
||||
Json(request): Json<AddDiscoveredDevicesRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
if request.devices.is_empty() {
|
||||
return Err(AppError::BadRequest("select at least one discovered device".into()));
|
||||
}
|
||||
if request.devices.len() > 64 {
|
||||
return Err(AppError::BadRequest("too many discovered devices selected".into()));
|
||||
}
|
||||
|
||||
let selected_count = request.devices.len();
|
||||
let mut added = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
|
||||
for candidate in request.devices {
|
||||
let protocol_locked = candidate.protocol_locked;
|
||||
let mut device = device_from_local_discovery(candidate)?;
|
||||
let _device_guard = state.lock_device_operation(&device.id).await;
|
||||
if state.db.get_device_by_mac(&device.mac)?.is_some() {
|
||||
skipped.push(device.mac.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
let client = state.providers.local().client();
|
||||
let bind_result = if protocol_locked {
|
||||
client.bind_exact(&device, device.protocol_version).await
|
||||
} else {
|
||||
// Auto discovery only provides a protocol hint. Try that generation
|
||||
// first, fall back to the other one, and persist the protocol that
|
||||
// actually completes binding.
|
||||
client.bind(&device).await
|
||||
};
|
||||
|
||||
match bind_result {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
device.communication_failures = 0;
|
||||
device.last_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
device.last_error = Some(format!("added, bind pending: {err}"));
|
||||
state.log(
|
||||
"warn",
|
||||
"device.bind_after_discovery",
|
||||
&format!("{}: {err}", device.name),
|
||||
json!({
|
||||
"device_id": device.id,
|
||||
"protocol_version": device.protocol_version,
|
||||
"protocol_locked": protocol_locked,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state.db.save_device(&device)?;
|
||||
added.push(device);
|
||||
}
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
"discovery.devices_added",
|
||||
&format!("Added {} discovered device(s)", added.len()),
|
||||
json!({
|
||||
"selected": selected_count,
|
||||
"added": added.len(),
|
||||
"skipped": skipped.len(),
|
||||
}),
|
||||
);
|
||||
if !added.is_empty() {
|
||||
state.broadcast("devices.discovered", json!({"devices": added}));
|
||||
}
|
||||
Ok(Json(json!({
|
||||
"count": added.len(),
|
||||
"devices": added,
|
||||
"skipped_macs": skipped,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn list_devices(State(state): State<AppState>) -> Result<Json<Vec<Device>>, AppError> {
|
||||
Ok(Json(state.db.list_devices()?))
|
||||
}
|
||||
|
||||
async fn add_device(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ManualDeviceRequest>,
|
||||
) -> Result<(StatusCode, Json<Device>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("name, mac and ip are required".into()));
|
||||
}
|
||||
input
|
||||
.ip
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| AppError::BadRequest("invalid IP address".into()))?;
|
||||
if state.db.get_device_by_mac(&input.mac)?.is_some() {
|
||||
return Err(AppError::BadRequest(
|
||||
"a device with this MAC already exists".into(),
|
||||
));
|
||||
}
|
||||
let now = Utc::now();
|
||||
let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase();
|
||||
let device = Device {
|
||||
id: format!("gree-{}", normalized_mac.to_ascii_lowercase()),
|
||||
mac: normalized_mac,
|
||||
name: input.name.trim().to_string(),
|
||||
connection_type: ConnectionType::Local,
|
||||
connection_status: ConnectionStatus::Unknown,
|
||||
cloud_device_id: None,
|
||||
cloud_parent_mac: None,
|
||||
cloud_account_id: None,
|
||||
ip: input.ip,
|
||||
port: input.port,
|
||||
protocol_version: input.protocol_version.min(2),
|
||||
model: String::new(),
|
||||
firmware: String::new(),
|
||||
key: input.key.filter(|v| !v.trim().is_empty()),
|
||||
cid: Some("app".into()),
|
||||
enabled: true,
|
||||
simulated: input.simulated,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 24.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: 0,
|
||||
swing_horizontal: 0,
|
||||
quiet: false,
|
||||
quiet_wire_value: None,
|
||||
turbo: false,
|
||||
light: true,
|
||||
air: false,
|
||||
xfan: false,
|
||||
health: false,
|
||||
sleep: false,
|
||||
supports_light: None,
|
||||
supports_quiet: None,
|
||||
supports_turbo: None,
|
||||
supports_air: None,
|
||||
supports_xfan: None,
|
||||
supports_health: None,
|
||||
supports_sleep: None,
|
||||
supports_buzzer_control: None,
|
||||
supports_energy_meter: None,
|
||||
total_energy_kwh: None,
|
||||
compressor_frequency_hz: None,
|
||||
last_cloud_sync: None,
|
||||
current_temperature: if input.simulated { Some(25.0) } else { None },
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
@@ -102,29 +341,75 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
|
||||
last_seen: if input.simulated { Some(now) } else { None },
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
pending_command: false,
|
||||
capabilities: crate::models::DeviceCapabilities::default(),
|
||||
energy_source: crate::models::EnergySourcePreference::Auto,
|
||||
ha_energy_entity_id: None,
|
||||
ha_energy_unit: None,
|
||||
ha_energy_device_class: None,
|
||||
ha_energy_state_class: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.db.save_device(&device)?;
|
||||
state.log("info", "device.created", &format!("Added {}", device.name), json!({"device_id": device.id}));
|
||||
state.log(
|
||||
"info",
|
||||
"device.created",
|
||||
&format!("Added {}", device.name),
|
||||
json!({"device_id": device.id}),
|
||||
);
|
||||
state.broadcast("device.created", serde_json::to_value(&device)?);
|
||||
Ok((StatusCode::CREATED, Json(device)))
|
||||
}
|
||||
|
||||
async fn get_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
|
||||
state.db.get_device(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device {id}")))
|
||||
async fn get_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Device>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.map(Json)
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))
|
||||
}
|
||||
|
||||
async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<DevicePatch>) -> Result<Json<Device>, AppError> {
|
||||
async fn patch_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<DevicePatch>,
|
||||
) -> Result<Json<Device>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
if patch.enabled == Some(false) {
|
||||
engine::disable_device_safely(&state, &id).await?;
|
||||
}
|
||||
let _device_guard = state.lock_device_operation(&id).await;
|
||||
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } }
|
||||
if let Some(v) = patch.ip { v.parse::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; }
|
||||
if let Some(v) = patch.port { device.port = v; }
|
||||
let mut device = state
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.connection_type == ConnectionType::GreeCloud
|
||||
&& (patch.ip.is_some()
|
||||
|| patch.port.is_some()
|
||||
|| patch.protocol_version.is_some()
|
||||
|| patch.key.is_some())
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"IP, UDP port, local protocol and local key are not configurable for GREE Cloud devices".into(),
|
||||
));
|
||||
}
|
||||
if let Some(v) = patch.name {
|
||||
if !v.trim().is_empty() {
|
||||
device.name = v.trim().to_string();
|
||||
}
|
||||
}
|
||||
if let Some(v) = patch.ip {
|
||||
v.parse::<IpAddr>()
|
||||
.map_err(|_| AppError::BadRequest("invalid IP address".into()))?;
|
||||
device.ip = v;
|
||||
}
|
||||
if let Some(v) = patch.port {
|
||||
device.port = v;
|
||||
}
|
||||
if let Some(v) = patch.protocol_version {
|
||||
let v = v.min(2);
|
||||
if device.protocol_version != v {
|
||||
@@ -139,8 +424,74 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
device.supports_sleep = None;
|
||||
}
|
||||
}
|
||||
if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); }
|
||||
if let Some(v) = patch.enabled { device.enabled = v; }
|
||||
if let Some(v) = patch.key {
|
||||
device.key = v.filter(|x| !x.trim().is_empty());
|
||||
}
|
||||
if let Some(v) = patch.enabled {
|
||||
device.enabled = v;
|
||||
}
|
||||
if let Some(v) = patch.energy_source {
|
||||
device.energy_source = v;
|
||||
}
|
||||
if let Some(v) = patch.ha_energy_entity_id {
|
||||
device.ha_energy_entity_id = v.filter(|x| !x.trim().is_empty());
|
||||
}
|
||||
if let Some(v) = patch.ha_energy_unit {
|
||||
device.ha_energy_unit = v.filter(|x| !x.trim().is_empty());
|
||||
}
|
||||
if let Some(v) = patch.ha_energy_device_class {
|
||||
device.ha_energy_device_class = v.filter(|x| !x.trim().is_empty());
|
||||
}
|
||||
if let Some(v) = patch.ha_energy_state_class {
|
||||
device.ha_energy_state_class = v.filter(|x| !x.trim().is_empty());
|
||||
}
|
||||
device.refresh_capabilities();
|
||||
if device.energy_source == EnergySourcePreference::GreeCloud
|
||||
&& !device.capabilities.energy_meter
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"GREE Cloud energy is not available for this device".into(),
|
||||
));
|
||||
}
|
||||
if device.energy_source == EnergySourcePreference::HomeAssistant
|
||||
&& device
|
||||
.ha_energy_entity_id
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"select a Home Assistant cumulative energy sensor first".into(),
|
||||
));
|
||||
}
|
||||
if device.ha_energy_entity_id.is_some() {
|
||||
if device.ha_energy_device_class.as_deref() != Some("energy") {
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must have device_class=energy".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
device.ha_energy_state_class.as_deref(),
|
||||
Some("total" | "total_increasing")
|
||||
) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must have state_class=total or total_increasing"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
device
|
||||
.ha_energy_unit
|
||||
.as_deref()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref(),
|
||||
Some("wh" | "kwh")
|
||||
) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Home Assistant energy sensor must use Wh or kWh".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&device)?;
|
||||
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
||||
@@ -151,7 +502,10 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
Ok(Json(device))
|
||||
}
|
||||
|
||||
async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
async fn delete_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
// Keep reference validation and the destructive DB operation in one serialized window.
|
||||
// Lock order for cross-resource destructive operations: configuration -> automation -> house -> schedule -> cycle -> zones -> device.
|
||||
@@ -159,14 +513,25 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
if state.db.get_device(&id)?.is_none() { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
if state.db.list_automations()?.iter().any(|item| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|| (item.action_group_id.is_none() && item.action_device_id == id)
|
||||
}) {
|
||||
return Err(AppError::BadRequest("device is used by an automation; remove or retarget that automation first".into()));
|
||||
let device = state
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
let automations = state.db.list_automations()?;
|
||||
if device.connection_type == ConnectionType::Local
|
||||
&& automations.iter().any(|item| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|| (item.action_group_id.is_none() && item.action_device_id == id)
|
||||
})
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"device is used by an automation; remove or retarget that automation first".into(),
|
||||
));
|
||||
}
|
||||
let removed_zone_ids: std::collections::HashSet<String> = state.db.list_zones()?.into_iter()
|
||||
let removed_zone_ids: std::collections::HashSet<String> = state
|
||||
.db
|
||||
.list_zones()?
|
||||
.into_iter()
|
||||
.filter(|zone| zone.device_id == id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
@@ -176,46 +541,174 @@ async fn delete_device(State(state): State<AppState>, Path(id): Path<String>) ->
|
||||
for zone_id in &sorted_zone_ids {
|
||||
zone_guards.push(state.lock_zone_operation(zone_id).await);
|
||||
}
|
||||
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
|
||||
if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); }
|
||||
if device.connection_type == ConnectionType::Local {
|
||||
ensure_zone_removal_safe(&state, &removed_zone_ids)?;
|
||||
ensure_device_stopped_for_detach(&state, &id, "device.deleted").await?;
|
||||
} else {
|
||||
// Cloud removal must remain possible even when the physical unit is offline. Remove
|
||||
// controller-only references that would otherwise block deletion, but never send an
|
||||
// OFF/status request and never depend on MQTT. Local keeps the historical safeguards.
|
||||
let groups = state.db.list_groups()?;
|
||||
let emptied_group_ids: std::collections::HashSet<String> = groups
|
||||
.iter()
|
||||
.filter(|group| {
|
||||
!group.zone_ids.is_empty()
|
||||
&& group
|
||||
.zone_ids
|
||||
.iter()
|
||||
.all(|zone_id| removed_zone_ids.contains(zone_id))
|
||||
})
|
||||
.map(|group| group.id.clone())
|
||||
.collect();
|
||||
for automation in automations.iter().filter(|item| {
|
||||
item.trigger_device_id.as_deref() == Some(id.as_str())
|
||||
|| (item.action_group_id.is_none() && item.action_device_id == id)
|
||||
|| item
|
||||
.action_group_id
|
||||
.as_ref()
|
||||
.is_some_and(|group_id| emptied_group_ids.contains(group_id))
|
||||
|| item
|
||||
.action_zone_id
|
||||
.as_ref()
|
||||
.is_some_and(|zone_id| removed_zone_ids.contains(zone_id))
|
||||
}) {
|
||||
if state.db.delete_automation(&automation.id)? {
|
||||
state.broadcast("automation.deleted", json!({"id": automation.id.clone()}));
|
||||
}
|
||||
}
|
||||
state.providers.cloud().unregister_device(&id).await;
|
||||
}
|
||||
if !state.db.delete_device(&id)? {
|
||||
return Err(AppError::NotFound(format!("device {id}")));
|
||||
}
|
||||
drop(zone_guards);
|
||||
remove_zone_ids_from_groups_locked(&state, &removed_zone_ids).await?;
|
||||
state.log("info", "device.deleted", "Device deleted", json!({"device_id": id}));
|
||||
state.log(
|
||||
"info",
|
||||
"device.deleted",
|
||||
"Device deleted",
|
||||
json!({"device_id": id, "connection_type": device.connection_type}),
|
||||
);
|
||||
state.broadcast("device.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
|
||||
async fn bind_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Device>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _device_guard = state.lock_device_operation(&id).await;
|
||||
let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.simulated { return Ok(Json(device)); }
|
||||
let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
let mut device = state
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.connection_type == ConnectionType::GreeCloud {
|
||||
return Err(AppError::BadRequest(
|
||||
"bind is only available for Local/LAN devices".into(),
|
||||
));
|
||||
}
|
||||
if device.simulated {
|
||||
return Ok(Json(device));
|
||||
}
|
||||
let bound = state
|
||||
.providers
|
||||
.local()
|
||||
.bind(&device)
|
||||
.await
|
||||
.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
device.communication_failures = 0;
|
||||
device.online = true;
|
||||
device.connection_status = ConnectionStatus::Online;
|
||||
device.last_seen = Some(Utc::now());
|
||||
device.last_error = None;
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&device)?;
|
||||
state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": id}));
|
||||
state.broadcast("device.updated", serde_json::to_value(&device)?);
|
||||
state.log(
|
||||
"info",
|
||||
"device.bound",
|
||||
&format!("Bound {}", device.name),
|
||||
json!({"device_id": id}),
|
||||
);
|
||||
Ok(Json(device))
|
||||
}
|
||||
|
||||
async fn poll_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, AppError> {
|
||||
async fn poll_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(engine::poll_one(&state, &id).await?))
|
||||
}
|
||||
|
||||
async fn command_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(engine::send_manual_command(&state, &id, command, "device.manual_control").await?))
|
||||
}
|
||||
|
||||
async fn command_home_assistant_device(State(state): State<AppState>, Path(id): Path<String>, Json(command): Json<DeviceCommand>) -> Result<Json<Device>, AppError> {
|
||||
if state.db.list_zones()?.iter().any(|zone| zone.device_id == id && !zone.enabled) {
|
||||
return Err(AppError::BadRequest("device belongs to a disabled thermostat zone; use technical device control for manual operation".into()));
|
||||
async fn probe_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let device = state
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.connection_type == ConnectionType::GreeCloud {
|
||||
return Err(AppError::BadRequest(
|
||||
"UDP probe is only available for Local/LAN devices".into(),
|
||||
));
|
||||
}
|
||||
Ok(Json(engine::send_manual_command(&state, &id, command, "home_assistant.device_manual_control").await?))
|
||||
let response_time_ms = state
|
||||
.providers
|
||||
.local()
|
||||
.client()
|
||||
.probe(&device)
|
||||
.await
|
||||
.map_err(|err| AppError::Device(err.to_string()))?;
|
||||
Ok(Json(json!({
|
||||
"device_id": device.id,
|
||||
"response_time_ms": response_time_ms,
|
||||
"ok": true
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManualDeviceCommandRequest {
|
||||
#[serde(flatten)]
|
||||
command: DeviceCommand,
|
||||
#[serde(default)]
|
||||
manual_override: bool,
|
||||
}
|
||||
|
||||
async fn command_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(request): Json<ManualDeviceCommandRequest>,
|
||||
) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(
|
||||
engine::send_manual_command(
|
||||
&state,
|
||||
&id,
|
||||
request.command,
|
||||
"device.manual_control",
|
||||
request.manual_override,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn command_home_assistant_device(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(command): Json<DeviceCommand>,
|
||||
) -> Result<Json<Device>, AppError> {
|
||||
Ok(Json(
|
||||
engine::send_manual_command(
|
||||
&state,
|
||||
&id,
|
||||
command,
|
||||
"home_assistant.device_manual_control",
|
||||
false,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
+9
-26
@@ -1,29 +1,12 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
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))?})))
|
||||
struct EventsQuery {
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[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 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))?}),
|
||||
))
|
||||
}
|
||||
|
||||
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})))
|
||||
}
|
||||
|
||||
|
||||
+1747
-347
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
fn cloud_debug_event(state: &AppState, data: Value) {
|
||||
if state.debug_cloud_requests.load(Ordering::Relaxed) {
|
||||
state.broadcast_with_control_plan_invalidation("gree_cloud.request", data, false);
|
||||
}
|
||||
}
|
||||
|
||||
fn cloud_error_kind(error: &anyhow::Error) -> &'static str {
|
||||
let text = format!("{error:#}").to_ascii_lowercase();
|
||||
if text.contains("authentication failed") || text.contains("login failed") {
|
||||
"authentication_error"
|
||||
} else if text.contains("timeout") || text.contains("timed out") {
|
||||
"timeout"
|
||||
} else if text.contains("http 5") || text.contains("service unavailable") {
|
||||
"api_unavailable"
|
||||
} else if text.contains("connect") || text.contains("dns") || text.contains("network") {
|
||||
"network_error"
|
||||
} else {
|
||||
"api_error"
|
||||
}
|
||||
}
|
||||
|
||||
async fn cloud_api_from_settings(
|
||||
state: &AppState,
|
||||
) -> Result<crate::protocol::gree_cloud::GreeCloudApi, AppError> {
|
||||
let settings = state.settings.read().await.gree_cloud.clone();
|
||||
crate::protocol::gree_cloud::GreeCloudApi::for_region(
|
||||
state.http.clone(),
|
||||
&settings.region,
|
||||
&settings.username,
|
||||
&settings.password,
|
||||
)
|
||||
.map_err(|err| AppError::BadRequest(err.to_string()))
|
||||
}
|
||||
|
||||
async fn test_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let mut api = cloud_api_from_settings(&state).await?;
|
||||
let started = Instant::now();
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({"operation":"test_connection","phase":"sent"}),
|
||||
);
|
||||
let result = async {
|
||||
api.login().await?;
|
||||
let devices = api.get_all_devices().await?;
|
||||
Ok::<usize, anyhow::Error>(devices.len())
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(device_count) => {
|
||||
let now = Utc::now();
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({
|
||||
"operation":"test_connection",
|
||||
"phase":"response",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"device_count": device_count,
|
||||
}),
|
||||
);
|
||||
{
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.gree_cloud.last_successful_contact = Some(now);
|
||||
settings.gree_cloud.last_rest_response_time_ms =
|
||||
Some(started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
}
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.login_success",
|
||||
"GREE Cloud connection test succeeded",
|
||||
json!({"device_count": device_count}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"status": "connected",
|
||||
"device_count": device_count,
|
||||
"last_successful_contact": now,
|
||||
})))
|
||||
}
|
||||
Err(error) => {
|
||||
let kind = cloud_error_kind(&error);
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({
|
||||
"operation":"test_connection",
|
||||
"phase":"error",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": kind,
|
||||
}),
|
||||
);
|
||||
tracing::warn!(kind, "GREE Cloud connection test failed");
|
||||
state.log(
|
||||
"warn",
|
||||
"gree_cloud.login_failure",
|
||||
"GREE Cloud connection test failed",
|
||||
json!({"kind": kind}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"ok": false,
|
||||
"status": kind,
|
||||
"message": match kind {
|
||||
"authentication_error" => "Invalid GREE Cloud login/password or authorization was rejected",
|
||||
"timeout" => "GREE Cloud request timed out",
|
||||
"network_error" => "Cannot reach GREE Cloud",
|
||||
"api_unavailable" => "GREE Cloud API is temporarily unavailable",
|
||||
_ => "GREE Cloud returned an unexpected response",
|
||||
}
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn discover_gree_cloud_devices(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let mut api = cloud_api_from_settings(&state).await?;
|
||||
let started = Instant::now();
|
||||
cloud_debug_event(&state, json!({"operation":"discovery","phase":"sent"}));
|
||||
api.login().await.map_err(|err| {
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({
|
||||
"operation":"discovery",
|
||||
"phase":"error",
|
||||
"stage":"login",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": cloud_error_kind(&err),
|
||||
}),
|
||||
);
|
||||
AppError::Dependency(format!("GREE Cloud login failed: {err}"))
|
||||
})?;
|
||||
let devices = api.get_all_devices().await.map_err(|err| {
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({
|
||||
"operation":"discovery",
|
||||
"phase":"error",
|
||||
"stage":"devices",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": cloud_error_kind(&err),
|
||||
}),
|
||||
);
|
||||
AppError::Dependency(format!("GREE Cloud discovery failed: {err}"))
|
||||
})?;
|
||||
let rest_duration_ms = started.elapsed().as_millis().min(u64::MAX as u128) as u64;
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({
|
||||
"operation":"discovery",
|
||||
"phase":"response",
|
||||
"duration_ms": rest_duration_ms,
|
||||
"device_count": devices.len(),
|
||||
}),
|
||||
);
|
||||
{
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.gree_cloud.last_successful_contact = Some(Utc::now());
|
||||
settings.gree_cloud.last_rest_response_time_ms = Some(rest_duration_ms);
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
}
|
||||
let existing = state.db.list_devices()?;
|
||||
let views = devices
|
||||
.into_iter()
|
||||
.map(|device| {
|
||||
let id = device.mac.replace([':', '-'], "").to_ascii_uppercase();
|
||||
let already_added = existing.iter().any(|saved| {
|
||||
saved.connection_type == ConnectionType::GreeCloud
|
||||
&& saved
|
||||
.cloud_device_id
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(&id))
|
||||
});
|
||||
crate::protocol::gree_cloud::CloudDeviceView {
|
||||
parent_mac: crate::protocol::gree_cloud::parent_mac(&id),
|
||||
id: id.clone(),
|
||||
name: device.name,
|
||||
mac: id,
|
||||
model: device.model,
|
||||
version: device.version,
|
||||
online: device.online,
|
||||
already_added,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.discovery",
|
||||
&format!("GREE Cloud discovery found {} device(s)", views.len()),
|
||||
json!({"count": views.len()}),
|
||||
);
|
||||
Ok(Json(json!({"count": views.len(), "devices": views})))
|
||||
}
|
||||
|
||||
async fn add_gree_cloud_device(
|
||||
State(state): State<AppState>,
|
||||
Path(cloud_id): Path<String>,
|
||||
) -> Result<(StatusCode, Json<Device>), AppError> {
|
||||
let cloud_id = cloud_id.replace([':', '-'], "").to_ascii_uppercase();
|
||||
if cloud_id.is_empty() {
|
||||
return Err(AppError::BadRequest("cloud device id is required".into()));
|
||||
}
|
||||
if state.db.list_devices()?.iter().any(|device| {
|
||||
device.connection_type == ConnectionType::GreeCloud
|
||||
&& device.cloud_device_id.as_deref() == Some(cloud_id.as_str())
|
||||
}) {
|
||||
return Err(AppError::Conflict(
|
||||
"this GREE Cloud device is already added".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Re-discover server-side so the frontend never needs to submit/store the device cipher key.
|
||||
let mut api = cloud_api_from_settings(&state).await?;
|
||||
let started = Instant::now();
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({
|
||||
"operation":"add_device_lookup",
|
||||
"phase":"sent",
|
||||
"cloud_device_id": cloud_id.clone(),
|
||||
}),
|
||||
);
|
||||
api.login().await.map_err(|err| {
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({
|
||||
"operation":"add_device_lookup",
|
||||
"phase":"error",
|
||||
"stage":"login",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": cloud_error_kind(&err),
|
||||
}),
|
||||
);
|
||||
AppError::Dependency(format!("GREE Cloud login failed: {err}"))
|
||||
})?;
|
||||
let cloud_device = api
|
||||
.get_all_devices()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({
|
||||
"operation":"add_device_lookup",
|
||||
"phase":"error",
|
||||
"stage":"devices",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"kind": cloud_error_kind(&err),
|
||||
}),
|
||||
);
|
||||
AppError::Dependency(format!("GREE Cloud discovery failed: {err}"))
|
||||
})?
|
||||
.into_iter()
|
||||
.find(|device| device.mac.eq_ignore_ascii_case(&cloud_id))
|
||||
.ok_or_else(|| AppError::NotFound(format!("GREE Cloud device {cloud_id}")))?;
|
||||
cloud_debug_event(
|
||||
&state,
|
||||
json!({
|
||||
"operation":"add_device_lookup",
|
||||
"phase":"response",
|
||||
"duration_ms": started.elapsed().as_millis() as u64,
|
||||
"cloud_device_id": cloud_id.clone(),
|
||||
}),
|
||||
);
|
||||
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let now = Utc::now();
|
||||
let account_id = state.settings.read().await.gree_cloud.account_id.clone();
|
||||
let normalized_cloud_mac = cloud_device
|
||||
.mac
|
||||
.replace([':', '-'], "")
|
||||
.to_ascii_uppercase();
|
||||
let device = Device {
|
||||
id: format!("gree-cloud-{}", normalized_cloud_mac.to_ascii_lowercase()),
|
||||
mac: normalized_cloud_mac.clone(),
|
||||
name: if cloud_device.name.trim().is_empty() {
|
||||
format!("GREE Cloud {}", &normalized_cloud_mac)
|
||||
} else {
|
||||
cloud_device.name.clone()
|
||||
},
|
||||
connection_type: ConnectionType::GreeCloud,
|
||||
connection_status: ConnectionStatus::CloudDisconnected,
|
||||
cloud_device_id: Some(normalized_cloud_mac.clone()),
|
||||
cloud_parent_mac: Some(crate::protocol::gree_cloud::parent_mac(
|
||||
&normalized_cloud_mac,
|
||||
)),
|
||||
cloud_account_id: Some(account_id),
|
||||
ip: String::new(),
|
||||
port: 0,
|
||||
// The reference HA integration currently creates CloudDevice with cipher_version=1.
|
||||
protocol_version: 1,
|
||||
model: cloud_device.model.unwrap_or_default(),
|
||||
firmware: cloud_device.version.unwrap_or_default(),
|
||||
key: Some(cloud_device.key),
|
||||
cid: Some("gree-cloud".into()),
|
||||
enabled: true,
|
||||
simulated: false,
|
||||
power: false,
|
||||
mode: "cool".into(),
|
||||
target_temperature: 24.0,
|
||||
fan_speed: 0,
|
||||
swing_vertical: 0,
|
||||
swing_horizontal: 0,
|
||||
quiet: false,
|
||||
quiet_wire_value: None,
|
||||
turbo: false,
|
||||
light: false,
|
||||
air: false,
|
||||
xfan: false,
|
||||
health: false,
|
||||
sleep: false,
|
||||
supports_light: None,
|
||||
supports_quiet: None,
|
||||
supports_turbo: None,
|
||||
supports_air: None,
|
||||
supports_xfan: None,
|
||||
supports_health: None,
|
||||
supports_sleep: None,
|
||||
supports_buzzer_control: None,
|
||||
supports_energy_meter: None,
|
||||
total_energy_kwh: None,
|
||||
compressor_frequency_hz: None,
|
||||
last_cloud_sync: None,
|
||||
current_temperature: None,
|
||||
outdoor_temperature: None,
|
||||
temperature_sensor_offset: None,
|
||||
online: false,
|
||||
response_time_ms: None,
|
||||
last_seen: None,
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
pending_command: false,
|
||||
capabilities: crate::models::DeviceCapabilities {
|
||||
vertical_swing: false,
|
||||
horizontal_swing: false,
|
||||
..crate::models::DeviceCapabilities::default()
|
||||
},
|
||||
energy_source: crate::models::EnergySourcePreference::Auto,
|
||||
ha_energy_entity_id: None,
|
||||
ha_energy_unit: None,
|
||||
ha_energy_device_class: None,
|
||||
ha_energy_state_class: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.db.save_device(&device)?;
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.device_added",
|
||||
&format!("Added GREE Cloud device {}", device.name),
|
||||
json!({"device_id": device.id, "cloud_device_id": device.cloud_device_id}),
|
||||
);
|
||||
state.broadcast("device.created", serde_json::to_value(&device)?);
|
||||
Ok((StatusCode::CREATED, Json(device)))
|
||||
}
|
||||
|
||||
async fn gree_cloud_status(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.gree_cloud.clone();
|
||||
let mqtt_connected = state.providers.cloud().is_connected().await;
|
||||
let cloud_devices = state
|
||||
.db
|
||||
.list_devices()?
|
||||
.into_iter()
|
||||
.filter(|device| device.connection_type == ConnectionType::GreeCloud)
|
||||
.collect::<Vec<_>>();
|
||||
let account_status = if !settings.enabled {
|
||||
"disabled"
|
||||
} else if settings.username.trim().is_empty() || settings.password.trim().is_empty() {
|
||||
"not_configured"
|
||||
} else if cloud_devices
|
||||
.iter()
|
||||
.any(|device| device.connection_status == ConnectionStatus::AuthenticationError)
|
||||
{
|
||||
"authentication_error"
|
||||
} else if mqtt_connected {
|
||||
"connected"
|
||||
} else {
|
||||
"cloud_disconnected"
|
||||
};
|
||||
let runtime = state.providers.cloud().runtime_status().await;
|
||||
Ok(Json(json!({
|
||||
"enabled": settings.enabled,
|
||||
"account_status": account_status,
|
||||
"mqtt_status": if mqtt_connected { "connected" } else { "disconnected" },
|
||||
"last_successful_contact": settings.last_successful_contact,
|
||||
"last_rest_response_time_ms": settings.last_rest_response_time_ms,
|
||||
"device_count": cloud_devices.len(),
|
||||
"online_device_count": cloud_devices.iter().filter(|device| device.connection_status == ConnectionStatus::Online).count(),
|
||||
"runtime": runtime,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn reconnect_gree_cloud(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.gree_cloud.clone();
|
||||
if !settings.enabled {
|
||||
return Err(AppError::BadRequest("GREE Cloud is disabled".into()));
|
||||
}
|
||||
let devices = state.db.list_devices()?;
|
||||
state.providers.cloud().shutdown().await;
|
||||
state
|
||||
.providers
|
||||
.cloud()
|
||||
.ensure_connected(&settings, &devices)
|
||||
.await
|
||||
.map_err(|error| AppError::Dependency(cloud_public_error_text(&error.to_string())))?;
|
||||
let now = Utc::now();
|
||||
{
|
||||
let mut runtime = state.settings.write().await;
|
||||
runtime.gree_cloud.last_successful_contact = Some(now);
|
||||
state.db.save_runtime_settings(&runtime)?;
|
||||
}
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.reconnect",
|
||||
"GREE Cloud MQTT reconnected",
|
||||
json!({"device_count": devices.iter().filter(|device| device.connection_type == ConnectionType::GreeCloud).count()}),
|
||||
);
|
||||
Ok(Json(
|
||||
json!({"ok": true, "mqtt_status": "connected", "last_successful_contact": now}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn cloud_device_diagnostics(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let device = state
|
||||
.db
|
||||
.get_device(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {id}")))?;
|
||||
if device.connection_type != ConnectionType::GreeCloud {
|
||||
return Err(AppError::BadRequest(
|
||||
"Cloud diagnostics are available only for GREE Cloud devices".into(),
|
||||
));
|
||||
}
|
||||
let diagnostics = state.providers.cloud().diagnostics(&device.id).await;
|
||||
Ok(Json(json!({
|
||||
"device_id": device.id,
|
||||
"cloud_device_id": device.cloud_device_id,
|
||||
"connection_status": device.connection_status,
|
||||
"last_sync": device.last_cloud_sync,
|
||||
"capabilities": device.capabilities,
|
||||
"provider": diagnostics,
|
||||
})))
|
||||
}
|
||||
|
||||
fn cloud_public_error_text(error: &str) -> String {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") {
|
||||
"GREE Cloud authentication failed".into()
|
||||
} else {
|
||||
error.chars().take(300).collect()
|
||||
}
|
||||
}
|
||||
+170
-62
@@ -8,7 +8,8 @@ struct GroupInput {
|
||||
}
|
||||
|
||||
fn normalize_group_zone_ids(zone_ids: Vec<String>) -> Vec<String> {
|
||||
let mut values: Vec<String> = zone_ids.into_iter()
|
||||
let mut values: Vec<String> = zone_ids
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
@@ -23,11 +24,15 @@ fn validate_group_input(state: &AppState, input: &GroupInput) -> Result<Vec<Stri
|
||||
}
|
||||
let zone_ids = normalize_group_zone_ids(input.zone_ids.clone());
|
||||
if zone_ids.is_empty() {
|
||||
return Err(AppError::BadRequest("group must contain at least one zone".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"group must contain at least one zone".into(),
|
||||
));
|
||||
}
|
||||
for zone_id in &zone_ids {
|
||||
if state.db.get_zone(zone_id)?.is_none() {
|
||||
return Err(AppError::BadRequest(format!("group references missing zone {zone_id}")));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"group references missing zone {zone_id}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(zone_ids)
|
||||
@@ -37,11 +42,21 @@ async fn list_groups(State(state): State<AppState>) -> Result<Json<Vec<ClimateGr
|
||||
Ok(Json(state.db.list_groups()?))
|
||||
}
|
||||
|
||||
async fn get_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
state.db.get_group(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("group {id}")))
|
||||
async fn get_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ClimateGroup>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_group(&id)?
|
||||
.map(Json)
|
||||
.ok_or_else(|| AppError::NotFound(format!("group {id}")))
|
||||
}
|
||||
|
||||
async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInput>) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
||||
async fn create_group(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<GroupInput>,
|
||||
) -> Result<(StatusCode, Json<ClimateGroup>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _reference_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
@@ -62,7 +77,11 @@ async fn create_group(State(state): State<AppState>, Json(input): Json<GroupInpu
|
||||
Ok((StatusCode::CREATED, Json(group)))
|
||||
}
|
||||
|
||||
async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<GroupInput>) -> Result<Json<ClimateGroup>, AppError> {
|
||||
async fn update_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<GroupInput>,
|
||||
) -> Result<Json<ClimateGroup>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
// Membership changes alter the target set of group automations, so serialize them with
|
||||
// automation execution/reference validation before taking the group lock.
|
||||
@@ -70,7 +89,10 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let _group_guard = state.lock_group_operation(&id).await;
|
||||
let existing = state.db.get_group(&id)?.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
|
||||
let existing = state
|
||||
.db
|
||||
.get_group(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("group {id}")))?;
|
||||
let zone_ids = validate_group_input(&state, &input)?;
|
||||
let group = ClimateGroup {
|
||||
id,
|
||||
@@ -86,46 +108,90 @@ async fn update_group(State(state): State<AppState>, Path(id): Path<String>, Jso
|
||||
Ok(Json(group))
|
||||
}
|
||||
|
||||
async fn delete_group(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
async fn delete_group(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let _group_guard = state.lock_group_operation(&id).await;
|
||||
if state.db.list_automations()?.iter().any(|item| item.action_group_id.as_deref() == Some(id.as_str())) {
|
||||
return Err(AppError::BadRequest("group is used by an automation; remove or retarget that automation first".into()));
|
||||
if state
|
||||
.db
|
||||
.list_automations()?
|
||||
.iter()
|
||||
.any(|item| item.action_group_id.as_deref() == Some(id.as_str()))
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"group is used by an automation; remove or retarget that automation first".into(),
|
||||
));
|
||||
}
|
||||
if !state.db.delete_group(&id)? {
|
||||
return Err(AppError::NotFound(format!("group {id}")));
|
||||
}
|
||||
if !state.db.delete_group(&id)? { return Err(AppError::NotFound(format!("group {id}"))); }
|
||||
state.broadcast("group.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
fn ensure_zone_removal_safe(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
let automated_groups: std::collections::HashSet<String> = state.db.list_automations()?.into_iter()
|
||||
fn ensure_zone_removal_safe(
|
||||
state: &AppState,
|
||||
zone_ids: &std::collections::HashSet<String>,
|
||||
) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let automated_groups: std::collections::HashSet<String> = state
|
||||
.db
|
||||
.list_automations()?
|
||||
.into_iter()
|
||||
.filter_map(|item| item.action_group_id)
|
||||
.collect();
|
||||
for group in state.db.list_groups()? {
|
||||
let remaining = group.zone_ids.iter().filter(|zone_id| !zone_ids.contains(*zone_id)).count();
|
||||
if remaining == 0 && group.zone_ids.iter().any(|zone_id| zone_ids.contains(zone_id)) && automated_groups.contains(&group.id) {
|
||||
let remaining = group
|
||||
.zone_ids
|
||||
.iter()
|
||||
.filter(|zone_id| !zone_ids.contains(*zone_id))
|
||||
.count();
|
||||
if remaining == 0
|
||||
&& group
|
||||
.zone_ids
|
||||
.iter()
|
||||
.any(|zone_id| zone_ids.contains(zone_id))
|
||||
&& automated_groups.contains(&group.id)
|
||||
{
|
||||
return Err(AppError::BadRequest(format!("cannot remove the last zone from group '{}' while an automation targets that group", group.name)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::collections::HashSet<String>) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() { return Ok(()); }
|
||||
let mut group_ids: Vec<String> = state.db.list_groups()?.into_iter().map(|group| group.id).collect();
|
||||
async fn remove_zone_ids_from_groups_locked(
|
||||
state: &AppState,
|
||||
zone_ids: &std::collections::HashSet<String>,
|
||||
) -> Result<(), AppError> {
|
||||
if zone_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut group_ids: Vec<String> = state
|
||||
.db
|
||||
.list_groups()?
|
||||
.into_iter()
|
||||
.map(|group| group.id)
|
||||
.collect();
|
||||
group_ids.sort();
|
||||
group_ids.dedup();
|
||||
for group_id in group_ids {
|
||||
let _group_guard = state.lock_group_operation(&group_id).await;
|
||||
let Some(mut group) = state.db.get_group(&group_id)? else { continue; };
|
||||
let Some(mut group) = state.db.get_group(&group_id)? else {
|
||||
continue;
|
||||
};
|
||||
let before = group.zone_ids.len();
|
||||
group.zone_ids.retain(|zone_id| !zone_ids.contains(zone_id));
|
||||
if group.zone_ids.len() == before { continue; }
|
||||
if group.zone_ids.len() == before {
|
||||
continue;
|
||||
}
|
||||
if group.zone_ids.is_empty() {
|
||||
state.db.delete_group(&group.id)?;
|
||||
state.broadcast("group.deleted", json!({"id": group.id}));
|
||||
@@ -138,19 +204,31 @@ async fn remove_zone_ids_from_groups_locked(state: &AppState, zone_ids: &std::co
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_group_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<GroupControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?))
|
||||
async fn update_group_control(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<GroupControlPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(
|
||||
engine::control_group(&state, &id, patch, "group.quick_control").await?,
|
||||
))
|
||||
}
|
||||
|
||||
fn home_assistant_group_mode(zones: &[&Zone]) -> String {
|
||||
let mut value: Option<&str> = None;
|
||||
for zone in zones {
|
||||
let current = if zone.inherit_house_mode { "house" } else { zone.mode.as_str() };
|
||||
let current = if zone.inherit_house_mode {
|
||||
"house"
|
||||
} else {
|
||||
zone.mode.as_str()
|
||||
};
|
||||
if !matches!(current, "house" | "cool" | "heat") {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
if previous != current {
|
||||
return "mixed".into();
|
||||
}
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
@@ -166,7 +244,9 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
if previous != current {
|
||||
return "mixed".into();
|
||||
}
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
@@ -174,14 +254,17 @@ fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
value.unwrap_or("mixed").to_string()
|
||||
}
|
||||
|
||||
|
||||
fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
|
||||
let mut value: Option<f64> = None;
|
||||
for zone in zones {
|
||||
if zone.manual_preset.as_deref() != Some("custom") { return None; }
|
||||
if zone.manual_preset.as_deref() != Some("custom") {
|
||||
return None;
|
||||
}
|
||||
let current = zone.manual_setpoint.or(zone.effective_setpoint)?;
|
||||
if let Some(previous) = value {
|
||||
if (previous - current).abs() > 0.05 { return None; }
|
||||
if (previous - current).abs() > 0.05 {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
@@ -189,28 +272,47 @@ fn home_assistant_group_custom_setpoint(zones: &[&Zone]) -> Option<f64> {
|
||||
value
|
||||
}
|
||||
|
||||
async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Json<Vec<Value>>, AppError> {
|
||||
async fn list_home_assistant_groups(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<Value>>, AppError> {
|
||||
let groups = state.db.list_groups()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
let devices = state.db.list_devices()?;
|
||||
let plan = engine::build_control_plan(&state).await?;
|
||||
let plan = engine::get_control_plan_snapshot(&state).await?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut output = Vec::with_capacity(groups.len());
|
||||
|
||||
for group in groups {
|
||||
let members = zones.iter()
|
||||
let members = zones
|
||||
.iter()
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id))
|
||||
.collect::<Vec<_>>();
|
||||
let planned_members = plan.zones.iter()
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id))
|
||||
let planned_members = plan
|
||||
.plan
|
||||
.zones
|
||||
.iter()
|
||||
.filter(|zone| {
|
||||
group
|
||||
.zone_ids
|
||||
.iter()
|
||||
.any(|zone_id| zone_id == &zone.zone_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let zone_names = members.iter().map(|zone| zone.name.clone()).collect::<Vec<_>>();
|
||||
let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::<std::collections::HashSet<_>>();
|
||||
let online_devices = devices.iter()
|
||||
let zone_names = members
|
||||
.iter()
|
||||
.map(|zone| zone.name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let member_device_ids = members
|
||||
.iter()
|
||||
.map(|zone| zone.device_id.as_str())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let online_devices = devices
|
||||
.iter()
|
||||
.filter(|device| member_device_ids.contains(device.id.as_str()) && device.online)
|
||||
.count();
|
||||
let current_temperatures = planned_members.iter()
|
||||
let current_temperatures = planned_members
|
||||
.iter()
|
||||
.filter_map(|zone| zone.current_temperature)
|
||||
.collect::<Vec<_>>();
|
||||
let current_temperature = if current_temperatures.is_empty() {
|
||||
@@ -228,27 +330,32 @@ async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Jso
|
||||
}
|
||||
next_events.sort_by_key(|event| event.at);
|
||||
next_events.truncate(8);
|
||||
let member_states = planned_members.iter().map(|zone| json!({
|
||||
"zone_id": zone.zone_id,
|
||||
"zone_name": zone.zone_name,
|
||||
"device_id": zone.device_id,
|
||||
"device_name": zone.device_name,
|
||||
"enabled": zone.enabled,
|
||||
"effective_enabled": zone.effective_enabled,
|
||||
"mode": zone.mode,
|
||||
"configured_mode": zone.configured_mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode,
|
||||
"preset": zone.preset,
|
||||
"current_temperature": zone.current_temperature,
|
||||
"target_temperature": zone.target_temperature,
|
||||
"demand": zone.demand,
|
||||
"control_source": zone.control_source,
|
||||
"current_schedule": zone.current_schedule_name,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"device_manual_override": zone.device_manual_override,
|
||||
"device_manual_override_until": zone.device_manual_override_until,
|
||||
})).collect::<Vec<_>>();
|
||||
let member_states = planned_members
|
||||
.iter()
|
||||
.map(|zone| {
|
||||
json!({
|
||||
"zone_id": zone.zone_id,
|
||||
"zone_name": zone.zone_name,
|
||||
"device_id": zone.device_id,
|
||||
"device_name": zone.device_name,
|
||||
"enabled": zone.enabled,
|
||||
"effective_enabled": zone.effective_enabled,
|
||||
"mode": zone.mode,
|
||||
"configured_mode": zone.configured_mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode,
|
||||
"preset": zone.preset,
|
||||
"current_temperature": zone.current_temperature,
|
||||
"target_temperature": zone.target_temperature,
|
||||
"demand": zone.demand,
|
||||
"control_source": zone.control_source,
|
||||
"current_schedule": zone.current_schedule_name,
|
||||
"local_thermostat_power": zone.local_thermostat_power,
|
||||
"local_thermostat_resume_at": zone.local_thermostat_resume_at,
|
||||
"device_manual_override": zone.device_manual_override,
|
||||
"device_manual_override_until": zone.device_manual_override_until,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
output.push(json!({
|
||||
"id": group.id,
|
||||
@@ -281,6 +388,7 @@ async fn update_home_assistant_group_control(
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<GroupControlPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?))
|
||||
Ok(Json(
|
||||
engine::control_group(&state, &id, patch, "home_assistant.group_control").await?,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
+1035
-76
File diff suppressed because it is too large
Load Diff
+306
-89
@@ -1,21 +1,36 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HouseControlPatch { mode: String }
|
||||
|
||||
struct HouseControlPatch {
|
||||
mode: String,
|
||||
}
|
||||
|
||||
async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<(), AppError> {
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
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; };
|
||||
let Some(mut zone) = state.db.get_zone(&zone_id)? else {
|
||||
continue;
|
||||
};
|
||||
let scoped_manual = zone.device_manual_override
|
||||
|| zone.local_thermostat_power.is_some()
|
||||
|| zone.control_source.starts_with("group:")
|
||||
|| engine::temporary_quick_thermostat_is_active(&zone, Utc::now());
|
||||
if scoped_manual { 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; }
|
||||
if scoped_manual {
|
||||
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::rearm_compressor_queue(&mut zone);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
@@ -25,16 +40,25 @@ async fn rearm_house_automation_compressor_queues(state: &AppState) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result<usize, AppError> {
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
let mut zone_ids: Vec<String> = state
|
||||
.db
|
||||
.list_zones()?
|
||||
.into_iter()
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
let mut changed = 0usize;
|
||||
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; };
|
||||
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);
|
||||
if engine::set_house_bulk_thermostat_power(&mut zone, power) {
|
||||
changed += 1;
|
||||
}
|
||||
engine::refresh_control_ownership(&mut zone);
|
||||
zone.revision = zone.revision.saturating_add(1);
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
@@ -43,10 +67,16 @@ async fn set_all_thermostat_power_state(state: &AppState, power: bool) -> Result
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn command_all_enabled_devices_power(state: &AppState, power: bool, source: &str) -> Result<Vec<Value>, AppError> {
|
||||
async fn command_all_enabled_devices_power(
|
||||
state: &AppState,
|
||||
power: bool,
|
||||
source: &str,
|
||||
) -> Result<Vec<Value>, AppError> {
|
||||
let mut failed = Vec::new();
|
||||
for device in state.db.list_devices()? {
|
||||
if !device.enabled { continue; }
|
||||
if !device.enabled {
|
||||
continue;
|
||||
}
|
||||
// The per-zone thermostat power state is persisted before these physical commands.
|
||||
// OFF is immediate; ON still respects compressor protection.
|
||||
let result = if power {
|
||||
@@ -55,12 +85,17 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source
|
||||
engine::force_house_power_off_device(state, &device.id, source).await
|
||||
};
|
||||
if let Err(err) = result {
|
||||
state.log("error", "house.power_all_error", &err.to_string(), json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"power": power,
|
||||
"source": source,
|
||||
}));
|
||||
state.log(
|
||||
"error",
|
||||
"house.power_all_error",
|
||||
&err.to_string(),
|
||||
json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"power": power,
|
||||
"source": source,
|
||||
}),
|
||||
);
|
||||
failed.push(json!({
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
@@ -71,25 +106,61 @@ async fn command_all_enabled_devices_power(state: &AppState, power: bool, source
|
||||
Ok(failed)
|
||||
}
|
||||
|
||||
async fn update_house_control(State(state): State<AppState>, Json(input): Json<HouseControlPatch>) -> Result<Json<Value>, AppError> {
|
||||
async fn clear_all_automation_compressor_queues(state: &AppState) -> Result<usize, AppError> {
|
||||
let mut zone_ids: Vec<String> = state
|
||||
.db
|
||||
.list_zones()?
|
||||
.into_iter()
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
let mut changed = 0usize;
|
||||
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::rearm_compressor_queue(&mut zone);
|
||||
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)?);
|
||||
changed += 1;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn update_house_control(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<HouseControlPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
// Serialize the ownership/configuration transition against an already-running thermostat
|
||||
// cycle. Otherwise a cycle that captured the previous house mode could send one stale
|
||||
// climate command after this interactive change.
|
||||
let cycle_guard = state.lock_zone_control_cycle().await;
|
||||
if !matches!(input.mode.as_str(), "cool" | "heat" | "off") {
|
||||
return Err(AppError::BadRequest("house mode must be cool, heat or off".into()));
|
||||
return Err(AppError::BadRequest(
|
||||
"house mode must be cool, heat or off".into(),
|
||||
));
|
||||
}
|
||||
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?;
|
||||
@@ -102,14 +173,114 @@ async fn update_house_control(State(state): State<AppState>, Json(input): Json<H
|
||||
} else {
|
||||
state.wake_zone_control();
|
||||
}
|
||||
state.log("info", "house.mode", &format!("House mode set to {}", mode), json!({"mode": mode}));
|
||||
state.log(
|
||||
"info",
|
||||
"house.mode",
|
||||
&format!("House mode set to {}", mode),
|
||||
json!({"mode": mode}),
|
||||
);
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HousePowerPatch { power: bool }
|
||||
struct HousePowerPatch {
|
||||
power: bool,
|
||||
}
|
||||
|
||||
async fn update_house_power(State(state): State<AppState>, Json(input): Json<HousePowerPatch>) -> Result<Json<Value>, AppError> {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HouseEmergencyStopPatch {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
async fn update_house_emergency_stop(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<HouseEmergencyStopPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
// Automation execution already uses this lock before committing an action. Taking it first
|
||||
// gives the emergency stop a clean barrier: an in-flight action finishes, then no newer
|
||||
// automatic action can cross the persisted safety flag.
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
// Persist the safety gate while automatic control is serialized. Once the flag is stored,
|
||||
// neither the background regulator nor an immediate thermostat run can emit automation
|
||||
// commands until the user explicitly resumes normal operation.
|
||||
let cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let (changed, emergency_stop_since) = {
|
||||
let mut settings = state.settings.write().await;
|
||||
let changed = settings.emergency_stop_enabled != input.active;
|
||||
if changed {
|
||||
settings.emergency_stop_enabled = input.active;
|
||||
settings.emergency_stop_since = if input.active { Some(Utc::now()) } else { None };
|
||||
state.db.save_runtime_settings(&settings)?;
|
||||
}
|
||||
(changed, settings.emergency_stop_since.clone())
|
||||
};
|
||||
|
||||
// OFF is intentionally a one-shot side effect of activating the emergency stop. The
|
||||
// persistent flag survives restarts, but startup never replays physical commands from it.
|
||||
let mut failed = Vec::new();
|
||||
let mut cleared_queues = 0usize;
|
||||
if input.active && changed {
|
||||
cleared_queues = clear_all_automation_compressor_queues(&state).await?;
|
||||
failed = command_all_enabled_devices_power(&state, false, "house_emergency_stop").await?;
|
||||
}
|
||||
|
||||
let payload = json!({
|
||||
"active": input.active,
|
||||
"since": emergency_stop_since,
|
||||
"changed": changed,
|
||||
"failed": failed,
|
||||
"cleared_queues": cleared_queues,
|
||||
});
|
||||
state.broadcast("house.emergency_stop_changed", payload.clone());
|
||||
drop(cycle_guard);
|
||||
state.wake_zone_control();
|
||||
|
||||
// Resuming does not force any unit ON. It only releases the persistent safety gate and
|
||||
// immediately re-evaluates current schedules, temperatures and ownership from fresh state.
|
||||
if !input.active
|
||||
&& changed
|
||||
&& state.initial_device_sync_complete.load(Ordering::Acquire)
|
||||
{
|
||||
if let Err(err) = engine::run_zone_control_now(&state).await {
|
||||
state.log(
|
||||
"error",
|
||||
"house.emergency_resume_control_error",
|
||||
&err.to_string(),
|
||||
json!({"active": false}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state.log(
|
||||
"info",
|
||||
if input.active {
|
||||
"house.emergency_stop_activated"
|
||||
} else {
|
||||
"house.emergency_stop_released"
|
||||
},
|
||||
if input.active {
|
||||
"Emergency stop activated; automatic climate control paused"
|
||||
} else {
|
||||
"Emergency stop released; automatic climate control resumed"
|
||||
},
|
||||
json!({
|
||||
"active": input.active,
|
||||
"changed": changed,
|
||||
"failed_devices": payload["failed"].as_array().map(Vec::len).unwrap_or(0),
|
||||
"cleared_queues": cleared_queues,
|
||||
"persistent_across_restart": true,
|
||||
"off_replayed_on_restart": false,
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(Json(payload))
|
||||
}
|
||||
|
||||
async fn update_house_power(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<HousePowerPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
|
||||
@@ -118,15 +289,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,53 +297,59 @@ 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 {
|
||||
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
|
||||
}, json!({
|
||||
"power": input.power,
|
||||
"failed": failed.len(),
|
||||
"changed_zones": changed_zones,
|
||||
"one_shot": true,
|
||||
"persistent_global_gate": false,
|
||||
}));
|
||||
state.log(
|
||||
"info",
|
||||
"house.power_all",
|
||||
if input.power {
|
||||
"Whole-house ON sent; local OFF state released and house thermostat intent armed"
|
||||
} else {
|
||||
"Whole-house OFF sent; all thermostats left locally OFF until explicitly re-enabled"
|
||||
},
|
||||
json!({
|
||||
"power": input.power,
|
||||
"failed": failed.len(),
|
||||
"changed_zones": changed_zones,
|
||||
"one_shot": true,
|
||||
"persistent_global_gate": false,
|
||||
}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"power": input.power,
|
||||
"one_shot": true,
|
||||
"devices": devices,
|
||||
"groups": groups,
|
||||
"settings": settings_payload,
|
||||
"failed": failed,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HousePresetPatch { preset: String }
|
||||
struct HousePresetPatch {
|
||||
preset: String,
|
||||
}
|
||||
|
||||
async fn update_house_preset(State(state): State<AppState>, Json(input): Json<HousePresetPatch>) -> Result<Json<Value>, AppError> {
|
||||
async fn update_house_preset(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<HousePresetPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let cycle_guard = state.lock_zone_control_cycle().await;
|
||||
if !matches!(input.preset.as_str(), "auto" | "comfort" | "sleep" | "away") {
|
||||
return Err(AppError::BadRequest("house preset must be auto, comfort, sleep or away".into()));
|
||||
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?;
|
||||
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let mut zone_ids: Vec<String> = state.db.list_zones()?.into_iter().map(|zone| zone.id).collect();
|
||||
let mut zone_ids: Vec<String> = state
|
||||
.db
|
||||
.list_zones()?
|
||||
.into_iter()
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
zone_ids.sort();
|
||||
zone_ids.dedup();
|
||||
let mut _zone_guards = Vec::with_capacity(zone_ids.len());
|
||||
@@ -190,9 +358,13 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
}
|
||||
let mut zones = Vec::with_capacity(zone_ids.len());
|
||||
for zone_id in &zone_ids {
|
||||
let Some(zone_snapshot) = state.db.get_zone(zone_id)? else { continue; };
|
||||
let 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; };
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else {
|
||||
continue;
|
||||
};
|
||||
let scoped_manual = zone.device_manual_override
|
||||
|| zone.local_thermostat_power.is_some()
|
||||
|| zone.control_source.starts_with("group:")
|
||||
@@ -209,7 +381,8 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
} else {
|
||||
zone.manual_preset = Some(input.preset.clone());
|
||||
zone.manual_setpoint = None;
|
||||
zone.manual_override_until = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||
zone.manual_override_until =
|
||||
engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||
}
|
||||
zone.updated_at = Utc::now();
|
||||
state.db.save_zone(&zone)?;
|
||||
@@ -226,40 +399,68 @@ async fn update_house_preset(State(state): State<AppState>, Json(input): Json<Ho
|
||||
// same arbitration cycle and no member is left waiting behind the periodic interval.
|
||||
let mut failed: Vec<Value> = Vec::new();
|
||||
if let Err(err) = engine::run_zone_control_now(&state).await {
|
||||
state.log("error", "house.immediate_control_error", &err.to_string(), json!({"source":"house_preset"}));
|
||||
state.log(
|
||||
"error",
|
||||
"house.immediate_control_error",
|
||||
&err.to_string(),
|
||||
json!({"source":"house_preset"}),
|
||||
);
|
||||
failed.push(json!({"scope":"thermostat_cycle","error":err.to_string()}));
|
||||
}
|
||||
let devices = state.db.list_devices()?;
|
||||
state.log("info", "house.preset", &format!("House preset set to {}", input.preset), json!({
|
||||
"preset": input.preset,
|
||||
"failed": failed.len(),
|
||||
}));
|
||||
state.log(
|
||||
"info",
|
||||
"house.preset",
|
||||
&format!("House preset set to {}", input.preset),
|
||||
json!({
|
||||
"preset": input.preset,
|
||||
"failed": failed.len(),
|
||||
}),
|
||||
);
|
||||
Ok(Json(json!({
|
||||
"preset": input.preset,
|
||||
"zones": zones,
|
||||
"devices": devices,
|
||||
"settings": settings_payload,
|
||||
"failed": failed,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScheduleTemplateRequest { template: String }
|
||||
struct ScheduleTemplateRequest {
|
||||
template: String,
|
||||
}
|
||||
|
||||
async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleTemplateRequest>) -> Result<Json<Value>, AppError> {
|
||||
async fn apply_schedule_template(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<ScheduleTemplateRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let zone = state
|
||||
.db
|
||||
.get_zone(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let mut items: Vec<Schedule> = Vec::new();
|
||||
let mut add = |name: &str, days: Vec<u32>, start: &str, end: &str, preset: &str| {
|
||||
items.push(Schedule {
|
||||
id: Uuid::new_v4().to_string(), zone_id: id.clone(), name: name.into(), enabled: true,
|
||||
weekdays: days, start_time: start.into(), end_time: end.into(), preset: preset.into(),
|
||||
setpoint: zone.setpoint, created_at: Utc::now(), updated_at: Utc::now(), flow_id: None, flow_node_id: None,
|
||||
id: Uuid::new_v4().to_string(),
|
||||
zone_id: id.clone(),
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
weekdays: days,
|
||||
start_time: start.into(),
|
||||
end_time: end.into(),
|
||||
preset: preset.into(),
|
||||
setpoint: zone.setpoint,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
flow_id: None,
|
||||
flow_node_id: None,
|
||||
});
|
||||
};
|
||||
let all = vec![1,2,3,4,5,6,7];
|
||||
let all = vec![1, 2, 3, 4, 5, 6, 7];
|
||||
match input.template.as_str() {
|
||||
"family" => {
|
||||
add("Comfort", all.clone(), "06:30", "22:30", "comfort");
|
||||
@@ -274,8 +475,8 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
|
||||
add("Sleep", all, "22:00", "06:30", "sleep");
|
||||
}
|
||||
"workday" => {
|
||||
let weekdays = vec![1,2,3,4,5];
|
||||
let weekend = vec![6,7];
|
||||
let weekdays = vec![1, 2, 3, 4, 5];
|
||||
let weekend = vec![6, 7];
|
||||
add("Morning", weekdays.clone(), "06:30", "08:00", "comfort");
|
||||
add("Away", weekdays.clone(), "08:00", "16:00", "away");
|
||||
add("Evening", weekdays.clone(), "16:00", "22:30", "comfort");
|
||||
@@ -292,28 +493,45 @@ async fn apply_schedule_template(State(state): State<AppState>, Path(id): Path<S
|
||||
validate_schedule_set(&items)?;
|
||||
state.db.replace_schedules_for_zone(&id, &items)?;
|
||||
refresh_zone_override_boundary(&state, &id).await?;
|
||||
state.broadcast("schedule.template_applied", json!({"zone_id": id, "template": input.template, "count": items.len()}));
|
||||
state.broadcast(
|
||||
"schedule.template_applied",
|
||||
json!({"zone_id": id, "template": input.template, "count": items.len()}),
|
||||
);
|
||||
state.wake_zone_control();
|
||||
Ok(Json(json!({"zone": zone, "schedules": items})))
|
||||
}
|
||||
|
||||
async fn update_home_assistant_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, AppError> {
|
||||
Ok(Json(apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?))
|
||||
async fn update_home_assistant_zone_control(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<ZoneControlPatch>,
|
||||
) -> Result<Json<Zone>, AppError> {
|
||||
Ok(Json(
|
||||
apply_zone_control_patch(&state, &id, patch, "home_assistant.zone_thermostat").await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
async fn delete_zone(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
let _house_guard = state.lock_house_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let zone_guard = state.lock_zone_operation(&id).await;
|
||||
let zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let zone = state
|
||||
.db
|
||||
.get_zone(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?;
|
||||
let mut removed = std::collections::HashSet::new();
|
||||
removed.insert(id.clone());
|
||||
ensure_zone_removal_safe(&state, &removed)?;
|
||||
ensure_device_stopped_for_detach(&state, &zone.device_id, "zone.deleted").await?;
|
||||
if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); }
|
||||
if !state.db.delete_zone(&id)? {
|
||||
return Err(AppError::NotFound(format!("zone {id}")));
|
||||
}
|
||||
// Group control locks group first and zone second. Release the zone lock before taking
|
||||
// group locks so deletion cannot form the inverse zone -> group lock order.
|
||||
drop(zone_guard);
|
||||
@@ -321,4 +539,3 @@ async fn delete_zone(State(state): State<AppState>, Path(id): Path<String>) -> R
|
||||
state.broadcast("zone.deleted", json!({"id": id}));
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
+180
-22
@@ -1,25 +1,129 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaTestRequest { entity_id: Option<String> }
|
||||
async fn test_home_assistant(State(state): State<AppState>, Json(input): Json<HaTestRequest>) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let resolved_entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, input.entity_id.as_deref());
|
||||
let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, resolved_entity_id.as_deref(), Some(settings.home_assistant.sensor_stale_after_seconds))
|
||||
.await.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
Ok(Json(json!({"ok": true, "temperature_c": temperature, "entity_id": resolved_entity_id})))
|
||||
fn map_home_assistant_integration_error(error: anyhow::Error) -> AppError {
|
||||
let message = error.to_string();
|
||||
if message.contains("Home Assistant URL is not configured")
|
||||
|| message.contains("Home Assistant token is not configured")
|
||||
|| message.contains("Home Assistant Supervisor token is not available")
|
||||
|| message.contains("invalid Home Assistant URL")
|
||||
|| message.contains("Home Assistant URL must use http or https")
|
||||
|| message.contains("cannot build Home Assistant API URL")
|
||||
|| message.contains("cannot build Home Assistant service URL")
|
||||
{
|
||||
AppError::BadRequest(message)
|
||||
} else {
|
||||
AppError::Dependency(message)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_notification_test_error(message: String) -> AppError {
|
||||
if matches!(
|
||||
message.as_str(),
|
||||
"Pushover credentials are incomplete"
|
||||
| "invalid webhook URL"
|
||||
| "webhook URL must use HTTPS"
|
||||
| "webhook host is not supported"
|
||||
| "unsupported notification provider"
|
||||
) {
|
||||
AppError::BadRequest(message)
|
||||
} else {
|
||||
AppError::Dependency(message)
|
||||
}
|
||||
}
|
||||
|
||||
async fn home_assistant_snapshot(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let control_plan = engine::get_control_plan_snapshot(&state).await?;
|
||||
let groups = list_home_assistant_groups(State(state.clone())).await?.0;
|
||||
Ok(Json(json!({
|
||||
"devices": state.db.list_devices()?,
|
||||
"control_plan": control_plan.plan.as_ref(),
|
||||
"control_plan_revision": control_plan.revision,
|
||||
"groups": groups,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn test_home_assistant(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let sample = home_assistant::test_connection(&state.http, &settings.home_assistant)
|
||||
.await
|
||||
.map_err(map_home_assistant_integration_error)?;
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
"auth_mode": home_assistant::auth_mode(&settings.home_assistant),
|
||||
"sample": sample,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn list_home_assistant_entities(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.home_assistant.clone();
|
||||
if !home_assistant::configured(&settings) {
|
||||
return Ok(Json(json!({
|
||||
"configured": false,
|
||||
"entities": [],
|
||||
})));
|
||||
}
|
||||
|
||||
let mut entities = home_assistant::list_entities(&state.http, &settings)
|
||||
.await
|
||||
.map_err(map_home_assistant_integration_error)?
|
||||
.into_iter()
|
||||
.filter_map(|entity| {
|
||||
let entity_id = entity.get("entity_id")?.as_str()?.trim();
|
||||
if entity_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let attributes = entity.get("attributes").and_then(Value::as_object);
|
||||
Some(json!({
|
||||
"entity_id": entity_id,
|
||||
"name": attributes.and_then(|value| value.get("friendly_name")).and_then(Value::as_str).unwrap_or_default(),
|
||||
"state": entity.get("state").and_then(Value::as_str).unwrap_or_default(),
|
||||
"unit": attributes.and_then(|value| value.get("unit_of_measurement")).and_then(Value::as_str).unwrap_or_default(),
|
||||
"device_class": attributes.and_then(|value| value.get("device_class")).and_then(Value::as_str).unwrap_or_default(),
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
entities.sort_by(|a, b| {
|
||||
a.get("entity_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.cmp(
|
||||
b.get("entity_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
});
|
||||
Ok(Json(json!({
|
||||
"configured": true,
|
||||
"entities": entities,
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HaEntityRequest { entity_id: String }
|
||||
struct HaEntityRequest {
|
||||
entity_id: String,
|
||||
}
|
||||
|
||||
async fn inspect_home_assistant_entity(State(state): State<AppState>, Json(input): Json<HaEntityRequest>) -> Result<Json<Value>, AppError> {
|
||||
async fn inspect_home_assistant_entity(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<HaEntityRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let entity_id = home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str()))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?;
|
||||
let payload = home_assistant::read_entity(&state.http, &settings.home_assistant, Some(entity_id.as_str()))
|
||||
.await.map_err(|e| AppError::Device(e.to_string()))?;
|
||||
let raw_state = payload.get("state").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let entity_id =
|
||||
home_assistant::resolve_entity_id(&settings.home_assistant, Some(input.entity_id.as_str()))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| AppError::BadRequest("Home Assistant entity_id is required".into()))?;
|
||||
let payload = home_assistant::read_entity(
|
||||
&state.http,
|
||||
&settings.home_assistant,
|
||||
Some(entity_id.as_str()),
|
||||
)
|
||||
.await
|
||||
.map_err(map_home_assistant_integration_error)?;
|
||||
let raw_state = payload
|
||||
.get("state")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let available = !matches!(raw_state.as_str(), "unknown" | "unavailable" | "");
|
||||
Ok(Json(json!({
|
||||
"ok": true,
|
||||
@@ -32,13 +136,67 @@ async fn inspect_home_assistant_entity(State(state): State<AppState>, Json(input
|
||||
})))
|
||||
}
|
||||
|
||||
async fn test_notifications(State(state): State<AppState>, Json(mut input): Json<NotificationSettings>) -> Result<Json<Value>, AppError> {
|
||||
async fn test_notifications(
|
||||
State(state): State<AppState>,
|
||||
Json(mut input): Json<NotificationSettings>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let old = state.settings.read().await.notifications.clone();
|
||||
if input.pushover_app_token.trim().is_empty() { input.pushover_app_token = old.pushover_app_token; }
|
||||
if input.pushover_user_key.trim().is_empty() { input.pushover_user_key = old.pushover_user_key; }
|
||||
if input.slack_webhook_url.trim().is_empty() { input.slack_webhook_url = old.slack_webhook_url; }
|
||||
if input.discord_webhook_url.trim().is_empty() { input.discord_webhook_url = old.discord_webhook_url; }
|
||||
notifications::test(&state, input).await.map_err(AppError::Device)?;
|
||||
if input.pushover_app_token.trim().is_empty() {
|
||||
input.pushover_app_token = old.pushover_app_token;
|
||||
}
|
||||
if input.pushover_user_key.trim().is_empty() {
|
||||
input.pushover_user_key = old.pushover_user_key;
|
||||
}
|
||||
if input.slack_webhook_url.trim().is_empty() {
|
||||
input.slack_webhook_url = old.slack_webhook_url;
|
||||
}
|
||||
if input.discord_webhook_url.trim().is_empty() {
|
||||
input.discord_webhook_url = old.discord_webhook_url;
|
||||
}
|
||||
notifications::test(&state, input)
|
||||
.await
|
||||
.map_err(map_notification_test_error)?;
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn list_home_assistant_energy_sensors(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let settings = state.settings.read().await.home_assistant.clone();
|
||||
if !home_assistant::configured(&settings) {
|
||||
return Ok(Json(json!({
|
||||
"configured": false,
|
||||
"sensors": [],
|
||||
})));
|
||||
}
|
||||
let entities = home_assistant::list_entities(&state.http, &settings)
|
||||
.await
|
||||
.map_err(|error| AppError::Dependency(error.to_string()))?;
|
||||
let sensors = entities
|
||||
.into_iter()
|
||||
.filter_map(|entity| {
|
||||
let attributes = entity.get("attributes")?.as_object()?;
|
||||
let device_class = attributes.get("device_class")?.as_str()?;
|
||||
let state_class = attributes.get("state_class")?.as_str()?;
|
||||
let unit = attributes.get("unit_of_measurement")?.as_str()?;
|
||||
if device_class != "energy"
|
||||
|| !matches!(state_class, "total" | "total_increasing")
|
||||
|| !matches!(unit.to_ascii_lowercase().as_str(), "wh" | "kwh")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"entity_id": entity.get("entity_id").and_then(Value::as_str).unwrap_or_default(),
|
||||
"name": attributes.get("friendly_name").and_then(Value::as_str).unwrap_or_default(),
|
||||
"state": entity.get("state").and_then(Value::as_str).unwrap_or_default(),
|
||||
"unit": unit,
|
||||
"device_class": device_class,
|
||||
"state_class": state_class,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"configured": true,
|
||||
"sensors": sensors,
|
||||
})))
|
||||
}
|
||||
|
||||
+49
-8
@@ -1,24 +1,65 @@
|
||||
async fn security_headers(request: Request, next: Next) -> Response {
|
||||
let is_api = request.uri().path().contains("/api/");
|
||||
let path = request.uri().path();
|
||||
let is_api = path == "/api" || path.starts_with("/api/");
|
||||
let is_api_docs = path == "/api-docs" || path.starts_with("/api-docs/");
|
||||
let is_custom_chart = path.starts_with("/charts/custom/");
|
||||
|
||||
let mut response = next.run(request).await;
|
||||
|
||||
let is_html = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.split(';').next().is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/html")));
|
||||
.is_some_and(|value| {
|
||||
value
|
||||
.split(';')
|
||||
.next()
|
||||
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/html"))
|
||||
});
|
||||
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"));
|
||||
headers.insert(header::HeaderName::from_static("referrer-policy"), HeaderValue::from_static("same-origin"));
|
||||
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("x-content-type-options"),
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("referrer-policy"),
|
||||
HeaderValue::from_static(if is_custom_chart {
|
||||
"no-referrer"
|
||||
} else {
|
||||
"same-origin"
|
||||
}),
|
||||
);
|
||||
|
||||
if is_html {
|
||||
headers.insert(header::HeaderName::from_static("x-frame-options"), HeaderValue::from_static("SAMEORIGIN"));
|
||||
headers.insert(header::HeaderName::from_static("content-security-policy"), HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"));
|
||||
headers.insert(header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"));
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("x-frame-options"),
|
||||
HeaderValue::from_static("SAMEORIGIN"),
|
||||
);
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static(
|
||||
"default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'"
|
||||
),
|
||||
);
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("permissions-policy"),
|
||||
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
);
|
||||
}
|
||||
|
||||
if is_api {
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-store"),
|
||||
);
|
||||
} else if is_api_docs {
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, must-revalidate"),
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
use serde_json::{json, Value};
|
||||
use utoipa_swagger_ui::{Config as SwaggerConfig, SwaggerUi};
|
||||
|
||||
const OPENAPI_JSON: &str = include_str!("../../docs/openapi.json");
|
||||
|
||||
pub(super) fn swagger_ui(base_path: &str) -> SwaggerUi {
|
||||
let docs_url = format!("{}/api-docs/openapi.json", normalized_base_path(base_path));
|
||||
SwaggerUi::new("/api-docs")
|
||||
// Keep the route itself relative to the application router so Axum's
|
||||
// outer base-path nesting prefixes it exactly once. Swagger UI may use
|
||||
// the externally visible, base-path-aware URL when fetching the spec.
|
||||
.external_url_unchecked("/api-docs/openapi.json", document(base_path))
|
||||
.config(
|
||||
SwaggerConfig::new([docs_url])
|
||||
.doc_expansion("none")
|
||||
.default_models_expand_depth(-1),
|
||||
)
|
||||
}
|
||||
|
||||
fn document(base_path: &str) -> Value {
|
||||
let mut document: Value = serde_json::from_str(OPENAPI_JSON)
|
||||
.expect("embedded OpenAPI document must contain valid JSON");
|
||||
document["info"]["version"] = Value::String(env!("CARGO_PKG_VERSION").to_string());
|
||||
document["servers"] = json!([{
|
||||
"url": server_base_path(base_path),
|
||||
"description": "This GREE Controller instance"
|
||||
}]);
|
||||
document
|
||||
}
|
||||
|
||||
fn normalized_base_path(base_path: &str) -> String {
|
||||
let base = base_path.trim().trim_end_matches('/');
|
||||
if base.is_empty() || base == "/" {
|
||||
String::new()
|
||||
} else if base.starts_with('/') {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("/{base}")
|
||||
}
|
||||
}
|
||||
|
||||
fn server_base_path(base_path: &str) -> String {
|
||||
let base = normalized_base_path(base_path);
|
||||
if base.is_empty() {
|
||||
"/".into()
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn openapi_document_is_valid_json_and_version_is_runtime_version() {
|
||||
let document = document("");
|
||||
assert_eq!(document["openapi"], "3.1.0");
|
||||
assert_eq!(document["info"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(document["servers"][0]["url"], "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_respects_configured_base_path() {
|
||||
let document = document("/gree/");
|
||||
assert_eq!(document["servers"][0]["url"], "/gree");
|
||||
assert_eq!(normalized_base_path("/gree/"), "/gree");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_documented_operation_has_summary_description_and_responses() {
|
||||
let document = document("");
|
||||
let paths = document["paths"].as_object().expect("OpenAPI paths object");
|
||||
assert!(!paths.contains_key("/api/discovery"));
|
||||
assert!(paths.contains_key("/api/discovery/scan"));
|
||||
assert!(paths.contains_key("/api/discovery/add"));
|
||||
for (path, item) in paths {
|
||||
let methods = item.as_object().expect("OpenAPI path item");
|
||||
for (method, operation) in methods {
|
||||
if !matches!(method.as_str(), "get" | "post" | "put" | "patch" | "delete") {
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
operation.get("summary").and_then(Value::as_str).is_some(),
|
||||
"{method} {path} is missing summary"
|
||||
);
|
||||
assert!(
|
||||
operation
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.is_some(),
|
||||
"{method} {path} is missing description"
|
||||
);
|
||||
assert!(
|
||||
operation
|
||||
.get("responses")
|
||||
.and_then(Value::as_object)
|
||||
.is_some(),
|
||||
"{method} {path} is missing responses"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,15 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
"history_threshold_days": settings.influxdb.history_threshold_days,
|
||||
},
|
||||
"home_assistant": {
|
||||
"url": settings.home_assistant.url,
|
||||
"url": home_assistant::effective_url(&settings.home_assistant),
|
||||
"manual_url": settings.home_assistant.url,
|
||||
"auth_mode": home_assistant::auth_mode(&settings.home_assistant),
|
||||
"token": "",
|
||||
"token_configured": !settings.home_assistant.token.trim().is_empty(),
|
||||
"default_entity_id": settings.home_assistant.default_entity_id,
|
||||
"token_configured": home_assistant::token_configured(&settings.home_assistant),
|
||||
"manual_token_configured": !settings.home_assistant.token.trim().is_empty(),
|
||||
"supervisor_detected": home_assistant::supervisor_detected(),
|
||||
"supervisor_token_detected": home_assistant::supervisor_token_detected(),
|
||||
"manual_auth_override": settings.home_assistant.manual_auth_override,
|
||||
"outdoor_entity_id": settings.home_assistant.outdoor_entity_id,
|
||||
"sensor_stale_after_seconds": settings.home_assistant.sensor_stale_after_seconds,
|
||||
"allow_invalid_tls": settings.home_assistant.allow_invalid_tls,
|
||||
@@ -61,4 +66,3 @@ fn public_settings(settings: &RuntimeSettings) -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+130
-34
@@ -11,39 +11,82 @@ struct ScheduleInput {
|
||||
preset: String,
|
||||
setpoint: f64,
|
||||
}
|
||||
fn schedule_preset() -> String { "custom".into() }
|
||||
fn schedule_preset() -> String {
|
||||
"custom".into()
|
||||
}
|
||||
impl ScheduleInput {
|
||||
fn validate(&self) -> Result<(), AppError> {
|
||||
if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); }
|
||||
if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); }
|
||||
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?;
|
||||
if !matches!(self.preset.as_str(), "comfort" | "sleep" | "away" | "custom") { return Err(AppError::BadRequest("unsupported schedule preset".into())); }
|
||||
if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); }
|
||||
if self.name.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("schedule name is required".into()));
|
||||
}
|
||||
if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) {
|
||||
return Err(AppError::BadRequest(
|
||||
"weekdays must contain numbers 1..7".into(),
|
||||
));
|
||||
}
|
||||
chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("invalid start time".into()))?;
|
||||
chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M")
|
||||
.map_err(|_| AppError::BadRequest("invalid end time".into()))?;
|
||||
if !matches!(
|
||||
self.preset.as_str(),
|
||||
"comfort" | "sleep" | "away" | "custom"
|
||||
) {
|
||||
return Err(AppError::BadRequest("unsupported schedule preset".into()));
|
||||
}
|
||||
if self.preset == "custom" && !(8.0..=30.0).contains(&self.setpoint) {
|
||||
return Err(AppError::BadRequest(
|
||||
"schedule setpoint must be between 8 and 30 C".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn into_schedule(self, id: String, created_at: chrono::DateTime<Utc>) -> Schedule {
|
||||
Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled,
|
||||
weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time,
|
||||
preset: self.preset, setpoint: self.setpoint, created_at, updated_at: Utc::now(), flow_id: None, flow_node_id: None }
|
||||
Schedule {
|
||||
id,
|
||||
zone_id: self.zone_id,
|
||||
name: self.name.trim().into(),
|
||||
enabled: self.enabled,
|
||||
weekdays: self.weekdays,
|
||||
start_time: self.start_time,
|
||||
end_time: self.end_time,
|
||||
preset: self.preset,
|
||||
setpoint: self.setpoint,
|
||||
created_at,
|
||||
updated_at: Utc::now(),
|
||||
flow_id: None,
|
||||
flow_node_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn validate_schedule_set(items: &[Schedule]) -> Result<(), AppError> {
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
for other in items.iter().skip(index + 1) {
|
||||
if engine::schedules_overlap(item, other) {
|
||||
return Err(AppError::BadRequest(format!("schedule '{}' overlaps with '{}' for the same zone", item.name, other.name)));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"schedule '{}' overlaps with '{}' for the same zone",
|
||||
item.name, other.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_schedule_conflicts(state: &AppState, item: &Schedule, exclude_id: Option<&str>) -> Result<(), AppError> {
|
||||
fn validate_schedule_conflicts(
|
||||
state: &AppState,
|
||||
item: &Schedule,
|
||||
exclude_id: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
for existing in state.db.list_schedules()? {
|
||||
if exclude_id == Some(existing.id.as_str()) { continue; }
|
||||
if exclude_id == Some(existing.id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if engine::schedules_overlap(item, &existing) {
|
||||
return Err(AppError::BadRequest(format!("schedule overlaps with '{}'", existing.name)));
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"schedule overlaps with '{}'",
|
||||
existing.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -57,11 +100,21 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else { return Ok(()); };
|
||||
let has_temporary_schedule_boundary = zone.temporary_quick_thermostat.as_ref()
|
||||
let Some(mut zone) = state.db.get_zone(zone_id)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let has_temporary_schedule_boundary = zone
|
||||
.temporary_quick_thermostat
|
||||
.as_ref()
|
||||
.map(|session| session.finish_kind == "schedule_boundary")
|
||||
.unwrap_or(false);
|
||||
if zone.manual_preset.is_none() && zone.manual_setpoint.is_none() && !zone.device_manual_override && !has_temporary_schedule_boundary { return Ok(()); }
|
||||
if zone.manual_preset.is_none()
|
||||
&& zone.manual_setpoint.is_none()
|
||||
&& !zone.device_manual_override
|
||||
&& !has_temporary_schedule_boundary
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let schedules = state.db.list_schedules()?;
|
||||
let boundary = engine::next_schedule_boundary_utc(&zone.id, &schedules, chrono::Local::now());
|
||||
// An active Temporary Quick Thermostat explicitly owns its target until its own finish
|
||||
@@ -79,11 +132,14 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
|
||||
zone.control_resume_at = None;
|
||||
}
|
||||
if has_temporary_schedule_boundary {
|
||||
let reference = zone.temporary_quick_thermostat.as_ref()
|
||||
let reference = zone
|
||||
.temporary_quick_thermostat
|
||||
.as_ref()
|
||||
.filter(|session| session.activated_at.is_none())
|
||||
.map(|session| session.started_at.with_timezone(&chrono::Local))
|
||||
.unwrap_or_else(chrono::Local::now);
|
||||
let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference).or(Some(Utc::now()));
|
||||
let refreshed = engine::next_schedule_boundary_utc(&zone.id, &schedules, reference)
|
||||
.or(Some(Utc::now()));
|
||||
if let Some(session) = zone.temporary_quick_thermostat.as_mut() {
|
||||
session.expires_at = refreshed;
|
||||
}
|
||||
@@ -95,16 +151,30 @@ async fn refresh_zone_override_boundary(state: &AppState, zone_id: &str) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> { Ok(Json(state.db.list_schedules()?)) }
|
||||
async fn get_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Schedule>, AppError> {
|
||||
state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
|
||||
async fn list_schedules(State(state): State<AppState>) -> Result<Json<Vec<Schedule>>, AppError> {
|
||||
Ok(Json(state.db.list_schedules()?))
|
||||
}
|
||||
async fn create_schedule(State(state): State<AppState>, Json(input): Json<ScheduleInput>) -> Result<(StatusCode, Json<Schedule>), AppError> {
|
||||
async fn get_schedule(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<Schedule>, AppError> {
|
||||
state
|
||||
.db
|
||||
.get_schedule(&id)?
|
||||
.map(Json)
|
||||
.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))
|
||||
}
|
||||
async fn create_schedule(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ScheduleInput>,
|
||||
) -> Result<(StatusCode, Json<Schedule>), AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
input.validate()?;
|
||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
||||
if state.db.get_zone(&input.zone_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("schedule zone does not exist".into()));
|
||||
}
|
||||
let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now());
|
||||
validate_schedule_conflicts(&state, &item, None)?;
|
||||
state.db.save_schedule(&item)?;
|
||||
@@ -113,34 +183,60 @@ async fn create_schedule(State(state): State<AppState>, Json(input): Json<Schedu
|
||||
state.wake_zone_control();
|
||||
Ok((StatusCode::CREATED, Json(item)))
|
||||
}
|
||||
async fn update_schedule(State(state): State<AppState>, Path(id): Path<String>, Json(input): Json<ScheduleInput>) -> Result<Json<Schedule>, AppError> {
|
||||
async fn update_schedule(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(input): Json<ScheduleInput>,
|
||||
) -> Result<Json<Schedule>, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
input.validate()?;
|
||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; edit it in the Flow editor".into())); }
|
||||
if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); }
|
||||
let existing = state
|
||||
.db
|
||||
.get_schedule(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||
if existing.flow_id.is_some() {
|
||||
return Err(AppError::BadRequest(
|
||||
"this schedule is generated by Flow; edit it in the Flow editor".into(),
|
||||
));
|
||||
}
|
||||
if state.db.get_zone(&input.zone_id)?.is_none() {
|
||||
return Err(AppError::BadRequest("schedule zone does not exist".into()));
|
||||
}
|
||||
let old_zone_id = existing.zone_id.clone();
|
||||
let item = input.into_schedule(id.clone(), existing.created_at);
|
||||
validate_schedule_conflicts(&state, &item, Some(&id))?;
|
||||
state.db.save_schedule(&item)?;
|
||||
refresh_zone_override_boundary(&state, &old_zone_id).await?;
|
||||
if item.zone_id != old_zone_id { refresh_zone_override_boundary(&state, &item.zone_id).await?; }
|
||||
if item.zone_id != old_zone_id {
|
||||
refresh_zone_override_boundary(&state, &item.zone_id).await?;
|
||||
}
|
||||
state.broadcast("schedule.updated", serde_json::to_value(&item)?);
|
||||
state.wake_zone_control();
|
||||
Ok(Json(item))
|
||||
}
|
||||
async fn delete_schedule(State(state): State<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
async fn delete_schedule(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
let _configuration_guard = state.lock_configuration_operation().await;
|
||||
let _schedule_guard = state.lock_schedule_operation().await;
|
||||
let _cycle_guard = state.lock_zone_control_cycle().await;
|
||||
let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||
if existing.flow_id.is_some() { return Err(AppError::BadRequest("this schedule is generated by Flow; delete it from the Flow editor".into())); }
|
||||
if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); }
|
||||
let existing = state
|
||||
.db
|
||||
.get_schedule(&id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?;
|
||||
if existing.flow_id.is_some() {
|
||||
return Err(AppError::BadRequest(
|
||||
"this schedule is generated by Flow; delete it from the Flow editor".into(),
|
||||
));
|
||||
}
|
||||
if !state.db.delete_schedule(&id)? {
|
||||
return Err(AppError::NotFound(format!("schedule {id}")));
|
||||
}
|
||||
refresh_zone_override_boundary(&state, &existing.zone_id).await?;
|
||||
state.broadcast("schedule.deleted", json!({"id": id}));
|
||||
state.wake_zone_control();
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
|
||||
+737
-448
File diff suppressed because it is too large
Load Diff
+212
-61
@@ -1,70 +1,221 @@
|
||||
async fn health(State(state): State<AppState>) -> Json<Value> {
|
||||
Json(json!({
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct HouseSnapshot {
|
||||
mode: String,
|
||||
emergency_stop_enabled: bool,
|
||||
emergency_stop_since: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct SystemInfoResponse {
|
||||
version: &'static str,
|
||||
uptime_seconds: u64,
|
||||
auth_required: bool,
|
||||
control_ready: bool,
|
||||
database: String,
|
||||
device_count: usize,
|
||||
online_count: usize,
|
||||
simulator_count: usize,
|
||||
bind: String,
|
||||
base_path: String,
|
||||
public_chart_base_url: String,
|
||||
gree_interface: String,
|
||||
gree_received_frames: u64,
|
||||
gree_received_frames_by_device: std::collections::HashMap<String, u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
struct DeviceGroupEnergySnapshot {
|
||||
total_kwh: f64,
|
||||
timestamp: Option<chrono::DateTime<Utc>>,
|
||||
source: String,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
fn device_group_energy_snapshot(
|
||||
state: &AppState,
|
||||
group: &DeviceGroup,
|
||||
devices: &[Device],
|
||||
) -> Result<Option<DeviceGroupEnergySnapshot>, AppError> {
|
||||
let selected_source = match group.energy_source {
|
||||
EnergySourcePreference::GreeCloud => Some("gree_cloud"),
|
||||
EnergySourcePreference::HomeAssistant => Some("home_assistant"),
|
||||
EnergySourcePreference::Auto if group.energy_device_id.is_some() => Some("gree_cloud"),
|
||||
EnergySourcePreference::Auto if group.ha_energy_entity_id.is_some() => {
|
||||
Some("home_assistant")
|
||||
}
|
||||
EnergySourcePreference::Auto => None,
|
||||
};
|
||||
|
||||
match selected_source {
|
||||
Some("gree_cloud") => {
|
||||
let Some(device_id) = group.energy_device_id.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(device) = devices.iter().find(|device| device.id == device_id) {
|
||||
if let Some(total_kwh) = device
|
||||
.total_energy_kwh
|
||||
.filter(|value| value.is_finite() && *value >= 0.0)
|
||||
{
|
||||
return Ok(Some(DeviceGroupEnergySnapshot {
|
||||
total_kwh,
|
||||
timestamp: device
|
||||
.last_cloud_sync
|
||||
.clone()
|
||||
.or_else(|| device.last_seen.clone()),
|
||||
source: "gree_cloud".into(),
|
||||
origin: "cloud".into(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(state
|
||||
.db
|
||||
.last_energy_reading(device_id, "gree_cloud")?
|
||||
.map(|reading| DeviceGroupEnergySnapshot {
|
||||
total_kwh: reading.normalized_meter_kwh,
|
||||
timestamp: Some(reading.timestamp),
|
||||
source: "gree_cloud".into(),
|
||||
origin: "database".into(),
|
||||
}))
|
||||
}
|
||||
Some("home_assistant") => {
|
||||
let storage_id = format!("group:{}", group.id);
|
||||
Ok(state
|
||||
.db
|
||||
.last_energy_reading(&storage_id, "home_assistant")?
|
||||
.map(|reading| DeviceGroupEnergySnapshot {
|
||||
total_kwh: reading.normalized_meter_kwh,
|
||||
timestamp: Some(reading.timestamp),
|
||||
source: "home_assistant".into(),
|
||||
origin: "database".into(),
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct BootstrapResponse {
|
||||
devices: Vec<Device>,
|
||||
zones: Vec<Zone>,
|
||||
groups: Vec<ClimateGroup>,
|
||||
device_groups: Vec<DeviceGroup>,
|
||||
device_group_energy: std::collections::HashMap<String, DeviceGroupEnergySnapshot>,
|
||||
schedules: Vec<Schedule>,
|
||||
automations: Vec<Automation>,
|
||||
flows: Vec<Flow>,
|
||||
access_tokens: Vec<ApiTokenInfo>,
|
||||
settings: SettingsSnapshot,
|
||||
house: HouseSnapshot,
|
||||
outdoor_temperature: Option<f64>,
|
||||
control_plan: crate::models::ControlPlan,
|
||||
control_plan_revision: u64,
|
||||
system: SystemInfoResponse,
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>, request: Request) -> Result<Json<Value>, AppError> {
|
||||
if home_assistant::supervisor_token_detected() {
|
||||
let trusted_supervisor = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|info| home_assistant::is_supervisor_ingress_peer(info.0.ip()))
|
||||
.unwrap_or(false);
|
||||
if !trusted_supervisor {
|
||||
let expected = state.config.app_token.trim();
|
||||
if expected.is_empty() || request_token(&request).as_deref() != Some(expected) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"status": "ok",
|
||||
"name": "gree-controller",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
"control_ready": state.initial_device_sync_complete.load(Ordering::Acquire),
|
||||
"time": Utc::now(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn bootstrap(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(build_bootstrap(&state).await?))
|
||||
}
|
||||
|
||||
async fn build_bootstrap(state: &AppState) -> Result<Value, AppError> {
|
||||
let settings = state.settings.read().await.clone();
|
||||
let devices = state.db.list_devices()?;
|
||||
let device_count = devices.len();
|
||||
let online_count = devices.iter().filter(|value| value.online).count();
|
||||
let simulator_count = devices.iter().filter(|value| value.simulated).count();
|
||||
let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
|
||||
Ok(json!({
|
||||
"devices": devices,
|
||||
"zones": state.db.list_zones()?,
|
||||
"groups": state.db.list_groups()?,
|
||||
"schedules": state.db.list_schedules()?,
|
||||
"automations": state.db.list_automations()?,
|
||||
"flows": state.db.list_flows()?,
|
||||
"access_tokens": state.db.list_api_tokens()?,
|
||||
"settings": public_settings(&settings),
|
||||
"outdoor_temperature": *state.outdoor_temperature.read().await,
|
||||
"system": {
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
"auth_required": !state.config.app_token.trim().is_empty(),
|
||||
"control_ready": state.initial_device_sync_complete.load(Ordering::Acquire),
|
||||
"database": state.config.database.display().to_string(),
|
||||
"device_count": device_count,
|
||||
"online_count": online_count,
|
||||
"simulator_count": simulator_count,
|
||||
"bind": state.config.bind.to_string(),
|
||||
"base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() },
|
||||
"gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() },
|
||||
"gree_received_frames": received_frames_total,
|
||||
"gree_received_frames_by_device": received_frames_by_device,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn system_info(State(state): State<AppState>) -> Result<Json<Value>, AppError> {
|
||||
let devices = state.db.list_devices()?;
|
||||
let (received_frames_total, received_frames_by_device) = state.gree.received_frame_stats();
|
||||
Ok(Json(json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"uptime_seconds": state.started.elapsed().as_secs(),
|
||||
"database": state.config.database.display().to_string(),
|
||||
"device_count": devices.len(),
|
||||
"online_count": devices.iter().filter(|v| v.online).count(),
|
||||
"simulator_count": devices.iter().filter(|v| v.simulated).count(),
|
||||
"control_ready": state.initial_device_sync_complete.load(Ordering::Acquire),
|
||||
"auth_required": !state.config.app_token.trim().is_empty(),
|
||||
"bind": state.config.bind.to_string(),
|
||||
"base_path": if state.config.base_path.is_empty() { "/" } else { state.config.base_path.as_str() },
|
||||
"gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() },
|
||||
"gree_received_frames": received_frames_total,
|
||||
"gree_received_frames_by_device": received_frames_by_device,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn bootstrap(State(state): State<AppState>) -> Result<Json<BootstrapResponse>, AppError> {
|
||||
Ok(Json(build_bootstrap(&state).await?))
|
||||
}
|
||||
|
||||
async fn build_bootstrap(state: &AppState) -> Result<BootstrapResponse, AppError> {
|
||||
let (settings, house_mode, emergency_stop_enabled, emergency_stop_since) = {
|
||||
let settings = state.settings.read().await;
|
||||
(
|
||||
settings_snapshot(&settings),
|
||||
settings.house_mode.clone(),
|
||||
settings.emergency_stop_enabled,
|
||||
settings.emergency_stop_since.clone(),
|
||||
)
|
||||
};
|
||||
let devices = state.db.list_devices()?;
|
||||
let device_groups = state.db.list_device_groups()?;
|
||||
let mut device_group_energy = std::collections::HashMap::new();
|
||||
for group in &device_groups {
|
||||
if let Some(snapshot) = device_group_energy_snapshot(state, group, &devices)? {
|
||||
device_group_energy.insert(group.id.clone(), snapshot);
|
||||
}
|
||||
}
|
||||
let system = build_system_info(state, &devices);
|
||||
let control_plan = engine::get_control_plan_snapshot(state).await?;
|
||||
|
||||
Ok(BootstrapResponse {
|
||||
devices,
|
||||
zones: state.db.list_zones()?,
|
||||
groups: state.db.list_groups()?,
|
||||
device_groups,
|
||||
device_group_energy,
|
||||
schedules: state.db.list_schedules()?,
|
||||
automations: state.db.list_automations()?,
|
||||
flows: state.db.list_flows()?,
|
||||
access_tokens: state.db.list_api_tokens()?,
|
||||
settings,
|
||||
house: HouseSnapshot {
|
||||
mode: house_mode,
|
||||
emergency_stop_enabled,
|
||||
emergency_stop_since,
|
||||
},
|
||||
outdoor_temperature: *state.outdoor_temperature.read().await,
|
||||
control_plan: control_plan.plan.as_ref().clone(),
|
||||
control_plan_revision: control_plan.revision,
|
||||
system,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_system_info(state: &AppState, devices: &[Device]) -> SystemInfoResponse {
|
||||
let (received_frames_total, received_frames_by_device) =
|
||||
state.providers.local().client().received_frame_stats();
|
||||
SystemInfoResponse {
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
uptime_seconds: state.started.elapsed().as_secs(),
|
||||
auth_required: !state.config.app_token.trim().is_empty()
|
||||
|| home_assistant::supervisor_token_detected(),
|
||||
control_ready: state.initial_device_sync_complete.load(Ordering::Acquire),
|
||||
database: state.config.database.display().to_string(),
|
||||
device_count: devices.len(),
|
||||
online_count: devices.iter().filter(|device| device.online).count(),
|
||||
simulator_count: devices.iter().filter(|device| device.simulated).count(),
|
||||
bind: state.config.bind.to_string(),
|
||||
base_path: if state.config.base_path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
state.config.base_path.clone()
|
||||
},
|
||||
public_chart_base_url: state.config.public_chart_base_url.clone(),
|
||||
gree_interface: if state.config.gree_interface.trim().is_empty() {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
state.config.gree_interface.trim().to_string()
|
||||
},
|
||||
gree_received_frames: received_frames_total,
|
||||
gree_received_frames_by_device: received_frames_by_device,
|
||||
}
|
||||
}
|
||||
|
||||
async fn system_info(State(state): State<AppState>) -> Result<Json<SystemInfoResponse>, AppError> {
|
||||
let devices = state.db.list_devices()?;
|
||||
Ok(Json(build_system_info(&state, &devices)))
|
||||
}
|
||||
|
||||
+94
-12
@@ -1,31 +1,114 @@
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WsQuery { token: Option<String> }
|
||||
async fn websocket(State(state): State<AppState>, Query(query): Query<WsQuery>, ws: WebSocketUpgrade) -> Result<Response, AppError> {
|
||||
let expected = state.config.app_token.trim();
|
||||
if !expected.is_empty() && query.token.as_deref() != Some(expected) { return Err(AppError::Unauthorized); }
|
||||
struct WsQuery {
|
||||
token: Option<String>,
|
||||
}
|
||||
async fn websocket(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<WsQuery>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Result<Response, AppError> {
|
||||
if !trusted_home_assistant_ingress_parts(&headers, peer.ip()) {
|
||||
let expected = state.config.app_token.trim();
|
||||
if home_assistant::supervisor_token_detected() && expected.is_empty() {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
if !expected.is_empty() && query.token.as_deref() != Some(expected) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
}
|
||||
Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket)))
|
||||
}
|
||||
|
||||
async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
let initial = match build_bootstrap(&state).await {
|
||||
Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}),
|
||||
Err(err) => json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}),
|
||||
fn control_plan_ws_message(snapshot: &crate::state::ControlPlanSnapshot) -> Value {
|
||||
json!({
|
||||
"event": "control_plan.updated",
|
||||
"timestamp": Utc::now(),
|
||||
"data": {
|
||||
"revision": snapshot.revision,
|
||||
"plan": snapshot.plan.as_ref(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_ws_bootstrap(state: &AppState, socket: &mut WebSocket) -> Result<Option<u64>, ()> {
|
||||
let (message, revision) = match build_bootstrap(state).await {
|
||||
Ok(data) => {
|
||||
let revision = Some(data.control_plan_revision);
|
||||
(
|
||||
json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}),
|
||||
revision,
|
||||
)
|
||||
}
|
||||
Err(err) => (
|
||||
json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}),
|
||||
None,
|
||||
),
|
||||
};
|
||||
if socket.send(Message::Text(initial.to_string())).await.is_err() { return; }
|
||||
socket
|
||||
.send(Message::Text(message.to_string().into()))
|
||||
.await
|
||||
.map_err(|_| ())?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
// Subscribe before building the bootstrap so state changes during bootstrap generation are
|
||||
// queued and can be applied immediately after the first frame.
|
||||
let mut receiver = state.events.subscribe();
|
||||
let mut control_plan = state.subscribe_control_plan();
|
||||
|
||||
let bootstrap_revision = match send_ws_bootstrap(&state, &mut socket).await {
|
||||
Ok(revision) => revision,
|
||||
Err(()) => return,
|
||||
};
|
||||
let mut last_control_plan_revision = bootstrap_revision;
|
||||
|
||||
// If the watch value is exactly the plan embedded in bootstrap, mark it seen to avoid a
|
||||
// duplicate control_plan.updated frame. A newer revision remains pending and is sent below.
|
||||
let current_revision = control_plan
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|snapshot| snapshot.revision);
|
||||
if current_revision == bootstrap_revision {
|
||||
control_plan.borrow_and_update();
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = receiver.recv() => {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
if let Ok(text) = serde_json::to_string(&event) {
|
||||
if socket.send(Message::Text(text)).await.is_err() { break; }
|
||||
if socket.send(Message::Text(text.into())).await.is_err() { break; }
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
tracing::warn!(skipped, "websocket client lagged; sending full bootstrap resync");
|
||||
// Drop the retained stale backlog before taking the replacement snapshot.
|
||||
// Events created while bootstrap is built are queued on this fresh receiver.
|
||||
receiver = state.events.subscribe();
|
||||
match send_ws_bootstrap(&state, &mut socket).await {
|
||||
Ok(revision) => last_control_plan_revision = revision,
|
||||
Err(()) => break,
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
changed = control_plan.changed() => {
|
||||
if changed.is_err() { break; }
|
||||
let snapshot = control_plan.borrow_and_update().clone();
|
||||
if let Some(snapshot) = snapshot {
|
||||
if last_control_plan_revision == Some(snapshot.revision) {
|
||||
continue;
|
||||
}
|
||||
let text = control_plan_ws_message(snapshot.as_ref()).to_string();
|
||||
if socket.send(Message::Text(text.into())).await.is_err() { break; }
|
||||
last_control_plan_revision = Some(snapshot.revision);
|
||||
}
|
||||
}
|
||||
message = socket.next() => {
|
||||
match message {
|
||||
Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } }
|
||||
@@ -37,4 +120,3 @@ async fn websocket_loop(state: AppState, mut socket: WebSocket) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+895
-332
File diff suppressed because it is too large
Load Diff
+250
-66
@@ -1,30 +1,59 @@
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
use crate::models::{
|
||||
DebugSettings, GreeCloudSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings,
|
||||
NotificationSettings, RuntimeSettings,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use crate::models::{DebugSettings, HomeAssistantSettings, InfluxDbSettings, NightModeSettings, NotificationSettings, RuntimeSettings};
|
||||
use std::{env, net::SocketAddr, path::PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(author, version, about)]
|
||||
pub struct Config {
|
||||
#[arg(long, env = "GREE_CONTROLLER_BIND", default_value = "0.0.0.0:8787")]
|
||||
pub bind: SocketAddr,
|
||||
#[arg(long, env = "GREE_CONTROLLER_DATABASE", default_value = "./data/gree-controller.db")]
|
||||
#[arg(
|
||||
long,
|
||||
env = "GREE_CONTROLLER_DATABASE",
|
||||
default_value = "./data/gree-controller.db"
|
||||
)]
|
||||
pub database: PathBuf,
|
||||
#[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")]
|
||||
pub app_token: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_BASE_PATH", default_value = "")]
|
||||
pub base_path: String,
|
||||
#[arg(
|
||||
long,
|
||||
env = "GREE_CONTROLLER_PUBLIC_CHART_BASE_URL",
|
||||
default_value = ""
|
||||
)]
|
||||
pub public_chart_base_url: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)]
|
||||
pub simulate: bool,
|
||||
#[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)]
|
||||
pub auto_seed: bool,
|
||||
#[arg(long, env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", default_value_t = 15)]
|
||||
#[arg(
|
||||
long,
|
||||
env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS",
|
||||
default_value_t = 15
|
||||
)]
|
||||
pub poll_interval_seconds: u64,
|
||||
#[arg(long, env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS", default_value_t = 5)]
|
||||
#[arg(
|
||||
long,
|
||||
env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS",
|
||||
default_value_t = 5
|
||||
)]
|
||||
pub zone_interval_seconds: u64,
|
||||
#[arg(long, env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS", default_value_t = 3000)]
|
||||
#[arg(
|
||||
long,
|
||||
env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS",
|
||||
default_value_t = 3000
|
||||
)]
|
||||
pub discovery_timeout_ms: u64,
|
||||
#[arg(long, env = "GREE_CONTROLLER_DISCOVERY_BROADCAST", default_value = "255.255.255.255:7000")]
|
||||
#[arg(
|
||||
long,
|
||||
env = "GREE_CONTROLLER_DISCOVERY_BROADCAST",
|
||||
default_value = "255.255.255.255:7000"
|
||||
)]
|
||||
pub discovery_broadcast: String,
|
||||
#[arg(long, env = "GREE_CONTROLLER_GREE_INTERFACE", default_value = "")]
|
||||
pub gree_interface: String,
|
||||
@@ -37,9 +66,12 @@ impl Config {
|
||||
dotenvy::dotenv().ok();
|
||||
let mut config = Self::parse();
|
||||
config.base_path = normalize_base_path(&config.base_path)?;
|
||||
config.public_chart_base_url =
|
||||
normalize_public_chart_base_url(&config.public_chart_base_url)?;
|
||||
if let Some(parent) = config.database.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create database directory {}", parent.display()))?;
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("cannot create database directory {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
@@ -52,39 +84,68 @@ impl Config {
|
||||
zone_interval_seconds: self.zone_interval_seconds.max(2),
|
||||
discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000),
|
||||
discovery_broadcast: self.discovery_broadcast.clone(),
|
||||
ping_metrics_enabled: env_bool("GREE_CONTROLLER_PING_METRICS_ENABLED").unwrap_or(true),
|
||||
ping_interval_seconds: env_u64("GREE_CONTROLLER_PING_INTERVAL_SECONDS")
|
||||
.unwrap_or(60)
|
||||
.clamp(10, 3600),
|
||||
ping_sample_count: env_u32("GREE_CONTROLLER_PING_SAMPLE_COUNT")
|
||||
.unwrap_or(3)
|
||||
.clamp(1, 10),
|
||||
house_mode: env::var("GREE_CONTROLLER_HOUSE_MODE").unwrap_or_else(|_| "cool".into()),
|
||||
house_power_enabled: true,
|
||||
emergency_stop_enabled: false,
|
||||
emergency_stop_since: None,
|
||||
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),
|
||||
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED").unwrap_or(true),
|
||||
event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(30).clamp(1, 3650),
|
||||
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),
|
||||
history_compaction_enabled: env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED")
|
||||
.unwrap_or(true),
|
||||
event_log_retention_days: env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS")
|
||||
.unwrap_or(30)
|
||||
.clamp(1, 3650),
|
||||
suppress_device_beep: env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP").unwrap_or(false),
|
||||
compressor_protection_enabled: env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED").unwrap_or(true),
|
||||
compressor_protection_seconds: env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS").unwrap_or(180).clamp(30, 1800),
|
||||
compressor_protection_enabled: env_bool(
|
||||
"GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED",
|
||||
)
|
||||
.unwrap_or(true),
|
||||
compressor_protection_seconds: env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS")
|
||||
.unwrap_or(180)
|
||||
.clamp(30, 1800),
|
||||
influxdb: influx_settings_from_env(),
|
||||
debug: DebugSettings {
|
||||
overlay_enabled: env_bool("GREE_CONTROLLER_DEBUG_OVERLAY").unwrap_or(false),
|
||||
gree_frames: env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES").unwrap_or(false),
|
||||
cloud_requests: env_bool("GREE_CONTROLLER_DEBUG_CLOUD_REQUESTS").unwrap_or(false),
|
||||
cloud_mqtt: env_bool("GREE_CONTROLLER_DEBUG_CLOUD_MQTT").unwrap_or(false),
|
||||
},
|
||||
notifications: NotificationSettings::default(),
|
||||
gree_cloud: GreeCloudSettings::default(),
|
||||
night_mode: NightModeSettings {
|
||||
enabled: env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED").unwrap_or(false),
|
||||
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START").unwrap_or_else(|_| "22:00".into()),
|
||||
end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END").unwrap_or_else(|_| "06:00".into()),
|
||||
max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED").unwrap_or(1).clamp(1, 5),
|
||||
start_time: env::var("GREE_CONTROLLER_NIGHT_MODE_START")
|
||||
.unwrap_or_else(|_| "22:00".into()),
|
||||
end_time: env::var("GREE_CONTROLLER_NIGHT_MODE_END")
|
||||
.unwrap_or_else(|_| "06:00".into()),
|
||||
max_fan_speed: env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED")
|
||||
.unwrap_or(1)
|
||||
.clamp(1, 5),
|
||||
force_quiet: env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET").unwrap_or(true),
|
||||
use_native_sleep: env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP").unwrap_or(true),
|
||||
use_native_sleep: env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP")
|
||||
.unwrap_or(true),
|
||||
},
|
||||
home_assistant: HomeAssistantSettings {
|
||||
url: env::var("HA_URL").unwrap_or_default(),
|
||||
token: env::var("HA_TOKEN").unwrap_or_default(),
|
||||
default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(),
|
||||
outdoor_entity_id: env::var("HA_OUTDOOR_ENTITY_ID").unwrap_or_default(),
|
||||
sensor_stale_after_seconds: env_u64("HA_SENSOR_STALE_AFTER_SECONDS").unwrap_or(300).clamp(30, 86_400),
|
||||
sensor_stale_after_seconds: env_u64("HA_SENSOR_STALE_AFTER_SECONDS")
|
||||
.unwrap_or(300)
|
||||
.clamp(30, 86_400),
|
||||
allow_invalid_tls: env::var("HA_ALLOW_INVALID_TLS")
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false),
|
||||
manual_auth_override: false,
|
||||
sensor_aliases: Default::default(),
|
||||
flow_inputs: Vec::new(),
|
||||
},
|
||||
@@ -93,33 +154,96 @@ impl Config {
|
||||
|
||||
/// Environment values explicitly supplied by the service override persisted runtime values.
|
||||
pub fn apply_runtime_env_overrides(&self, settings: &mut RuntimeSettings) {
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_PING_METRICS_ENABLED") {
|
||||
settings.ping_metrics_enabled = value;
|
||||
}
|
||||
if let Some(value) = env_u64("GREE_CONTROLLER_PING_INTERVAL_SECONDS") {
|
||||
settings.ping_interval_seconds = value.clamp(10, 3600);
|
||||
}
|
||||
if let Some(value) = env_u32("GREE_CONTROLLER_PING_SAMPLE_COUNT") {
|
||||
settings.ping_sample_count = value.clamp(1, 10);
|
||||
}
|
||||
if env::var_os("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").is_some() {
|
||||
settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS").unwrap_or(settings.history_retention_days).clamp(1, 3650);
|
||||
settings.history_retention_days = env_u32("GREE_CONTROLLER_HISTORY_RETENTION_DAYS")
|
||||
.unwrap_or(settings.history_retention_days)
|
||||
.clamp(1, 3650);
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") {
|
||||
settings.history_compaction_enabled = value;
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_HISTORY_COMPACTION_ENABLED") { settings.history_compaction_enabled = value; }
|
||||
if env::var_os("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").is_some() {
|
||||
settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS").unwrap_or(settings.event_log_retention_days).clamp(1, 3650);
|
||||
settings.event_log_retention_days = env_u32("GREE_CONTROLLER_EVENT_LOG_RETENTION_DAYS")
|
||||
.unwrap_or(settings.event_log_retention_days)
|
||||
.clamp(1, 3650);
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") {
|
||||
settings.suppress_device_beep = value;
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED") {
|
||||
settings.compressor_protection_enabled = value;
|
||||
}
|
||||
if let Some(value) = env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") {
|
||||
settings.compressor_protection_seconds = value.clamp(30, 1800);
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") {
|
||||
settings.debug.overlay_enabled = value;
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") {
|
||||
settings.debug.gree_frames = value;
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_CLOUD_REQUESTS") {
|
||||
settings.debug.cloud_requests = value;
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_CLOUD_MQTT") {
|
||||
settings.debug.cloud_mqtt = value;
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") {
|
||||
settings.night_mode.enabled = value;
|
||||
}
|
||||
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") {
|
||||
if !value.trim().is_empty() {
|
||||
settings.night_mode.start_time = value;
|
||||
}
|
||||
}
|
||||
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") {
|
||||
if !value.trim().is_empty() {
|
||||
settings.night_mode.end_time = value;
|
||||
}
|
||||
}
|
||||
if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") {
|
||||
settings.night_mode.max_fan_speed = value.clamp(1, 5);
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") {
|
||||
settings.night_mode.force_quiet = value;
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") {
|
||||
settings.night_mode.use_native_sleep = value;
|
||||
}
|
||||
if let Some(value) = env_u64("HA_SENSOR_STALE_AFTER_SECONDS") {
|
||||
settings.home_assistant.sensor_stale_after_seconds = value.clamp(30, 86_400);
|
||||
}
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_SUPPRESS_DEVICE_BEEP") { settings.suppress_device_beep = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_COMPRESSOR_PROTECTION_ENABLED") { settings.compressor_protection_enabled = value; }
|
||||
if let Some(value) = env_u64("GREE_CONTROLLER_COMPRESSOR_PROTECTION_SECONDS") { settings.compressor_protection_seconds = value.clamp(30, 1800); }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_OVERLAY") { settings.debug.overlay_enabled = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_DEBUG_GREE_FRAMES") { settings.debug.gree_frames = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_ENABLED") { settings.night_mode.enabled = value; }
|
||||
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_START") { if !value.trim().is_empty() { settings.night_mode.start_time = value; } }
|
||||
if let Ok(value) = env::var("GREE_CONTROLLER_NIGHT_MODE_END") { if !value.trim().is_empty() { settings.night_mode.end_time = value; } }
|
||||
if let Some(value) = env_u8("GREE_CONTROLLER_NIGHT_MODE_MAX_FAN_SPEED") { settings.night_mode.max_fan_speed = value.clamp(1, 5); }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_FORCE_QUIET") { settings.night_mode.force_quiet = value; }
|
||||
if let Some(value) = env_bool("GREE_CONTROLLER_NIGHT_MODE_NATIVE_SLEEP") { settings.night_mode.use_native_sleep = value; }
|
||||
if let Some(value) = env_u64("HA_SENSOR_STALE_AFTER_SECONDS") { settings.home_assistant.sensor_stale_after_seconds = value.clamp(30, 86_400); }
|
||||
|
||||
let influx_env_present = [
|
||||
"GREE_CONTROLLER_INFLUX_ENABLED", "GREE_CONTROLLER_INFLUX_VERSION", "GREE_CONTROLLER_INFLUX_URL",
|
||||
"GREE_CONTROLLER_INFLUX_DATABASE", "GREE_CONTROLLER_INFLUX_USERNAME", "GREE_CONTROLLER_INFLUX_PASSWORD",
|
||||
"GREE_CONTROLLER_INFLUX_ORG", "GREE_CONTROLLER_INFLUX_BUCKET", "GREE_CONTROLLER_INFLUX_TOKEN",
|
||||
"GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS", "INFLUXDB_URL", "INFLUXDB_DATABASE", "INFLUXDB_USERNAME",
|
||||
"INFLUXDB_PASSWORD", "INFLUXDB_TOKEN", "INFLUXDB_ORG", "INFLUXDB_BUCKET",
|
||||
].iter().any(|name| env::var_os(name).is_some());
|
||||
"GREE_CONTROLLER_INFLUX_ENABLED",
|
||||
"GREE_CONTROLLER_INFLUX_VERSION",
|
||||
"GREE_CONTROLLER_INFLUX_URL",
|
||||
"GREE_CONTROLLER_INFLUX_DATABASE",
|
||||
"GREE_CONTROLLER_INFLUX_USERNAME",
|
||||
"GREE_CONTROLLER_INFLUX_PASSWORD",
|
||||
"GREE_CONTROLLER_INFLUX_ORG",
|
||||
"GREE_CONTROLLER_INFLUX_BUCKET",
|
||||
"GREE_CONTROLLER_INFLUX_TOKEN",
|
||||
"GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS",
|
||||
"INFLUXDB_URL",
|
||||
"INFLUXDB_DATABASE",
|
||||
"INFLUXDB_USERNAME",
|
||||
"INFLUXDB_PASSWORD",
|
||||
"INFLUXDB_TOKEN",
|
||||
"INFLUXDB_ORG",
|
||||
"INFLUXDB_BUCKET",
|
||||
]
|
||||
.iter()
|
||||
.any(|name| env::var_os(name).is_some());
|
||||
if influx_env_present {
|
||||
let env_settings = influx_settings_from_env();
|
||||
if env::var_os("GREE_CONTROLLER_INFLUX_ENABLED").is_some() {
|
||||
@@ -127,35 +251,87 @@ impl Config {
|
||||
} else if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() {
|
||||
settings.influxdb.enabled = true;
|
||||
}
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).is_some() { settings.influxdb.version = env_settings.version; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() { settings.influxdb.url = env_settings.url; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).is_some() { settings.influxdb.database = env_settings.database; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).is_some() { settings.influxdb.username = env_settings.username; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).is_some() { settings.influxdb.password = env_settings.password; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).is_some() { settings.influxdb.org = env_settings.org; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).is_some() { settings.influxdb.bucket = env_settings.bucket; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).is_some() { settings.influxdb.token = env_settings.token; }
|
||||
if env::var_os("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").is_some() { settings.influxdb.history_threshold_days = env_settings.history_threshold_days; }
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).is_some() {
|
||||
settings.influxdb.version = env_settings.version;
|
||||
}
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).is_some() {
|
||||
settings.influxdb.url = env_settings.url;
|
||||
}
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).is_some() {
|
||||
settings.influxdb.database = env_settings.database;
|
||||
}
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).is_some() {
|
||||
settings.influxdb.username = env_settings.username;
|
||||
}
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).is_some() {
|
||||
settings.influxdb.password = env_settings.password;
|
||||
}
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).is_some() {
|
||||
settings.influxdb.org = env_settings.org;
|
||||
}
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).is_some() {
|
||||
settings.influxdb.bucket = env_settings.bucket;
|
||||
}
|
||||
if first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).is_some() {
|
||||
settings.influxdb.token = env_settings.token;
|
||||
}
|
||||
if env::var_os("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").is_some() {
|
||||
settings.influxdb.history_threshold_days = env_settings.history_threshold_days;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_base_path(value: &str) -> Result<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value == "/" { return Ok(String::new()); }
|
||||
if value.contains('?') || value.contains('#') || value.split('/').any(|part| matches!(part, "." | "..")) {
|
||||
if value.is_empty() || value == "/" {
|
||||
return Ok(String::new());
|
||||
}
|
||||
if value.contains('?')
|
||||
|| value.contains('#')
|
||||
|| value.split('/').any(|part| matches!(part, "." | ".."))
|
||||
{
|
||||
anyhow::bail!("GREE_CONTROLLER_BASE_PATH must be a simple URL path without '.', '..', query or fragment");
|
||||
}
|
||||
Ok(format!("/{}", value.trim_matches('/')))
|
||||
}
|
||||
|
||||
fn env_bool(name: &str) -> Option<bool> {
|
||||
env::var(name).ok().map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
fn normalize_public_chart_base_url(value: &str) -> Result<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
let parsed = url::Url::parse(value)
|
||||
.with_context(|| "GREE_CONTROLLER_PUBLIC_CHART_BASE_URL must be an absolute HTTP(S) URL")?;
|
||||
if !matches!(parsed.scheme(), "http" | "https")
|
||||
|| parsed.host_str().is_none()
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
anyhow::bail!("GREE_CONTROLLER_PUBLIC_CHART_BASE_URL must be an absolute HTTP(S) URL without credentials, query or fragment");
|
||||
}
|
||||
|
||||
Ok(value.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn env_u32(name: &str) -> Option<u32> { env::var(name).ok()?.parse().ok() }
|
||||
fn env_u64(name: &str) -> Option<u64> { env::var(name).ok()?.parse().ok() }
|
||||
fn env_u8(name: &str) -> Option<u8> { env::var(name).ok()?.parse().ok() }
|
||||
fn env_bool(name: &str) -> Option<bool> {
|
||||
env::var(name)
|
||||
.ok()
|
||||
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
|
||||
}
|
||||
|
||||
fn env_u32(name: &str) -> Option<u32> {
|
||||
env::var(name).ok()?.parse().ok()
|
||||
}
|
||||
fn env_u64(name: &str) -> Option<u64> {
|
||||
env::var(name).ok()?.parse().ok()
|
||||
}
|
||||
fn env_u8(name: &str) -> Option<u8> {
|
||||
env::var(name).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
fn first_env(names: &[&str]) -> Option<String> {
|
||||
names.iter().find_map(|name| {
|
||||
@@ -168,13 +344,21 @@ fn influx_settings_from_env() -> InfluxDbSettings {
|
||||
let mut settings = InfluxDbSettings::default();
|
||||
settings.version = first_env(&["GREE_CONTROLLER_INFLUX_VERSION"]).unwrap_or_else(|| "2".into());
|
||||
settings.url = first_env(&["GREE_CONTROLLER_INFLUX_URL", "INFLUXDB_URL"]).unwrap_or_default();
|
||||
settings.enabled = env_bool("GREE_CONTROLLER_INFLUX_ENABLED").unwrap_or(!settings.url.is_empty());
|
||||
settings.database = first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"]).unwrap_or_else(|| "gree_controller".into());
|
||||
settings.username = first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).unwrap_or_default();
|
||||
settings.password = first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).unwrap_or_default();
|
||||
settings.enabled =
|
||||
env_bool("GREE_CONTROLLER_INFLUX_ENABLED").unwrap_or(!settings.url.is_empty());
|
||||
settings.database = first_env(&["GREE_CONTROLLER_INFLUX_DATABASE", "INFLUXDB_DATABASE"])
|
||||
.unwrap_or_else(|| "gree_controller".into());
|
||||
settings.username =
|
||||
first_env(&["GREE_CONTROLLER_INFLUX_USERNAME", "INFLUXDB_USERNAME"]).unwrap_or_default();
|
||||
settings.password =
|
||||
first_env(&["GREE_CONTROLLER_INFLUX_PASSWORD", "INFLUXDB_PASSWORD"]).unwrap_or_default();
|
||||
settings.org = first_env(&["GREE_CONTROLLER_INFLUX_ORG", "INFLUXDB_ORG"]).unwrap_or_default();
|
||||
settings.bucket = first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"]).unwrap_or_else(|| "gree_controller".into());
|
||||
settings.token = first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).unwrap_or_default();
|
||||
settings.history_threshold_days = env_u32("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS").unwrap_or(30).clamp(1, 3650);
|
||||
settings.bucket = first_env(&["GREE_CONTROLLER_INFLUX_BUCKET", "INFLUXDB_BUCKET"])
|
||||
.unwrap_or_else(|| "gree_controller".into());
|
||||
settings.token =
|
||||
first_env(&["GREE_CONTROLLER_INFLUX_TOKEN", "INFLUXDB_TOKEN"]).unwrap_or_default();
|
||||
settings.history_threshold_days = env_u32("GREE_CONTROLLER_INFLUX_THRESHOLD_DAYS")
|
||||
.unwrap_or(30)
|
||||
.clamp(1, 3650);
|
||||
settings
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
use std::{path::Path, sync::{Arc, Mutex}};
|
||||
use crate::{
|
||||
models::{
|
||||
ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, ConnectionType, Device,
|
||||
DeviceGroup, EnergyReading, EnergySourcePreference, EventLog, Flow, HaReading,
|
||||
NetworkReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading,
|
||||
},
|
||||
queries,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use serde_json::Value;
|
||||
use crate::{
|
||||
models::{ApiTokenInfo, Automation, ClimateGroup, ConfigurationExport, Device, EventLog, Flow, HaReading, Reading, RuntimeSettings, Schedule, Zone, ZoneReading},
|
||||
queries,
|
||||
use std::{
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -14,7 +21,6 @@ pub struct Db {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
|
||||
// Functional source split intentionally keeps items in the existing module namespace.
|
||||
include!("db/core_devices.rs");
|
||||
include!("db/climate.rs");
|
||||
@@ -23,6 +29,8 @@ include!("db/flows.rs");
|
||||
include!("db/device_history.rs");
|
||||
include!("db/zone_history.rs");
|
||||
include!("db/ha_history.rs");
|
||||
include!("db/energy_history.rs");
|
||||
include!("db/network_history.rs");
|
||||
include!("db/events_tokens.rs");
|
||||
include!("db/configuration.rs");
|
||||
include!("db/tests.rs");
|
||||
|
||||
+46
-6
@@ -49,6 +49,34 @@ impl Db {
|
||||
self.delete_by_id("groups", id)
|
||||
}
|
||||
|
||||
pub fn save_device_group(&self, group: &DeviceGroup) -> Result<()> {
|
||||
let payload = Self::to_json(group)?;
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_DEVICE_GROUP,
|
||||
params![group.id, payload, group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_device_groups(&self) -> Result<Vec<DeviceGroup>> {
|
||||
self.list_payloads(queries::LIST_DEVICE_GROUPS)
|
||||
}
|
||||
|
||||
pub fn get_device_group(&self, id: &str) -> Result<Option<DeviceGroup>> {
|
||||
self.get_payload(queries::GET_DEVICE_GROUP, id)
|
||||
}
|
||||
|
||||
pub fn device_group_for_device(&self, device_id: &str) -> Result<Option<DeviceGroup>> {
|
||||
Ok(self
|
||||
.list_device_groups()?
|
||||
.into_iter()
|
||||
.find(|group| group.device_ids.iter().any(|id| id == device_id)))
|
||||
}
|
||||
|
||||
pub fn delete_device_group(&self, id: &str) -> Result<bool> {
|
||||
self.delete_by_id("device_groups", id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Db {
|
||||
@@ -62,8 +90,12 @@ impl Db {
|
||||
mode_changed: bool,
|
||||
at: DateTime<Utc>,
|
||||
) -> Result<Vec<Zone>> {
|
||||
if !power_changed && !mode_changed { return Ok(Vec::new()); }
|
||||
let zone_ids: Vec<String> = self.list_zones()?.into_iter()
|
||||
if !power_changed && !mode_changed {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let zone_ids: Vec<String> = self
|
||||
.list_zones()?
|
||||
.into_iter()
|
||||
.filter(|zone| zone.device_id == device_id)
|
||||
.map(|zone| zone.id)
|
||||
.collect();
|
||||
@@ -71,10 +103,16 @@ impl Db {
|
||||
for zone_id in zone_ids {
|
||||
let mut saved = false;
|
||||
for _ in 0..8 {
|
||||
let Some(mut zone) = self.get_zone(&zone_id)? else { break; };
|
||||
let Some(mut zone) = self.get_zone(&zone_id)? else {
|
||||
break;
|
||||
};
|
||||
let expected_updated_at = zone.updated_at.to_rfc3339();
|
||||
if power_changed { zone.last_power_change_at = Some(at); }
|
||||
if mode_changed { zone.last_mode_change_at = Some(at); }
|
||||
if power_changed {
|
||||
zone.last_power_change_at = Some(at);
|
||||
}
|
||||
if mode_changed {
|
||||
zone.last_mode_change_at = Some(at);
|
||||
}
|
||||
let payload = Self::to_json(&zone)?;
|
||||
let conn = self.lock()?;
|
||||
let changed = conn.execute(
|
||||
@@ -89,7 +127,9 @@ impl Db {
|
||||
}
|
||||
}
|
||||
if !saved && self.get_zone(&zone_id)?.is_some() {
|
||||
return Err(anyhow::anyhow!("zone {zone_id} kept changing while device transition timestamps were merged"));
|
||||
return Err(anyhow::anyhow!(
|
||||
"zone {zone_id} kept changing while device transition timestamps were merged"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(updated)
|
||||
|
||||
+65
-10
@@ -1,12 +1,13 @@
|
||||
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()?,
|
||||
zones: self.list_zones()?,
|
||||
groups: self.list_groups()?,
|
||||
device_groups: self.list_device_groups()?,
|
||||
schedules: self.list_schedules()?,
|
||||
automations: self.list_automations()?,
|
||||
flows: self.list_flows()?,
|
||||
@@ -18,38 +19,92 @@ impl Db {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(queries::CLEAR_CONFIGURATION)?;
|
||||
for device in &export.devices {
|
||||
let payload = Self::to_json(device)?;
|
||||
tx.execute(queries::UPSERT_DEVICE, params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()])?;
|
||||
let payload = Self::device_to_storage_json(device)?;
|
||||
let index_mac = match device.connection_type {
|
||||
ConnectionType::Local => device.mac.clone(),
|
||||
ConnectionType::GreeCloud => format!(
|
||||
"cloud:{}",
|
||||
device.cloud_device_id.as_deref().unwrap_or(&device.mac)
|
||||
),
|
||||
};
|
||||
tx.execute(
|
||||
queries::UPSERT_DEVICE,
|
||||
params![
|
||||
device.id,
|
||||
index_mac,
|
||||
device.name,
|
||||
device.ip,
|
||||
device.simulated as i64,
|
||||
payload,
|
||||
device.updated_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for zone in &export.zones {
|
||||
let payload = Self::to_json(zone)?;
|
||||
tx.execute(queries::UPSERT_ZONE, params![zone.id, payload, zone.updated_at.to_rfc3339()])?;
|
||||
tx.execute(
|
||||
queries::UPSERT_ZONE,
|
||||
params![zone.id, payload, zone.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
for group in &export.groups {
|
||||
let payload = Self::to_json(group)?;
|
||||
tx.execute(queries::UPSERT_GROUP, params![group.id, payload, group.updated_at.to_rfc3339()])?;
|
||||
tx.execute(
|
||||
queries::UPSERT_GROUP,
|
||||
params![group.id, payload, group.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
for device_group in &export.device_groups {
|
||||
let payload = Self::to_json(device_group)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_DEVICE_GROUP,
|
||||
params![
|
||||
device_group.id,
|
||||
payload,
|
||||
device_group.updated_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for schedule in &export.schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?;
|
||||
tx.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![
|
||||
schedule.id,
|
||||
schedule.zone_id,
|
||||
payload,
|
||||
schedule.updated_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for item in &export.automations {
|
||||
let payload = Self::to_json(item)?;
|
||||
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?;
|
||||
tx.execute(
|
||||
queries::UPSERT_AUTOMATION,
|
||||
params![item.id, payload, item.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
for flow in &export.flows {
|
||||
let payload = Self::to_json(flow)?;
|
||||
tx.execute(queries::UPSERT_FLOW, params![flow.id, payload, flow.updated_at.to_rfc3339()])?;
|
||||
tx.execute(
|
||||
queries::UPSERT_FLOW,
|
||||
params![flow.id, payload, flow.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
let settings_json = Self::to_json(&export.settings)?;
|
||||
tx.execute(queries::UPSERT_RUNTIME_SETTINGS, params![settings_json, Utc::now().to_rfc3339()])?;
|
||||
tx.execute(
|
||||
queries::UPSERT_RUNTIME_SETTINGS,
|
||||
params![settings_json, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_runtime_settings(&self) -> Result<Option<RuntimeSettings>> {
|
||||
let conn = self.lock()?;
|
||||
let value: Option<String> = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?;
|
||||
let value: Option<String> = conn
|
||||
.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0))
|
||||
.optional()?;
|
||||
value.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
|
||||
+66
-8
@@ -4,11 +4,15 @@ impl Db {
|
||||
.with_context(|| format!("cannot open SQLite database {}", path.display()))?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
conn.execute_batch(queries::INIT_SCHEMA)?;
|
||||
Ok(Self { conn: Arc::new(Mutex::new(conn)) })
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, Connection>> {
|
||||
self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned"))
|
||||
self.conn
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("database mutex poisoned"))
|
||||
}
|
||||
|
||||
fn from_json<T: DeserializeOwned>(payload: String) -> Result<T> {
|
||||
@@ -19,6 +23,18 @@ impl Db {
|
||||
Ok(serde_json::to_string(value)?)
|
||||
}
|
||||
|
||||
/// Device protocol keys are intentionally omitted by the public `Device` serializer.
|
||||
/// Persist them only in the private SQLite payload so normal API responses never expose them.
|
||||
fn device_to_storage_json(device: &Device) -> Result<String> {
|
||||
let mut value = serde_json::to_value(device)?;
|
||||
if let Some(object) = value.as_object_mut() {
|
||||
if let Some(key) = device.key.as_ref().filter(|key| !key.is_empty()) {
|
||||
object.insert("key".into(), serde_json::Value::String(key.clone()));
|
||||
}
|
||||
}
|
||||
Ok(serde_json::to_string(&value)?)
|
||||
}
|
||||
|
||||
pub fn count_devices(&self) -> Result<u64> {
|
||||
let conn = self.lock()?;
|
||||
let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?;
|
||||
@@ -26,11 +42,26 @@ impl Db {
|
||||
}
|
||||
|
||||
pub fn save_device(&self, device: &Device) -> Result<()> {
|
||||
let payload = Self::to_json(device)?;
|
||||
let payload = Self::device_to_storage_json(device)?;
|
||||
let index_mac = match device.connection_type {
|
||||
ConnectionType::Local => device.mac.clone(),
|
||||
ConnectionType::GreeCloud => format!(
|
||||
"cloud:{}",
|
||||
device.cloud_device_id.as_deref().unwrap_or(&device.mac)
|
||||
),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_DEVICE,
|
||||
params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()],
|
||||
params![
|
||||
device.id,
|
||||
index_mac,
|
||||
device.name,
|
||||
device.ip,
|
||||
device.simulated as i64,
|
||||
payload,
|
||||
device.updated_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -38,27 +69,55 @@ impl Db {
|
||||
pub fn list_devices(&self) -> Result<Vec<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICES)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
let payloads = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
|
||||
pub fn get_device(&self, id: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?;
|
||||
let payload: Option<String> = conn
|
||||
.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0))
|
||||
.optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn get_device_by_mac(&self, mac: &str) -> Result<Option<Device>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?;
|
||||
let payload: Option<String> = conn
|
||||
.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0))
|
||||
.optional()?;
|
||||
payload.map(Self::from_json).transpose()
|
||||
}
|
||||
|
||||
pub fn delete_device(&self, id: &str) -> Result<bool> {
|
||||
for mut group in self.list_device_groups()? {
|
||||
if !group.device_ids.iter().any(|device_id| device_id == id) {
|
||||
continue;
|
||||
}
|
||||
group.device_ids.retain(|device_id| device_id != id);
|
||||
if group.energy_device_id.as_deref() == Some(id) {
|
||||
group.energy_device_id = None;
|
||||
if group.energy_source == EnergySourcePreference::GreeCloud {
|
||||
group.energy_source = EnergySourcePreference::Auto;
|
||||
}
|
||||
}
|
||||
if group.outdoor_temperature_device_id.as_deref() == Some(id) {
|
||||
group.outdoor_temperature_device_id = None;
|
||||
}
|
||||
if group.device_ids.is_empty() {
|
||||
self.delete_device_group(&group.id)?;
|
||||
} else {
|
||||
group.updated_at = Utc::now();
|
||||
self.save_device_group(&group)?;
|
||||
}
|
||||
}
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_DEVICE_READINGS, [id])?;
|
||||
tx.execute(queries::DELETE_DEVICE_ENERGY_READINGS, [id])?;
|
||||
tx.execute("DELETE FROM network_readings WHERE target_id=?1", [id])?;
|
||||
tx.execute(queries::DELETE_ZONE_READINGS_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_DEVICE_ID, [id])?;
|
||||
tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?;
|
||||
@@ -66,5 +125,4 @@ impl Db {
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+69
-18
@@ -3,41 +3,76 @@ impl Db {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_READING,
|
||||
params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature,
|
||||
reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source],
|
||||
params![
|
||||
reading.device_id,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.indoor_temperature,
|
||||
reading.outdoor_temperature,
|
||||
reading.target_temperature,
|
||||
reading.power as i64,
|
||||
reading.source
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_readings(&self, device_id: Option<&str>, since: DateTime<Utc>, limit: u32) -> Result<Vec<Reading>> {
|
||||
pub fn list_readings(
|
||||
&self,
|
||||
device_id: Option<&str>,
|
||||
since: DateTime<Utc>,
|
||||
limit: u32,
|
||||
) -> Result<Vec<Reading>> {
|
||||
let conn = self.lock()?;
|
||||
let limit = limit.clamp(1, 5000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
let rows = stmt.query_map(
|
||||
params![device_id, since.to_rfc3339(), limit],
|
||||
Self::map_reading,
|
||||
)?;
|
||||
for row in rows {
|
||||
rows_out.push(row?);
|
||||
}
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
for row in rows {
|
||||
rows_out.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
|
||||
pub fn list_device_history(&self, device_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<Reading>> {
|
||||
pub fn list_device_history(
|
||||
&self,
|
||||
device_id: Option<&str>,
|
||||
since: DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<Vec<Reading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(device_id) = device_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_BY_DEVICE_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![device_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
let rows = stmt.query_map(
|
||||
params![device_id, since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_reading,
|
||||
)?;
|
||||
for row in rows {
|
||||
rows_out.push(row?);
|
||||
}
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_DEVICE_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
let rows = stmt.query_map(
|
||||
params![since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_reading,
|
||||
)?;
|
||||
for row in rows {
|
||||
rows_out.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
@@ -58,7 +93,11 @@ impl Db {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn history_before(&self, before: DateTime<Utc>, limit_per_family: u32) -> Result<(Vec<Reading>, Vec<ZoneReading>, Vec<HaReading>)> {
|
||||
pub fn history_before(
|
||||
&self,
|
||||
before: DateTime<Utc>,
|
||||
limit_per_family: u32,
|
||||
) -> Result<(Vec<Reading>, Vec<ZoneReading>, Vec<HaReading>)> {
|
||||
let conn = self.lock()?;
|
||||
let limit = limit_per_family.clamp(1, 5_000) as i64;
|
||||
let before = before.to_rfc3339();
|
||||
@@ -81,13 +120,24 @@ impl Db {
|
||||
Ok((devices, zones, ha))
|
||||
}
|
||||
|
||||
pub fn delete_history_batch(&self, devices: &[Reading], zones: &[ZoneReading], ha: &[HaReading]) -> Result<u64> {
|
||||
pub fn delete_history_batch(
|
||||
&self,
|
||||
devices: &[Reading],
|
||||
zones: &[ZoneReading],
|
||||
ha: &[HaReading],
|
||||
) -> Result<u64> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
let mut changed = 0_u64;
|
||||
for row in devices { changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64; }
|
||||
for row in zones { changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64; }
|
||||
for row in ha { changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64; }
|
||||
for row in devices {
|
||||
changed += tx.execute(queries::DELETE_READING_BY_ID, [row.id])? as u64;
|
||||
}
|
||||
for row in zones {
|
||||
changed += tx.execute(queries::DELETE_ZONE_READING_BY_ID, [row.id])? as u64;
|
||||
}
|
||||
for row in ha {
|
||||
changed += tx.execute(queries::DELETE_HA_READING_BY_ID, [row.id])? as u64;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
@@ -114,7 +164,9 @@ impl Db {
|
||||
(600_i64, one_day, seven_days),
|
||||
(1800_i64, seven_days, retention),
|
||||
] {
|
||||
if older_than <= newer_than { continue; }
|
||||
if older_than <= newer_than {
|
||||
continue;
|
||||
}
|
||||
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
|
||||
changed += conn.execute(queries::COMPACT_DEVICE_HISTORY, args)? as u64;
|
||||
let args = params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()];
|
||||
@@ -125,5 +177,4 @@ impl Db {
|
||||
conn.execute_batch("PRAGMA optimize;")?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
impl Db {
|
||||
pub fn add_energy_reading(&self, reading: &EnergyReading) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
"INSERT INTO energy_readings(device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
||||
params![
|
||||
reading.device_id,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.source,
|
||||
reading.raw_meter_value,
|
||||
reading.raw_unit,
|
||||
reading.normalized_meter_kwh,
|
||||
reading.consumption_kwh.max(0.0),
|
||||
reading.current_power_kw,
|
||||
reading.quality,
|
||||
reading.reset_detected as i64,
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn last_energy_reading(
|
||||
&self,
|
||||
device_id: &str,
|
||||
source: &str,
|
||||
) -> Result<Option<EnergyReading>> {
|
||||
let conn = self.lock()?;
|
||||
conn.query_row(
|
||||
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND source=?2 ORDER BY timestamp DESC,id DESC LIMIT 1",
|
||||
params![device_id, source],
|
||||
Self::map_energy_reading,
|
||||
).optional().map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn list_energy_readings(
|
||||
&self,
|
||||
device_id: &str,
|
||||
since: DateTime<Utc>,
|
||||
limit: u32,
|
||||
) -> Result<Vec<EnergyReading>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE device_id=?1 AND timestamp>=?2 ORDER BY timestamp ASC,id ASC LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![
|
||||
device_id,
|
||||
since.to_rfc3339(),
|
||||
limit.clamp(1, 100_000) as i64
|
||||
],
|
||||
Self::map_energy_reading,
|
||||
)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn energy_before(&self, before: DateTime<Utc>, limit: u32) -> Result<Vec<EnergyReading>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id,device_id,timestamp,source,raw_meter_value,raw_unit,normalized_meter_kwh,consumption_kwh,current_power_kw,quality,reset_detected FROM energy_readings WHERE timestamp<?1 ORDER BY timestamp ASC,id ASC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![before.to_rfc3339(), limit.clamp(1, 5000) as i64],
|
||||
Self::map_energy_reading,
|
||||
)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn delete_energy_batch(&self, rows: &[EnergyReading]) -> Result<u64> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
let mut changed = 0_u64;
|
||||
for row in rows {
|
||||
changed += tx.execute("DELETE FROM energy_readings WHERE id=?1", [row.id])? as u64;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn prune_energy_readings(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(
|
||||
"DELETE FROM energy_readings WHERE timestamp < ?1",
|
||||
[before.to_rfc3339()],
|
||||
)? as u64)
|
||||
}
|
||||
|
||||
fn map_energy_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<EnergyReading> {
|
||||
let timestamp: String = row.get(2)?;
|
||||
Ok(EnergyReading {
|
||||
id: row.get(0)?,
|
||||
device_id: row.get(1)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
source: row.get(3)?,
|
||||
raw_meter_value: row.get(4)?,
|
||||
raw_unit: row.get(5)?,
|
||||
normalized_meter_kwh: row.get(6)?,
|
||||
consumption_kwh: row.get::<_, f64>(7)?.max(0.0),
|
||||
current_power_kw: row.get(8)?,
|
||||
quality: row.get(9)?,
|
||||
reset_detected: row.get::<_, i64>(10)? != 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
+68
-12
@@ -1,19 +1,42 @@
|
||||
impl Db {
|
||||
pub fn history_counts(&self) -> Result<(u64, u64, u64)> {
|
||||
let conn = self.lock()?;
|
||||
let (device, zone, ha): (i64, i64, i64) = conn.query_row(queries::HISTORY_COUNTS, [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
|
||||
let (device, zone, ha): (i64, i64, i64) =
|
||||
conn.query_row(queries::HISTORY_COUNTS, [], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
})?;
|
||||
Ok((device.max(0) as u64, zone.max(0) as u64, ha.max(0) as u64))
|
||||
}
|
||||
|
||||
pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result<i64> {
|
||||
pub fn log_event(
|
||||
&self,
|
||||
level: &str,
|
||||
kind: &str,
|
||||
message: &str,
|
||||
metadata: &Value,
|
||||
) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_EVENT,
|
||||
params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?],
|
||||
params![
|
||||
Utc::now().to_rfc3339(),
|
||||
level,
|
||||
kind,
|
||||
message,
|
||||
serde_json::to_string(metadata)?
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn update_event_metadata(&self, id: i64, metadata: &Value) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(
|
||||
queries::UPDATE_EVENT_METADATA,
|
||||
params![serde_json::to_string(metadata)?, id],
|
||||
)? > 0)
|
||||
}
|
||||
|
||||
pub fn list_events(&self, limit: u32) -> Result<Vec<EventLog>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(queries::LIST_EVENTS)?;
|
||||
@@ -22,14 +45,17 @@ impl Db {
|
||||
let metadata: String = row.get(5)?;
|
||||
Ok(EventLog {
|
||||
id: row.get(0)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()),
|
||||
timestamp: DateTime::parse_from_rfc3339(&ts)
|
||||
.map(|v| v.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
level: row.get(2)?,
|
||||
kind: row.get(3)?,
|
||||
message: row.get(4)?,
|
||||
metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn prune_events(&self, retention_days: i64) -> Result<u64> {
|
||||
@@ -52,25 +78,30 @@ impl Db {
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>().map_err(Into::into)
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_API_TOKEN,
|
||||
params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()],
|
||||
params![
|
||||
token.id,
|
||||
token.name,
|
||||
token_hash,
|
||||
token.token_prefix,
|
||||
token.created_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn api_token_exists(&self, token_hash: &str) -> Result<bool> {
|
||||
let conn = self.lock()?;
|
||||
let found: Option<i64> = conn.query_row(
|
||||
queries::API_TOKEN_EXISTS,
|
||||
[token_hash],
|
||||
|row| row.get(0),
|
||||
).optional()?;
|
||||
let found: Option<i64> = conn
|
||||
.query_row(queries::API_TOKEN_EXISTS, [token_hash], |row| row.get(0))
|
||||
.optional()?;
|
||||
Ok(found.is_some())
|
||||
}
|
||||
|
||||
@@ -79,4 +110,29 @@ impl Db {
|
||||
Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0)
|
||||
}
|
||||
|
||||
pub fn save_public_chart_share(&self, token_hash: &str, payload: &Value) -> Result<()> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::INSERT_PUBLIC_CHART_SHARE,
|
||||
params![
|
||||
token_hash,
|
||||
serde_json::to_string(payload)?,
|
||||
Utc::now().to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_public_chart_share(&self, token_hash: &str) -> Result<Option<Value>> {
|
||||
let conn = self.lock()?;
|
||||
let payload: Option<String> = conn
|
||||
.query_row(queries::GET_PUBLIC_CHART_SHARE, [token_hash], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
payload
|
||||
.map(|value| serde_json::from_str(&value))
|
||||
.transpose()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
+67
-4
@@ -7,21 +7,84 @@ impl Db {
|
||||
self.get_payload(queries::GET_FLOW, id)
|
||||
}
|
||||
|
||||
pub fn replace_flow_outputs(&self, flow: &Flow, schedules: &[Schedule], automations: &[Automation]) -> Result<()> {
|
||||
pub fn replace_flow_outputs(
|
||||
&self,
|
||||
flow: &Flow,
|
||||
schedules: &[Schedule],
|
||||
automations: &[Automation],
|
||||
) -> Result<()> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_FLOW_ID, [flow.id.as_str()])?;
|
||||
tx.execute(queries::DELETE_AUTOMATIONS_BY_FLOW_ID, [flow.id.as_str()])?;
|
||||
for schedule in schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(queries::UPSERT_SCHEDULE, params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()])?;
|
||||
tx.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![
|
||||
schedule.id,
|
||||
schedule.zone_id,
|
||||
payload,
|
||||
schedule.updated_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for item in automations {
|
||||
let payload = Self::to_json(item)?;
|
||||
tx.execute(queries::UPSERT_AUTOMATION, params![item.id, payload, item.updated_at.to_rfc3339()])?;
|
||||
tx.execute(
|
||||
queries::UPSERT_AUTOMATION,
|
||||
params![item.id, payload, item.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
let payload = Self::to_json(flow)?;
|
||||
tx.execute(queries::UPSERT_FLOW, params![flow.id, payload, flow.updated_at.to_rfc3339()])?;
|
||||
tx.execute(
|
||||
queries::UPSERT_FLOW,
|
||||
params![flow.id, payload, flow.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn replace_flow_outputs_with_runtime_settings(
|
||||
&self,
|
||||
flow: &Flow,
|
||||
schedules: &[Schedule],
|
||||
automations: &[Automation],
|
||||
settings: &RuntimeSettings,
|
||||
) -> Result<()> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(queries::DELETE_SCHEDULES_BY_FLOW_ID, [flow.id.as_str()])?;
|
||||
tx.execute(queries::DELETE_AUTOMATIONS_BY_FLOW_ID, [flow.id.as_str()])?;
|
||||
for schedule in schedules {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![
|
||||
schedule.id,
|
||||
schedule.zone_id,
|
||||
payload,
|
||||
schedule.updated_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
for item in automations {
|
||||
let payload = Self::to_json(item)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_AUTOMATION,
|
||||
params![item.id, payload, item.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
}
|
||||
let flow_payload = Self::to_json(flow)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_FLOW,
|
||||
params![flow.id, flow_payload, flow.updated_at.to_rfc3339()],
|
||||
)?;
|
||||
let settings_payload = Self::to_json(settings)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_RUNTIME_SETTINGS,
|
||||
params![settings_payload, Utc::now().to_rfc3339()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+26
-7
@@ -1,5 +1,9 @@
|
||||
impl Db {
|
||||
pub fn add_ha_reading_if_due(&self, reading: &HaReading, min_interval_seconds: i64) -> Result<bool> {
|
||||
pub fn add_ha_reading_if_due(
|
||||
&self,
|
||||
reading: &HaReading,
|
||||
min_interval_seconds: i64,
|
||||
) -> Result<bool> {
|
||||
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
|
||||
let conn = self.lock()?;
|
||||
let changed = conn.execute(
|
||||
@@ -16,19 +20,35 @@ impl Db {
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_ha_history(&self, entity_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<HaReading>> {
|
||||
pub fn list_ha_history(
|
||||
&self,
|
||||
entity_id: Option<&str>,
|
||||
since: DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<Vec<HaReading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(entity_id) = entity_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_BY_ENTITY_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![entity_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
let rows = stmt.query_map(
|
||||
params![entity_id, since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_ha_reading,
|
||||
)?;
|
||||
for row in rows {
|
||||
rows_out.push(row?);
|
||||
}
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_HA_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_ha_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
let rows = stmt.query_map(
|
||||
params![since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_ha_reading,
|
||||
)?;
|
||||
for row in rows {
|
||||
rows_out.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
@@ -46,5 +66,4 @@ impl Db {
|
||||
temperature: row.get(5)?,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
impl Db {
|
||||
pub fn add_network_reading(&self, reading: &NetworkReading) -> Result<i64> {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
"INSERT INTO network_readings(target_id,target_kind,timestamp,latency_ms,jitter_ms,packet_loss_pct,sample_count,successful_samples,source) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)",
|
||||
params![
|
||||
reading.target_id,
|
||||
reading.target_kind,
|
||||
reading.timestamp.to_rfc3339(),
|
||||
reading.latency_ms,
|
||||
reading.jitter_ms,
|
||||
reading.packet_loss_pct.clamp(0.0, 100.0),
|
||||
reading.sample_count as i64,
|
||||
reading.successful_samples as i64,
|
||||
reading.source,
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn list_network_history(
|
||||
&self,
|
||||
target_id: Option<&str>,
|
||||
since: DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<Vec<NetworkReading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 50_000) as i64;
|
||||
let sql_by_target = r#"
|
||||
SELECT MIN(id),target_id,MAX(target_kind),MIN(timestamp),AVG(latency_ms),AVG(jitter_ms),AVG(packet_loss_pct),
|
||||
CAST(ROUND(AVG(sample_count)) AS INTEGER),CAST(ROUND(AVG(successful_samples)) AS INTEGER),MAX(source)
|
||||
FROM network_readings
|
||||
WHERE target_id=?1 AND timestamp>=?2
|
||||
GROUP BY target_id,source,CAST(unixepoch(timestamp)/?3 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?4
|
||||
"#;
|
||||
let sql_all = r#"
|
||||
SELECT MIN(id),target_id,MAX(target_kind),MIN(timestamp),AVG(latency_ms),AVG(jitter_ms),AVG(packet_loss_pct),
|
||||
CAST(ROUND(AVG(sample_count)) AS INTEGER),CAST(ROUND(AVG(successful_samples)) AS INTEGER),MAX(source)
|
||||
FROM network_readings
|
||||
WHERE timestamp>=?1
|
||||
GROUP BY target_id,source,CAST(unixepoch(timestamp)/?2 AS INTEGER)
|
||||
ORDER BY MIN(timestamp) ASC
|
||||
LIMIT ?3
|
||||
"#;
|
||||
let mut out = Vec::new();
|
||||
if let Some(target_id) = target_id {
|
||||
let mut stmt = conn.prepare(sql_by_target)?;
|
||||
let rows = stmt.query_map(
|
||||
params![target_id, since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_network_reading,
|
||||
)?;
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
} else {
|
||||
let mut stmt = conn.prepare(sql_all)?;
|
||||
let rows = stmt.query_map(
|
||||
params![since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_network_reading,
|
||||
)?;
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn network_before(&self, before: DateTime<Utc>, limit: u32) -> Result<Vec<NetworkReading>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id,target_id,target_kind,timestamp,latency_ms,jitter_ms,packet_loss_pct,sample_count,successful_samples,source FROM network_readings WHERE timestamp<?1 ORDER BY timestamp ASC,id ASC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![before.to_rfc3339(), limit.clamp(1, 5000) as i64],
|
||||
Self::map_network_reading,
|
||||
)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn delete_network_batch(&self, rows: &[NetworkReading]) -> Result<u64> {
|
||||
let mut conn = self.lock()?;
|
||||
let tx = conn.transaction()?;
|
||||
let mut changed = 0_u64;
|
||||
for row in rows {
|
||||
changed += tx.execute("DELETE FROM network_readings WHERE id=?1", [row.id])? as u64;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn prune_network_readings(&self, retention_days: i64) -> Result<u64> {
|
||||
let before = Utc::now() - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(
|
||||
"DELETE FROM network_readings WHERE timestamp < ?1",
|
||||
[before.to_rfc3339()],
|
||||
)? as u64)
|
||||
}
|
||||
|
||||
pub fn compact_network_history(&self, retention_days: i64) -> Result<u64> {
|
||||
let now = Utc::now();
|
||||
let one_day = now - Duration::days(1);
|
||||
let seven_days = now - Duration::days(7);
|
||||
let retention = now - Duration::days(retention_days.max(1));
|
||||
let conn = self.lock()?;
|
||||
let sql = r#"
|
||||
DELETE FROM network_readings WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY target_id,source,CAST(unixepoch(timestamp)/?1 AS INTEGER)
|
||||
ORDER BY timestamp DESC,id DESC
|
||||
) AS rn
|
||||
FROM network_readings WHERE timestamp < ?2 AND timestamp >= ?3
|
||||
) WHERE rn > 1
|
||||
)
|
||||
"#;
|
||||
let mut changed = 0_u64;
|
||||
for (bucket, older_than, newer_than) in [
|
||||
(600_i64, one_day, seven_days),
|
||||
(1800_i64, seven_days, retention),
|
||||
] {
|
||||
if older_than <= newer_than {
|
||||
continue;
|
||||
}
|
||||
changed += conn.execute(
|
||||
sql,
|
||||
params![bucket, older_than.to_rfc3339(), newer_than.to_rfc3339()],
|
||||
)? as u64;
|
||||
}
|
||||
conn.execute_batch("PRAGMA optimize;")?;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn map_network_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result<NetworkReading> {
|
||||
let timestamp: String = row.get(3)?;
|
||||
Ok(NetworkReading {
|
||||
id: row.get(0)?,
|
||||
target_id: row.get(1)?,
|
||||
target_kind: row.get(2)?,
|
||||
timestamp: DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|value| value.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
latency_ms: row.get(4)?,
|
||||
jitter_ms: row.get(5)?,
|
||||
packet_loss_pct: row.get::<_, f64>(6)?.clamp(0.0, 100.0),
|
||||
sample_count: row.get::<_, i64>(7)?.max(0) as u32,
|
||||
successful_samples: row.get::<_, i64>(8)?.max(0) as u32,
|
||||
source: row.get(9)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,12 @@ impl Db {
|
||||
let conn = self.lock()?;
|
||||
conn.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
params![
|
||||
schedule.id,
|
||||
schedule.zone_id,
|
||||
payload,
|
||||
schedule.updated_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -29,7 +34,12 @@ impl Db {
|
||||
let payload = Self::to_json(schedule)?;
|
||||
tx.execute(
|
||||
queries::UPSERT_SCHEDULE,
|
||||
params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()],
|
||||
params![
|
||||
schedule.id,
|
||||
schedule.zone_id,
|
||||
payload,
|
||||
schedule.updated_at.to_rfc3339()
|
||||
],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
@@ -61,7 +71,8 @@ impl Db {
|
||||
fn list_payloads<T: DeserializeOwned>(&self, sql: &str) -> Result<Vec<T>> {
|
||||
let conn = self.lock()?;
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let payloads = stmt.query_map([], |row| row.get::<_, String>(0))?
|
||||
let payloads = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
payloads.into_iter().map(Self::from_json).collect()
|
||||
}
|
||||
@@ -77,10 +88,10 @@ impl Db {
|
||||
"schedules" => queries::DELETE_SCHEDULE,
|
||||
"automations" => queries::DELETE_AUTOMATION,
|
||||
"groups" => queries::DELETE_GROUP,
|
||||
"device_groups" => queries::DELETE_DEVICE_GROUP,
|
||||
_ => anyhow::bail!("unsupported table"),
|
||||
};
|
||||
let conn = self.lock()?;
|
||||
Ok(conn.execute(sql, [id])? > 0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+122
-16
@@ -1,7 +1,7 @@
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{ApiTokenInfo, Device, HaReading, Reading};
|
||||
use crate::models::{ApiTokenInfo, ConnectionType, Device, HaReading, Reading};
|
||||
|
||||
#[test]
|
||||
fn history_compaction_keeps_one_sample_per_old_bucket() {
|
||||
@@ -13,10 +13,16 @@ mod tests {
|
||||
let base = DateTime::<Utc>::from_timestamp(seconds, 0).unwrap();
|
||||
for offset in [10_i64, 20_i64] {
|
||||
db.add_reading(&Reading {
|
||||
id: 0, device_id: device.id.clone(), timestamp: base + Duration::seconds(offset),
|
||||
indoor_temperature: Some(22.0), outdoor_temperature: None, target_temperature: 23.0,
|
||||
power: true, source: "gree".into(),
|
||||
}).unwrap();
|
||||
id: 0,
|
||||
device_id: device.id.clone(),
|
||||
timestamp: base + Duration::seconds(offset),
|
||||
indoor_temperature: Some(22.0),
|
||||
outdoor_temperature: None,
|
||||
target_temperature: 23.0,
|
||||
power: true,
|
||||
source: "gree".into(),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(db.history_counts().unwrap().0, 2);
|
||||
assert_eq!(db.compact_history(30).unwrap(), 1);
|
||||
@@ -32,11 +38,32 @@ mod tests {
|
||||
let loaded = db.get_device(&device.id).unwrap().unwrap();
|
||||
assert_eq!(loaded.mac, device.mac);
|
||||
assert_eq!(db.list_devices().unwrap().len(), 1);
|
||||
db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap();
|
||||
let event_id = db
|
||||
.log_event("info", "test", "ok", &serde_json::json!({"a":1}))
|
||||
.unwrap();
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
db.update_event_metadata(
|
||||
event_id,
|
||||
&serde_json::json!({"a":1,"notification":{"status":"silent"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.list_events(10).unwrap()[0].metadata["notification"]["status"],
|
||||
"silent"
|
||||
);
|
||||
{
|
||||
let conn = db.lock().unwrap();
|
||||
conn.execute(queries::INSERT_EVENT, rusqlite::params![(Utc::now() - Duration::days(40)).to_rfc3339(), "info", "old", "old", "{}"] ).unwrap();
|
||||
conn.execute(
|
||||
queries::INSERT_EVENT,
|
||||
rusqlite::params![
|
||||
(Utc::now() - Duration::days(40)).to_rfc3339(),
|
||||
"info",
|
||||
"old",
|
||||
"old",
|
||||
"{}"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(db.prune_events(30).unwrap(), 1);
|
||||
assert_eq!(db.list_events(10).unwrap().len(), 1);
|
||||
@@ -53,18 +80,97 @@ mod tests {
|
||||
assert!(db.delete_api_token(&access_token.id).unwrap());
|
||||
assert!(!db.api_token_exists("test-hash").unwrap());
|
||||
|
||||
let chart_share = serde_json::json!({
|
||||
"title": "Room temperatures",
|
||||
"series": ["device|dev-1|indoor"],
|
||||
"hours": 24,
|
||||
"lang": "en"
|
||||
});
|
||||
db.save_public_chart_share("chart-hash", &chart_share)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.get_public_chart_share("chart-hash").unwrap(),
|
||||
Some(chart_share)
|
||||
);
|
||||
assert!(db
|
||||
.get_public_chart_share("missing-chart")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
|
||||
let now = Utc::now();
|
||||
db.add_reading(&Reading {
|
||||
id: 0, device_id: device.id.clone(), timestamp: now.clone(), indoor_temperature: Some(22.5),
|
||||
outdoor_temperature: Some(31.0), target_temperature: 23.0, power: true, source: "gree".into(),
|
||||
}).unwrap();
|
||||
assert_eq!(db.list_device_history(Some(&device.id), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
id: 0,
|
||||
device_id: device.id.clone(),
|
||||
timestamp: now.clone(),
|
||||
indoor_temperature: Some(22.5),
|
||||
outdoor_temperature: Some(31.0),
|
||||
target_temperature: 23.0,
|
||||
power: true,
|
||||
source: "gree".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.list_device_history(
|
||||
Some(&device.id),
|
||||
now.clone() - Duration::minutes(1),
|
||||
30,
|
||||
100
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
db.add_ha_reading_if_due(&HaReading {
|
||||
id: 0, entity_id: "sensor.room".into(), zone_id: Some("zone-room".into()), kind: "room".into(),
|
||||
timestamp: now.clone(), temperature: 22.1,
|
||||
}, 15).unwrap();
|
||||
assert_eq!(db.list_ha_history(Some("sensor.room"), now.clone() - Duration::minutes(1), 30, 100).unwrap().len(), 1);
|
||||
db.add_ha_reading_if_due(
|
||||
&HaReading {
|
||||
id: 0,
|
||||
entity_id: "sensor.room".into(),
|
||||
zone_id: Some("zone-room".into()),
|
||||
kind: "room".into(),
|
||||
timestamp: now.clone(),
|
||||
temperature: 22.1,
|
||||
},
|
||||
15,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.list_ha_history(
|
||||
Some("sensor.room"),
|
||||
now.clone() - Duration::minutes(1),
|
||||
30,
|
||||
100
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(db.history_counts().unwrap(), (1, 0, 1));
|
||||
}
|
||||
#[test]
|
||||
fn local_and_cloud_entries_with_same_physical_mac_can_coexist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Db::open(&dir.path().join("duplicate-transport.db")).unwrap();
|
||||
let mut local = Device::simulated_default();
|
||||
local.id = "local-device".into();
|
||||
local.mac = "AABBCCDDEEFF".into();
|
||||
local.connection_type = ConnectionType::Local;
|
||||
let mut cloud = local.clone();
|
||||
cloud.id = "cloud-device".into();
|
||||
cloud.connection_type = ConnectionType::GreeCloud;
|
||||
cloud.cloud_device_id = Some(local.mac.clone());
|
||||
cloud.ip.clear();
|
||||
cloud.port = 0;
|
||||
cloud.key = Some("0123456789abcdef".into());
|
||||
|
||||
db.save_device(&local).unwrap();
|
||||
db.save_device(&cloud).unwrap();
|
||||
let devices = db.list_devices().unwrap();
|
||||
assert_eq!(devices.len(), 2);
|
||||
assert!(devices
|
||||
.iter()
|
||||
.any(|item| item.connection_type == ConnectionType::Local));
|
||||
assert!(devices
|
||||
.iter()
|
||||
.any(|item| item.connection_type == ConnectionType::GreeCloud));
|
||||
}
|
||||
}
|
||||
|
||||
+26
-7
@@ -1,5 +1,9 @@
|
||||
impl Db {
|
||||
pub fn add_zone_reading_if_due(&self, reading: &ZoneReading, min_interval_seconds: i64) -> Result<bool> {
|
||||
pub fn add_zone_reading_if_due(
|
||||
&self,
|
||||
reading: &ZoneReading,
|
||||
min_interval_seconds: i64,
|
||||
) -> Result<bool> {
|
||||
let cutoff = reading.timestamp.clone() - Duration::seconds(min_interval_seconds.max(1));
|
||||
let conn = self.lock()?;
|
||||
let changed = conn.execute(
|
||||
@@ -26,19 +30,35 @@ impl Db {
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
pub fn list_zone_history(&self, zone_id: Option<&str>, since: DateTime<Utc>, bucket_seconds: i64, limit: u32) -> Result<Vec<ZoneReading>> {
|
||||
pub fn list_zone_history(
|
||||
&self,
|
||||
zone_id: Option<&str>,
|
||||
since: DateTime<Utc>,
|
||||
bucket_seconds: i64,
|
||||
limit: u32,
|
||||
) -> Result<Vec<ZoneReading>> {
|
||||
let conn = self.lock()?;
|
||||
let bucket_seconds = bucket_seconds.max(1);
|
||||
let limit = limit.clamp(1, 20_000) as i64;
|
||||
let mut rows_out = Vec::new();
|
||||
if let Some(zone_id) = zone_id {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_BY_ZONE_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![zone_id, since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
let rows = stmt.query_map(
|
||||
params![zone_id, since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_zone_reading,
|
||||
)?;
|
||||
for row in rows {
|
||||
rows_out.push(row?);
|
||||
}
|
||||
} else {
|
||||
let mut stmt = conn.prepare(queries::LIST_ZONE_HISTORY_ALL_BUCKETED)?;
|
||||
let rows = stmt.query_map(params![since.to_rfc3339(), bucket_seconds, limit], Self::map_zone_reading)?;
|
||||
for row in rows { rows_out.push(row?); }
|
||||
let rows = stmt.query_map(
|
||||
params![since.to_rfc3339(), bucket_seconds, limit],
|
||||
Self::map_zone_reading,
|
||||
)?;
|
||||
for row in rows {
|
||||
rows_out.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(rows_out)
|
||||
}
|
||||
@@ -66,5 +86,4 @@ impl Db {
|
||||
active_preset: row.get(15)?,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+20
-9
@@ -1,16 +1,25 @@
|
||||
use std::{collections::HashMap, sync::atomic::Ordering, time::{Duration, Instant}};
|
||||
use crate::{
|
||||
error::AppError,
|
||||
home_assistant, influxdb,
|
||||
models::{
|
||||
Automation, AutomationPlanRule, ClimateGroup, ConnectionStatus, ConnectionType,
|
||||
ControlPlan, ControlPlanEvent, Device, DeviceCommand, DeviceGroup, EnergyReading,
|
||||
HORIZONTAL_SWING_MAX, VERTICAL_SWING_MAX,
|
||||
EnergySourcePreference, GroupControlPatch, HaReading, NetworkReading, NightModeSettings,
|
||||
Reading, RuntimeSettings, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan,
|
||||
ZoneReading,
|
||||
},
|
||||
state::{AppState, ControlPlanSnapshot, PendingControllerCommand},
|
||||
};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::time::sleep;
|
||||
use crate::{
|
||||
error::AppError,
|
||||
home_assistant,
|
||||
influxdb,
|
||||
models::{Automation, AutomationPlanRule, ControlPlan, ControlPlanEvent, Device, DeviceCommand, GroupControlPatch, HaReading, NightModeSettings, Reading, Schedule, TemporaryQuickThermostat, Zone, ZoneControlPlan, ZoneReading},
|
||||
state::{AppState, PendingControllerCommand},
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{atomic::Ordering, Arc},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tokio::{sync::broadcast, time::sleep};
|
||||
|
||||
// Functional source split intentionally keeps items in the existing module namespace.
|
||||
include!("engine/runtime.rs");
|
||||
@@ -24,6 +33,8 @@ include!("engine/groups.rs");
|
||||
include!("engine/zone_actions.rs");
|
||||
include!("engine/zone_control.rs");
|
||||
include!("engine/history.rs");
|
||||
include!("engine/connectivity.rs");
|
||||
include!("engine/energy.rs");
|
||||
include!("engine/temperature.rs");
|
||||
include!("engine/targets.rs");
|
||||
include!("engine/schedules.rs");
|
||||
|
||||
+97
-16
@@ -13,6 +13,9 @@ fn device_has_thermostat_zone(device_id: &str, zones: &[Zone]) -> bool {
|
||||
}
|
||||
|
||||
async fn run_automations(state: &AppState) -> Result<()> {
|
||||
if state.settings.read().await.emergency_stop_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let devices = state.db.list_devices()?;
|
||||
let mut automations = state.db.list_automations()?;
|
||||
// Stable arbitration for same-cycle conflicts: the oldest configured rule wins, then ID.
|
||||
@@ -51,6 +54,11 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
||||
// changed since this cycle snapshot was taken, skip it now and evaluate the new
|
||||
// definition on the next cycle instead of firing stale configuration.
|
||||
let _automation_guard = state.lock_automation_operation().await;
|
||||
// The emergency-stop handler takes the same lock before persisting its safety flag.
|
||||
// Re-check here so a rule evaluated immediately before STOP can never execute after it.
|
||||
if state.settings.read().await.emergency_stop_enabled {
|
||||
continue;
|
||||
}
|
||||
let Some(latest_item) = state.db.get_automation(&item.id)? else { continue; };
|
||||
if latest_item.updated_at != item.updated_at { continue; }
|
||||
item = latest_item;
|
||||
@@ -157,10 +165,14 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
||||
Ok(true) => {
|
||||
for device_id in target_devices { claimed_devices.insert(device_id); }
|
||||
let fired_at = Utc::now();
|
||||
flow_acknowledge_change_gates(&mut item);
|
||||
flow_record_rate_limited_execution(&mut item, fired_at.clone());
|
||||
item.last_fired_at = Some(fired_at);
|
||||
item.updated_at = Utc::now();
|
||||
state.db.save_automation(&item)?;
|
||||
// Keep connected clients in sync with runtime metadata such as last_fired_at.
|
||||
// automation.updated also invalidates the materialized control plan.
|
||||
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
||||
state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({
|
||||
"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id, "group_id": item.action_group_id, "device_id": item.action_device_id
|
||||
}));
|
||||
@@ -181,6 +193,7 @@ async fn run_automations(state: &AppState) -> Result<()> {
|
||||
item.last_fired_at = Some(Utc::now());
|
||||
item.updated_at = Utc::now();
|
||||
state.db.save_automation(&item)?;
|
||||
state.broadcast("automation.updated", serde_json::to_value(&item)?);
|
||||
state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id, "flow_id": item.flow_id, "flow_node_id": item.flow_node_id}));
|
||||
}
|
||||
}
|
||||
@@ -260,6 +273,18 @@ fn flow_compare_value(actual: &Value, operator: &str, expected: &Value) -> bool
|
||||
if operator == "neq" { !equal } else { equal }
|
||||
}
|
||||
|
||||
fn flow_louver_compat_value(field: &str, value: Value) -> Value {
|
||||
if !matches!(field, "swing_vertical" | "swing_horizontal") {
|
||||
return value;
|
||||
}
|
||||
match value {
|
||||
Value::Bool(value) => json!(u8::from(value)),
|
||||
Value::String(value) if value.eq_ignore_ascii_case("true") => json!(1),
|
||||
Value::String(value) if value.eq_ignore_ascii_case("false") => json!(0),
|
||||
value => value,
|
||||
}
|
||||
}
|
||||
|
||||
fn flow_device_state_value(device: &Device, field: &str) -> Option<Value> {
|
||||
Some(match field {
|
||||
"enabled" => json!(device.enabled),
|
||||
@@ -389,10 +414,27 @@ fn flow_state_duration_update(
|
||||
(within_min && within_max, elapsed)
|
||||
}
|
||||
|
||||
fn flow_change_gate_update(state: &mut crate::models::FlowRuntimeNodeState, current: Value) -> bool {
|
||||
fn flow_change_gate_update(
|
||||
state: &mut crate::models::FlowRuntimeNodeState,
|
||||
current: Value,
|
||||
input: bool,
|
||||
) -> (bool, bool) {
|
||||
let changed = state.last_value.as_ref().is_some_and(|previous| previous != ¤t);
|
||||
state.last_value = Some(current);
|
||||
changed
|
||||
if !input {
|
||||
state.pending = false;
|
||||
} else if changed {
|
||||
state.pending = true;
|
||||
}
|
||||
(changed, state.pending)
|
||||
}
|
||||
|
||||
fn flow_acknowledge_change_gates(item: &mut Automation) {
|
||||
for condition in item.flow_conditions.iter().filter(|condition| condition.kind == "on_change") {
|
||||
if let Some(state) = item.flow_runtime.get_mut(&condition.id) {
|
||||
state.pending = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flow_rate_limit_status(
|
||||
@@ -428,9 +470,13 @@ fn flow_rolling_stat_update(
|
||||
statistic: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> Option<f64> {
|
||||
let started_at = state.since.get_or_insert_with(|| now.clone()).clone();
|
||||
let cutoff = now.clone() - chrono::Duration::seconds(window_seconds as i64);
|
||||
state.samples.retain(|item| item.at >= cutoff);
|
||||
state.samples.push(crate::models::FlowRuntimeSample { at: now, value: sample });
|
||||
state.samples.push(crate::models::FlowRuntimeSample { at: now.clone(), value: sample });
|
||||
if now.signed_duration_since(started_at).num_seconds().max(0) < window_seconds as i64 {
|
||||
return None;
|
||||
}
|
||||
let mut values = state.samples.iter().map(|item| item.value).collect::<Vec<_>>();
|
||||
if values.is_empty() { return None; }
|
||||
if statistic == "median" {
|
||||
@@ -599,8 +645,14 @@ async fn flow_leaf_observation(
|
||||
"device_state" => {
|
||||
let id = c.get("device_id").and_then(Value::as_str).unwrap_or("");
|
||||
let field = c.get("field").and_then(Value::as_str).unwrap_or("");
|
||||
let actual = override_value.unwrap_or_else(|| devices.iter().find(|device| device.id == id).and_then(|device| flow_device_state_value(device, field)).unwrap_or(Value::Null));
|
||||
let expected = c.get("value").cloned().unwrap_or(Value::Null);
|
||||
let actual = flow_louver_compat_value(
|
||||
field,
|
||||
override_value.unwrap_or_else(|| devices.iter().find(|device| device.id == id).and_then(|device| flow_device_state_value(device, field)).unwrap_or(Value::Null)),
|
||||
);
|
||||
let expected = flow_louver_compat_value(
|
||||
field,
|
||||
c.get("value").cloned().unwrap_or(Value::Null),
|
||||
);
|
||||
(flow_compare_value(&actual, c.get("operator").and_then(Value::as_str).unwrap_or("eq"), &expected), actual)
|
||||
}
|
||||
"zone_state" => {
|
||||
@@ -708,13 +760,14 @@ pub async fn evaluate_flow_conditions_trace(
|
||||
let mode = condition.config.get("mode").and_then(Value::as_str).unwrap_or("result");
|
||||
let observed = if mode == "value" { actual_values.get(&input_id).cloned() } else { Some(json!(input)) };
|
||||
let mut changed = false;
|
||||
let mut pending = false;
|
||||
let mut previous = None;
|
||||
if let (Some(current), Some(map)) = (observed.clone(), runtime.as_deref_mut()) {
|
||||
let state = map.entry(condition.id.clone()).or_default();
|
||||
previous = state.last_value.clone();
|
||||
changed = flow_change_gate_update(state, current);
|
||||
(changed, pending) = flow_change_gate_update(state, current, input);
|
||||
}
|
||||
(input && changed, json!({"input": input, "mode": mode, "previous": previous, "current": observed, "changed": changed}))
|
||||
(input && pending, json!({"input": input, "mode": mode, "previous": previous, "current": observed, "changed": changed, "pending": pending}))
|
||||
}
|
||||
"rate_limit" => {
|
||||
let input = condition.inputs.len() == 1 && values.get(&condition.inputs[0]).copied().unwrap_or(false);
|
||||
@@ -750,12 +803,17 @@ pub async fn evaluate_flow_conditions_trace(
|
||||
let window = condition.config.get("window_seconds").and_then(Value::as_u64).unwrap_or(60);
|
||||
let mut aggregate = None;
|
||||
let mut count = 0usize;
|
||||
if let (Some(sample), Some(map)) = (sample, runtime.as_deref_mut()) {
|
||||
if let Some(map) = runtime.as_deref_mut() {
|
||||
let node_state = map.entry(condition.id.clone()).or_default();
|
||||
aggregate = flow_rolling_stat_update(
|
||||
node_state, sample, window, condition.config.get("statistic").and_then(Value::as_str).unwrap_or("mean"), now.with_timezone(&Utc),
|
||||
);
|
||||
count = node_state.samples.len();
|
||||
if let Some(sample) = sample {
|
||||
aggregate = flow_rolling_stat_update(
|
||||
node_state, sample, window, condition.config.get("statistic").and_then(Value::as_str).unwrap_or("mean"), now.with_timezone(&Utc),
|
||||
);
|
||||
count = node_state.samples.len();
|
||||
} else {
|
||||
node_state.since = None;
|
||||
node_state.samples.clear();
|
||||
}
|
||||
}
|
||||
let expected = condition.config.get("value").and_then(Value::as_f64);
|
||||
let matched = predecessors_match && aggregate.zip(expected).map(|(a,e)| flow_compare(a, condition.config.get("operator").and_then(Value::as_str).unwrap_or("lt"), e)).unwrap_or(false);
|
||||
@@ -864,7 +922,7 @@ async fn apply_flow_zone_action(state: &AppState, zone_id: &str, preset: Option<
|
||||
"auto" => { zone.manual_preset = None; zone.manual_setpoint = None; zone.manual_override_until = None; }
|
||||
"custom" => {
|
||||
let target = action.target_temperature.ok_or_else(|| AppError::BadRequest("Flow custom thermostat action has no target".into()))?;
|
||||
zone.manual_preset = Some("custom".into()); zone.manual_setpoint = Some((target.clamp(8.0,30.0)*2.0).round()/2.0);
|
||||
zone.manual_preset = Some("custom".into()); zone.manual_setpoint = Some(normalize_thermostat_target(target.clamp(8.0, 30.0)));
|
||||
zone.manual_override_until = next_schedule_boundary_utc(&zone.id, &state.db.list_schedules()?, Local::now());
|
||||
}
|
||||
value @ ("comfort" | "sleep" | "away") => {
|
||||
@@ -887,19 +945,42 @@ 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();
|
||||
}
|
||||
refresh_zone_runtime_target(&mut zone, &schedules, &house_mode);
|
||||
state.db.save_zone(&zone)?; state.broadcast("zone.updated", serde_json::to_value(&zone)?); state.wake_zone_control();
|
||||
state.db.save_zone(&zone)?;
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
|
||||
let swing_command = DeviceCommand {
|
||||
swing_vertical: action.swing_vertical,
|
||||
swing_horizontal: action.swing_horizontal,
|
||||
..Default::default()
|
||||
};
|
||||
if action.power == Some(false) {
|
||||
// Disabled zones are intentionally skipped by the normal thermostat cycle. Perform the
|
||||
// physical OFF under the canonical zone -> device lock order so the durable Flow intent
|
||||
// cannot leave a unit running and polling/manual control cannot interleave with the frame.
|
||||
// Swing can safely share this explicit frame because it is outside thermostat regulation.
|
||||
let _device_guard = state.lock_device_operation(&device_id).await;
|
||||
send_command_locked_forced(state, &device_id, DeviceCommand { power: Some(false), ..Default::default() }).await?;
|
||||
send_command_locked_forced(
|
||||
state,
|
||||
&device_id,
|
||||
DeviceCommand {
|
||||
power: Some(false),
|
||||
swing_vertical: swing_command.swing_vertical,
|
||||
swing_horizontal: swing_command.swing_horizontal,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
} else if !swing_command.is_empty() {
|
||||
// Swing is intentionally a one-shot auxiliary unit setting. It does not participate in
|
||||
// temperature/fan regulation, so the thermostat keeps ownership of the zone.
|
||||
let _ = send_automatic_device_command_if_owned(state, &device_id, swing_command).await?;
|
||||
}
|
||||
state.wake_zone_control();
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
+347
-47
@@ -1,8 +1,16 @@
|
||||
async fn send_command_locked(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
async fn send_command_locked(
|
||||
state: &AppState,
|
||||
device_id: &str,
|
||||
command: DeviceCommand,
|
||||
) -> Result<Device, AppError> {
|
||||
send_command_locked_inner(state, device_id, command, true, true).await
|
||||
}
|
||||
|
||||
async fn send_command_locked_forced(state: &AppState, device_id: &str, command: DeviceCommand) -> Result<Device, AppError> {
|
||||
async fn send_command_locked_forced(
|
||||
state: &AppState,
|
||||
device_id: &str,
|
||||
command: DeviceCommand,
|
||||
) -> Result<Device, AppError> {
|
||||
send_command_locked_inner(state, device_id, command, false, true).await
|
||||
}
|
||||
|
||||
@@ -14,14 +22,34 @@ async fn send_command_locked_inner(
|
||||
track_controller_command: bool,
|
||||
) -> Result<Device, AppError> {
|
||||
validate_command(&command)?;
|
||||
let mut device = state.db.get_device(device_id)?
|
||||
let mut device = state
|
||||
.db
|
||||
.get_device(device_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?;
|
||||
if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); }
|
||||
if !device.enabled {
|
||||
return Err(AppError::BadRequest("device is disabled".into()));
|
||||
}
|
||||
if device.connection_type == ConnectionType::GreeCloud {
|
||||
return send_cloud_command_locked_inner(
|
||||
state,
|
||||
device,
|
||||
command,
|
||||
dedupe_against_cache,
|
||||
track_controller_command,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Routine control avoids redundant frames. Explicit safety transitions (global/group OFF,
|
||||
// detach) may bypass cache de-duplication so stale state cannot leave a unit powered.
|
||||
let command = if dedupe_against_cache && device.online && device.communication_failures == 0 { command.changed_from(&device) } else { command };
|
||||
if command.is_empty() { return Ok(device); }
|
||||
let command = if dedupe_against_cache && device.online && device.communication_failures == 0 {
|
||||
command.changed_from(&device)
|
||||
} else {
|
||||
command
|
||||
};
|
||||
if command.is_empty() {
|
||||
return Ok(device);
|
||||
}
|
||||
let controller_command_baseline = device.clone();
|
||||
let suppress_beep = state.settings.read().await.suppress_device_beep;
|
||||
let response_started = Instant::now();
|
||||
@@ -39,7 +67,7 @@ async fn send_command_locked_inner(
|
||||
state.db.save_device(&device)?;
|
||||
} else {
|
||||
if device.key.as_deref().unwrap_or_default().is_empty() {
|
||||
match state.gree.bind(&device).await {
|
||||
match state.providers.local().bind(&device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
@@ -53,14 +81,19 @@ async fn send_command_locked_inner(
|
||||
}
|
||||
}
|
||||
}
|
||||
match state.gree.command(&device, &command, suppress_beep).await {
|
||||
match state
|
||||
.providers
|
||||
.local()
|
||||
.command(&device, &command, suppress_beep)
|
||||
.await
|
||||
{
|
||||
Ok(result) => applied_command = result,
|
||||
Err(first_err) => {
|
||||
// A lost command ACK does not mean the command was lost. Read the device
|
||||
// first and avoid sending the same frame (and another beep) when the requested
|
||||
// state is already present. Only rebind when the verification read also fails.
|
||||
let mut observed = device.clone();
|
||||
let retry_result = match state.gree.poll(&mut observed).await {
|
||||
let retry_result = match state.providers.local().poll(&mut observed).await {
|
||||
Ok(()) if command.changed_from(&observed).is_empty() => {
|
||||
device = observed;
|
||||
confirmed_state = true;
|
||||
@@ -70,20 +103,29 @@ async fn send_command_locked_inner(
|
||||
Ok(()) => {
|
||||
device = observed;
|
||||
let remaining = command.changed_from(&device);
|
||||
if remaining.is_empty() { Ok(command.clone()) }
|
||||
else { state.gree.command(&device, &remaining, suppress_beep).await }
|
||||
}
|
||||
Err(_) => {
|
||||
match state.gree.bind(&device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
state.db.save_device(&device)?;
|
||||
state.gree.command(&device, &command, suppress_beep).await
|
||||
}
|
||||
Err(_) => Err(first_err),
|
||||
if remaining.is_empty() {
|
||||
Ok(command.clone())
|
||||
} else {
|
||||
state
|
||||
.providers
|
||||
.local()
|
||||
.command(&device, &remaining, suppress_beep)
|
||||
.await
|
||||
}
|
||||
}
|
||||
Err(_) => match state.providers.local().bind(&device).await {
|
||||
Ok(bound) => {
|
||||
device.key = Some(bound.key);
|
||||
device.protocol_version = bound.protocol_version;
|
||||
state.db.save_device(&device)?;
|
||||
state
|
||||
.providers
|
||||
.local()
|
||||
.command(&device, &command, suppress_beep)
|
||||
.await
|
||||
}
|
||||
Err(_) => Err(first_err),
|
||||
},
|
||||
};
|
||||
match retry_result {
|
||||
Ok(result) => applied_command = result,
|
||||
@@ -94,8 +136,12 @@ async fn send_command_locked_inner(
|
||||
}
|
||||
}
|
||||
}
|
||||
if command.quiet.is_some() && applied_command.quiet.is_none() { device.supports_quiet = Some(false); }
|
||||
if command.sleep.is_some() && applied_command.sleep.is_none() { device.supports_sleep = Some(false); }
|
||||
if command.quiet.is_some() && applied_command.quiet.is_none() {
|
||||
device.supports_quiet = Some(false);
|
||||
}
|
||||
if command.sleep.is_some() && applied_command.sleep.is_none() {
|
||||
device.supports_sleep = Some(false);
|
||||
}
|
||||
|
||||
// A command ACK confirms transport/acceptance, but several GREE firmwares keep
|
||||
// returning the pre-command status for a short settling window. Publishing that first
|
||||
@@ -105,16 +151,20 @@ async fn send_command_locked_inner(
|
||||
let verification_delays_ms = [0_u64, 150, 350, 650];
|
||||
let mut last_verification_error: Option<String> = None;
|
||||
for delay_ms in verification_delays_ms {
|
||||
if delay_ms > 0 { sleep(Duration::from_millis(delay_ms)).await; }
|
||||
if delay_ms > 0 {
|
||||
sleep(Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
let mut observed = device.clone();
|
||||
match state.gree.poll(&mut observed).await {
|
||||
match state.providers.local().poll(&mut observed).await {
|
||||
Ok(()) => {
|
||||
let requested_matches = applied_command.changed_from(&observed).is_empty();
|
||||
device = observed;
|
||||
confirmed_state = true;
|
||||
confirmed_requested_state = requested_matches;
|
||||
last_verification_error = None;
|
||||
if requested_matches { break; }
|
||||
if requested_matches {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
last_verification_error = Some(err.to_string());
|
||||
@@ -125,23 +175,49 @@ async fn send_command_locked_inner(
|
||||
if confirmed_state && !confirmed_requested_state {
|
||||
tracing::debug!(device=%device.id, command=?applied_command, "GREE command acknowledged but status still differs after settling window");
|
||||
} else if !confirmed_state {
|
||||
let error = last_verification_error.unwrap_or_else(|| "status verification failed".into());
|
||||
record_poll_failure(&mut device, &format!("command accepted but status verification failed: {error}"));
|
||||
state.log("warn", "device.command_unconfirmed", &format!("Command accepted by {}, but resulting state could not be verified", device.name), json!({
|
||||
"device_id": device.id, "error": error
|
||||
}));
|
||||
let error =
|
||||
last_verification_error.unwrap_or_else(|| "status verification failed".into());
|
||||
record_poll_failure(
|
||||
&mut device,
|
||||
&format!("command accepted but status verification failed: {error}"),
|
||||
);
|
||||
state.log(
|
||||
"warn",
|
||||
"device.command_unconfirmed",
|
||||
&format!(
|
||||
"Command accepted by {}, but resulting state could not be verified",
|
||||
device.name
|
||||
),
|
||||
json!({
|
||||
"device_id": device.id, "error": error
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if confirmed_state {
|
||||
device.response_time_ms = Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
device.response_time_ms =
|
||||
Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
}
|
||||
state.db.save_device(&device)?;
|
||||
if !dedupe_against_cache && confirmed_state && !confirmed_requested_state {
|
||||
if track_controller_command && !command_manual_control_fields(&applied_command).is_empty() {
|
||||
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await;
|
||||
if track_controller_command
|
||||
&& !command_manual_control_fields(&applied_command).is_empty()
|
||||
{
|
||||
remember_controller_command(
|
||||
state,
|
||||
device_id,
|
||||
&applied_command,
|
||||
&controller_command_baseline,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
return Err(AppError::Device("device did not confirm the requested forced state change".into()));
|
||||
state.broadcast(
|
||||
"device.updated",
|
||||
serde_json::to_value(&device).unwrap_or_default(),
|
||||
);
|
||||
return Err(AppError::Device(
|
||||
"device did not confirm the requested forced state change".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,32 +226,256 @@ async fn send_command_locked_inner(
|
||||
// observed once. Several GREE modules can briefly publish an older snapshot again
|
||||
// and then return to the controller-requested state. Without this guard that normal
|
||||
// firmware bounce can be misclassified as a physical/pilot takeover.
|
||||
remember_controller_command(state, device_id, &applied_command, &controller_command_baseline).await;
|
||||
remember_controller_command(
|
||||
state,
|
||||
device_id,
|
||||
&applied_command,
|
||||
&controller_command_baseline,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
record_device_transition_timestamps(state, &controller_command_baseline, &device)?;
|
||||
|
||||
state.log("info", "device.command", &format!("Updated {}", device.name), json!({
|
||||
"device_id": device.id,
|
||||
"command": applied_command,
|
||||
"confirmed": confirmed_state,
|
||||
}));
|
||||
state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default());
|
||||
state.log(
|
||||
"info",
|
||||
"device.command",
|
||||
&format!("Updated {}", device.name),
|
||||
json!({
|
||||
"device_id": device.id,
|
||||
"command": applied_command,
|
||||
"confirmed": confirmed_state,
|
||||
}),
|
||||
);
|
||||
state.broadcast(
|
||||
"device.updated",
|
||||
serde_json::to_value(&device).unwrap_or_default(),
|
||||
);
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
fn record_device_transition_timestamps(state: &AppState, before: &Device, after: &Device) -> Result<(), AppError> {
|
||||
async fn send_cloud_command_locked_inner(
|
||||
state: &AppState,
|
||||
mut device: Device,
|
||||
command: DeviceCommand,
|
||||
dedupe_against_cache: bool,
|
||||
track_controller_command: bool,
|
||||
) -> Result<Device, AppError> {
|
||||
validate_cloud_command_capabilities(&device, &command)?;
|
||||
let command = if dedupe_against_cache && device.online && device.communication_failures == 0 {
|
||||
command.changed_from(&device)
|
||||
} else {
|
||||
command
|
||||
};
|
||||
if command.is_empty() {
|
||||
return Ok(device);
|
||||
}
|
||||
|
||||
let baseline = device.clone();
|
||||
let suppress_beep = state.settings.read().await.suppress_device_beep;
|
||||
let cloud_settings = state.settings.read().await.gree_cloud.clone();
|
||||
let all_devices = state.db.list_devices()?;
|
||||
let response_started = Instant::now();
|
||||
|
||||
// Optimistic UI state is explicit and reversible. The provider never falls back to UDP.
|
||||
device.pending_command = true;
|
||||
command.apply(&mut device);
|
||||
if let Some(target) = command.target_temperature {
|
||||
let step = device.capabilities.temperature_step.max(0.5);
|
||||
let rounded = (target / step).round() * step;
|
||||
device.target_temperature = rounded.clamp(
|
||||
device.capabilities.min_temperature,
|
||||
device.capabilities.max_temperature,
|
||||
);
|
||||
}
|
||||
device.updated_at = Utc::now();
|
||||
state.db.save_device(&device)?;
|
||||
state.broadcast(
|
||||
"device.updated",
|
||||
serde_json::to_value(&device).unwrap_or_default(),
|
||||
);
|
||||
|
||||
let mut transport_device = baseline.clone();
|
||||
let applied = match state
|
||||
.providers
|
||||
.cloud()
|
||||
.command(
|
||||
&cloud_settings,
|
||||
&all_devices,
|
||||
&mut transport_device,
|
||||
&command,
|
||||
suppress_beep,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(applied) => applied,
|
||||
Err(err) => {
|
||||
let mut restored = baseline.clone();
|
||||
restored.pending_command = false;
|
||||
register_cloud_failure(&mut restored, &err.to_string());
|
||||
state.db.save_device(&restored)?;
|
||||
state.broadcast(
|
||||
"device.updated",
|
||||
serde_json::to_value(&restored).unwrap_or_default(),
|
||||
);
|
||||
state.log(
|
||||
"warn",
|
||||
"gree_cloud.command_rejected",
|
||||
&format!("Cloud command failed for {}", restored.name),
|
||||
json!({"device_id": restored.id, "error": cloud_public_error(&err.to_string())}),
|
||||
);
|
||||
return Err(AppError::Dependency(cloud_public_error(&err.to_string())));
|
||||
}
|
||||
};
|
||||
|
||||
// The reference cloud client treats a missing 2s command ACK as uncertain success.
|
||||
// Do not turn that into a synchronous chain of 10s status reads: it makes Manual Control
|
||||
// look broken even when the unit accepted the command. Publish the optimistic state now;
|
||||
// MQTT push is the primary confirmation path and one delayed recovery read is scheduled.
|
||||
let mut accepted = transport_device;
|
||||
accepted.pending_command = true;
|
||||
accepted.response_time_ms =
|
||||
Some(response_started.elapsed().as_millis().min(u64::MAX as u128) as u64);
|
||||
accepted.updated_at = Utc::now();
|
||||
accepted.refresh_capabilities();
|
||||
state.db.save_device(&accepted)?;
|
||||
|
||||
if track_controller_command && !command_manual_control_fields(&applied).is_empty() {
|
||||
remember_controller_command(state, &accepted.id, &applied, &baseline).await;
|
||||
}
|
||||
record_device_transition_timestamps(state, &baseline, &accepted)?;
|
||||
state.log(
|
||||
"info",
|
||||
"gree_cloud.command_sent",
|
||||
&format!("Updated {} through GREE Cloud", accepted.name),
|
||||
json!({
|
||||
"device_id": accepted.id,
|
||||
"command": applied,
|
||||
"confirmation": "mqtt_push_or_recovery_poll",
|
||||
}),
|
||||
);
|
||||
state.broadcast(
|
||||
"device.updated",
|
||||
serde_json::to_value(&accepted).unwrap_or_default(),
|
||||
);
|
||||
|
||||
// Recovery is deliberately asynchronous. It waits until the command handler releases
|
||||
// the per-device lock, then performs one normal provider poll. A successful push may
|
||||
// already have confirmed the state by then; the poll is only a bounded fallback.
|
||||
let recovery_state = state.clone();
|
||||
let recovery_device_id = accepted.id.clone();
|
||||
tokio::spawn(async move {
|
||||
sleep(Duration::from_millis(1500)).await;
|
||||
let Some(_cloud_poll_guard) = recovery_state.try_begin_cloud_poll(&recovery_device_id)
|
||||
else {
|
||||
tracing::debug!(device=%recovery_device_id, "skipping duplicate GREE Cloud recovery poll");
|
||||
return;
|
||||
};
|
||||
if let Err(err) = poll_one(&recovery_state, &recovery_device_id).await {
|
||||
tracing::debug!(
|
||||
device=%recovery_device_id,
|
||||
error=?err,
|
||||
"GREE Cloud post-command recovery poll did not confirm state"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(accepted)
|
||||
}
|
||||
|
||||
fn validate_cloud_command_capabilities(
|
||||
device: &Device,
|
||||
command: &DeviceCommand,
|
||||
) -> Result<(), AppError> {
|
||||
let unsupported = if command.swing_vertical.is_some() && !device.capabilities.vertical_swing {
|
||||
Some("vertical swing")
|
||||
} else if command.swing_horizontal.is_some() && !device.capabilities.horizontal_swing {
|
||||
Some("horizontal swing")
|
||||
} else if command.quiet.is_some() && device.supports_quiet == Some(false) {
|
||||
Some("quiet")
|
||||
} else if command.turbo.is_some() && device.supports_turbo == Some(false) {
|
||||
Some("turbo")
|
||||
} else if command.light.is_some() && device.supports_light == Some(false) {
|
||||
Some("light")
|
||||
} else if command.air.is_some() && device.supports_air == Some(false) {
|
||||
Some("air")
|
||||
} else if command.xfan.is_some() && device.supports_xfan == Some(false) {
|
||||
Some("X-Fan")
|
||||
} else if command.health.is_some() && device.supports_health == Some(false) {
|
||||
Some("health")
|
||||
} else if command.sleep.is_some() && device.supports_sleep == Some(false) {
|
||||
Some("sleep")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(feature) = unsupported {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"{} does not report support for {feature}",
|
||||
device.name
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_cloud_failure(device: &mut Device, error: &str) {
|
||||
device.communication_failures = device.communication_failures.saturating_add(1);
|
||||
device.online = false;
|
||||
device.connection_status = cloud_connection_status(error);
|
||||
device.response_time_ms = None;
|
||||
if device.last_seen.is_none() {
|
||||
device.last_cloud_sync = None;
|
||||
}
|
||||
device.last_error = Some(cloud_public_error(error));
|
||||
device.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
fn cloud_connection_status(error: &str) -> ConnectionStatus {
|
||||
let error = error.to_ascii_lowercase();
|
||||
if error.contains("authentication")
|
||||
|| error.contains("not authorized")
|
||||
|| error.contains("invalid username")
|
||||
{
|
||||
ConnectionStatus::AuthenticationError
|
||||
} else if error.contains("mqtt")
|
||||
|| error.contains("connect")
|
||||
|| error.contains("tls")
|
||||
|| error.contains("network")
|
||||
{
|
||||
ConnectionStatus::CloudDisconnected
|
||||
} else {
|
||||
ConnectionStatus::Offline
|
||||
}
|
||||
}
|
||||
|
||||
fn cloud_public_error(error: &str) -> String {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
if lower.contains("password") || lower.contains("token") || lower.contains("authorization") {
|
||||
"GREE Cloud authentication failed".into()
|
||||
} else {
|
||||
error.chars().take(300).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn record_device_transition_timestamps(
|
||||
state: &AppState,
|
||||
before: &Device,
|
||||
after: &Device,
|
||||
) -> Result<(), AppError> {
|
||||
let power_changed = before.power != after.power;
|
||||
let mode_changed = before.mode != after.mode;
|
||||
if !power_changed && !mode_changed { return Ok(()); }
|
||||
if !power_changed && !mode_changed {
|
||||
return Ok(());
|
||||
}
|
||||
// This function is often called while the device lock is held, so acquiring a zone lock
|
||||
// here would invert the global zone -> device order. Merge only these timestamp fields
|
||||
// with a DB compare-and-swap instead of saving a stale whole-zone snapshot.
|
||||
for zone in state.db.merge_zone_device_transition_timestamps(
|
||||
&after.id, power_changed, mode_changed, Utc::now(),
|
||||
&after.id,
|
||||
power_changed,
|
||||
mode_changed,
|
||||
Utc::now(),
|
||||
)? {
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user