Compare commits

..
117 Commits
Author SHA1 Message Date
Mateusz Gruszczyński 0148daf410 0.15.17 2026-09-21 23:33:23 +02:00
Mateusz Gruszczyński 16001d1a55 v0.15.16 2026-09-21 23:21:10 +02:00
Mateusz Gruszczyński d80005de33 v0.15.15 2026-09-21 22:58:30 +02:00
Mateusz Gruszczyński 3b969c8754 v0.15.14 2026-09-21 11:30:36 +02:00
Mateusz Gruszczyński f6269024bb v0.15.13-hotfix 2026-09-21 10:37:06 +02:00
Mateusz Gruszczyński e5013ea819 v0.15.13 2026-09-21 10:28:28 +02:00
Mateusz Gruszczyński 09c10d8008 v0.15.12 2026-09-21 10:05:00 +02:00
Mateusz Gruszczyński 334102d9cb v0.15.11 2026-09-19 22:22:36 +02:00
Mateusz Gruszczyński 45a993ed80 v0.15.10 2026-09-19 21:51:51 +02:00
Mateusz Gruszczyński ac2dd8b62a v0.15.9 2026-09-19 21:37:46 +02:00
Mateusz Gruszczyński 044adef401 v0.15.8 2026-09-19 00:23:10 +02:00
Mateusz Gruszczyński be3708f14d v0.15.8 2026-09-18 23:57:28 +02:00
Mateusz Gruszczyński 00cbe975bb v0.15.7 2026-09-18 12:26:48 +02:00
Mateusz Gruszczyński 8d96ad2d38 v0.15.6 2026-09-18 11:21:48 +02:00
Mateusz Gruszczyński 3ffb02595e v0.15.4 2026-09-18 10:27:39 +02:00
Mateusz Gruszczyński 7d9e6c8189 v0.15.2 2026-09-17 21:46:10 +02:00
Mateusz Gruszczyński cafbf6e5fa v0.15.1 2026-09-17 13:01:41 +02:00
Mateusz Gruszczyński 4bdcf1b8dd v0.15.0 2026-09-17 11:32:06 +02:00
Mateusz Gruszczyński df10ead47e v0.15.0 2026-09-17 11:21:49 +02:00
Mateusz Gruszczyński 32f8a32bd2 v0.14.24 2026-09-17 10:06:11 +02:00
Mateusz Gruszczyński 2f0bf20033 v0.14.23-fixed_js 2026-09-17 09:45:14 +02:00
Mateusz Gruszczyński c459027b56 v0.14.23 2026-09-17 09:16:51 +02:00
Mateusz Gruszczyński 299f0addb3 v0.14.22 2026-09-17 09:07:20 +02:00
Mateusz Gruszczyński b7846c3b9f v0.14.21 2026-09-17 08:52:02 +02:00
Mateusz Gruszczyński ff4f2b60e7 v0.14.20 2026-09-16 22:53:04 +02:00
Mateusz Gruszczyński 6d4c523273 v0.14.20 2026-09-16 22:47:06 +02:00
Mateusz Gruszczyński ef7298889c v0.14.20 2026-09-16 22:43:57 +02:00
Mateusz Gruszczyński e02557bb65 v0.14.19 2026-09-16 22:38:45 +02:00
Mateusz Gruszczyński bb39ab9dfa v0.14.18 2026-09-16 22:22:09 +02:00
Mateusz Gruszczyński c7d47db64e v0.14.15-fix 2026-09-16 17:07:17 +02:00
Mateusz Gruszczyński 1862fed87f v0.14.15 2026-09-16 16:56:13 +02:00
Mateusz Gruszczyński 143ff0d272 v0.14.14 2026-09-16 15:28:06 +02:00
Mateusz Gruszczyński 3f9acb1675 v0.14.13 2026-09-16 14:24:40 +02:00
Mateusz Gruszczyński d0ae3d0f6a v0.14.12 2026-09-16 12:51:06 +02:00
Mateusz Gruszczyński 0bd33958da v0.14.11 2026-09-16 11:27:36 +02:00
Mateusz Gruszczyński 0d73844f23 v0.14.10 2026-09-16 11:09:38 +02:00
Mateusz Gruszczyński 0437ef7b6f v0.14.8 2026-09-16 09:04:43 +02:00
Mateusz Gruszczyński 3d69e74319 v0.14.4 2026-09-15 18:47:20 +02:00
Mateusz Gruszczyński 7ffaba6c7a v0.14.2-hotfix 2026-09-15 10:14:01 +02:00
Mateusz Gruszczyński c3f6e773f8 v0.14.2-connectivity 2026-09-15 10:02:49 +02:00
Mateusz Gruszczyński 2fd7e47eb9 v0.14.2-hotfix 2026-09-15 09:14:20 +02:00
Mateusz Gruszczyński 2ff310bdc5 v0.14.2 2026-09-15 09:04:29 +02:00
Mateusz Gruszczyński 0a2fb8c6c7 v0.14.1 2026-09-14 23:10:17 +02:00
Mateusz Gruszczyński 4bb9c8621a v0.14.0 2026-09-14 16:36:40 +02:00
Mateusz Gruszczyński 021cbddba5 v0.14.0 2026-09-14 16:32:28 +02:00
Mateusz Gruszczyński c3fbc5ccc6 v0.13.10 2026-09-13 10:21:55 +02:00
Mateusz Gruszczyński a7fc1f2dc4 v0.13.10 2026-09-13 10:21:14 +02:00
gru 10a7aac121 Update README.md 2026-09-13 10:00:35 +02:00
Mateusz Gruszczyński 34e62da372 v0.13.9-rework_ha_addon 2026-09-10 08:24:53 +02:00
gru b905a6fcc4 Update ha-addon/build.sh 2026-09-09 15:38:35 +02:00
Mateusz Gruszczyński fa0d4d0754 v0.13.9-ha_addon 2026-09-09 15:19:10 +02:00
Mateusz Gruszczyński 4196fbe0ef v0.13.9 2026-09-08 22:46:08 +02:00
Mateusz Gruszczyński 0cb09769fd v0.13.8 2026-09-07 11:38:55 +02:00
Mateusz Gruszczyński 749172e527 v0.13.7 2026-09-07 11:20:23 +02:00
Mateusz Gruszczyński 7772e1e339 v0.13.7 2026-09-07 10:01:52 +02:00
Mateusz Gruszczyński 11da46c4d6 v0.12.6-hotfix 2026-09-04 14:50:18 +02:00
Mateusz Gruszczyński 63e6e13a5d v0.13.6 2026-09-04 13:44:18 +02:00
Mateusz Gruszczyński 2882fd3523 v0.13.5 2026-09-04 12:47:26 +02:00
Mateusz Gruszczyński 4a0aae77c3 v0.13.5 2026-09-04 12:47:07 +02:00
Mateusz Gruszczyński 028abbe32f v0.13.5 2026-09-04 12:07:34 +02:00
Mateusz Gruszczyński 5c941352bb v0.13.4 2026-09-04 12:02:33 +02:00
Mateusz Gruszczyński ade221bb76 v0.13.2 2026-09-04 11:43:37 +02:00
Mateusz Gruszczyński 9b4fafdfa6 v0.13.1 2026-09-04 11:30:48 +02:00
Mateusz Gruszczyński 236540e0f0 v0.13.0 2026-09-04 11:00:15 +02:00
Mateusz Gruszczyński 8152e63d63 v0.12.1-cleanups 2026-09-04 09:44:26 +02:00
Mateusz Gruszczyński 642f43ed9f v0.12.1-cleanups 2026-09-04 09:42:43 +02:00
Mateusz Gruszczyński 7fd5921ba9 v0.12.1 2026-09-04 08:59:11 +02:00
Mateusz Gruszczyński 39f50eba2e v0.12.0-hotfix2 2026-09-03 22:28:46 +02:00
Mateusz Gruszczyński 97fff976fe v0.12.0-hotfix1 2026-09-03 22:18:32 +02:00
Mateusz Gruszczyński 07be4b85d9 v0.12.0-preety_code 2026-09-03 15:33:37 +02:00
Mateusz Gruszczyński be67932b47 v0.12.0-red_lines 2026-09-03 15:11:15 +02:00
Mateusz Gruszczyński 351ea765fa v0.12.0-api 2026-09-03 13:38:27 +02:00
Mateusz Gruszczyński abcd84f5ef v0.12.0 2026-09-03 13:16:32 +02:00
Mateusz Gruszczyński b8ff31f205 v0.12.0 2026-09-03 13:08:28 +02:00
Mateusz Gruszczyński d7b6d0478f v0.11.6 2026-09-03 11:49:10 +02:00
Mateusz Gruszczyński 4c9f8ff403 v0.11.6 2026-09-03 10:26:18 +02:00
Mateusz Gruszczyński 8f6169fbe3 v0.11.5 2026-09-03 10:02:55 +02:00
Mateusz Gruszczyński f0909a443d v0.11.4-devices 2026-09-03 09:58:26 +02:00
Mateusz Gruszczyński 500516b0fd v0.11.4-layout 2026-09-03 09:30:20 +02:00
Mateusz Gruszczyński 14782c86aa v0.11.4 2026-09-03 09:12:27 +02:00
Mateusz Gruszczyński 93d93eca91 v0.11.3 2026-09-02 23:17:29 +02:00
Mateusz Gruszczyński 76bbd39378 v0.11.2 2026-09-02 23:10:44 +02:00
Mateusz Gruszczyński 563523d2e2 v0.11.1 2026-09-02 22:58:55 +02:00
Mateusz Gruszczyński db6d2f09db v0.11.0 2026-09-02 22:41:27 +02:00
Mateusz Gruszczyński 8004be0841 0.10.0-hotfix 2026-09-02 13:29:30 +02:00
Mateusz Gruszczyński cb3470951f v0.10.0 2026-09-02 10:38:39 +02:00
Mateusz Gruszczyński 154b54d452 v0.10.0 2026-09-02 10:35:19 +02:00
Mateusz Gruszczyński 5e5bd4ad3a v0.10.0 2026-09-02 10:27:49 +02:00
Mateusz Gruszczyński e50c94b6a3 v0.10.0 2026-09-02 10:12:51 +02:00
Mateusz Gruszczyński 8180d22cef v0.9.14 2026-09-02 09:27:24 +02:00
Mateusz Gruszczyński 87b68187e7 v0.9.13 2026-09-02 09:17:56 +02:00
Mateusz Gruszczyński 43441d2fb7 v0.9.11 2026-09-02 09:02:56 +02:00
Mateusz Gruszczyński 9c6e51fc35 v0.9.9 2026-09-02 08:50:51 +02:00
Mateusz Gruszczyński a161d8785d v0.9.7 2026-09-02 08:39:20 +02:00
Mateusz Gruszczyński 16e0d94564 v0.9.6 2026-09-01 22:20:10 +02:00
Mateusz Gruszczyński 1cb62c8d0b v0.9.5 2026-09-01 15:53:05 +02:00
Mateusz Gruszczyński 3000dbaf01 v0.9.5 2026-09-01 15:38:46 +02:00
Mateusz Gruszczyński 1392114c1e v0.9.4-fix_ux 2026-09-01 15:14:45 +02:00
Mateusz Gruszczyński 7d46e06a4f v0.9.4-fix 2026-09-01 15:01:40 +02:00
Mateusz Gruszczyński 150cf6cb7f v0.9.4-fix 2026-09-01 14:59:00 +02:00
Mateusz Gruszczyński 3c490b3064 v0.9.4 2026-09-01 14:33:45 +02:00
Mateusz Gruszczyński d0346dd797 v0.9.3 2026-09-01 14:18:00 +02:00
Mateusz Gruszczyński 97c26c67b6 v0.9.2 2026-09-01 13:33:16 +02:00
Mateusz Gruszczyński dc20267787 v0.9.1 2026-09-01 13:16:10 +02:00
Mateusz Gruszczyński 7e0160834d v0.9.0 2026-09-01 13:05:44 +02:00
Mateusz Gruszczyński e496f911da v0.8.2 2026-09-01 12:07:04 +02:00
Mateusz Gruszczyński 6207bc4de6 v0.8.22 2026-09-01 11:36:11 +02:00
Mateusz Gruszczyński 3e3f8c3f19 v0.8.21 2026-09-01 11:24:47 +02:00
Mateusz Gruszczyński 9f08d7ccf9 v0.8.20 2026-09-01 11:10:28 +02:00
Mateusz Gruszczyński 3479ed750d v0.8.19 2026-09-01 10:20:19 +02:00
Mateusz Gruszczyński 1a5c1305dc v0.8.17 2026-09-01 09:19:33 +02:00
Mateusz Gruszczyński ac5a464d96 v0.8.16 2026-08-30 23:33:23 +02:00
Mateusz Gruszczyński 4054fe3f76 v0.8.15 2026-08-30 23:00:29 +02:00
Mateusz Gruszczyński 6465ba1125 v0.8.14-fix_css 2026-08-30 15:27:00 +02:00
Mateusz Gruszczyński 5c05eddb8f v0.8.14 2026-08-30 13:39:29 +02:00
Mateusz Gruszczyński 3e950ab5fa v0.8.13 2026-08-30 09:50:37 +02:00
Mateusz Gruszczyński fbc66b0c26 v0.8.13 2026-08-30 09:50:03 +02:00
230 changed files with 75648 additions and 16064 deletions
+36 -15
View File
@@ -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=...
-18
View File
@@ -1,18 +0,0 @@
# GREE Controller v0.8.10 - dashboard and notification-filter report
Source baseline: GREE Controller v0.8.9.
## Changes
- added fine-grained notification categories with a dedicated stale Home Assistant sensor switch;
- split stale HA reads into the `ha.sensor_stale` event kind while retaining other HA failures as `ha.sensor_error`;
- rebuilt the Dashboard as horizontal **Main / Thermostats / Manual control** tabs with one vertically scrolling panel at a time;
- moved LAN discovery from the global toolbar to Devices;
- added centered `active/total running` unit status to the top toolbar;
- changed control-owner metadata to show date and time instead of time only;
- removed visible "Quick" wording from the Dashboard thermostat/manual-control section titles;
- rotated the PWA cache key for the changed frontend.
## Validation
The Rust toolchain is not installed in this build environment, so `cargo test`/`cargo check` cannot be run here. JavaScript syntax checks, JSON parsing, HTML ID checks, shell syntax checks, manifest regeneration and ZIP integrity verification are performed before packaging. The target-host updater should run the full Rust test suite before replacing the service.
Generated
+654 -279
View File
File diff suppressed because it is too large Load Diff
+29 -26
View File
@@ -1,41 +1,44 @@
[package]
name = "gree-controller"
version = "0.8.10"
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
+215 -60
View File
@@ -1,72 +1,227 @@
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
de0793949cf01d27d903653226ecc9b5f72523d8711f20bcab022cf42c4c4ed0 ./.env.example
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
875d85d3388362890ef0fa28b7192634856b8c095f5fbdacc75d60e014f692cc ./BUILD_REPORT.md
c91ff48b9278d8394e79e734b11cc49d0c092881500edc11121b6baca23d2e67 ./Cargo.lock
43bf5b7dc87db920fdd5a7157c50c92aef7053a293c51c86c5b5fa76c761f9d7 ./Cargo.toml
4051d2de7fa9858545582dc6bb5a411c980a282d0b25aa3766df88597442f94a ./Cargo.lock
4bf5b286ffb52b22a32ef3d2d5e4dfaae600c1be01c1cac0b87c7138ecb4a129 ./Cargo.toml
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
21dd3dc409d51f29890ee303f97eaa4e68362c100e499c48bfc1ef8e57f525fd ./README.md
91c826e2f8c974bdb7b15e01e9b20487f06ff887247ecd9600f1263c11a61b96 ./build.rs
6e2953807ea0d1f346f99adb527391806cb088afdbc62e721193be7253a3bc9c ./docs/API.md
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
2ef5cb04cc74be3aab77a9d117b9040f186c73a7dcf24375f1cca2d73e428449 ./docs/PROJECT_SPEC.md
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
c3f3f12206c552d3fb919022d64b3fa60342fc1031081e893b7efd743961c402 ./future.md
95e3c8c99ba439a86a2fb05cfd8ef00c75cb959a024a349a082da935b726f500 ./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
98f494432dbc861b5b4b8928715845590189008482733006af0b0d9277d193a8 ./home-assistant/custom_components/gree_controller/manifest.json
38d659d0273c0d1428679e44574a2666c605d1b3796d323b626baa40560788cf ./home-assistant/custom_components/gree_controller/number.py
39c4309001b75abb56234f05662bc06e077054986876f1927937edbce528ec95 ./home-assistant/custom_components/gree_controller/select.py
1cae12876eb81085910907cf1fcf340a18dcba4f8c967ef222c9496325057849 ./home-assistant/custom_components/gree_controller/sensor.py
229ef9b3de8001bdedea42015a219c7f7c17d50f90b10705508494d434aeb549 ./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
29cc08123a581474a4445d8bf98dcf22f17cdd2e0e7f1358158e588e1252eec5 ./lang/en.json
166d7daf829d8b3f9127a90f2da8c3db0ee2d8b490a083d1c278e7ccbde48a27 ./lang/pl.json
028e1f16e9fbaed57cadb88eff04e65b4bd67722c50b4d6b1fb525f5a2f39abf ./make_zip.py
bb89bac237e750e9b1bf73761d7df97a6b81853091615878c03f13d7b6399aa7 ./scripts/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
dbb92ddc27b8724faf709983e4feecd3f188052b2cba39daba96d4bc16914df5 ./scripts/dev.sh
14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py
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
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
bd703ef841f9763f13367aaa5764381b9c59ee87210e806c24617f3cc0739a9b ./scripts/smoke.sh
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
8295771c6907bcbc4e57d3c4572c120b72501167d02706f1fade7c1c09d6a6bf ./src/api.rs
9040e8cb6647c76a875148b7591abcccfe4e2d4708ef462a1e5d25dd4ccac911 ./src/config.rs
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
15d07a2c0ad8ec6530897980787e6e9a94103bda849f07c233f6a37796d1f0b1 ./src/engine.rs
4b271b6fc365b1078c01d6178eb563841b2ecaed5d8639196f58e1312d2236fe ./src/error.rs
c6ff66da9ad08506f839ec56a869ead3abc824b5311e0ea9244517d11f7f4207 ./src/home_assistant.rs
190b0a33431539676e5dd7796698077f16c179d42eae4501ca96a91bf797cbf8 ./src/influxdb.rs
c67212da6a2bd5c2933ece31586ca4048b484fda3105f23a6e06e881b079d129 ./src/main.rs
ef841f3ab27da395465ef94256dba74b6f04a24f65b248d80eb560f542ee968e ./src/models.rs
9c735d7c475f21f2f388c21be784376f7184f0bf18af1886544ef51585a3b290 ./src/notifications.rs
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
bc03d88e5476386747ff5e32f1788cdc1fcd4bef238ab66df2dada282c348c8f ./src/protocol/gree.rs
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
6a1c0cab3eab80ecd254c5e486b1fba068523edb8d699c64054c17f67f4a31d0 ./src/queries.rs
2d69811db832c90ce06035ee29665205a04e4c514a21ecae9623991a9b14d825 ./src/state.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
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
22c7882d8830101f21435069489dc976acb31fe027be8182c5bc444d3367c451 ./web/app.js
544a31cb2e5374227026afd6abd0d910381f3fca1638154ebd74e3c3d96a35d1 ./web/404.html
1d6fa9e291ebbae40a7a8b06a7cef9dbd0ee06d88ec6b664361c37cb6c1aa281 ./web/css/styles.css
183d122ba366303d9952ac0e68aff4bced4e0d9a468fbe51316c13f15227402b ./web/custom-chart.html
e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.svg
5900f4f892c1515178c26a8a243476010513970185009a0b3d44889e0c4c9681 ./web/index.html
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
db9319c0a0c6927a4f3e2b0a2a83927dd61ad6a456e9ca8fad9f3b48d1987778 ./web/styles.css
2da305f344ba9cf2d56975fdc4c446ef47b65a117daf13e0689091bd31303f32 ./web/sw.js
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.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
+394 -495
View File
File diff suppressed because it is too large Load Diff
+226
View File
@@ -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
+272 -16
View File
@@ -1,5 +1,8 @@
use serde_json::{json, Value};
use std::{env, fs, path::PathBuf};
use std::{
env, fs,
path::{Path, PathBuf},
};
fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str {
meta.get(key)
@@ -8,10 +11,125 @@ fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str {
.unwrap_or_else(|| panic!("{file}: meta.{key} must be a non-empty string"))
}
fn has_text(value: Option<&Value>) -> bool {
match value {
Some(Value::String(text)) => !text.trim().is_empty(),
Some(Value::Object(items)) => items
.values()
.any(|item| item.as_str().is_some_and(|text| !text.trim().is_empty())),
_ => false,
}
}
fn fnv1a_update(mut hash: u64, bytes: &[u8]) -> u64 {
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
fn content_hash(bytes: &[u8]) -> String {
format!("{:016x}", fnv1a_update(0xcbf29ce484222325, bytes))
}
const APP_JS_MODULES: &[&str] = &[
"core.js",
"select-ui.js",
"forms.js",
"bootstrap.js",
"dashboard.js",
"entities.js",
"flows.js",
"settings-ui.js",
"router.js",
"navigation.js",
"charts.js",
"history.js",
"realtime.js",
"events.js",
"settings.js",
"main.js",
];
fn bundle_app_js(web_dir: &Path) -> String {
let js_dir = web_dir.join("js-dynamic");
println!("cargo:rerun-if-changed={}", js_dir.display());
let mut bundle = String::new();
for &filename in APP_JS_MODULES {
let path = js_dir.join(filename);
println!("cargo:rerun-if-changed={}", path.display());
let source = fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display()));
bundle.push_str(&source);
if !source.ends_with('\n') {
bundle.push('\n');
}
}
bundle
}
fn main() {
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"));
let lang_dir = manifest_dir.join("lang");
let preset_dir = manifest_dir.join("presets");
let web_dir = manifest_dir.join("web");
println!("cargo:rerun-if-changed={}", lang_dir.display());
println!("cargo:rerun-if-changed={}", preset_dir.display());
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("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,
&not_found_path,
&sw_path,
&manifest_path,
&favicon_path,
] {
println!("cargo:rerun-if-changed={}", path.display());
}
let app_js = bundle_app_js(&web_dir);
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,
&not_found_path,
&sw_path,
&manifest_path,
&favicon_path,
] {
let bytes = fs::read(path)
.unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display()));
build_hash = fnv1a_update(build_hash, &bytes);
}
let mut files: Vec<PathBuf> = fs::read_dir(&lang_dir)
.unwrap_or_else(|error| panic!("cannot read {}: {error}", lang_dir.display()))
@@ -25,31 +143,47 @@ 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());
let filename = path.file_name().and_then(|v| v.to_str()).expect("UTF-8 language filename");
let stem = path.file_stem().and_then(|v| v.to_str()).expect("UTF-8 language code");
if !stem.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
let filename = path
.file_name()
.and_then(|v| v.to_str())
.expect("UTF-8 language filename");
let stem = path
.file_stem()
.and_then(|v| v.to_str())
.expect("UTF-8 language code");
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)
.unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display()));
build_hash = fnv1a_update(build_hash, source.as_bytes());
let document: Value = serde_json::from_str(&source)
.unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}"));
let root_object = document.as_object().unwrap_or_else(|| panic!("{filename}: language pack root must be an object"));
let unexpected: Vec<&str> = root_object.keys()
let root_object = document
.as_object()
.unwrap_or_else(|| panic!("{filename}: language pack root must be an object"));
let unexpected: Vec<&str> = root_object
.keys()
.filter(|key| key.as_str() != "meta" && key.as_str() != "translations")
.map(|key| key.as_str())
.collect();
if !unexpected.is_empty() {
panic!("{filename}: unexpected top-level keys {:?}; translation keys must be inside 'translations'", unexpected);
}
let meta = document.get("meta").unwrap_or_else(|| panic!("{filename}: missing meta object"));
let meta = document
.get("meta")
.unwrap_or_else(|| panic!("{filename}: missing meta object"));
let code = required_string(meta, "code", filename);
let name = required_string(meta, "name", filename);
let native_name = required_string(meta, "native_name", filename);
@@ -60,32 +194,140 @@ 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)
.unwrap_or_else(|error| panic!("cannot read {}: {error}", preset_dir.display()))
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
.collect();
preset_files.sort();
if preset_files.is_empty() {
panic!("no flow preset files found in {}", preset_dir.display());
}
let mut preset_assets = Vec::new();
let mut preset_manifest = Vec::new();
for path in preset_files {
println!("cargo:rerun-if-changed={}", path.display());
let filename = path
.file_name()
.and_then(|value| value.to_str())
.expect("UTF-8 preset filename");
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.expect("UTF-8 preset id");
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)
.unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display()));
build_hash = fnv1a_update(build_hash, source.as_bytes());
let document: Value = serde_json::from_str(&source)
.unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}"));
let object = document
.as_object()
.unwrap_or_else(|| panic!("{filename}: preset root must be an object"));
let id = object
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| panic!("{filename}: id must be a non-empty string"));
if id != stem {
panic!("{filename}: id '{id}' must match filename '{stem}.json'");
}
let category = object
.get("category")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| panic!("{filename}: category must be a non-empty string"));
if !has_text(object.get("name")) {
panic!("{filename}: name must be a string or localized object with text");
}
if !has_text(object.get("description")) {
panic!("{filename}: description must be a string or localized object with text");
}
let flow = object
.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)
{
panic!("{filename}: flow.nodes and flow.edges must be arrays");
}
preset_manifest.push(json!({
"id": id,
"category": category,
"file": filename,
}));
preset_assets.push((filename.to_owned(), path));
}
let manifest_json = serde_json::to_string(&json!({
"default": "en",
"default": default_language,
"languages": manifest_languages
})).expect("serialize language manifest");
}))
.expect("serialize language manifest");
let preset_manifest_json = serde_json::to_string(&json!({
"version": 1,
"presets": preset_manifest,
}))
.expect("serialize preset manifest");
let mut generated = String::new();
generated.push_str("// @generated by build.rs - do not edit.\n");
generated.push_str(&format!("pub const LANGUAGE_MANIFEST_JSON: &str = {:?};\n", manifest_json));
generated.push_str(&format!(
"pub const APP_JS_ASSET_PATH: &str = {:?};\n",
app_js_asset_path
));
generated.push_str(&format!(
"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
));
generated.push_str(&format!(
"pub const ASSET_BUILD_ID: &str = \"{:016x}\";\n",
build_hash
));
generated.push_str(&format!(
"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!(
@@ -95,7 +337,21 @@ fn main() {
));
}
generated.push_str("];\n");
generated.push_str(&format!(
"pub const PRESET_MANIFEST_JSON: &str = {:?};\n",
preset_manifest_json
));
generated.push_str("pub const PRESET_ASSETS: &[(&str, &str)] = &[\n");
for (filename, path) in preset_assets {
generated.push_str(&format!(
" ({:?}, include_str!({:?})),\n",
filename,
path.to_string_lossy()
));
}
generated.push_str("];\n");
let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
fs::write(out_dir.join("app.bundle.js"), app_js).expect("write generated app.bundle.js");
fs::write(out_dir.join("languages.rs"), generated).expect("write generated languages.rs");
}
+1685 -211
View File
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
# Visual Flow architecture
Flow is an authoring/orchestration layer. It does not replace the thermostat, schedule, group or device engines.
## Compilation
- `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.
- Flow updates require `expected_revision`; stale editor tabs receive HTTP 409.
- Work-in-progress graphs may be persisted with `draft=true`. Drafts are forced disabled, compile to zero schedules/automations, and can contain unfinished semantic wiring such as a graph without an action. Missing node references and duplicate/empty IDs are still rejected so the editor document remains structurally safe.
- Saving a previously executable Flow as a draft atomically deletes its generated outputs, so no stale task can keep running. Completing and saving it normally clears draft status and recompiles it.
## Existing control domains remain authoritative
- `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/louver/air/xfan/health remain available through the normal command path.
## Concurrency invariants
Structural Flow writes use the global order:
`configuration -> automation -> schedule -> thermostat-cycle`
Thermostat/device actions then use:
`automation -> schedule -> thermostat-cycle -> zone -> device`
Group actions use the existing group order:
`automation -> house -> thermostat-cycle -> group -> zone(s) -> device`
The control loop runs thermostat arbitration and automation arbitration sequentially. Automation execution reloads the row after acquiring the automation lock and skips it when `updated_at` changed. Same-cycle due automations deterministically claim target devices and Home Assistant entity IDs so two rules cannot issue conflicting commands to the same target in one pass. Polling and physical commands share per-zone/per-device locks.
Holding the automation lock across the physical action is deliberate: Flow cannot be deleted/recompiled while an already-selected action is in flight. Do not shorten this ownership window without adding an explicit execution-generation/token mechanism.
External sensor values can naturally change between condition sampling and action execution. This is normal sampled-control semantics, not a persistent-state race; safety/ownership is rechecked at the action boundary.
## Home Assistant safety
`ha_state`, `ha_numeric`, `ha_attribute` and `ha_available` fail closed. Connection failures, missing attributes and non-numeric numeric states evaluate to `false` and appear as an error object in dry-run traces. This prevents `NOT` / `neq` branches from becoming true merely because Home Assistant is unavailable.
## Diagnostics
The Flow editor exposes:
- dry-run at an arbitrary date/time,
- per-source overrides,
- per-node trace,
- `would_execute` and ownership/block reason,
- Flow-scoped execution/dry-run logs,
- 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. `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, 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. 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-dynamic/flows.js`.
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
Stateful timing now has persisted runtime support for `stable_for`, bounded state duration, change detection, rolling execution limits, delay-before-action, rolling mean/median samples and oscillation detection. Useful next additions on the same runtime-state foundation are:
- debounce with configurable dead time,
- a dedicated external-entity thermostat/hysteresis action (rather than duplicating the built-in HVAC thermostat),
- explicit rising/falling edge modes,
- retry/backoff for external service failures,
- variables/counters,
- reusable subflows/macros,
- explicit priority/mutex groups for actions,
- trace replay from historical sensor samples.
These need durable state and restart semantics before they should be exposed in the editor.
### Display name / alias
The Flow name edited in `/flows` is a presentation alias. Renaming it changes labels shown in the UI (including the dashboard) but does not change the stable Flow ID or generated `flow-<unique-id>` schedule/automation names.
-67
View File
@@ -1,67 +0,0 @@
# Home Assistant entity-ID migration
## Goal
Replace an existing GREE climate entity while preserving its `entity_id`.
Example:
```text
Before: climate.klima_salon -> default GREE integration -> AC
After: climate.klima_salon -> GREE Controller integration -> Rust controller -> AC
```
Keeping the same entity ID allows existing dashboards, scripts, scenes and automations that reference the entity by ID to continue working.
## Why a takeover step is required
Home Assistant's entity registry reserves entity IDs. A second integration cannot create another active `climate.klima_salon` while the original entity still exists. The new integration therefore checks for conflicts and stops setup rather than allowing HA to generate a suffixed name such as `climate.klima_salon_2`.
## Generate the mapping
For one entity:
```bash
./scripts/generate_ha_migration.py \
--entity climate.klima_salon \
--device gree-aabbccddeeff
```
For several:
```bash
./scripts/generate_ha_migration.py \
--map climate.klima_salon=gree-aabbccddeeff \
--map climate.klima_sypialnia=gree-112233445566
```
Generated structure:
```json
{
"version": 1,
"entities": [
{
"entity_id": "climate.klima_salon",
"device_id": "gree-aabbccddeeff"
}
]
}
```
Copy it to `/config/gree_controller_entities.json` on the Home Assistant host.
## Migration sequence
1. Add the physical AC to the standalone Rust application.
2. Test power, mode and target temperature from its web UI.
3. Generate the HA entity mapping.
4. Copy the custom component to `/config/custom_components/gree_controller/`.
5. Disable/remove the previous GREE integration in HA so it no longer controls or publishes the old climate entity.
6. Remove any stale entity registry entry only after the old integration is unloaded.
7. Add the **GREE Controller** integration and provide its URL/token.
8. Confirm the exact old entity ID is present again.
9. Test `climate.set_temperature`, HVAC modes and power from HA.
10. Verify automations and dashboards that use the preserved entity ID.
The standalone controller remains available during the HA migration, so the AC can still be controlled from its own web UI if HA is restarting.
-71
View File
@@ -1,71 +0,0 @@
# Localization
The web interface uses JSON language packs from `lang/`. Language files are discovered at Rust build time and embedded in the application binary.
English (`lang/en.json`) is required and is always the fallback language. Polish (`lang/pl.json`) is included by default.
## Add a language
1. Copy `lang/en.json` to a file named with the new language code, for example `lang/de.json`.
2. Update the `meta` object.
3. Translate values inside `translations`. Do not rename translation keys.
4. Run `./scripts/dev.sh --check` or build the project again.
5. Start the rebuilt binary. The new language appears automatically in the language selector.
Example structure:
```json
{
"meta": {
"code": "de",
"name": "German",
"native_name": "Deutsch",
"locale": "de-DE"
},
"translations": {
"controls.language": "Sprache",
"controls.theme": "Darstellung"
}
}
```
The filename and `meta.code` must match (`de.json` -> `"code": "de"`). File names may contain ASCII letters, digits, `-` and `_` only.
## Fallback behavior
A language pack does not have to duplicate every English key while it is being developed. If a key is missing from the selected language, the UI uses the value from `en.json`. If a key is also missing from English, the translation key itself is shown, which makes incomplete strings visible during development.
## Build-time validation
`build.rs` checks that:
- at least one JSON language file exists,
- `en.json` exists,
- every language file contains valid JSON,
- every file has `meta.code`, `meta.name`, `meta.native_name` and `meta.locale`,
- `meta.code` matches the filename,
- `translations` is a JSON object.
A malformed language pack fails the Rust build instead of producing a broken selector at runtime.
## Runtime endpoints
The embedded language catalog is available at:
```text
GET /lang/index.json
```
Individual embedded packs are available at:
```text
GET /lang/en.json
GET /lang/pl.json
GET /lang/<code>.json
```
These endpoints are intentionally public so that localization also works before API authentication is completed.
## Browser preference
The selected language code is stored for one year in the `gree_controller_language` cookie. If the stored language is no longer present in a later build, the UI falls back to English.
-177
View File
@@ -1,177 +0,0 @@
# LXC installation and update
This document describes the supported Debian/Ubuntu systemd deployment used for LXC testing.
## First installation
Unpack the source archive inside the container and run:
```bash
cd gree-controller
chmod +x scripts/*.sh
sudo ./scripts/install.sh
```
The installer:
1. installs required build packages when missing,
2. installs stable Rust with rustup when Cargo is unavailable,
3. runs `cargo test --all-targets`,
4. builds `target/release/gree-controller`,
5. creates the `gree-controller` system user/group,
6. creates `/var/lib/gree-controller`, `/opt/gree-controller` and `/var/backups/gree-controller`,
7. installs the systemd unit,
8. creates `/etc/gree-controller.env` only when it does not already exist,
9. enables/restarts the service,
10. verifies `GET /api/health`.
Installed state is intentionally outside the extracted source directory:
```text
/opt/gree-controller/gree-controller installed binary
/etc/gree-controller.env persistent configuration/secrets
/var/lib/gree-controller/gree-controller.db persistent SQLite database
/var/backups/gree-controller/ update backups
/etc/systemd/system/gree-controller.service systemd service
```
The default installation starts in simulator mode. Change `GREE_CONTROLLER_SIMULATE=false` and `GREE_CONTROLLER_AUTO_SEED=false` in `/etc/gree-controller.env` when moving to physical units, then restart the service.
## Update
Unpack a newer source archive and run from that new directory:
```bash
sudo ./scripts/update.sh
```
The update workflow is designed to minimize downtime and preserve the database:
1. build dependencies/Rust are checked,
2. tests run while the currently installed controller stays online,
3. the new release binary is built while the old service stays online,
4. the service is stopped,
5. the installed binary, service unit, environment file and SQLite database are copied to `/var/backups/gree-controller/<UTC timestamp>/`,
6. the new binary/unit is installed,
7. systemd starts the new version,
8. `/api/health` is checked,
9. on failure the old binary, unit and database are restored automatically.
The updater does not overwrite `/etc/gree-controller.env` during a successful update.
Use `--skip-tests` only for deliberate fast testing:
```bash
sudo ./scripts/update.sh --skip-tests
```
## Service helper
```bash
./scripts/service.sh status
./scripts/service.sh health
./scripts/service.sh logs
sudo ./scripts/service.sh restart
sudo ./scripts/service.sh stop
sudo ./scripts/service.sh start
```
Equivalent native commands remain available:
```bash
systemctl status gree-controller
journalctl -u gree-controller -f
```
## Configuration
Edit:
```text
/etc/gree-controller.env
```
and restart:
```bash
sudo ./scripts/service.sh restart
```
Do not store controller or Home Assistant secrets in the source tree.
## Database and SQL layout
Runtime persistence uses SQLite. All schema definitions and SQL statements in the Rust application are centralized in:
```text
src/queries.rs
```
`src/db.rs` contains connection/transaction logic and maps database rows to Rust domain models, but it does not embed SQL statements.
## Two network interfaces
When the LXC has a management interface and a dedicated GREE network, configure the service environment explicitly. Example for `eth1` on `10.87.65.0/25`:
```env
GREE_CONTROLLER_GREE_INTERFACE=eth1
GREE_CONTROLLER_DISCOVERY_BROADCAST=auto
This explicit interface configuration is recommended for predictable LXC deployments. Version 0.3.7 also automatically selects the directly connected local IPv4 address for a GREE device when the interface variable is omitted; for example, a target in `10.87.65.0/25` selects the local address on that subnet.
GREE_CONTROLLER_SIMULATE=false
GREE_CONTROLLER_AUTO_SEED=false
```
Restart the service after editing `/etc/gree-controller.env`:
```bash
systemctl restart gree-controller
journalctl -u gree-controller -n 100 --no-pager
```
During discovery the log should contain a line similar to:
```text
Starting GREE discovery target=10.87.65.127:7000 local=10.87.65.27:<ephemeral> interface=eth1
```
Use `scripts/network-debug.sh` for routing diagnostics.
## Mixed GREE model generations
Version 0.3.7 can discover both AES-ECB and AES-GCM modules. In the Web UI choose **Discover -> Auto (V1 + V2)** and use 3-5 scan passes. If a family is still missing, repeat with V1-only and V2-only to see which protocol its Wi-Fi module answers with.
A single command/status timeout no longer immediately flips a device offline; offline requires three consecutive communication failures.
## Multi-NIC LXC and AF_NETLINK
When `GREE_CONTROLLER_GREE_INTERFACE` is set, the controller enumerates IPv4 addresses with Linux `getifaddrs()`. On Linux this requires a Netlink socket. The systemd sandbox therefore allows `AF_NETLINK` in addition to `AF_UNIX`, `AF_INET`, and `AF_INET6`.
If an older unit reports:
```text
getifaddrs failed
Address family not supported by protocol (os error 97)
```
update the systemd unit or add `AF_NETLINK` to `RestrictAddressFamilies`, then run:
```bash
systemctl daemon-reload
systemctl restart gree-controller
```
For a dedicated GREE NIC such as `eth1`, the expected startup/bind log should identify the IPv4 address of that interface instead of `0.0.0.0`.
### Legacy V1 devices discovered but not binding
Version 0.3.8 sends the GREE protocol `tcid` and inner `mac` identifiers in canonical lowercase hexadecimal. If an older `502cc6...` device is discovered on UDP/7000 but stays offline after bind timeouts, update to v0.3.8 before changing routing or firewall settings. With debug logging, `Sending GREE request` should show `wire_mac=502cc6...` in lowercase.
## Home Assistant HTTPS with a self-signed certificate
The optional outbound HA sensor client validates TLS certificates by default. For a trusted local endpoint such as `https://10.87.65.2` that uses a self-signed, expired or hostname-mismatched certificate, enable **Settings -> Allow invalid/self-signed HTTPS certificate** and save. The same initial setting can be supplied as `HA_ALLOW_INVALID_TLS=true`.
This opt-in affects only controller -> Home Assistant sensor requests. It does not change the GREE UDP transport and it does not add HTTPS termination to the controller itself.
-196
View File
@@ -1,196 +0,0 @@
# GREE Controller project specification
## Product goal
Build an autonomous local GREE HVAC controller running primarily as a Rust service in a dedicated Linux/LXC environment. Home Assistant is optional: it can provide external sensor data and can consume controller devices through a thin custom integration, but it must not contain the core GREE protocol or heating logic.
## Architecture
```text
Home Assistant / Web UI / REST clients
|
REST + WebSocket
|
GREE Controller (Rust)
+--------------------------------+
| Device/state manager |
| GREE protocol UDP/AES |
| Zone/heating engine |
| Scheduler |
| Automation engine |
| SQLite |
| Mobile-first web UI |
+--------------------------------+
|
UDP/7000
|
GREE AC
```
## Independence requirements
- GREE control must work when Home Assistant is stopped.
- Local schedules and automations must continue without HA.
- HA sensor failures must never remove basic access to the AC.
- Device state is persisted by the controller and can be restored to clients after reconnect/restart.
## GREE protocol layer
The protocol implementation is isolated from application logic and covers:
- discovery,
- packet encoding/decoding,
- AES-128-ECB support,
- AES-128-GCM envelope support,
- bind/key acquisition,
- status polling,
- command transport,
- reconnect/offline handling.
The rest of the application works with generic device state and commands rather than raw protocol packets.
## Device model
Each device stores:
- stable controller ID,
- MAC/CID,
- name,
- IP/port,
- protocol version,
- model/firmware when available,
- encryption key when bound,
- enabled/simulated flags,
- power and HVAC mode,
- target/current/outdoor temperature,
- fan, swing, quiet, turbo, light,
- online/last-seen/error state.
## Smart thermostat, zones and schedules
The primary user model is a fast thermostat, not raw device automation. A global climate mode selects the season (`cool`, `heat`, `off`). Zones follow it by default and can optionally override Heat/Cool. The UI labels this as **Mode policy**: keep **Follow global mode** when the zone should inherit the mode selected globally in GREE Controller; the fixed Cooling/Heating policies are explicit overrides. House `off` is a **do not control** state for inherited zones: manual device operation is left untouched, while a zone explicitly switched to Heat/Cool can operate independently. Whole-house power on/off is a separate master state. Master Off is authoritative for zones and controller automations but does not modify the selected house thermostat mode.
During normal heating/cooling the controller minimizes unit power cycling. It keeps the indoor unit powered and uses **setpoint modulation**:
- when the zone requires conditioning, use the active target (optionally assisted slightly by outdoor weather),
- when the zone is satisfied, move the device setpoint to the non-demand side of the room target so the inverter/compressor can stop naturally,
- use hysteresis and a minimum adjustment interval to avoid command chatter,
- optionally adjust fan speed based on room error and outdoor extremes; with Smart fan enabled, a satisfied zone uses Low fan together with the standby setpoint and requests Quiet mode in the same frame when the unit supports it. Unsupported Quiet commands automatically fall back to Low fan without breaking thermostat control.
Each zone has separate seasonal profile temperatures:
- Cooling: Comfort / Sleep / Away,
- Heating: Comfort / Sleep / Away.
The zone can use the GREE internal sensor, its own Home Assistant room sensor, or a weighted combination. External room sensors are configured per zone; loss/discrepancy falls back safely to the GREE sensor.
Weekly schedules reference profiles instead of duplicating temperatures. Ready-made Family, Child room, Bedroom, Workday and Always-comfort templates create ordinary schedule rows that remain fully editable. A manual **Sleep**, Comfort, Away or custom-temperature override ends automatically at the next schedule boundary; if no future transition exists, it remains active until explicitly cleared. Whole-house preset actions apply the same temporary policy to every zone.
Automations remain available for advanced exceptions. Daily comfort should be implemented with zones/profiles/schedules so direct-device automation commands do not compete with the thermostat engine.
Climate groups are named collections of thermostat zones (for example Upstairs/Downstairs). Group commands can independently set power, Heat/Cool/house-follow mode and Auto/Comfort/Sleep/Away profile for only their member zones. Automation actions may target either a direct device or a climate group.
### Outdoor temperature assist
A configured Home Assistant outdoor-temperature entity is optional auxiliary context. It never replaces the room-control temperature. Under strong heat/cold the engine may slightly bias the active AC target and increase Smart Fan airflow. If HA is unavailable, the smart thermostat continues without outdoor assistance.
## Scheduler and automations
Schedules are weekly time windows and may cross midnight. Equal start/end times mean a 24-hour block for each selected weekday. Enabled schedules for one zone may not overlap. Schedules can select Comfort/Sleep/Away or a custom setpoint. Automations support time or temperature triggers and device commands with cooldown protection; a failed command also observes the cooldown instead of retrying every control cycle. They are intended for exceptions rather than the normal daily thermostat cycle.
The automation engine runs in Rust and does not require Home Assistant YAML automation logic.
## API
The controller exposes REST for configuration/commands and WebSocket for live state updates. The HA integration and web UI use the same controller API.
## Home Assistant direction 1: external sensor input
The Rust application can read a specifically configured HA entity using a Long-Lived Access Token. This input is optional and is not a prerequisite for GREE control.
## Home Assistant direction 2: native climate entities
A custom integration creates HA climate entities and translates HA service calls to controller API commands. It does not communicate with GREE directly.
For migrations from the default GREE integration, a mapping file can request an existing entity ID such as `climate.klima_salon`. The previous integration must release that ID before takeover.
## Web UI
Primary use is from a phone. Requirements:
- responsive mobile-first layout,
- touch-friendly controls,
- live state updates,
- device, zone, schedule, automation, history and diagnostics screens,
- JSON-based language packs from `lang/*.json`, with English as the required default/fallback and Polish included,
- automatic language discovery at build time, so a new valid `<code>.json` file adds a language without JavaScript changes,
- language stored in the `gree_controller_language` cookie,
- system/light/dark appearance modes,
- appearance stored in a cookie,
- flat visual design without decorative shadows,
- no letter-logo badge next to the application name.
## Storage
SQLite stores configuration, state, readings and events. PostgreSQL or other external database services are not required for the single-node deployment target.
## Deployment
Primary target:
```text
LXC / Debian or Ubuntu
/opt/gree-controller/gree-controller
/etc/gree-controller.env
/var/lib/gree-controller/gree-controller.db
systemd: gree-controller.service
```
A development script prepares dependencies/build/runtime configuration, and a separate installer creates the systemd deployment.
## Security
- optional administrator Bearer token for the controller Web/API,
- separately generated, revocable Home Assistant client tokens stored only as SHA-256 hashes,
- Home Assistant client tokens are restricted to device read/control endpoints,
- secrets kept outside source control,
- no direct public Internet exposure,
- TLS delegated to a trusted reverse proxy/VPN when required,
- local device keys and HA token protected in environment/database files,
- invalid/self-signed HA HTTPS certificates are rejected by default; bypass is an explicit trusted-LAN opt-in.
## Per-zone room sensors
A zone represents one room and normally maps one GREE indoor unit to one optional external room-temperature sensor. External sensors are not global. For example, Living Room can use `sensor.living_room_temperature` while Bedroom independently uses `sensor.bedroom_temperature`.
The zone controller supports `device`, `combined`, and `home_assistant` temperature strategies. Combined mode uses a configurable external-sensor weight (40% by default), validates the difference between sensors, and falls back to the GREE sensor when the external source is missing or outside the configured discrepancy limit. The local GREE measurement therefore remains available even when Home Assistant is offline.
## Operational packaging conventions
- All operator-facing shell/Python utilities live under `scripts/`.
- `scripts/install.sh` performs the first systemd/LXC installation.
- `scripts/update.sh` performs an in-place update with a stopped SQLite backup, health check and automatic rollback.
- `scripts/service.sh` provides common systemd operations.
- Cargo's `build.rs` stays at the package root because Cargo requires that location.
- Every SQLite statement and schema definition is centralized in `src/queries.rs`; database/domain code must not embed SQL strings elsewhere.
## Web UI design system (v0.4.4)
The embedded UI uses the classic GREE Controller visual language: large rounded surfaces, circular thermostat controls, comfortable spacing, compact desktop navigation and mobile bottom navigation. Light, Dark and System themes use neutral surfaces with a restrained green accent.
The UI ships as a normal static `web/styles.css` file embedded into the Rust binary. There is no frontend package manager, CDN, or CSS build pipeline.
### Rich zone history
Zone history stores GREE temperature, optional Home Assistant room temperature, calculated control temperature, profile target, actual device setpoint, outdoor temperature assist, power, mode, fan speed, demand, active preset and control source. The UI can compare all zones on common temperature/target charts or inspect one zone in detail.
### History information architecture (v0.4.4)
History is not a single long page. It is split into URL-addressable views: Overview, Zones, GREE devices, HA sensors and Custom chart. Device history exists independently from zones, HA sensors have their own history stream, and zone history adds the thermostat/control context. A compatibility fallback maps existing GREE device readings into a zone timeline when the richer zone table is still empty.
The custom chart composer can mix GREE indoor/outdoor/target values, zone GREE/HA/control/target/device-setpoint/outdoor values, and HA temperature entities. Saved definitions persist in browser storage and a chart definition can be serialized into the `/history/custom?chart=...` URL for direct sharing/bookmarking.
Quick thermostat temperature nudges are optimistic in the browser and stored separately from the selected Comfort/Sleep/Away/Auto preset. Changing `+/-` therefore does not silently leave Auto mode.
-32
View File
@@ -1,32 +0,0 @@
# Reverse proxy
The web UI supports HTTPS/WSS and sub-path deployments. The server honors `GREE_CONTROLLER_BASE_PATH` and also understands `X-Forwarded-Prefix` when a proxy strips the prefix before forwarding.
## Root deployment
Proxy `/` to `http://127.0.0.1:8787` and forward WebSocket upgrade headers. No controller setting is required.
## Sub-path deployment
For a public path such as `/gree`, either:
- set `GREE_CONTROLLER_BASE_PATH=/gree` and proxy `/gree` to the controller without stripping the path, or
- strip `/gree` in the proxy and send `X-Forwarded-Prefix: /gree`.
Static assets, language packs, browser routes, API requests, PWA scope and WebSocket URLs use the effective base path.
### nginx example (prefix stripped)
```nginx
location /gree/ {
proxy_pass http://127.0.0.1:8787/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /gree;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
For an Internet-facing endpoint, add TLS and proxy authentication. If the application token is enabled, avoid access-log formats that record query strings because the browser WebSocket connection includes the token in its URL.
-31
View File
@@ -1,31 +0,0 @@
# Security notes
The controller is designed primarily for a trusted LAN. This release adds safe defaults without forcing authentication on existing installations.
## Implemented safeguards
- `GREE_CONTROLLER_APP_TOKEN=` remains supported. Authentication is optional and existing installations are compatible.
- Permissive CORS was removed. The web UI and API are same-origin by default.
- Responses include `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy` and a restrictive `Permissions-Policy`; API responses are marked `no-store`.
- Runtime secrets for Home Assistant, InfluxDB and notifications are stored server-side and never returned to the browser. Empty secret fields keep the previously stored value.
- Slack and Discord notification webhooks require HTTPS and are restricted to the official webhook hosts. Redirect following is disabled for these outbound webhook requests. Pushover uses its fixed official API endpoint.
- API access tokens are stored hashed. Administrator token behavior is unchanged for backwards compatibility.
- History/event API limits and SQLite indexes bound common read paths. History retention/compaction is configurable.
## Remaining risks / recommendations
1. With an empty application token, every client that can reach the controller HTTP port can operate the air conditioners and change configuration. Keep port 8787 on a trusted VLAN or put authentication in the reverse proxy.
2. The WebSocket administrator token is still passed in its query string because browser WebSocket APIs cannot set an Authorization header. Avoid logging query strings at the proxy and prefer HTTPS/WSS when a token is enabled.
3. The application does not terminate TLS. Use a reverse proxy or VPN for traffic crossing an untrusted network.
4. Home Assistant `allow_invalid_tls` should be used only with a known local instance; keep it disabled otherwise.
5. Configuration exports contain device keys and may contain integration secrets. Treat backup files as secrets.
6. There is no per-client API rate limiter. For exposure beyond a trusted LAN, configure rate limiting at the reverse proxy.
7. No CSRF token is used. Same-origin operation, removal of permissive CORS and bearer-token authentication reduce the risk, but an authenticated public deployment should also use proxy-level origin/access controls.
## Suggested reverse-proxy hardening
- TLS only; redirect HTTP to HTTPS.
- Do not log URL query strings if administrator authentication is enabled.
- Pass WebSocket upgrades for `/ws`.
- Add authentication at the proxy when `GREE_CONTROLLER_APP_TOKEN` is empty and the service is reachable outside the trusted LAN.
- Limit request body sizes and add basic request-rate limiting.
+10009
View File
File diff suppressed because it is too large Load Diff
-73
View File
@@ -1,73 +0,0 @@
1. Timer powrotu do automatyzacji
Backend już przechowywał local_thermostat_resume_at, ale przy takim scenariuszu:
lokalny OFF → licznik 15 min → pilot/direct ON → pilot/direct OFF
stary deadline nadal istniał. Sterowanie pilotem miało wyższy priorytet, ale timer lokalnego OFF nadal „leciał pod spodem”.
Poprawiłem to tak:
dodałem centralną metodę set_local_thermostat_power(...),
każde nowe OFF ustawia świeże now + 15 min,
ON kasuje deadline,
podczas device_manual_override timer lokalnego OFF nie może wygasnąć,
gdy użytkownik po pilocie/direct wróci do wcześniejszego stanu OFF, backend ustawia nowe pełne 15 minut,
HA bierze dokładny local_thermostat_resume_at zwrócony przez backend, więc stary odczyt nie powinien chwilowo przywracać poprzedniego timera.
Frontend nadal może odświeżać MM:SS co sekundę lokalnie — to jest właściwe. Frontend tylko wyświetla czas, backend decyduje kiedy przejąć sterowanie. Jeśli chcesz całkowicie uniezależnić wyświetlanie od zegara przeglądarki, później można dodać server_time albo remaining_seconds.
2. Pilot + Home Assistant + Web — jak powinno to działać
Tutaj obecna architektura jest już częściowo dobra. Masz dwa różne rodzaje poleceń:
bezpośrednie urządzenie — pilot, Web „Devices”, fizyczny climate w HA,
termostat strefy — Quick Thermostat w Web i climate.<strefa>_thermostat w HA.
Logicznie ustawiłbym następujące zasady:
Pilot/direct przejmuje urządzenie. Powstaje device_manual_override. Harmonogram, grupa i automatyka nie walczą wtedy z człowiekiem.
Kolejna komenda direct z HA/Web zmienia tę samą ręczną sesję i nadal nie uruchamia automatyki.
Komenda do termostatu strefy oznacza świadome: „teraz znowu steruje regulator”. Wtedy device_manual_override jest kasowany i zaczyna obowiązywać termostat.
Grupy, harmonogram i automatyzacje nie powinny po cichu odbierać sterowania pilotowi.
Globalne OFF powinno pozostać nadrzędne — i obecny kod właśnie tak działa.
To jest bardzo sensowna polityka.
W panelu dodałbym jednak jawny status jednego właściciela:
Automatyka / Termostat lokalny / Pilot / HA — sterowanie bezpośrednie / Web — sterowanie bezpośrednie / Globalnie wyłączone
oraz:
od kiedy,
do kiedy,
dlaczego,
przycisk Przejmij sterowanie termostatem.
API też powinno to wystawiać, np. logicznie jako:
control_owner
control_source
control_since
resume_at
Obecnie device_manual_override jest zapisany, ale dokładne źródło przejęcia jest głównie w logach. Przy dalszym rozwoju warto zrobić z tego normalny stan.
Szczególnie w HA rozważyłbym, żeby fizyczny climate urządzenia był oznaczony jako „sterowanie bezpośrednie / zaawansowane”, a głównym encją użytkową był termostat strefy. Teraz użytkownik może mieć dwa climate dla jednego klimatyzatora, które celowo mają inne znaczenie — łatwo się pomylić.
3. Co jeszcze brakuje przed używaniem tego jako głównego ogrzewania/chłodzenia
Najważniejsze rzeczy, w tej kolejności:
Blokada współbieżnych zmian strefy. To jest realny problem w obecnym kodzie. HA i Web mogą prawie równocześnie pobrać ten sam Zone, zmienić różne pola i zapisać cały JSON. Ostatni zapis może nadpisać poprzednią zmianę. Dałbym per-zone mutex oraz docelowo revision/optimistic locking. To jest dla mnie najwyższy następny priorytet.
Formalny model własności sterowania. Zamiast sprawdzania w wielu miejscach device_manual_override, local_thermostat_power, group gate itd., jedna metoda typu resolve_control_owner() powinna określać kto aktualnie może wysyłać komendy. Panel, HA i engine korzystałyby z tej samej odpowiedzi.
Ochrona przed szybkim przełączaniem Heat ↔ Cool / OFF ↔ ON. W modelu masz min_on_seconds i min_off_seconds, ale obecnie praktycznie nie są wykorzystywane przez regulator. Zmiana trybu jest wręcz traktowana jako pilna. Dla głównego źródła ogrzewania dodałbym minimum 35 minut blokady po wyłączeniu oraz przy zmianie Heat/Cool.
Kontrola świeżości czujnika HA. read_temperature() sprawdza, czy wartość jest liczbą, ale nie sprawdza wieku last_updated. Sensor może wisieć z poprawną wartością przez wiele godzin. Potrzebny jest np. sensor_stale_after=5 min, fallback na GREE i informacja/alarm.
Tryby ręcznego „hold”. Zamiast jednej zasady warto mieć: 15 min, 30 min, 1 h, do kolejnego harmonogramu, do godziny..., bezterminowo. Ten sam mechanizm można potem wykorzystać dla temperatury, profilu, pilota i lokalnego OFF.
Zmiana harmonogramu podczas ręcznego przejęcia. Kod aktualizuje granicę manual_override_until dla ręcznego profilu/setpointu po edycji harmonogramu, ale nie widzę analogicznego przeliczania device_manual_override_until. To może pozostawić nieaktualny termin przejęcia po zmianie grafiku.
Desired state vs actual state. Warto w API jasno publikować „czego chce regulator” i „co faktycznie ma klimatyzator”, plus powód rozbieżności: pilot, offline, timer, group OFF, lockout. Część tego już masz w control-plan, więc to jest naturalne rozszerzenie.
Ochrona temperaturowa. Dla głównego ogrzewania przyda się opcjonalny „frost protection”, np. alarm lub awaryjne grzanie poniżej określonej temperatury. Powinno być osobno konfigurowalne, żeby świadome globalne OFF nie zostało niespodziewanie złamane.
Dopiero później rozważałbym automatyczne Heat/Cool na podstawie temperatury z dużą martwą strefą i minimalnym czasem pozostawania w jednym trybie.
Najbliższa zmiana architektoniczna, którą zrobiłbym teraz, to więc ControlOwnership + per-zone locking/revision. To rozwiąże większość przyszłych konfliktów HA ↔ Web ↔ pilot w jednym miejscu, zamiast dodawać kolejne wyjątki.
+17
View File
@@ -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
+126
View File
@@ -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"]
+85
View File
@@ -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
```
+165
View File
@@ -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 now reports the configured zone state independently from group power gates; `effective_enabled` remains available in attributes/sensors to show when an enabled zone is currently blocked by a disabled group. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone: local ON may run the zone while its group is off, with full thermostat logic intact. 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.
@@ -134,13 +133,13 @@ The integration also exposes three controller-level entities on the **GREE Contr
- **Thermostat mode** (`select`) — Cooling, Heating or **Do not control**. Do not control pauses house-level thermostat commands but leaves direct device control and explicit per-zone Heat/Cool overrides untouched.
- **Work profile** (`select`) — Auto schedule, Comfort, Sleep or Away. If zones have mixed manual profiles, the select has no single current option until a whole-house profile is chosen again.
- Choosing **Cooling/Heating** in the thermostat-mode select or any whole-house **Work profile** is treated as explicit whole-house activation: it switches **All air conditioners** back on. **Do not control** does not change master power.
- **All air conditioners** (`switch`) — separate master power. Turning it off powers every enabled unit down and prevents zones/controller automations from restarting them. Turning it on powers all enabled units on again without changing the selected thermostat mode or profile.
- Choosing **Cooling/Heating** changes the house rule for zones that inherit the global mode. **Do not control** pauses only that inherited house rule; explicit local/group/manual control remains independent.
- **All air conditioners** (`switch`) performs bulk thermostat power control. OFF powers all enabled units down and leaves their thermostats locally OFF with no hand-back timer; later local/group/global ON can re-enable the selected scope. ON releases local OFF states and respects compressor protection. Its displayed state reflects whether all currently enabled units are physically ON.
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.
@@ -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")
@@ -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})
@@ -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

@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.8.10",
"version": "0.15.17",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -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
@@ -63,7 +63,9 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator]
"outdoor_temperature": plan.get("outdoor_temperature"),
"control_strategy": plan.get("control_strategy"),
"work_profile": plan.get("house_preset"),
"master_power": bool(plan.get("house_power", False)),
"all_units_powered": bool(self.coordinator.data) and all(
bool(device.get("power")) for device in self.coordinator.data.values() if device.get("enabled", True)
),
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
"effective_enabled_zones": sum(bool(zone.get("effective_enabled", zone.get("enabled"))) for zone in zones),
"demanding_zones": sum(bool(zone.get("demand")) for zone in zones),
@@ -165,8 +167,6 @@ class GreeControllerGroupPlanSensor(CoordinatorEntity[GreeControllerCoordinator]
group = self._group
if not group.get("power_enabled", False):
return "off"
if not group.get("effective_power", False):
return "master_off"
if group.get("mode") == "house" and group.get("house_mode") == "off":
return "paused"
return "requesting" if int(group.get("demanding_zones") or 0) > 0 else "satisfied"
@@ -62,7 +62,8 @@ class GreeControllerHousePowerSwitch(CoordinatorEntity[GreeControllerCoordinator
@property
def is_on(self) -> bool:
return bool(self.coordinator.plan.get("house_power", False))
devices = [device for device in self.coordinator.data.values() if device.get("enabled", True)]
return bool(devices) and all(bool(device.get("power")) for device in devices)
@property
def device_info(self) -> DeviceInfo:
@@ -83,7 +84,7 @@ class GreeControllerHousePowerSwitch(CoordinatorEntity[GreeControllerCoordinator
class GreeControllerGroupPowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Enable or disable one controller climate group."""
"""Power a controller climate group on or off."""
_attr_has_entity_name = True
_attr_name = "Power"
@@ -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"
}
}
}
}
}
}
}
@@ -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`.
+144
View File
@@ -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
![Topologia Home Assistant OS + GREE Controller + VLAN](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topologia-ha-vlan-gree-pl.png)
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
![Home Assistant OS + GREE Controller + VLAN topology](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topology-ha-vlan-gree-en.png)
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.
![Home Assistant OS + GREE + VLAN topology](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topology-ha-vlan-gree-en.png)
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.
+3
View File
@@ -0,0 +1,3 @@
name: GREE Controller
url: https://git.linuxiarz.pl/gru/gree-controller-ha-addon/
maintainer: linuxiarz.pl
+94
View File
@@ -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
+70
View File
@@ -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
View File
@@ -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"
}
}
}
+883 -43
View File
File diff suppressed because it is too large Load Diff
+889 -49
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+1 -1
View File
@@ -67,4 +67,4 @@ def main():
if __name__ == "__main__":
main()
main()
+83
View File
@@ -0,0 +1,83 @@
{
"id": "bedroom_window_night",
"category": "night",
"name": {
"pl": "Sypialnia: noc + zamknięte okno",
"en": "Bedroom: night + closed window"
},
"description": {
"pl": "Łączy tryb nocny z NOT otwartego okna, aby profil sleep działał tylko przy zamkniętym oknie.",
"en": "Combines night mode with NOT open-window logic so the sleep profile runs only with a closed window."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000148",
"kind": "night_mode",
"x": 35,
"y": 65,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000149",
"kind": "ha_state",
"x": 35,
"y": 185,
"config": {
"entity_id": "binary_sensor.bedroom_window",
"operator": "eq",
"value": "on"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000150",
"kind": "logic_not",
"x": 275,
"y": 185,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000151",
"kind": "logic_and",
"x": 500,
"y": 125,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000152",
"kind": "zone_thermostat",
"x": 745,
"y": 125,
"config": {
"zone_id": "$zone1",
"preset": "sleep",
"setpoint": 20,
"mode": "auto",
"cooldown_seconds": 180
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000153",
"from": "node-00000000-0000-4000-8000-000000000149",
"to": "node-00000000-0000-4000-8000-000000000150"
},
{
"id": "edge-00000000-0000-4000-8000-000000000154",
"from": "node-00000000-0000-4000-8000-000000000148",
"to": "node-00000000-0000-4000-8000-000000000151"
},
{
"id": "edge-00000000-0000-4000-8000-000000000155",
"from": "node-00000000-0000-4000-8000-000000000150",
"to": "node-00000000-0000-4000-8000-000000000151"
},
{
"id": "edge-00000000-0000-4000-8000-000000000156",
"from": "node-00000000-0000-4000-8000-000000000151",
"to": "node-00000000-0000-4000-8000-000000000152"
}
]
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"id": "device_resilience",
"category": "reliability",
"name": {
"pl": "Sterowanie tylko przy sprawnym urządzeniu",
"en": "Only when device is healthy"
},
"description": {
"pl": "Uruchamia termostat tylko gdy urządzenie jest włączone, online i tryb domu pozwala na pracę.",
"en": "Runs the thermostat only when the device is enabled, online and house mode allows operation."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000157",
"kind": "device_state",
"x": 40,
"y": 55,
"config": {
"device_id": "$device1",
"field": "online",
"operator": "eq",
"value": "true"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000158",
"kind": "device_state",
"x": 40,
"y": 175,
"config": {
"device_id": "$device1",
"field": "enabled",
"operator": "eq",
"value": "true"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000159",
"kind": "house_mode",
"x": 40,
"y": 295,
"config": {
"operator": "neq",
"value": "off"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000160",
"kind": "logic_and",
"x": 315,
"y": 175,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000161",
"kind": "zone_thermostat",
"x": 570,
"y": 175,
"config": {
"zone_id": "$zone1",
"preset": "auto",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 90
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000162",
"from": "node-00000000-0000-4000-8000-000000000157",
"to": "node-00000000-0000-4000-8000-000000000160"
},
{
"id": "edge-00000000-0000-4000-8000-000000000163",
"from": "node-00000000-0000-4000-8000-000000000158",
"to": "node-00000000-0000-4000-8000-000000000160"
},
{
"id": "edge-00000000-0000-4000-8000-000000000164",
"from": "node-00000000-0000-4000-8000-000000000159",
"to": "node-00000000-0000-4000-8000-000000000160"
},
{
"id": "edge-00000000-0000-4000-8000-000000000165",
"from": "node-00000000-0000-4000-8000-000000000160",
"to": "node-00000000-0000-4000-8000-000000000161"
}
]
}
}
+130
View File
@@ -0,0 +1,130 @@
{
"id": "dual_threshold_control",
"category": "advanced",
"name": {
"pl": "Dwa progi: grzanie i chłodzenie",
"en": "Dual threshold heating/cooling"
},
"description": {
"pl": "Dwie niezależne gałęzie sterują grzaniem poniżej dolnego progu i chłodzeniem powyżej górnego.",
"en": "Two independent branches heat below the lower threshold and cool above the upper threshold."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000312",
"kind": "zone_temperature",
"x": 35,
"y": 70,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 19
}
},
{
"id": "node-00000000-0000-4000-8000-000000000313",
"kind": "zone_temperature",
"x": 35,
"y": 245,
"config": {
"zone_id": "$zone1",
"operator": "gt",
"value": 25
}
},
{
"id": "node-00000000-0000-4000-8000-000000000314",
"kind": "house_mode",
"x": 250,
"y": 70,
"config": {
"operator": "eq",
"value": "heat"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000315",
"kind": "house_mode",
"x": 250,
"y": 245,
"config": {
"operator": "eq",
"value": "cool"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000316",
"kind": "logic_and",
"x": 470,
"y": 70,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000317",
"kind": "logic_and",
"x": 470,
"y": 245,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000318",
"kind": "zone_thermostat",
"x": 715,
"y": 70,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "heat",
"cooldown_seconds": 90
}
},
{
"id": "node-00000000-0000-4000-8000-000000000319",
"kind": "zone_thermostat",
"x": 715,
"y": 245,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "cool",
"cooldown_seconds": 90
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000320",
"from": "node-00000000-0000-4000-8000-000000000312",
"to": "node-00000000-0000-4000-8000-000000000316"
},
{
"id": "edge-00000000-0000-4000-8000-000000000321",
"from": "node-00000000-0000-4000-8000-000000000314",
"to": "node-00000000-0000-4000-8000-000000000316"
},
{
"id": "edge-00000000-0000-4000-8000-000000000322",
"from": "node-00000000-0000-4000-8000-000000000313",
"to": "node-00000000-0000-4000-8000-000000000317"
},
{
"id": "edge-00000000-0000-4000-8000-000000000323",
"from": "node-00000000-0000-4000-8000-000000000315",
"to": "node-00000000-0000-4000-8000-000000000317"
},
{
"id": "edge-00000000-0000-4000-8000-000000000324",
"from": "node-00000000-0000-4000-8000-000000000316",
"to": "node-00000000-0000-4000-8000-000000000318"
},
{
"id": "edge-00000000-0000-4000-8000-000000000325",
"from": "node-00000000-0000-4000-8000-000000000317",
"to": "node-00000000-0000-4000-8000-000000000319"
}
]
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"id": "energy_price_eco",
"category": "energy",
"name": {
"pl": "Droga energia + brak domowników",
"en": "Expensive energy + nobody home"
},
"description": {
"pl": "Przy wysokiej cenie energii i nieobecności przełącza wybraną strefę w oszczędny profil away.",
"en": "When energy is expensive and nobody is home, switches the selected zone to an economical away profile."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000054",
"kind": "ha_numeric",
"x": 40,
"y": 70,
"config": {
"entity_id": "sensor.energy_price",
"operator": "gt",
"value": 0.8
}
},
{
"id": "node-00000000-0000-4000-8000-000000000055",
"kind": "ha_state",
"x": 40,
"y": 190,
"config": {
"entity_id": "person.someone",
"operator": "neq",
"value": "home"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000056",
"kind": "logic_and",
"x": 315,
"y": 130,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000057",
"kind": "zone_thermostat",
"x": 570,
"y": 130,
"config": {
"zone_id": "$zone1",
"preset": "away",
"setpoint": 18,
"mode": "auto",
"cooldown_seconds": 300
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000058",
"from": "node-00000000-0000-4000-8000-000000000054",
"to": "node-00000000-0000-4000-8000-000000000056"
},
{
"id": "edge-00000000-0000-4000-8000-000000000059",
"from": "node-00000000-0000-4000-8000-000000000055",
"to": "node-00000000-0000-4000-8000-000000000056"
},
{
"id": "edge-00000000-0000-4000-8000-000000000060",
"from": "node-00000000-0000-4000-8000-000000000056",
"to": "node-00000000-0000-4000-8000-000000000057"
}
]
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"id": "frost_guard",
"category": "safety",
"name": {
"pl": "Ochrona przeciwzamrożeniowa",
"en": "Frost protection"
},
"description": {
"pl": "W trybie grzania, przy niskiej temperaturze zewnętrznej i pokojowej, ustawia bezpieczny niski cel termostatu.",
"en": "In heating mode, low outdoor and room temperatures select a safe low thermostat target."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000084",
"kind": "house_mode",
"x": 35,
"y": 50,
"config": {
"operator": "eq",
"value": "heat"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000085",
"kind": "outdoor_temperature",
"x": 35,
"y": 170,
"config": {
"operator": "lt",
"value": 3
}
},
{
"id": "node-00000000-0000-4000-8000-000000000086",
"kind": "zone_temperature",
"x": 35,
"y": 290,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 9
}
},
{
"id": "node-00000000-0000-4000-8000-000000000087",
"kind": "logic_and",
"x": 315,
"y": 170,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000088",
"kind": "zone_thermostat",
"x": 575,
"y": 170,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 12,
"mode": "heat",
"cooldown_seconds": 300
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000089",
"from": "node-00000000-0000-4000-8000-000000000084",
"to": "node-00000000-0000-4000-8000-000000000087"
},
{
"id": "edge-00000000-0000-4000-8000-000000000090",
"from": "node-00000000-0000-4000-8000-000000000085",
"to": "node-00000000-0000-4000-8000-000000000087"
},
{
"id": "edge-00000000-0000-4000-8000-000000000091",
"from": "node-00000000-0000-4000-8000-000000000086",
"to": "node-00000000-0000-4000-8000-000000000087"
},
{
"id": "edge-00000000-0000-4000-8000-000000000092",
"from": "node-00000000-0000-4000-8000-000000000087",
"to": "node-00000000-0000-4000-8000-000000000088"
}
]
}
}
+91
View File
@@ -0,0 +1,91 @@
{
"id": "ha_attribute_mode_guard",
"category": "advanced",
"name": {
"pl": "Atrybut HA + okno + dostępność",
"en": "HA attribute + window + availability"
},
"description": {
"pl": "Czyta atrybut hvac_action, dostępność encji i stan okna, aby zatrzymać HVAC tylko przy wiarygodnych danych.",
"en": "Reads hvac_action, entity availability and window state to stop HVAC only when the data is trustworthy."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000337",
"kind": "ha_attribute",
"x": 35,
"y": 60,
"config": {
"entity_id": "climate.living_room",
"attribute": "hvac_action",
"operator": "neq",
"value": "idle"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000338",
"kind": "ha_state",
"x": 35,
"y": 180,
"config": {
"entity_id": "binary_sensor.window",
"operator": "eq",
"value": "on"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000339",
"kind": "ha_available",
"x": 35,
"y": 300,
"config": {
"entity_id": "climate.living_room"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000340",
"kind": "logic_and",
"x": 315,
"y": 180,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000341",
"kind": "zone_thermostat",
"x": 575,
"y": 180,
"config": {
"zone_id": "$zone1",
"preset": "auto",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 60,
"power": false
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000342",
"from": "node-00000000-0000-4000-8000-000000000337",
"to": "node-00000000-0000-4000-8000-000000000340"
},
{
"id": "edge-00000000-0000-4000-8000-000000000343",
"from": "node-00000000-0000-4000-8000-000000000338",
"to": "node-00000000-0000-4000-8000-000000000340"
},
{
"id": "edge-00000000-0000-4000-8000-000000000344",
"from": "node-00000000-0000-4000-8000-000000000339",
"to": "node-00000000-0000-4000-8000-000000000340"
},
{
"id": "edge-00000000-0000-4000-8000-000000000345",
"from": "node-00000000-0000-4000-8000-000000000340",
"to": "node-00000000-0000-4000-8000-000000000341"
}
]
}
}
+105
View File
@@ -0,0 +1,105 @@
{
"id": "ha_boiler_supply_boost",
"category": "home_assistant",
"name": {
"pl": "Gorące zasilanie kotła + zimny pokój → boost",
"en": "Hot boiler supply + cold room → boost"
},
"description": {
"pl": "Czyta sensor.boiler_supply_temperature. Przy temperaturze zasilania powyżej 45°C i zimnym pokoju ustawia GREE na 23,5°C.",
"en": "Reads sensor.boiler_supply_temperature. Above 45°C with a cold room, GREE is set to 23.5°C."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000256",
"kind": "ha_available",
"x": 25,
"y": 45,
"config": {
"entity_id": "sensor.boiler_supply_temperature"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000257",
"kind": "ha_numeric",
"x": 25,
"y": 160,
"config": {
"entity_id": "sensor.boiler_supply_temperature",
"operator": "gt",
"value": 45
}
},
{
"id": "node-00000000-0000-4000-8000-000000000258",
"kind": "zone_temperature",
"x": 25,
"y": 275,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 20
}
},
{
"id": "node-00000000-0000-4000-8000-000000000259",
"kind": "house_mode",
"x": 25,
"y": 390,
"config": {
"operator": "eq",
"value": "heat"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000260",
"kind": "logic_and",
"x": 320,
"y": 215,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000261",
"kind": "zone_thermostat",
"x": 590,
"y": 215,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 23.5,
"mode": "heat",
"cooldown_seconds": 180,
"power": true
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000262",
"from": "node-00000000-0000-4000-8000-000000000256",
"to": "node-00000000-0000-4000-8000-000000000260"
},
{
"id": "edge-00000000-0000-4000-8000-000000000263",
"from": "node-00000000-0000-4000-8000-000000000257",
"to": "node-00000000-0000-4000-8000-000000000260"
},
{
"id": "edge-00000000-0000-4000-8000-000000000264",
"from": "node-00000000-0000-4000-8000-000000000258",
"to": "node-00000000-0000-4000-8000-000000000260"
},
{
"id": "edge-00000000-0000-4000-8000-000000000265",
"from": "node-00000000-0000-4000-8000-000000000259",
"to": "node-00000000-0000-4000-8000-000000000260"
},
{
"id": "edge-00000000-0000-4000-8000-000000000266",
"from": "node-00000000-0000-4000-8000-000000000260",
"to": "node-00000000-0000-4000-8000-000000000261"
}
]
}
}
+105
View File
@@ -0,0 +1,105 @@
{
"id": "ha_external_heat_source_assist",
"category": "home_assistant",
"name": {
"pl": "Inne źródło ciepła ON → wspomagaj GREE",
"en": "Other heat source ON → assist with GREE"
},
"description": {
"pl": "Gdy zewnętrzne źródło działa, ale pokój nadal ma mniej niż 20°C, GREE wspomaga je w trybie heat z celem 23°C.",
"en": "When an external heat source is running but the room is still below 20°C, GREE assists in heat mode with a 23°C target."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000236",
"kind": "ha_available",
"x": 25,
"y": 40,
"config": {
"entity_id": "binary_sensor.external_heat_source"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000237",
"kind": "ha_state",
"x": 25,
"y": 155,
"config": {
"entity_id": "binary_sensor.external_heat_source",
"operator": "eq",
"value": "on"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000238",
"kind": "zone_temperature",
"x": 25,
"y": 270,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 20
}
},
{
"id": "node-00000000-0000-4000-8000-000000000239",
"kind": "house_mode",
"x": 25,
"y": 385,
"config": {
"operator": "eq",
"value": "heat"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000240",
"kind": "logic_and",
"x": 320,
"y": 210,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000241",
"kind": "zone_thermostat",
"x": 590,
"y": 210,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 23,
"mode": "heat",
"cooldown_seconds": 180,
"power": true
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000242",
"from": "node-00000000-0000-4000-8000-000000000236",
"to": "node-00000000-0000-4000-8000-000000000240"
},
{
"id": "edge-00000000-0000-4000-8000-000000000243",
"from": "node-00000000-0000-4000-8000-000000000237",
"to": "node-00000000-0000-4000-8000-000000000240"
},
{
"id": "edge-00000000-0000-4000-8000-000000000244",
"from": "node-00000000-0000-4000-8000-000000000238",
"to": "node-00000000-0000-4000-8000-000000000240"
},
{
"id": "edge-00000000-0000-4000-8000-000000000245",
"from": "node-00000000-0000-4000-8000-000000000239",
"to": "node-00000000-0000-4000-8000-000000000240"
},
{
"id": "edge-00000000-0000-4000-8000-000000000246",
"from": "node-00000000-0000-4000-8000-000000000240",
"to": "node-00000000-0000-4000-8000-000000000241"
}
]
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"id": "ha_external_heat_source_off",
"category": "home_assistant",
"name": {
"pl": "Inne źródło ciepła ON → wyłącz GREE",
"en": "Other heat source ON → turn GREE off"
},
"description": {
"pl": "Ogólny preset dla kominka, pieca lub innego źródła. Stan binary_sensor.external_heat_source = on wyłącza grupę albo strefę GREE.",
"en": "Generic fireplace, furnace or other source preset. binary_sensor.external_heat_source = on turns the GREE group or zone off."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000229",
"kind": "ha_available",
"x": 35,
"y": 70,
"config": {
"entity_id": "binary_sensor.external_heat_source"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000230",
"kind": "ha_state",
"x": 35,
"y": 200,
"config": {
"entity_id": "binary_sensor.external_heat_source",
"operator": "eq",
"value": "on"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000231",
"kind": "logic_and",
"x": 315,
"y": 135,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000232",
"kind": "group_action",
"x": 575,
"y": 135,
"config": {
"group_id": "$group1",
"power": false,
"mode": "auto",
"preset": "auto",
"setpoint": 21,
"cooldown_seconds": 120
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000233",
"from": "node-00000000-0000-4000-8000-000000000229",
"to": "node-00000000-0000-4000-8000-000000000231"
},
{
"id": "edge-00000000-0000-4000-8000-000000000234",
"from": "node-00000000-0000-4000-8000-000000000230",
"to": "node-00000000-0000-4000-8000-000000000231"
},
{
"id": "edge-00000000-0000-4000-8000-000000000235",
"from": "node-00000000-0000-4000-8000-000000000231",
"to": "node-00000000-0000-4000-8000-000000000232"
}
]
}
}
+106
View File
@@ -0,0 +1,106 @@
{
"id": "ha_gas_backup_heat",
"category": "home_assistant",
"name": {
"pl": "Gaz nie grzeje + zimno → GREE jako backup",
"en": "Gas not heating + room cold → GREE backup"
},
"description": {
"pl": "Gdy encja kotła jest dostępna, ale nie zgłasza heating, a w pokoju jest poniżej 19°C, GREE przejmuje awaryjne grzanie do 22°C.",
"en": "When the boiler entity is available but does not report heating and the room drops below 19°C, GREE provides backup heat to 22°C."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000218",
"kind": "ha_available",
"x": 25,
"y": 40,
"config": {
"entity_id": "climate.gas_boiler"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000219",
"kind": "ha_attribute",
"x": 25,
"y": 155,
"config": {
"entity_id": "climate.gas_boiler",
"attribute": "hvac_action",
"operator": "neq",
"value": "heating"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000220",
"kind": "zone_temperature",
"x": 25,
"y": 270,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 19
}
},
{
"id": "node-00000000-0000-4000-8000-000000000221",
"kind": "house_mode",
"x": 25,
"y": 385,
"config": {
"operator": "eq",
"value": "heat"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000222",
"kind": "logic_and",
"x": 320,
"y": 210,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000223",
"kind": "zone_thermostat",
"x": 590,
"y": 210,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 22,
"mode": "heat",
"cooldown_seconds": 180,
"power": true
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000224",
"from": "node-00000000-0000-4000-8000-000000000218",
"to": "node-00000000-0000-4000-8000-000000000222"
},
{
"id": "edge-00000000-0000-4000-8000-000000000225",
"from": "node-00000000-0000-4000-8000-000000000219",
"to": "node-00000000-0000-4000-8000-000000000222"
},
{
"id": "edge-00000000-0000-4000-8000-000000000226",
"from": "node-00000000-0000-4000-8000-000000000220",
"to": "node-00000000-0000-4000-8000-000000000222"
},
{
"id": "edge-00000000-0000-4000-8000-000000000227",
"from": "node-00000000-0000-4000-8000-000000000221",
"to": "node-00000000-0000-4000-8000-000000000222"
},
{
"id": "edge-00000000-0000-4000-8000-000000000228",
"from": "node-00000000-0000-4000-8000-000000000222",
"to": "node-00000000-0000-4000-8000-000000000223"
}
]
}
}
+106
View File
@@ -0,0 +1,106 @@
{
"id": "ha_gas_heating_boost",
"category": "home_assistant",
"name": {
"pl": "Kocioł gazowy grzeje → dogrzewanie GREE",
"en": "Gas boiler heating → GREE heat boost"
},
"description": {
"pl": "Gdy kocioł gazowy aktywnie grzeje, pokój ma mniej niż 20°C i dom jest w trybie heat, GREE wspomaga ogrzewanie celem 23,5°C.",
"en": "When the gas boiler is actively heating, the room is below 20°C and house mode is heat, GREE assists with a 23.5°C target."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000198",
"kind": "ha_available",
"x": 25,
"y": 35,
"config": {
"entity_id": "climate.gas_boiler"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000199",
"kind": "ha_attribute",
"x": 25,
"y": 145,
"config": {
"entity_id": "climate.gas_boiler",
"attribute": "hvac_action",
"operator": "eq",
"value": "heating"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000200",
"kind": "zone_temperature",
"x": 25,
"y": 255,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 20
}
},
{
"id": "node-00000000-0000-4000-8000-000000000201",
"kind": "house_mode",
"x": 25,
"y": 365,
"config": {
"operator": "eq",
"value": "heat"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000202",
"kind": "logic_and",
"x": 320,
"y": 200,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000203",
"kind": "zone_thermostat",
"x": 590,
"y": 200,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 23.5,
"mode": "heat",
"cooldown_seconds": 180,
"power": true
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000204",
"from": "node-00000000-0000-4000-8000-000000000198",
"to": "node-00000000-0000-4000-8000-000000000202"
},
{
"id": "edge-00000000-0000-4000-8000-000000000205",
"from": "node-00000000-0000-4000-8000-000000000199",
"to": "node-00000000-0000-4000-8000-000000000202"
},
{
"id": "edge-00000000-0000-4000-8000-000000000206",
"from": "node-00000000-0000-4000-8000-000000000200",
"to": "node-00000000-0000-4000-8000-000000000202"
},
{
"id": "edge-00000000-0000-4000-8000-000000000207",
"from": "node-00000000-0000-4000-8000-000000000201",
"to": "node-00000000-0000-4000-8000-000000000202"
},
{
"id": "edge-00000000-0000-4000-8000-000000000208",
"from": "node-00000000-0000-4000-8000-000000000202",
"to": "node-00000000-0000-4000-8000-000000000203"
}
]
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"id": "ha_gas_heating_off",
"category": "home_assistant",
"name": {
"pl": "Kocioł gazowy grzeje → wyłącz GREE",
"en": "Gas boiler heating → turn GREE off"
},
"description": {
"pl": "Czyta hvac_action z climate.gas_boiler. Gdy HA zgłasza heating i encja jest dostępna, wyłącza wybraną strefę GREE.",
"en": "Reads hvac_action from climate.gas_boiler. When HA reports heating and the entity is available, the selected GREE zone is turned off."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000191",
"kind": "ha_available",
"x": 35,
"y": 70,
"config": {
"entity_id": "climate.gas_boiler"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000192",
"kind": "ha_attribute",
"x": 35,
"y": 200,
"config": {
"entity_id": "climate.gas_boiler",
"attribute": "hvac_action",
"operator": "eq",
"value": "heating"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000193",
"kind": "logic_and",
"x": 315,
"y": 135,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000194",
"kind": "zone_thermostat",
"x": 575,
"y": 135,
"config": {
"zone_id": "$zone1",
"preset": "auto",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 120,
"power": false
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000195",
"from": "node-00000000-0000-4000-8000-000000000191",
"to": "node-00000000-0000-4000-8000-000000000193"
},
{
"id": "edge-00000000-0000-4000-8000-000000000196",
"from": "node-00000000-0000-4000-8000-000000000192",
"to": "node-00000000-0000-4000-8000-000000000193"
},
{
"id": "edge-00000000-0000-4000-8000-000000000197",
"from": "node-00000000-0000-4000-8000-000000000193",
"to": "node-00000000-0000-4000-8000-000000000194"
}
]
}
}
+91
View File
@@ -0,0 +1,91 @@
{
"id": "ha_gas_heating_reduce",
"category": "home_assistant",
"name": {
"pl": "Kocioł grzeje + pokój ciepły → obniż GREE",
"en": "Boiler heating + room warm → reduce GREE"
},
"description": {
"pl": "Jeśli gaz już grzeje, a strefa osiągnęła co najmniej 21°C, obniża cel GREE do 18°C, aby źródła ciepła nie walczyły ze sobą.",
"en": "When gas heat is already active and the zone reaches at least 21°C, lowers the GREE target to 18°C so the heat sources do not fight each other."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000209",
"kind": "ha_available",
"x": 35,
"y": 50,
"config": {
"entity_id": "climate.gas_boiler"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000210",
"kind": "ha_attribute",
"x": 35,
"y": 165,
"config": {
"entity_id": "climate.gas_boiler",
"attribute": "hvac_action",
"operator": "eq",
"value": "heating"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000211",
"kind": "zone_temperature",
"x": 35,
"y": 280,
"config": {
"zone_id": "$zone1",
"operator": "gte",
"value": 21
}
},
{
"id": "node-00000000-0000-4000-8000-000000000212",
"kind": "logic_and",
"x": 315,
"y": 165,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000213",
"kind": "zone_thermostat",
"x": 575,
"y": 165,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 18,
"mode": "heat",
"cooldown_seconds": 180,
"power": true
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000214",
"from": "node-00000000-0000-4000-8000-000000000209",
"to": "node-00000000-0000-4000-8000-000000000212"
},
{
"id": "edge-00000000-0000-4000-8000-000000000215",
"from": "node-00000000-0000-4000-8000-000000000210",
"to": "node-00000000-0000-4000-8000-000000000212"
},
{
"id": "edge-00000000-0000-4000-8000-000000000216",
"from": "node-00000000-0000-4000-8000-000000000211",
"to": "node-00000000-0000-4000-8000-000000000212"
},
{
"id": "edge-00000000-0000-4000-8000-000000000217",
"from": "node-00000000-0000-4000-8000-000000000212",
"to": "node-00000000-0000-4000-8000-000000000213"
}
]
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"id": "ha_heating_demand_follow",
"category": "home_assistant",
"name": {
"pl": "Podążaj za żądaniem grzania z HA",
"en": "Follow Home Assistant heating demand"
},
"description": {
"pl": "Stan binary_sensor.heating_demand = on uruchamia ogrzewanie GREE do 22,5°C, jeśli dom jest w trybie heat.",
"en": "binary_sensor.heating_demand = on starts GREE heating to 22.5°C while the house is in heat mode."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000247",
"kind": "ha_available",
"x": 35,
"y": 60,
"config": {
"entity_id": "binary_sensor.heating_demand"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000248",
"kind": "ha_state",
"x": 35,
"y": 180,
"config": {
"entity_id": "binary_sensor.heating_demand",
"operator": "eq",
"value": "on"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000249",
"kind": "house_mode",
"x": 35,
"y": 300,
"config": {
"operator": "eq",
"value": "heat"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000250",
"kind": "logic_and",
"x": 315,
"y": 180,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000251",
"kind": "zone_thermostat",
"x": 575,
"y": 180,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 22.5,
"mode": "heat",
"cooldown_seconds": 120,
"power": true
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000252",
"from": "node-00000000-0000-4000-8000-000000000247",
"to": "node-00000000-0000-4000-8000-000000000250"
},
{
"id": "edge-00000000-0000-4000-8000-000000000253",
"from": "node-00000000-0000-4000-8000-000000000248",
"to": "node-00000000-0000-4000-8000-000000000250"
},
{
"id": "edge-00000000-0000-4000-8000-000000000254",
"from": "node-00000000-0000-4000-8000-000000000249",
"to": "node-00000000-0000-4000-8000-000000000250"
},
{
"id": "edge-00000000-0000-4000-8000-000000000255",
"from": "node-00000000-0000-4000-8000-000000000250",
"to": "node-00000000-0000-4000-8000-000000000251"
}
]
}
}
+106
View File
@@ -0,0 +1,106 @@
{
"id": "ha_thermostat_idle_fallback",
"category": "home_assistant",
"name": {
"pl": "Termostat gazowy idle → awaryjne grzanie GREE",
"en": "Gas thermostat idle → GREE fallback heat"
},
"description": {
"pl": "Jeśli climate.gas_boiler jest dostępny, ma hvac_action=idle i pokój spadnie poniżej 19°C, GREE uruchamia grzanie do 21,5°C.",
"en": "If climate.gas_boiler is available, reports hvac_action=idle and the room falls below 19°C, GREE heats to 21.5°C."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000267",
"kind": "ha_available",
"x": 25,
"y": 40,
"config": {
"entity_id": "climate.gas_boiler"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000268",
"kind": "ha_attribute",
"x": 25,
"y": 155,
"config": {
"entity_id": "climate.gas_boiler",
"attribute": "hvac_action",
"operator": "eq",
"value": "idle"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000269",
"kind": "zone_temperature",
"x": 25,
"y": 270,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 19
}
},
{
"id": "node-00000000-0000-4000-8000-000000000270",
"kind": "house_mode",
"x": 25,
"y": 385,
"config": {
"operator": "eq",
"value": "heat"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000271",
"kind": "logic_and",
"x": 320,
"y": 210,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000272",
"kind": "zone_thermostat",
"x": 590,
"y": 210,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 21.5,
"mode": "heat",
"cooldown_seconds": 240,
"power": true
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000273",
"from": "node-00000000-0000-4000-8000-000000000267",
"to": "node-00000000-0000-4000-8000-000000000271"
},
{
"id": "edge-00000000-0000-4000-8000-000000000274",
"from": "node-00000000-0000-4000-8000-000000000268",
"to": "node-00000000-0000-4000-8000-000000000271"
},
{
"id": "edge-00000000-0000-4000-8000-000000000275",
"from": "node-00000000-0000-4000-8000-000000000269",
"to": "node-00000000-0000-4000-8000-000000000271"
},
{
"id": "edge-00000000-0000-4000-8000-000000000276",
"from": "node-00000000-0000-4000-8000-000000000270",
"to": "node-00000000-0000-4000-8000-000000000271"
},
{
"id": "edge-00000000-0000-4000-8000-000000000277",
"from": "node-00000000-0000-4000-8000-000000000271",
"to": "node-00000000-0000-4000-8000-000000000272"
}
]
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"id": "ha_window_guard",
"category": "safety",
"name": {
"pl": "Otwarte okno — wyłącz HVAC",
"en": "Open window — stop HVAC"
},
"description": {
"pl": "Przykład integracji z binary_sensor Home Assistant: otwarte okno wyłącza termostat. Ustaw właściwy entity_id.",
"en": "Home Assistant binary_sensor example: an open window turns the thermostat off. Set the correct entity_id."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000093",
"kind": "ha_state",
"x": 45,
"y": 90,
"config": {
"entity_id": "binary_sensor.window",
"operator": "eq",
"value": "on"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000094",
"kind": "house_mode",
"x": 45,
"y": 220,
"config": {
"operator": "neq",
"value": "off"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000095",
"kind": "logic_and",
"x": 305,
"y": 155,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000096",
"kind": "zone_thermostat",
"x": 560,
"y": 155,
"config": {
"zone_id": "$zone1",
"preset": "auto",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 60,
"power": false
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000097",
"from": "node-00000000-0000-4000-8000-000000000093",
"to": "node-00000000-0000-4000-8000-000000000095"
},
{
"id": "edge-00000000-0000-4000-8000-000000000098",
"from": "node-00000000-0000-4000-8000-000000000094",
"to": "node-00000000-0000-4000-8000-000000000095"
},
{
"id": "edge-00000000-0000-4000-8000-000000000099",
"from": "node-00000000-0000-4000-8000-000000000095",
"to": "node-00000000-0000-4000-8000-000000000096"
}
]
}
}
+90
View File
@@ -0,0 +1,90 @@
{
"id": "humidity_guard",
"category": "safety",
"name": {
"pl": "Wysoka wilgotność — chłodzenie",
"en": "High humidity cooling"
},
"description": {
"pl": "Przy wysokiej wilgotności, obecności domowników i trybie chłodzenia wymusza umiarkowane chłodzenie.",
"en": "With high humidity, occupancy and cooling house mode, applies moderate cooling."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000100",
"kind": "ha_numeric",
"x": 40,
"y": 60,
"config": {
"entity_id": "sensor.living_room_humidity",
"operator": "gt",
"value": 70
}
},
{
"id": "node-00000000-0000-4000-8000-000000000101",
"kind": "house_mode",
"x": 40,
"y": 180,
"config": {
"operator": "eq",
"value": "cool"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000102",
"kind": "ha_state",
"x": 40,
"y": 300,
"config": {
"entity_id": "person.someone",
"operator": "eq",
"value": "home"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000103",
"kind": "logic_and",
"x": 315,
"y": 180,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000104",
"kind": "zone_thermostat",
"x": 575,
"y": 180,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 22,
"mode": "cool",
"cooldown_seconds": 180
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000105",
"from": "node-00000000-0000-4000-8000-000000000100",
"to": "node-00000000-0000-4000-8000-000000000103"
},
{
"id": "edge-00000000-0000-4000-8000-000000000106",
"from": "node-00000000-0000-4000-8000-000000000101",
"to": "node-00000000-0000-4000-8000-000000000103"
},
{
"id": "edge-00000000-0000-4000-8000-000000000107",
"from": "node-00000000-0000-4000-8000-000000000102",
"to": "node-00000000-0000-4000-8000-000000000103"
},
{
"id": "edge-00000000-0000-4000-8000-000000000108",
"from": "node-00000000-0000-4000-8000-000000000103",
"to": "node-00000000-0000-4000-8000-000000000104"
}
]
}
}
+90
View File
@@ -0,0 +1,90 @@
{
"id": "mild_weather_eco",
"category": "energy",
"name": {
"pl": "Łagodna pogoda — HVAC off",
"en": "Mild weather — HVAC off"
},
"description": {
"pl": "Gdy temperatura zewnętrzna mieści się w komfortowym zakresie i okno jest zamknięte, wyłącza HVAC.",
"en": "When outdoor temperature is within a comfortable band and the window is closed, turns HVAC off."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000068",
"kind": "outdoor_temperature",
"x": 35,
"y": 55,
"config": {
"operator": "gte",
"value": 17
}
},
{
"id": "node-00000000-0000-4000-8000-000000000069",
"kind": "outdoor_temperature",
"x": 35,
"y": 175,
"config": {
"operator": "lte",
"value": 24
}
},
{
"id": "node-00000000-0000-4000-8000-000000000070",
"kind": "ha_state",
"x": 35,
"y": 295,
"config": {
"entity_id": "binary_sensor.window",
"operator": "eq",
"value": "off"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000071",
"kind": "logic_and",
"x": 310,
"y": 175,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000072",
"kind": "zone_thermostat",
"x": 565,
"y": 175,
"config": {
"zone_id": "$zone1",
"preset": "auto",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 300,
"power": false
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000073",
"from": "node-00000000-0000-4000-8000-000000000068",
"to": "node-00000000-0000-4000-8000-000000000071"
},
{
"id": "edge-00000000-0000-4000-8000-000000000074",
"from": "node-00000000-0000-4000-8000-000000000069",
"to": "node-00000000-0000-4000-8000-000000000071"
},
{
"id": "edge-00000000-0000-4000-8000-000000000075",
"from": "node-00000000-0000-4000-8000-000000000070",
"to": "node-00000000-0000-4000-8000-000000000071"
},
{
"id": "edge-00000000-0000-4000-8000-000000000076",
"from": "node-00000000-0000-4000-8000-000000000071",
"to": "node-00000000-0000-4000-8000-000000000072"
}
]
}
}
+108
View File
@@ -0,0 +1,108 @@
{
"id": "morning_boost",
"category": "comfort",
"name": {
"pl": "Poranne dogrzanie przy mrozie",
"en": "Cold-weather morning boost"
},
"description": {
"pl": "W dni robocze rano, tylko w trybie grzania i przy mrozie na zewnątrz, podnosi cel do 22,5°C.",
"en": "On workday mornings, only in heating mode and during cold weather, raises the target to 22.5°C."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000028",
"kind": "weekday",
"x": 35,
"y": 50,
"config": {
"days": [
1,
2,
3,
4,
5
]
}
},
{
"id": "node-00000000-0000-4000-8000-000000000029",
"kind": "time_range",
"x": 35,
"y": 170,
"config": {
"start": "05:30",
"end": "07:30"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000030",
"kind": "outdoor_temperature",
"x": 35,
"y": 290,
"config": {
"operator": "lt",
"value": 5
}
},
{
"id": "node-00000000-0000-4000-8000-000000000031",
"kind": "house_mode",
"x": 35,
"y": 410,
"config": {
"operator": "eq",
"value": "heat"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000032",
"kind": "logic_and",
"x": 320,
"y": 220,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000033",
"kind": "zone_thermostat",
"x": 585,
"y": 220,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 22.5,
"mode": "heat",
"cooldown_seconds": 180
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000034",
"from": "node-00000000-0000-4000-8000-000000000028",
"to": "node-00000000-0000-4000-8000-000000000032"
},
{
"id": "edge-00000000-0000-4000-8000-000000000035",
"from": "node-00000000-0000-4000-8000-000000000029",
"to": "node-00000000-0000-4000-8000-000000000032"
},
{
"id": "edge-00000000-0000-4000-8000-000000000036",
"from": "node-00000000-0000-4000-8000-000000000030",
"to": "node-00000000-0000-4000-8000-000000000032"
},
{
"id": "edge-00000000-0000-4000-8000-000000000037",
"from": "node-00000000-0000-4000-8000-000000000031",
"to": "node-00000000-0000-4000-8000-000000000032"
},
{
"id": "edge-00000000-0000-4000-8000-000000000038",
"from": "node-00000000-0000-4000-8000-000000000032",
"to": "node-00000000-0000-4000-8000-000000000033"
}
]
}
}
+103
View File
@@ -0,0 +1,103 @@
{
"id": "multi_room_group_guard",
"category": "advanced",
"name": {
"pl": "Wiele pokoi → jedna grupa",
"en": "Multiple rooms → one group"
},
"description": {
"pl": "Jeśli dowolna z dwóch stref jest za ciepła i dom jest w trybie cool, uruchamia wspólną grupę lub strefę.",
"en": "If either of two zones is too warm and house mode is cooling, starts a shared group or zone."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000326",
"kind": "zone_temperature",
"x": 30,
"y": 55,
"config": {
"zone_id": "$zone1",
"operator": "gt",
"value": 25
}
},
{
"id": "node-00000000-0000-4000-8000-000000000327",
"kind": "zone_temperature",
"x": 30,
"y": 175,
"config": {
"zone_id": "$zone2",
"operator": "gt",
"value": 25
}
},
{
"id": "node-00000000-0000-4000-8000-000000000328",
"kind": "logic_or",
"x": 280,
"y": 115,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000329",
"kind": "house_mode",
"x": 280,
"y": 245,
"config": {
"operator": "eq",
"value": "cool"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000330",
"kind": "logic_and",
"x": 505,
"y": 175,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000331",
"kind": "group_action",
"x": 760,
"y": 175,
"config": {
"group_id": "$group1",
"power": true,
"mode": "auto",
"preset": "comfort",
"setpoint": 22,
"cooldown_seconds": 120
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000332",
"from": "node-00000000-0000-4000-8000-000000000326",
"to": "node-00000000-0000-4000-8000-000000000328"
},
{
"id": "edge-00000000-0000-4000-8000-000000000333",
"from": "node-00000000-0000-4000-8000-000000000327",
"to": "node-00000000-0000-4000-8000-000000000328"
},
{
"id": "edge-00000000-0000-4000-8000-000000000334",
"from": "node-00000000-0000-4000-8000-000000000328",
"to": "node-00000000-0000-4000-8000-000000000330"
},
{
"id": "edge-00000000-0000-4000-8000-000000000335",
"from": "node-00000000-0000-4000-8000-000000000329",
"to": "node-00000000-0000-4000-8000-000000000330"
},
{
"id": "edge-00000000-0000-4000-8000-000000000336",
"from": "node-00000000-0000-4000-8000-000000000330",
"to": "node-00000000-0000-4000-8000-000000000331"
}
]
}
}
+163
View File
@@ -0,0 +1,163 @@
{
"id": "nested_guard",
"category": "advanced",
"name": {
"pl": "Złożony warunek z OR i NOT",
"en": "Nested OR and NOT guard"
},
"description": {
"pl": "Dni i godziny + temperatura zewnętrzna LUB pokojowa + NOT dla otwartego okna. Przykład wielopoziomowego grafu.",
"en": "Weekdays and time + outdoor OR room temperature + NOT open window. An example of a multi-level graph."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000278",
"kind": "weekday",
"x": 25,
"y": 35,
"config": {
"days": [
1,
2,
3,
4,
5
]
}
},
{
"id": "node-00000000-0000-4000-8000-000000000279",
"kind": "time_range",
"x": 25,
"y": 145,
"config": {
"start": "06:00",
"end": "22:30"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000280",
"kind": "outdoor_temperature",
"x": 25,
"y": 255,
"config": {
"operator": "lt",
"value": 8
}
},
{
"id": "node-00000000-0000-4000-8000-000000000281",
"kind": "zone_temperature",
"x": 25,
"y": 365,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 20
}
},
{
"id": "node-00000000-0000-4000-8000-000000000282",
"kind": "logic_or",
"x": 275,
"y": 310,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000283",
"kind": "ha_available",
"x": 275,
"y": 430,
"config": {
"entity_id": "binary_sensor.window"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000284",
"kind": "ha_state",
"x": 275,
"y": 540,
"config": {
"entity_id": "binary_sensor.window",
"operator": "eq",
"value": "on"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000285",
"kind": "logic_not",
"x": 495,
"y": 540,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000286",
"kind": "logic_and",
"x": 510,
"y": 235,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000287",
"kind": "zone_thermostat",
"x": 770,
"y": 235,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 90
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000288",
"from": "node-00000000-0000-4000-8000-000000000280",
"to": "node-00000000-0000-4000-8000-000000000282"
},
{
"id": "edge-00000000-0000-4000-8000-000000000289",
"from": "node-00000000-0000-4000-8000-000000000281",
"to": "node-00000000-0000-4000-8000-000000000282"
},
{
"id": "edge-00000000-0000-4000-8000-000000000290",
"from": "node-00000000-0000-4000-8000-000000000284",
"to": "node-00000000-0000-4000-8000-000000000285"
},
{
"id": "edge-00000000-0000-4000-8000-000000000291",
"from": "node-00000000-0000-4000-8000-000000000278",
"to": "node-00000000-0000-4000-8000-000000000286"
},
{
"id": "edge-00000000-0000-4000-8000-000000000292",
"from": "node-00000000-0000-4000-8000-000000000279",
"to": "node-00000000-0000-4000-8000-000000000286"
},
{
"id": "edge-00000000-0000-4000-8000-000000000293",
"from": "node-00000000-0000-4000-8000-000000000282",
"to": "node-00000000-0000-4000-8000-000000000286"
},
{
"id": "edge-00000000-0000-4000-8000-000000000294",
"from": "node-00000000-0000-4000-8000-000000000283",
"to": "node-00000000-0000-4000-8000-000000000286"
},
{
"id": "edge-00000000-0000-4000-8000-000000000295",
"from": "node-00000000-0000-4000-8000-000000000285",
"to": "node-00000000-0000-4000-8000-000000000286"
},
{
"id": "edge-00000000-0000-4000-8000-000000000296",
"from": "node-00000000-0000-4000-8000-000000000286",
"to": "node-00000000-0000-4000-8000-000000000287"
}
]
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"id": "night_group",
"category": "night",
"name": {
"pl": "Noc dla grupy",
"en": "Night group"
},
"description": {
"pl": "Nocny przedział czasu + tryb domu sterują presetem sleep dla grupy lub strefy.",
"en": "Night time + house mode applies sleep preset to a group or zone."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000125",
"kind": "time_range",
"x": 50,
"y": 100,
"config": {
"start": "22:30",
"end": "06:00"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000126",
"kind": "house_mode",
"x": 50,
"y": 230,
"config": {
"operator": "neq",
"value": "off"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000127",
"kind": "logic_and",
"x": 315,
"y": 165,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000128",
"kind": "group_action",
"x": 570,
"y": 165,
"config": {
"group_id": "$group1",
"power": true,
"mode": "auto",
"preset": "sleep",
"setpoint": 20,
"cooldown_seconds": 120
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000129",
"from": "node-00000000-0000-4000-8000-000000000125",
"to": "node-00000000-0000-4000-8000-000000000127"
},
{
"id": "edge-00000000-0000-4000-8000-000000000130",
"from": "node-00000000-0000-4000-8000-000000000126",
"to": "node-00000000-0000-4000-8000-000000000127"
},
{
"id": "edge-00000000-0000-4000-8000-000000000131",
"from": "node-00000000-0000-4000-8000-000000000127",
"to": "node-00000000-0000-4000-8000-000000000128"
}
]
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"id": "night_quiet",
"category": "night",
"name": {
"pl": "Nocny profil grupy",
"en": "Night group profile"
},
"description": {
"pl": "Korzysta bezpośrednio z istniejącego Trybu nocnego aplikacji i stanu grupy, aby zastosować preset sleep.",
"en": "Uses the app Night mode and group state directly to apply the sleep preset."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000132",
"kind": "night_mode",
"x": 45,
"y": 90,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000133",
"kind": "logic_and",
"x": 310,
"y": 155,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000134",
"kind": "group_action",
"x": 565,
"y": 155,
"config": {
"group_id": "$group1",
"power": null,
"mode": "auto",
"preset": "sleep",
"setpoint": 20,
"cooldown_seconds": 180
}
},
{
"id": "node-00000000-0000-4000-8000-000000000136",
"kind": "group_state",
"x": 45,
"y": 225,
"config": {
"group_id": "$group1",
"field": "power_enabled",
"operator": "eq",
"value": "true"
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000135",
"from": "node-00000000-0000-4000-8000-000000000132",
"to": "node-00000000-0000-4000-8000-000000000133"
},
{
"id": "edge-00000000-0000-4000-8000-000000000137",
"from": "node-00000000-0000-4000-8000-000000000136",
"to": "node-00000000-0000-4000-8000-000000000133"
},
{
"id": "edge-00000000-0000-4000-8000-000000000138",
"from": "node-00000000-0000-4000-8000-000000000133",
"to": "node-00000000-0000-4000-8000-000000000134"
}
]
}
}
+130
View File
@@ -0,0 +1,130 @@
{
"id": "occupancy_weather_matrix",
"category": "advanced",
"name": {
"pl": "Obecność + pogoda + okno",
"en": "Occupancy + weather + window"
},
"description": {
"pl": "Komfort działa tylko gdy ktoś jest w domu, występuje zapotrzebowanie pogodowe lub pokojowe i okno nie jest otwarte.",
"en": "Comfort runs only when someone is home, outdoor or room demand exists, and the window is not open."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000297",
"kind": "ha_state",
"x": 25,
"y": 40,
"config": {
"entity_id": "person.someone",
"operator": "eq",
"value": "home"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000298",
"kind": "outdoor_temperature",
"x": 25,
"y": 155,
"config": {
"operator": "lt",
"value": 9
}
},
{
"id": "node-00000000-0000-4000-8000-000000000299",
"kind": "zone_temperature",
"x": 25,
"y": 270,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 20
}
},
{
"id": "node-00000000-0000-4000-8000-000000000300",
"kind": "logic_or",
"x": 275,
"y": 215,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000301",
"kind": "ha_state",
"x": 275,
"y": 345,
"config": {
"entity_id": "binary_sensor.window",
"operator": "eq",
"value": "on"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000302",
"kind": "logic_not",
"x": 495,
"y": 345,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000303",
"kind": "logic_and",
"x": 505,
"y": 150,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000304",
"kind": "zone_thermostat",
"x": 765,
"y": 150,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 90
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000305",
"from": "node-00000000-0000-4000-8000-000000000298",
"to": "node-00000000-0000-4000-8000-000000000300"
},
{
"id": "edge-00000000-0000-4000-8000-000000000306",
"from": "node-00000000-0000-4000-8000-000000000299",
"to": "node-00000000-0000-4000-8000-000000000300"
},
{
"id": "edge-00000000-0000-4000-8000-000000000307",
"from": "node-00000000-0000-4000-8000-000000000301",
"to": "node-00000000-0000-4000-8000-000000000302"
},
{
"id": "edge-00000000-0000-4000-8000-000000000308",
"from": "node-00000000-0000-4000-8000-000000000297",
"to": "node-00000000-0000-4000-8000-000000000303"
},
{
"id": "edge-00000000-0000-4000-8000-000000000309",
"from": "node-00000000-0000-4000-8000-000000000300",
"to": "node-00000000-0000-4000-8000-000000000303"
},
{
"id": "edge-00000000-0000-4000-8000-000000000310",
"from": "node-00000000-0000-4000-8000-000000000302",
"to": "node-00000000-0000-4000-8000-000000000303"
},
{
"id": "edge-00000000-0000-4000-8000-000000000311",
"from": "node-00000000-0000-4000-8000-000000000303",
"to": "node-00000000-0000-4000-8000-000000000304"
}
]
}
}
+78
View File
@@ -0,0 +1,78 @@
{
"id": "offline_safe_off",
"category": "reliability",
"name": {
"pl": "Urządzenie offline — bezpieczne wyłączenie",
"en": "Offline device safe-off"
},
"description": {
"pl": "Gdy urządzenie jest offline, a termostat nadal aktywny, wydaje bezpieczne polecenie wyłączenia z długim cooldownem.",
"en": "When a device is offline while its thermostat remains active, issues a conservative off command with a long cooldown."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000175",
"kind": "device_state",
"x": 35,
"y": 85,
"config": {
"device_id": "$device1",
"field": "online",
"operator": "eq",
"value": "false"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000176",
"kind": "zone_state",
"x": 35,
"y": 215,
"config": {
"zone_id": "$zone1",
"field": "enabled",
"operator": "eq",
"value": "true"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000177",
"kind": "logic_and",
"x": 310,
"y": 150,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000178",
"kind": "zone_thermostat",
"x": 565,
"y": 150,
"config": {
"zone_id": "$zone1",
"preset": "auto",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 300,
"power": false
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000179",
"from": "node-00000000-0000-4000-8000-000000000175",
"to": "node-00000000-0000-4000-8000-000000000177"
},
{
"id": "edge-00000000-0000-4000-8000-000000000180",
"from": "node-00000000-0000-4000-8000-000000000176",
"to": "node-00000000-0000-4000-8000-000000000177"
},
{
"id": "edge-00000000-0000-4000-8000-000000000181",
"from": "node-00000000-0000-4000-8000-000000000177",
"to": "node-00000000-0000-4000-8000-000000000178"
}
]
}
}
+90
View File
@@ -0,0 +1,90 @@
{
"id": "overheat_guard",
"category": "safety",
"name": {
"pl": "Ochrona przed przegrzaniem",
"en": "Overheat guard"
},
"description": {
"pl": "Jeśli strefa jest bardzo ciepła, tryb domu to chłodzenie i okno jest zamknięte, uruchamia chłodzenie ochronne.",
"en": "If a zone is very warm, house mode is cooling and the window is closed, starts protective cooling."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000109",
"kind": "zone_temperature",
"x": 35,
"y": 65,
"config": {
"zone_id": "$zone1",
"operator": "gt",
"value": 28
}
},
{
"id": "node-00000000-0000-4000-8000-000000000110",
"kind": "house_mode",
"x": 35,
"y": 185,
"config": {
"operator": "eq",
"value": "cool"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000111",
"kind": "ha_state",
"x": 35,
"y": 305,
"config": {
"entity_id": "binary_sensor.window",
"operator": "eq",
"value": "off"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000112",
"kind": "logic_and",
"x": 310,
"y": 185,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000113",
"kind": "zone_thermostat",
"x": 570,
"y": 185,
"config": {
"zone_id": "$zone1",
"preset": "custom",
"setpoint": 22,
"mode": "cool",
"cooldown_seconds": 120
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000114",
"from": "node-00000000-0000-4000-8000-000000000109",
"to": "node-00000000-0000-4000-8000-000000000112"
},
{
"id": "edge-00000000-0000-4000-8000-000000000115",
"from": "node-00000000-0000-4000-8000-000000000110",
"to": "node-00000000-0000-4000-8000-000000000112"
},
{
"id": "edge-00000000-0000-4000-8000-000000000116",
"from": "node-00000000-0000-4000-8000-000000000111",
"to": "node-00000000-0000-4000-8000-000000000112"
},
{
"id": "edge-00000000-0000-4000-8000-000000000117",
"from": "node-00000000-0000-4000-8000-000000000112",
"to": "node-00000000-0000-4000-8000-000000000113"
}
]
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"id": "peak_power_guard",
"category": "energy",
"name": {
"pl": "Ochrona przed szczytem mocy",
"en": "Peak power guard"
},
"description": {
"pl": "Wyłącza HVAC, gdy pobór mocy domu przekroczy ustawiony próg i system klimatu jest aktywny.",
"en": "Turns HVAC off when household power draw exceeds the configured threshold while climate control is active."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000061",
"kind": "ha_numeric",
"x": 40,
"y": 75,
"config": {
"entity_id": "sensor.house_power",
"operator": "gt",
"value": 5000
}
},
{
"id": "node-00000000-0000-4000-8000-000000000062",
"kind": "house_mode",
"x": 40,
"y": 200,
"config": {
"operator": "neq",
"value": "off"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000063",
"kind": "logic_and",
"x": 315,
"y": 140,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000064",
"kind": "zone_thermostat",
"x": 570,
"y": 140,
"config": {
"zone_id": "$zone1",
"preset": "auto",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 180,
"power": false
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000065",
"from": "node-00000000-0000-4000-8000-000000000061",
"to": "node-00000000-0000-4000-8000-000000000063"
},
{
"id": "edge-00000000-0000-4000-8000-000000000066",
"from": "node-00000000-0000-4000-8000-000000000062",
"to": "node-00000000-0000-4000-8000-000000000063"
},
{
"id": "edge-00000000-0000-4000-8000-000000000067",
"from": "node-00000000-0000-4000-8000-000000000063",
"to": "node-00000000-0000-4000-8000-000000000064"
}
]
}
}
+76
View File
@@ -0,0 +1,76 @@
{
"id": "presence_eco",
"category": "energy",
"name": {
"pl": "Obecność: komfort / eco",
"en": "Presence: comfort / eco"
},
"description": {
"pl": "Dwie jawne gałęzie Home Assistant: stan home włącza komfort, a not_home ustawia preset away. Ustaw właściwy entity_id osoby lub czujnika obecności.",
"en": "Two explicit Home Assistant branches: home enables comfort while not_home selects the away preset. Set the correct person or presence entity_id."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000048",
"kind": "ha_state",
"x": 45,
"y": 75,
"config": {
"entity_id": "person.someone",
"operator": "eq",
"value": "home"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000049",
"kind": "ha_state",
"x": 45,
"y": 245,
"config": {
"entity_id": "person.someone",
"operator": "eq",
"value": "not_home"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000050",
"kind": "zone_thermostat",
"x": 360,
"y": 65,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 90
}
},
{
"id": "node-00000000-0000-4000-8000-000000000051",
"kind": "zone_thermostat",
"x": 360,
"y": 245,
"config": {
"zone_id": "$zone1",
"preset": "away",
"setpoint": 18,
"mode": "auto",
"cooldown_seconds": 180
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000052",
"from": "node-00000000-0000-4000-8000-000000000048",
"to": "node-00000000-0000-4000-8000-000000000050"
},
{
"id": "edge-00000000-0000-4000-8000-000000000053",
"from": "node-00000000-0000-4000-8000-000000000049",
"to": "node-00000000-0000-4000-8000-000000000051"
}
]
}
}
+88
View File
@@ -0,0 +1,88 @@
{
"id": "sensor_availability_guard",
"category": "reliability",
"name": {
"pl": "Chłodzenie z kontrolą sensora",
"en": "Cooling with sensor validation"
},
"description": {
"pl": "Najpierw sprawdza dostępność sensora HA, potem temperaturę i tryb domu; dopiero wtedy uruchamia chłodzenie.",
"en": "Checks HA sensor availability first, then temperature and house mode before starting cooling."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000166",
"kind": "ha_available",
"x": 35,
"y": 55,
"config": {
"entity_id": "sensor.room_temperature"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000167",
"kind": "ha_numeric",
"x": 35,
"y": 175,
"config": {
"entity_id": "sensor.room_temperature",
"operator": "gt",
"value": 26
}
},
{
"id": "node-00000000-0000-4000-8000-000000000168",
"kind": "house_mode",
"x": 35,
"y": 295,
"config": {
"operator": "eq",
"value": "cool"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000169",
"kind": "logic_and",
"x": 310,
"y": 175,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000170",
"kind": "zone_thermostat",
"x": 570,
"y": 175,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "cool",
"cooldown_seconds": 120
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000171",
"from": "node-00000000-0000-4000-8000-000000000166",
"to": "node-00000000-0000-4000-8000-000000000169"
},
{
"id": "edge-00000000-0000-4000-8000-000000000172",
"from": "node-00000000-0000-4000-8000-000000000167",
"to": "node-00000000-0000-4000-8000-000000000169"
},
{
"id": "edge-00000000-0000-4000-8000-000000000173",
"from": "node-00000000-0000-4000-8000-000000000168",
"to": "node-00000000-0000-4000-8000-000000000169"
},
{
"id": "edge-00000000-0000-4000-8000-000000000174",
"from": "node-00000000-0000-4000-8000-000000000169",
"to": "node-00000000-0000-4000-8000-000000000170"
}
]
}
}
+86
View File
@@ -0,0 +1,86 @@
{
"id": "sleep_temperature_guard",
"category": "night",
"name": {
"pl": "Nocne chłodzenie tylko gdy trzeba",
"en": "Night cooling only when needed"
},
"description": {
"pl": "W trybie nocnym chłodzi tylko wtedy, gdy temperatura pokoju przekracza próg i dom jest w trybie cool.",
"en": "In night mode, cools only when room temperature is above the threshold and the house is in cooling mode."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000139",
"kind": "night_mode",
"x": 35,
"y": 55,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000140",
"kind": "zone_temperature",
"x": 35,
"y": 175,
"config": {
"zone_id": "$zone1",
"operator": "gt",
"value": 23.5
}
},
{
"id": "node-00000000-0000-4000-8000-000000000141",
"kind": "house_mode",
"x": 35,
"y": 295,
"config": {
"operator": "eq",
"value": "cool"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000142",
"kind": "logic_and",
"x": 310,
"y": 175,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000143",
"kind": "zone_thermostat",
"x": 570,
"y": 175,
"config": {
"zone_id": "$zone1",
"preset": "sleep",
"setpoint": 20,
"mode": "cool",
"cooldown_seconds": 180
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000144",
"from": "node-00000000-0000-4000-8000-000000000139",
"to": "node-00000000-0000-4000-8000-000000000142"
},
{
"id": "edge-00000000-0000-4000-8000-000000000145",
"from": "node-00000000-0000-4000-8000-000000000140",
"to": "node-00000000-0000-4000-8000-000000000142"
},
{
"id": "edge-00000000-0000-4000-8000-000000000146",
"from": "node-00000000-0000-4000-8000-000000000141",
"to": "node-00000000-0000-4000-8000-000000000142"
},
{
"id": "edge-00000000-0000-4000-8000-000000000147",
"from": "node-00000000-0000-4000-8000-000000000142",
"to": "node-00000000-0000-4000-8000-000000000143"
}
]
}
}
+116
View File
@@ -0,0 +1,116 @@
{
"id": "smart_demand",
"category": "comfort",
"name": {
"pl": "Inteligentne zapotrzebowanie",
"en": "Smart demand"
},
"description": {
"pl": "Złożona logika czasu, trybu domu i alternatywy temperatury zewnętrznej lub pokojowej.",
"en": "Complex time, house-mode and outdoor-or-room temperature logic."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000015",
"kind": "time_range",
"x": 30,
"y": 40,
"config": {
"start": "05:30",
"end": "23:00"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000016",
"kind": "outdoor_temperature",
"x": 30,
"y": 160,
"config": {
"operator": "lt",
"value": 10
}
},
{
"id": "node-00000000-0000-4000-8000-000000000017",
"kind": "zone_temperature",
"x": 30,
"y": 280,
"config": {
"zone_id": "$zone1",
"operator": "lt",
"value": 20
}
},
{
"id": "node-00000000-0000-4000-8000-000000000018",
"kind": "logic_or",
"x": 280,
"y": 220,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000019",
"kind": "house_mode",
"x": 280,
"y": 70,
"config": {
"operator": "neq",
"value": "off"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000020",
"kind": "logic_and",
"x": 510,
"y": 155,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000021",
"kind": "zone_thermostat",
"x": 760,
"y": 155,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 90
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000022",
"from": "node-00000000-0000-4000-8000-000000000016",
"to": "node-00000000-0000-4000-8000-000000000018"
},
{
"id": "edge-00000000-0000-4000-8000-000000000023",
"from": "node-00000000-0000-4000-8000-000000000017",
"to": "node-00000000-0000-4000-8000-000000000018"
},
{
"id": "edge-00000000-0000-4000-8000-000000000024",
"from": "node-00000000-0000-4000-8000-000000000015",
"to": "node-00000000-0000-4000-8000-000000000020"
},
{
"id": "edge-00000000-0000-4000-8000-000000000025",
"from": "node-00000000-0000-4000-8000-000000000019",
"to": "node-00000000-0000-4000-8000-000000000020"
},
{
"id": "edge-00000000-0000-4000-8000-000000000026",
"from": "node-00000000-0000-4000-8000-000000000018",
"to": "node-00000000-0000-4000-8000-000000000020"
},
{
"id": "edge-00000000-0000-4000-8000-000000000027",
"from": "node-00000000-0000-4000-8000-000000000020",
"to": "node-00000000-0000-4000-8000-000000000021"
}
]
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"id": "thermostat_enabled_guard",
"category": "reliability",
"name": {
"pl": "Steruj tylko aktywnym termostatem",
"en": "Control only an active thermostat"
},
"description": {
"pl": "Wymaga włączonej strefy, aktywnego zapotrzebowania i niezerowego trybu domu przed akcją.",
"en": "Requires an enabled zone, active demand and a non-off house mode before the action executes."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000182",
"kind": "zone_state",
"x": 35,
"y": 55,
"config": {
"zone_id": "$zone1",
"field": "enabled",
"operator": "eq",
"value": "true"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000183",
"kind": "zone_state",
"x": 35,
"y": 175,
"config": {
"zone_id": "$zone1",
"field": "demand",
"operator": "eq",
"value": "true"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000184",
"kind": "house_mode",
"x": 35,
"y": 295,
"config": {
"operator": "neq",
"value": "off"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000185",
"kind": "logic_and",
"x": 310,
"y": 175,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000186",
"kind": "zone_thermostat",
"x": 570,
"y": 175,
"config": {
"zone_id": "$zone1",
"preset": "auto",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 120
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000187",
"from": "node-00000000-0000-4000-8000-000000000182",
"to": "node-00000000-0000-4000-8000-000000000185"
},
{
"id": "edge-00000000-0000-4000-8000-000000000188",
"from": "node-00000000-0000-4000-8000-000000000183",
"to": "node-00000000-0000-4000-8000-000000000185"
},
{
"id": "edge-00000000-0000-4000-8000-000000000189",
"from": "node-00000000-0000-4000-8000-000000000184",
"to": "node-00000000-0000-4000-8000-000000000185"
},
{
"id": "edge-00000000-0000-4000-8000-000000000190",
"from": "node-00000000-0000-4000-8000-000000000185",
"to": "node-00000000-0000-4000-8000-000000000186"
}
]
}
}
+75
View File
@@ -0,0 +1,75 @@
{
"id": "unoccupied_shutdown",
"category": "energy",
"name": {
"pl": "Wyłącz podczas nieobecności",
"en": "Shut down while away"
},
"description": {
"pl": "W godzinach dziennych wyłącza grupę lub strefę, gdy wskazana osoba jest poza domem.",
"en": "During daytime hours, turns off a group or zone when the selected person is away."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000077",
"kind": "ha_state",
"x": 35,
"y": 70,
"config": {
"entity_id": "person.someone",
"operator": "eq",
"value": "not_home"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000078",
"kind": "time_range",
"x": 35,
"y": 190,
"config": {
"start": "09:00",
"end": "16:00"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000079",
"kind": "logic_and",
"x": 310,
"y": 130,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000080",
"kind": "group_action",
"x": 565,
"y": 130,
"config": {
"group_id": "$group1",
"power": false,
"mode": "",
"preset": "",
"setpoint": 21,
"cooldown_seconds": 300
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000081",
"from": "node-00000000-0000-4000-8000-000000000077",
"to": "node-00000000-0000-4000-8000-000000000079"
},
{
"id": "edge-00000000-0000-4000-8000-000000000082",
"from": "node-00000000-0000-4000-8000-000000000078",
"to": "node-00000000-0000-4000-8000-000000000079"
},
{
"id": "edge-00000000-0000-4000-8000-000000000083",
"from": "node-00000000-0000-4000-8000-000000000079",
"to": "node-00000000-0000-4000-8000-000000000080"
}
]
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"id": "weather_comfort",
"category": "comfort",
"name": {
"pl": "Komfort zależny od pogody",
"en": "Weather-aware comfort"
},
"description": {
"pl": "Łączy dzień, godzinę i temperaturę zewnętrzną przez AND przed sterowaniem termostatem.",
"en": "Combines weekday, time and outdoor temperature with AND before thermostat control."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000006",
"kind": "weekday",
"x": 35,
"y": 40,
"config": {
"days": [
1,
2,
3,
4,
5
]
}
},
{
"id": "node-00000000-0000-4000-8000-000000000007",
"kind": "time_range",
"x": 35,
"y": 155,
"config": {
"start": "06:00",
"end": "22:30"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000008",
"kind": "outdoor_temperature",
"x": 35,
"y": 270,
"config": {
"operator": "lt",
"value": 12
}
},
{
"id": "node-00000000-0000-4000-8000-000000000009",
"kind": "logic_and",
"x": 300,
"y": 155,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000010",
"kind": "zone_thermostat",
"x": 555,
"y": 155,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 90
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000011",
"from": "node-00000000-0000-4000-8000-000000000006",
"to": "node-00000000-0000-4000-8000-000000000009"
},
{
"id": "edge-00000000-0000-4000-8000-000000000012",
"from": "node-00000000-0000-4000-8000-000000000007",
"to": "node-00000000-0000-4000-8000-000000000009"
},
{
"id": "edge-00000000-0000-4000-8000-000000000013",
"from": "node-00000000-0000-4000-8000-000000000008",
"to": "node-00000000-0000-4000-8000-000000000009"
},
{
"id": "edge-00000000-0000-4000-8000-000000000014",
"from": "node-00000000-0000-4000-8000-000000000009",
"to": "node-00000000-0000-4000-8000-000000000010"
}
]
}
}
+90
View File
@@ -0,0 +1,90 @@
{
"id": "weekend_comfort",
"category": "comfort",
"name": {
"pl": "Weekendowy komfort",
"en": "Weekend comfort"
},
"description": {
"pl": "W sobotę i niedzielę utrzymuje komfort w ciągu dnia, jeśli tryb domu nie jest wyłączony.",
"en": "Keeps daytime comfort on Saturday and Sunday while the house climate mode is not off."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000039",
"kind": "weekday",
"x": 40,
"y": 90,
"config": {
"days": [
6,
7
]
}
},
{
"id": "node-00000000-0000-4000-8000-000000000040",
"kind": "time_range",
"x": 250,
"y": 90,
"config": {
"start": "08:00",
"end": "23:00"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000041",
"kind": "house_mode",
"x": 40,
"y": 220,
"config": {
"operator": "neq",
"value": "off"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000042",
"kind": "logic_and",
"x": 465,
"y": 145,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000043",
"kind": "zone_thermostat",
"x": 710,
"y": 145,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 90
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000044",
"from": "node-00000000-0000-4000-8000-000000000039",
"to": "node-00000000-0000-4000-8000-000000000042"
},
{
"id": "edge-00000000-0000-4000-8000-000000000045",
"from": "node-00000000-0000-4000-8000-000000000040",
"to": "node-00000000-0000-4000-8000-000000000042"
},
{
"id": "edge-00000000-0000-4000-8000-000000000046",
"from": "node-00000000-0000-4000-8000-000000000041",
"to": "node-00000000-0000-4000-8000-000000000042"
},
{
"id": "edge-00000000-0000-4000-8000-000000000047",
"from": "node-00000000-0000-4000-8000-000000000042",
"to": "node-00000000-0000-4000-8000-000000000043"
}
]
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"id": "window_available_guard",
"category": "safety",
"name": {
"pl": "Okno: dostępność + otwarcie",
"en": "Window availability + open state"
},
"description": {
"pl": "Wyłącza HVAC dopiero gdy czujnik okna jest dostępny i zgłasza otwarcie — bez fałszywej reakcji na unavailable.",
"en": "Turns HVAC off only when the window sensor is available and reports open, avoiding false reactions to unavailable."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000118",
"kind": "ha_available",
"x": 35,
"y": 80,
"config": {
"entity_id": "binary_sensor.window"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000119",
"kind": "ha_state",
"x": 35,
"y": 210,
"config": {
"entity_id": "binary_sensor.window",
"operator": "eq",
"value": "on"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000120",
"kind": "logic_and",
"x": 310,
"y": 145,
"config": {}
},
{
"id": "node-00000000-0000-4000-8000-000000000121",
"kind": "zone_thermostat",
"x": 565,
"y": 145,
"config": {
"zone_id": "$zone1",
"preset": "auto",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 60,
"power": false
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000122",
"from": "node-00000000-0000-4000-8000-000000000118",
"to": "node-00000000-0000-4000-8000-000000000120"
},
{
"id": "edge-00000000-0000-4000-8000-000000000123",
"from": "node-00000000-0000-4000-8000-000000000119",
"to": "node-00000000-0000-4000-8000-000000000120"
},
{
"id": "edge-00000000-0000-4000-8000-000000000124",
"from": "node-00000000-0000-4000-8000-000000000120",
"to": "node-00000000-0000-4000-8000-000000000121"
}
]
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"id": "workday_comfort",
"category": "comfort",
"name": {
"pl": "Komfort w dni robocze",
"en": "Workday comfort"
},
"description": {
"pl": "Pon.pt. rano uruchamia preset komfortu w wybranej strefie.",
"en": "Weekday morning comfort preset for the selected thermostat zone."
},
"flow": {
"nodes": [
{
"id": "node-00000000-0000-4000-8000-000000000001",
"kind": "weekday",
"x": 40,
"y": 70,
"config": {
"days": [
1,
2,
3,
4,
5
]
}
},
{
"id": "node-00000000-0000-4000-8000-000000000002",
"kind": "time_range",
"x": 250,
"y": 70,
"config": {
"start": "06:00",
"end": "08:30"
}
},
{
"id": "node-00000000-0000-4000-8000-000000000003",
"kind": "zone_thermostat",
"x": 500,
"y": 70,
"config": {
"zone_id": "$zone1",
"preset": "comfort",
"setpoint": 21,
"mode": "auto",
"cooldown_seconds": 90
}
}
],
"edges": [
{
"id": "edge-00000000-0000-4000-8000-000000000004",
"from": "node-00000000-0000-4000-8000-000000000001",
"to": "node-00000000-0000-4000-8000-000000000002"
},
{
"id": "edge-00000000-0000-4000-8000-000000000005",
"from": "node-00000000-0000-4000-8000-000000000002",
"to": "node-00000000-0000-4000-8000-000000000003"
}
]
}
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
SOURCE_MANIFEST="SOURCE_MANIFEST.sha256"
FILE_MANIFEST="FILE_MANIFEST.sha256"
SOURCE_TMP="${SOURCE_MANIFEST}.tmp"
FILE_TMP="${FILE_MANIFEST}.tmp"
find . -type f \
! -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 > "$SOURCE_TMP"
mv "$SOURCE_TMP" "$SOURCE_MANIFEST"
sha256sum -c "$SOURCE_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
View File
@@ -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.
+708
View File
@@ -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())
+3
View File
@@ -98,3 +98,6 @@ display_host="${bind%:*}"
say "Panel: http://${display_host}:${bind##*:}"
say "Stop: Ctrl+C"
exec "$BINARY"
Wspierane przez Gitea
Wersja: 1.27.2
Strona: 30ms Szablon: 4ms
+1 -1
View File
@@ -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)
+289
View File
@@ -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);
});

Some files were not shown because too many files have changed in this diff Show More