commit 1d3dcba1a9e7a212dbc0635f5ed23e9ec2d74f9b Author: Mateusz Gruszczyński Date: Sun Aug 23 21:34:07 2026 +0200 first commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..066e34e --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Serwer HTTP +GREE_BIND=0.0.0.0:8787 +GREE_DATABASE=./data/gree-controller.db +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= + +# 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 +GREE_CONTROLLER_ID=gree-controller + +# Opcjonalny sensor temperatury z Home Assistant. +HA_URL= +HA_TOKEN= +HA_ENTITY_ID= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c29fb09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Rust build output +/target/ +**/*.rs.bk + +# Local configuration and secrets +.env +.env.* +!.env.example + +# Runtime data +/data/* +!/data/.gitkeep +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# Logs and diagnostics +*.log +*.pid +*.pcap +*.pcapng + +# Editor and IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS metadata +.DS_Store +Thumbs.db + +# Local packages and generated binaries +/*.zip +/*.tar.gz +/gree-controller + +# Test and coverage artifacts +/coverage/ +*.profraw +*.profdata diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md new file mode 100644 index 0000000..bd77bf8 --- /dev/null +++ b/BUILD_REPORT.md @@ -0,0 +1,70 @@ +# GREE Controller v0.3.3 - build and validation report + +## Scope of this release + +Version 0.3.3 prepares the project for repeatable Debian/Ubuntu LXC testing and centralizes SQLite statements. + +Changes include: + +- all operator-facing shell/Python utilities are under `scripts/`, +- root-level `dev.sh` and `install-lxc.sh` were removed, +- `scripts/dev.sh` contains the previous development workflow, +- `scripts/install.sh` performs first systemd/LXC installation, +- `scripts/update.sh` performs tested in-place updates with stopped SQLite backup, health check and automatic rollback, +- `scripts/service.sh` wraps common service operations, +- `scripts/install-lxc.sh` is a compatibility alias located inside `scripts/`, +- `scripts/common.sh` provides shared deployment helpers, +- `docs/LXC.md` documents the installed filesystem layout and update process, +- every application SQLite statement and schema definition is now in `src/queries.rs`, +- `src/db.rs` contains no embedded SQL statements, +- Cargo package version updated to 0.3.3. + +## LXC persistent paths + +```text +/opt/gree-controller/gree-controller +/etc/gree-controller.env +/var/lib/gree-controller/gree-controller.db +/var/backups/gree-controller// +/etc/systemd/system/gree-controller.service +``` + +The updater preserves `/etc/gree-controller.env` and backs up the stopped SQLite database before launching the new binary. + +## Validation performed in the packaging environment + +| Check | Result | +|---|---| +| `bash -n scripts/*.sh` | PASS | +| Python syntax for migration/HA integration | PASS | +| JSON parsing for language packs, HA translations and manifests | PASS | +| JavaScript syntax (`node --check web/app.js`) | PASS | +| No root-level operator `.sh`/`.py` files | PASS | +| SQL keyword scan outside `src/queries.rs` | PASS - no SQL statements found | +| EN/PL localization files preserved | PASS | +| `gree_controller` namespace preserved | PASS | + +`shellcheck` is not installed in the packaging environment, so a shellcheck pass could not be performed. + +The packaging environment also does not contain a Rust/Cargo toolchain, therefore the final compiler/type-check must be executed inside the target LXC. The provided installer does this by default. + +Recommended first-install validation: + +```bash +sudo ./scripts/install.sh +./scripts/service.sh status +./scripts/service.sh health +``` + +Recommended source/development validation: + +```bash +./scripts/dev.sh --check +``` + +Recommended update validation with a newer archive: + +```bash +sudo ./scripts/update.sh +./scripts/service.sh health +``` diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d1102e3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2398 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gree-controller" +version = "0.3.3" +dependencies = [ + "aes", + "aes-gcm", + "anyhow", + "axum", + "base64", + "chrono", + "clap", + "dotenvy", + "futures-util", + "rand 0.8.7", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.7", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a5c2d9e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "gree-controller" +version = "0.3.3" +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" +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"] } + +[build-dependencies] +serde_json = "1" + +[dev-dependencies] +tempfile = "3" + +[profile.release] +strip = true +lto = "thin" +codegen-units = 1 +panic = "abort" diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 new file mode 100644 index 0000000..3caa0e8 --- /dev/null +++ b/FILE_MANIFEST.sha256 @@ -0,0 +1,57 @@ +f6ccf0fb91df0db03b045feb0de014b78690da50e86b6ae8cf128139effa0b8e ./.env.example +2fe1cf4e544fead5ae58436145a5b45e7a5a105143dfd815d28e307c94d5d19b ./.gitignore +d9d4dc23f77f1fc8319367a1eb53ee740807fd7a4a291a1add0a48056d832cec ./BUILD_REPORT.md +d9bea6d5fb8031f6923b9e1f78abe4648b67a33b5da70b587e8bf413f2b48125 ./Cargo.toml +19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE +615318767594b75456566494ae924e93a5b5b95bc58af49d5d74e25cfad173fb ./README.md +a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./data/.gitkeep +597d7116a44dea68f1af679b327006a2397cec68751546d4c05d62a41dd17343 ./docs/API.md +234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md +7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md +bbd650c208779c04c95f7ee1153e6b3556351ee9aba282fa2a2f6315e5acf362 ./docs/LXC.md +a728ddc324613671932bd83522155b09c7503d4fbcd0d04d8e217676ea9e10e4 ./docs/PROJECT_SPEC.md +33214270b96ac4c64e3db41c11e54158792b4a87ee5668c651bad571766b591a ./home-assistant/README.md +f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py +e0cf725c9f84be51cdbd83a5ab12b2b8288f90b36623cb4671cefcbf04378504 ./home-assistant/custom_components/gree_controller/api.py +f71f3ce47bcf6981c4044afdd321d96aa464125f55fdc4cc231013b2bfff284b ./home-assistant/custom_components/gree_controller/climate.py +5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py +e1821b74859bc40773a6ee39e6ccc9650980b62af50d6426b46cdb3e3a90d200 ./home-assistant/custom_components/gree_controller/const.py +2d27d7cb67c53e819b99a0302cd0bd339c27e3e2dfc85e07d30c31f505f740b5 ./home-assistant/custom_components/gree_controller/coordinator.py +5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py +9901371e91ef74a534b16f06c92baf66347e74a619ec5e470a82889671a05097 ./home-assistant/custom_components/gree_controller/manifest.json +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 +253a0bc912786e67ea7fc92a64e4a510ad973bec343a88ccfb1f28fca3e8cf01 ./lang/README.md +7a5381985f51b0cf7389d9ec2debf27181797c9d8d305b40b0ab516e29c47ed5 ./lang/en.json +4856406283b0ba0fb953b73ab6ddd7ea725257bcdbf55c42413b4e4d381211af ./lang/pl.json +5328833e82fabe3acd9d6a68a61a9550ba09ad941a2639ff9b791737a7d01ac2 ./scripts/README.md +be8c4bf17723d5e2e23e774a679ca75b02ab49825a93201237bf58cdd4974136 ./scripts/common.sh +054e6862857fd1d02dabedd977d4175c2011f451aba51b5d4fc3a3363e66e80f ./scripts/dev.sh +14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py +e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh +34d6aba352f0fc01e2882e7855d22ad620311084d814d23c1571defe1e31335f ./scripts/install.sh +81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh +69a1c2ad30685cac517eeb18a27d40368506dc5e54b57d943c324339303afc2b ./scripts/smoke.sh +b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh +7567099da203faaaabd2f940936d162f4d376f731b17dbd4975fb87a92e22bf8 ./src/api.rs +ef7e85336fa3a33977c731b4e2f273dc19b6155a0e0fa2ce30e4f0e6ba8c757a ./src/config.rs +54f3449ea140b0a0eefb8cff1d95090e62d5a955e50fcad47bc10b669ea3d68e ./src/db.rs +281ad87b779d71bcd1f0cec4610a268b36ffe7e6f9813163d0fb236a61106dda ./src/engine.rs +ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs +f85cb4ba6435843431d93779aa0653fd24ec8987e06ef36efde096cbea321d7a ./src/home_assistant.rs +3b14be5162ecb2aab357815d41a05042dbeedbdb7fc69a6a8210e7c50047a97d ./src/main.rs +48b2460a34af3b61830223431f6f400fa1339a8e9e929b639c437dcfa85767d3 ./src/models.rs +446615748ce2105457f0c81a295f6012e4dbc791894a64d45b4089a7dd8e4d7e ./src/protocol/crypto.rs +42b623e8616951cdab96e609b5c4d904f967932138762f05892f9258330a5332 ./src/protocol/gree.rs +a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs +c80164631bba476db29469fa43b28d73f68412c06bafabe2143ab8d4cd330a52 ./src/queries.rs +521070a4c63bec73372cf4873f20c5fad23c37db4df7199bcc5bf6f6e566b3f6 ./src/state.rs +52aea29e17e7ce45ea10f7783be7278ecefe976141c5823360f10f0ea2bbcf33 ./systemd/gree-controller.service +85781fef525c27356f8f70bc525553b96ee5adbcefdeadeece449dd3f16f21b0 ./web/app.js +b6bba1e1e7127d06c885a9f8fffbf0becde339e0f2464da8b4725f11ddabe433 ./web/favicon.svg +6439b633c76637c452d4d1e65f864ed87be122df94ced4891b9770eaa6cdef57 ./web/index.html +42143de65d81083938fc82aa601a38fb9f865860d628752580b0f1c8b77275d4 ./web/manifest.webmanifest +453d2041e6dc2ffa258dc7a6fcc27cb534047aa83722be0872d31b8f79b3ac49 ./web/styles.css +2ecdd077b73c4c725de8b642c6658992bcefe99b08a22a78eaf7903eaf9f813a ./web/sw.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..82b5f11 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GREE Controller contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e9f480 --- /dev/null +++ b/README.md @@ -0,0 +1,299 @@ +# GREE Controller + +> Identifier convention: project-owned namespace identifiers use `gree_controller`. Home Assistant uses `gree_controller`, UI storage uses `gree_controller_*`, and environment variables use `GREE_CONTROLLER_*`. + +Standalone local GREE air-conditioner controller written in Rust. It runs on a regular Linux host or an LXC container and provides a mobile-first web interface without depending on the vendor cloud. + +Current version: **0.3.3**. + +## Highlights + +- local GREE discovery over UDP/7000, +- V1 bind/status/command transport using AES-128-ECB, +- V2 AES-128-GCM envelope support, +- power, HVAC mode, target temperature, fan, vertical/horizontal swing, quiet, turbo and display light, +- SQLite state/history/event storage, +- temperature zones with hysteresis and minimum ON/OFF protection, +- weekly schedules including ranges that cross midnight, +- temperature/time automations, +- per-zone optional Home Assistant room-temperature sensors with GREE fallback, +- combined zone temperature using configurable GREE/external sensor weighting and discrepancy protection, +- REST API and WebSocket updates, +- responsive PWA optimized for phones, +- JSON-based UI localization loaded from embedded `lang/*.json` language packs, +- light, dark and system appearance modes stored in a browser cookie, +- optional Bearer-token authentication, +- simulator mode for development without physical hardware, +- Debian/Ubuntu LXC systemd installer, +- Home Assistant custom integration that proxies `climate` commands through this controller, +- migration mapping generator for retaining existing HA entity IDs such as `climate.klima_salon`. + +See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`docs/LXC.md`](docs/LXC.md) for the LXC deployment/update workflow. + +## Quick start + +On Debian, Ubuntu or an LXC container: + +```bash +unzip gree-controller-v0.3.3.zip +cd gree-controller +chmod +x scripts/*.sh +./scripts/dev.sh +``` + +`scripts/dev.sh` installs missing build tools when possible, installs stable Rust with `rustup` when required, creates `.env`, builds the application and starts the web panel. + +Default address: + +```text +http://HOST_ADDRESS:8787 +``` + +The first empty database can be seeded with **Living Room (simulator)** so the UI, history, zones and automations can be tested without an AC. + +### Development commands + +```bash +./scripts/dev.sh --check +./scripts/dev.sh --release +./scripts/dev.sh --reset +./scripts/dev.sh --host 0.0.0.0 --port 8787 +./scripts/dev.sh --no-install +``` + +## Web interface + +The UI is mobile-first and uses no external CDN. + +Language selector: + +- English is the required default/fallback language, +- Polish is included, +- additional languages are discovered automatically from `lang/*.json` at build time. + +The selected language is stored in the `gree_controller_language` cookie. The UI contains no hard-coded list of supported languages. To add a language, copy `lang/en.json`, translate the `translations` values, set the `meta` fields, save it as `.json`, and rebuild. For example, `lang/de.json` becomes an additional language after `cargo build` / `./scripts/dev.sh`. Missing translation keys fall back to English. + +See [`docs/LOCALIZATION.md`](docs/LOCALIZATION.md) for the language-pack format and validation rules. + +Appearance selector: + +- System, +- Light, +- Dark. + +The selected appearance is stored in the `gree_controller_theme` cookie. The interface uses flat surfaces and borders; decorative UI shadows were removed in v0.2.0. + +## Connecting a physical GREE device + +1. Put the controller and AC in a network where UDP/7000 traffic is allowed. +2. Open the web interface and select **Discover**. +3. Use **Bind** or send a command so the controller can obtain/use the device key. +4. If LXC/VLAN broadcast does not pass, add the unit manually with IP and MAC/CID. + +Some GREE firmware families use protocol variations. V1 covers common Wi-Fi units. V2 implements the standard AES-GCM envelope but unusual firmware may require protocol-specific adaptation. + +## LXC/systemd installation and updates + +All operator scripts are under `scripts/`. On a clean Debian/Ubuntu LXC container: + +```bash +chmod +x scripts/*.sh +sudo ./scripts/install.sh +``` + +The installer installs build dependencies/Rust when required, runs the Rust tests, builds a release binary, creates the `gree-controller` service account, stores runtime data in `/var/lib/gree-controller`, installs the binary under `/opt/gree-controller`, creates `/etc/gree-controller.env`, generates an administrator token and enables the systemd service. Existing `/etc/gree-controller.env` is preserved. + +For a later release, unpack the new source archive and run: + +```bash +sudo ./scripts/update.sh +``` + +The update is designed for LXC testing and production-style upgrades: compilation/tests happen before the running service is stopped; then the script backs up the installed binary, service unit, environment file and stopped SQLite database under `/var/backups/gree-controller//`. After replacement it checks `/api/health`. A failed startup triggers automatic rollback to the previous binary, unit and database backup. + +Service helpers: + +```bash +./scripts/service.sh status +sudo ./scripts/service.sh restart +./scripts/service.sh logs +./scripts/service.sh health +``` + +Use `--skip-tests` with `install.sh` or `update.sh` only when you explicitly want to skip `cargo test --all-targets`. `scripts/install-lxc.sh` remains as a compatibility alias to `scripts/install.sh`. + +## Environment configuration + +| Variable | Default | Purpose | +|---|---:|---| +| `GREE_CONTROLLER_BIND` | `0.0.0.0:8787` | HTTP/WebSocket bind address | +| `GREE_CONTROLLER_DATABASE` | `./data/gree-controller.db` | SQLite file | +| `GREE_CONTROLLER_APP_TOKEN` | empty | Bearer token; empty disables API authentication | +| `GREE_CONTROLLER_SIMULATE` | `true` | Enables simulator support | +| `GREE_CONTROLLER_AUTO_SEED` | `true` | Seeds a simulator into an empty database | +| `GREE_CONTROLLER_POLL_INTERVAL_SECONDS` | `15` | Device polling interval | +| `GREE_CONTROLLER_ZONE_INTERVAL_SECONDS` | `5` | Zone-control interval | +| `GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS` | `3000` | UDP discovery timeout | +| `GREE_CONTROLLER_DISCOVERY_BROADCAST` | `255.255.255.255:7000` | Discovery broadcast target | +| `GREE_CONTROLLER_ID` | `gree-controller` | GREE protocol client CID | +| `HA_URL` | empty | Optional Home Assistant URL | +| `HA_TOKEN` | empty | Optional Home Assistant Long-Lived Access Token | +| `HA_ENTITY_ID` | empty | Optional default HA temperature sensor | + +Settings changed from the web panel are stored in SQLite. `GREE_CONTROLLER_APP_TOKEN` is loaded at process startup. + +## Per-zone room temperature sensors + +Each control zone can pair one GREE indoor unit with its own optional room sensor from Home Assistant. This is intentionally configured per zone, so rooms do not share a global temperature source. + +Example: + +```text +Living room -> GREE Living Room + sensor.living_room_temperature +Bedroom -> GREE Bedroom + sensor.bedroom_temperature +Office -> GREE Office + sensor.office_temperature +``` + +Zone temperature strategies: + +- **GREE only** — use the AC internal sensor. +- **GREE + room sensor** — recommended; calculate a weighted control temperature from both sensors. The default room-sensor weight is 40%. +- **Room sensor only** — use the assigned HA room sensor, with automatic fallback to GREE if HA or the entity becomes unavailable. + +For combined control, `max_sensor_difference` protects against an obviously incorrect external measurement. If the two sensors differ by more than the configured threshold (default `3.0°C`), the zone uses the GREE temperature and logs a sensor-discrepancy event. + +The zone API exposes `device_temperature`, `external_temperature`, `current_temperature` (the actual control temperature) and `control_temperature_source` for diagnostics. Existing SQLite zone records remain compatible because the new fields have defaults and are stored in the existing JSON payload. + +## Home Assistant + +There are two independent HA directions: + +1. **HA as an optional sensor source**: the Rust controller can read a selected HA sensor and use it for a zone. +2. **HA as a client of GREE Controller**: the included custom integration creates `climate` entities whose commands are sent to this Rust application. + +For the HA client connection, open **Settings -> Home Assistant integration access** in GREE Controller and press **Create new token**. The secret is shown once. Paste that token into the Home Assistant `GREE Controller` integration together with the controller URL. Managed HA tokens are stored as SHA-256 hashes and are scoped to the dedicated HA device/command API; they cannot change controller settings or manage other tokens. + +The second option is designed to replace the built-in/default GREE integration without changing automation/dashboard references. + +Example migration target: + +```text +old: climate.klima_salon -> built-in GREE integration +new: climate.klima_salon -> GREE Controller custom integration -> Rust API -> AC +``` + +Generate an entity mapping: + +```bash +./scripts/generate_ha_migration.py \ + --entity climate.klima_salon \ + --device gree-aabbccddeeff +``` + +Or validate against the controller and automatically use its only device: + +```bash +./scripts/generate_ha_migration.py \ + --entity climate.klima_salon \ + --controller-url http://192.168.1.20:8787 \ + --controller-token YOUR_CONTROLLER_TOKEN +``` + +The generated file must be copied to: + +```text +/config/gree_controller_entities.json +``` + +Full installation and safe takeover procedure: [`home-assistant/README.md`](home-assistant/README.md) and [`docs/HOME_ASSISTANT_MIGRATION.md`](docs/HOME_ASSISTANT_MIGRATION.md). + +## API + +Important endpoints: + +```text +GET /api/health +GET /api/bootstrap +POST /api/discovery +GET /api/devices +POST /api/devices +GET /api/devices/:id +PATCH /api/devices/:id +POST /api/devices/:id/bind +POST /api/devices/:id/poll +POST /api/devices/:id/command +GET /api/zones +POST /api/zones +GET /api/schedules +POST /api/schedules +GET /api/automations +POST /api/automations +GET /api/readings +GET /api/events +GET /api/settings +PUT /api/settings +GET /api/access-tokens +POST /api/access-tokens +DELETE /api/access-tokens/{id} +POST /api/integrations/home-assistant/test +GET /api/integrations/home-assistant/devices +POST /api/integrations/home-assistant/devices/{id}/command +WS /ws +``` + +Example command: + +```bash +curl -X POST http://127.0.0.1:8787/api/devices/sim-salon/command \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer YOUR_TOKEN' \ + -d '{"power":true,"mode":"cool","target_temperature":22,"fan_speed":3}' +``` + +`/api/health` is public. The normal controller API and Web UI require `GREE_CONTROLLER_APP_TOKEN` only when that administrator token is configured. The dedicated `/api/integrations/home-assistant/*` client endpoints always require either a generated HA access token or the administrator token. WebSocket accepts only the administrator token using `?token=...`. + +More examples: [`docs/API.md`](docs/API.md). + +## Security + +- The application does not terminate TLS. Use HTTPS reverse proxy or VPN on untrusted networks. +- Do not expose port 8787 directly to the public Internet. +- Use a long random `GREE_CONTROLLER_APP_TOKEN`. +- Protect `.env`, `/etc/gree-controller.env`, SQLite data and HA tokens. +- The controller sends device commands only to configured local-network devices. + +## Backup + +For a systemd installation: + +```bash +systemctl stop gree-controller +cp /var/lib/gree-controller/gree-controller.db /safe/backup/location/ +systemctl start gree-controller +``` + +Development data is stored under `data/` by default. + +## Project layout + +```text +src/api.rs HTTP API, WebSocket, embedded web assets +src/db.rs SQLite persistence and row/domain mapping +src/queries.rs all SQLite schema and SQL statements +src/engine.rs polling, zones, schedules and automations +src/protocol/ GREE UDP/AES discovery, bind, status, command +src/home_assistant.rs optional HA sensor client +web/ mobile-first bilingual PWA +home-assistant/custom_components/ HA custom integration +scripts/install.sh first LXC/systemd installation +scripts/update.sh safe LXC/systemd update with backup/rollback +scripts/service.sh service status/start/stop/restart/logs/health +scripts/dev.sh development build/run/check workflow +scripts/generate_ha_migration.py legacy HA entity-ID mapping generator +scripts/smoke.sh API smoke test +systemd/ systemd service unit +``` + +## License + +MIT. This project uses a community-reconstructed local device protocol and is not an official product of GREE Electric Appliances Inc. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..6ef62f4 --- /dev/null +++ b/build.rs @@ -0,0 +1,93 @@ +use serde_json::{json, Value}; +use std::{env, fs, path::PathBuf}; + +fn required_string<'a>(meta: &'a Value, key: &str, file: &str) -> &'a str { + meta.get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| panic!("{file}: meta.{key} must be a non-empty string")) +} + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let lang_dir = manifest_dir.join("lang"); + println!("cargo:rerun-if-changed={}", lang_dir.display()); + + let mut files: Vec = fs::read_dir(&lang_dir) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", lang_dir.display())) + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json")) + .collect(); + files.sort(); + + if files.is_empty() { + panic!("no language files found in {}", lang_dir.display()); + } + + let mut assets = Vec::new(); + let mut manifest_languages = Vec::new(); + let mut has_english = 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 == '_') { + 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())); + let document: Value = serde_json::from_str(&source) + .unwrap_or_else(|error| panic!("{filename}: invalid JSON: {error}")); + 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); + let locale = required_string(meta, "locale", filename); + if code != stem { + panic!("{filename}: meta.code '{code}' must match filename '{stem}.json'"); + } + if !document.get("translations").is_some_and(Value::is_object) { + panic!("{filename}: translations must be a JSON object"); + } + if code == "en" { + has_english = true; + } + + manifest_languages.push(json!({ + "code": code, + "name": name, + "native_name": native_name, + "locale": locale, + "path": format!("/lang/{code}.json") + })); + assets.push((code.to_owned(), path)); + } + + if !has_english { + panic!("lang/en.json is required as the default fallback language"); + } + + let manifest_json = serde_json::to_string(&json!({ + "default": "en", + "languages": manifest_languages + })).expect("serialize language 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("pub const LANGUAGE_ASSETS: &[(&str, &str)] = &[\n"); + for (code, path) in assets { + generated.push_str(&format!( + " ({:?}, include_str!({:?})),\n", + code, + 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("languages.rs"), generated).expect("write generated languages.rs"); +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..36c65e1 --- /dev/null +++ b/dev.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +MODE="debug" +ACTION="run" +INSTALL_DEPS=1 +RESET_DB=0 +HOST="" +PORT="" + +usage() { + cat <<'TXT' +GREE Controller - development environment + +Usage: + ./dev.sh install missing tools, build and run + ./dev.sh --release run an optimized build + ./dev.sh --check formatting, tests, build and API smoke test + ./dev.sh --reset remove the local database before startup + ./dev.sh --no-install do not install system packages or Rust + ./dev.sh --host 0.0.0.0 --port 8787 +TXT +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) MODE="release"; shift ;; + --check) ACTION="check"; shift ;; + --reset) RESET_DB=1; shift ;; + --no-install) INSTALL_DEPS=0; shift ;; + --host) HOST="${2:?missing value for --host}"; shift 2 ;; + --port) PORT="${2:?missing value for --port}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +run_privileged() { + if [[ ${EUID:-$(id -u)} -eq 0 ]]; then "$@" + elif command -v sudo >/dev/null 2>&1; then sudo "$@" + else fail "Root/sudo privileges are required to install dependencies: $*" + fi +} + +install_build_tools() { + [[ "$INSTALL_DEPS" -eq 1 ]] || return 0 + local missing=0 + command -v curl >/dev/null 2>&1 || missing=1 + command -v cc >/dev/null 2>&1 || missing=1 + if [[ "$missing" -eq 0 ]]; then return 0; fi + if command -v apt-get >/dev/null 2>&1; then + say "Installing build packages (Debian/Ubuntu/LXC)" + run_privileged apt-get update + run_privileged apt-get install -y --no-install-recommends build-essential curl ca-certificates pkg-config + elif command -v dnf >/dev/null 2>&1; then + say "Installing build packages (Fedora/RHEL)" + run_privileged dnf install -y gcc gcc-c++ make curl ca-certificates pkgconf-pkg-config + elif command -v apk >/dev/null 2>&1; then + say "Installing build packages (Alpine)" + run_privileged apk add --no-cache build-base curl ca-certificates pkgconf + else + fail "Unsupported package manager. Install a C compiler, make, curl and CA certificates." + fi +} + +install_rust() { + if command -v cargo >/dev/null 2>&1; then return 0; fi + [[ "$INSTALL_DEPS" -eq 1 ]] || fail "Cargo is not installed and --no-install was requested" + command -v curl >/dev/null 2>&1 || fail "curl is required to install Rust" + say "Installing stable Rust with rustup" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + # shellcheck disable=SC1090 + source "$HOME/.cargo/env" +} + +install_build_tools +install_rust +command -v cargo >/dev/null 2>&1 || fail "Cargo is still unavailable" + +if [[ ! -f .env ]]; then + cp .env.example .env + say "Created .env from the example configuration" +fi +mkdir -p data +if [[ "$RESET_DB" -eq 1 ]]; then + rm -f data/gree-controller.db data/gree-controller.db-shm data/gree-controller.db-wal + say "Removed the local database" +fi + +# Export simple KEY=VALUE entries from .env. +set -a +# shellcheck disable=SC1091 +source ./.env +set +a + +if [[ -n "$HOST" || -n "$PORT" ]]; then + current="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}" + current_host="${current%:*}" + current_port="${current##*:}" + export GREE_CONTROLLER_BIND="${HOST:-$current_host}:${PORT:-$current_port}" +fi + +if [[ "$ACTION" == "check" ]]; then + say "Checking formatting" + cargo fmt --all -- --check + say "Running Rust tests" + cargo test --all-targets + say "Building the application" + cargo build + say "Running HTTP/API smoke test" + ./scripts/smoke.sh + say "All checks passed" + exit 0 +fi + +if [[ "$MODE" == "release" ]]; then + say "Building release version" + cargo build --release + BINARY="$ROOT/target/release/gree-controller" +else + say "Building debug version" + cargo build + BINARY="$ROOT/target/debug/gree-controller" +fi + +bind="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}" +display_host="${bind%:*}" +[[ "$display_host" == "0.0.0.0" ]] && display_host="127.0.0.1" +say "Panel: http://${display_host}:${bind##*:}" +say "Stop: Ctrl+C" +exec "$BINARY" diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..a2b3247 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,112 @@ +# API examples + +`TOKEN` is optional when `GREE_CONTROLLER_APP_TOKEN` is empty. + +```bash +AUTH='Authorization: Bearer TOKEN' +BASE='http://127.0.0.1:8787' +``` + + +## Home Assistant access tokens + +Create and revoke integration tokens from the controller Web UI under **Settings -> Home Assistant integration access**. The clear-text secret is returned only once and the SQLite database stores only its SHA-256 hash. + +Administrator endpoints: + +```text +GET /api/access-tokens +POST /api/access-tokens +DELETE /api/access-tokens/{id} +``` + +The Home Assistant custom integration uses a restricted API surface: + +```text +GET /api/integrations/home-assistant/devices +POST /api/integrations/home-assistant/devices/{id}/command +``` + +These two endpoints always require `Authorization: Bearer ` (or the administrator `GREE_CONTROLLER_APP_TOKEN`). A generated HA token cannot update settings, run discovery, delete devices, manage tokens, or use the controller WebSocket. + +## Discovery + +```bash +curl -X POST "$BASE/api/discovery" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"timeout_ms":3000,"broadcast":"255.255.255.255:7000"}' +``` + +## Device command + +```json +{ + "power": true, + "mode": "cool", + "target_temperature": 22.0, + "fan_speed": 3, + "swing_vertical": true, + "quiet": false, + "turbo": false, + "light": true +} +``` + +Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. + +## Zone + +```json +{ + "name": "Living room", + "device_id": "gree-aabbccddeeff", + "enabled": true, + "mode": "cool", + "setpoint": 23.0, + "hysteresis": 0.6, + "min_on_seconds": 180, + "min_off_seconds": 180, + "sensor_source": "combined", + "ha_entity_id": "sensor.living_room_temperature", + "external_sensor_weight": 0.4, + "max_sensor_difference": 3.0 +} +``` + +`sensor_source` supports: + +- `device` — GREE indoor sensor only, +- `combined` — GREE + this zone's HA room sensor, +- `home_assistant` — this zone's HA room sensor, with GREE fallback. + +For `combined` and `home_assistant`, set a per-zone `ha_entity_id`. `external_sensor_weight` is `0.0..1.0`. If a combined sensor pair differs by more than `max_sensor_difference`, the controller falls back to GREE. The returned zone object includes `device_temperature`, `external_temperature`, `current_temperature`, and `control_temperature_source`. + +## Schedule + +Weekdays use ISO numbers: Monday `1`, Sunday `7`. + +```json +{ + "zone_id": "UUID", + "name": "Night", + "enabled": true, + "weekdays": [1,2,3,4,5,6,7], + "start_time": "22:00", + "end_time": "06:00", + "setpoint": 24.0 +} +``` + +## WebSocket + +Connect to `ws://HOST:8787/ws?token=TOKEN`. The first message uses event type `bootstrap`; later events include `device.updated`, `zone.updated`, `settings.updated` and `log.created`. + +## Localization assets + +Localization endpoints are public because the login dialog also needs translations. Language packs are embedded in the Rust binary at build time. + +```bash +curl "$BASE/lang/index.json" +curl "$BASE/lang/en.json" +``` + +`GET /lang/index.json` returns the automatically generated language catalog. `GET /lang/.json` returns the corresponding language pack. Add a valid `lang/.json` file and rebuild to expose a new language. diff --git a/docs/HOME_ASSISTANT_MIGRATION.md b/docs/HOME_ASSISTANT_MIGRATION.md new file mode 100644 index 0000000..4734ca0 --- /dev/null +++ b/docs/HOME_ASSISTANT_MIGRATION.md @@ -0,0 +1,67 @@ +# 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. diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md new file mode 100644 index 0000000..53a33dd --- /dev/null +++ b/docs/LOCALIZATION.md @@ -0,0 +1,71 @@ +# 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/.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. diff --git a/docs/LXC.md b/docs/LXC.md new file mode 100644 index 0000000..472c46b --- /dev/null +++ b/docs/LXC.md @@ -0,0 +1,110 @@ +# 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//`, +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. diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md new file mode 100644 index 0000000..d6b8ce1 --- /dev/null +++ b/docs/PROJECT_SPEC.md @@ -0,0 +1,162 @@ +# 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. + +## Temperature zones + +A zone connects an AC to a temperature-control policy. It contains: + +- target setpoint, +- heat/cool mode, +- hysteresis, +- minimum ON time, +- minimum OFF time, +- temperature source, +- optional Home Assistant sensor entity. + +Each zone can assign its own Home Assistant room sensor. Combined mode fuses the GREE and room measurements with a configurable weight (40% room sensor by default), while GREE remains the primary input. If HA is unavailable or the two sensors differ beyond the configured limit, control falls back to the GREE sensor. + +## Scheduler and automations + +Schedules are weekly time windows and may cross midnight. Automations currently support time or temperature triggers and send device commands with cooldown protection. + +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 `.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. + + +## 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. diff --git a/docs/original-spec.md b/docs/original-spec.md new file mode 100644 index 0000000..ff85282 --- /dev/null +++ b/docs/original-spec.md @@ -0,0 +1,3085 @@ +Tak — i **to jest bardzo sensowny projekt**, szczególnie że nie musimy wymyślać protokołu od zera. GREE/EWPE ma lokalny protokół UDP, a obecna integracja Home Assistant również komunikuje się lokalnie. Oficjalna integracja HA jest typu *Local Polling* i obsługuje m.in. tryb HVAC, temperaturę, wentylator, swing i presety. + +Co ważniejsze, są już implementacje pokazujące, że protokół można obsługiwać niezależnie od Home Assistanta: UDP/7000, szyfrowanie AES, discovery/bind/status/command. Nowsze implementacje obsługują nawet AES-128-ECB i AES-128-GCM. + +### Ja zrobiłbym to trochę inaczej niż zwykły custom component + +Zaprojektowałbym **własny lokalny serwer GREE + integrację HA**, np.: + +``` +``` + +``` + ┌──────────────────────────┐ + │ Home Assistant │ + │ │ + │ climate.gree_salon │ + │ sensor.gree_temperature │ + │ switch.gree_quiet │ + │ ... │ + └────────────┬─────────────┘ + │ + REST / WebSocket + │ + ┌────────────▼─────────────┐ + │ Gree Controller │ + │ │ + │ Device Manager │ + │ State Manager │ + │ Automation Engine │ + │ Heating Manager │ + │ Scheduler │ + │ Web UI │ + └────────────┬─────────────┘ + │ + GREE UDP / AES + │ + ┌─────────────────────┼─────────────────────┐ + │ │ │ + ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ + │ GREE AC │ │ GREE AC │ │ GREE AC │ + │ salon │ │ sypial. │ │ biuro │ + └─────────┘ └─────────┘ └─────────┘ +``` + +Dzięki temu **HA nie jest miejscem, w którym znajduje się cała logika**. HA staje się klientem naszego kontrolera. + +--- + +## Najważniejsze: kompatybilność 1:1 + +Chciałbym zachować praktycznie ten sam model urządzenia, który HA już zna: + +### Climate + +- `HVAC_MODE_OFF` +- `HVAC_MODE_AUTO` +- `HVAC_MODE_COOL` +- `HVAC_MODE_HEAT` +- `HVAC_MODE_DRY` +- `HVAC_MODE_FAN_ONLY` +- temperatura zadana +- temperatura pomieszczenia +- prędkość wentylatora +- swing pionowy +- swing poziomy +- preset +- power + +Obecna integracja GREE obsługuje już te podstawowe funkcje. + +### Switch + +Np.: + +- panel light +- quiet +- X-Fan +- turbo +- health +- sleep +- child lock +- display +- eco + +Oczywiście tylko te, które faktycznie obsługuje konkretny model. + +### Dodatkowe sensory + +I tutaj możemy zrobić znacznie więcej: + +``` +``` + +``` +sensor.gree_salon_temperature +sensor.gree_salon_humidity +sensor.gree_salon_power +sensor.gree_salon_mode +sensor.gree_salon_fan +sensor.gree_salon_connection +sensor.gree_salon_last_update +sensor.gree_salon_energy +``` + +oraz diagnostykę: + +``` +``` + +``` +sensor.gree_salon_response_time +sensor.gree_salon_packets +sensor.gree_salon_errors +sensor.gree_salon_uptime +``` + +--- + +# Discovery + +To też możemy zrobić porządnie. + +Standardowo GREE wykorzystuje UDP/7000. Discovery może działać przez broadcast, a dla VLAN-ów możemy zrobić dodatkowo aktywne wykrywanie po podsieciach. Istniejące implementacje już stosują unicast UDP/7000 właśnie do urządzeń znajdujących się w innych VLAN-ach. + +W naszym UI: + +``` +``` + +``` +GREE Controller +──────────────────────────────── + +Devices + +✓ GREE Salon + 192.168.10.51 + AA:BB:CC:DD:EE:01 + Online + 22.4°C + +✓ GREE Sypialnia + 192.168.10.52 + AA:BB:CC:DD:EE:02 + Online + 20.8°C + +[ Scan network ] + +Network: +192.168.10.0/24 + +[ Scan ] +``` + +A po znalezieniu: + +``` +``` + +``` +Found GREE device + +Model: GREE ... +MAC: xx:xx:xx:xx:xx:xx +IP: 192.168.10.51 +Protocol: GREE / EWPE +Encryption: AES-128-GCM +Firmware: ... + +[ Add device ] +``` + +--- + +# I tutaj widzę bardzo ważną rzecz + +Nie opierałbym projektu bezpośrednio na obecnym kodzie integracji HA. + +Mamy już kilka projektów, które pokazują różne implementacje protokołu: + +- Home Assistant GreeClimateComponent +- `gree-remote` +- implementacje EWPE +- implementacje Rust +- lokalne serwery GREE + +Nawet istniejący projekt Rust ma już architekturę bardzo zbliżoną do tego, co chcemy osiągnąć: daemon rozmawia z klimatyzatorem przez UDP/7000, a frontend dostaje normalne dane JSON i aktualizacje na żywo. + +**Ale nasz projekt powinien mieć własną warstwę protokołu.** + +Czyli: + +``` +``` + +``` +gree_protocol/ + discovery + encryption + packet + bind + status + command + device_capabilities +``` + +nad tym: + +``` +``` + +``` +gree_core/ + Device + DeviceManager + StateManager + Scheduler + AutomationEngine + HeatingManager +``` + +nad tym: + +``` +``` + +``` +api/ + REST + WebSocket +``` + +oraz: + +``` +``` + +``` +web/ + dashboard + devices + automations + heating + schedules + diagnostics +``` + +i osobno: + +``` +``` + +``` +homeassistant/ + config_flow + climate + sensor + switch + binary_sensor +``` + +--- + +# Najciekawsza część: inteligentne ogrzewanie + +Tutaj nasza aplikacja może być **dużo bardziej użyteczna niż zwykła integracja GREE**. + +Przykład: + +### Strefa: salon + +``` +``` + +``` +Heating zone: Salon + +Target: + 21.5°C + +Comfort: + 21.5°C + +Night: + 19.0°C + +Away: + 17.0°C + +Minimum: + 16.0°C +``` + +Źródło temperatury: + +``` +``` + +``` +○ GREE +● Xiaomi BLE sensor +○ Home Assistant sensor +○ średnia z kilku czujników +``` + +I wtedy kontroler decyduje: + +``` +``` + +``` +22:00 +↓ +Night mode +↓ +target = 19°C + +06:30 +↓ +Morning +↓ +target = 21°C + +08:30 +↓ +Nobody home +↓ +target = 18°C + +16:00 +↓ +Somebody home +↓ +target = 21°C +``` + +Ale możemy zrobić jeszcze lepiej. + +--- + +# Histereza + +Nie chcemy: + +``` +``` + +``` +21.0 → ON +21.1 → OFF +21.0 → ON +21.1 → OFF +``` + +tylko: + +``` +``` + +``` +target = 21°C +hysteresis = 0.4°C + +ON < 20.6°C +OFF > 21.4°C +``` + +A dodatkowo: + +``` +``` + +``` +minimum ON time +minimum OFF time +minimum interval between commands +``` + +żeby nie katować klimatyzatora. + +--- + +# Predykcyjne ogrzewanie + +To jest funkcja, którą naprawdę warto zrobić. + +Jeżeli system zauważy: + +``` +``` + +``` +18:00 19.2°C +18:15 19.7°C +18:30 20.1°C +18:45 20.5°C +19:00 20.8°C +``` + +może policzyć: + +``` +``` + +``` +Heating rate: ++0.53°C / hour +``` + +i wiedzieć, że dojście do 21°C zajmie około: + +``` +``` + +``` +23 min +``` + +Możemy więc ustawić: + +> „O 19:00 chcę mieć 21°C” + +i rozpocząć ogrzewanie odpowiednio wcześniej. + +--- + +# Możemy też sterować wieloma klimatyzatorami + +Przykładowo: + +``` +``` + +``` + Heating Zone + │ + ┌─────────┴─────────┐ + │ │ + GREE Salon GREE Kuchnia + 21°C 21°C + │ │ + └─────────┬─────────┘ + │ + Temperature + 21.2°C +``` + +System może zdecydować: + +``` +``` + +``` +Salon: + HEAT 21°C + +Kuchnia: + OFF +``` + +albo: + +``` +``` + +``` +Salon: + HEAT 20.5°C + +Kuchnia: + HEAT 20.5°C +``` + +--- + +# Automatyzacje + +Zamiast pisać YAML: + +``` +``` + +``` +trigger: +... +condition: +... +action: +... +``` + +robimy prosty GUI: + +``` +``` + +``` +Automation + +WHEN + +[ Temperature ] +[ Salon ] +[ falls below ] +[ 20°C ] + +AND + +[ Time ] +[ 06:00 - 22:00 ] + +THEN + +[ Climate ] +[ Salon ] +[ Heat ] +[ 21°C ] +``` + +Albo: + +``` +``` + +``` +WHEN + Outside temperature < 5°C + +AND + Time = 22:00 + +THEN + Set heating zone "Salon" + Target = 19°C + Fan = Auto +``` + +--- + +# Live state + +Web UI zrobiłbym na WebSocketach/SSE. + +Na dashboardzie: + +``` +``` + +``` +┌──────────────────────────────────────────┐ +│ SALON │ +│ │ +│ 21.3 °C │ +│ │ +│ Target 21.0 °C │ +│ │ +│ ● HEATING │ +│ │ +│ Mode HEAT │ +│ Fan AUTO │ +│ Swing AUTO │ +│ │ +│ Power ███████████░ │ +│ │ +│ [ − ] 21.0°C [ + ] │ +└──────────────────────────────────────────┘ +``` + +I **bez odświeżania strony**: + +``` +``` + +``` +AC → Controller → WebSocket → Browser +``` + +--- + +# Historia + +Możemy przechowywać: + +``` +``` + +``` +temperature +target temperature +mode +fan +power +connection +commands +errors +``` + +i zrobić wykres: + +``` +``` + +``` +22°C ┤ ╭──────── +21°C ┤ ╭──────╯ +20°C ┤ ╭────╯ +19°C ┤───────╯ + └──────────────────────────── + 12 14 16 18 20 22 +``` + +Do tego: + +``` +``` + +``` +Heating time today: 3h 42m +Commands: 27 +Communication errors: 0 +Estimated consumption: ... +``` + +--- + +# Bardzo ważna rzecz — odporność + +To jest właśnie coś, co może być **lepsze od obecnej integracji HA**. + +Nie chcemy: + +``` +``` + +``` +HA + │ + ├── request + │ + ├── timeout + │ + ├── retry + │ + └── error +``` + +tylko: + +``` +``` + +``` + Controller + │ + persistent state + │ + ┌─────────────┴─────────────┐ + │ │ + GREE AC Home Assistant +``` + +Controller cały czas monitoruje urządzenia. + +Jeżeli AC zniknie: + +``` +``` + +``` +ONLINE + ↓ +TIMEOUT + ↓ +RETRY 1 + ↓ +RETRY 2 + ↓ +OFFLINE +``` + +Po powrocie: + +``` +``` + +``` +OFFLINE + ↓ +DISCOVER/RECONNECT + ↓ +GET STATE + ↓ +ONLINE +``` + +HA dostaje już tylko poprawny stan. + +--- + +# I jest jeszcze jedna bardzo istotna kwestia + +**Nowsze firmware GREE faktycznie może być problemem.** + +Znalazłem świeże zgłoszenie z lipca 2026 dotyczące GREE Clivia, gdzie autor raportuje, że na firmware 2.12+ urządzenie pozostaje pingowalne, ale nie odpowiada na UDP/7000, przez co standardowa integracja HA nie może wykonać discovery/bind. + +To oznacza, że zanim zaczniemy pisać GUI, zrobiłbym **reverse engineering konkretnego dongla/klimatyzatora, którego masz**. + +I to jest najlepszy pierwszy etap. + +### Etap 1 — „GREE Protocol Lab” + +Napiszemy mały program: + +``` +``` + +``` +gree-probe +``` + +który: + +``` +``` + +``` +1. nasłuchuje UDP +2. wyszukuje GREE +3. identyfikuje urządzenie +4. wykonuje discovery +5. próbuje bind +6. odczytuje capabilities +7. odczytuje status +8. wykonuje pojedyncze komendy +9. loguje wszystkie pakiety +10. zapisuje PCAP +``` + +Np.: + +``` +``` + +``` +$ gree-probe scan + +Found 2 devices + +192.168.10.51 + MAC: ... + Model: ... + Protocol: GREE + Encryption: AES-GCM + UDP: 7000 + Status: OK + +192.168.10.52 + MAC: ... + Model: ... + Protocol: GREE + Encryption: AES-GCM + UDP: 7000 + Status: OK +``` + +Potem: + +``` +``` + +``` +gree-probe monitor 192.168.10.51 +``` + +i dostajemy: + +``` +``` + +``` +14:32:01 temp=21.4 target=21.0 mode=heat fan=auto +14:32:02 temp=21.4 target=21.0 mode=heat fan=auto +14:32:03 temp=21.5 target=21.0 mode=heat fan=auto +``` + +Dopiero na tym fundamencie budowałbym resztę. + +**I zdecydowanie nie robiłbym tego jako kolejnego prostego custom componentu HA.** Zrobiłbym własny daemon/controller + oficjalny custom integration HA. Wtedy możesz mieć jednocześnie **pełną kompatybilność encji z HA, własny panel WWW, własny scheduler, inteligentne ogrzewanie, historię i automatyzacje**, a protokół GREE jest całkowicie odseparowany od Home Assistanta. + +Jeżeli chcesz, możemy teraz przejść do **konkretnej architektury projektu i zacząć od reverse-engineeringu protokołu GREE — pliki, moduły, Python/Rust, API, baza danych i później gotowy custom integration dla HA**. + +Plan jest na to absyntów było w rust z interfejsem i będzie to na osobnym hoście/ kontenerze lxc lub w integracji homeassistant i jakoś trzeba pogodzić przekazywanie encji, generalnie osobny kontener i środowisko to must have + +Tak — w takim przypadku **zdecydowanie wybrałbym osobny kontener z usługą w Rust**, a integrację Home Assistant potraktowałbym wyłącznie jako cienkiego klienta. To daje nam izolację, własny lifecycle i możliwość rozwijania całego systemu niezależnie od HA. + +Najważniejsze jest tylko dobre rozwiązanie kwestii encji. + +## Docelowa architektura + +``` +``` + +``` + ┌─────────────────────────────┐ + │ HOME ASSISTANT │ + │ │ + │ climate.gree_salon │ + │ sensor.gree_salon_temp │ + │ sensor.gree_salon_humidity │ + │ switch.gree_salon_turbo │ + │ ... │ + └──────────────┬──────────────┘ + │ + Custom Integration + │ + WebSocket + REST + │ + ┌──────────────▼──────────────┐ + │ GREE CONTROLLER │ + │ │ + │ Rust │ + │ │ + │ ┌───────────────────────┐ │ + │ │ GREE Protocol │ │ + │ │ UDP / AES / discovery │ │ + │ └───────────────────────┘ │ + │ │ + │ ┌───────────────────────┐ │ + │ │ Device Manager │ │ + │ └───────────────────────┘ │ + │ │ + │ ┌───────────────────────┐ │ + │ │ Automation Engine │ │ + │ └───────────────────────┘ │ + │ │ + │ ┌───────────────────────┐ │ + │ │ Heating Manager │ │ + │ └───────────────────────┘ │ + │ │ + │ ┌───────────────────────┐ │ + │ │ REST API │ │ + │ │ WebSocket │ │ + │ └───────────────────────┘ │ + │ │ + │ ┌───────────────────────┐ │ + │ │ Web UI │ │ + │ └───────────────────────┘ │ + └──────────────┬──────────────┘ + │ + LAN / VLAN + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + GREE #1 GREE #2 GREE #3 +``` + +### Kontener + +Ja bym zrobił: + +``` +``` + +``` +LXC + └── gree-controller + ├── gree-controller + ├── gree.db + └── config/ +``` + +albo Docker/Podman, jeżeli później będziesz chciał łatwo przenosić usługę. + +**Rust + jeden binarny executable** jest tutaj bardzo dobrym rozwiązaniem. + +--- + +# Encje HA + +Nie przekazywałbym encji z kontenera bezpośrednio. + +To znaczy controller **nie powinien próbować udawać Home Assistanta**. + +Zamiast tego: + +``` +``` + +``` +GREE Controller + │ + │ API + ▼ +HA Custom Integration + │ + ▼ +HA Entity Registry + │ + ├── climate + ├── sensor + ├── switch + ├── binary_sensor + └── number +``` + +Czyli: + +**Rust zarządza urządzeniem, HA zarządza encją.** + +To jest bardzo ważne rozdzielenie odpowiedzialności. + +--- + +# Jak będzie wyglądała komunikacja + +Najlepiej zrobić **WebSocket jako kanał realtime**, a REST jako konfigurację/command API. + +### REST + +``` +``` + +``` +GET /api/v1/devices + +GET /api/v1/devices/{id} + +GET /api/v1/devices/{id}/state + +POST /api/v1/devices/{id}/commands + +GET /api/v1/zones + +GET /api/v1/automations +POST /api/v1/automations + +GET /api/v1/history +``` + +### WebSocket + +``` +``` + +``` +WS /api/v1/events +``` + +Controller wysyła: + +``` +``` + +``` +{ + "event": "device_state", + "device_id": "gree-salon", + "state": { + "power": true, + "mode": "heat", + "temperature": 21.4, + "target_temperature": 21.0, + "fan": "auto" + } +} +``` + +HA dostaje to natychmiast. + +--- + +# Co z komendami? + +HA: + +``` +``` + +``` +climate.set_temperature +``` + +↓ + +Custom integration: + +``` +``` + +``` +POST /api/v1/devices/gree-salon/commands +``` + +↓ + +Rust: + +``` +``` + +``` +Command::SetTemperature(21.0) +``` + +↓ + +GREE UDP + +↓ + +odpowiedź + +↓ + +Rust aktualizuje stan + +↓ + +WebSocket + +↓ + +HA + +I wtedy HA nie musi samodzielnie rozmawiać z GREE. + +--- + +# Ale zrobiłbym jeszcze jedną rzecz + +**Stan powinien być przechowywany w controllerze.** + +Czyli jeśli HA się zrestartuje: + +``` +``` + +``` +HA + ↓ +restart + ↓ +integration connects + ↓ +GET /api/v1/devices + ↓ +GET current state +``` + +i natychmiast odtwarza encje. + +A nie: + +``` +``` + +``` +HA restart + ↓ +czekamy aż GREE odpowie + ↓ +discovery + ↓ +bind + ↓ +state +``` + +Controller już wie: + +``` +``` + +``` +GREE Salon + last_state + last_seen + capabilities + configuration +``` + +--- + +# Discovery HA + +Custom integration może działać bardzo elegancko. + +Po instalacji: + +``` +``` + +``` +Settings + → Devices & services + → Add integration + → GREE Controller +``` + +Podajesz: + +``` +``` + +``` +Host: +192.168.1.20 + +Port: +8765 +``` + +Integration robi: + +``` +``` + +``` +GET /api/v1/info +``` + +i: + +``` +``` + +``` +GET /api/v1/devices +``` + +Dostaje: + +``` +``` + +``` +{ + "devices": [ + { + "id": "gree-salon", + "name": "Salon", + "type": "air_conditioner", + "capabilities": [ + "heat", + "cool", + "dry", + "fan", + "swing", + "turbo" + ] + } + ] +} +``` + +I tworzy encje. + +--- + +# Co daje nam osobny Rust? + +Ogromnie dużo. + +### 1. HA nie musi znać protokołu GREE + +Jeżeli GREE zmieni: + +``` +``` + +``` +AES +UDP +bind +discovery +packet format +``` + +aktualizujemy controller. + +HA integration pozostaje praktycznie bez zmian. + +--- + +### 2. Możemy mieć własny Web UI + +I to będzie **pełnoprawny panel**, nie dodatek do HA. + +Np.: + +``` +``` + +``` +Dashboard + +┌───────────────────────────────────────────────┐ +│ GREE CONTROLLER │ +│ │ +│ 3 devices 3 online 0 errors │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ │ +│ │ SALON │ │ SYPIALNIA │ │ +│ │ │ │ │ │ +│ │ 21.4°C │ │ 20.1°C │ │ +│ │ → 21.0°C │ │ → 19.0°C │ │ +│ │ │ │ │ │ +│ │ HEAT │ │ HEAT │ │ +│ └──────────────┘ └──────────────┘ │ +└───────────────────────────────────────────────┘ +``` + +--- + +# 3. Automatyzacje nie muszą zależeć od HA + +I to jest moim zdaniem **najciekawsza rzecz**. + +Możemy zrobić: + +``` +``` + +``` +GREE Controller + │ + ├── automation engine + │ + ├── heating engine + │ + ├── scheduler + │ + └── HA integration +``` + +Czyli np. ogrzewanie działa nawet kiedy HA jest wyłączony. + +--- + +# Ale wtedy potrzebujemy integracji z HA także w drugą stronę + +To znaczy Rust może potrzebować informacji z HA: + +``` +``` + +``` +temperatura z czujnika +obecność +otwarte okno +temperatura zewnętrzna +tryb domu +``` + +Nie robiłbym jednak bezpośredniego dostępu Rust → HA API jako podstawowej architektury. + +Lepiej: + +``` +``` + +``` +HA + │ + │ WebSocket/API + ▼ +GREE Integration + │ + ▼ +Rust Controller +``` + +Integracja może opcjonalnie przekazywać do controllera wybrane encje HA. + +Na przykład: + +``` +``` + +``` +External sensors + +Outdoor temperature + → sensor.outdoor_temperature + +Living room temperature + → sensor.living_room_temperature + +Presence + → binary_sensor.home_occupied +``` + +Controller może wtedy używać ich w automatyzacjach. + +--- + +# Strefy ogrzewania + +Wtedy możemy zrobić coś znacznie bardziej zaawansowanego. + +``` +``` + +``` +Heating +│ +├── Zone: Salon +│ ├── GREE Salon +│ ├── temperature sensor +│ └── schedule +│ +├── Zone: Sypialnia +│ ├── GREE Sypialnia +│ └── temperature sensor +│ +└── Zone: Biuro + ├── GREE Biuro + └── temperature sensor +``` + +I każda strefa ma: + +``` +``` + +``` +Comfort: 21.5°C +Night: 19°C +Away: 17°C +Frost protect: 10°C + +Schedule: +06:00 → Comfort +08:30 → Away +16:00 → Comfort +23:00 → Night +``` + +--- + +# Technologia + +Ja bym poszedł w: + +``` +``` + +``` +Rust +├── tokio +├── axum +├── serde +├── serde_json +├── sqlx +├── SQLite +├── tracing +├── tower +└── tokio-tungstenite / axum WebSocket +``` + +Frontend: + +``` +``` + +``` +React / TypeScript +``` + +albo, jeśli chcemy maksymalnie ograniczyć zasoby: + +``` +``` + +``` +Leptos +``` + +Ale tutaj **nie komplikowałbym pierwszej wersji**. Rust backend + lekki frontend będzie rozsądniejszy. + +--- + +# SQLite + +Na początek: + +``` +``` + +``` +gree.db +``` + +z tabelami mniej więcej: + +``` +``` + +``` +devices +device_capabilities +device_state +device_history + +zones +zone_devices +zone_sensors + +automations +automation_conditions +automation_actions + +schedules + +settings +``` + +Bez PostgreSQL. + +To będzie jedna usługa na jednym LXC, więc SQLite jest idealny. + +--- + +# I jeszcze jedna decyzja architektoniczna + +Nie robiłbym: + +``` +``` + +``` +Rust + └── web server + └── GREE protocol +``` + +jako jednego wielkiego modułu. + +Tylko: + +``` +``` + +``` +src/ +├── main.rs +│ +├── api/ +│ ├── rest.rs +│ ├── websocket.rs +│ └── auth.rs +│ +├── gree/ +│ ├── protocol.rs +│ ├── crypto.rs +│ ├── discovery.rs +│ ├── device.rs +│ ├── commands.rs +│ └── state.rs +│ +├── devices/ +│ ├── manager.rs +│ ├── capabilities.rs +│ └── registry.rs +│ +├── automation/ +│ ├── engine.rs +│ ├── conditions.rs +│ └── actions.rs +│ +├── heating/ +│ ├── controller.rs +│ ├── zones.rs +│ ├── hysteresis.rs +│ └── prediction.rs +│ +├── scheduler/ +│ +├── database/ +│ +└── events/ +``` + +To pozwoli nam później rozwijać projekt bez robienia spaghetti. + +--- + +## Moim zdaniem docelowo powinno to wyglądać tak + +``` +``` + +``` + ┌────────────────────────────┐ + │ HOME ASSISTANT │ + │ │ + │ Native HA entities │ + │ │ + │ GREE custom integration │ + └─────────────┬──────────────┘ + │ + REST + WebSocket + │ + ┌─────────────▼──────────────┐ + │ GREE CONTROLLER │ + │ │ + │ RUST │ + │ │ + │ ┌────────────────────────┐ │ + │ │ GREE protocol │ │ + │ ├────────────────────────┤ │ + │ │ Device manager │ │ + │ ├────────────────────────┤ │ + │ │ State engine │ │ + │ ├────────────────────────┤ │ + │ │ Automation engine │ │ + │ ├────────────────────────┤ │ + │ │ Heating engine │ │ + │ ├────────────────────────┤ │ + │ │ Scheduler │ │ + │ ├────────────────────────┤ │ + │ │ SQLite │ │ + │ └────────────────────────┘ │ + │ │ + │ Web UI │ + └─────────────┬──────────────┘ + │ + UDP / 7000 + │ + ┌─────────────┼──────────────┐ + ▼ ▼ ▼ + GREE GREE GREE +``` + +**To jest architektura, którą bym przyjął.** Kontener/LXC jest niezależnym, autonomicznym serwisem, a HA dostaje normalne, natywne encje przez custom integration. Dzięki temu nawet całkowite wyłączenie HA nie zabija automatyki ogrzewania. + +Następny krok zrobiłbym już praktycznie: **ustalić dokładnie protokół GREE/EWPE dla obecnych dongli, wybrać Rust crate/implementację jako punkt startowy i zaprojektować v1 API + model encji HA**, zanim zaczniemy pisać właściwy kod. + +I jako dodatek do odczytu temperatur i lepsze odczyty jako opcja dodatkowa odczyt z api homeassistant zewnętrznego czujnika temperatury w pokoju z api homeassistant ale nie jako główne zrodlo informacji tylko jako opcja dodatkowa żeby wspomóc algorytm, zrób takamspecyfikacje uniezależnij to Dodatkowo interfejs musi być napełni kompatybilny z komórek bo to będzie główne miejsce sterowania, zastanawiam się na tailwind css z trybem ciemnym / jasnym Zrób takamspecyfikaje z tym co proponowałeś + +Tak. W tej wersji zrobiłbym z tego **całkowicie niezależny kontroler GREE**, a Home Assistant byłby tylko jednym z możliwych źródeł danych i odbiorcą encji. Szczególnie ważne jest rozdzielenie **temperatury zmierzonej przez klimatyzator**, **temperatury z zewnętrznego sensora HA** oraz **temperatury używanej przez algorytm sterowania**. + +Poniżej specyfikacja, którą można potraktować jako bazę projektu. + +# Specyfikacja GREE Controller + +## 1. Cel projektu + +Samodzielna aplikacja działająca w osobnym środowisku: + +- Rust +- LXC / kontener +- własny backend +- własna baza danych +- własny interfejs WWW +- komunikacja lokalna z klimatyzatorami GREE +- REST API +- WebSocket +- opcjonalna integracja z Home Assistant +- natywne encje Home Assistant +- własny silnik automatyzacji +- własny silnik sterowania ogrzewaniem/chłodzeniem +- obsługa zewnętrznych czujników temperatury + +**Home Assistant nie jest wymagany do działania podstawowych funkcji systemu.** + +--- + +# 2. Zasada nadrzędna + +Architektura ma być **HA-independent**. + +``` +``` + +``` + ┌──────────────────────┐ + │ GREE Controller │ + │ │ + │ Rust │ + │ │ + │ GREE protocol │ + │ State engine │ + │ Heating engine │ + │ Automation engine │ + │ Scheduler │ + │ Web UI │ + │ Database │ + └──────────┬───────────┘ + │ + ┌─────────────┴──────────────┐ + │ │ + GREE AC Home Assistant + │ │ + │ Custom Integration + │ │ + │ Entities / sensors +``` + +HA jest **integracją z kontrolerem**, a nie jego fundamentem. + +--- + +# 3. Źródła temperatury + +To będzie jeden z najważniejszych elementów projektu. + +Każda strefa może mieć kilka źródeł temperatury. + +### Źródło 1 — temperatura GREE + +Podstawowe: + +``` +``` + +``` +GREE internal temperature +``` + +np. + +``` +``` + +``` +21.7°C +``` + +To jest temperatura raportowana przez klimatyzator. + +--- + +### Źródło 2 — zewnętrzny sensor + +Opcjonalnie: + +``` +``` + +``` +Home Assistant + ↓ +REST API / WebSocket + ↓ +GREE Controller + ↓ +external temperature +``` + +Przykład: + +``` +``` + +``` +sensor.salon_temperature +``` + +Controller może cyklicznie pobierać: + +``` +``` + +``` +21.2°C +``` + +ale **nie traktuje tego automatycznie jako zamiennika temperatury GREE**. + +--- + +### Źródło 3 — temperatura obliczona + +Najważniejsze: + +``` +``` + +``` +CONTROL TEMPERATURE +``` + +czyli temperatura, którą faktycznie wykorzystuje algorytm. + +Przykładowo: + +``` +``` + +``` +GREE: +21.7°C + +External: +21.2°C + +Control: +21.4°C +``` + +Algorytm może korzystać z: + +``` +``` + +``` +control_temperature = + weighted_average( + gree_temperature, + external_temperature + ) +``` + +albo: + +``` +``` + +``` +control_temperature = + external_temperature +``` + +jeżeli użytkownik jawnie tak skonfiguruje. + +--- + +# 4. Konfiguracja źródła temperatury + +W UI: + +``` +``` + +``` +Temperature source + +○ GREE internal sensor + +○ External Home Assistant sensor + +● Combined + + GREE weight: 40% + External weight: 60% + +External sensor: + sensor.salon_temperature +``` + +Dodatkowo: + +``` +``` + +``` +Maximum allowed difference: +3.0°C +``` + +Jeżeli: + +``` +``` + +``` +GREE = 21°C +External = 26°C +``` + +system nie powinien bezwarunkowo przyjąć 26°C. + +Generuje: + +``` +``` + +``` +Temperature sensor discrepancy +``` + +i może automatycznie przejść na GREE. + +--- + +# 5. Confidence / jakość pomiaru + +Każdy pomiar powinien mieć metadane: + +``` +``` + +``` +temperature +timestamp +source +age +quality +``` + +Przykład: + +``` +``` + +``` +External sensor + +Temperature: 21.3°C +Age: 4 sec +Quality: 100% +``` + +Jeżeli HA przestanie odpowiadać: + +``` +``` + +``` +Age: 83 sec +Quality: 20% +``` + +Po przekroczeniu timeoutu: + +``` +``` + +``` +External sensor: +UNAVAILABLE +``` + +i system automatycznie korzysta z GREE. + +To jest bardzo ważne dla niezależności. + +--- + +# 6. Algorytm sterowania + +Nie robimy prostego: + +``` +``` + +``` +if temp < target: + heat() +``` + +tylko warstwę kontrolną. + +``` +``` + +``` +Sensors + ↓ +Sensor validation + ↓ +Temperature estimator + ↓ +Target temperature + ↓ +Heating/Cooling controller + ↓ +Safety limits + ↓ +GREE command +``` + +--- + +# 7. Target temperature + +Temperatura docelowa może wynikać z: + +``` +``` + +``` +Manual +Schedule +Night +Away +Presence +External temperature +Automation +Heating zone +``` + +Przykład: + +``` +``` + +``` +23:00 +Night profile +↓ +19°C + +06:00 +Morning +↓ +21°C + +08:30 +Away +↓ +17°C + +16:00 +Home +↓ +21°C +``` + +--- + +# 8. Histereza + +Konfigurowalna: + +``` +``` + +``` +Target: 21°C +Hysteresis: 0.4°C +``` + +czyli: + +``` +``` + +``` +< 20.6°C → HEAT + +20.6–21.4°C → maintain + +> 21.4°C → STOP +``` + +Dodatkowo: + +``` +``` + +``` +Minimum ON time +Minimum OFF time +Minimum command interval +Maximum commands/hour +``` + +--- + +# 9. Inteligentne ogrzewanie + +System powinien opcjonalnie analizować: + +``` +``` + +``` +temperature history +target history +heating state +outside temperature +time +heating rate +``` + +i obliczać: + +``` +``` + +``` +temperature change/hour +``` + +Przykład: + +``` +``` + +``` +19:00 19.4°C +19:15 19.8°C +19:30 20.2°C +19:45 20.6°C +``` + +System: + +``` +``` + +``` +Heating rate ≈ 1.6°C/hour +``` + +Może więc przewidzieć: + +``` +``` + +``` +Target 21°C +ETA ≈ 15 min +``` + +W kolejnej wersji można dodać predykcję rozpoczęcia ogrzewania. + +--- + +# 10. Strefy + +Klimatyzator nie musi być bezpośrednio jednostką sterowania. + +Tworzymy: + +``` +``` + +``` +Zone +``` + +np. + +``` +``` + +``` +Salon +``` + +która posiada: + +``` +``` + +``` +Devices: + GREE Salon + +Temperature sources: + GREE + sensor.salon_temperature + +Target: + 21°C + +Schedule: + Salon schedule + +Control: + Intelligent heating +``` + +Dzięki temu później można mieć: + +``` +``` + +``` +Salon + ├── GREE + ├── Xiaomi temperature sensor + └── window sensor + +Sypialnia + ├── GREE + └── temperature sensor +``` + +--- + +# 11. Home Assistant jako opcjonalne źródło danych + +Controller posiada moduł: + +``` +``` + +``` +home_assistant/ +``` + +który jest **opcjonalny**. + +Konfiguracja: + +``` +``` + +``` +Home Assistant integration + +Enabled: YES + +URL: +http://homeassistant:8123 + +Token: +************ + +Poll interval: +10 sec +``` + +Możemy pobierać: + +``` +``` + +``` +sensor.* +binary_sensor.* +input_boolean.* +input_number.* +``` + +ale użytkownik wskazuje konkretne encje. + +Np.: + +``` +``` + +``` +External temperature: + +HA entity: +sensor.salon_temperature +``` + +Nie robimy automatycznego pobierania całego HA. + +--- + +# 12. HA nie może być wymaganiem + +Jeżeli: + +``` +``` + +``` +HA DOWN +``` + +system nadal: + +- steruje GREE +- wykonuje harmonogram +- wykonuje automatyzacje lokalne +- używa temperatury GREE +- działa przez Web UI +- przechowuje historię + +Jedynie: + +``` +``` + +``` +External HA sensors +``` + +stają się niedostępne. + +--- + +# 13. Integracja Home Assistant + +Po stronie HA: + +``` +``` + +``` +GREE Controller + ↓ +Custom Integration + ↓ +Device + ↓ +Entities +``` + +Dla klimatyzatora: + +``` +``` + +``` +climate.gree_salon +``` + +oraz np.: + +``` +``` + +``` +sensor.gree_salon_temperature +sensor.gree_salon_target_temperature +sensor.gree_salon_power +sensor.gree_salon_connection +sensor.gree_salon_fan +``` + +--- + +# 14. Encja Climate + +Powinna być maksymalnie kompatybilna z natywnym HA. + +Obsługa: + +``` +``` + +``` +OFF +HEAT +COOL +AUTO +DRY +FAN_ONLY +``` + +oraz: + +``` +``` + +``` +target temperature +fan mode +preset mode +swing +``` + +--- + +# 15. Dodatkowe encje + +W zależności od capabilities urządzenia: + +``` +``` + +``` +switch.gree_salon_turbo +switch.gree_salon_quiet +switch.gree_salon_sleep +switch.gree_salon_light +switch.gree_salon_xfan +``` + +oraz: + +``` +``` + +``` +sensor.gree_salon_temperature +sensor.gree_salon_humidity +sensor.gree_salon_power +sensor.gree_salon_signal +``` + +Nie tworzymy encji dla funkcji, których konkretna jednostka nie obsługuje. + +--- + +# 16. Web UI — bardzo ważne + +Ponieważ telefon będzie głównym sposobem obsługi, projektujemy UI **mobile-first**, a nie desktop-first. + +Technologia: + +**Tailwind CSS** + +z: + +``` +``` + +``` +Dark mode +Light mode +System mode +``` + +czyli: + +``` +``` + +``` +Appearance + +● System +○ Dark +○ Light +``` + +--- + +# 17. Mobile UI + +Na telefonie dashboard: + +``` +``` + +``` +┌────────────────────────┐ +│ GREE Controller ☰ │ +├────────────────────────┤ +│ │ +│ SALON │ +│ │ +│ 21.4°C │ +│ │ +│ Target 21°C │ +│ │ +│ 🔥 HEATING │ +│ │ +│ − 21°C + │ +│ │ +│ HEAT AUTO │ +│ │ +│ ────────────────────── │ +│ │ +│ Fan Auto │ +│ Swing Auto │ +│ │ +│ Turbo ○ │ +│ Quiet ● │ +│ Sleep ○ │ +│ │ +└────────────────────────┘ +``` + +Bez mikroskopijnych przycisków. + +Minimalny rozmiar interaktywnych elementów powinien być około: + +``` +``` + +``` +44 × 44 px +``` + +--- + +# 18. Mobile navigation + +Dolny pasek: + +``` +``` + +``` +┌────────────────────────┐ +│ │ +│ CONTENT │ +│ │ +├────────────────────────┤ +│ 🏠 🌡 ⚙ │ +│ Home Zones More │ +└────────────────────────┘ +``` + +Na desktopie może przełączać się w sidebar. + +--- + +# 19. Dashboard + +Główne widoki: + +``` +``` + +``` +Dashboard +Devices +Zones +Heating +Automations +Schedules +History +Diagnostics +Settings +``` + +--- + +# 20. Automatyzacje + +GUI: + +``` +``` + +``` +WHEN + +Temperature +[ Salon ] + +[ < ] + +[ 20°C ] + +AND + +Time +[ 06:00 - 22:00 ] + +THEN + +Climate +[ Salon ] + +Mode: +[ HEAT ] + +Target: +[ 21°C ] +``` + +Ale silnik jest w Rust. + +HA nie jest potrzebny. + +--- + +# 21. WebSocket + +Frontend nie powinien wykonywać ciągłego: + +``` +``` + +``` +GET /state +GET /state +GET /state +``` + +Tylko: + +``` +``` + +``` +Browser + │ + │ WebSocket + ▼ +Rust + │ + ▼ +Event stream +``` + +Przykładowo: + +``` +``` + +``` +device_state_changed +temperature_changed +device_online +device_offline +automation_triggered +heating_state_changed +``` + +Dzięki temu UI będzie faktycznie „live”. + +--- + +# 22. API + +REST: + +``` +``` + +``` +/api/v1/system +/api/v1/devices +/api/v1/devices/{id} +/api/v1/devices/{id}/state +/api/v1/devices/{id}/commands + +/api/v1/zones +/api/v1/zones/{id} + +/api/v1/automations +/api/v1/schedules + +/api/v1/sensors +/api/v1/history + +/api/v1/ha +``` + +WebSocket: + +``` +``` + +``` +/api/v1/ws +``` + +--- + +# 23. Authentication + +Ponieważ będzie to osobny host, od początku robimy: + +``` +``` + +``` +API authentication +``` + +np.: + +``` +``` + +``` +Bearer token +``` + +oraz osobne konto dla Web UI. + +W przyszłości: + +``` +``` + +``` +OIDC +``` + +ale nie jest potrzebne w v1. + +--- + +# 24. GREE Protocol Layer + +Cały protokół izolujemy: + +``` +``` + +``` +gree/ +├── discovery.rs +├── protocol.rs +├── crypto.rs +├── packet.rs +├── bind.rs +├── commands.rs +├── state.rs +└── capabilities.rs +``` + +Dzięki temu reszta programu **nie wie, że pod spodem jest GREE**. + +To pozwoli kiedyś potencjalnie dodać: + +``` +``` + +``` +gree/ +haier/ +midea/ +``` + +bez przebudowy całego systemu. + +--- + +# 25. Model urządzenia + +``` +``` + +``` +Device +├── id +├── name +├── ip +├── mac +├── model +├── firmware +├── protocol +├── capabilities +├── state +├── last_seen +└── availability +``` + +--- + +# 26. Stan urządzenia + +``` +``` + +``` +DeviceState +├── power +├── mode +├── target_temperature +├── room_temperature +├── humidity +├── fan_mode +├── swing_vertical +├── swing_horizontal +├── preset +├── turbo +├── quiet +└── ... +``` + +Nie zakładamy, że każde GREE ma wszystkie pola. + +--- + +# 27. Capability discovery + +Po wykryciu urządzenia controller określa: + +``` +``` + +``` +supports_heat +supports_cool +supports_dry +supports_fan +supports_turbo +supports_quiet +supports_swing +supports_humidity +... +``` + +Frontend oraz HA integration korzystają z capabilities. + +--- + +# 28. Baza + +Na początek: + +**SQLite** + +``` +``` + +``` +devices +device_capabilities +device_state +temperature_history +events + +zones +zone_devices +zone_sensors + +automations +automation_conditions +automation_actions + +schedules + +ha_connections +ha_entities + +settings +``` + +Bez zewnętrznego PostgreSQL. + +--- + +# 29. Historia + +Zapisywane: + +``` +``` + +``` +temperature +target +mode +power +fan +heating state +source temperature +``` + +Dzięki temu UI może pokazać: + +``` +``` + +``` +Temperature + +22°C ┤ ╭────── +21°C ┤ ──────╯ +20°C ┤ + └──────────────── +``` + +oraz: + +``` +``` + +``` +GREE: 21.7°C +External: 21.2°C +Control: 21.4°C +``` + +To ostatnie jest szczególnie istotne diagnostycznie. + +--- + +# 30. Diagnostics + +Każdy GREE: + +``` +``` + +``` +Status: ONLINE + +IP: +192.168.x.x + +Last seen: +1.2 sec ago + +Latency: +12 ms + +Packets: +12342 + +Errors: +0 + +Last error: +None +``` + +Możliwość: + +``` +``` + +``` +[ Ping ] +[ Refresh state ] +[ Rediscover ] +[ Rebind ] +``` + +--- + +# 31. Kontener + +Docelowo: + +``` +``` + +``` +LXC +└── Debian + └── gree-controller + ├── /usr/bin/gree-controller + ├── /etc/gree-controller/ + └── /var/lib/gree-controller/ + └── gree.db +``` + +Service: + +``` +``` + +``` +systemd +``` + +czyli: + +``` +``` + +``` +gree-controller.service +``` + +Nie potrzebujemy Dockera. + +--- + +# 32. Aktualizacja + +Rust daje nam prosty model: + +``` +``` + +``` +binary ++ +database migrations +``` + +Możemy później zrobić: + +``` +``` + +``` +v0.1 +v0.2 +v0.3 +... +``` + +z migracjami SQLite. + +--- + +# 33. Struktura projektu + +Docelowo: + +``` +``` + +``` +gree-controller/ +│ +├── Cargo.toml +│ +├── crates/ +│ ├── gree-protocol/ +│ ├── gree-core/ +│ ├── gree-api/ +│ └── gree-ha/ +│ +├── backend/ +│ └── src/ +│ +├── frontend/ +│ ├── src/ +│ ├── public/ +│ └── ... +│ +├── ha-integration/ +│ └── custom_components/ +│ └── gree_controller/ +│ +├── migrations/ +│ +├── packaging/ +│ ├── systemd/ +│ └── debian/ +│ +└── README.md +``` + +Przy większym projekcie podział na Cargo workspace będzie bardzo wygodny. + +--- + +# 34. Najważniejsza zasada dotycząca HA + +**Nie kopiujemy logiki między Rust i HA.** + +Nie: + +``` +``` + +``` +HA heating logic ++ +Rust heating logic +``` + +Tylko: + +``` +``` + +``` + Rust Controller + │ + ┌─────────────┴──────────────┐ + │ │ + heating engine automation engine + │ │ + └─────────────┬──────────────┘ + │ + Controller API + │ + Home Assistant + │ + Entities +``` + +HA pokazuje i steruje. + +**Rust podejmuje decyzje.** + +--- + +# 35. Kolejność implementacji + +Nie zaczynałbym od GUI. + +### Faza 1 — GREE protocol + +``` +``` + +``` +[ ] discovery +[ ] bind +[ ] encryption +[ ] status +[ ] commands +[ ] capabilities +[ ] reconnect +``` + +### Faza 2 — Core + +``` +``` + +``` +[ ] Device Manager +[ ] State Manager +[ ] event system +[ ] SQLite +[ ] configuration +``` + +### Faza 3 — API + +``` +``` + +``` +[ ] REST +[ ] WebSocket +[ ] authentication +``` + +### Faza 4 — Web UI + +``` +``` + +``` +[ ] Tailwind +[ ] dark/light +[ ] mobile-first +[ ] dashboard +[ ] devices +[ ] zones +[ ] history +[ ] diagnostics +``` + +### Faza 5 — Heating Engine + +``` +``` + +``` +[ ] target +[ ] schedules +[ ] hysteresis +[ ] minimum run time +[ ] sensor weighting +[ ] sensor fallback +[ ] heating zones +[ ] prediction +``` + +### Faza 6 — Automations + +``` +``` + +``` +[ ] triggers +[ ] conditions +[ ] actions +[ ] scheduler +[ ] GUI builder +``` + +### Faza 7 — Home Assistant + +``` +``` + +``` +[ ] config flow +[ ] device discovery +[ ] climate +[ ] sensors +[ ] switches +[ ] diagnostics +[ ] external HA sensors +``` + +--- + +## Jedna rzecz, którą zmieniłbym względem pierwotnego pomysłu + +Nie nazywałbym tego nawet wewnętrznie „integracją GREE do Home Assistant”. + +To powinien być **samodzielny GREE Controller**, a Home Assistant będzie jednym z klientów. + +Wtedy mamy: + +``` +``` + +``` + GREE Controller + / | \ + / | \ + ▼ ▼ ▼ + Web UI HA REST API + │ + HA entities +``` + +I dokładnie spełniamy Twój warunek: + +**osobne środowisko jest obowiązkowe**, HA nie jest wymagane do działania, zewnętrzny czujnik HA jest tylko **opcjonalnym źródłem wspomagającym algorytm**, a użytkownik może praktycznie całe sterowanie wykonywać z telefonu przez responsywny UI. + +**Tailwind + dark/light + mobile-first** jest tutaj bardzo dobrym wyborem. Zrobiłbym nawet projekt UI od początku z założeniem „telefon jako primary, desktop jako secondary”, zamiast później próbować dopasowywać desktopowy panel do telefonu. \ No newline at end of file diff --git a/home-assistant/README.md b/home-assistant/README.md new file mode 100644 index 0000000..8843d58 --- /dev/null +++ b/home-assistant/README.md @@ -0,0 +1,111 @@ +# Home Assistant integration + +The project includes a custom Home Assistant integration under: + +```text +home-assistant/custom_components/gree_controller/ +``` + +It creates HA `climate` entities but sends every command to the standalone Rust controller. Home Assistant therefore becomes a client, while UDP/AES GREE communication remains outside HA. + +The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing. + +## Install the custom integration + +Copy the directory into your HA configuration: + +```text +/config/custom_components/gree_controller/ +``` + +Before adding the integration, open the standalone controller Web UI and go to **Settings -> Home Assistant integration access -> 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: + +- controller URL, for example `http://192.168.1.20:8787`, +- the generated controller access token. + +The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices and sending climate commands. + +## Preserve an existing entity ID + +If existing automations and dashboards use an entity such as: + +```text +climate.klima_salon +``` + +use the migration generator before switching integrations: + +```bash +./scripts/generate_ha_migration.py \ + --entity climate.klima_salon \ + --device gree-aabbccddeeff +``` + +Copy the generated JSON file to: + +```text +/config/gree_controller_entities.json +``` + +The custom integration reads this file and requests the exact same `climate.*` entity ID. + +Home Assistant cannot have two active entities with the same `entity_id`. Therefore the old/default GREE entity must release `climate.klima_salon` before the new integration is loaded. Do not run both integrations against the same entity ID. + +Safe order: + +1. Configure and test the standalone Rust controller first. +2. Confirm the AC can be controlled from the GREE Controller web UI. +3. Generate and copy `gree_controller_entities.json`. +4. Disable or remove the old/default GREE integration entry in Home Assistant. +5. If its old entity registry record remains, remove that stale entity from HA after the old integration is unloaded. +6. Install/restart the `gree_controller` custom integration. +7. Verify that `climate.klima_salon` exists and controls the AC through the Rust service. +8. Check existing dashboards, scripts and automations. Because the entity ID is unchanged, references to `climate.klima_salon` do not need to be rewritten. + +The integration deliberately fails setup on an entity-ID conflict instead of silently creating `climate.klima_salon_2`. + +## Multiple devices + +Use repeated mappings: + +```bash +./scripts/generate_ha_migration.py \ + --map climate.klima_salon=gree-aabbccddeeff \ + --map climate.klima_sypialnia=gree-112233445566 +``` + +## Optional validation + +Validate the target controller device: + +```bash +./scripts/generate_ha_migration.py \ + --entity climate.klima_salon \ + --controller-url http://192.168.1.20:8787 \ + --controller-token CONTROLLER_TOKEN +``` + +Validate that the source HA entity currently exists as well: + +```bash +./scripts/generate_ha_migration.py \ + --entity climate.klima_salon \ + --device gree-aabbccddeeff \ + --ha-url http://homeassistant.local:8123 \ + --ha-token HOME_ASSISTANT_LONG_LIVED_TOKEN +``` + +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: + +```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. diff --git a/home-assistant/custom_components/gree_controller/__init__.py b/home-assistant/custom_components/gree_controller/__init__.py new file mode 100644 index 0000000..589aa15 --- /dev/null +++ b/home-assistant/custom_components/gree_controller/__init__.py @@ -0,0 +1,51 @@ +"""Home Assistant bridge for the standalone GREE Controller service.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import GreeControllerApiError, GreeControllerClient +from .const import CONF_TOKEN, CONF_URL, PLATFORMS +from .coordinator import GreeControllerCoordinator +from .entity_map import async_load_entity_map + + +@dataclass +class GreeControllerRuntimeData: + """Runtime objects kept on the config entry.""" + + client: GreeControllerClient + coordinator: GreeControllerCoordinator + entity_map: dict[str, str] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up GREE Controller from a config entry.""" + client = GreeControllerClient( + async_get_clientsession(hass), + entry.data[CONF_URL], + entry.data.get(CONF_TOKEN, ""), + ) + try: + await client.devices() + except GreeControllerApiError as err: + if "authentication" in str(err).lower(): + raise ConfigEntryAuthFailed(str(err)) from err + raise ConfigEntryNotReady(str(err)) from err + + coordinator = GreeControllerCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + entity_map = await async_load_entity_map(hass) + entry.runtime_data = GreeControllerRuntimeData(client, coordinator, entity_map) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload the config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/home-assistant/custom_components/gree_controller/api.py b/home-assistant/custom_components/gree_controller/api.py new file mode 100644 index 0000000..03a4f46 --- /dev/null +++ b/home-assistant/custom_components/gree_controller/api.py @@ -0,0 +1,76 @@ +"""HTTP client for the standalone GREE Controller service.""" + +from __future__ import annotations + +from typing import Any + +from aiohttp import ClientError, ClientSession + + +class GreeControllerApiError(Exception): + """Raised when the controller API cannot be used.""" + + +class GreeControllerClient: + """Small async client backed by Home Assistant's shared ClientSession.""" + + def __init__(self, session: ClientSession, base_url: str, token: str = "") -> None: + self._session = session + self._base_url = base_url.rstrip("/") + self._token = token.strip() + + @property + def base_url(self) -> str: + """Return the normalized controller URL.""" + return self._base_url + + def _headers(self) -> dict[str, str]: + headers = {"Accept": "application/json"} + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + return headers + + async def _request(self, method: str, path: str, **kwargs: Any) -> Any: + try: + async with self._session.request( + method, + f"{self._base_url}{path}", + headers=self._headers(), + timeout=10, + **kwargs, + ) as response: + if response.status == 401: + raise GreeControllerApiError("Controller authentication failed") + if response.status >= 400: + try: + body = await response.json() + message = body.get("error", f"HTTP {response.status}") + except (ValueError, TypeError): + message = f"HTTP {response.status}" + raise GreeControllerApiError(message) + if response.status == 204: + return None + return await response.json() + except GreeControllerApiError: + raise + except (ClientError, TimeoutError) as err: + raise GreeControllerApiError(str(err)) from err + + async def health(self) -> dict[str, Any]: + """Return the public controller health payload.""" + return await self._request("GET", "/api/health") + + async def devices(self) -> list[dict[str, Any]]: + """Return all controller devices.""" + data = await self._request("GET", "/api/integrations/home-assistant/devices") + if not isinstance(data, list): + raise GreeControllerApiError("Controller returned an invalid devices payload") + return data + + async def command(self, device_id: str, payload: dict[str, Any]) -> dict[str, Any]: + """Send a device command and return the updated device state.""" + return await self._request( + "POST", + f"/api/integrations/home-assistant/devices/{device_id}/command", + json=payload, + ) diff --git a/home-assistant/custom_components/gree_controller/climate.py b/home-assistant/custom_components/gree_controller/climate.py new file mode 100644 index 0000000..22e3f2c --- /dev/null +++ b/home-assistant/custom_components/gree_controller/climate.py @@ -0,0 +1,204 @@ +"""Climate entities proxied through the standalone GREE Controller.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + SWING_OFF, + SWING_ON, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import GreeControllerRuntimeData +from .const import DOMAIN +from .coordinator import GreeControllerCoordinator + +_LOGGER = logging.getLogger(__name__) + +MODE_TO_HA = { + "auto": HVACMode.AUTO, + "cool": HVACMode.COOL, + "dry": HVACMode.DRY, + "fan": HVACMode.FAN_ONLY, + "heat": HVACMode.HEAT, +} +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()} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Create climate entities for all devices exposed by the controller.""" + runtime: GreeControllerRuntimeData = entry.runtime_data + registry = er.async_get(hass) + entities: list[GreeControllerClimate] = [] + + for device_id, device in runtime.coordinator.data.items(): + desired_entity_id = runtime.entity_map.get(device_id) + unique_id = f"{device_id}-climate" + if desired_entity_id: + if not desired_entity_id.startswith("climate."): + raise ConfigEntryError(f"Mapped entity ID must use the climate domain: {desired_entity_id}") + existing = registry.async_get(desired_entity_id) + if existing and not (existing.platform == DOMAIN and existing.unique_id == unique_id): + raise ConfigEntryError( + f"Entity ID {desired_entity_id} is still reserved by integration {existing.platform}. " + "Disable/remove the previous GREE integration and remove its entity registry entry before takeover." + ) + if hass.states.get(desired_entity_id) is not None and existing is None: + raise ConfigEntryError( + f"Entity ID {desired_entity_id} is still active in Home Assistant. " + "Unload the previous integration before takeover." + ) + entities.append( + GreeControllerClimate(runtime.coordinator, device_id, desired_entity_id) + ) + + async_add_entities(entities) + + +class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity): + """Home Assistant climate entity controlled through the Rust service.""" + + _attr_has_entity_name = True + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = 8.0 + _attr_max_temp = 32.0 + _attr_target_temperature_step = 0.5 + _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_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE + | ClimateEntityFeature.TURN_ON + | ClimateEntityFeature.TURN_OFF + ) + + def __init__( + self, + coordinator: GreeControllerCoordinator, + device_id: str, + requested_entity_id: str | None, + ) -> None: + super().__init__(coordinator) + self._device_id = device_id + self._attr_unique_id = f"{device_id}-climate" + self._attr_name = None + if requested_entity_id: + # This is intentionally limited to same-domain takeover migrations. + # The setup guard above prevents accidental collisions. + self.entity_id = requested_entity_id + + @property + def _device(self) -> dict[str, Any]: + return self.coordinator.data.get(self._device_id, {}) + + @property + def available(self) -> bool: + return super().available and bool(self._device.get("online", False)) + + @property + def device_info(self) -> DeviceInfo: + device = self._device + return DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + name=str(device.get("name") or self._device_id), + manufacturer="GREE", + model=str(device.get("model") or "GREE HVAC"), + sw_version=str(device.get("firmware") or "") or None, + ) + + @property + def current_temperature(self) -> float | None: + value = self._device.get("current_temperature") + return float(value) if value is not None else None + + @property + def target_temperature(self) -> float | None: + value = self._device.get("target_temperature") + return float(value) if value is not None else None + + @property + def hvac_mode(self) -> HVACMode: + device = self._device + if not device.get("power", False): + return HVACMode.OFF + return MODE_TO_HA.get(str(device.get("mode", "auto")), HVACMode.AUTO) + + @property + def fan_mode(self) -> str: + return FAN_TO_NAME.get(int(self._device.get("fan_speed", 0)), "auto") + + @property + def swing_mode(self) -> str: + return SWING_ON if self._device.get("swing_vertical", False) else SWING_OFF + + @property + def swing_horizontal_mode(self) -> str: + return SWING_ON if self._device.get("swing_horizontal", False) else SWING_OFF + + @property + def extra_state_attributes(self) -> dict[str, Any]: + device = self._device + return { + "controller_device_id": self._device_id, + "controller_online": bool(device.get("online", False)), + "quiet": bool(device.get("quiet", False)), + "turbo": bool(device.get("turbo", False)), + "light": bool(device.get("light", False)), + "last_seen": device.get("last_seen"), + } + + async def _command(self, payload: dict[str, Any]) -> None: + await self.coordinator.client.command(self._device_id, payload) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self) -> None: + await self._command({"power": True}) + + async def async_turn_off(self) -> None: + await self._command({"power": False}) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + if hvac_mode == HVACMode.OFF: + await self._command({"power": False}) + return + mode = HA_TO_MODE.get(hvac_mode) + if mode is None: + return + await self._command({"power": True, "mode": mode}) + + async def async_set_temperature(self, **kwargs: Any) -> None: + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is not None: + await self._command({"target_temperature": float(temperature)}) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + if fan_mode in NAME_TO_FAN: + 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}) + + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + await self._command({"swing_horizontal": swing_horizontal_mode == SWING_ON}) diff --git a/home-assistant/custom_components/gree_controller/config_flow.py b/home-assistant/custom_components/gree_controller/config_flow.py new file mode 100644 index 0000000..1091bad --- /dev/null +++ b/home-assistant/custom_components/gree_controller/config_flow.py @@ -0,0 +1,46 @@ +"""UI configuration flow for GREE Controller.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import GreeControllerApiError, GreeControllerClient +from .const import CONF_TOKEN, CONF_URL, DOMAIN + + +class GreeControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Configure a standalone GREE Controller instance.""" + + VERSION = 1 + + async def async_step_user(self, user_input: dict[str, Any] | None = None): + """Handle the initial connection form.""" + errors: dict[str, str] = {} + if user_input is not None: + url = str(user_input[CONF_URL]).strip().rstrip("/") + token = str(user_input[CONF_TOKEN]).strip() + client = GreeControllerClient(async_get_clientsession(self.hass), url, token) + try: + await client.devices() + except GreeControllerApiError as err: + errors["base"] = "invalid_auth" if "authentication" in str(err).lower() else "cannot_connect" + else: + await self.async_set_unique_id("gree-controller") + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="GREE Controller", + data={CONF_URL: url, CONF_TOKEN: token}, + ) + + schema = vol.Schema( + { + vol.Required(CONF_URL, default="http://gree-controller:8787"): str, + vol.Required(CONF_TOKEN): str, + } + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/home-assistant/custom_components/gree_controller/const.py b/home-assistant/custom_components/gree_controller/const.py new file mode 100644 index 0000000..ad5f635 --- /dev/null +++ b/home-assistant/custom_components/gree_controller/const.py @@ -0,0 +1,11 @@ +"""Constants for the GREE Controller Home Assistant integration.""" + +from homeassistant.const import Platform + +DOMAIN = "gree_controller" +PLATFORMS = [Platform.CLIMATE] + +CONF_URL = "url" +CONF_TOKEN = "token" +ENTITY_MAP_FILE = "gree_controller_entities.json" +DEFAULT_SCAN_INTERVAL_SECONDS = 10 diff --git a/home-assistant/custom_components/gree_controller/coordinator.py b/home-assistant/custom_components/gree_controller/coordinator.py new file mode 100644 index 0000000..9149e02 --- /dev/null +++ b/home-assistant/custom_components/gree_controller/coordinator.py @@ -0,0 +1,36 @@ +"""Data coordinator for the GREE Controller integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import GreeControllerApiError, GreeControllerClient +from .const import DEFAULT_SCAN_INTERVAL_SECONDS, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]): + """Poll the standalone controller and cache device states.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client: GreeControllerClient) -> None: + super().__init__( + hass, + logger=_LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS), + ) + self.client = client + + async def _async_update_data(self) -> dict[str, dict]: + try: + devices = await self.client.devices() + except GreeControllerApiError as err: + raise UpdateFailed(str(err)) from err + return {str(device["id"]): device for device in devices if device.get("id")} diff --git a/home-assistant/custom_components/gree_controller/entity_map.py b/home-assistant/custom_components/gree_controller/entity_map.py new file mode 100644 index 0000000..30c7605 --- /dev/null +++ b/home-assistant/custom_components/gree_controller/entity_map.py @@ -0,0 +1,34 @@ +"""Load optional legacy entity ID mappings for GREE Controller.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from homeassistant.core import HomeAssistant + +from .const import ENTITY_MAP_FILE + + +def _load(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as handle: + data: Any = json.load(handle) + items = data.get("entities", []) if isinstance(data, dict) else [] + result: dict[str, str] = {} + for item in items: + if not isinstance(item, dict): + continue + device_id = str(item.get("device_id", "")).strip() + entity_id = str(item.get("entity_id", "")).strip() + if device_id and entity_id.startswith("climate."): + result[device_id] = entity_id + return result + + +async def async_load_entity_map(hass: HomeAssistant) -> dict[str, str]: + """Load the optional mapping file without blocking Home Assistant's loop.""" + path = Path(hass.config.path(ENTITY_MAP_FILE)) + return await hass.async_add_executor_job(_load, path) diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json new file mode 100644 index 0000000..2b38e70 --- /dev/null +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "gree_controller", + "name": "GREE Controller", + "version": "0.3.3", + "config_flow": true, + "integration_type": "hub", + "iot_class": "local_polling", + "single_config_entry": true +} diff --git a/home-assistant/custom_components/gree_controller/translations/en.json b/home-assistant/custom_components/gree_controller/translations/en.json new file mode 100644 index 0000000..898f7a1 --- /dev/null +++ b/home-assistant/custom_components/gree_controller/translations/en.json @@ -0,0 +1,22 @@ +{ + "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" + } + } +} diff --git a/home-assistant/custom_components/gree_controller/translations/pl.json b/home-assistant/custom_components/gree_controller/translations/pl.json new file mode 100644 index 0000000..bf877a9 --- /dev/null +++ b/home-assistant/custom_components/gree_controller/translations/pl.json @@ -0,0 +1,22 @@ +{ + "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" + } + } +} diff --git a/home-assistant/generated/gree_controller_entities.example.json b/home-assistant/generated/gree_controller_entities.example.json new file mode 100644 index 0000000..ad2b638 --- /dev/null +++ b/home-assistant/generated/gree_controller_entities.example.json @@ -0,0 +1,9 @@ +{ + "version": 1, + "entities": [ + { + "entity_id": "climate.klima_salon", + "device_id": "gree-aabbccddeeff" + } + ] +} diff --git a/install-lxc.sh b/install-lxc.sh new file mode 100755 index 0000000..b69876c --- /dev/null +++ b/install-lxc.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "Run as root: sudo ./install-lxc.sh" >&2; exit 1; } +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +apt-get update +apt-get install -y --no-install-recommends build-essential curl ca-certificates pkg-config +if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + source /root/.cargo/env +fi +cargo build --release + +id gree-controller >/dev/null 2>&1 || useradd --system --home /var/lib/gree-controller --shell /usr/sbin/nologin gree-controller +install -d -o gree-controller -g gree-controller -m 0750 /var/lib/gree-controller +install -d -o root -g root -m 0755 /opt/gree-controller +install -o root -g root -m 0755 target/release/gree-controller /opt/gree-controller/gree-controller +install -o root -g root -m 0644 systemd/gree-controller.service /etc/systemd/system/gree-controller.service + +if [[ ! -f /etc/gree-controller.env ]]; then + token="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" + cat > /etc/gree-controller.env <.json`, update `meta`, translate values, and rebuild. + +See `docs/LOCALIZATION.md` for the full format and validation rules. diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..d5df64f --- /dev/null +++ b/lang/en.json @@ -0,0 +1,231 @@ +{ + "meta": { + "code": "en", + "name": "English", + "native_name": "English", + "locale": "en-GB" + }, + "translations": { + "meta.description": "Local GREE air conditioner controller", + "status.connecting": "Connecting…", + "status.connected": "Connected", + "status.disconnected": "Disconnected", + "status.connectionError": "Connection error", + "status.online": "Online", + "status.offline": "Offline", + "controls.language": "Language", + "controls.theme": "Theme", + "theme.system": "System", + "theme.light": "Light", + "theme.dark": "Dark", + "nav.navigation": "Navigation", + "nav.dashboard": "Dashboard", + "nav.devices": "Devices", + "nav.zones": "Zones", + "nav.history": "History", + "nav.more": "More", + "nav.schedules": "Schedules", + "nav.automations": "Automations", + "nav.settings": "Settings", + "nav.logs": "Events", + "nav.logsAndEvents": "Events and logs", + "actions.refresh": "Refresh", + "actions.discover": "Discover", + "actions.discovering": "Searching…", + "actions.add": "Add", + "actions.show": "Show", + "actions.save": "Save", + "actions.cancel": "Cancel", + "actions.edit": "Edit", + "actions.delete": "Delete", + "actions.read": "Read", + "actions.bind": "Bind", + "actions.noChange": "No change", + "actions.turnOn": "Turn on", + "actions.turnOff": "Turn off", + "dashboard.home": "Home", + "dashboard.title": "Comfort under control", + "dashboard.loading": "Loading devices…", + "dashboard.quickControl": "Quick control", + "dashboard.summary": "{online} of {total} devices online{active}.", + "dashboard.summaryActive": ", {count} running", + "dashboard.empty": "No devices. Start discovery or add a device manually.", + "dashboard.metricOnline": "Online", + "dashboard.metricActive": "Active", + "dashboard.metricDemand": "Zones requesting", + "devices.lan": "LAN network", + "devices.addManual": "Add manually", + "devices.add": "Add device", + "devices.ipAddress": "IP address", + "devices.protocol": "Protocol", + "devices.keyOptional": "Key (optional)", + "devices.keyPlaceholder": "Leave empty for automatic bind", + "devices.simulated": "Simulated device", + "devices.simulator": "simulator", + "devices.power": "Power", + "devices.currentTemperature": "Temperature", + "devices.outdoor": "outside", + "devices.quiet": "Quiet", + "devices.swing": "Swing", + "devices.turbo": "Turbo", + "devices.emptyTitle": "No devices", + "devices.emptyText": "Use Discover while the controller is on the same LAN.", + "devices.readDone": "Device state updated", + "devices.bound": "Device bound", + "devices.added": "Device added", + "zones.automation": "Automation", + "zones.new": "New zone", + "zones.description": "A zone controls a device using temperature, hysteresis and minimum cycle time.", + "zones.hysteresis": "Hysteresis °C", + "zones.source": "Temperature strategy", + "zones.greeSensor": "GREE only", + "zones.combinedSensor": "GREE + room sensor", + "zones.externalSensor": "Room sensor only", + "zones.minOn": "Min. ON (s)", + "zones.minOff": "Min. OFF (s)", + "zones.greeSource": "GREE sensor", + "zones.combinedSource": "GREE + room sensor", + "zones.externalSource": "room sensor", + "zones.measurement": "Control temperature", + "zones.demand": "Demand", + "zones.emptyTitle": "No zones", + "zones.emptyText": "Add a zone to control temperature automatically.", + "zones.roomSensorEntity": "Room sensor entity_id", + "zones.roomSensorWeight": "Room sensor weight %", + "zones.maxDifference": "Max. sensor difference °C", + "zones.sensorHelp": "This sensor belongs only to this zone. If it becomes unavailable, the controller automatically falls back to the GREE sensor.", + "zones.greeTemp": "GREE", + "zones.externalTemp": "Room", + "zones.usedSource": "used", + "zones.sourceDevice": "GREE", + "zones.sourceExternal": "room sensor", + "zones.sourceCombined": "combined", + "zones.sourceFallback": "GREE fallback", + "zones.sourceDiscrepancy": "GREE fallback: sensor mismatch", + "zones.sourceUnavailable": "unavailable", + "schedules.calendar": "Calendar", + "schedules.weekdays": "Weekdays", + "schedules.crossMidnight": "Intervals crossing midnight are supported", + "schedules.emptyTitle": "No schedules", + "schedules.emptyText": "Set a temperature by day and time.", + "automations.rules": "Rules", + "automations.namePlaceholder": "Emergency cooling", + "automations.trigger": "Trigger", + "automations.tempAbove": "Temperature above", + "automations.tempBelow": "Temperature below", + "automations.time": "Time", + "automations.measurementDevice": "Measurement device", + "automations.thresholdC": "Threshold °C", + "automations.action": "Action", + "automations.triggerAt": "at {time}", + "automations.triggerAbove": "above {temperature}", + "automations.triggerBelow": "below {temperature}", + "automations.triggerSummary": "Trigger {trigger} · action: {device}", + "automations.last": "Last", + "automations.emptyTitle": "No automations", + "automations.emptyText": "Add a temperature or time rule.", + "history.measurements": "Measurements", + "history.title": "Temperature history", + "history.6h": "6 hours", + "history.24h": "24 hours", + "history.7d": "7 days", + "history.30d": "30 days", + "history.noData": "No measurements for the selected period", + "settings.system": "System", + "settings.controller": "Controller", + "settings.clientId": "Client identifier", + "settings.pollInterval": "Poll interval (s)", + "settings.zoneInterval": "Zone interval (s)", + "settings.broadcast": "Broadcast address", + "settings.discoveryTimeout": "Discovery timeout (ms)", + "settings.simulationMode": "Simulation mode", + "settings.haTokenKeep": "Leave empty to keep the saved token", + "settings.haTokenSaved": "Token saved — leave empty to keep it", + "settings.haLongLivedToken": "Long-Lived Access Token", + "settings.defaultEntity": "Default entity_id", + "settings.testHa": "Test HA", + "settings.systemState": "System status", + "settings.version": "Version", + "settings.uptime": "Uptime", + "settings.apiAuth": "API authentication", + "settings.enabled": "enabled", + "settings.disabled": "disabled", + "logs.diagnostics": "Diagnostics", + "logs.emptyTitle": "No events", + "logs.emptyText": "The event log is empty.", + "common.name": "Name", + "common.zone": "Zone", + "common.device": "Device", + "common.mode": "Mode", + "common.target": "Target", + "common.targetC": "Target °C", + "common.temperature": "Temperature", + "common.temperatureC": "Temperature °C", + "common.schedule": "Schedule", + "common.automation": "Automation", + "common.enabled": "Enabled", + "common.disabled": "Disabled", + "common.active": "Active", + "common.from": "From", + "common.to": "To", + "common.power": "Power", + "common.on": "ON", + "common.off": "OFF", + "common.noDevice": "No device", + "common.noZone": "No zone", + "common.cooldown": "Cooldown", + "common.cooldownSeconds": "Cooldown (s)", + "common.removed": "Removed", + "common.updated": "Data refreshed", + "common.saved": "Saved", + "placeholder.livingRoom": "Living room", + "placeholder.night": "Night", + "mode.auto": "Auto", + "mode.cool": "Cooling", + "mode.dry": "Dry", + "mode.fan": "Fan", + "mode.heat": "Heating", + "fan.auto": "Auto", + "fan.low": "Low", + "fan.mediumLow": "Medium-low", + "fan.medium": "Medium", + "fan.mediumHigh": "Medium-high", + "fan.high": "High", + "auth.required": "Authentication required", + "auth.token": "Access token", + "auth.connect": "Connect", + "auth.invalid": "Invalid access token", + "error.http": "HTTP error {status}", + "confirm.delete": "Delete {label}?", + "label.device": "device", + "label.zone": "zone", + "label.schedule": "schedule", + "label.automation": "automation", + "toast.found": "Found: {count}", + "toast.haTemperature": "Home Assistant: {temperature}°C", + "day.1": "Mon", + "day.2": "Tue", + "day.3": "Wed", + "day.4": "Thu", + "day.5": "Fri", + "day.6": "Sat", + "day.7": "Sun", + "settings.haSensorInput": "Home Assistant sensor input", + "settings.haSensorInputHint": "Optional. Used only when a room zone reads an external Home Assistant temperature sensor.", + "settings.haIntegrationAccess": "Home Assistant integration access", + "settings.haIntegrationHint": "Create a controller token and paste it into the GREE Controller integration in Home Assistant.", + "settings.newToken": "Create new token", + "settings.noTokens": "No integration tokens", + "settings.noTokensHint": "Create a token before adding GREE Controller to Home Assistant.", + "settings.tokenCreated": "Token created", + "settings.tokenCreatedHint": "Copy this token now. It will not be shown again.", + "settings.created": "Created", + "actions.copy": "Copy", + "actions.revoke": "Revoke", + "actions.done": "Done", + "confirm.revokeToken": "Revoke this Home Assistant access token?", + "toast.tokenCreated": "Home Assistant access token created", + "toast.tokenCopied": "Token copied", + "toast.tokenRevoked": "Token revoked" + } +} diff --git a/lang/pl.json b/lang/pl.json new file mode 100644 index 0000000..dc57ab9 --- /dev/null +++ b/lang/pl.json @@ -0,0 +1,231 @@ +{ + "meta": { + "code": "pl", + "name": "Polish", + "native_name": "Polski", + "locale": "pl-PL" + }, + "translations": { + "meta.description": "Lokalny sterownik klimatyzatorów GREE", + "status.connecting": "Łączenie…", + "status.connected": "Połączono", + "status.disconnected": "Rozłączono", + "status.connectionError": "Błąd połączenia", + "status.online": "Online", + "status.offline": "Offline", + "controls.language": "Język", + "controls.theme": "Motyw", + "theme.system": "System", + "theme.light": "Jasny", + "theme.dark": "Ciemny", + "nav.navigation": "Nawigacja", + "nav.dashboard": "Pulpit", + "nav.devices": "Urządzenia", + "nav.zones": "Strefy", + "nav.history": "Historia", + "nav.more": "Więcej", + "nav.schedules": "Harmonogramy", + "nav.automations": "Automatyzacje", + "nav.settings": "Ustawienia", + "nav.logs": "Zdarzenia", + "nav.logsAndEvents": "Zdarzenia i logi", + "actions.refresh": "Odśwież", + "actions.discover": "Wykryj", + "actions.discovering": "Szukam…", + "actions.add": "Dodaj", + "actions.show": "Pokaż", + "actions.save": "Zapisz", + "actions.cancel": "Anuluj", + "actions.edit": "Edytuj", + "actions.delete": "Usuń", + "actions.read": "Odczyt", + "actions.bind": "Bind", + "actions.noChange": "Bez zmiany", + "actions.turnOn": "Włącz", + "actions.turnOff": "Wyłącz", + "dashboard.home": "Dom", + "dashboard.title": "Komfort pod kontrolą", + "dashboard.loading": "Wczytywanie urządzeń…", + "dashboard.quickControl": "Szybkie sterowanie", + "dashboard.summary": "{online} z {total} urządzeń online{active}.", + "dashboard.summaryActive": ", {count} pracuje", + "dashboard.empty": "Brak urządzeń. Uruchom wykrywanie lub dodaj urządzenie ręcznie.", + "dashboard.metricOnline": "Online", + "dashboard.metricActive": "Aktywne", + "dashboard.metricDemand": "Strefy z żądaniem", + "devices.lan": "Sieć LAN", + "devices.addManual": "Dodaj ręcznie", + "devices.add": "Dodaj urządzenie", + "devices.ipAddress": "Adres IP", + "devices.protocol": "Protokół", + "devices.keyOptional": "Klucz (opcjonalnie)", + "devices.keyPlaceholder": "Zostaw puste — bind automatyczny", + "devices.simulated": "Urządzenie symulowane", + "devices.simulator": "symulator", + "devices.power": "Zasilanie", + "devices.currentTemperature": "Temperatura", + "devices.outdoor": "na zewnątrz", + "devices.quiet": "Cichy", + "devices.swing": "Swing", + "devices.turbo": "Turbo", + "devices.emptyTitle": "Brak urządzeń", + "devices.emptyText": "Użyj przycisku Wykryj w tej samej sieci LAN.", + "devices.readDone": "Odczyt zakończony", + "devices.bound": "Urządzenie powiązane", + "devices.added": "Urządzenie dodane", + "zones.automation": "Automatyka", + "zones.new": "Nowa strefa", + "zones.description": "Strefa steruje urządzeniem według temperatury, histerezy i minimalnego czasu cyklu.", + "zones.hysteresis": "Histereza °C", + "zones.source": "Strategia temperatury", + "zones.greeSensor": "Tylko GREE", + "zones.combinedSensor": "GREE + czujnik pokojowy", + "zones.externalSensor": "Tylko czujnik pokojowy", + "zones.minOn": "Min. ON (s)", + "zones.minOff": "Min. OFF (s)", + "zones.greeSource": "sensor GREE", + "zones.combinedSource": "GREE + czujnik pokojowy", + "zones.externalSource": "czujnik pokojowy", + "zones.measurement": "Temperatura sterująca", + "zones.demand": "Żądanie", + "zones.emptyTitle": "Brak stref", + "zones.emptyText": "Dodaj strefę, aby sterować temperaturą automatycznie.", + "zones.roomSensorEntity": "entity_id czujnika pokojowego", + "zones.roomSensorWeight": "Waga czujnika pokojowego %", + "zones.maxDifference": "Maks. różnica czujników °C", + "zones.sensorHelp": "Ten czujnik jest przypisany tylko do tej strefy. Gdy przestanie być dostępny, kontroler automatycznie wróci do sensora GREE.", + "zones.greeTemp": "GREE", + "zones.externalTemp": "Pokój", + "zones.usedSource": "użyte", + "zones.sourceDevice": "GREE", + "zones.sourceExternal": "czujnik pokojowy", + "zones.sourceCombined": "połączona", + "zones.sourceFallback": "fallback GREE", + "zones.sourceDiscrepancy": "fallback GREE: rozbieżność", + "zones.sourceUnavailable": "niedostępna", + "schedules.calendar": "Kalendarz", + "schedules.weekdays": "Dni tygodnia", + "schedules.crossMidnight": "Obsługa przedziałów przez północ", + "schedules.emptyTitle": "Brak harmonogramów", + "schedules.emptyText": "Ustaw temperaturę zależnie od dnia i godziny.", + "automations.rules": "Reguły", + "automations.namePlaceholder": "Chłodzenie awaryjne", + "automations.trigger": "Wyzwalacz", + "automations.tempAbove": "Temperatura powyżej", + "automations.tempBelow": "Temperatura poniżej", + "automations.time": "Godzina", + "automations.measurementDevice": "Urządzenie pomiarowe", + "automations.thresholdC": "Próg °C", + "automations.action": "Akcja", + "automations.triggerAt": "o {time}", + "automations.triggerAbove": "powyżej {temperature}", + "automations.triggerBelow": "poniżej {temperature}", + "automations.triggerSummary": "Wyzwalacz {trigger} · akcja: {device}", + "automations.last": "Ostatnio", + "automations.emptyTitle": "Brak automatyzacji", + "automations.emptyText": "Dodaj regułę temperatury lub czasu.", + "history.measurements": "Pomiary", + "history.title": "Historia temperatury", + "history.6h": "6 godzin", + "history.24h": "24 godziny", + "history.7d": "7 dni", + "history.30d": "30 dni", + "history.noData": "Brak pomiarów dla wybranego okresu", + "settings.system": "System", + "settings.controller": "Kontroler", + "settings.clientId": "Identyfikator klienta", + "settings.pollInterval": "Interwał odczytu (s)", + "settings.zoneInterval": "Interwał stref (s)", + "settings.broadcast": "Adres broadcast", + "settings.discoveryTimeout": "Timeout wykrywania (ms)", + "settings.simulationMode": "Tryb symulacji", + "settings.haTokenKeep": "Pozostaw puste, aby zachować zapisany token", + "settings.haTokenSaved": "Token zapisany — pozostaw puste", + "settings.haLongLivedToken": "Długotrwały token dostępu", + "settings.defaultEntity": "Domyślny entity_id", + "settings.testHa": "Test HA", + "settings.systemState": "Stan systemu", + "settings.version": "Wersja", + "settings.uptime": "Czas pracy", + "settings.apiAuth": "Autoryzacja API", + "settings.enabled": "włączona", + "settings.disabled": "wyłączona", + "logs.diagnostics": "Diagnostyka", + "logs.emptyTitle": "Brak zdarzeń", + "logs.emptyText": "Log jest pusty.", + "common.name": "Nazwa", + "common.zone": "Strefa", + "common.device": "Urządzenie", + "common.mode": "Tryb", + "common.target": "Cel", + "common.targetC": "Cel °C", + "common.temperature": "Temperatura", + "common.temperatureC": "Temperatura °C", + "common.schedule": "Harmonogram", + "common.automation": "Automatyzacja", + "common.enabled": "Aktywna", + "common.disabled": "Wyłączona", + "common.active": "Aktywna", + "common.from": "Od", + "common.to": "Do", + "common.power": "Zasilanie", + "common.on": "WŁ.", + "common.off": "WYŁ.", + "common.noDevice": "Brak urządzenia", + "common.noZone": "Brak strefy", + "common.cooldown": "Przerwa", + "common.cooldownSeconds": "Przerwa (s)", + "common.removed": "Usunięto", + "common.updated": "Dane odświeżone", + "common.saved": "Zapisano", + "placeholder.livingRoom": "Salon", + "placeholder.night": "Noc", + "mode.auto": "Auto", + "mode.cool": "Chłodzenie", + "mode.dry": "Osuszanie", + "mode.fan": "Nawiew", + "mode.heat": "Grzanie", + "fan.auto": "Auto", + "fan.low": "Niski", + "fan.mediumLow": "Śr.-niski", + "fan.medium": "Średni", + "fan.mediumHigh": "Śr.-wysoki", + "fan.high": "Wysoki", + "auth.required": "Wymagane uwierzytelnienie", + "auth.token": "Token dostępu", + "auth.connect": "Połącz", + "auth.invalid": "Nieprawidłowy token dostępu", + "error.http": "Błąd HTTP {status}", + "confirm.delete": "Usunąć {label}?", + "label.device": "urządzenie", + "label.zone": "strefę", + "label.schedule": "harmonogram", + "label.automation": "automatyzację", + "toast.found": "Znaleziono: {count}", + "toast.haTemperature": "Home Assistant: {temperature}°C", + "day.1": "Pn", + "day.2": "Wt", + "day.3": "Śr", + "day.4": "Cz", + "day.5": "Pt", + "day.6": "So", + "day.7": "Nd", + "settings.haSensorInput": "Źródło czujników Home Assistant", + "settings.haSensorInputHint": "Opcjonalne. Używane tylko wtedy, gdy strefa korzysta z zewnętrznego czujnika temperatury z Home Assistant.", + "settings.haIntegrationAccess": "Dostęp integracji Home Assistant", + "settings.haIntegrationHint": "Utwórz token kontrolera i wklej go w integracji GREE Controller w Home Assistant.", + "settings.newToken": "Utwórz nowy token", + "settings.noTokens": "Brak tokenów integracji", + "settings.noTokensHint": "Utwórz token przed dodaniem GREE Controller do Home Assistant.", + "settings.tokenCreated": "Token utworzony", + "settings.tokenCreatedHint": "Skopiuj token teraz. Nie będzie można wyświetlić go ponownie.", + "settings.created": "Utworzono", + "actions.copy": "Kopiuj", + "actions.revoke": "Unieważnij", + "actions.done": "Gotowe", + "confirm.revokeToken": "Unieważnić ten token dostępu Home Assistant?", + "toast.tokenCreated": "Utworzono token dostępu Home Assistant", + "toast.tokenCopied": "Token skopiowany", + "toast.tokenRevoked": "Token unieważniony" + } +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..43aa738 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,14 @@ +# Operational scripts + +All operator-facing scripts live in this directory. + +- `install.sh` — first installation on a Debian/Ubuntu systemd host or LXC container. +- `update.sh` — safe in-place update with SQLite/config/binary backup and automatic rollback on failed health check. +- `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`. +- `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. + +`build.rs` remains in the package root because Cargo requires the build script at that location; it is not an operator script. diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..6b75fad --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# Shared helpers for GREE Controller operational scripts. +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SERVICE_NAME="gree-controller.service" +SERVICE_USER="gree-controller" +SERVICE_GROUP="gree-controller" +INSTALL_DIR="/opt/gree-controller" +INSTALL_BINARY="$INSTALL_DIR/gree-controller" +DATA_DIR="/var/lib/gree-controller" +ENV_FILE="/etc/gree-controller.env" +SERVICE_FILE="/etc/systemd/system/$SERVICE_NAME" +BACKUP_ROOT="/var/backups/gree-controller" + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +require_root() { + [[ ${EUID:-$(id -u)} -eq 0 ]] || fail "Run this script as root (for example: sudo $0)." +} + +require_systemd() { + command -v systemctl >/dev/null 2>&1 || fail "systemd/systemctl is required for the LXC installation." +} + +run_privileged() { + if [[ ${EUID:-$(id -u)} -eq 0 ]]; then + "$@" + elif command -v sudo >/dev/null 2>&1; then + sudo "$@" + else + fail "Root/sudo privileges are required: $*" + fi +} + +install_build_dependencies() { + local missing=0 + for cmd in curl cc make pkg-config; do + command -v "$cmd" >/dev/null 2>&1 || missing=1 + done + command -v python3 >/dev/null 2>&1 || missing=1 + [[ "$missing" -eq 1 ]] || return 0 + + if command -v apt-get >/dev/null 2>&1; then + say "Installing build dependencies" + run_privileged apt-get update + run_privileged apt-get install -y --no-install-recommends \ + build-essential curl ca-certificates pkg-config python3 + elif command -v dnf >/dev/null 2>&1; then + say "Installing build dependencies" + run_privileged dnf install -y gcc gcc-c++ make curl ca-certificates pkgconf-pkg-config python3 + elif command -v apk >/dev/null 2>&1; then + say "Installing build dependencies" + run_privileged apk add --no-cache build-base curl ca-certificates pkgconf python3 + else + fail "Unsupported package manager. Install a C compiler, make, curl, pkg-config, Python 3 and CA certificates." + fi +} + +ensure_rust() { + if command -v cargo >/dev/null 2>&1; then + return 0 + fi + command -v curl >/dev/null 2>&1 || fail "curl is required to install Rust." + say "Installing stable Rust with rustup" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH" + command -v cargo >/dev/null 2>&1 || fail "Cargo is unavailable after rustup installation." +} + +project_version() { + awk ' + /^\[package\]/ { package=1; next } + /^\[/ && package { exit } + package && /^version[[:space:]]*=/ { + gsub(/.*=[[:space:]]*"|".*/, "", $0); print; exit + } + ' "$PROJECT_ROOT/Cargo.toml" +} + +read_env_value() { + local key="$1" default_value="${2:-}" value="" + if [[ -f "$ENV_FILE" ]]; then + value="$(grep -E "^[[:space:]]*${key}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- || true)" + value="${value%\"}"; value="${value#\"}" + value="${value%\'}"; value="${value#\'}" + fi + printf '%s' "${value:-$default_value}" +} + +health_url() { + local bind port + bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')" + port="${bind##*:}" + printf 'http://127.0.0.1:%s/api/health' "$port" +} + +database_path() { + local db + db="$(read_env_value GREE_CONTROLLER_DATABASE "$DATA_DIR/gree-controller.db")" + if [[ "$db" != /* ]]; then + db="$DATA_DIR/$db" + fi + printf '%s' "$db" +} + +panel_url() { + local bind port ip + bind="$(read_env_value GREE_CONTROLLER_BIND '0.0.0.0:8787')" + port="${bind##*:}" + ip="$(hostname -I 2>/dev/null | awk '{print $1}')" + printf 'http://%s:%s' "${ip:-LXC_ADDRESS}" "$port" +} + +wait_for_health() { + local url attempts="${1:-60}" + url="$(health_url)" + for _ in $(seq 1 "$attempts"); do + if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then + return 0 + fi + sleep 0.5 + done + return 1 +} + +backup_timestamp() { + date -u +'%Y%m%dT%H%M%SZ' +} diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..6c44d5f --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" +cd "$PROJECT_ROOT" + +MODE="debug" +ACTION="run" +INSTALL_DEPS=1 +RESET_DB=0 +HOST="" +PORT="" + +usage() { + cat <<'TXT' +GREE Controller - development environment + +Usage: + ./scripts/dev.sh install missing tools, build and run + ./scripts/dev.sh --release run an optimized build + ./scripts/dev.sh --check formatting, tests, build and API smoke test + ./scripts/dev.sh --reset remove the local database before startup + ./scripts/dev.sh --no-install do not install system packages or Rust + ./scripts/dev.sh --host 0.0.0.0 --port 8787 +TXT +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) MODE="release"; shift ;; + --check) ACTION="check"; shift ;; + --reset) RESET_DB=1; shift ;; + --no-install) INSTALL_DEPS=0; shift ;; + --host) HOST="${2:?missing value for --host}"; shift 2 ;; + --port) PORT="${2:?missing value for --port}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +if [[ "$INSTALL_DEPS" -eq 1 ]]; then + install_build_dependencies + ensure_rust +else + command -v cargo >/dev/null 2>&1 || fail "Cargo is not installed and --no-install was requested." +fi + +if [[ ! -f .env ]]; then + cp .env.example .env + say "Created .env from the example configuration" +fi +mkdir -p data +if [[ "$RESET_DB" -eq 1 ]]; then + rm -f data/gree-controller.db data/gree-controller.db-shm data/gree-controller.db-wal + say "Removed the local database" +fi + +set -a +# shellcheck disable=SC1091 +source ./.env +set +a + +if [[ -n "$HOST" || -n "$PORT" ]]; then + current="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}" + current_host="${current%:*}" + current_port="${current##*:}" + export GREE_CONTROLLER_BIND="${HOST:-$current_host}:${PORT:-$current_port}" +fi + +if [[ "$ACTION" == "check" ]]; then + say "Checking formatting" + cargo fmt --all -- --check + say "Running Rust tests" + cargo test --all-targets + say "Building the application" + cargo build + say "Running HTTP/API smoke test" + "$SCRIPT_DIR/smoke.sh" + say "All checks passed" + exit 0 +fi + +if [[ "$MODE" == "release" ]]; then + say "Building release version" + cargo build --release + BINARY="$PROJECT_ROOT/target/release/gree-controller" +else + say "Building debug version" + cargo build + BINARY="$PROJECT_ROOT/target/debug/gree-controller" +fi + +bind="${GREE_CONTROLLER_BIND:-0.0.0.0:8787}" +display_host="${bind%:*}" +[[ "$display_host" == "0.0.0.0" ]] && display_host="127.0.0.1" +say "Panel: http://${display_host}:${bind##*:}" +say "Stop: Ctrl+C" +exec "$BINARY" diff --git a/scripts/generate_ha_migration.py b/scripts/generate_ha_migration.py new file mode 100755 index 0000000..91017e8 --- /dev/null +++ b/scripts/generate_ha_migration.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Generate a Home Assistant entity-ID takeover mapping for GREE Controller.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +ENTITY_RE = re.compile(r"^climate\.[a-z0-9_]+$") + + +def request_json(url: str, token: str = "") -> Any: + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(request, timeout=10) as response: + return json.load(response) + except urllib.error.HTTPError as err: + detail = err.read().decode("utf-8", errors="replace") + raise RuntimeError(f"HTTP {err.code}: {detail or err.reason}") from err + except urllib.error.URLError as err: + raise RuntimeError(f"Connection failed: {err.reason}") from err + + +def normalize_url(value: str) -> str: + value = value.strip().rstrip("/") + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError(f"Invalid URL: {value}") + return value + + +def parse_mapping(value: str) -> tuple[str, str]: + if "=" not in value: + raise argparse.ArgumentTypeError("mapping must be ENTITY_ID=CONTROLLER_DEVICE_ID") + entity_id, device_id = (part.strip() for part in value.split("=", 1)) + if not ENTITY_RE.fullmatch(entity_id): + raise argparse.ArgumentTypeError(f"invalid climate entity ID: {entity_id}") + if not device_id: + raise argparse.ArgumentTypeError("controller device ID cannot be empty") + return entity_id, device_id + + +def validate_ha_entity(base_url: str, token: str, entity_id: str) -> dict[str, Any]: + encoded = urllib.parse.quote(entity_id, safe="") + state = request_json(f"{base_url}/api/states/{encoded}", token) + if not isinstance(state, dict) or state.get("entity_id") != entity_id: + raise RuntimeError(f"Home Assistant did not return {entity_id}") + return state + + +def controller_devices(base_url: str, token: str) -> list[dict[str, Any]]: + data = request_json(f"{base_url}/api/integrations/home-assistant/devices", token) + if not isinstance(data, list): + raise RuntimeError("Controller returned an invalid devices response") + return [item for item in data if isinstance(item, dict) and item.get("id")] + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Generate /config/gree_controller_entities.json so the custom Home Assistant " + "integration can claim an existing climate entity ID after the old integration is unloaded." + ) + ) + parser.add_argument("--map", action="append", default=[], type=parse_mapping, metavar="ENTITY=DEVICE", help="Repeatable mapping, e.g. climate.klima_salon=gree-aabbccddeeff") + parser.add_argument("--entity", help="Single existing HA climate entity, e.g. climate.klima_salon") + parser.add_argument("--device", help="Controller device ID for --entity") + parser.add_argument("--controller-url", help="Optional controller URL used to validate or auto-select a single device") + 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") + args = parser.parse_args() + + mappings: list[tuple[str, str]] = list(args.map) + devices: list[dict[str, Any]] = [] + controller_url = normalize_url(args.controller_url) if args.controller_url else "" + ha_url = normalize_url(args.ha_url) if args.ha_url else "" + + if controller_url: + devices = controller_devices(controller_url, args.controller_token) + + if args.entity: + if not ENTITY_RE.fullmatch(args.entity): + parser.error("--entity must be a climate.* entity ID using lowercase letters, digits and underscores") + device_id = (args.device or "").strip() + if not device_id: + if len(devices) == 1: + device_id = str(devices[0]["id"]) + elif not controller_url: + parser.error("--device is required unless --controller-url identifies exactly one device") + else: + choices = ", ".join(f"{item['id']} ({item.get('name', 'unnamed')})" for item in devices) or "none" + parser.error(f"--device is required because the controller exposes {len(devices)} devices: {choices}") + mappings.append((args.entity, device_id)) + + if not mappings: + parser.error("provide at least one --map or --entity") + + by_entity: dict[str, str] = {} + by_device: dict[str, str] = {} + for entity_id, device_id in mappings: + if entity_id in by_entity and by_entity[entity_id] != device_id: + parser.error(f"duplicate entity mapping: {entity_id}") + if device_id in by_device and by_device[device_id] != entity_id: + parser.error(f"one controller device cannot claim two climate entity IDs: {device_id}") + by_entity[entity_id] = device_id + by_device[device_id] = entity_id + + if devices: + valid_ids = {str(item["id"]) for item in devices} + missing = [device_id for device_id in by_device if device_id not in valid_ids] + if missing: + parser.error(f"controller device ID not found: {', '.join(missing)}") + + source_metadata: dict[str, dict[str, Any]] = {} + if ha_url: + if not args.ha_token: + parser.error("--ha-token is required when --ha-url is used") + for entity_id in by_entity: + state = validate_ha_entity(ha_url, args.ha_token, entity_id) + source_metadata[entity_id] = { + "friendly_name": state.get("attributes", {}).get("friendly_name", ""), + "state": state.get("state"), + } + + payload = { + "version": 1, + "entities": [ + { + "entity_id": entity_id, + "device_id": device_id, + **({"source": source_metadata[entity_id]} if entity_id in source_metadata else {}), + } + for entity_id, device_id in sorted(by_entity.items()) + ], + } + + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + print(f"Generated: {output}") + print("Copy this file to Home Assistant as /config/gree_controller_entities.json.") + print("Before adding GREE Controller in HA, verify the Rust controller can control the AC, then disable/remove the old GREE integration so the requested entity IDs are free.") + print("Automations and dashboards that reference the same entity_id can then continue using it.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/install-lxc.sh b/scripts/install-lxc.sh new file mode 100755 index 0000000..041284a --- /dev/null +++ b/scripts/install-lxc.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +echo "install-lxc.sh is kept as a compatibility alias; using scripts/install.sh." >&2 +exec "$SCRIPT_DIR/install.sh" "$@" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..4fc221a --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" +cd "$PROJECT_ROOT" + +RUN_TESTS=1 +START_SERVICE=1 + +usage() { + cat <<'TXT' +Install GREE Controller as a systemd service in a Debian/Ubuntu LXC container. + +Usage: + sudo ./scripts/install.sh + sudo ./scripts/install.sh --skip-tests + sudo ./scripts/install.sh --no-start + +The installer preserves an existing /etc/gree-controller.env file and database. +Use scripts/update.sh for later releases. +TXT +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-tests) RUN_TESTS=0; shift ;; + --no-start) START_SERVICE=0; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +require_root +require_systemd +if [[ -x "$INSTALL_BINARY" && -f "$SERVICE_FILE" ]]; then + fail "An existing installation was detected. Use sudo ./scripts/update.sh instead." +fi +install_build_dependencies +ensure_rust + +if command -v systemd-detect-virt >/dev/null 2>&1; then + virt="$(systemd-detect-virt --container 2>/dev/null || true)" + [[ -n "$virt" ]] || warn "No container runtime was detected. Installation can still continue on a regular systemd host." +fi + +version="$(project_version)" +say "Installing GREE Controller ${version:-unknown}" + +if [[ "$RUN_TESTS" -eq 1 ]]; then + say "Running Rust tests before installation" + cargo test --all-targets +fi +say "Building release binary" +cargo build --release + +getent group "$SERVICE_GROUP" >/dev/null 2>&1 || groupadd --system "$SERVICE_GROUP" +id "$SERVICE_USER" >/dev/null 2>&1 || \ + useradd --system --gid "$SERVICE_GROUP" --home "$DATA_DIR" --shell /usr/sbin/nologin "$SERVICE_USER" +install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$DATA_DIR" +install -d -o root -g root -m 0755 "$INSTALL_DIR" +install -d -o root -g root -m 0700 "$BACKUP_ROOT" +install -o root -g root -m 0755 target/release/gree-controller "$INSTALL_BINARY" +install -o root -g root -m 0644 systemd/gree-controller.service "$SERVICE_FILE" + +if [[ ! -f "$ENV_FILE" ]]; then + token="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" + cat > "$ENV_FILE" </dev/null +if [[ "$START_SERVICE" -eq 1 ]]; then + say "Starting GREE Controller" + systemctl restart "$SERVICE_NAME" + if ! wait_for_health 80; then + systemctl --no-pager --full status "$SERVICE_NAME" || true + journalctl -u "$SERVICE_NAME" -n 80 --no-pager || true + fail "Service did not pass the health check." + fi + say "Installation complete" + printf 'Panel: %s\n' "$(panel_url)" + printf 'Status: %s status\n' "$SCRIPT_DIR/service.sh" + printf 'Logs: %s logs\n' "$SCRIPT_DIR/service.sh" +else + say "Installation complete; service was not started (--no-start)." +fi diff --git a/scripts/service.sh b/scripts/service.sh new file mode 100755 index 0000000..07b8620 --- /dev/null +++ b/scripts/service.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +action="${1:-status}" +case "$action" in + start|stop|restart) + require_root + systemctl "$action" "$SERVICE_NAME" + ;; + status) + systemctl --no-pager --full status "$SERVICE_NAME" + ;; + logs) + exec journalctl -u "$SERVICE_NAME" -f + ;; + health) + curl -fsS "$(health_url)"; printf '\n' + ;; + *) + echo "Usage: $0 {start|stop|restart|status|logs|health}" >&2 + exit 2 + ;; +esac diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..add926f --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +BINARY="${GREE_CONTROLLER_TEST_BINARY:-$ROOT/target/debug/gree-controller}" +[[ -x "$BINARY" ]] || cargo build >/dev/null +TMP="$(mktemp -d)" +PORT="${GREE_CONTROLLER_TEST_PORT:-$((19000 + RANDOM % 1000))}" +LOG="$TMP/server.log" +PID="" +cleanup() { + if [[ -n "$PID" ]] && kill -0 "$PID" 2>/dev/null; then kill "$PID" 2>/dev/null || true; wait "$PID" 2>/dev/null || true; fi + rm -rf "$TMP" +} +trap cleanup EXIT + +GREE_CONTROLLER_BIND="127.0.0.1:$PORT" \ +GREE_CONTROLLER_DATABASE="$TMP/test.db" \ +GREE_CONTROLLER_SIMULATE=true \ +GREE_CONTROLLER_AUTO_SEED=true \ +GREE_CONTROLLER_POLL_INTERVAL_SECONDS=2 \ +GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=2 \ +GREE_CONTROLLER_APP_TOKEN="" \ +RUST_LOG=warn \ +"$BINARY" >"$LOG" 2>&1 & +PID=$! + +for _ in $(seq 1 80); do + if curl -fsS "http://127.0.0.1:$PORT/api/health" >"$TMP/health.json"; then break; fi + if ! kill -0 "$PID" 2>/dev/null; then cat "$LOG" >&2; exit 1; fi + sleep 0.1 +done +grep -q '"status":"ok"' "$TMP/health.json" + +curl -fsS "http://127.0.0.1:$PORT/api/bootstrap" >"$TMP/bootstrap.json" +grep -q 'sim-salon' "$TMP/bootstrap.json" + +curl -fsS -X POST -H 'Content-Type: application/json' \ + -d '{"power":true,"mode":"cool","target_temperature":22}' \ + "http://127.0.0.1:$PORT/api/devices/sim-salon/command" >"$TMP/command.json" +grep -q '"power":true' "$TMP/command.json" + +curl -fsS -X POST -H 'Content-Type: application/json' \ + -d '{"name":"Test","device_id":"sim-salon","enabled":true,"mode":"cool","setpoint":23,"hysteresis":0.6,"min_on_seconds":0,"min_off_seconds":0,"sensor_source":"device"}' \ + "http://127.0.0.1:$PORT/api/zones" >"$TMP/zone.json" +grep -q '"name":"Test"' "$TMP/zone.json" + +curl -fsS "http://127.0.0.1:$PORT/api/readings?device_id=sim-salon&hours=1" >"$TMP/readings.json" +grep -q '"readings"' "$TMP/readings.json" + +# Home Assistant gets its own generated, restricted controller token. +curl -fsS -X POST -H 'Content-Type: application/json' \ + -d '{"name":"Smoke Home Assistant"}' \ + "http://127.0.0.1:$PORT/api/access-tokens" >"$TMP/access-token.json" +HA_ACCESS_TOKEN="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["token"])' "$TMP/access-token.json")" +[[ "$HA_ACCESS_TOKEN" == gree_controller_* ]] + +if curl -fsS "http://127.0.0.1:$PORT/api/integrations/home-assistant/devices" >/dev/null 2>&1; then + echo "Restricted Home Assistant API unexpectedly accepted a request without a token" >&2 + exit 1 +fi +curl -fsS -H "Authorization: Bearer $HA_ACCESS_TOKEN" \ + "http://127.0.0.1:$PORT/api/integrations/home-assistant/devices" >"$TMP/ha-devices.json" +grep -q 'sim-salon' "$TMP/ha-devices.json" + +curl -fsS -X POST -H "Authorization: Bearer $HA_ACCESS_TOKEN" -H 'Content-Type: application/json' \ + -d '{"power":false}' \ + "http://127.0.0.1:$PORT/api/integrations/home-assistant/devices/sim-salon/command" >"$TMP/ha-command.json" +grep -q '"power":false' "$TMP/ha-command.json" + +echo "Smoke test OK (port $PORT)" diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..d99cd29 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" +cd "$PROJECT_ROOT" + +RUN_TESTS=1 + +usage() { + cat <<'TXT' +Update an existing systemd/LXC GREE Controller installation from this source tree. + +Usage: + sudo ./scripts/update.sh + sudo ./scripts/update.sh --skip-tests + +The updater: + 1. builds and tests the new release while the old service is still running, + 2. stops the service, + 3. backs up the binary, systemd unit, environment file and SQLite database, + 4. installs the new binary and unit, + 5. restarts and checks /api/health, + 6. rolls back the binary/unit/database automatically if startup fails. +TXT +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --skip-tests) RUN_TESTS=0; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown argument: $1" >&2; usage; exit 2 ;; + esac +done + +require_root +require_systemd +[[ -x "$INSTALL_BINARY" ]] || fail "No installed binary at $INSTALL_BINARY. Run scripts/install.sh first." +[[ -f "$SERVICE_FILE" ]] || fail "No installed systemd unit at $SERVICE_FILE. Run scripts/install.sh first." + +install_build_dependencies +ensure_rust +version="$(project_version)" +say "Preparing update to GREE Controller ${version:-unknown}" + +if [[ "$RUN_TESTS" -eq 1 ]]; then + say "Running Rust tests before touching the running service" + cargo test --all-targets +fi +say "Building release binary" +cargo build --release + +stamp="$(backup_timestamp)" +backup_dir="$BACKUP_ROOT/$stamp" +install -d -o root -g root -m 0700 "$backup_dir" +db_path="$(database_path)" + +rollback() { + local rc=$? + trap - ERR + set +e + warn "Update failed; restoring previous installation from $backup_dir" + systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true + if [[ -f "$backup_dir/gree-controller.binary" ]]; then + install -o root -g root -m 0755 "$backup_dir/gree-controller.binary" "$INSTALL_BINARY" + fi + if [[ -f "$backup_dir/gree-controller.service" ]]; then + install -o root -g root -m 0644 "$backup_dir/gree-controller.service" "$SERVICE_FILE" + fi + if [[ -f "$backup_dir/gree-controller.env" ]]; then + install -o root -g root -m 0600 "$backup_dir/gree-controller.env" "$ENV_FILE" + fi + if [[ -f "$backup_dir/gree-controller.db" ]]; then + install -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0640 "$backup_dir/gree-controller.db" "$db_path" + fi + rm -f "${db_path}-wal" "${db_path}-shm" + systemctl daemon-reload + systemctl start "$SERVICE_NAME" >/dev/null 2>&1 || true + exit "$rc" +} +trap rollback ERR + +say "Stopping service for a consistent SQLite backup" +systemctl stop "$SERVICE_NAME" + +cp -a "$INSTALL_BINARY" "$backup_dir/gree-controller.binary" +cp -a "$SERVICE_FILE" "$backup_dir/gree-controller.service" +[[ -f "$ENV_FILE" ]] && cp -a "$ENV_FILE" "$backup_dir/gree-controller.env" +if [[ -f "$db_path" ]]; then + cp -a "$db_path" "$backup_dir/gree-controller.db" +fi +say "Backup created: $backup_dir" + +say "Installing new release binary" +install -o root -g root -m 0755 target/release/gree-controller "$INSTALL_BINARY.new" +mv -f "$INSTALL_BINARY.new" "$INSTALL_BINARY" +install -o root -g root -m 0644 systemd/gree-controller.service "$SERVICE_FILE" +systemctl daemon-reload +systemctl start "$SERVICE_NAME" + +if ! wait_for_health 80; then + journalctl -u "$SERVICE_NAME" -n 100 --no-pager || true + false +fi + +trap - ERR +say "Update complete" +printf 'Version: %s\n' "${version:-unknown}" +printf 'Panel: %s\n' "$(panel_url)" +printf 'Backup: %s\n' "$backup_dir" diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..61ebf8e --- /dev/null +++ b/src/api.rs @@ -0,0 +1,689 @@ +use std::{net::IpAddr, time::Duration}; +use axum::{ + body::Body, + extract::{Path, Query, Request, State, WebSocketUpgrade, ws::{Message, WebSocket}}, + http::{header, HeaderValue, StatusCode}, + middleware::{self, Next}, + response::{Html, Response}, + routing::{get, post}, + Json, Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::{Duration as ChronoDuration, Utc}; +use futures_util::StreamExt; +use rand::{rngs::OsRng, RngCore}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use serde_json::{json, Value}; +use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer}; +use uuid::Uuid; +use crate::{ + engine, + error::AppError, + home_assistant, + models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone}, + protocol::merge_discovered, + state::AppState, +}; + +const INDEX_HTML: &str = include_str!("../web/index.html"); +const APP_JS: &str = include_str!("../web/app.js"); +const STYLES_CSS: &str = include_str!("../web/styles.css"); +const MANIFEST: &str = include_str!("../web/manifest.webmanifest"); +const SERVICE_WORKER: &str = include_str!("../web/sw.js"); +const FAVICON: &str = include_str!("../web/favicon.svg"); +include!(concat!(env!("OUT_DIR"), "/languages.rs")); + +pub fn router(state: AppState) -> Router { + let protected = Router::new() + .route("/api/bootstrap", get(bootstrap)) + .route("/api/system/info", get(system_info)) + .route("/api/discovery", post(discover)) + .route("/api/devices", get(list_devices).post(add_device)) + .route("/api/devices/:id", get(get_device).patch(patch_device).delete(delete_device)) + .route("/api/devices/:id/bind", post(bind_device)) + .route("/api/devices/:id/poll", post(poll_device)) + .route("/api/devices/:id/command", post(command_device)) + .route("/api/zones", get(list_zones).post(create_zone)) + .route("/api/zones/:id", get(get_zone).put(update_zone).delete(delete_zone)) + .route("/api/schedules", get(list_schedules).post(create_schedule)) + .route("/api/schedules/:id", get(get_schedule).put(update_schedule).delete(delete_schedule)) + .route("/api/automations", get(list_automations).post(create_automation)) + .route("/api/automations/:id", get(get_automation).put(update_automation).delete(delete_automation)) + .route("/api/readings", get(readings)) + .route("/api/events", get(events)) + .route("/api/settings", get(get_settings).put(update_settings)) + .route("/api/access-tokens", get(list_access_tokens).post(create_access_token)) + .route("/api/access-tokens/:id", axum::routing::delete(delete_access_token)) + .route("/api/integrations/home-assistant/test", post(test_home_assistant)) + .route_layer(middleware::from_fn_with_state(state.clone(), auth)); + + let home_assistant_api = Router::new() + .route("/api/integrations/home-assistant/devices", get(list_devices)) + .route("/api/integrations/home-assistant/devices/:id/command", post(command_device)) + .route_layer(middleware::from_fn_with_state(state.clone(), home_assistant_auth)); + + Router::new() + .route("/api/health", get(health)) + .route("/ws", get(websocket)) + .route("/", get(index)) + .route("/index.html", get(index)) + .route("/app.js", get(app_js)) + .route("/styles.css", get(styles_css)) + .route("/manifest.webmanifest", get(manifest)) + .route("/sw.js", get(service_worker)) + .route("/favicon.svg", get(favicon)) + .route("/lang/index.json", get(language_index)) + .route("/lang/:file", get(language_file)) + .merge(protected) + .merge(home_assistant_api) + .fallback(index) + .layer(CompressionLayer::new()) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +async fn auth(State(state): State, request: Request, next: Next) -> Result { + let expected = state.config.app_token.trim(); + if expected.is_empty() { + return Ok(next.run(request).await); + } + let supplied = request_token(&request); + if supplied.as_deref() != Some(expected) { + return Err(AppError::Unauthorized); + } + Ok(next.run(request).await) +} + +async fn home_assistant_auth( + State(state): State, + request: Request, + next: Next, +) -> Result { + let supplied = request_token(&request).ok_or(AppError::Unauthorized)?; + let admin_token = state.config.app_token.trim(); + if !admin_token.is_empty() && supplied == admin_token { + return Ok(next.run(request).await); + } + if state.db.api_token_exists(&hash_token(&supplied))? { + return Ok(next.run(request).await); + } + Err(AppError::Unauthorized) +} + +fn request_token(request: &Request) -> Option { + request.headers().get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .or_else(|| request.headers().get("x-api-token").and_then(|value| value.to_str().ok())) + .map(str::to_owned) +} + +fn hash_token(token: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(token.as_bytes())) +} + +fn generate_access_token() -> String { + let mut bytes = [0u8; 32]; + let mut rng = OsRng; + rng.fill_bytes(&mut bytes); + format!("gree_controller_{}", URL_SAFE_NO_PAD.encode(bytes)) +} + +async fn health(State(state): State) -> Json { + Json(json!({ + "status": "ok", + "name": "gree-controller", + "version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": state.started.elapsed().as_secs(), + "time": Utc::now(), + })) +} + +async fn bootstrap(State(state): State) -> Result, AppError> { + Ok(Json(build_bootstrap(&state).await?)) +} + +async fn build_bootstrap(state: &AppState) -> Result { + let settings = state.settings.read().await.clone(); + Ok(json!({ + "devices": state.db.list_devices()?, + "zones": state.db.list_zones()?, + "schedules": state.db.list_schedules()?, + "automations": state.db.list_automations()?, + "access_tokens": state.db.list_api_tokens()?, + "settings": public_settings(&settings), + "system": { + "version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": state.started.elapsed().as_secs(), + "auth_required": !state.config.app_token.trim().is_empty(), + } + })) +} + +async fn system_info(State(state): State) -> Result, AppError> { + let devices = state.db.list_devices()?; + Ok(Json(json!({ + "version": env!("CARGO_PKG_VERSION"), + "uptime_seconds": state.started.elapsed().as_secs(), + "database": state.config.database.display().to_string(), + "device_count": devices.len(), + "online_count": devices.iter().filter(|v| v.online).count(), + "simulator_count": devices.iter().filter(|v| v.simulated).count(), + "bind": state.config.bind.to_string(), + }))) +} + +async fn discover(State(state): State, Json(request): Json) -> Result, AppError> { + let settings = state.settings.read().await.clone(); + let timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(300, 30_000); + let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast); + let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms)).await + .map_err(|e| AppError::Device(e.to_string()))?; + let mut saved = Vec::new(); + for item in discovered { + let existing = state.db.get_device_by_mac(&item.mac)?; + let merged = merge_discovered(existing, item); + state.db.save_device(&merged)?; + saved.push(merged); + } + state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len()})); + state.broadcast("devices.discovered", json!({"devices": saved})); + Ok(Json(json!({"count": saved.len(), "devices": saved}))) +} + +async fn list_devices(State(state): State) -> Result>, AppError> { + Ok(Json(state.db.list_devices()?)) +} + +async fn add_device(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + if input.name.trim().is_empty() || input.mac.trim().is_empty() || input.ip.trim().is_empty() { + return Err(AppError::BadRequest("name, mac and ip are required".into())); + } + input.ip.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; + if state.db.get_device_by_mac(&input.mac)?.is_some() { + return Err(AppError::BadRequest("a device with this MAC already exists".into())); + } + let now = Utc::now(); + let normalized_mac = input.mac.replace([':', '-'], "").to_ascii_uppercase(); + let device = Device { + id: format!("gree-{}", normalized_mac.to_ascii_lowercase()), + mac: normalized_mac, + name: input.name.trim().to_string(), + ip: input.ip, + port: input.port, + protocol_version: input.protocol_version.clamp(1, 2), + model: String::new(), + firmware: String::new(), + key: input.key.filter(|v| !v.trim().is_empty()), + cid: Some(state.settings.read().await.controller_id.clone()), + enabled: true, + simulated: input.simulated, + power: false, + mode: "cool".into(), + target_temperature: 24.0, + fan_speed: 0, + swing_vertical: false, + swing_horizontal: false, + quiet: false, + turbo: false, + light: true, + current_temperature: if input.simulated { Some(25.0) } else { None }, + outdoor_temperature: None, + online: input.simulated, + last_seen: if input.simulated { Some(now) } else { None }, + last_error: None, + created_at: now, + updated_at: now, + }; + state.db.save_device(&device)?; + state.log("info", "device.created", &format!("Added {}", device.name), json!({"device_id": device.id})); + state.broadcast("device.created", serde_json::to_value(&device)?); + Ok((StatusCode::CREATED, Json(device))) +} + +async fn get_device(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_device(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("device {id}"))) +} + +async fn patch_device(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { + let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; + if let Some(v) = patch.name { if !v.trim().is_empty() { device.name = v.trim().to_string(); } } + if let Some(v) = patch.ip { v.parse::().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; } + if let Some(v) = patch.port { device.port = v; } + if let Some(v) = patch.protocol_version { device.protocol_version = v.clamp(1, 2); } + if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); } + if let Some(v) = patch.enabled { device.enabled = v; } + device.updated_at = Utc::now(); + state.db.save_device(&device)?; + state.broadcast("device.updated", serde_json::to_value(&device)?); + Ok(Json(device)) +} + +async fn delete_device(State(state): State, Path(id): Path) -> Result { + if !state.db.delete_device(&id)? { return Err(AppError::NotFound(format!("device {id}"))); } + state.log("info", "device.deleted", "Device deleted", json!({"device_id": id})); + state.broadcast("device.deleted", json!({"id": id})); + Ok(StatusCode::NO_CONTENT) +} + +async fn bind_device(State(state): State, Path(id): Path) -> Result, AppError> { + let mut device = state.db.get_device(&id)?.ok_or_else(|| AppError::NotFound(format!("device {id}")))?; + if device.simulated { return Ok(Json(device)); } + let key = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?; + device.key = Some(key); + device.online = true; + device.last_seen = Some(Utc::now()); + device.last_error = None; + device.updated_at = Utc::now(); + state.db.save_device(&device)?; + state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": id})); + Ok(Json(device)) +} + +async fn poll_device(State(state): State, Path(id): Path) -> Result, AppError> { + Ok(Json(engine::poll_one(&state, &id).await?)) +} + +async fn command_device(State(state): State, Path(id): Path, Json(command): Json) -> Result, AppError> { + Ok(Json(engine::send_command(&state, &id, command).await?)) +} + +#[derive(Debug, Deserialize)] +struct ZoneInput { + name: String, + device_id: String, + #[serde(default = "yes")] + enabled: bool, + #[serde(default = "cool")] + mode: String, + #[serde(default = "setpoint")] + setpoint: f64, + #[serde(default = "hysteresis")] + hysteresis: f64, + #[serde(default = "cycle")] + min_on_seconds: u64, + #[serde(default = "cycle")] + min_off_seconds: u64, + #[serde(default = "device_source")] + sensor_source: String, + #[serde(default)] + ha_entity_id: Option, + #[serde(default = "external_sensor_weight")] + external_sensor_weight: f64, + #[serde(default = "max_sensor_difference")] + max_sensor_difference: f64, +} +fn yes() -> bool { true } +fn cool() -> String { "cool".into() } +fn setpoint() -> f64 { 24.0 } +fn hysteresis() -> f64 { 0.6 } +fn cycle() -> u64 { 180 } +fn external_sensor_weight() -> f64 { 0.4 } +fn max_sensor_difference() -> f64 { 3.0 } +fn device_source() -> String { "device".into() } + +impl ZoneInput { + fn validate(&self) -> Result<(), AppError> { + if self.name.trim().is_empty() { return Err(AppError::BadRequest("zone name is required".into())); } + if !(8.0..=32.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 32 C".into())); } + if !(0.1..=5.0).contains(&self.hysteresis) { return Err(AppError::BadRequest("hysteresis must be between 0.1 and 5 C".into())); } + if !matches!(self.mode.as_str(), "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); } + if !matches!(self.sensor_source.as_str(), "device" | "home_assistant" | "combined") { return Err(AppError::BadRequest("unsupported sensor source".into())); } + if !(0.0..=1.0).contains(&self.external_sensor_weight) { return Err(AppError::BadRequest("external sensor weight must be between 0 and 1".into())); } + if !(0.1..=20.0).contains(&self.max_sensor_difference) { return Err(AppError::BadRequest("maximum sensor difference must be between 0.1 and 20 C".into())); } + if matches!(self.sensor_source.as_str(), "home_assistant" | "combined") && self.ha_entity_id.as_deref().map(|value| value.trim()).unwrap_or("").is_empty() { + return Err(AppError::BadRequest("a per-zone Home Assistant entity_id is required for external or combined temperature control".into())); + } + Ok(()) + } + fn into_zone(self, id: String, created_at: chrono::DateTime) -> Zone { + Zone { + id, name: self.name.trim().into(), device_id: self.device_id, enabled: self.enabled, + mode: self.mode, setpoint: self.setpoint, hysteresis: self.hysteresis, + min_on_seconds: self.min_on_seconds, min_off_seconds: self.min_off_seconds, + sensor_source: self.sensor_source, ha_entity_id: self.ha_entity_id.filter(|v| !v.trim().is_empty()), + external_sensor_weight: self.external_sensor_weight, max_sensor_difference: self.max_sensor_difference, + device_temperature: None, external_temperature: None, current_temperature: None, control_temperature_source: "device".into(), + demand: false, last_action_at: None, + created_at, updated_at: Utc::now(), + } + } +} + +async fn list_zones(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_zones()?)) } +async fn get_zone(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_zone(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("zone {id}"))) +} +async fn create_zone(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + input.validate()?; + if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } + let zone = input.into_zone(Uuid::new_v4().to_string(), Utc::now()); + state.db.save_zone(&zone)?; + state.broadcast("zone.created", serde_json::to_value(&zone)?); + Ok((StatusCode::CREATED, Json(zone))) +} +async fn update_zone(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + input.validate()?; + let existing = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + if state.db.get_device(&input.device_id)?.is_none() { return Err(AppError::BadRequest("zone device does not exist".into())); } + let mut zone = input.into_zone(id, existing.created_at); + zone.device_temperature = existing.device_temperature; + zone.external_temperature = existing.external_temperature; + zone.current_temperature = existing.current_temperature; + zone.control_temperature_source = existing.control_temperature_source; + zone.demand = existing.demand; + zone.last_action_at = existing.last_action_at; + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + Ok(Json(zone)) +} +async fn delete_zone(State(state): State, Path(id): Path) -> Result { + if !state.db.delete_zone(&id)? { return Err(AppError::NotFound(format!("zone {id}"))); } + state.broadcast("zone.deleted", json!({"id": id})); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct ScheduleInput { + zone_id: String, + name: String, + #[serde(default = "yes")] + enabled: bool, + weekdays: Vec, + start_time: String, + end_time: String, + setpoint: f64, +} +impl ScheduleInput { + fn validate(&self) -> Result<(), AppError> { + if self.name.trim().is_empty() { return Err(AppError::BadRequest("schedule name is required".into())); } + if self.weekdays.is_empty() || self.weekdays.iter().any(|v| !(1..=7).contains(v)) { return Err(AppError::BadRequest("weekdays must contain numbers 1..7".into())); } + chrono::NaiveTime::parse_from_str(&self.start_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid start time".into()))?; + chrono::NaiveTime::parse_from_str(&self.end_time, "%H:%M").map_err(|_| AppError::BadRequest("invalid end time".into()))?; + if !(8.0..=32.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 32 C".into())); } + Ok(()) + } + fn into_schedule(self, id: String, created_at: chrono::DateTime) -> Schedule { + Schedule { id, zone_id: self.zone_id, name: self.name.trim().into(), enabled: self.enabled, + weekdays: self.weekdays, start_time: self.start_time, end_time: self.end_time, + setpoint: self.setpoint, created_at, updated_at: Utc::now() } + } +} +async fn list_schedules(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_schedules()?)) } +async fn get_schedule(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_schedule(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("schedule {id}"))) +} +async fn create_schedule(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + input.validate()?; + if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } + let item = input.into_schedule(Uuid::new_v4().to_string(), Utc::now()); + state.db.save_schedule(&item)?; + state.broadcast("schedule.created", serde_json::to_value(&item)?); + Ok((StatusCode::CREATED, Json(item))) +} +async fn update_schedule(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + input.validate()?; + let existing = state.db.get_schedule(&id)?.ok_or_else(|| AppError::NotFound(format!("schedule {id}")))?; + if state.db.get_zone(&input.zone_id)?.is_none() { return Err(AppError::BadRequest("schedule zone does not exist".into())); } + let item = input.into_schedule(id, existing.created_at); + state.db.save_schedule(&item)?; + state.broadcast("schedule.updated", serde_json::to_value(&item)?); + Ok(Json(item)) +} +async fn delete_schedule(State(state): State, Path(id): Path) -> Result { + if !state.db.delete_schedule(&id)? { return Err(AppError::NotFound(format!("schedule {id}"))); } + state.broadcast("schedule.deleted", json!({"id": id})); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct AutomationInput { + name: String, + #[serde(default = "yes")] + enabled: bool, + trigger_kind: String, + #[serde(default)] + trigger_device_id: Option, + #[serde(default)] + threshold: Option, + #[serde(default)] + at_time: Option, + action_device_id: String, + #[serde(default)] + action: DeviceCommand, + #[serde(default = "automation_cooldown")] + cooldown_seconds: u64, +} +fn automation_cooldown() -> u64 { 300 } +impl AutomationInput { + fn validate(&self) -> Result<(), AppError> { + if self.name.trim().is_empty() { return Err(AppError::BadRequest("automation name is required".into())); } + match self.trigger_kind.as_str() { + "temperature_above" | "temperature_below" => { + if self.trigger_device_id.as_deref().unwrap_or_default().is_empty() || self.threshold.is_none() { + return Err(AppError::BadRequest("temperature trigger needs device and threshold".into())); + } + } + "time" => { + let at = self.at_time.as_deref().ok_or_else(|| AppError::BadRequest("time trigger needs at_time".into()))?; + chrono::NaiveTime::parse_from_str(at, "%H:%M").map_err(|_| AppError::BadRequest("invalid automation time".into()))?; + } + _ => return Err(AppError::BadRequest("unsupported automation trigger".into())), + } + Ok(()) + } + fn into_automation(self, id: String, created_at: chrono::DateTime, last_fired_at: Option>) -> Automation { + Automation { id, name: self.name.trim().into(), enabled: self.enabled, + trigger_kind: self.trigger_kind, trigger_device_id: self.trigger_device_id, + threshold: self.threshold, at_time: self.at_time, action_device_id: self.action_device_id, + action: self.action, cooldown_seconds: self.cooldown_seconds.max(30), last_fired_at, + created_at, updated_at: Utc::now() } + } +} +async fn list_automations(State(state): State) -> Result>, AppError> { Ok(Json(state.db.list_automations()?)) } +async fn get_automation(State(state): State, Path(id): Path) -> Result, AppError> { + state.db.get_automation(&id)?.map(Json).ok_or_else(|| AppError::NotFound(format!("automation {id}"))) +} +async fn create_automation(State(state): State, Json(input): Json) -> Result<(StatusCode, Json), AppError> { + input.validate()?; + if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); } + let item = input.into_automation(Uuid::new_v4().to_string(), Utc::now(), None); + state.db.save_automation(&item)?; + state.broadcast("automation.created", serde_json::to_value(&item)?); + Ok((StatusCode::CREATED, Json(item))) +} +async fn update_automation(State(state): State, Path(id): Path, Json(input): Json) -> Result, AppError> { + input.validate()?; + let existing = state.db.get_automation(&id)?.ok_or_else(|| AppError::NotFound(format!("automation {id}")))?; + if state.db.get_device(&input.action_device_id)?.is_none() { return Err(AppError::BadRequest("automation action device does not exist".into())); } + let item = input.into_automation(id, existing.created_at, existing.last_fired_at); + state.db.save_automation(&item)?; + state.broadcast("automation.updated", serde_json::to_value(&item)?); + Ok(Json(item)) +} +async fn delete_automation(State(state): State, Path(id): Path) -> Result { + if !state.db.delete_automation(&id)? { return Err(AppError::NotFound(format!("automation {id}"))); } + state.broadcast("automation.deleted", json!({"id": id})); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct ReadingsQuery { device_id: Option, hours: Option, limit: Option } +async fn readings(State(state): State, Query(query): Query) -> Result, AppError> { + let hours = query.hours.unwrap_or(24).clamp(1, 24 * 31); + let values = state.db.list_readings(query.device_id.as_deref(), Utc::now() - ChronoDuration::hours(hours), query.limit.unwrap_or(1500))?; + Ok(Json(json!({"readings": values}))) +} + +#[derive(Debug, Deserialize)] +struct EventsQuery { limit: Option } +async fn events(State(state): State, Query(query): Query) -> Result, AppError> { + Ok(Json(json!({"events": state.db.list_events(query.limit.unwrap_or(100))?}))) +} + +async fn get_settings(State(state): State) -> Json { + let settings = state.settings.read().await; + Json(public_settings(&*settings)) +} + +async fn update_settings(State(state): State, Json(mut input): Json) -> Result, AppError> { + let old = state.settings.read().await.clone(); + input.poll_interval_seconds = input.poll_interval_seconds.clamp(2, 3600); + input.zone_interval_seconds = input.zone_interval_seconds.clamp(2, 3600); + input.discovery_timeout_ms = input.discovery_timeout_ms.clamp(300, 30_000); + input.discovery_broadcast.parse::() + .map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?; + if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; } + if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; } + if !input.home_assistant.url.trim().is_empty() { + let parsed = url::Url::parse(&input.home_assistant.url).map_err(|_| AppError::BadRequest("invalid Home Assistant URL".into()))?; + if !matches!(parsed.scheme(), "http" | "https") { return Err(AppError::BadRequest("Home Assistant URL must use http or https".into())); } + } + state.db.save_runtime_settings(&input)?; + *state.settings.write().await = input.clone(); + state.log("info", "settings.updated", "Settings updated", json!({})); + state.broadcast("settings.updated", public_settings(&input)); + Ok(Json(public_settings(&input))) +} + +#[derive(Debug, Deserialize)] +struct CreateAccessTokenRequest { + name: Option, +} + +async fn list_access_tokens(State(state): State) -> Result>, AppError> { + Ok(Json(state.db.list_api_tokens()?)) +} + +async fn create_access_token( + State(state): State, + Json(input): Json, +) -> Result<(StatusCode, Json), AppError> { + let name = input.name.unwrap_or_else(|| "Home Assistant".into()).trim().to_string(); + if name.is_empty() || name.len() > 80 { + return Err(AppError::BadRequest("token name must contain 1 to 80 characters".into())); + } + + let secret = generate_access_token(); + let item = ApiTokenInfo { + id: Uuid::new_v4().to_string(), + name, + token_prefix: format!("{}...", secret.chars().take(24).collect::()), + created_at: Utc::now(), + }; + state.db.save_api_token(&item, &hash_token(&secret))?; + state.log( + "info", + "access_token.created", + "Created a Home Assistant access token", + json!({"token_id": item.id.clone(), "name": item.name.clone()}), + ); + Ok((StatusCode::CREATED, Json(json!({"token": secret, "item": item})))) +} + +async fn delete_access_token(State(state): State, Path(id): Path) -> Result { + if !state.db.delete_api_token(&id)? { + return Err(AppError::NotFound(format!("access token {id}"))); + } + state.log( + "info", + "access_token.revoked", + "Revoked a Home Assistant access token", + json!({"token_id": id}), + ); + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct HaTestRequest { entity_id: Option } +async fn test_home_assistant(State(state): State, Json(input): Json) -> Result, AppError> { + let settings = state.settings.read().await.clone(); + let temperature = home_assistant::read_temperature(&state.http, &settings.home_assistant, input.entity_id.as_deref()) + .await.map_err(|e| AppError::Device(e.to_string()))?; + Ok(Json(json!({"ok": true, "temperature_c": temperature}))) +} + +fn public_settings(settings: &RuntimeSettings) -> Value { + json!({ + "controller_id": settings.controller_id, + "simulator_enabled": settings.simulator_enabled, + "poll_interval_seconds": settings.poll_interval_seconds, + "zone_interval_seconds": settings.zone_interval_seconds, + "discovery_timeout_ms": settings.discovery_timeout_ms, + "discovery_broadcast": settings.discovery_broadcast, + "home_assistant": { + "url": settings.home_assistant.url, + "token": "", + "token_configured": !settings.home_assistant.token.trim().is_empty(), + "default_entity_id": settings.home_assistant.default_entity_id, + } + }) +} + +#[derive(Debug, Deserialize)] +struct WsQuery { token: Option } +async fn websocket(State(state): State, Query(query): Query, ws: WebSocketUpgrade) -> Result { + let expected = state.config.app_token.trim(); + if !expected.is_empty() && query.token.as_deref() != Some(expected) { return Err(AppError::Unauthorized); } + Ok(ws.on_upgrade(move |socket| websocket_loop(state, socket))) +} + +async fn websocket_loop(state: AppState, mut socket: WebSocket) { + let initial = match build_bootstrap(&state).await { + Ok(data) => json!({"event":"bootstrap","timestamp":Utc::now(),"data":data}), + Err(err) => json!({"event":"error","timestamp":Utc::now(),"data":{"message":err.to_string()}}), + }; + if socket.send(Message::Text(initial.to_string())).await.is_err() { return; } + let mut receiver = state.events.subscribe(); + loop { + tokio::select! { + event = receiver.recv() => { + match event { + Ok(event) => { + if let Ok(text) = serde_json::to_string(&event) { + if socket.send(Message::Text(text)).await.is_err() { break; } + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + } + } + message = socket.next() => { + match message { + Some(Ok(Message::Ping(value))) => { if socket.send(Message::Pong(value)).await.is_err() { break; } } + Some(Ok(Message::Text(text))) if text == "ping" => { if socket.send(Message::Text("pong".into())).await.is_err() { break; } } + Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break, + _ => {} + } + } + } + } +} + +async fn index() -> Html<&'static str> { Html(INDEX_HTML) } +async fn app_js() -> Response { static_response(APP_JS, "application/javascript; charset=utf-8", "no-cache") } +async fn styles_css() -> Response { static_response(STYLES_CSS, "text/css; charset=utf-8", "no-cache") } +async fn manifest() -> Response { static_response(MANIFEST, "application/manifest+json", "public, max-age=3600") } +async fn service_worker() -> Response { static_response(SERVICE_WORKER, "application/javascript; charset=utf-8", "no-cache") } +async fn favicon() -> Response { static_response(FAVICON, "image/svg+xml", "public, max-age=86400") } +async fn language_index() -> Response { + static_response(LANGUAGE_MANIFEST_JSON, "application/json; charset=utf-8", "no-cache") +} +async fn language_file(Path(file): Path) -> Response { + let code = file.strip_suffix(".json").unwrap_or(&file); + if let Some((_, body)) = LANGUAGE_ASSETS.iter().find(|(language, _)| *language == code) { + return static_response(*body, "application/json; charset=utf-8", "no-cache"); + } + let mut response = Response::new(Body::from("Language not found")); + *response.status_mut() = StatusCode::NOT_FOUND; + response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8")); + response +} +fn static_response(body: &'static str, content_type: &'static str, cache: &'static str) -> Response { + let mut response = Response::new(Body::from(body)); + response.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); + response +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..de14bb4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,57 @@ +use std::{env, net::SocketAddr, path::PathBuf}; +use anyhow::{Context, Result}; +use clap::Parser; +use crate::models::{HomeAssistantSettings, RuntimeSettings}; + +#[derive(Debug, Clone, Parser)] +#[command(author, version, about)] +pub struct Config { + #[arg(long, env = "GREE_CONTROLLER_BIND", default_value = "0.0.0.0:8787")] + pub bind: SocketAddr, + #[arg(long, env = "GREE_CONTROLLER_DATABASE", default_value = "./data/gree-controller.db")] + pub database: PathBuf, + #[arg(long, env = "GREE_CONTROLLER_APP_TOKEN", default_value = "")] + pub app_token: String, + #[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = true)] + pub simulate: bool, + #[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = true)] + pub auto_seed: bool, + #[arg(long, env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", default_value_t = 15)] + pub poll_interval_seconds: u64, + #[arg(long, env = "GREE_CONTROLLER_ZONE_INTERVAL_SECONDS", default_value_t = 5)] + pub zone_interval_seconds: u64, + #[arg(long, env = "GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS", default_value_t = 3000)] + pub discovery_timeout_ms: u64, + #[arg(long, env = "GREE_CONTROLLER_DISCOVERY_BROADCAST", default_value = "255.255.255.255:7000")] + pub discovery_broadcast: String, + #[arg(long, env = "GREE_CONTROLLER_ID", default_value = "gree-controller")] + pub controller_id: String, +} + +impl Config { + pub fn load() -> Result { + dotenvy::dotenv().ok(); + let config = Self::parse(); + if let Some(parent) = config.database.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("cannot create database directory {}", parent.display()))?; + } + Ok(config) + } + + pub fn runtime_defaults(&self) -> RuntimeSettings { + RuntimeSettings { + controller_id: self.controller_id.clone(), + simulator_enabled: self.simulate, + poll_interval_seconds: self.poll_interval_seconds.max(2), + zone_interval_seconds: self.zone_interval_seconds.max(2), + discovery_timeout_ms: self.discovery_timeout_ms.clamp(300, 30_000), + discovery_broadcast: self.discovery_broadcast.clone(), + home_assistant: HomeAssistantSettings { + url: env::var("HA_URL").unwrap_or_default(), + token: env::var("HA_TOKEN").unwrap_or_default(), + default_entity_id: env::var("HA_ENTITY_ID").unwrap_or_default(), + }, + } + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..a2dfc66 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,341 @@ +use std::{path::Path, sync::{Arc, Mutex}}; +use anyhow::{Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::Value; +use crate::{ + models::{ApiTokenInfo, Automation, Device, EventLog, Reading, RuntimeSettings, Schedule, Zone}, + queries, +}; + +#[derive(Clone)] +pub struct Db { + conn: Arc>, +} + +impl Db { + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path) + .with_context(|| format!("cannot open SQLite database {}", path.display()))?; + conn.busy_timeout(std::time::Duration::from_secs(5))?; + conn.execute_batch(queries::INIT_SCHEMA)?; + Ok(Self { conn: Arc::new(Mutex::new(conn)) }) + } + + fn lock(&self) -> Result> { + self.conn.lock().map_err(|_| anyhow::anyhow!("database mutex poisoned")) + } + + fn from_json(payload: String) -> Result { + Ok(serde_json::from_str(&payload)?) + } + + fn to_json(value: &T) -> Result { + Ok(serde_json::to_string(value)?) + } + + pub fn count_devices(&self) -> Result { + let conn = self.lock()?; + let count: i64 = conn.query_row(queries::COUNT_DEVICES, [], |row| row.get(0))?; + Ok(count.max(0) as u64) + } + + pub fn save_device(&self, device: &Device) -> Result<()> { + let payload = Self::to_json(device)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_DEVICE, + params![device.id, device.mac, device.name, device.ip, device.simulated as i64, payload, device.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_devices(&self) -> Result> { + let conn = self.lock()?; + let mut stmt = conn.prepare(queries::LIST_DEVICES)?; + let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + payloads.into_iter().map(Self::from_json).collect() + } + + pub fn get_device(&self, id: &str) -> Result> { + let conn = self.lock()?; + let payload: Option = conn.query_row(queries::GET_DEVICE_BY_ID, [id], |row| row.get(0)).optional()?; + payload.map(Self::from_json).transpose() + } + + pub fn get_device_by_mac(&self, mac: &str) -> Result> { + let conn = self.lock()?; + let payload: Option = conn.query_row(queries::GET_DEVICE_BY_MAC, [mac], |row| row.get(0)).optional()?; + payload.map(Self::from_json).transpose() + } + + pub fn delete_device(&self, id: &str) -> Result { + let mut conn = self.lock()?; + let tx = conn.transaction()?; + tx.execute(queries::DELETE_DEVICE_READINGS, [id])?; + tx.execute(queries::DELETE_ZONES_BY_DEVICE_ID, [id])?; + let changed = tx.execute(queries::DELETE_DEVICE, [id])? > 0; + tx.commit()?; + Ok(changed) + } + + pub fn save_zone(&self, zone: &Zone) -> Result<()> { + let payload = Self::to_json(zone)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_ZONE, + params![zone.id, payload, zone.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_zones(&self) -> Result> { + self.list_payloads(queries::LIST_ZONES) + } + + pub fn get_zone(&self, id: &str) -> Result> { + self.get_payload(queries::GET_ZONE, id) + } + + pub fn delete_zone(&self, id: &str) -> Result { + let mut conn = self.lock()?; + let tx = conn.transaction()?; + tx.execute(queries::DELETE_SCHEDULES_BY_ZONE_ID, [id])?; + let changed = tx.execute(queries::DELETE_ZONE, [id])? > 0; + tx.commit()?; + Ok(changed) + } + + pub fn save_schedule(&self, schedule: &Schedule) -> Result<()> { + let payload = Self::to_json(schedule)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_SCHEDULE, + params![schedule.id, schedule.zone_id, payload, schedule.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_schedules(&self) -> Result> { + self.list_payloads(queries::LIST_SCHEDULES) + } + + pub fn get_schedule(&self, id: &str) -> Result> { + self.get_payload(queries::GET_SCHEDULE, id) + } + + pub fn delete_schedule(&self, id: &str) -> Result { + self.delete_by_id("schedules", id) + } + + pub fn save_automation(&self, item: &Automation) -> Result<()> { + let payload = Self::to_json(item)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_AUTOMATION, + params![item.id, payload, item.updated_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn list_automations(&self) -> Result> { + self.list_payloads(queries::LIST_AUTOMATIONS) + } + + pub fn get_automation(&self, id: &str) -> Result> { + self.get_payload(queries::GET_AUTOMATION, id) + } + + pub fn delete_automation(&self, id: &str) -> Result { + self.delete_by_id("automations", id) + } + + fn list_payloads(&self, sql: &str) -> Result> { + let conn = self.lock()?; + let mut stmt = conn.prepare(sql)?; + let payloads = stmt.query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + payloads.into_iter().map(Self::from_json).collect() + } + + fn get_payload(&self, sql: &str, id: &str) -> Result> { + let conn = self.lock()?; + let payload: Option = conn.query_row(sql, [id], |row| row.get(0)).optional()?; + payload.map(Self::from_json).transpose() + } + + fn delete_by_id(&self, table: &str, id: &str) -> Result { + let sql = match table { + "schedules" => queries::DELETE_SCHEDULE, + "automations" => queries::DELETE_AUTOMATION, + _ => anyhow::bail!("unsupported table"), + }; + let conn = self.lock()?; + Ok(conn.execute(sql, [id])? > 0) + } + + pub fn add_reading(&self, reading: &Reading) -> Result { + let conn = self.lock()?; + conn.execute( + queries::INSERT_READING, + params![reading.device_id, reading.timestamp.to_rfc3339(), reading.indoor_temperature, + reading.outdoor_temperature, reading.target_temperature, reading.power as i64, reading.source], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn list_readings(&self, device_id: Option<&str>, since: DateTime, limit: u32) -> Result> { + let conn = self.lock()?; + let limit = limit.clamp(1, 5000) as i64; + let mut rows_out = Vec::new(); + if let Some(device_id) = device_id { + let mut stmt = conn.prepare(queries::LIST_READINGS_BY_DEVICE)?; + let rows = stmt.query_map(params![device_id, since.to_rfc3339(), limit], Self::map_reading)?; + for row in rows { rows_out.push(row?); } + } else { + let mut stmt = conn.prepare(queries::LIST_READINGS_ALL)?; + let rows = stmt.query_map(params![since.to_rfc3339(), limit], Self::map_reading)?; + for row in rows { rows_out.push(row?); } + } + Ok(rows_out) + } + + fn map_reading(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let timestamp: String = row.get(2)?; + Ok(Reading { + id: row.get(0)?, + device_id: row.get(1)?, + timestamp: DateTime::parse_from_rfc3339(×tamp) + .map(|v| v.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + indoor_temperature: row.get(3)?, + outdoor_temperature: row.get(4)?, + target_temperature: row.get(5)?, + power: row.get::<_, i64>(6)? != 0, + source: row.get(7)?, + }) + } + + pub fn prune_readings(&self, retention_days: i64) -> Result { + let before = Utc::now() - Duration::days(retention_days.max(1)); + let conn = self.lock()?; + Ok(conn.execute(queries::PRUNE_READINGS, [before.to_rfc3339()])? as u64) + } + + pub fn log_event(&self, level: &str, kind: &str, message: &str, metadata: &Value) -> Result { + let conn = self.lock()?; + conn.execute( + queries::INSERT_EVENT, + params![Utc::now().to_rfc3339(), level, kind, message, serde_json::to_string(metadata)?], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn list_events(&self, limit: u32) -> Result> { + let conn = self.lock()?; + let mut stmt = conn.prepare(queries::LIST_EVENTS)?; + let rows = stmt.query_map([limit.clamp(1, 1000) as i64], |row| { + let ts: String = row.get(1)?; + let metadata: String = row.get(5)?; + Ok(EventLog { + id: row.get(0)?, + timestamp: DateTime::parse_from_rfc3339(&ts).map(|v| v.with_timezone(&Utc)).unwrap_or_else(|_| Utc::now()), + level: row.get(2)?, + kind: row.get(3)?, + message: row.get(4)?, + metadata: serde_json::from_str(&metadata).unwrap_or(Value::Null), + }) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn list_api_tokens(&self) -> Result> { + let conn = self.lock()?; + let mut stmt = conn.prepare(queries::LIST_API_TOKENS)?; + let rows = stmt.query_map([], |row| { + let created_at: String = row.get(3)?; + Ok(ApiTokenInfo { + id: row.get(0)?, + name: row.get(1)?, + token_prefix: row.get(2)?, + created_at: DateTime::parse_from_rfc3339(&created_at) + .map(|value| value.with_timezone(&Utc)) + .unwrap_or_else(|_| Utc::now()), + }) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn save_api_token(&self, token: &ApiTokenInfo, token_hash: &str) -> Result<()> { + let conn = self.lock()?; + conn.execute( + queries::INSERT_API_TOKEN, + params![token.id, token.name, token_hash, token.token_prefix, token.created_at.to_rfc3339()], + )?; + Ok(()) + } + + pub fn api_token_exists(&self, token_hash: &str) -> Result { + let conn = self.lock()?; + let found: Option = conn.query_row( + queries::API_TOKEN_EXISTS, + [token_hash], + |row| row.get(0), + ).optional()?; + Ok(found.is_some()) + } + + pub fn delete_api_token(&self, id: &str) -> Result { + let conn = self.lock()?; + Ok(conn.execute(queries::DELETE_API_TOKEN, [id])? > 0) + } + + pub fn load_runtime_settings(&self) -> Result> { + let conn = self.lock()?; + let value: Option = conn.query_row(queries::LOAD_RUNTIME_SETTINGS, [], |row| row.get(0)).optional()?; + value.map(Self::from_json).transpose() + } + + pub fn save_runtime_settings(&self, settings: &RuntimeSettings) -> Result<()> { + let value = Self::to_json(settings)?; + let conn = self.lock()?; + conn.execute( + queries::UPSERT_RUNTIME_SETTINGS, + params![value, Utc::now().to_rfc3339()], + )?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{ApiTokenInfo, Device}; + + #[test] + fn sqlite_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let db = Db::open(&dir.path().join("test.db")).unwrap(); + let device = Device::simulated_default(); + db.save_device(&device).unwrap(); + let loaded = db.get_device(&device.id).unwrap().unwrap(); + assert_eq!(loaded.mac, device.mac); + assert_eq!(db.list_devices().unwrap().len(), 1); + db.log_event("info", "test", "ok", &serde_json::json!({"a":1})).unwrap(); + assert_eq!(db.list_events(10).unwrap().len(), 1); + + let access_token = ApiTokenInfo { + id: "token-1".into(), + name: "Home Assistant".into(), + token_prefix: "gree_controller_test...".into(), + created_at: Utc::now(), + }; + db.save_api_token(&access_token, "test-hash").unwrap(); + assert!(db.api_token_exists("test-hash").unwrap()); + assert_eq!(db.list_api_tokens().unwrap().len(), 1); + assert!(db.delete_api_token(&access_token.id).unwrap()); + assert!(!db.api_token_exists("test-hash").unwrap()); + } +} diff --git a/src/engine.rs b/src/engine.rs new file mode 100644 index 0000000..f1200b2 --- /dev/null +++ b/src/engine.rs @@ -0,0 +1,450 @@ +use std::time::Duration; +use anyhow::Result; +use chrono::{DateTime, Datelike, Local, NaiveTime, Timelike, Utc, Weekday}; +use serde_json::json; +use tokio::time::sleep; +use crate::{ + error::AppError, + home_assistant, + models::{Automation, Device, DeviceCommand, Reading, Schedule, Zone}, + state::AppState, +}; + +pub fn start(state: AppState) { + let poll_state = state.clone(); + tokio::spawn(async move { + sleep(Duration::from_millis(500)).await; + loop { + if let Err(err) = poll_all(&poll_state).await { + tracing::error!(error=?err, "device poll cycle failed"); + } + let seconds = poll_state.settings.read().await.poll_interval_seconds.max(2); + sleep(Duration::from_secs(seconds)).await; + } + }); + + let control_state = state.clone(); + tokio::spawn(async move { + sleep(Duration::from_secs(2)).await; + loop { + if let Err(err) = control_zones(&control_state).await { + tracing::error!(error=?err, "zone cycle failed"); + } + if let Err(err) = run_automations(&control_state).await { + tracing::error!(error=?err, "automation cycle failed"); + } + let seconds = control_state.settings.read().await.zone_interval_seconds.max(2); + sleep(Duration::from_secs(seconds)).await; + } + }); + + let maintenance_state = state; + tokio::spawn(async move { + loop { + sleep(Duration::from_secs(6 * 60 * 60)).await; + match maintenance_state.db.prune_readings(30) { + Ok(count) if count > 0 => tracing::info!(count, "old readings pruned"), + Ok(_) => {} + Err(err) => tracing::warn!(error=?err, "cannot prune readings"), + } + } + }); +} + +pub async fn send_command(state: &AppState, device_id: &str, command: DeviceCommand) -> Result { + validate_command(&command)?; + let mut device = state.db.get_device(device_id)? + .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; + if !device.enabled { return Err(AppError::BadRequest("device is disabled".into())); } + + if device.simulated { + command.apply(&mut device); + device.online = true; + device.last_seen = Some(Utc::now()); + device.last_error = None; + state.db.save_device(&device)?; + } else { + if device.key.as_deref().unwrap_or_default().is_empty() { + match state.gree.bind(&device).await { + Ok(key) => { + device.key = Some(key); + state.db.save_device(&device)?; + state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": device.id})); + } + Err(err) => { + mark_device_error(state, &mut device, &err.to_string())?; + return Err(AppError::Device(err.to_string())); + } + } + } + if let Err(err) = state.gree.command(&device, &command).await { + mark_device_error(state, &mut device, &err.to_string())?; + return Err(AppError::Device(err.to_string())); + } + command.apply(&mut device); + device.online = true; + device.last_seen = Some(Utc::now()); + device.last_error = None; + state.db.save_device(&device)?; + } + + state.log("info", "device.command", &format!("Updated {}", device.name), json!({ + "device_id": device.id, + "command": command, + })); + state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); + Ok(device) +} + +pub async fn poll_one(state: &AppState, device_id: &str) -> Result { + let mut device = state.db.get_device(device_id)? + .ok_or_else(|| AppError::NotFound(format!("device {device_id}")))?; + poll_device(state, &mut device).await; + state.db.save_device(&device)?; + record_reading(state, &device)?; + state.broadcast("device.updated", serde_json::to_value(&device).unwrap_or_default()); + Ok(device) +} + +async fn poll_all(state: &AppState) -> Result<()> { + for mut device in state.db.list_devices()? { + if !device.enabled { continue; } + poll_device(state, &mut device).await; + state.db.save_device(&device)?; + record_reading(state, &device)?; + state.broadcast("device.updated", serde_json::to_value(&device)?); + } + Ok(()) +} + +async fn poll_device(state: &AppState, device: &mut Device) { + if device.simulated { + simulate_tick(device); + return; + } + if device.key.as_deref().unwrap_or_default().is_empty() { + match state.gree.bind(device).await { + Ok(key) => device.key = Some(key), + Err(err) => { + device.online = false; + device.last_error = Some(err.to_string()); + device.updated_at = Utc::now(); + return; + } + } + } + if let Err(err) = state.gree.poll(device).await { + device.online = false; + device.last_error = Some(err.to_string()); + device.updated_at = Utc::now(); + } +} + +fn simulate_tick(device: &mut Device) { + let mut current = device.current_temperature.unwrap_or(25.0); + let minute_wave = ((Utc::now().timestamp() % 3600) as f64 / 3600.0 * std::f64::consts::TAU).sin(); + let ambient = 25.5 + minute_wave * 0.35; + if device.power { + match device.mode.as_str() { + "cool" => { + let floor = device.target_temperature - 0.2; + if current > floor { current -= if device.turbo { 0.25 } else { 0.12 }; } + } + "heat" => { + let ceiling = device.target_temperature + 0.2; + if current < ceiling { current += if device.turbo { 0.25 } else { 0.12 }; } + } + "dry" => current -= 0.04, + _ => current += (ambient - current) * 0.02, + } + } else { + current += (ambient - current) * 0.04; + } + device.current_temperature = Some((current * 10.0).round() / 10.0); + device.outdoor_temperature = Some((30.0 + minute_wave * 1.2) * 10.0_f64.round() / 10.0); + // Correct rounding for outdoor temperature without accumulating precision noise. + device.outdoor_temperature = device.outdoor_temperature.map(|v| (v * 10.0).round() / 10.0); + device.online = true; + device.last_seen = Some(Utc::now()); + device.last_error = None; + device.updated_at = Utc::now(); +} + +fn record_reading(state: &AppState, device: &Device) -> Result<()> { + state.db.add_reading(&Reading { + id: 0, + device_id: device.id.clone(), + timestamp: Utc::now(), + indoor_temperature: device.current_temperature, + outdoor_temperature: device.outdoor_temperature, + target_temperature: device.target_temperature, + power: device.power, + source: if device.simulated { "simulator".into() } else { "gree".into() }, + })?; + Ok(()) +} + +fn mark_device_error(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> { + device.online = false; + device.last_error = Some(error.to_string()); + device.updated_at = Utc::now(); + state.db.save_device(device)?; + state.log("error", "device.error", &format!("{}: {error}", device.name), json!({"device_id": device.id})); + Ok(()) +} + +fn validate_command(command: &DeviceCommand) -> Result<(), AppError> { + if let Some(value) = command.target_temperature { + if !(8.0..=32.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 32 C".into())); } + } + if let Some(value) = command.fan_speed { + if value > 5 { return Err(AppError::BadRequest("fan speed must be between 0 and 5".into())); } + } + if let Some(value) = &command.mode { + if !matches!(value.as_str(), "auto" | "cool" | "dry" | "fan" | "heat") { + return Err(AppError::BadRequest("unsupported HVAC mode".into())); + } + } + Ok(()) +} + +async fn control_zones(state: &AppState) -> Result<()> { + let schedules = state.db.list_schedules()?; + let settings = state.settings.read().await.clone(); + for mut zone in state.db.list_zones()? { + if !zone.enabled { continue; } + if let Some(setpoint) = active_setpoint(&zone, &schedules, Local::now()) { + zone.setpoint = setpoint; + } + let Some(device) = state.db.get_device(&zone.device_id)? else { + state.log("error", "zone.device_missing", &format!("Zone {} has no device", zone.name), json!({"zone_id": zone.id})); + continue; + }; + let previous_source = zone.control_temperature_source.clone(); + let device_temperature = device.current_temperature; + let external_temperature = if matches!(zone.sensor_source.as_str(), "home_assistant" | "combined") { + match home_assistant::read_temperature(&state.http, &settings.home_assistant, zone.ha_entity_id.as_deref()).await { + Ok(value) => Some(value), + Err(err) => { + if !matches!(previous_source.as_str(), "device_fallback" | "device_discrepancy_fallback") { + state.log("warn", "ha.sensor_error", &err.to_string(), json!({"zone_id": zone.id, "entity_id": zone.ha_entity_id.as_deref()})); + } + None + } + } + } else { + None + }; + + let (temperature, control_source, discrepancy) = select_zone_temperature(&zone, device_temperature, external_temperature); + zone.device_temperature = device_temperature; + zone.external_temperature = external_temperature; + zone.current_temperature = temperature; + zone.control_temperature_source = control_source; + zone.updated_at = Utc::now(); + + if discrepancy && previous_source != "device_discrepancy_fallback" { + state.log("warn", "zone.sensor_discrepancy", &format!("Zone {} sensors differ by more than {:.1} C; using GREE sensor", zone.name, zone.max_sensor_difference), json!({ + "zone_id": zone.id, + "device_temperature": zone.device_temperature, + "external_temperature": zone.external_temperature, + "max_difference": zone.max_sensor_difference, + "entity_id": zone.ha_entity_id.as_deref(), + })); + } + + let Some(temp) = temperature else { state.db.save_zone(&zone)?; continue; }; + + let half = zone.hysteresis.max(0.1) / 2.0; + let desired = match zone.mode.as_str() { + "heat" => if temp <= zone.setpoint - half { Some(true) } else if temp >= zone.setpoint + half { Some(false) } else { None }, + _ => if temp >= zone.setpoint + half { Some(true) } else if temp <= zone.setpoint - half { Some(false) } else { None }, + }; + if let Some(on) = desired { + zone.demand = on; + if on != device.power && cycle_allowed(&zone, device.power) { + let command = DeviceCommand { + power: Some(on), + mode: if on { Some(zone.mode.clone()) } else { None }, + target_temperature: if on { Some(zone.setpoint) } else { None }, + ..Default::default() + }; + match send_command(state, &zone.device_id, command).await { + Ok(_) => { + zone.last_action_at = Some(Utc::now()); + state.log("info", "zone.action", &format!("Zone {} demand {}", zone.name, if on { "ON" } else { "OFF" }), json!({ + "zone_id": zone.id, "temperature": temp, "setpoint": zone.setpoint, + })); + } + Err(err) => state.log("error", "zone.action_error", &err.to_string(), json!({"zone_id": zone.id})), + } + } + } + state.db.save_zone(&zone)?; + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + } + Ok(()) +} + +fn select_zone_temperature(zone: &Zone, device_temperature: Option, external_temperature: Option) -> (Option, String, bool) { + match zone.sensor_source.as_str() { + "home_assistant" => match (external_temperature, device_temperature) { + (Some(value), _) => (Some(value), "external".into(), false), + (None, Some(value)) => (Some(value), "device_fallback".into(), false), + (None, None) => (None, "unavailable".into(), false), + }, + "combined" => match (device_temperature, external_temperature) { + (Some(device), Some(external)) => { + if (device - external).abs() > zone.max_sensor_difference.max(0.1) { + (Some(device), "device_discrepancy_fallback".into(), true) + } else { + let external_weight = zone.external_sensor_weight.clamp(0.0, 1.0); + let value = device * (1.0 - external_weight) + external * external_weight; + (Some((value * 10.0).round() / 10.0), "combined".into(), false) + } + } + (Some(value), None) => (Some(value), "device_fallback".into(), false), + (None, Some(value)) => (Some(value), "external".into(), false), + (None, None) => (None, "unavailable".into(), false), + }, + _ => match device_temperature { + Some(value) => (Some(value), "device".into(), false), + None => (None, "unavailable".into(), false), + }, + } +} + +fn cycle_allowed(zone: &Zone, currently_on: bool) -> bool { + let Some(last) = zone.last_action_at else { return true; }; + let elapsed = (Utc::now() - last).num_seconds().max(0) as u64; + if currently_on { elapsed >= zone.min_on_seconds } else { elapsed >= zone.min_off_seconds } +} + +fn active_setpoint(zone: &Zone, schedules: &[Schedule], now: DateTime) -> Option { + schedules.iter() + .filter(|item| item.enabled && item.zone_id == zone.id && schedule_active(item, now)) + .last() + .map(|item| item.setpoint) +} + +fn schedule_active(item: &Schedule, now: DateTime) -> bool { + let Ok(start) = NaiveTime::parse_from_str(&item.start_time, "%H:%M") else { return false; }; + let Ok(end) = NaiveTime::parse_from_str(&item.end_time, "%H:%M") else { return false; }; + let time = now.time(); + let today = now.weekday().number_from_monday(); + if start <= end { + item.weekdays.contains(&today) && time >= start && time < end + } else if time >= start { + item.weekdays.contains(&today) + } else if time < end { + let previous = previous_weekday(now.weekday()).number_from_monday(); + item.weekdays.contains(&previous) + } else { + false + } +} + +fn previous_weekday(day: Weekday) -> Weekday { + match day { + Weekday::Mon => Weekday::Sun, Weekday::Tue => Weekday::Mon, Weekday::Wed => Weekday::Tue, + Weekday::Thu => Weekday::Wed, Weekday::Fri => Weekday::Thu, Weekday::Sat => Weekday::Fri, + Weekday::Sun => Weekday::Sat, + } +} + +async fn run_automations(state: &AppState) -> Result<()> { + let devices = state.db.list_devices()?; + for mut item in state.db.list_automations()? { + if !item.enabled || !automation_ready(&item) { continue; } + let should_fire = match item.trigger_kind.as_str() { + "temperature_above" => find_temperature(&devices, item.trigger_device_id.as_deref()) + .zip(item.threshold).map(|(t, threshold)| t > threshold).unwrap_or(false), + "temperature_below" => find_temperature(&devices, item.trigger_device_id.as_deref()) + .zip(item.threshold).map(|(t, threshold)| t < threshold).unwrap_or(false), + "time" => item.at_time.as_deref().map(time_matches).unwrap_or(false), + _ => false, + }; + if !should_fire { continue; } + match send_command(state, &item.action_device_id, item.action.clone()).await { + Ok(_) => { + item.last_fired_at = Some(Utc::now()); + item.updated_at = Utc::now(); + state.db.save_automation(&item)?; + state.log("info", "automation.fired", &format!("Automation {} fired", item.name), json!({"automation_id": item.id})); + } + Err(err) => state.log("error", "automation.error", &err.to_string(), json!({"automation_id": item.id})), + } + } + Ok(()) +} + +fn find_temperature(devices: &[Device], device_id: Option<&str>) -> Option { + let id = device_id?; + devices.iter().find(|d| d.id == id)?.current_temperature +} + +fn automation_ready(item: &Automation) -> bool { + item.last_fired_at.map(|last| (Utc::now() - last).num_seconds().max(0) as u64 >= item.cooldown_seconds).unwrap_or(true) +} + +fn time_matches(expected: &str) -> bool { + let Ok(value) = NaiveTime::parse_from_str(expected, "%H:%M") else { return false; }; + let now = Local::now().time(); + now.hour() == value.hour() && now.minute() == value.minute() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn overnight_schedule_works() { + let now = Utc.with_ymd_and_hms(2025, 1, 7, 1, 0, 0).unwrap().with_timezone(&Local); // Tuesday + let item = Schedule { + id: "1".into(), zone_id: "z".into(), name: "night".into(), enabled: true, + weekdays: vec![1], start_time: "22:00".into(), end_time: "06:00".into(), setpoint: 20.0, + created_at: Utc::now(), updated_at: Utc::now(), + }; + assert!(schedule_active(&item, now)); + } + + fn test_zone(source: &str) -> Zone { + Zone { + id: "z".into(), name: "Room".into(), device_id: "d".into(), enabled: true, + mode: "heat".into(), setpoint: 21.0, hysteresis: 0.6, min_on_seconds: 180, min_off_seconds: 180, + sensor_source: source.into(), ha_entity_id: Some("sensor.room_temperature".into()), + external_sensor_weight: 0.4, max_sensor_difference: 3.0, device_temperature: None, external_temperature: None, + current_temperature: None, control_temperature_source: "device".into(), demand: false, last_action_at: None, + created_at: Utc::now(), updated_at: Utc::now(), + } + } + + #[test] + fn combined_temperature_prefers_room_sensor_weight() { + let zone = test_zone("combined"); + let (value, source, discrepancy) = select_zone_temperature(&zone, Some(22.0), Some(20.0)); + assert_eq!(value, Some(21.2)); + assert_eq!(source, "combined"); + assert!(!discrepancy); + } + + #[test] + fn combined_temperature_falls_back_on_large_discrepancy() { + let zone = test_zone("combined"); + let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.0), Some(27.0)); + assert_eq!(value, Some(21.0)); + assert_eq!(source, "device_discrepancy_fallback"); + assert!(discrepancy); + } + + #[test] + fn combined_temperature_falls_back_when_external_is_missing() { + let zone = test_zone("combined"); + let (value, source, discrepancy) = select_zone_temperature(&zone, Some(21.5), None); + assert_eq!(value, Some(21.5)); + assert_eq!(source, "device_fallback"); + assert!(!discrepancy); + } + +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..8651959 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,41 @@ +use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; +use serde_json::json; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("not found: {0}")] + NotFound(String), + #[error("invalid request: {0}")] + BadRequest(String), + #[error("unauthorized")] + Unauthorized, + #[error("device communication failed: {0}")] + Device(String), + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + Self::NotFound(v) => (StatusCode::NOT_FOUND, v.clone()), + Self::BadRequest(v) => (StatusCode::BAD_REQUEST, v.clone()), + Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), + Self::Device(v) => (StatusCode::BAD_GATEWAY, v.clone()), + Self::Internal(v) => { + tracing::error!(error = ?v, "internal error"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into()) + } + }; + (status, Json(json!({"error": message}))).into_response() + } +} + +impl From for AppError { + fn from(value: rusqlite::Error) -> Self { Self::Internal(value.into()) } +} + +impl From for AppError { + fn from(value: serde_json::Error) -> Self { Self::Internal(value.into()) } +} diff --git a/src/home_assistant.rs b/src/home_assistant.rs new file mode 100644 index 0000000..9c0eec4 --- /dev/null +++ b/src/home_assistant.rs @@ -0,0 +1,40 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::Value; +use url::Url; +use crate::models::HomeAssistantSettings; + +pub async fn read_temperature( + client: &reqwest::Client, + settings: &HomeAssistantSettings, + entity_override: Option<&str>, +) -> Result { + if settings.url.trim().is_empty() { bail!("Home Assistant URL is not configured") } + if settings.token.trim().is_empty() { bail!("Home Assistant token is not configured") } + let entity = entity_override.filter(|v| !v.trim().is_empty()) + .unwrap_or(settings.default_entity_id.trim()); + if entity.is_empty() { bail!("Home Assistant entity_id is not configured") } + + let mut base = Url::parse(settings.url.trim()).context("invalid Home Assistant URL")?; + if !matches!(base.scheme(), "http" | "https") { bail!("Home Assistant URL must use http or https") } + let path = format!("api/states/{}", entity.trim_start_matches('/')); + base = base.join(&path).context("cannot build Home Assistant API URL")?; + + let response = client.get(base) + .bearer_auth(settings.token.trim()) + .header("Accept", "application/json") + .send().await.context("Home Assistant request failed")?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + bail!("Home Assistant returned {status}: {}", body.chars().take(200).collect::()) + } + let payload: Value = response.json().await.context("invalid Home Assistant JSON")?; + let state = payload.get("state").and_then(Value::as_str) + .ok_or_else(|| anyhow!("Home Assistant state is missing"))?; + let mut temperature: f64 = state.parse().context("Home Assistant state is not a number")?; + let unit = payload.pointer("/attributes/unit_of_measurement").and_then(Value::as_str).unwrap_or("°C"); + if unit.eq_ignore_ascii_case("°F") || unit.eq_ignore_ascii_case("F") { + temperature = (temperature - 32.0) * 5.0 / 9.0; + } + Ok((temperature * 10.0).round() / 10.0) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..681fce9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,96 @@ +mod api; +mod config; +mod db; +mod engine; +mod error; +mod home_assistant; +mod models; +mod protocol; +mod queries; +mod state; + +use std::{sync::Arc, time::{Duration, Instant}}; +use anyhow::{Context, Result}; +use config::Config; +use db::Db; +use models::Device; +use protocol::GreeClient; +use state::AppState; +use tokio::{net::TcpListener, signal, sync::{broadcast, RwLock}}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() -> Result<()> { + let config = Config::load()?; + init_tracing(); + + let db = Db::open(&config.database)?; + let runtime_settings = db.load_runtime_settings()?.unwrap_or_else(|| config.runtime_defaults()); + db.save_runtime_settings(&runtime_settings)?; + + if config.simulate && config.auto_seed && db.count_devices()? == 0 { + db.save_device(&Device::simulated_default())?; + db.log_event( + "info", + "simulator.seeded", + "Created the default simulator device", + &serde_json::json!({"device_id":"sim-salon"}), + )?; + } + + let (events, _) = broadcast::channel(256); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .user_agent(concat!("gree-controller/", env!("CARGO_PKG_VERSION"))) + .build()?; + let state = AppState { + db, + settings: Arc::new(RwLock::new(runtime_settings.clone())), + config: Arc::new(config.clone()), + gree: GreeClient::new(runtime_settings.controller_id.clone()), + events, + http, + started: Instant::now(), + }; + + engine::start(state.clone()); + let app = api::router(state.clone()); + let listener = TcpListener::bind(config.bind).await + .with_context(|| format!("cannot bind HTTP server to {}", config.bind))?; + + tracing::info!( + address = %config.bind, + database = %config.database.display(), + simulator = config.simulate, + auth = !config.app_token.trim().is_empty(), + "GREE Controller started" + ); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + tracing::info!("GREE Controller stopped"); + Ok(()) +} + +fn init_tracing() { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "info,tower_http=info".into()); + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer().compact()) + .init(); +} + +async fn shutdown_signal() { + let ctrl_c = async { signal::ctrl_c().await.expect("cannot install Ctrl+C handler"); }; + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("cannot install SIGTERM handler") + .recv().await; + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { _ = ctrl_c => {}, _ = terminate => {} } +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..285b6f6 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,317 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +fn default_true() -> bool { true } +fn default_port() -> u16 { 7000 } +fn default_protocol() -> u8 { 1 } +fn default_mode() -> String { "cool".into() } +fn default_fan() -> u8 { 0 } +fn default_target() -> f64 { 24.0 } +fn default_hysteresis() -> f64 { 0.6 } +fn default_external_sensor_weight() -> f64 { 0.4 } +fn default_max_sensor_difference() -> f64 { 3.0 } +fn default_control_temperature_source() -> String { "device".into() } +fn default_min_cycle() -> u64 { 180 } +fn default_cooldown() -> u64 { 300 } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Device { + pub id: String, + pub mac: String, + pub name: String, + pub ip: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_protocol")] + pub protocol_version: u8, + #[serde(default)] + pub model: String, + #[serde(default)] + pub firmware: String, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub cid: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub simulated: bool, + #[serde(default)] + pub power: bool, + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default = "default_target")] + pub target_temperature: f64, + #[serde(default = "default_fan")] + pub fan_speed: u8, + #[serde(default)] + pub swing_vertical: bool, + #[serde(default)] + pub swing_horizontal: bool, + #[serde(default)] + pub quiet: bool, + #[serde(default)] + pub turbo: bool, + #[serde(default)] + pub light: bool, + #[serde(default)] + pub current_temperature: Option, + #[serde(default)] + pub outdoor_temperature: Option, + #[serde(default)] + pub online: bool, + #[serde(default)] + pub last_seen: Option>, + #[serde(default)] + pub last_error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Device { + pub fn simulated_default() -> Self { + let now = Utc::now(); + Self { + id: "sim-salon".into(), + mac: "SIM000000001".into(), + name: "Living Room (simulator)".into(), + ip: "127.0.0.1".into(), + port: 7000, + protocol_version: 1, + model: "GREE-SIM".into(), + firmware: "sim-1.0".into(), + key: None, + cid: Some("gree-controller".into()), + enabled: true, + simulated: true, + power: false, + mode: "cool".into(), + target_temperature: 23.0, + fan_speed: 0, + swing_vertical: false, + swing_horizontal: false, + quiet: false, + turbo: false, + light: true, + current_temperature: Some(26.0), + outdoor_temperature: Some(30.0), + online: true, + last_seen: Some(now), + last_error: None, + created_at: now, + updated_at: now, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DevicePatch { + pub name: Option, + pub ip: Option, + pub port: Option, + pub protocol_version: Option, + pub key: Option>, + pub enabled: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct DeviceCommand { + pub power: Option, + pub mode: Option, + pub target_temperature: Option, + pub fan_speed: Option, + pub swing_vertical: Option, + pub swing_horizontal: Option, + pub quiet: Option, + pub turbo: Option, + pub light: Option, +} + +impl DeviceCommand { + pub fn apply(&self, device: &mut Device) { + if let Some(v) = self.power { device.power = v; } + if let Some(v) = &self.mode { device.mode = v.clone(); } + if let Some(v) = self.target_temperature { device.target_temperature = v.clamp(8.0, 32.0); } + if let Some(v) = self.fan_speed { device.fan_speed = v.min(5); } + if let Some(v) = self.swing_vertical { device.swing_vertical = v; } + if let Some(v) = self.swing_horizontal { device.swing_horizontal = v; } + if let Some(v) = self.quiet { device.quiet = v; } + if let Some(v) = self.turbo { device.turbo = v; } + if let Some(v) = self.light { device.light = v; } + device.updated_at = Utc::now(); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Zone { + pub id: String, + pub name: String, + pub device_id: String, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_mode")] + pub mode: String, + #[serde(default = "default_target")] + pub setpoint: f64, + #[serde(default = "default_hysteresis")] + pub hysteresis: f64, + #[serde(default = "default_min_cycle")] + pub min_on_seconds: u64, + #[serde(default = "default_min_cycle")] + pub min_off_seconds: u64, + #[serde(default = "default_sensor_source")] + pub sensor_source: String, + #[serde(default)] + pub ha_entity_id: Option, + /// Weight of the optional room sensor when sensor_source is `combined`. + #[serde(default = "default_external_sensor_weight")] + pub external_sensor_weight: f64, + /// If GREE and external sensor differ more than this, the controller falls back to GREE. + #[serde(default = "default_max_sensor_difference")] + pub max_sensor_difference: f64, + /// Temperature reported by the GREE indoor sensor during the last zone cycle. + #[serde(default)] + pub device_temperature: Option, + /// Temperature reported by the per-zone external Home Assistant sensor. + #[serde(default)] + pub external_temperature: Option, + /// Temperature actually used by the zone controller. + #[serde(default)] + pub current_temperature: Option, + /// `device`, `external`, `combined`, `device_fallback`, or `device_discrepancy_fallback`. + #[serde(default = "default_control_temperature_source")] + pub control_temperature_source: String, + #[serde(default)] + pub demand: bool, + #[serde(default)] + pub last_action_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +fn default_sensor_source() -> String { "device".into() } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Schedule { + pub id: String, + pub zone_id: String, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + /// ISO weekday numbers, Monday=1, Sunday=7. + pub weekdays: Vec, + /// Local time HH:MM. + pub start_time: String, + /// Local time HH:MM. Ranges crossing midnight are supported. + pub end_time: String, + pub setpoint: f64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Automation { + pub id: String, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + /// temperature_above, temperature_below, time + pub trigger_kind: String, + #[serde(default)] + pub trigger_device_id: Option, + #[serde(default)] + pub threshold: Option, + #[serde(default)] + pub at_time: Option, + pub action_device_id: String, + #[serde(default)] + pub action: DeviceCommand, + #[serde(default = "default_cooldown")] + pub cooldown_seconds: u64, + #[serde(default)] + pub last_fired_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Reading { + pub id: i64, + pub device_id: String, + pub timestamp: DateTime, + pub indoor_temperature: Option, + pub outdoor_temperature: Option, + pub target_temperature: f64, + pub power: bool, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventLog { + pub id: i64, + pub timestamp: DateTime, + pub level: String, + pub kind: String, + pub message: String, + pub metadata: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HomeAssistantSettings { + #[serde(default)] + pub url: String, + #[serde(default)] + pub token: String, + #[serde(default)] + pub default_entity_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSettings { + pub controller_id: String, + pub simulator_enabled: bool, + pub poll_interval_seconds: u64, + pub zone_interval_seconds: u64, + pub discovery_timeout_ms: u64, + pub discovery_broadcast: String, + pub home_assistant: HomeAssistantSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveryRequest { + #[serde(default)] + pub timeout_ms: Option, + #[serde(default)] + pub broadcast: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManualDeviceRequest { + pub name: String, + pub mac: String, + pub ip: String, + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_protocol")] + pub protocol_version: u8, + #[serde(default)] + pub key: Option, + #[serde(default)] + pub simulated: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiTokenInfo { + pub id: String, + pub name: String, + pub token_prefix: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApiEvent { + pub event: String, + pub timestamp: DateTime, + pub data: Value, +} diff --git a/src/protocol/crypto.rs b/src/protocol/crypto.rs new file mode 100644 index 0000000..86ef425 --- /dev/null +++ b/src/protocol/crypto.rs @@ -0,0 +1,112 @@ +use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}}; +use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}}; +use anyhow::{anyhow, bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use rand::RngCore; + +pub const GENERIC_GREE_KEY: &str = "a3K8Bx%2r8Y7cB!a"; + +pub fn normalize_key(key: &str) -> Result<[u8; 16]> { + let bytes = key.as_bytes(); + if bytes.len() == 16 { + let mut out = [0_u8; 16]; + out.copy_from_slice(bytes); + return Ok(out); + } + if let Ok(decoded) = STANDARD.decode(key) { + if decoded.len() == 16 { + let mut out = [0_u8; 16]; + out.copy_from_slice(&decoded); + return Ok(out); + } + } + bail!("GREE key must contain 16 bytes or base64-encoded 16 bytes") +} + +pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result { + let key = normalize_key(key)?; + let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?; + let pad = 16 - (plaintext.len() % 16); + let mut data = Vec::with_capacity(plaintext.len() + pad); + data.extend_from_slice(plaintext); + data.extend(std::iter::repeat(pad as u8).take(pad)); + for block in data.chunks_exact_mut(16) { + cipher.encrypt_block(GenericArray::from_mut_slice(block)); + } + Ok(STANDARD.encode(data)) +} + +pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result> { + let key = normalize_key(key)?; + let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?; + let mut data = STANDARD.decode(ciphertext_b64).context("invalid base64 packet")?; + if data.is_empty() || data.len() % 16 != 0 { + bail!("invalid AES-ECB ciphertext length") + } + for block in data.chunks_exact_mut(16) { + cipher.decrypt_block(GenericArray::from_mut_slice(block)); + } + let pad = *data.last().ok_or_else(|| anyhow!("empty plaintext"))? as usize; + if pad == 0 || pad > 16 || data.len() < pad || data[data.len() - pad..].iter().any(|v| *v as usize != pad) { + bail!("invalid PKCS#7 padding") + } + data.truncate(data.len() - pad); + Ok(data) +} + +#[derive(Debug, Clone)] +pub struct V2Encrypted { + pub ciphertext: String, + pub nonce: String, + pub tag: String, +} + +pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result { + let key = normalize_key(key)?; + let cipher = ::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?; + let mut nonce_bytes = [0_u8; 12]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let mut buffer = plaintext.to_vec(); + let tag = cipher.encrypt_in_place_detached(nonce, b"", &mut buffer) + .map_err(|_| anyhow!("AES-GCM encryption failed"))?; + Ok(V2Encrypted { + ciphertext: STANDARD.encode(buffer), + nonce: STANDARD.encode(nonce_bytes), + tag: STANDARD.encode(tag), + }) +} + +pub fn decrypt_v2(key: &str, ciphertext_b64: &str, nonce_b64: &str, tag_b64: &str) -> Result> { + let key = normalize_key(key)?; + let cipher = ::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?; + let nonce_bytes = STANDARD.decode(nonce_b64).context("invalid GCM nonce")?; + if nonce_bytes.len() != 12 { bail!("invalid GCM nonce length") } + let tag_bytes = STANDARD.decode(tag_b64).context("invalid GCM tag")?; + if tag_bytes.len() != 16 { bail!("invalid GCM tag length") } + let mut data = STANDARD.decode(ciphertext_b64).context("invalid GCM ciphertext")?; + let nonce = Nonce::from_slice(&nonce_bytes); + let tag = GenericArray::from_slice(&tag_bytes); + cipher.decrypt_in_place_detached(nonce, b"", &mut data, tag) + .map_err(|_| anyhow!("AES-GCM authentication failed"))?; + Ok(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v1_round_trip() { + let value = br#"{"t":"status","mac":"112233445566"}"#; + let encrypted = encrypt_v1(GENERIC_GREE_KEY, value).unwrap(); + assert_eq!(decrypt_v1(GENERIC_GREE_KEY, &encrypted).unwrap(), value); + } + + #[test] + fn v2_round_trip() { + let value = b"gree-gcm-test"; + let encrypted = encrypt_v2(GENERIC_GREE_KEY, value).unwrap(); + assert_eq!(decrypt_v2(GENERIC_GREE_KEY, &encrypted.ciphertext, &encrypted.nonce, &encrypted.tag).unwrap(), value); + } +} diff --git a/src/protocol/gree.rs b/src/protocol/gree.rs new file mode 100644 index 0000000..e791afd --- /dev/null +++ b/src/protocol/gree.rs @@ -0,0 +1,259 @@ +use std::{collections::HashSet, net::SocketAddr, sync::{Arc, atomic::{AtomicU64, Ordering}}, time::Duration}; +use anyhow::{anyhow, bail, Context, Result}; +use chrono::Utc; +use serde_json::{json, Value}; +use tokio::{net::UdpSocket, time::{timeout, Instant}}; +use uuid::Uuid; +use crate::models::{Device, DeviceCommand}; +use super::crypto::{decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, GENERIC_GREE_KEY}; + +#[derive(Clone)] +pub struct GreeClient { + controller_id: String, + sequence: Arc, +} + +impl GreeClient { + pub fn new(controller_id: String) -> Self { + Self { controller_id, sequence: Arc::new(AtomicU64::new(1)) } + } + + fn next_id(&self) -> u64 { self.sequence.fetch_add(1, Ordering::Relaxed) } + + pub async fn discover(&self, broadcast: &str, duration: Duration) -> Result> { + let target: SocketAddr = broadcast.parse().context("invalid discovery broadcast address")?; + let socket = UdpSocket::bind("0.0.0.0:0").await?; + socket.set_broadcast(true)?; + socket.send_to(br#"{"t":"scan"}"#, target).await?; + + let deadline = Instant::now() + duration; + let mut result = Vec::new(); + let mut seen = HashSet::new(); + let mut buffer = vec![0_u8; 8192]; + + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + match timeout(remaining.min(Duration::from_millis(450)), socket.recv_from(&mut buffer)).await { + Ok(Ok((size, source))) => { + if let Ok(value) = serde_json::from_slice::(&buffer[..size]) { + if let Some(mut device) = self.parse_discovery(value, source) { + let key = device.mac.to_ascii_lowercase(); + if seen.insert(key) { + device.last_seen = Some(Utc::now()); + result.push(device); + } + } + } + } + Ok(Err(err)) => return Err(err.into()), + Err(_) => continue, + } + } + Ok(result) + } + + fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Option { + if value.get("t").and_then(Value::as_str) == Some("pack") { + if let Some(pack) = value.get("pack").and_then(Value::as_str) { + if let Ok(clear) = decrypt_v1(GENERIC_GREE_KEY, pack) { + if let Ok(inner) = serde_json::from_slice::(&clear) { value = inner; } + } + } + } + let kind = value.get("t").and_then(Value::as_str).unwrap_or_default(); + if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() { + return None; + } + let mac = value.get("mac").or_else(|| value.get("cid"))?.as_str()?.replace(':', ""); + if mac.is_empty() { return None; } + let name = value.get("name").and_then(Value::as_str) + .filter(|v| !v.trim().is_empty()) + .unwrap_or("Klimatyzator GREE").to_string(); + let model = value.get("model").or_else(|| value.get("series")) + .and_then(Value::as_str).unwrap_or_default().to_string(); + let firmware = value.get("ver").and_then(Value::as_str).unwrap_or_default().to_string(); + let protocol_version = value.get("protocol").and_then(Value::as_u64) + .or_else(|| value.get("v").and_then(Value::as_u64)) + .map(|v| v as u8) + .unwrap_or_else(|| if value.get("tag").is_some() || value.get("nonce").is_some() { 2 } else { 1 }); + let now = Utc::now(); + Some(Device { + id: format!("gree-{}", mac.to_ascii_lowercase()), + mac, + name, + ip: source.ip().to_string(), + port: source.port(), + protocol_version, + model, + firmware, + key: None, + cid: Some(self.controller_id.clone()), + enabled: true, + simulated: false, + power: false, + mode: "cool".into(), + target_temperature: 24.0, + fan_speed: 0, + swing_vertical: false, + swing_horizontal: false, + quiet: false, + turbo: false, + light: true, + current_temperature: None, + outdoor_temperature: None, + online: true, + last_seen: Some(now), + last_error: None, + created_at: now, + updated_at: now, + }) + } + + pub async fn bind(&self, device: &Device) -> Result { + let inner = json!({"mac": device.mac, "t": "bind", "uid": 0}); + let response = self.request(device, &inner, GENERIC_GREE_KEY, true).await?; + let key = response.get("key").and_then(Value::as_str) + .ok_or_else(|| anyhow!("bind response does not contain device key"))?; + if key.is_empty() { bail!("device returned an empty key") } + Ok(key.to_string()) + } + + pub async fn poll(&self, device: &mut Device) -> Result<()> { + let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?; + let cols = [ + "Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig", + "SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType", + "TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem" + ]; + let inner = json!({"cols": cols, "mac": device.mac, "t": "status"}); + let response = self.request(device, &inner, key, false).await?; + let response_cols = response.get("cols").and_then(Value::as_array) + .ok_or_else(|| anyhow!("status response has no cols"))?; + let data = response.get("dat").and_then(Value::as_array) + .ok_or_else(|| anyhow!("status response has no dat"))?; + for (name, value) in response_cols.iter().zip(data.iter()) { + let Some(name) = name.as_str() else { continue; }; + match name { + "Pow" => device.power = value_as_i64(value) != 0, + "Mod" => device.mode = mode_name(value_as_i64(value)).into(), + "SetTem" => device.target_temperature = value_as_f64(value).clamp(8.0, 32.0), + "WdSpd" => device.fan_speed = value_as_i64(value).clamp(0, 5) as u8, + "SwUpDn" => device.swing_vertical = value_as_i64(value) != 0, + "SwingLfRig" => device.swing_horizontal = value_as_i64(value) != 0, + "Quiet" => device.quiet = value_as_i64(value) != 0, + "Tur" => device.turbo = value_as_i64(value) != 0, + "Lig" => device.light = value_as_i64(value) != 0, + "TemSen" => { + let raw = value_as_f64(value); + device.current_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw }); + } + _ => {} + } + } + device.online = true; + device.last_seen = Some(Utc::now()); + device.last_error = None; + device.updated_at = Utc::now(); + Ok(()) + } + + pub async fn command(&self, device: &Device, command: &DeviceCommand) -> Result { + let key = device.key.as_deref().ok_or_else(|| anyhow!("device is not bound"))?; + let mut opt = Vec::<&str>::new(); + let mut values = Vec::::new(); + if let Some(v) = command.power { opt.push("Pow"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = &command.mode { opt.push("Mod"); values.push(json!(mode_value(v)?)); } + if let Some(v) = command.target_temperature { opt.push("SetTem"); values.push(json!(v.clamp(8.0, 32.0).round() as i64)); } + if let Some(v) = command.fan_speed { opt.push("WdSpd"); values.push(json!(v.min(5))); } + if let Some(v) = command.swing_vertical { opt.push("SwUpDn"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.swing_horizontal { opt.push("SwingLfRig"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.quiet { opt.push("Quiet"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.turbo { opt.push("Tur"); values.push(json!(if v { 1 } else { 0 })); } + if let Some(v) = command.light { opt.push("Lig"); values.push(json!(if v { 1 } else { 0 })); } + if opt.is_empty() { bail!("empty device command") } + let inner = json!({"opt": opt, "p": values, "t": "cmd"}); + self.request(device, &inner, key, false).await + } + + async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool) -> Result { + let target: SocketAddr = format!("{}:{}", device.ip, device.port).parse() + .context("invalid device address")?; + let inner_bytes = serde_json::to_vec(inner)?; + let mut outer = json!({ + "cid": self.controller_id, + "i": self.next_id(), + "t": "pack", + "tcid": device.mac, + "uid": 0 + }); + if device.protocol_version >= 2 && !binding { + let encrypted = encrypt_v2(key, &inner_bytes)?; + outer["pack"] = json!(encrypted.ciphertext); + outer["nonce"] = json!(encrypted.nonce); + outer["tag"] = json!(encrypted.tag); + } else { + outer["pack"] = json!(encrypt_v1(key, &inner_bytes)?); + } + let payload = serde_json::to_vec(&outer)?; + let socket = UdpSocket::bind("0.0.0.0:0").await?; + socket.send_to(&payload, target).await?; + let mut buffer = vec![0_u8; 16 * 1024]; + let (size, _) = timeout(Duration::from_secs(4), socket.recv_from(&mut buffer)) + .await.context("GREE response timeout")??; + let response: Value = serde_json::from_slice(&buffer[..size]).context("invalid GREE JSON response")?; + let pack = response.get("pack").and_then(Value::as_str) + .ok_or_else(|| anyhow!("GREE response does not contain encrypted pack"))?; + let clear = if let (Some(nonce), Some(tag)) = ( + response.get("nonce").and_then(Value::as_str), + response.get("tag").and_then(Value::as_str), + ) { + decrypt_v2(key, pack, nonce, tag)? + } else { + decrypt_v1(key, pack)? + }; + let decoded: Value = serde_json::from_slice(&clear).context("invalid decrypted GREE response")?; + if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { + bail!("GREE device error: {err}") + } + Ok(decoded) + } +} + +fn value_as_i64(value: &Value) -> i64 { + value.as_i64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default() +} + +fn value_as_f64(value: &Value) -> f64 { + value.as_f64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default() +} + +fn mode_name(value: i64) -> &'static str { + match value { 0 => "auto", 1 => "cool", 2 => "dry", 3 => "fan", 4 => "heat", _ => "auto" } +} + +fn mode_value(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "auto" => Ok(0), "cool" => Ok(1), "dry" => Ok(2), "fan" => Ok(3), "heat" => Ok(4), + _ => bail!("unsupported mode: {value}"), + } +} + +pub fn merge_discovered(existing: Option, discovered: Device) -> Device { + if let Some(mut old) = existing { + old.ip = discovered.ip; + old.port = discovered.port; + if old.name.trim().is_empty() || old.name == "Klimatyzator GREE" { old.name = discovered.name; } + if !discovered.model.is_empty() { old.model = discovered.model; } + if !discovered.firmware.is_empty() { old.firmware = discovered.firmware; } + old.protocol_version = discovered.protocol_version; + old.online = true; + old.last_seen = Some(Utc::now()); + old.last_error = None; + old.updated_at = Utc::now(); + old + } else { + let mut new = discovered; + if new.id.is_empty() { new.id = Uuid::new_v4().to_string(); } + new + } +} diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs new file mode 100644 index 0000000..1208d79 --- /dev/null +++ b/src/protocol/mod.rs @@ -0,0 +1,4 @@ +pub mod crypto; +pub mod gree; + +pub use gree::{GreeClient, merge_discovered}; diff --git a/src/queries.rs b/src/queries.rs new file mode 100644 index 0000000..e29bae0 --- /dev/null +++ b/src/queries.rs @@ -0,0 +1,196 @@ +//! Centralized SQLite statements used by the controller. +//! +//! Keep SQL in this module so database access code stays focused on mapping, +//! transactions and domain behavior. New queries and schema migrations should +//! be added here instead of embedding SQL strings in other Rust modules. + +pub const INIT_SCHEMA: &str = r#" +PRAGMA journal_mode=WAL; +PRAGMA synchronous=NORMAL; +PRAGMA foreign_keys=ON; + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + mac TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + ip TEXT NOT NULL, + simulated INTEGER NOT NULL DEFAULT 0, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS zones ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + zone_id TEXT NOT NULL, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS schedules_zone_idx ON schedules(zone_id); + +CREATE TABLE IF NOT EXISTS automations ( + id TEXT PRIMARY KEY, + payload TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + indoor_temperature REAL, + outdoor_temperature REAL, + target_temperature REAL NOT NULL, + power INTEGER NOT NULL, + source TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS readings_device_time_idx + ON readings(device_id, timestamp DESC); + +CREATE TABLE IF NOT EXISTS event_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + level TEXT NOT NULL, + kind TEXT NOT NULL, + message TEXT NOT NULL, + metadata TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS event_log_time_idx ON event_log(timestamp DESC); + +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + created_at TEXT NOT NULL +); + +INSERT OR IGNORE INTO schema_migrations(version, applied_at) + VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +INSERT OR IGNORE INTO schema_migrations(version, applied_at) + VALUES (2, strftime('%Y-%m-%dT%H:%M:%fZ','now')); +"#; + +pub const COUNT_DEVICES: &str = "SELECT COUNT(*) FROM devices"; + +pub const UPSERT_DEVICE: &str = r#" +INSERT INTO devices(id, mac, name, ip, simulated, payload, updated_at) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) +ON CONFLICT(id) DO UPDATE SET + mac=excluded.mac, + name=excluded.name, + ip=excluded.ip, + simulated=excluded.simulated, + payload=excluded.payload, + updated_at=excluded.updated_at +"#; + +pub const LIST_DEVICES: &str = "SELECT payload FROM devices ORDER BY name COLLATE NOCASE"; +pub const GET_DEVICE_BY_ID: &str = "SELECT payload FROM devices WHERE id=?1"; +pub const GET_DEVICE_BY_MAC: &str = "SELECT payload FROM devices WHERE lower(mac)=lower(?1)"; +pub const DELETE_DEVICE_READINGS: &str = "DELETE FROM readings WHERE device_id=?1"; +pub const DELETE_ZONES_BY_DEVICE_ID: &str = + "DELETE FROM zones WHERE json_extract(payload, '$.device_id')=?1"; +pub const DELETE_DEVICE: &str = "DELETE FROM devices WHERE id=?1"; + +pub const UPSERT_ZONE: &str = r#" +INSERT INTO zones(id,payload,updated_at) VALUES(?1,?2,?3) +ON CONFLICT(id) DO UPDATE SET + payload=excluded.payload, + updated_at=excluded.updated_at +"#; +pub const LIST_ZONES: &str = + "SELECT payload FROM zones ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; +pub const GET_ZONE: &str = "SELECT payload FROM zones WHERE id=?1"; +pub const DELETE_SCHEDULES_BY_ZONE_ID: &str = "DELETE FROM schedules WHERE zone_id=?1"; +pub const DELETE_ZONE: &str = "DELETE FROM zones WHERE id=?1"; + +pub const UPSERT_SCHEDULE: &str = r#" +INSERT INTO schedules(id,zone_id,payload,updated_at) VALUES(?1,?2,?3,?4) +ON CONFLICT(id) DO UPDATE SET + zone_id=excluded.zone_id, + payload=excluded.payload, + updated_at=excluded.updated_at +"#; +pub const LIST_SCHEDULES: &str = + "SELECT payload FROM schedules ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; +pub const GET_SCHEDULE: &str = "SELECT payload FROM schedules WHERE id=?1"; +pub const DELETE_SCHEDULE: &str = "DELETE FROM schedules WHERE id=?1"; + +pub const UPSERT_AUTOMATION: &str = r#" +INSERT INTO automations(id,payload,updated_at) VALUES(?1,?2,?3) +ON CONFLICT(id) DO UPDATE SET + payload=excluded.payload, + updated_at=excluded.updated_at +"#; +pub const LIST_AUTOMATIONS: &str = + "SELECT payload FROM automations ORDER BY json_extract(payload, '$.name') COLLATE NOCASE"; +pub const GET_AUTOMATION: &str = "SELECT payload FROM automations WHERE id=?1"; +pub const DELETE_AUTOMATION: &str = "DELETE FROM automations WHERE id=?1"; + +pub const INSERT_READING: &str = r#" +INSERT INTO readings( + device_id, + timestamp, + indoor_temperature, + outdoor_temperature, + target_temperature, + power, + source +) +VALUES(?1,?2,?3,?4,?5,?6,?7) +"#; + +pub const LIST_READINGS_BY_DEVICE: &str = r#" +SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source +FROM readings +WHERE device_id=?1 AND timestamp>=?2 +ORDER BY timestamp ASC +LIMIT ?3 +"#; + +pub const LIST_READINGS_ALL: &str = r#" +SELECT id,device_id,timestamp,indoor_temperature,outdoor_temperature,target_temperature,power,source +FROM readings +WHERE timestamp>=?1 +ORDER BY timestamp ASC +LIMIT ?2 +"#; + +pub const PRUNE_READINGS: &str = "DELETE FROM readings WHERE timestamp < ?1"; + +pub const INSERT_EVENT: &str = + "INSERT INTO event_log(timestamp,level,kind,message,metadata) VALUES(?1,?2,?3,?4,?5)"; +pub const LIST_EVENTS: &str = + "SELECT id,timestamp,level,kind,message,metadata FROM event_log ORDER BY id DESC LIMIT ?1"; + +pub const LIST_API_TOKENS: &str = + "SELECT id,name,token_prefix,created_at FROM api_tokens ORDER BY created_at DESC"; +pub const INSERT_API_TOKEN: &str = + "INSERT INTO api_tokens(id,name,token_hash,token_prefix,created_at) VALUES(?1,?2,?3,?4,?5)"; +pub const API_TOKEN_EXISTS: &str = "SELECT 1 FROM api_tokens WHERE token_hash=?1 LIMIT 1"; +pub const DELETE_API_TOKEN: &str = "DELETE FROM api_tokens WHERE id=?1"; + +pub const LOAD_RUNTIME_SETTINGS: &str = "SELECT value FROM settings WHERE key='runtime'"; +pub const UPSERT_RUNTIME_SETTINGS: &str = r#" +INSERT INTO settings(key,value,updated_at) VALUES('runtime',?1,?2) +ON CONFLICT(key) DO UPDATE SET + value=excluded.value, + updated_at=excluded.updated_at +"#; diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..1f9e1d3 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,38 @@ +use std::{sync::Arc, time::Instant}; +use chrono::Utc; +use serde_json::Value; +use tokio::sync::{broadcast, RwLock}; +use crate::{config::Config, db::Db, models::{ApiEvent, RuntimeSettings}, protocol::GreeClient}; + +#[derive(Clone)] +pub struct AppState { + pub db: Db, + pub settings: Arc>, + pub config: Arc, + pub gree: GreeClient, + pub events: broadcast::Sender, + pub http: reqwest::Client, + pub started: Instant, +} + +impl AppState { + pub fn broadcast(&self, event: impl Into, data: Value) { + let _ = self.events.send(ApiEvent { + event: event.into(), + timestamp: Utc::now(), + data, + }); + } + + pub fn log(&self, level: &str, kind: &str, message: &str, metadata: Value) { + if let Err(err) = self.db.log_event(level, kind, message, &metadata) { + tracing::warn!(error=?err, "cannot persist event log"); + } + self.broadcast("log.created", serde_json::json!({ + "level": level, + "kind": kind, + "message": message, + "metadata": metadata, + })); + } +} diff --git a/systemd/gree-controller.service b/systemd/gree-controller.service new file mode 100644 index 0000000..6d85089 --- /dev/null +++ b/systemd/gree-controller.service @@ -0,0 +1,26 @@ +[Unit] +Description=GREE Controller +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=gree-controller +Group=gree-controller +WorkingDirectory=/var/lib/gree-controller +EnvironmentFile=-/etc/gree-controller.env +ExecStart=/opt/gree-controller/gree-controller +Restart=on-failure +RestartSec=3 +TimeoutStopSec=20 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/var/lib/gree-controller +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +LockPersonality=true +MemoryDenyWriteExecute=true + +[Install] +WantedBy=multi-user.target diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..0b63f57 --- /dev/null +++ b/web/app.js @@ -0,0 +1,633 @@ +'use strict'; + +const getCookie = name => { + const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`)); + return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : ''; +}; +const setCookie = (name, value) => { + document.cookie = `${name}=${encodeURIComponent(value)}; Max-Age=31536000; Path=/; SameSite=Lax`; +}; + +const DEFAULT_LANGUAGE = 'en'; +const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGUAGE; +const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system'; + +const app = { + devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, + token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null, + currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting', + languages: [], translations: {}, locales: {}, +}; + +const $ = (selector, root = document) => root.querySelector(selector); +const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; +const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); +const esc = value => String(value ?? '').replace(/[&<>'"]/g, char => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char])); +const locale = () => app.locales[app.language] || app.locales[DEFAULT_LANGUAGE] || 'en-GB'; +const tr = (key, params = {}) => { + const template = app.translations[app.language]?.[key] ?? app.translations[DEFAULT_LANGUAGE]?.[key] ?? key; + return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => params[name] ?? `{${name}}`); +}; +const fmtTemp = value => Number.isFinite(Number(value)) ? `${Number(value).toFixed(1)}°C` : '--'; +const modeLabel = mode => tr(`mode.${mode}`) === `mode.${mode}` ? mode : tr(`mode.${mode}`); +const fanLabel = value => ({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value] ? tr({0:'fan.auto',1:'fan.low',2:'fan.mediumLow',3:'fan.medium',4:'fan.mediumHigh',5:'fan.high'}[value]) : value); +const dateTime = value => value ? new Intl.DateTimeFormat(locale(), {dateStyle:'short', timeStyle:'short'}).format(new Date(value)) : '—'; + +function applyTheme() { + const resolved = app.theme === 'light' || app.theme === 'dark' + ? app.theme + : (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'); + document.documentElement.dataset.theme = resolved; + const meta = $('#themeColorMeta'); + if (meta) meta.content = resolved === 'light' ? '#f5f7f6' : '#0a1110'; + const select = $('#themeSelect'); + if (select) select.value = app.theme; + drawCurrentChartIfVisible(); +} + +function applyTranslations() { + document.documentElement.lang = app.language; + $$('[data-i18n]').forEach(node => { node.textContent = tr(node.dataset.i18n); }); + $$('[data-i18n-placeholder]').forEach(node => { node.placeholder = tr(node.dataset.i18nPlaceholder); }); + $$('[data-i18n-title]').forEach(node => { node.title = tr(node.dataset.i18nTitle); }); + $$('[data-i18n-aria]').forEach(node => { node.setAttribute('aria-label', tr(node.dataset.i18nAria)); }); + $$('[data-i18n-content]').forEach(node => { node.setAttribute('content', tr(node.dataset.i18nContent)); }); + $$('[data-day]').forEach(node => { node.textContent = tr(`day.${node.dataset.day}`); }); + $('#languageSelect').value = app.language; + $('#themeSelect').value = app.theme; + const connectionLabel = $('#connectionLabel'); + if (connectionLabel) connectionLabel.textContent = tr(`status.${app.connectionStatus}`); + renderAll(); + if (app.currentView === 'logs') loadLogs(); + if (app.currentView === 'history' && app.devices.length) loadHistory(); +} + +function setLanguage(language) { + const available = app.languages.some(item => item.code === language); + app.language = available ? language : DEFAULT_LANGUAGE; + setCookie('gree_controller_language', app.language); + applyTranslations(); +} + +function renderLanguageOptions() { + const select = $('#languageSelect'); + if (!select) return; + select.innerHTML = app.languages.map(item => { + const label = item.native_name || item.name || item.code.toUpperCase(); + return ``; + }).join(''); + select.value = app.language; +} + +async function loadLanguages() { + try { + const response = await fetch('/lang/index.json', {cache: 'no-cache'}); + if (!response.ok) throw new Error(`Language index HTTP ${response.status}`); + const manifest = await response.json(); + const languages = Array.isArray(manifest.languages) ? manifest.languages : []; + if (!languages.some(item => item.code === DEFAULT_LANGUAGE)) throw new Error('Default English language pack is missing'); + + const loaded = await Promise.all(languages.map(async item => { + const packResponse = await fetch(item.path || `/lang/${encodeURIComponent(item.code)}.json`, {cache: 'no-cache'}); + if (!packResponse.ok) throw new Error(`Language ${item.code} HTTP ${packResponse.status}`); + const pack = await packResponse.json(); + return {item, pack}; + })); + + app.languages = loaded.map(({item}) => item); + app.translations = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.translations || {}])); + app.locales = Object.fromEntries(loaded.map(({item, pack}) => [item.code, pack.meta?.locale || item.locale || item.code])); + app.language = app.languages.some(item => item.code === preferredLanguage) + ? preferredLanguage + : (manifest.default || DEFAULT_LANGUAGE); + if (!app.languages.some(item => item.code === app.language)) app.language = DEFAULT_LANGUAGE; + renderLanguageOptions(); + } catch (error) { + console.error('Unable to load language packs:', error); + app.languages = [{code: DEFAULT_LANGUAGE, name: 'English', native_name: 'English', locale: 'en-GB'}]; + app.translations = {[DEFAULT_LANGUAGE]: {}}; + app.locales = {[DEFAULT_LANGUAGE]: 'en-GB'}; + app.language = DEFAULT_LANGUAGE; + renderLanguageOptions(); + } +} + +function setTheme(theme) { + app.theme = ['system', 'light', 'dark'].includes(theme) ? theme : 'system'; + setCookie('gree_controller_theme', app.theme); + applyTheme(); +} + +async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + headers.set('Accept', 'application/json'); + if (app.token) headers.set('Authorization', `Bearer ${app.token}`); + let body = options.body; + if (body !== undefined && body !== null && typeof body !== 'string') { + headers.set('Content-Type', 'application/json'); + body = JSON.stringify(body); + } + const response = await fetch(path, {...options, headers, body}); + if (response.status === 401) { + showTokenDialog(); + throw new Error(tr('auth.invalid')); + } + if (!response.ok) { + let message = tr('error.http', {status: response.status}); + try { message = (await response.json()).error || message; } catch (_) {} + throw new Error(message); + } + if (response.status === 204) return null; + return response.json(); +} + +function toast(message, error = false) { + const node = $('#toast'); + node.textContent = message; + node.className = error ? 'show error' : 'show'; + clearTimeout(node._timer); + node._timer = setTimeout(() => node.className = '', 3200); +} + +function showTokenDialog() { + const dialog = $('#tokenDialog'); + if (!dialog.open) dialog.showModal(); +} + +async function loadBootstrap(showMessage = false) { + if (app.loading) return; + app.loading = true; + try { + const data = await api('/api/bootstrap'); + app.devices = data.devices || []; + app.zones = data.zones || []; + app.schedules = data.schedules || []; + app.automations = data.automations || []; + app.accessTokens = data.access_tokens || []; + app.settings = data.settings || null; + app.system = data.system || {}; + renderAll(); + if (showMessage) toast(tr('common.updated')); + if ($('#tokenDialog').open) $('#tokenDialog').close(); + connectWebSocket(); + } catch (error) { + if (!String(error.message).toLowerCase().includes('token')) toast(error.message, true); + } finally { + app.loading = false; + } +} + +function renderAll() { + renderSummary(); + renderDevices(); + renderZones(); + renderSchedules(); + renderAutomations(); + renderAccessTokens(); + fillSelects(); + renderSettings(); +} + +function renderSummary() { + const temperatures = app.devices.map(d => d.current_temperature).filter(Number.isFinite); + const average = temperatures.length ? temperatures.reduce((a,b) => a+b, 0) / temperatures.length : null; + const online = app.devices.filter(d => d.online).length; + const active = app.devices.filter(d => d.power).length; + const demand = app.zones.filter(z => z.enabled && z.demand).length; + $('#heroTemperature').innerHTML = `${average === null ? '--' : average.toFixed(1)}°C`; + const activeSuffix = active ? tr('dashboard.summaryActive', {count: active}) : ''; + $('#summaryText').textContent = app.devices.length + ? tr('dashboard.summary', {online, total: app.devices.length, active: activeSuffix}) + : tr('dashboard.empty'); + $('#metrics').innerHTML = [ + [tr('dashboard.metricOnline'), `${online}/${app.devices.length}`], + [tr('dashboard.metricActive'), active], + [tr('dashboard.metricDemand'), demand], + ].map(([label,value]) => `
${esc(label)}${esc(value)}
`).join(''); +} + +function deviceCard(device, detailed = false) { + const modes = ['auto','cool','dry','fan','heat']; + const fans = [0,1,3,5]; + const error = device.last_error + ? `${esc(device.last_error)}` + : `${esc(device.ip)}:${esc(device.port)} · ${device.simulated ? tr('devices.simulator') : `V${device.protocol_version}`}`; + return `
+
+

${esc(device.name)}

${esc(tr(device.online ? 'status.online' : 'status.offline'))} · ${esc(device.model || device.mac)}

+ +
+
+ +
${Number(device.target_temperature).toFixed(1)}°C
+ +
+
${esc(tr('devices.currentTemperature'))}: ${fmtTemp(device.current_temperature)}${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}
+
${modes.map(mode => ``).join('')}
+
${fans.map(fan => ``).join('')}
+
+ + + +
+ ${detailed ? `` : ''} +
`; +} + +function renderDevices() { + const empty = `
${esc(tr('devices.emptyTitle'))}${esc(tr('devices.emptyText'))}
`; + $('#dashboardDevices').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, false)).join('') : empty; + $('#deviceList').innerHTML = app.devices.length ? app.devices.map(d => deviceCard(d, true)).join('') : empty; +} + +function zoneStrategyLabel(zone) { + if (zone.sensor_source === 'combined') return tr('zones.combinedSource'); + if (zone.sensor_source === 'home_assistant') return tr('zones.externalSource'); + return tr('zones.greeSource'); +} + +function zoneControlSourceLabel(source) { + return ({ + device: tr('zones.sourceDevice'), + external: tr('zones.sourceExternal'), + combined: tr('zones.sourceCombined'), + device_fallback: tr('zones.sourceFallback'), + device_discrepancy_fallback: tr('zones.sourceDiscrepancy'), + unavailable: tr('zones.sourceUnavailable'), + })[source] || source || tr('zones.sourceUnavailable'); +} + +function renderZones() { + $('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => { + const device = app.devices.find(d => d.id === zone.device_id); + const state = zone.enabled ? tr('common.active') : tr('common.disabled'); + const sensorDetails = zone.sensor_source === 'device' + ? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}` + : `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`; + return `
+

${esc(zone.name)}

${esc(device?.name || tr('common.noDevice'))} · ${esc(zoneStrategyLabel(zone))}${zone.ha_entity_id ? ` · ${esc(zone.ha_entity_id)}` : ''}

${esc(state)}
+
${esc(tr('zones.measurement'))}${fmtTemp(zone.current_temperature)}
${esc(tr('common.target'))}${fmtTemp(zone.setpoint)}
${esc(tr('zones.demand'))}${esc(tr(zone.demand ? 'common.on' : 'common.off'))}
+
${esc(sensorDetails)}
+ +
`; + }).join('') : `
${esc(tr('zones.emptyTitle'))}${esc(tr('zones.emptyText'))}
`; +} + +function renderSchedules() { + const dayNames = Array.from({length:7}, (_, index) => tr(`day.${index + 1}`)); + $('#scheduleList').innerHTML = app.schedules.length ? app.schedules.map(item => { + const zone = app.zones.find(z => z.id === item.zone_id); + const days = item.weekdays.map(day => dayNames[day-1]).join(', '); + return `

${esc(item.name)}

${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}

${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}
+
${esc(tr('common.from'))}${esc(item.start_time)}
${esc(tr('common.to'))}${esc(item.end_time)}
${esc(tr('common.target'))}${fmtTemp(item.setpoint)}
+
`; + }).join('') : `
${esc(tr('schedules.emptyTitle'))}${esc(tr('schedules.emptyText'))}
`; +} + +function renderAutomations() { + const triggerLabel = item => item.trigger_kind === 'time' + ? tr('automations.triggerAt', {time: item.at_time}) + : tr(item.trigger_kind === 'temperature_above' ? 'automations.triggerAbove' : 'automations.triggerBelow', {temperature: fmtTemp(item.threshold)}); + $('#automationList').innerHTML = app.automations.length ? app.automations.map(item => { + const actionDevice = app.devices.find(d => d.id === item.action_device_id); + return `

${esc(item.name)}

${esc(tr('automations.triggerSummary', {trigger: triggerLabel(item), device: actionDevice?.name || tr('common.noDevice')}))}

${esc(item.enabled ? tr('common.active') : tr('common.disabled'))}
+
${esc(tr('common.power'))}${item.action.power == null ? '—' : item.action.power ? tr('common.on') : tr('common.off')}
${esc(tr('common.mode'))}${item.action.mode ? esc(modeLabel(item.action.mode)) : '—'}
${esc(tr('automations.last'))}${item.last_fired_at ? new Date(item.last_fired_at).toLocaleTimeString(locale(),{hour:'2-digit',minute:'2-digit'}) : '—'}
+
`; + }).join('') : `
${esc(tr('automations.emptyTitle'))}${esc(tr('automations.emptyText'))}
`; +} + +function fillSelects() { + const deviceOptions = app.devices.map(d => ``).join(''); + const zoneOptions = app.zones.map(z => ``).join(''); + ['#zoneForm [name=device_id]', '#automationForm [name=trigger_device_id]', '#automationForm [name=action_device_id]'].forEach(selector => { + const select = $(selector); if (!select) return; + const current = select.value; select.innerHTML = deviceOptions; if ([...select.options].some(o => o.value === current)) select.value = current; + }); + const zoneSelect = $('#scheduleForm [name=zone_id]'); + if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; } + const history = $('#historyDevice'); + if (history) { const historyCurrent = history.value; history.innerHTML = deviceOptions; if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent; } +} + +function renderAccessTokens() { + const list = $('#accessTokenList'); + if (!list) return; + list.innerHTML = app.accessTokens.length ? app.accessTokens.map(item => ` +
+
${esc(item.name)}${esc(item.token_prefix)}${esc(tr('settings.created'))}: ${esc(dateTime(item.created_at))}
+ +
`).join('') : `
${esc(tr('settings.noTokens'))}${esc(tr('settings.noTokensHint'))}
`; +} + +function renderSettings() { + if (!app.settings) return; + const form = $('#settingsForm'); + form.controller_id.value = app.settings.controller_id || ''; + form.poll_interval_seconds.value = app.settings.poll_interval_seconds || 15; + form.zone_interval_seconds.value = app.settings.zone_interval_seconds || 5; + form.discovery_broadcast.value = app.settings.discovery_broadcast || '255.255.255.255:7000'; + form.discovery_timeout_ms.value = app.settings.discovery_timeout_ms || 3000; + form.simulator_enabled.checked = !!app.settings.simulator_enabled; + form.ha_url.value = app.settings.home_assistant?.url || ''; + form.ha_token.value = ''; + form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken'); + form.ha_entity_id.value = app.settings.home_assistant?.default_entity_id || ''; + $('#systemInfo').innerHTML = `

${esc(tr('settings.systemState'))}

${esc(tr('settings.version'))}: ${esc(app.system.version || '—')}
${esc(tr('settings.uptime'))}: ${esc(formatDuration(app.system.uptime_seconds || 0))}
${esc(tr('settings.apiAuth'))}: ${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}
`; +} + +function formatDuration(seconds) { + const days = Math.floor(seconds / 86400), hours = Math.floor((seconds % 86400) / 3600), minutes = Math.floor((seconds % 3600) / 60); + return `${days ? `${days}d ` : ''}${hours}h ${minutes}m`; +} + +function showView(name) { + app.currentView = name; + $$('.view').forEach(view => view.classList.toggle('active', view.dataset.view === name)); + $$('.bottom-nav button').forEach(button => button.classList.toggle('active', button.dataset.nav === name || (button.dataset.nav === 'more' && ['schedules','automations','settings','logs'].includes(name)))); + window.scrollTo({top: 0, behavior: 'smooth'}); + if (name === 'history' && app.devices.length) loadHistory(); + if (name === 'logs') loadLogs(); +} + +async function sendDeviceCommand(id, command) { + try { + const device = await api(`/api/devices/${encodeURIComponent(id)}/command`, {method:'POST', body:command}); + updateDevice(device); renderAll(); + } catch (error) { toast(error.message, true); } +} + +function updateDevice(device) { + const index = app.devices.findIndex(item => item.id === device.id); + if (index >= 0) app.devices[index] = device; else app.devices.push(device); +} + +async function deleteEntity(type, id, labelKey) { + if (!confirm(tr('confirm.delete', {label: tr(labelKey)}))) return; + try { await api(`/api/${type}/${encodeURIComponent(id)}`, {method:'DELETE'}); await loadBootstrap(); toast(tr('common.removed')); } + catch (error) { toast(error.message, true); } +} + +function openDialog(id) { + fillSelects(); + const dialog = document.getElementById(id); + if (dialog && !dialog.open) dialog.showModal(); +} + +function updateZoneSensorFields() { + const form = $('#zoneForm'); + if (!form) return; + const external = form.sensor_source.value !== 'device'; + $('#externalSensorFields').hidden = !external; + form.ha_entity_id.required = external; +} + +function populateZone(id) { + const item = app.zones.find(v => v.id === id); if (!item) return; + const form = $('#zoneForm'); form.reset(); fillSelects(); + Object.entries(item).forEach(([key,value]) => { if (form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; }); + form.external_sensor_weight_percent.value = Math.round(Number(item.external_sensor_weight ?? 0.4) * 100); + form.max_sensor_difference.value = Number(item.max_sensor_difference ?? 3); + form.enabled.checked = item.enabled; updateZoneSensorFields(); openDialog('zoneDialog'); +} + +function populateSchedule(id) { + const item = app.schedules.find(v => v.id === id); if (!item) return; + const form = $('#scheduleForm'); form.reset(); fillSelects(); + ['id','name','zone_id','start_time','end_time','setpoint'].forEach(key => form.elements[key].value = item[key]); + form.enabled.checked = item.enabled; + $$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value))); + openDialog('scheduleDialog'); +} + +function populateAutomation(id) { + const item = app.automations.find(v => v.id === id); if (!item) return; + const form = $('#automationForm'); form.reset(); fillSelects(); + ['id','name','trigger_kind','trigger_device_id','threshold','at_time','action_device_id','cooldown_seconds'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; }); + form.action_power.value = item.action.power == null ? '' : String(item.action.power); + form.action_mode.value = item.action.mode || ''; + form.action_target_temperature.value = item.action.target_temperature ?? ''; + form.enabled.checked = item.enabled; + openDialog('automationDialog'); +} + +async function loadHistory() { + const deviceId = $('#historyDevice').value; + if (!deviceId) return drawChart([]); + try { + const data = await api(`/api/readings?device_id=${encodeURIComponent(deviceId)}&hours=${encodeURIComponent($('#historyHours').value)}&limit=2500`); + drawChart(data.readings || []); + } catch (error) { toast(error.message, true); } +} + +function drawChart(readings) { + const canvas = $('#historyChart'); + const rect = canvas.getBoundingClientRect(); + const width = Math.max(680, Math.floor(rect.width || 680)); + const height = 340; + const dpr = window.devicePixelRatio || 1; + canvas.width = width * dpr; canvas.height = height * dpr; + canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; + const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); + const styles = getComputedStyle(document.documentElement); + const text = styles.getPropertyValue('--muted').trim(), grid = styles.getPropertyValue('--grid').trim(), indoor = styles.getPropertyValue('--accent').trim(), target = styles.getPropertyValue('--warning').trim(); + ctx.clearRect(0,0,width,height); + const pad = {left:52,right:20,top:22,bottom:40}; + if (!readings.length) { ctx.fillStyle = text; ctx.font = '14px system-ui'; ctx.textAlign='center'; ctx.fillText(tr('history.noData'), width/2, height/2); return; } + const values = readings.flatMap(r => [r.indoor_temperature, r.target_temperature]).filter(Number.isFinite); + let min = Math.floor(Math.min(...values) - 1), max = Math.ceil(Math.max(...values) + 1); if (max-min < 4) { min -= 2; max += 2; } + const x = i => pad.left + i / Math.max(1, readings.length-1) * (width-pad.left-pad.right); + const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom); + ctx.lineWidth = 1; ctx.font = '11px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; + for (let i=0;i<=5;i++) { const value = min+(max-min)*i/5, py=y(value); ctx.beginPath(); ctx.moveTo(pad.left,py); ctx.lineTo(width-pad.right,py); ctx.stroke(); ctx.textAlign='right'; ctx.fillText(`${value.toFixed(1)}°`,pad.left-8,py+4); } + const ticks = Math.min(5, readings.length-1); + for (let i=0;i<=ticks;i++) { const idx=Math.round(i*(readings.length-1)/Math.max(1,ticks)), px=x(idx); ctx.textAlign='center'; ctx.fillText(new Date(readings[idx].timestamp).toLocaleString(locale(),{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}),px,height-13); } + function line(field, color, dashed=false) { + ctx.beginPath(); ctx.strokeStyle=color; ctx.lineWidth=2.3; ctx.setLineDash(dashed?[6,5]:[]); let started=false; + readings.forEach((row,index)=>{ const value=Number(row[field]); if(!Number.isFinite(value)) return; if(!started){ctx.moveTo(x(index),y(value));started=true;}else ctx.lineTo(x(index),y(value)); }); ctx.stroke(); ctx.setLineDash([]); + } + line('target_temperature', target, true); line('indoor_temperature', indoor, false); + canvas._readings = readings; +} + +function drawCurrentChartIfVisible() { + const canvas = $('#historyChart'); + if (app.currentView === 'history' && canvas?._readings) drawChart(canvas._readings); +} + +async function loadLogs() { + try { + const data = await api('/api/events?limit=150'); + const logs = data.events || []; + $('#logList').innerHTML = logs.length + ? logs.map(item => `
${esc(item.kind)}${esc(item.message)}
`).join('') + : `
${esc(tr('logs.emptyTitle'))}${esc(tr('logs.emptyText'))}
`; + } catch (error) { toast(error.message, true); } +} + +function connectWebSocket() { + if (app.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(app.ws.readyState)) return; + clearTimeout(app.wsTimer); + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const query = app.token ? `?token=${encodeURIComponent(app.token)}` : ''; + const ws = new WebSocket(`${protocol}//${location.host}/ws${query}`); app.ws = ws; + ws.onopen = () => { app.connectionStatus = 'connected'; $('#connectionLabel').textContent = tr('status.connected'); }; + ws.onclose = () => { app.connectionStatus = 'disconnected'; $('#connectionLabel').textContent = tr('status.disconnected'); app.wsTimer = setTimeout(connectWebSocket, 3000); }; + ws.onerror = () => { app.connectionStatus = 'connectionError'; $('#connectionLabel').textContent = tr('status.connectionError'); }; + ws.onmessage = event => { + try { + const message = JSON.parse(event.data); + if (message.event === 'bootstrap') { + const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; renderAll(); return; + } + const data = message.data || {}; + if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); } + else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); } + else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); } + else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderZones(); } + else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); } + else if (message.event === 'log.created' && app.currentView === 'logs') loadLogs(); + } catch (_) {} + }; +} + +document.addEventListener('click', async event => { + const button = event.target.closest('button'); if (!button) return; + if (button.dataset.nav) { + if (button.dataset.nav === 'more') openDialog('moreDialog'); else showView(button.dataset.nav); + return; + } + if (button.dataset.go) { $('#moreDialog').close(); showView(button.dataset.go); return; } + if (button.dataset.open) { const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); if (form) form.reset(); if (button.dataset.open === 'zoneDialog') updateZoneSensorFields(); openDialog(button.dataset.open); return; } + if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); return; } + const action = button.dataset.action; if (!action) return; + const device = app.devices.find(v => v.id === button.dataset.device); + if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power}); + if (action === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,32)}); + if (action === 'mode' && device) return sendDeviceCommand(device.id, {mode:button.dataset.value, power:true}); + if (action === 'fan' && device) return sendDeviceCommand(device.id, {fan_speed:Number(button.dataset.value)}); + if (action === 'toggle' && device) return sendDeviceCommand(device.id, {[button.dataset.field]:!device[button.dataset.field]}); + if (action === 'poll' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/poll`,{method:'POST'})); renderAll(); toast(tr('devices.readDone')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } + if (action === 'bind' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`,{method:'POST'})); renderAll(); toast(tr('devices.bound')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; } + if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device'); + if (action === 'edit-zone') return populateZone(button.dataset.id); + if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone'); + if (action === 'edit-schedule') return populateSchedule(button.dataset.id); + if (action === 'delete-schedule') return deleteEntity('schedules', button.dataset.id, 'label.schedule'); + if (action === 'edit-automation') return populateAutomation(button.dataset.id); + if (action === 'delete-automation') return deleteEntity('automations', button.dataset.id, 'label.automation'); + if (action === 'revoke-access-token') { + if (!confirm(tr('confirm.revokeToken'))) return; + try { + await api(`/api/access-tokens/${encodeURIComponent(button.dataset.id)}`, {method:'DELETE'}); + app.accessTokens = app.accessTokens.filter(item => item.id !== button.dataset.id); + renderAccessTokens(); + toast(tr('toast.tokenRevoked')); + } catch (error) { toast(error.message, true); } + return; + } +}); + +$('#refreshButton').addEventListener('click', () => loadBootstrap(true)); +$('#discoverButton').addEventListener('click', async event => { + const button=event.currentTarget; button.disabled=true; button.textContent=tr('actions.discovering'); + try { const result=await api('/api/discovery',{method:'POST',body:{}}); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); } + catch(error){toast(error.message,true);} finally{button.disabled=false;button.textContent=tr('actions.discover');} +}); +$('#historyRefresh').addEventListener('click', loadHistory); +$('#historyDevice').addEventListener('change', loadHistory); +$('#historyHours').addEventListener('change', loadHistory); +$('#logsRefresh').addEventListener('click', loadLogs); +$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value)); +$('#themeSelect').addEventListener('change', event => setTheme(event.target.value)); +$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields); + +$('#tokenForm').addEventListener('submit', event => { + event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim(); + localStorage.setItem('gree_controller_token', app.token); if (app.ws) app.ws.close(); loadBootstrap(); +}); + +$('#deviceForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, data=Object.fromEntries(new FormData(form)); + data.port=Number(data.port); data.protocol_version=Number(data.protocol_version); data.simulated=form.simulated.checked; + try { await api('/api/devices',{method:'POST',body:data}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('devices.added')); } + catch(error){toast(error.message,true);} +}); + +$('#zoneForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + const id=raw.id; const body={name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,mode:raw.mode,setpoint:Number(raw.setpoint),hysteresis:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:Number(raw.max_sensor_difference)}; + try { await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } + catch(error){toast(error.message,true);} +}); + +$('#scheduleForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value)); + const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,setpoint:Number(raw.setpoint)}; + try { await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } + catch(error){toast(error.message,true);} +}); + +$('#automationForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); const id=raw.id; + const action={}; if(raw.action_power!=='') action.power=raw.action_power==='true'; if(raw.action_mode) action.mode=raw.action_mode; if(raw.action_target_temperature!=='') action.target_temperature=Number(raw.action_target_temperature); + const body={name:raw.name,enabled:form.enabled.checked,trigger_kind:raw.trigger_kind,trigger_device_id:raw.trigger_device_id||null,threshold:raw.threshold===''?null:Number(raw.threshold),at_time:raw.at_time||null,action_device_id:raw.action_device_id,action,cooldown_seconds:Number(raw.cooldown_seconds)}; + try { await api(id?`/api/automations/${encodeURIComponent(id)}`:'/api/automations',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); } + catch(error){toast(error.message,true);} +}); + +$('#settingsForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + const body={controller_id:raw.controller_id,simulator_enabled:form.simulator_enabled.checked,poll_interval_seconds:Number(raw.poll_interval_seconds),zone_interval_seconds:Number(raw.zone_interval_seconds),discovery_timeout_ms:Number(raw.discovery_timeout_ms),discovery_broadcast:raw.discovery_broadcast,home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id}}; + try { app.settings=await api('/api/settings',{method:'PUT',body}); renderSettings(); toast(tr('common.saved')); } + catch(error){toast(error.message,true);} +}); + +$('#createAccessToken').addEventListener('click', async event => { + const button = event.currentTarget; + button.disabled = true; + try { + const result = await api('/api/access-tokens', {method:'POST', body:{name:'Home Assistant'}}); + if (result.item) app.accessTokens.unshift(result.item); + renderAccessTokens(); + $('#generatedAccessToken').value = result.token || ''; + openDialog('generatedTokenDialog'); + toast(tr('toast.tokenCreated')); + } catch (error) { toast(error.message, true); } + finally { button.disabled = false; } +}); + +$('#copyAccessToken').addEventListener('click', async () => { + const input = $('#generatedAccessToken'); + try { + await navigator.clipboard.writeText(input.value); + toast(tr('toast.tokenCopied')); + } catch (_) { + input.select(); + document.execCommand('copy'); + toast(tr('toast.tokenCopied')); + } +}); + +$('#haTest').addEventListener('click', async () => { + const form=$('#settingsForm'); + if (form.ha_token.value || form.ha_url.value !== (app.settings?.home_assistant?.url || '')) { + form.requestSubmit(); await new Promise(resolve=>setTimeout(resolve,250)); + } + try { const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:form.ha_entity_id.value||null}}); toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); } + catch(error){toast(error.message,true);} +}); + +window.addEventListener('resize', () => { if (app.currentView === 'history') loadHistory(); }); +window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => { if (app.theme === 'system') applyTheme(); }); +if ('serviceWorker' in navigator) window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(()=>{})); + +async function startApplication() { + applyTheme(); + await loadLanguages(); + applyTranslations(); + updateZoneSensorFields(); + await loadBootstrap(); +} + +startApplication().catch(error => console.error('Application startup failed:', error)); diff --git a/web/favicon.svg b/web/favicon.svg new file mode 100644 index 0000000..719eaaa --- /dev/null +++ b/web/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..cbceae7 --- /dev/null +++ b/web/index.html @@ -0,0 +1,230 @@ + + + + + + + + + + + + GREE Controller + + +
+
+
GREE ControllerConnecting…
+
+
+ + + + +
+
+ +
+
+
+
Home

Comfort under control

Loading devices…

+
--°C
+
+
+
Devices

Quick control

+
+
+ +
+
+
LAN network

Devices

+ +
+
+
+ +
+
Automation

Zones

+

A zone controls a device using temperature, hysteresis and minimum cycle time.

+
+
+ +
+
Calendar

Schedules

+
+
+ +
+
Rules

Automations

+
+
+ +
+
Measurements

Temperature history

+
+
+ + + +
+
+
TemperatureTarget
+
+
+ +
+
System

Settings

+
+

Controller

+ + + + + + +
+

Home Assistant sensor input

+

Optional. Used only when a room zone reads an external Home Assistant temperature sensor.

+ + + +
+
+

Home Assistant integration access

+

Create a controller token and paste it into the GREE Controller integration in Home Assistant.

+
+
+
+
+
+
+
+ +
+
Diagnostics

Events

+
+
+
+ + + + +

More

+ +
+ + +
+

Add device

+ + + +
+ + +
+
+
+ + +
+ +

Zone

+ + +
+
+
+ +
+

The room sensor is assigned only to this zone. If it becomes unavailable, the controller falls back to the GREE sensor.

+
+
+ +
+
+
+ + +
+ +

Schedule

+ + +
Weekdays
+
+ + +
+
+
+ + +
+ +

Automation

+ + + +
+

Action

+ +
+
+ +
+
+
+ + +
+

Token created

+

Copy this token now. It will not be shown again.

+ +
+
+
+ + +
+
GREE ControllerAuthentication required
+ + +
+
+ +
+ + + diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest new file mode 100644 index 0000000..955dec6 --- /dev/null +++ b/web/manifest.webmanifest @@ -0,0 +1,9 @@ +{ + "name": "GREE Controller", + "short_name": "GREE", + "start_url": "/", + "display": "standalone", + "background_color": "#f5f7f6", + "theme_color": "#0d1715", + "icons": [{"src":"/favicon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}] +} diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..f73e1fa --- /dev/null +++ b/web/styles.css @@ -0,0 +1,234 @@ +:root { + color-scheme: dark; + --bg: #0a1110; + --surface: #111d1a; + --surface-2: #172622; + --surface-muted: #14201d; + --input-bg: #0c1614; + --nav-bg: #0d1715; + --dialog-bg: #101c19; + --line: rgba(255,255,255,.10); + --text: #f3faf7; + --muted: #91aaa0; + --accent: #5ee2a0; + --accent-strong: #28c779; + --accent-text: #092218; + --danger: #ff8b8b; + --warning: #f6c866; + --backdrop: rgba(1,8,6,.72); + --grid: rgba(255,255,255,.08); + --radius: 22px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +:root[data-theme="light"] { + color-scheme: light; + --bg: #f5f7f6; + --surface: #ffffff; + --surface-2: #edf2ef; + --surface-muted: #f0f4f2; + --input-bg: #ffffff; + --nav-bg: #ffffff; + --dialog-bg: #ffffff; + --line: rgba(19,42,33,.14); + --text: #14221d; + --muted: #62756d; + --accent: #20b86b; + --accent-strong: #149557; + --accent-text: #ffffff; + --danger: #c94646; + --warning: #b77900; + --backdrop: rgba(18,33,27,.28); + --grid: rgba(19,42,33,.10); +} +* { box-sizing: border-box; } +html, body { background: var(--bg); } +body { margin: 0; min-height: 100vh; color: var(--text); } +button, input, select { font: inherit; } +button { cursor: pointer; } +button:disabled { opacity: .5; cursor: wait; } +.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 72px; padding: 12px max(18px, env(safe-area-inset-left)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 94%, transparent); backdrop-filter: blur(14px); } +.brand { display: flex; align-items: center; min-width: 0; } +.brand strong, .brand small { display: block; } +.brand strong { font-size: 15px; letter-spacing: -.01em; white-space: nowrap; } +.brand small { margin-top: 2px; color: var(--muted); font-size: 11px; } +.brand.large { margin-bottom: 1rem; } +.brand.large strong { font-size: 21px; } +.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; } +.toolbar-select { width: auto; min-width: 58px; min-height: 40px; padding: 7px 28px 7px 10px; border-radius: 11px; } +.theme-select { min-width: 94px; } +button { border: 0; border-radius: 13px; padding: 11px 15px; color: var(--text); background: var(--surface-2); transition: transform .15s ease, background .15s ease, opacity .15s ease; } +button:active { transform: scale(.97); } +.primary { background: var(--accent); color: var(--accent-text); font-weight: 800; } +.primary:hover { background: var(--accent-strong); } +.secondary { border: 1px solid var(--line); background: var(--surface-muted); font-weight: 650; } +.secondary:hover { background: var(--surface-2); } +.danger { color: var(--danger); } +.compact { padding: 10px 14px; } +.icon-button { width: 40px; height: 40px; padding: 0; font-size: 22px; background: transparent; } +main { width: min(1180px, 100%); margin: 0 auto; padding: 24px 18px 108px; } +.view { display: none; animation: reveal .2s ease; } +.view.active { display: block; } +@keyframes reveal { from { opacity: 0; transform: translateY(5px); } } +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 8px; font-size: clamp(28px, 6vw, 44px); letter-spacing: -.045em; } +h2 { margin-bottom: 0; font-size: clamp(23px, 4vw, 31px); letter-spacing: -.035em; } +h3 { margin-bottom: 10px; } +.eyebrow { color: var(--accent); font-size: 11px; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; } +.lead { max-width: 690px; color: var(--muted); line-height: 1.55; } +.hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; align-items: flex-end; gap: 18px; min-height: 210px; padding: clamp(24px, 6vw, 44px); border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 30px; background: var(--surface); } +.hero::after { content: ""; position: absolute; right: -80px; bottom: -120px; width: 220px; height: 220px; border: 34px solid color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 50%; } +.hero h1 { margin: 5px 0 8px; } +.hero p { margin: 0; color: var(--muted); } +.hero-temp { z-index: 1; white-space: nowrap; font-size: clamp(48px, 12vw, 88px); font-weight: 250; letter-spacing: -.075em; } +.hero-temp small { margin-left: 4px; color: var(--muted); font-size: .28em; font-weight: 600; letter-spacing: 0; } +.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 14px 0 34px; } +.metric { padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); } +.metric span { display: block; margin-bottom: 8px; color: var(--muted); font-size: 12px; } +.metric strong { font-size: 24px; letter-spacing: -.03em; } +.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 8px 0 18px; } +.device-grid, .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 14px; } +.device-card, .panel, .list-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); } +.device-card { overflow: hidden; padding: 20px; } +.device-card.off { opacity: .86; } +.device-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.device-title { min-width: 0; } +.device-title h3 { overflow: hidden; margin: 0 0 4px; text-overflow: ellipsis; white-space: nowrap; font-size: 19px; } +.device-title p { overflow: hidden; margin: 0; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } +.status { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); } +.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--danger); } +.status.online::before { background: var(--accent); } +.power-button { display: grid; place-items: center; flex: 0 0 auto; width: 46px; height: 46px; padding: 0; border-radius: 50%; color: var(--muted); font-size: 21px; } +.power-button.on { background: var(--accent); color: var(--accent-text); } +.temperature-control { display: flex; align-items: center; justify-content: center; gap: 22px; padding: 24px 0 17px; } +.temperature-control button { width: 42px; height: 42px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: transparent; font-size: 22px; } +.target-temp { min-width: 106px; text-align: center; font-size: 48px; font-weight: 300; letter-spacing: -.06em; } +.target-temp small { margin-left: 3px; color: var(--muted); font-size: 15px; font-weight: 650; } +.current-line { display: flex; justify-content: center; gap: 6px; margin: -10px 0 18px; color: var(--muted); font-size: 12px; } +.current-line strong { color: var(--text); } +.mode-row, .fan-row { display: flex; gap: 7px; overflow-x: auto; padding: 2px 0 10px; scrollbar-width: none; } +.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar { display: none; } +.mode-row button, .fan-row button { flex: 1 0 auto; min-width: 58px; padding: 9px 10px; color: var(--muted); background: var(--surface-muted); font-size: 12px; } +.mode-row button.active, .fan-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; } +.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; } +.device-toggles button { padding: 9px 5px; color: var(--muted); font-size: 11px; } +.device-toggles button.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); } +.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); } +.card-footer small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } +.card-menu { display: flex; gap: 5px; } +.card-menu button { padding: 7px 9px; background: transparent; color: var(--muted); font-size: 12px; } +.list-card { padding: 19px; } +.list-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.list-card h3 { margin: 0 0 5px; font-size: 18px; } +.list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; } +.badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; } +.badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); } +.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 16px; } +.card-stat { padding: 10px; border-radius: 12px; background: var(--surface-muted); text-align: center; } +.card-stat small, .card-stat strong { display: block; } +.card-stat small { margin-bottom: 5px; color: var(--muted); font-size: 10px; } +.card-stat strong { font-size: 15px; } +.empty { grid-column: 1/-1; padding: 38px 24px; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); text-align: center; } +.empty strong { display: block; margin-bottom: 7px; color: var(--text); } +.panel { padding: 20px; } +.chart-panel { overflow: hidden; } +.chart-toolbar { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 18px; } +.chart-toolbar select { flex: 1; min-width: 140px; } +.chart-wrap { position: relative; overflow-x: auto; min-height: 310px; } +#historyChart { width: 100%; min-width: 680px; height: 340px; } +.legend { display: flex; gap: 18px; margin-top: 10px; color: var(--muted); font-size: 12px; } +.legend > span { display: flex; align-items: center; gap: 7px; } +.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); } +.dot.target { background: var(--warning); } +.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; } +.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: 1/-1; } +.form-grid hr, .dialog-form hr { width: 100%; border: 0; border-top: 1px solid var(--line); } +label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 650; } +input, select { width: 100%; min-height: 45px; border: 1px solid var(--line); border-radius: 12px; outline: 0; padding: 10px 12px; color: var(--text); background: var(--input-bg); } +input:focus, select:focus { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; } +.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 9px; min-height: 44px; } +.check input { width: 19px; min-height: 19px; accent-color: var(--accent); } +.form-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 6px; } +.system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; } +.system-panel strong { color: var(--text); } +.log-list { display: grid; gap: 2px; padding: 8px; } +.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 11px 12px; border-bottom: 1px solid var(--line); font-size: 12px; } +.log-row:last-child { border-bottom: 0; } +.log-row time, .log-row .kind { color: var(--muted); } +.log-row.error .kind { color: var(--danger); } +.log-row.warn .kind { color: var(--warning); } +.bottom-nav { position: fixed; z-index: 30; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(5, 1fr); padding: 8px max(8px, env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 96%, transparent); backdrop-filter: blur(14px); } +.bottom-nav button { display: grid; place-items: center; gap: 3px; padding: 7px 2px; border-radius: 12px; color: var(--muted); background: transparent; font-size: 10px; } +.bottom-nav button span { font-size: 20px; line-height: 1; } +.bottom-nav button b { font-weight: 650; } +.bottom-nav button.active { color: var(--accent); } +dialog { width: min(540px, calc(100% - 24px)); max-height: min(88vh, 760px); overflow: auto; border: 1px solid var(--line); border-radius: 24px; padding: 0; color: var(--text); background: var(--dialog-bg); } +dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(4px); } +.dialog-form { display: grid; gap: 14px; padding: 20px; } +.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.dialog-head h2 { font-size: 23px; } +.dialog-head button { width: 38px; height: 38px; padding: 0; background: transparent; color: var(--muted); font-size: 25px; } +.two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +#externalSensorFields { display: grid; gap: 12px; } +.field-note { margin: -4px 0 0; color: var(--muted); font-size: .8rem; line-height: 1.45; } +.sensor-detail { margin-top: 10px; color: var(--muted); font-size: .8rem; line-height: 1.45; } +fieldset { border: 1px solid var(--line); border-radius: 14px; padding: 10px; } +legend { padding: 0 5px; color: var(--muted); font-size: 11px; } +.days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; } +.days label { display: grid; place-items: center; gap: 5px; padding: 6px 2px; border-radius: 8px; background: var(--surface-muted); font-size: 10px; } +.days input { width: 16px; min-height: 16px; accent-color: var(--accent); } +.sheet { width: min(500px, calc(100% - 18px)); margin: auto auto 10px; } +.sheet .dialog-head { padding: 18px 18px 5px; } +.menu-list { display: grid; padding: 8px 10px 12px; } +.menu-list button { display: flex; justify-content: space-between; border-bottom: 1px solid var(--line); border-radius: 0; padding: 16px 9px; background: transparent; text-align: left; } +.menu-list button:last-child { border-bottom: 0; } +.auth-dialog { width: min(420px, calc(100% - 30px)); } +#toast { position: fixed; z-index: 100; right: 18px; bottom: 92px; max-width: min(380px, calc(100% - 36px)); transform: translateY(15px); opacity: 0; pointer-events: none; padding: 12px 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-2); transition: .2s ease; font-size: 13px; } +#toast.show { transform: translateY(0); opacity: 1; } +#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); } +@media (min-width: 900px) { + .bottom-nav { top: 92px; right: auto; bottom: auto; left: max(16px, calc((100vw - 1380px)/2)); grid-template-columns: 1fr; width: 90px; border: 1px solid var(--line); border-radius: 22px; padding: 9px; background: var(--surface); } + .bottom-nav button { padding: 10px 4px; } + main { padding-left: 122px; padding-bottom: 50px; } +} +@media (max-width: 720px) { + .topbar { flex-wrap: wrap; } + .top-actions { width: 100%; } + .top-actions .primary { margin-left: auto; } +} +@media (max-width: 620px) { + main { padding: 18px 13px 105px; } + .hero { min-height: 190px; padding: 23px; } + .hero-temp { font-size: 52px; } + .metrics { gap: 7px; } + .metric { padding: 14px 10px; } + .metric strong { font-size: 20px; } + .section-heading { align-items: center; } + .section-heading button { padding: 9px 11px; font-size: 12px; } + .form-grid { grid-template-columns: 1fr; } + .form-grid h3, .form-grid hr, .form-grid .wide { grid-column: auto; } + .log-row { grid-template-columns: 62px 1fr; } + .log-row .message { grid-column: 1/-1; } + .theme-select { min-width: 86px; } +} +@media (max-width: 410px) { + .topbar { padding-inline: 12px; } + .brand strong { font-size: 13px; } + .hero { align-items: start; flex-direction: column; } + .hero-temp { align-self: flex-end; margin-top: -15px; } + .two { grid-template-columns: 1fr; } + .toolbar-select { min-width: 54px; } + .theme-select { min-width: 82px; } +} + +.token-manager { display: grid; gap: 12px; } +.token-list { display: grid; gap: 8px; } +.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-muted); } +.token-row > div { min-width: 0; display: grid; gap: 3px; } +.token-row strong { color: var(--text); font-size: 13px; } +.token-row small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; } +.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; } +.empty.compact { padding: 18px; } +@media (max-width: 620px) { + .token-row { align-items: stretch; flex-direction: column; } + .token-row button { width: 100%; } +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..6ffb773 --- /dev/null +++ b/web/sw.js @@ -0,0 +1,10 @@ +const CACHE = 'gree-controller-v5'; +const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json']; +self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting()))); +self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()))); +self.addEventListener('fetch', event => { + if (event.request.method !== 'GET' || new URL(event.request.url).pathname.startsWith('/api/')) return; + event.respondWith(fetch(event.request).then(response => { + const copy = response.clone(); caches.open(CACHE).then(cache => cache.put(event.request, copy)); return response; + }).catch(() => caches.match(event.request).then(response => response || caches.match('/')))); +});