diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index bd77bf8..259f15f 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -1,70 +1,57 @@ -# GREE Controller v0.3.3 - build and validation report +# GREE Controller v0.3.5 - build and validation report -## Scope of this release +## Scope -Version 0.3.3 prepares the project for repeatable Debian/Ubuntu LXC testing and centralizes SQLite statements. +Version 0.3.5 focuses on mixed-generation GREE networks and mobile day-to-day control. -Changes include: +### GREE protocol fixes -- 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. +- corrected the standard AES-128-ECB generic key, +- corrected the standard AES-128-GCM generic key, +- GCM now uses the fixed 12-byte nonce and `qualcomm-test` AAD used by EWPE/GREE Wi-Fi modules, +- discovery detects GCM by the response `tag` and decrypts it with the GCM generic key, +- discovery supports Auto/V1/V2 filtering and 1-10 repeated scan passes, +- protocol packets use `cid="app"`, `i=1` for bind and `i=0` for status/commands, +- Celsius commands follow the standard command payload and use whole-degree `SetTem`; `TemRec` is not misused as a Celsius half-degree flag, +- bind refreshes the device with a direct scan and falls back between ECB/GCM, +- discovery attempts an immediate bind and persists the successful protocol/key, +- status falls back to a smaller core property list if a model rejects the extended list, +- command failure triggers one fresh-bind retry before returning an error, +- polling also performs a one-time rebind/retry before increasing the communication failure counter. -## LXC persistent paths +### Availability behavior -```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 -``` +- one UDP timeout no longer immediately marks a device offline, +- a device is marked offline after 3 consecutive communication failures, +- successful bind/poll/command resets the failure counter. -The updater preserves `/etc/gree-controller.env` and backs up the stopped SQLite database before launching the new binary. +### Web UI -## Validation performed in the packaging environment +- newly discovered units open a friendly-name step, and they can also be renamed later from the Devices view, +- unnamed units receive a model/MAC-based fallback name instead of `Klimatyzator GREE`, +- discovery dialog exposes Auto/V1/V2, scan passes and total scan time, +- zone cards have direct +/- 0.5 C controller setpoint controls; physical GREE setpoints are normalized to whole Celsius degrees, +- zone cards have direct Heat/Cool buttons without opening the edit dialog. + +## Validation performed | 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 | +| JSON parsing for EN/PL language packs | PASS | +| Shell syntax (`bash -n scripts/*.sh`) | PASS | +| Python syntax for scripts and HA integration | PASS | +| SQL remains centralized in `src/queries.rs` | PASS | +| Package naming remains `gree_controller` / `GREE_CONTROLLER_*` | PASS | -`shellcheck` is not installed in the packaging environment, so a shellcheck pass could not be performed. +A full `./scripts/dev.sh --check` was attempted in the packaging environment, but Rust is not preinstalled and DNS access to `sh.rustup.rs` is blocked there. Final Rust type-check, tests and release build are therefore performed by `scripts/update.sh` or `scripts/install.sh` inside the target LXC. -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: +Recommended LXC update: ```bash sudo ./scripts/update.sh ./scripts/service.sh health +journalctl -u gree-controller -n 150 --no-pager ``` + +For the reported mixed-model network, start discovery with **Auto (V1 + V2)**, 3 passes and 6000 ms. If fewer units appear, run V1-only and V2-only scans separately and inspect the service log. diff --git a/Cargo.lock b/Cargo.lock index d1102e3..e2b6e5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,7 +633,7 @@ dependencies = [ [[package]] name = "gree-controller" -version = "0.3.3" +version = "0.3.5" dependencies = [ "aes", "aes-gcm", @@ -644,6 +644,7 @@ dependencies = [ "clap", "dotenvy", "futures-util", + "libc", "rand 0.8.7", "reqwest", "rusqlite", diff --git a/Cargo.toml b/Cargo.toml index a5c2d9e..b9ac325 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gree-controller" -version = "0.3.3" +version = "0.3.5" edition = "2021" authors = ["GREE Controller contributors"] description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support" @@ -16,6 +16,7 @@ chrono = { version = "0.4", features = ["serde", "clock"] } clap = { version = "4", features = ["derive", "env"] } dotenvy = "0.15" futures-util = "0.3" +libc = "0.2" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/FILE_MANIFEST.sha256 b/FILE_MANIFEST.sha256 index 3caa0e8..c0775b8 100644 --- a/FILE_MANIFEST.sha256 +++ b/FILE_MANIFEST.sha256 @@ -1,57 +1,58 @@ -f6ccf0fb91df0db03b045feb0de014b78690da50e86b6ae8cf128139effa0b8e ./.env.example +6b10bf7903a32a6cdd902d990911105589690deafe7da12e5b3b9c994c1fd4f6 ./.env.example 2fe1cf4e544fead5ae58436145a5b45e7a5a105143dfd815d28e307c94d5d19b ./.gitignore -d9d4dc23f77f1fc8319367a1eb53ee740807fd7a4a291a1add0a48056d832cec ./BUILD_REPORT.md -d9bea6d5fb8031f6923b9e1f78abe4648b67a33b5da70b587e8bf413f2b48125 ./Cargo.toml +6a321094970809e697c2bab8b7861abc0808496b1c143a667dec30bded1a1758 ./BUILD_REPORT.md +0a3b167534311a32306c567814562adefe2c5f9d1302a1b3426288801915cf65 ./Cargo.toml 19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE -615318767594b75456566494ae924e93a5b5b95bc58af49d5d74e25cfad173fb ./README.md +c8f9e1cbc54329e2dad226245a8bbd3f17de416c5e6cbb3a43469d77e6006916 ./README.md a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ./data/.gitkeep -597d7116a44dea68f1af679b327006a2397cec68751546d4c05d62a41dd17343 ./docs/API.md +37453ecce2c7290ecae5ada4249cfd36bc70fb50c479aa680b74e4f1bc707251 ./docs/API.md 234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md 7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md -bbd650c208779c04c95f7ee1153e6b3556351ee9aba282fa2a2f6315e5acf362 ./docs/LXC.md +26ca44b749930ee15cdfde8106198e621b322ae1e5c60a7bd8a1680489b83de9 ./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 +fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./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 +4eca7d2c224282fde1db22a5010bcf0a3d17f2342c206371a9202314be0d7b56 ./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 +c604815c850574c573315306f368a8379ab87f5e2a99aca32be3131b05070afa ./lang/en.json +0b2d174258610440d5d4c2d6c5bec5ecc2528f41946bf7d59cc2206aab50ce58 ./lang/pl.json +09caaa48f9979f209b96e6d0feebbecda053b997c470a5d80abb80b7fb06a484 ./scripts/README.md be8c4bf17723d5e2e23e774a679ca75b02ab49825a93201237bf58cdd4974136 ./scripts/common.sh +6403786610ee6d2f628193c25aee0dd058d62e904aa1a31d5f62fdaae0e94b4f ./scripts/configure-gree-network.sh 054e6862857fd1d02dabedd977d4175c2011f451aba51b5d4fc3a3363e66e80f ./scripts/dev.sh 14076104c042fba1284ebb07531a6c3ff972df1f9f5b18f70da18ab774efed27 ./scripts/generate_ha_migration.py e4849261fd9ed1f01df96c0637c439c0c4eff8fa317b2918026167bba343af79 ./scripts/install-lxc.sh -34d6aba352f0fc01e2882e7855d22ad620311084d814d23c1571defe1e31335f ./scripts/install.sh +a89ecb7367e56d20a85ad8bb02895ecd07e1f8b10f6bda3ca79ce656651918f5 ./scripts/install.sh +e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/network-debug.sh 81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh 69a1c2ad30685cac517eeb18a27d40368506dc5e54b57d943c324339303afc2b ./scripts/smoke.sh b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh -7567099da203faaaabd2f940936d162f4d376f731b17dbd4975fb87a92e22bf8 ./src/api.rs -ef7e85336fa3a33977c731b4e2f273dc19b6155a0e0fa2ce30e4f0e6ba8c757a ./src/config.rs +9122d43347e3fb34276fa6bfee5108a078c0ba0923e6037ff79e6989a84f1506 ./src/api.rs +e986a3d5fe53d9d00bf177a8b2a6e825aea366c30ef86ced2745774cbb17df30 ./src/config.rs 54f3449ea140b0a0eefb8cff1d95090e62d5a955e50fcad47bc10b669ea3d68e ./src/db.rs -281ad87b779d71bcd1f0cec4610a268b36ffe7e6f9813163d0fb236a61106dda ./src/engine.rs +d4df2f22ac481d8859e74593f72b30b246b6b23ea214ca274638af5a32bd8f4f ./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 +27eca87a9078aad7a7a4ef1fc39ce7ddaffcf05466acb995d571e0a35dfe898c ./src/main.rs +a5923f8fb72ebac68c13e4767dd35012041e2783167969521e757bf502fafabd ./src/models.rs +286a38d9e5a2380fad26f8fdb0393dfbcf22d208de8b753a4ba65282eec4ce40 ./src/protocol/crypto.rs +e940d3344ca34a7c8c0821b054044cfec0ef12637e0104f31cbd5de69c3e1c8a ./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 +9d05fe7219a4e9806239792169417d577c56a38bea4561e4064ae4cf41418257 ./web/app.js b6bba1e1e7127d06c885a9f8fffbf0becde339e0f2464da8b4725f11ddabe433 ./web/favicon.svg -6439b633c76637c452d4d1e65f864ed87be122df94ced4891b9770eaa6cdef57 ./web/index.html +45f0ddc583cf0ee3197dda5c72209f80389291e33c72a29e7555ebaa329819de ./web/index.html 42143de65d81083938fc82aa601a38fb9f865860d628752580b0f1c8b77275d4 ./web/manifest.webmanifest -453d2041e6dc2ffa258dc7a6fcc27cb534047aa83722be0872d31b8f79b3ac49 ./web/styles.css +3b91751dd418fca60e888a4d4f5e077683c12332cd76b9d05a6dbd18d8599fa2 ./web/styles.css 2ecdd077b73c4c725de8b642c6658992bcefe99b08a22a78eaf7903eaf9f813a ./web/sw.js diff --git a/README.md b/README.md index 2e9f480..1fc66eb 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ 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**. +Current version: **0.3.5**. ## Highlights - local GREE discovery over UDP/7000, -- V1 bind/status/command transport using AES-128-ECB, -- V2 AES-128-GCM envelope support, +- V1 AES-128-ECB and V2 AES-128-GCM discovery/bind/status/command transport, +- automatic ECB/GCM detection from discovery responses and bind fallback, - 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, @@ -35,7 +35,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do On Debian, Ubuntu or an LXC container: ```bash -unzip gree-controller-v0.3.3.zip +unzip gree-controller-v0.3.5.zip cd gree-controller chmod +x scripts/*.sh ./scripts/dev.sh @@ -87,10 +87,12 @@ The selected appearance is stored in the `gree_controller_theme` cookie. The int 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. +3. Start with **Auto (V1 + V2)** and 3 scan passes. The controller recognizes ECB responses and GCM responses carrying a `tag` and automatically binds discovered units. +4. If required, repeat discovery using **V1 AES-ECB** or **V2 AES-GCM** to isolate a model family. +5. New devices immediately open a naming step so you can enter room-friendly names such as **Salon** or **Sypialnia**. They can also be renamed later from **Devices -> Rename**. Re-discovery preserves your custom name. +6. 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. +Version 0.3.5 uses the EWPE/GREE protocol constants for ECB and GCM, the fixed GCM nonce/AAD, standard `cid=app`, correct packet `i` values, and a fresh scan before bind. A single lost UDP response no longer marks a device offline; three consecutive communication failures are required. ## LXC/systemd installation and updates @@ -129,18 +131,19 @@ Use `--skip-tests` with `install.sh` or `update.sh` only when you explicitly wan | `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_SIMULATE` | `false` | Enables simulator seeding for development/testing | +| `GREE_CONTROLLER_AUTO_SEED` | `false` | Seeds a simulator into an empty database when simulation is enabled | | `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 | +| `GREE_CONTROLLER_GREE_INTERFACE` | empty | Optional Linux interface used for all GREE UDP traffic, e.g. `eth1` | +| `GREE_CONTROLLER_ID` | `gree-controller` | Controller instance identifier used for logs/metadata; the GREE wire protocol uses the standard `cid=app` | | `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. +Settings changed from the web panel are stored in SQLite. `GREE_CONTROLLER_APP_TOKEN` is loaded at process startup. When `GREE_CONTROLLER_DISCOVERY_BROADCAST` is explicitly present in the service environment, it overrides the persisted discovery target. Use `auto` together with `GREE_CONTROLLER_GREE_INTERFACE` to derive the subnet broadcast automatically. ## Per-zone room temperature sensors @@ -164,6 +167,8 @@ For combined control, `max_sensor_difference` protects against an obviously inco 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. +Zone cards also expose quick `- / +` target controls and Heat/Cool buttons. These update the zone without opening the edit dialog and immediately push the changed mode/setpoint to the paired GREE unit without forcing power ON/OFF. Active schedules may still replace the zone setpoint while their time window is active. + ## Home Assistant There are two independent HA directions: @@ -297,3 +302,19 @@ 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. + + +## Multi-NIC / dedicated GREE interface + +For LXC hosts with a management NIC and a separate GREE/VLAN NIC, bind GREE UDP traffic explicitly to the GREE interface: + +```env +GREE_CONTROLLER_GREE_INTERFACE=eth1 +GREE_CONTROLLER_DISCOVERY_BROADCAST=auto +GREE_CONTROLLER_SIMULATE=false +GREE_CONTROLLER_AUTO_SEED=false +``` + +`GREE_CONTROLLER_GREE_INTERFACE` is resolved to the interface current IPv4 address whenever a UDP socket is created, so DHCP address changes do not require hard-coding the source IP. `GREE_CONTROLLER_DISCOVERY_BROADCAST=auto` derives the subnet broadcast from the selected interface and overrides an older runtime value stored in SQLite. + +Run `sudo ./scripts/network-debug.sh eth1 10.87.65.127` to verify addressing and routing. diff --git a/docs/API.md b/docs/API.md index a2b3247..cd8b901 100644 --- a/docs/API.md +++ b/docs/API.md @@ -33,9 +33,11 @@ These two endpoints always require `Authorization: Bearer ` (or ```bash curl -X POST "$BASE/api/discovery" -H "$AUTH" -H 'Content-Type: application/json' \ - -d '{"timeout_ms":3000,"broadcast":"255.255.255.255:7000"}' + -d '{"timeout_ms":6000,"broadcast":"255.255.255.255:7000","protocol_version":0,"passes":3}' ``` +`protocol_version` is `0` for auto/both, `1` for AES-ECB only, and `2` for AES-GCM only. `passes` is `1..10`. Auto is recommended when different GREE Wi-Fi module generations share the network. + ## Device command ```json @@ -51,7 +53,7 @@ curl -X POST "$BASE/api/discovery" -H "$AUTH" -H 'Content-Type: application/json } ``` -Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. +Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. Physical GREE Celsius setpoints are normalized to whole degrees in the `8..30°C` range. ## Zone @@ -80,6 +82,15 @@ Supported modes: `auto`, `cool`, `dry`, `fan`, `heat`. Fan speed: `0..5`. 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`. +### Quick zone control + +```bash +curl -X POST "$BASE/api/zones/ZONE_ID/control" -H "$AUTH" -H 'Content-Type: application/json' \ + -d '{"setpoint":21.5,"mode":"heat"}' +``` + +Fields are optional: `setpoint`, `mode` (`heat`/`cool`) and `enabled`. The zone setpoint may use 0.5°C precision for controller hysteresis; when the quick control changes setpoint/mode, the paired GREE unit is updated immediately while its current power state is preserved. + ## Schedule Weekdays use ISO numbers: Monday `1`, Sunday `7`. diff --git a/docs/LXC.md b/docs/LXC.md index 472c46b..5becbcb 100644 --- a/docs/LXC.md +++ b/docs/LXC.md @@ -108,3 +108,37 @@ src/queries.rs ``` `src/db.rs` contains connection/transaction logic and maps database rows to Rust domain models, but it does not embed SQL statements. + + +## Two network interfaces + +When the LXC has a management interface and a dedicated GREE network, configure the service environment explicitly. Example for `eth1` on `10.87.65.0/25`: + +```env +GREE_CONTROLLER_GREE_INTERFACE=eth1 +GREE_CONTROLLER_DISCOVERY_BROADCAST=auto +GREE_CONTROLLER_SIMULATE=false +GREE_CONTROLLER_AUTO_SEED=false +``` + +Restart the service after editing `/etc/gree-controller.env`: + +```bash +systemctl restart gree-controller +journalctl -u gree-controller -n 100 --no-pager +``` + +During discovery the log should contain a line similar to: + +```text +Starting GREE discovery target=10.87.65.127:7000 local=10.87.65.27: interface=eth1 +``` + +Use `scripts/network-debug.sh` for routing diagnostics. + + +## Mixed GREE model generations + +Version 0.3.5 can discover both AES-ECB and AES-GCM modules. In the Web UI choose **Discover -> Auto (V1 + V2)** and use 3-5 scan passes. If a family is still missing, repeat with V1-only and V2-only to see which protocol its Wi-Fi module answers with. + +A single command/status timeout no longer immediately flips a device offline; offline requires three consecutive communication failures. diff --git a/home-assistant/custom_components/gree_controller/climate.py b/home-assistant/custom_components/gree_controller/climate.py index 22e3f2c..8d40a2c 100644 --- a/home-assistant/custom_components/gree_controller/climate.py +++ b/home-assistant/custom_components/gree_controller/climate.py @@ -79,8 +79,8 @@ class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], Climat _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_max_temp = 30.0 + _attr_target_temperature_step = 1.0 _attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.DRY, HVACMode.FAN_ONLY] _attr_fan_modes = list(NAME_TO_FAN) _attr_swing_modes = [SWING_OFF, SWING_ON] diff --git a/home-assistant/custom_components/gree_controller/manifest.json b/home-assistant/custom_components/gree_controller/manifest.json index 2b38e70..4a185ee 100644 --- a/home-assistant/custom_components/gree_controller/manifest.json +++ b/home-assistant/custom_components/gree_controller/manifest.json @@ -1,7 +1,7 @@ { "domain": "gree_controller", "name": "GREE Controller", - "version": "0.3.3", + "version": "0.3.5", "config_flow": true, "integration_type": "hub", "iot_class": "local_polling", diff --git a/lang/en.json b/lang/en.json index d5df64f..631ea00 100644 --- a/lang/en.json +++ b/lang/en.json @@ -226,6 +226,18 @@ "confirm.revokeToken": "Revoke this Home Assistant access token?", "toast.tokenCreated": "Home Assistant access token created", "toast.tokenCopied": "Token copied", - "toast.tokenRevoked": "Token revoked" + "toast.tokenRevoked": "Token revoked", + "devices.protocolAuto": "Auto (V1 + V2)", + "devices.rename": "Rename", + "devices.protocolChangeHint": "Changing protocol clears the saved device key and performs a new bind on the next request.", + "discovery.title": "Discover GREE devices", + "discovery.help": "Auto searches both AES-ECB and AES-GCM devices. Multiple passes improve discovery when several Wi-Fi modules answer the same broadcast.", + "discovery.protocol": "Discovery protocol", + "discovery.passes": "Scan passes", + "discovery.timeout": "Total scan time (ms)", + "zones.quickHint": "Quick target and mode control", + "actions.later": "Later", + "discovery.nameDevices": "Name discovered devices", + "discovery.nameDevicesHelp": "Give each new unit a friendly room name. The technical model and MAC remain available in diagnostics." } } diff --git a/lang/pl.json b/lang/pl.json index dc57ab9..48bf794 100644 --- a/lang/pl.json +++ b/lang/pl.json @@ -226,6 +226,18 @@ "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" + "toast.tokenRevoked": "Token unieważniony", + "devices.protocolAuto": "Auto (V1 + V2)", + "devices.rename": "Zmień nazwę", + "devices.protocolChangeHint": "Zmiana protokołu usuwa zapisany klucz urządzenia i wykona ponowny bind przy następnym żądaniu.", + "discovery.title": "Wykrywanie urządzeń GREE", + "discovery.help": "Auto wyszukuje urządzenia AES-ECB i AES-GCM. Kilka przebiegów zwiększa skuteczność, gdy wiele modułów Wi-Fi odpowiada na ten sam broadcast.", + "discovery.protocol": "Protokół wykrywania", + "discovery.passes": "Liczba przebiegów", + "discovery.timeout": "Łączny czas wyszukiwania (ms)", + "zones.quickHint": "Szybka zmiana celu i trybu", + "actions.later": "Później", + "discovery.nameDevices": "Nazwij znalezione urządzenia", + "discovery.nameDevicesHelp": "Nadaj każdej nowej jednostce przyjazną nazwę pokoju. Model techniczny i MAC pozostaną dostępne w diagnostyce." } } diff --git a/scripts/README.md b/scripts/README.md index 43aa738..6194833 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,3 +12,6 @@ All operator-facing scripts live in this directory. - `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. + +- `configure-gree-network.sh ` — configures a dedicated GREE NIC, automatic subnet broadcast, disables the simulator, and restarts the service. +- `network-debug.sh [broadcast-ip]` — prints interface/routing diagnostics and a tcpdump command. diff --git a/scripts/configure-gree-network.sh b/scripts/configure-gree-network.sh new file mode 100755 index 0000000..ed6ad51 --- /dev/null +++ b/scripts/configure-gree-network.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=common.sh +source "$SCRIPT_DIR/common.sh" + +IFACE="${1:-}" +[[ -n "$IFACE" ]] || fail "Usage: sudo ./scripts/configure-gree-network.sh " +require_root +ip link show dev "$IFACE" >/dev/null 2>&1 || fail "Interface not found: $IFACE" + +ADDR_LINE="$(ip -4 -o addr show dev "$IFACE" scope global | head -n1 || true)" +[[ -n "$ADDR_LINE" ]] || fail "Interface $IFACE has no global IPv4 address" +LOCAL_CIDR="$(awk '{print $4}' <<<"$ADDR_LINE")" +BROADCAST="$(awk '{for (i=1;i<=NF;i++) if ($i=="brd") {print $(i+1); exit}}' <<<"$ADDR_LINE")" + +upsert_env() { + local key="$1" value="$2" tmp + tmp="$(mktemp)" + if [[ -f "$ENV_FILE" ]]; then + awk -v key="$key" -v value="$value" ' + BEGIN { done=0 } + $0 ~ "^" key "=" { print key "=" value; done=1; next } + { print } + END { if (!done) print key "=" value } + ' "$ENV_FILE" > "$tmp" + else + printf '%s=%s\n' "$key" "$value" > "$tmp" + fi + install -o root -g root -m 0600 "$tmp" "$ENV_FILE" + rm -f "$tmp" +} + +upsert_env GREE_CONTROLLER_GREE_INTERFACE "$IFACE" +upsert_env GREE_CONTROLLER_DISCOVERY_BROADCAST auto +upsert_env GREE_CONTROLLER_SIMULATE false +upsert_env GREE_CONTROLLER_AUTO_SEED false + +say "Configured GREE interface: $IFACE ($LOCAL_CIDR)" +if [[ -n "$BROADCAST" ]]; then + say "Detected subnet broadcast: $BROADCAST (controller will derive it automatically)" +fi + +if systemctl cat "$SERVICE_NAME" >/dev/null 2>&1; then + say "Restarting $SERVICE_NAME" + systemctl restart "$SERVICE_NAME" + sleep 1 + systemctl --no-pager --full status "$SERVICE_NAME" || true + echo + journalctl -u "$SERVICE_NAME" -n 40 --no-pager || true +else + warn "$SERVICE_NAME is not installed yet. Configuration was written to $ENV_FILE." +fi diff --git a/scripts/install.sh b/scripts/install.sh index 4fc221a..bba2e3c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -69,12 +69,11 @@ if [[ ! -f "$ENV_FILE" ]]; then GREE_CONTROLLER_BIND=0.0.0.0:8787 GREE_CONTROLLER_DATABASE=$DATA_DIR/gree-controller.db GREE_CONTROLLER_APP_TOKEN=$token -GREE_CONTROLLER_SIMULATE=true -GREE_CONTROLLER_AUTO_SEED=true +GREE_CONTROLLER_SIMULATE=false +GREE_CONTROLLER_AUTO_SEED=false GREE_CONTROLLER_POLL_INTERVAL_SECONDS=15 GREE_CONTROLLER_ZONE_INTERVAL_SECONDS=5 GREE_CONTROLLER_DISCOVERY_TIMEOUT_MS=3000 -GREE_CONTROLLER_DISCOVERY_BROADCAST=255.255.255.255:7000 GREE_CONTROLLER_ID=gree-controller RUST_LOG=info,tower_http=info HA_URL= diff --git a/scripts/network-debug.sh b/scripts/network-debug.sh new file mode 100755 index 0000000..7b540a5 --- /dev/null +++ b/scripts/network-debug.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +IFACE="${1:-}" +TARGET="${2:-}" +ENV_FILE="${GREE_CONTROLLER_ENV_FILE:-/etc/gree-controller.env}" + +if [[ -z "$IFACE" && -r "$ENV_FILE" ]]; then + IFACE="$(sed -n 's/^GREE_CONTROLLER_GREE_INTERFACE=//p' "$ENV_FILE" | tail -n1)" +fi +if [[ -z "$TARGET" && -r "$ENV_FILE" ]]; then + TARGET="$(sed -n 's/^GREE_CONTROLLER_DISCOVERY_BROADCAST=//p' "$ENV_FILE" | tail -n1)" + TARGET="${TARGET%:*}" +fi + +if [[ -z "$IFACE" ]]; then + echo "Usage: $0 [broadcast-ip]" >&2 + echo "Example: $0 eth1 10.87.65.127" >&2 + exit 2 +fi + +echo "==> Interface" +ip -br addr show dev "$IFACE" +echo + +echo "==> IPv4 details" +ip -4 addr show dev "$IFACE" +echo + +echo "==> Routes" +ip route show dev "$IFACE" +echo + +if [[ -n "$TARGET" ]]; then + echo "==> Route to discovery target $TARGET" + ip route get "$TARGET" || true + echo +fi + +echo "==> GREE Controller environment" +if [[ -r "$ENV_FILE" ]]; then + grep -E '^(GREE_CONTROLLER_GREE_INTERFACE|GREE_CONTROLLER_DISCOVERY_BROADCAST|GREE_CONTROLLER_SIMULATE|GREE_CONTROLLER_AUTO_SEED)=' "$ENV_FILE" || true +else + echo "$ENV_FILE is not readable" +fi + +echo +if command -v tcpdump >/dev/null 2>&1; then + echo "Capture discovery traffic with:" + echo " tcpdump -ni $IFACE -vv 'udp port 7000'" +else + echo "tcpdump is not installed. Optional: apt-get install tcpdump" +fi diff --git a/src/api.rs b/src/api.rs index 61ebf8e..f2d2f55 100644 --- a/src/api.rs +++ b/src/api.rs @@ -21,7 +21,7 @@ use crate::{ engine, error::AppError, home_assistant, - models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone}, + models::{ApiTokenInfo, Automation, Device, DeviceCommand, DevicePatch, DiscoveryRequest, ManualDeviceRequest, RuntimeSettings, Schedule, Zone, ZoneControlPatch}, protocol::merge_discovered, state::AppState, }; @@ -46,6 +46,7 @@ pub fn router(state: AppState) -> Router { .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/zones/:id/control", post(update_zone_control)) .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)) @@ -172,25 +173,47 @@ async fn system_info(State(state): State) -> Result, AppEr "online_count": devices.iter().filter(|v| v.online).count(), "simulator_count": devices.iter().filter(|v| v.simulated).count(), "bind": state.config.bind.to_string(), + "gree_interface": if state.config.gree_interface.trim().is_empty() { "auto" } else { state.config.gree_interface.trim() }, }))) } 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 timeout_ms = request.timeout_ms.unwrap_or(settings.discovery_timeout_ms).clamp(500, 30_000); let broadcast = request.broadcast.unwrap_or(settings.discovery_broadcast); - let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms)).await + let protocol_version = request.protocol_version.unwrap_or(0).min(2); + let passes = request.passes.unwrap_or(3).clamp(1, 10); + let discovered = state.gree.discover(&broadcast, Duration::from_millis(timeout_ms), protocol_version, passes).await .map_err(|e| AppError::Device(e.to_string()))?; let mut saved = Vec::new(); + let mut new_device_ids = Vec::new(); for item in discovered { let existing = state.db.get_device_by_mac(&item.mac)?; - let merged = merge_discovered(existing, item); + let is_new = existing.is_none(); + let mut merged = merge_discovered(existing, item); + // Bind right after discovery. GREE modules can have a short bind window; + // bind() also refreshes it with a direct scan before the handshake. + if !merged.simulated && merged.key.as_deref().unwrap_or_default().is_empty() { + match state.gree.bind(&merged).await { + Ok(bound) => { + merged.key = Some(bound.key); + merged.protocol_version = bound.protocol_version; + merged.communication_failures = 0; + merged.last_error = None; + } + Err(err) => { + merged.last_error = Some(format!("discovered, bind pending: {err}")); + state.log("warn", "device.bind_after_discovery", &format!("{}: {err}", merged.name), json!({"device_id": merged.id})); + } + } + } state.db.save_device(&merged)?; + if is_new { new_device_ids.push(merged.id.clone()); } saved.push(merged); } - state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len()})); + state.log("info", "discovery.complete", &format!("Discovery found {} device(s)", saved.len()), json!({"count": saved.len(), "protocol_version": protocol_version, "passes": passes, "new_devices": new_device_ids.len()})); state.broadcast("devices.discovered", json!({"devices": saved})); - Ok(Json(json!({"count": saved.len(), "devices": saved}))) + Ok(Json(json!({"count": saved.len(), "devices": saved, "new_device_ids": new_device_ids}))) } async fn list_devices(State(state): State) -> Result>, AppError> { @@ -213,11 +236,11 @@ async fn add_device(State(state): State, Json(input): Json, Json(input): Json, Path(id): Path, Jso 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.protocol_version { let v = v.min(2); if device.protocol_version != v { device.protocol_version = v; device.key = None; } } if let Some(v) = patch.key { device.key = v.filter(|x| !x.trim().is_empty()); } if let Some(v) = patch.enabled { device.enabled = v; } device.updated_at = Utc::now(); @@ -271,8 +295,10 @@ async fn delete_device(State(state): State, Path(id): Path) -> 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); + let bound = state.gree.bind(&device).await.map_err(|e| AppError::Device(e.to_string()))?; + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + device.communication_failures = 0; device.online = true; device.last_seen = Some(Utc::now()); device.last_error = None; @@ -327,7 +353,7 @@ 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 !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 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())); } @@ -379,6 +405,38 @@ async fn update_zone(State(state): State, Path(id): Path, Json state.broadcast("zone.updated", serde_json::to_value(&zone)?); Ok(Json(zone)) } +async fn update_zone_control(State(state): State, Path(id): Path, Json(patch): Json) -> Result, AppError> { + let mut zone = state.db.get_zone(&id)?.ok_or_else(|| AppError::NotFound(format!("zone {id}")))?; + if let Some(value) = patch.setpoint { + if !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("zone setpoint must be between 8 and 30 C".into())); } + zone.setpoint = (value * 2.0).round() / 2.0; + } + if let Some(value) = patch.mode.as_deref() { + if !matches!(value, "cool" | "heat") { return Err(AppError::BadRequest("zone mode must be cool or heat".into())); } + zone.mode = value.to_string(); + } + if let Some(value) = patch.enabled { zone.enabled = value; } + zone.updated_at = Utc::now(); + state.db.save_zone(&zone)?; + + // Quick zone controls also update the paired climate unit immediately. Power is + // intentionally left unchanged; the zone engine still owns ON/OFF demand. + if patch.setpoint.is_some() || patch.mode.is_some() { + let command = DeviceCommand { + mode: patch.mode.as_ref().map(|_| zone.mode.clone()), + target_temperature: patch.setpoint.map(|_| zone.setpoint), + ..Default::default() + }; + if let Err(err) = engine::send_command(&state, &zone.device_id, command).await { + state.log("warn", "zone.quick_control_device_error", &format!("{}: {err}", zone.name), json!({"zone_id": zone.id, "device_id": zone.device_id})); + } + } + + state.broadcast("zone.updated", serde_json::to_value(&zone)?); + state.log("info", "zone.quick_control", &format!("Quick control updated for {}", zone.name), json!({"zone_id": zone.id, "setpoint": zone.setpoint, "mode": zone.mode, "enabled": zone.enabled})); + 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})); @@ -402,7 +460,7 @@ impl ScheduleInput { 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())); } + if !(8.0..=30.0).contains(&self.setpoint) { return Err(AppError::BadRequest("schedule setpoint must be between 8 and 30 C".into())); } Ok(()) } fn into_schedule(self, id: String, created_at: chrono::DateTime) -> Schedule { @@ -533,8 +591,11 @@ async fn update_settings(State(state): State, Json(mut input): Json() - .map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?; + if !(input.discovery_broadcast.eq_ignore_ascii_case("auto") + || input.discovery_broadcast.to_ascii_lowercase().starts_with("auto:")) { + 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() { diff --git a/src/config.rs b/src/config.rs index de14bb4..65e57bf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,9 +12,9 @@ pub struct Config { 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)] + #[arg(long, env = "GREE_CONTROLLER_SIMULATE", default_value_t = false)] pub simulate: bool, - #[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = true)] + #[arg(long, env = "GREE_CONTROLLER_AUTO_SEED", default_value_t = false)] pub auto_seed: bool, #[arg(long, env = "GREE_CONTROLLER_POLL_INTERVAL_SECONDS", default_value_t = 15)] pub poll_interval_seconds: u64, @@ -24,6 +24,8 @@ pub struct Config { 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_GREE_INTERFACE", default_value = "")] + pub gree_interface: String, #[arg(long, env = "GREE_CONTROLLER_ID", default_value = "gree-controller")] pub controller_id: String, } diff --git a/src/engine.rs b/src/engine.rs index f1200b2..9207691 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -66,23 +66,39 @@ pub async fn send_command(state: &AppState, device_id: &str, command: DeviceComm } else { if device.key.as_deref().unwrap_or_default().is_empty() { match state.gree.bind(&device).await { - Ok(key) => { - device.key = Some(key); + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + device.communication_failures = 0; state.db.save_device(&device)?; - state.log("info", "device.bound", &format!("Bound {}", device.name), json!({"device_id": device.id})); + state.log("info", "device.bound", &format!("Bound {} using protocol V{}", device.name, device.protocol_version), json!({"device_id": device.id, "protocol_version": device.protocol_version})); } Err(err) => { - mark_device_error(state, &mut device, &err.to_string())?; + register_device_failure(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())); + if let Err(first_err) = state.gree.command(&device, &command).await { + // Retry once after a fresh bind. This covers stale keys and devices that + // switched between ECB/GCM after a firmware update. + let retry_result = match state.gree.bind(&device).await { + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + state.db.save_device(&device)?; + state.gree.command(&device, &command).await + } + Err(_) => Err(first_err), + }; + if let Err(err) = retry_result { + register_device_failure(state, &mut device, &err.to_string())?; + return Err(AppError::Device(err.to_string())); + } } command.apply(&mut device); device.online = true; + device.communication_failures = 0; device.last_seen = Some(Utc::now()); device.last_error = None; state.db.save_device(&device)?; @@ -124,19 +140,30 @@ async fn poll_device(state: &AppState, device: &mut Device) { } if device.key.as_deref().unwrap_or_default().is_empty() { match state.gree.bind(device).await { - Ok(key) => device.key = Some(key), + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + device.communication_failures = 0; + } Err(err) => { - device.online = false; - device.last_error = Some(err.to_string()); - device.updated_at = Utc::now(); + record_poll_failure(device, &err.to_string()); 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(); + if let Err(first_err) = state.gree.poll(device).await { + // A stale key or wrong cipher should heal automatically during polling. + // Rebind once, then retry the status request before counting a failure. + match state.gree.bind(device).await { + Ok(bound) => { + device.key = Some(bound.key); + device.protocol_version = bound.protocol_version; + if let Err(err) = state.gree.poll(device).await { + record_poll_failure(device, &err.to_string()); + } + } + Err(_) => record_poll_failure(device, &first_err.to_string()), + } } } @@ -184,18 +211,28 @@ fn record_reading(state: &AppState, device: &Device) -> Result<()> { Ok(()) } -fn mark_device_error(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> { - device.online = false; +fn record_poll_failure(device: &mut Device, error: &str) { + device.communication_failures = device.communication_failures.saturating_add(1); + // A single dropped UDP response is not enough to declare an AC offline. + if device.communication_failures >= 3 { device.online = false; } device.last_error = Some(error.to_string()); device.updated_at = Utc::now(); +} + +fn register_device_failure(state: &AppState, device: &mut Device, error: &str) -> Result<(), AppError> { + record_poll_failure(device, error); state.db.save_device(device)?; - state.log("error", "device.error", &format!("{}: {error}", device.name), json!({"device_id": device.id})); + state.log("warn", "device.communication_error", &format!("{}: {error}", device.name), json!({ + "device_id": device.id, + "consecutive_failures": device.communication_failures, + "offline": !device.online, + })); 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 !(8.0..=30.0).contains(&value) { return Err(AppError::BadRequest("target temperature must be between 8 and 30 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())); } diff --git a/src/main.rs b/src/main.rs index 681fce9..45833d8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,12 @@ async fn main() -> Result<()> { init_tracing(); let db = Db::open(&config.database)?; - let runtime_settings = db.load_runtime_settings()?.unwrap_or_else(|| config.runtime_defaults()); + let mut runtime_settings = db.load_runtime_settings()?.unwrap_or_else(|| config.runtime_defaults()); + // Network deployment settings explicitly provided by the service environment are authoritative. + // This makes /etc/gree-controller.env useful even after runtime settings were persisted in SQLite. + if std::env::var_os("GREE_CONTROLLER_DISCOVERY_BROADCAST").is_some() { + runtime_settings.discovery_broadcast = config.discovery_broadcast.clone(); + } db.save_runtime_settings(&runtime_settings)?; if config.simulate && config.auto_seed && db.count_devices()? == 0 { @@ -47,7 +52,10 @@ async fn main() -> Result<()> { db, settings: Arc::new(RwLock::new(runtime_settings.clone())), config: Arc::new(config.clone()), - gree: GreeClient::new(runtime_settings.controller_id.clone()), + gree: GreeClient::new( + runtime_settings.controller_id.clone(), + (!config.gree_interface.trim().is_empty()).then(|| config.gree_interface.trim().to_string()), + ), events, http, started: Instant::now(), @@ -58,11 +66,14 @@ async fn main() -> Result<()> { let listener = TcpListener::bind(config.bind).await .with_context(|| format!("cannot bind HTTP server to {}", config.bind))?; + let gree_interface_log = if config.gree_interface.trim().is_empty() { "auto" } else { config.gree_interface.trim() }; tracing::info!( address = %config.bind, database = %config.database.display(), simulator = config.simulate, auth = !config.app_token.trim().is_empty(), + gree_interface = %gree_interface_log, + discovery_broadcast = %runtime_settings.discovery_broadcast, "GREE Controller started" ); diff --git a/src/models.rs b/src/models.rs index 285b6f6..38545e7 100644 --- a/src/models.rs +++ b/src/models.rs @@ -4,7 +4,7 @@ use serde_json::Value; fn default_true() -> bool { true } fn default_port() -> u16 { 7000 } -fn default_protocol() -> u8 { 1 } +fn default_protocol() -> u8 { 0 } fn default_mode() -> String { "cool".into() } fn default_fan() -> u8 { 0 } fn default_target() -> f64 { 24.0 } @@ -65,6 +65,8 @@ pub struct Device { pub last_seen: Option>, #[serde(default)] pub last_error: Option, + #[serde(default)] + pub communication_failures: u8, pub created_at: DateTime, pub updated_at: DateTime, } @@ -99,6 +101,7 @@ impl Device { online: true, last_seen: Some(now), last_error: None, + communication_failures: 0, created_at: now, updated_at: now, } @@ -132,7 +135,7 @@ 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.target_temperature { device.target_temperature = v.clamp(8.0, 30.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; } @@ -192,6 +195,17 @@ pub struct Zone { fn default_sensor_source() -> String { "device".into() } + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ZoneControlPatch { + #[serde(default)] + pub setpoint: Option, + #[serde(default)] + pub mode: Option, + #[serde(default)] + pub enabled: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Schedule { pub id: String, @@ -284,6 +298,12 @@ pub struct DiscoveryRequest { pub timeout_ms: Option, #[serde(default)] pub broadcast: Option, + /// 0 = auto (accept both), 1 = AES-ECB only, 2 = AES-GCM only. + #[serde(default)] + pub protocol_version: Option, + /// Number of scan broadcasts sent during one discovery operation. + #[serde(default)] + pub passes: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/protocol/crypto.rs b/src/protocol/crypto.rs index 86ef425..7b027a8 100644 --- a/src/protocol/crypto.rs +++ b/src/protocol/crypto.rs @@ -2,9 +2,14 @@ use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::G 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"; +/// Shared discovery/bind key used by the original AES-128-ECB protocol. +pub const GENERIC_GREE_V1_KEY: &str = "a3K8Bx%2r8Y7#xDh"; +/// Shared discovery/bind key used by AES-128-GCM capable Wi-Fi modules. +pub const GENERIC_GREE_V2_KEY: &str = "{yxAHAY_Lm6pbC/<"; +/// GREE protocol v2 uses a fixed nonce and AAD, matching the EWPE/GREE LAN protocol. +const GCM_NONCE: [u8; 12] = [0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13]; +const GCM_AAD: &[u8] = b"qualcomm-test"; pub fn normalize_key(key: &str) -> Result<[u8; 16]> { let bytes = key.as_bytes(); @@ -57,37 +62,31 @@ pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result> { #[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 nonce = Nonce::from_slice(&GCM_NONCE); let mut buffer = plaintext.to_vec(); - let tag = cipher.encrypt_in_place_detached(nonce, b"", &mut buffer) + let tag = cipher.encrypt_in_place_detached(nonce, GCM_AAD, &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> { +pub fn decrypt_v2(key: &str, ciphertext_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 nonce = Nonce::from_slice(&GCM_NONCE); let tag = GenericArray::from_slice(&tag_bytes); - cipher.decrypt_in_place_detached(nonce, b"", &mut data, tag) + cipher.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag) .map_err(|_| anyhow!("AES-GCM authentication failed"))?; Ok(data) } @@ -99,14 +98,14 @@ mod tests { #[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); + let encrypted = encrypt_v1(GENERIC_GREE_V1_KEY, value).unwrap(); + assert_eq!(decrypt_v1(GENERIC_GREE_V1_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); + let encrypted = encrypt_v2(GENERIC_GREE_V2_KEY, value).unwrap(); + assert_eq!(decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(), value); } } diff --git a/src/protocol/gree.rs b/src/protocol/gree.rs index e791afd..5cb3edd 100644 --- a/src/protocol/gree.rs +++ b/src/protocol/gree.rs @@ -1,48 +1,108 @@ -use std::{collections::HashSet, net::SocketAddr, sync::{Arc, atomic::{AtomicU64, Ordering}}, time::Duration}; +use std::{collections::HashSet, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, 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}; +use super::crypto::{ + decrypt_v1, decrypt_v2, encrypt_v1, encrypt_v2, + GENERIC_GREE_V1_KEY, GENERIC_GREE_V2_KEY, +}; + +#[derive(Debug, Clone)] +pub struct BindResult { + pub key: String, + pub protocol_version: u8, +} #[derive(Clone)] pub struct GreeClient { controller_id: String, - sequence: Arc, + interface: Option, } impl GreeClient { - pub fn new(controller_id: String) -> Self { - Self { controller_id, sequence: Arc::new(AtomicU64::new(1)) } + pub fn new(controller_id: String, interface: Option) -> Self { + Self { controller_id, interface } } - fn next_id(&self) -> u64 { self.sequence.fetch_add(1, Ordering::Relaxed) } + async fn udp_socket(&self, broadcast: bool) -> Result { + let socket = if let Some(interface) = self.interface.as_deref() { + let ip = interface_ipv4(interface)?; + UdpSocket::bind(SocketAddrV4::new(ip, 0)).await + .with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))? + } else { + UdpSocket::bind("0.0.0.0:0").await? + }; + socket.set_broadcast(broadcast)?; + Ok(socket) + } - 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?; + fn discovery_target(&self, configured: &str) -> Result { + let value = configured.trim(); + if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") { + let port = value.split_once(':') + .map(|(_, port)| port.parse::().context("invalid automatic discovery port")) + .transpose()? + .unwrap_or(7000); + let interface = self.interface.as_deref() + .ok_or_else(|| anyhow!("automatic discovery broadcast requires GREE_CONTROLLER_GREE_INTERFACE"))?; + let (_, broadcast) = interface_ipv4_config(interface)?; + return Ok(SocketAddr::V4(SocketAddrV4::new(broadcast, port))); + } + value.parse().context("invalid discovery broadcast address") + } + + /// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only. + pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result> { + let target = self.discovery_target(broadcast)?; + let socket = self.udp_socket(true).await?; + let local = socket.local_addr()?; + let passes = passes.clamp(1, 10); + tracing::info!( + target = %target, + local = %local, + interface = %self.interface.as_deref().unwrap_or("auto"), + protocol = protocol_filter, + passes, + controller_id = %self.controller_id, + "Starting GREE discovery" + ); let deadline = Instant::now() + duration; + let interval = if passes > 1 { duration / passes as u32 } else { duration }; + let mut next_scan = Instant::now(); + let mut sent = 0_u8; let mut result = Vec::new(); let mut seen = HashSet::new(); - let mut buffer = vec![0_u8; 8192]; + let mut buffer = vec![0_u8; 16 * 1024]; while Instant::now() < deadline { + if sent < passes && Instant::now() >= next_scan { + socket.send_to(br#"{"t":"scan"}"#, target).await?; + sent += 1; + next_scan = Instant::now() + interval.max(Duration::from_millis(250)); + tracing::debug!(pass = sent, passes, target = %target, "Sent GREE discovery packet"); + } + let remaining = deadline.saturating_duration_since(Instant::now()); - match timeout(remaining.min(Duration::from_millis(450)), socket.recv_from(&mut buffer)).await { + let wait = remaining.min(Duration::from_millis(250)); + match timeout(wait, 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 Ok(value) = serde_json::from_slice::(&buffer[..size]) else { continue; }; + match self.parse_discovery(value, source) { + Ok(Some(mut device)) => { + if protocol_filter != 0 && device.protocol_version != protocol_filter { continue; } let key = device.mac.to_ascii_lowercase(); if seen.insert(key) { device.last_seen = Some(Utc::now()); + tracing::info!(ip=%device.ip, mac=%device.mac, protocol=device.protocol_version, model=%device.model, firmware=%device.firmware, "Discovered GREE device"); result.push(device); } } + Ok(None) => {} + Err(err) => tracing::debug!(source=%source, error=?err, "Ignoring undecodable discovery response"), } } Ok(Err(err)) => return Err(err.into()), @@ -52,42 +112,71 @@ impl GreeClient { Ok(result) } - fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Option { + fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Result> { + let mut detected_protocol = 1_u8; 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; } + if let Some(pack_value) = value.get("pack") { + if let Some(pack) = pack_value.as_str() { + let clear = if let Some(tag) = value.get("tag").and_then(Value::as_str) { + detected_protocol = 2; + decrypt_v2(GENERIC_GREE_V2_KEY, pack, tag)? + } else { + decrypt_v1(GENERIC_GREE_V1_KEY, pack)? + }; + value = serde_json::from_slice::(&clear).context("invalid decrypted discovery JSON")?; + } else if pack_value.is_object() { + value = pack_value.clone(); } } } - let kind = value.get("t").and_then(Value::as_str).unwrap_or_default(); + let kind = value.get("t").and_then(Value::as_str).unwrap_or_default().to_ascii_lowercase(); if kind != "dev" && kind != "scan" && value.get("mac").is_none() && value.get("cid").is_none() { - return None; + return Ok(None); } - let mac = value.get("mac").or_else(|| value.get("cid"))?.as_str()?.replace(':', ""); - if mac.is_empty() { return None; } + let mac = value.get("mac") + .or_else(|| value.get("cid")) + .and_then(Value::as_str) + .unwrap_or_default() + .replace([':', '-'], "").to_ascii_uppercase(); + if mac.is_empty() { return Ok(None); } + + let raw_model = value.get("model").or_else(|| value.get("series")) + .and_then(Value::as_str).unwrap_or_default().trim().to_string(); + let model_type = value.get("ModelType") + .and_then(|v| v.as_str().map(str::to_string).or_else(|| v.as_i64().map(|n| n.to_string()))) + .unwrap_or_default(); + let model = if !model_type.is_empty() && (raw_model.is_empty() || raw_model.eq_ignore_ascii_case("gree")) { + format!("GREE {model_type}") + } else if raw_model.is_empty() { + "GREE".to_string() + } else { + raw_model + }; + let ver = value.get("ver").and_then(Value::as_str).unwrap_or_default().trim(); + let hid = value.get("hid").and_then(Value::as_str).unwrap_or_default().trim(); + let firmware = match (ver.is_empty(), hid.is_empty()) { + (false, false) => format!("{ver} · {hid}"), + (false, true) => ver.to_string(), + (true, false) => hid.to_string(), + (true, true) => String::new(), + }; + let suffix = mac.chars().rev().take(4).collect::().chars().rev().collect::().to_ascii_uppercase(); 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 }); + .map(str::trim).filter(|v| !v.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{model} {suffix}")); let now = Utc::now(); - Some(Device { + Ok(Some(Device { id: format!("gree-{}", mac.to_ascii_lowercase()), mac, name, ip: source.ip().to_string(), - port: source.port(), - protocol_version, + port: if source.port() == 0 { 7000 } else { source.port() }, + protocol_version: detected_protocol, model, firmware, key: None, - cid: Some(self.controller_id.clone()), + cid: Some("app".into()), enabled: true, simulated: false, power: false, @@ -104,14 +193,47 @@ impl GreeClient { online: true, last_seen: Some(now), last_error: None, + communication_failures: 0, created_at: now, updated_at: now, - }) + })) } - pub async fn bind(&self, device: &Device) -> Result { + pub async fn bind(&self, device: &Device) -> Result { + let versions: &[u8] = match device.protocol_version { + 2 => &[2, 1], + _ => &[1, 2], + }; + let mut errors = Vec::new(); + for &version in versions { + match self.bind_attempt(device, version).await { + Ok(key) => return Ok(BindResult { key, protocol_version: version }), + Err(err) => { + tracing::warn!(device=%device.id, ip=%device.ip, protocol=version, error=?err, "GREE bind attempt failed"); + errors.push(format!("V{version}: {err}")); + } + } + } + bail!("unable to bind device ({})", errors.join("; ")) + } + + async fn bind_attempt(&self, device: &Device, version: u8) -> Result { + let target = self.device_target(device)?; + let socket = self.udp_socket(false).await?; + + // Some Wi-Fi modules only accept bind shortly after a scan. A direct scan + // refreshes that window and works across routed/VLAN deployments too. + socket.send_to(br#"{"t":"scan"}"#, target).await?; + let mut scan_buf = vec![0_u8; 16 * 1024]; + let _ = timeout(Duration::from_millis(900), socket.recv_from(&mut scan_buf)).await; + let inner = json!({"mac": device.mac, "t": "bind", "uid": 0}); - let response = self.request(device, &inner, GENERIC_GREE_KEY, true).await?; + let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY }; + let response = self.request_on_socket(device, &inner, generic_key, true, version, &socket).await?; + let kind = response.get("t").and_then(Value::as_str).unwrap_or_default(); + if !kind.eq_ignore_ascii_case("bindok") { + bail!("unexpected bind response type: {kind}") + } 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") } @@ -120,23 +242,45 @@ impl GreeClient { 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 = [ + let full_cols = [ "Pow","Mod","SetTem","WdSpd","Air","Blo","Health","SwhSlp","Lig", "SwingLfRig","SwUpDn","Quiet","Tur","StHt","TemUn","HeatCoolType", - "TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem" + "TemRec","SvSt","TemSen","CoolSvTem","HeatSvTem","OutEnvTem" ]; + let core_cols = ["Pow","Mod","SetTem","TemRec","TemUn","TemSen","WdSpd","Lig","SwingLfRig","SwUpDn","Quiet","Tur"]; + let response = match self.status_request(device, key, &full_cols).await { + Ok(value) => value, + Err(first) => { + tracing::debug!(device=%device.id, error=?first, "Full GREE status request failed; retrying core properties"); + self.status_request(device, key, &core_cols).await? + } + }; + self.apply_status(device, &response)?; + device.online = true; + device.communication_failures = 0; + device.last_seen = Some(Utc::now()); + device.last_error = None; + device.updated_at = Utc::now(); + Ok(()) + } + + async fn status_request(&self, device: &Device, key: &str, cols: &[&str]) -> Result { let inner = json!({"cols": cols, "mac": device.mac, "t": "status"}); - let response = self.request(device, &inner, key, false).await?; + self.request(device, &inner, key, false, device.protocol_version).await + } + + fn apply_status(&self, device: &mut Device, response: &Value) -> Result<()> { 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"))?; + let mut set_temp = None; 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), + "SetTem" => set_temp = Some(value_as_f64(value)), "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, @@ -147,13 +291,16 @@ impl GreeClient { let raw = value_as_f64(value); device.current_temperature = Some(if raw > 40.0 { raw - 40.0 } else { raw }); } + "OutEnvTem" => { + let raw = value_as_f64(value); + device.outdoor_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(); + if let Some(base) = set_temp { + device.target_temperature = base.clamp(8.0, 30.0); + } Ok(()) } @@ -163,7 +310,12 @@ impl GreeClient { 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.target_temperature { + // GREE's Celsius setpoint is whole-degree. TemRec is used by the + // Fahrenheit conversion path and should not be abused as a 0.5 C bit. + let whole = v.clamp(8.0, 30.0).round() as i64; + opt.push("SetTem"); values.push(json!(whole)); + } 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 })); } @@ -172,65 +324,131 @@ impl GreeClient { 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 + self.request(device, &inner, key, false, device.protocol_version).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")?; + async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result { + let socket = self.udp_socket(false).await?; + self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await + } + + async fn request_on_socket(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8, socket: &UdpSocket) -> Result { + let target = self.device_target(device)?; + let version = if protocol_version == 2 { 2 } else { 1 }; let inner_bytes = serde_json::to_vec(inner)?; let mut outer = json!({ - "cid": self.controller_id, - "i": self.next_id(), + "cid": "app", + "i": if binding { 1 } else { 0 }, "t": "pack", "tcid": device.mac, "uid": 0 }); - if device.protocol_version >= 2 && !binding { + if version == 2 { 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?; + tracing::debug!(target=%target, local=%socket.local_addr()?, protocol=version, interface=%self.interface.as_deref().unwrap_or("auto"), binding, "Sending GREE request"); socket.send_to(&payload, target).await?; + + let deadline = Instant::now() + Duration::from_secs(4); 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}") + let mut last_decode_error = None; + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()); + let received = timeout(remaining, socket.recv_from(&mut buffer)).await; + let (size, source) = match received { + Ok(Ok(value)) => value, + Ok(Err(err)) => return Err(err.into()), + Err(_) => break, + }; + if source.ip() != target.ip() { continue; } + let response: Value = match serde_json::from_slice(&buffer[..size]) { + Ok(value) => value, + Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; } + }; + if let Some(pack) = response.get("pack").and_then(Value::as_object) { + return Ok(Value::Object(pack.clone())); + } + let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; }; + let clear = if version == 2 { + let Some(tag) = response.get("tag").and_then(Value::as_str) else { + last_decode_error = Some(anyhow!("AES-GCM response is missing tag")); + continue; + }; + match decrypt_v2(key, pack, tag) { + Ok(v) => v, + Err(err) => { last_decode_error = Some(err); continue; } + } + } else { + match decrypt_v1(key, pack) { + Ok(v) => v, + Err(err) => { last_decode_error = Some(err); continue; } + } + }; + let decoded: Value = match serde_json::from_slice(&clear) { + Ok(value) => value, + Err(err) => { last_decode_error = Some(anyhow!("invalid decrypted GREE response: {err}")); continue; } + }; + if binding { + let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default(); + if !response_type.eq_ignore_ascii_case("bindok") { continue; } + } + if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") } + return Ok(decoded); } - Ok(decoded) + if let Some(err) = last_decode_error { return Err(err); } + bail!("GREE response timeout after 4 seconds") + } + + fn device_target(&self, device: &Device) -> Result { + format!("{}:{}", device.ip, device.port).parse().context("invalid device address") } } -fn value_as_i64(value: &Value) -> i64 { - value.as_i64().or_else(|| value.as_str()?.parse().ok()).unwrap_or_default() +#[cfg(target_os = "linux")] +fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> { + use std::{ffi::CStr, ptr}; + unsafe { + let mut addrs: *mut libc::ifaddrs = ptr::null_mut(); + if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); } + let mut current = addrs; + let mut found = None; + while !current.is_null() { + let ifa = &*current; + if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() { + let name = CStr::from_ptr(ifa.ifa_name).to_string_lossy(); + if name == interface && (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET { + let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in); + let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes()); + let broadcast = if !ifa.ifa_netmask.is_null() { + let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in); + let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes()); + Ipv4Addr::from(u32::from(ip) | !u32::from(mask)) + } else { Ipv4Addr::BROADCAST }; + found = Some((ip, broadcast)); + break; + } + } + current = ifa.ifa_next; + } + libc::freeifaddrs(addrs); + found.ok_or_else(|| anyhow!("interface {interface} has no IPv4 address")) + } } -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" } +#[cfg(not(target_os = "linux"))] +fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> { + bail!("GREE interface binding is only supported on Linux (requested {interface})") } +fn interface_ipv4(interface: &str) -> Result { interface_ipv4_config(interface).map(|(ip, _)| ip) } +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), @@ -242,11 +460,15 @@ 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 old.name.trim().is_empty() || old.name == "Klimatyzator GREE" || old.name == "GREE air conditioner" { 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; + if old.protocol_version != discovered.protocol_version { + old.protocol_version = discovered.protocol_version; + old.key = None; + } old.online = true; + old.communication_failures = 0; old.last_seen = Some(Utc::now()); old.last_error = None; old.updated_at = Utc::now(); diff --git a/web/app.js b/web/app.js index 0b63f57..2151fdb 100644 --- a/web/app.js +++ b/web/app.js @@ -211,16 +211,16 @@ function deviceCard(device, detailed = false) { 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}`}`; + : `${esc(device.ip)}:${esc(device.port)} · ${device.simulated ? tr('devices.simulator') : device.protocol_version === 2 ? 'V2 GCM' : device.protocol_version === 1 ? 'V1 ECB' : tr('devices.protocolAuto')}`; 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('')}
@@ -230,7 +230,7 @@ function deviceCard(device, detailed = false) { - ${detailed ? `` : ''} + ${detailed ? `` : ''}
`; } @@ -267,8 +267,12 @@ function renderZones() { 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'))}
+
+
${Number(zone.setpoint).toFixed(1)}°C
+
+
${esc(sensorDetails)}
- +
`; }).join('') : `
${esc(tr('zones.emptyTitle'))}${esc(tr('zones.emptyText'))}
`; } @@ -361,6 +365,35 @@ function updateDevice(device) { if (index >= 0) app.devices[index] = device; else app.devices.push(device); } +async function sendZoneControl(id, patch) { + try { + const zone = await api(`/api/zones/${encodeURIComponent(id)}/control`, {method:'POST', body:patch}); + const index = app.zones.findIndex(item => item.id === zone.id); + if (index >= 0) app.zones[index] = zone; else app.zones.push(zone); + renderSummary(); renderZones(); + } catch (error) { toast(error.message, true); } +} + +function showDiscoveryNames(ids) { + const wanted = new Set(Array.isArray(ids) ? ids : []); + const devices = app.devices.filter(device => wanted.has(device.id)); + if (!devices.length) return; + const list = $('#discoveryNamesList'); + list.innerHTML = devices.map(device => ` + `).join(''); + openDialog('discoveryNamesDialog'); +} + +function populateDeviceRename(id) { + const device = app.devices.find(item => item.id === id); if (!device) return; + const form = $('#renameDeviceForm'); form.reset(); + form.id.value = device.id; form.name.value = device.name; form.protocol_version.value = String(device.protocol_version ?? 0); + openDialog('renameDeviceDialog'); +} + 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')); } @@ -502,13 +535,16 @@ document.addEventListener('click', async event => { 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 === 'temperature' && device) return sendDeviceCommand(device.id, {target_temperature:clamp(Number(device.target_temperature)+Number(button.dataset.delta),8,30)}); 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 === 'rename-device' && device) return populateDeviceRename(device.id); if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device'); + if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) return sendZoneControl(zone.id,{setpoint:clamp(Number(zone.setpoint)+Number(button.dataset.delta),8,30)}); } + if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value}); 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); @@ -528,10 +564,10 @@ document.addEventListener('click', async event => { }); $('#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');} +$('#discoverButton').addEventListener('click', () => { + const form=$('#discoverForm'); form.reset(); + form.protocol_version.value='0'; form.passes.value='3'; form.timeout_ms.value=String(Math.max(6000, Number(app.settings?.discovery_timeout_ms || 3000))); + openDialog('discoverDialog'); }); $('#historyRefresh').addEventListener('click', loadHistory); $('#historyDevice').addEventListener('change', loadHistory); @@ -546,6 +582,36 @@ $('#tokenForm').addEventListener('submit', event => { localStorage.setItem('gree_controller_token', app.token); if (app.ws) app.ws.close(); loadBootstrap(); }); +$('#discoverForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + const submit=form.querySelector('button[type=submit]'); submit.disabled=true; submit.textContent=tr('actions.discovering'); + try { + const result=await api('/api/discovery',{method:'POST',body:{protocol_version:Number(raw.protocol_version),passes:Number(raw.passes),timeout_ms:Number(raw.timeout_ms)}}); + form.closest('dialog').close(); await loadBootstrap(); toast(tr('toast.found',{count:result.count})); showDiscoveryNames(result.new_device_ids || []); + } catch(error){toast(error.message,true);} finally {submit.disabled=false;submit.textContent=tr('actions.discover');} +}); + +$('#discoveryNamesForm').addEventListener('submit', async event => { + event.preventDefault(); + const form = event.currentTarget; + const inputs = $$('input[data-device-id]', form); + try { + const updated = await Promise.all(inputs.map(input => api(`/api/devices/${encodeURIComponent(input.dataset.deviceId)}`, {method:'PATCH', body:{name:input.value.trim()}}))); + updated.forEach(updateDevice); + form.closest('dialog').close(); + renderAll(); + toast(tr('common.saved')); + } catch (error) { toast(error.message, true); } +}); + +$('#renameDeviceForm').addEventListener('submit', async event => { + event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form)); + try { + const device=await api(`/api/devices/${encodeURIComponent(raw.id)}`,{method:'PATCH',body:{name:raw.name.trim(),protocol_version:Number(raw.protocol_version)}}); + updateDevice(device); form.closest('dialog').close(); renderAll(); toast(tr('common.saved')); + } catch(error){toast(error.message,true);} +}); + $('#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; diff --git a/web/index.html b/web/index.html index cbceae7..d4f40a1 100644 --- a/web/index.html +++ b/web/index.html @@ -150,20 +150,51 @@ -
+
+ + +
+

Discover GREE devices

+

Auto searches both AES-ECB and AES-GCM devices. Multiple passes improve discovery when several Wi-Fi modules answer the same broadcast.

+ +
+
+
+
+ + +
+

Name discovered devices

+

Give each new unit a friendly room name. The technical model and MAC remain available in diagnostics.

+
+
+
+
+ + +
+ +

Rename device

+ + +

Changing protocol clears the saved device key and performs a new bind on the next request.

+
+
+
+

Zone

-
+
@@ -184,7 +215,7 @@
Weekdays
- +
@@ -201,7 +232,7 @@

Action

-
+
diff --git a/web/styles.css b/web/styles.css index f73e1fa..604e075 100644 --- a/web/styles.css +++ b/web/styles.css @@ -232,3 +232,16 @@ legend { padding: 0 5px; color: var(--muted); font-size: 11px; } .token-row { align-items: stretch; flex-direction: column; } .token-row button { width: 100%; } } + +/* Zone quick controls */ +.zone-quick-control { display: grid; gap: 4px; margin-top: 10px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-muted); } +.temperature-control.compact { padding: 4px 0 7px; gap: 16px; } +.temperature-control.compact button { width: 38px; height: 38px; } +.target-temp.compact { min-width: 92px; font-size: 32px; } +.zone-quick-control .mode-row { padding-bottom: 2px; } + +.discovery-name-list { display:grid; gap:12px; } +.discovery-name-row { display:grid; gap:8px; padding:12px 0; border-bottom:1px solid var(--border); } +.discovery-name-row:last-child { border-bottom:0; } +.discovery-name-row span { display:grid; gap:2px; } +.discovery-name-row small { color:var(--muted); overflow-wrap:anywhere; }