v0.3.5
This commit is contained in:
+38
-51
@@ -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/<timestamp>/
|
||||
/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.
|
||||
|
||||
Generated
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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"] }
|
||||
|
||||
+24
-23
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+13
-2
@@ -33,9 +33,11 @@ These two endpoints always require `Authorization: Bearer <generated-token>` (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`.
|
||||
|
||||
+34
@@ -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:<ephemeral> 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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
+13
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <interface>` — configures a dedicated GREE NIC, automatic subnet broadcast, disables the simulator, and restarts the service.
|
||||
- `network-debug.sh <interface> [broadcast-ip]` — prints interface/routing diagnostics and a tcpdump command.
|
||||
|
||||
Executable
+53
@@ -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 <interface>"
|
||||
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
|
||||
+2
-3
@@ -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=
|
||||
|
||||
Executable
+53
@@ -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 <interface> [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
|
||||
+76
-15
@@ -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<AppState>) -> Result<Json<Value>, 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<AppState>, Json(request): Json<DiscoveryRequest>) -> Result<Json<Value>, 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<AppState>) -> Result<Json<Vec<Device>>, AppError> {
|
||||
@@ -213,11 +236,11 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
|
||||
name: input.name.trim().to_string(),
|
||||
ip: input.ip,
|
||||
port: input.port,
|
||||
protocol_version: input.protocol_version.clamp(1, 2),
|
||||
protocol_version: input.protocol_version.min(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()),
|
||||
cid: Some("app".into()),
|
||||
enabled: true,
|
||||
simulated: input.simulated,
|
||||
power: false,
|
||||
@@ -234,6 +257,7 @@ async fn add_device(State(state): State<AppState>, Json(input): Json<ManualDevic
|
||||
online: input.simulated,
|
||||
last_seen: if input.simulated { Some(now) } else { None },
|
||||
last_error: None,
|
||||
communication_failures: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -252,7 +276,7 @@ async fn patch_device(State(state): State<AppState>, Path(id): Path<String>, 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::<IpAddr>().map_err(|_| AppError::BadRequest("invalid IP address".into()))?; device.ip = v; }
|
||||
if let Some(v) = patch.port { device.port = v; }
|
||||
if let Some(v) = patch.protocol_version { 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<AppState>, Path(id): Path<String>) ->
|
||||
async fn bind_device(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Device>, 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<AppState>, Path(id): Path<String>, Json
|
||||
state.broadcast("zone.updated", serde_json::to_value(&zone)?);
|
||||
Ok(Json(zone))
|
||||
}
|
||||
async fn update_zone_control(State(state): State<AppState>, Path(id): Path<String>, Json(patch): Json<ZoneControlPatch>) -> Result<Json<Zone>, 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<AppState>, Path(id): Path<String>) -> Result<StatusCode, AppError> {
|
||||
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<Utc>) -> Schedule {
|
||||
@@ -533,8 +591,11 @@ async fn update_settings(State(state): State<AppState>, Json(mut input): Json<Ru
|
||||
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::<std::net::SocketAddr>()
|
||||
.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::<std::net::SocketAddr>()
|
||||
.map_err(|_| AppError::BadRequest("invalid discovery broadcast address".into()))?;
|
||||
}
|
||||
if input.controller_id.trim().is_empty() { input.controller_id = old.controller_id; }
|
||||
if input.home_assistant.token.trim().is_empty() { input.home_assistant.token = old.home_assistant.token; }
|
||||
if !input.home_assistant.url.trim().is_empty() {
|
||||
|
||||
+4
-2
@@ -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,
|
||||
}
|
||||
|
||||
+56
-19
@@ -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())); }
|
||||
|
||||
+13
-2
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
+22
-2
@@ -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<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub last_error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub communication_failures: u8,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -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<f64>,
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Schedule {
|
||||
pub id: String,
|
||||
@@ -284,6 +298,12 @@ pub struct DiscoveryRequest {
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub broadcast: Option<String>,
|
||||
/// 0 = auto (accept both), 1 = AES-ECB only, 2 = AES-GCM only.
|
||||
#[serde(default)]
|
||||
pub protocol_version: Option<u8>,
|
||||
/// Number of scan broadcasts sent during one discovery operation.
|
||||
#[serde(default)]
|
||||
pub passes: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
+16
-17
@@ -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<Vec<u8>> {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct V2Encrypted {
|
||||
pub ciphertext: String,
|
||||
pub nonce: String,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = <Aes128Gcm as AeadKeyInit>::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<Vec<u8>> {
|
||||
pub fn decrypt_v2(key: &str, ciphertext_b64: &str, tag_b64: &str) -> Result<Vec<u8>> {
|
||||
let key = normalize_key(key)?;
|
||||
let cipher = <Aes128Gcm as AeadKeyInit>::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);
|
||||
}
|
||||
}
|
||||
|
||||
+306
-84
@@ -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<AtomicU64>,
|
||||
interface: Option<String>,
|
||||
}
|
||||
|
||||
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<String>) -> 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<UdpSocket> {
|
||||
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<Vec<Device>> {
|
||||
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<SocketAddr> {
|
||||
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::<u16>().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<Vec<Device>> {
|
||||
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::<Value>(&buffer[..size]) {
|
||||
if let Some(mut device) = self.parse_discovery(value, source) {
|
||||
let Ok(value) = serde_json::from_slice::<Value>(&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<Device> {
|
||||
fn parse_discovery(&self, mut value: Value, source: SocketAddr) -> Result<Option<Device>> {
|
||||
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::<Value>(&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::<Value>(&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::<String>().chars().rev().collect::<String>().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<String> {
|
||||
pub async fn bind(&self, device: &Device) -> Result<BindResult> {
|
||||
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<String> {
|
||||
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<Value> {
|
||||
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::<Value>::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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<SocketAddr> {
|
||||
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<Ipv4Addr> { 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<i64> {
|
||||
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<Device>, 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();
|
||||
|
||||
+76
-10
@@ -211,16 +211,16 @@ function deviceCard(device, detailed = false) {
|
||||
const fans = [0,1,3,5];
|
||||
const error = device.last_error
|
||||
? `<small title="${esc(device.last_error)}">${esc(device.last_error)}</small>`
|
||||
: `<small>${esc(device.ip)}:${esc(device.port)} · ${device.simulated ? tr('devices.simulator') : `V${device.protocol_version}`}</small>`;
|
||||
: `<small>${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')}</small>`;
|
||||
return `<article class="device-card ${device.power ? '' : 'off'}" data-device-card="${esc(device.id)}">
|
||||
<div class="device-head">
|
||||
<div class="device-title"><h3>${esc(device.name)}</h3><p><span class="status ${device.online ? 'online' : ''}">${esc(tr(device.online ? 'status.online' : 'status.offline'))}</span> · ${esc(device.model || device.mac)}</p></div>
|
||||
<button class="power-button ${device.power ? 'on' : ''}" data-action="power" data-device="${esc(device.id)}" aria-label="${esc(tr('devices.power'))}">⏻</button>
|
||||
</div>
|
||||
<div class="temperature-control">
|
||||
<button data-action="temperature" data-delta="-0.5" data-device="${esc(device.id)}">−</button>
|
||||
<button data-action="temperature" data-delta="-1" data-device="${esc(device.id)}">−</button>
|
||||
<div class="target-temp">${Number(device.target_temperature).toFixed(1)}<small>°C</small></div>
|
||||
<button data-action="temperature" data-delta="0.5" data-device="${esc(device.id)}">+</button>
|
||||
<button data-action="temperature" data-delta="1" data-device="${esc(device.id)}">+</button>
|
||||
</div>
|
||||
<div class="current-line">${esc(tr('devices.currentTemperature'))}: <strong>${fmtTemp(device.current_temperature)}</strong>${device.outdoor_temperature == null ? '' : ` · ${esc(tr('devices.outdoor'))} ${fmtTemp(device.outdoor_temperature)}`}</div>
|
||||
<div class="mode-row">${modes.map(mode => `<button class="${device.mode === mode ? 'active' : ''}" data-action="mode" data-value="${mode}" data-device="${esc(device.id)}">${esc(modeLabel(mode))}</button>`).join('')}</div>
|
||||
@@ -230,7 +230,7 @@ function deviceCard(device, detailed = false) {
|
||||
<button class="${device.quiet ? 'active' : ''}" data-action="toggle" data-field="quiet" data-device="${esc(device.id)}">${esc(tr('devices.quiet'))}</button>
|
||||
<button class="${device.turbo ? 'active' : ''}" data-action="toggle" data-field="turbo" data-device="${esc(device.id)}">${esc(tr('devices.turbo'))}</button>
|
||||
</div>
|
||||
${detailed ? `<div class="card-footer">${error}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
${detailed ? `<div class="card-footer">${error}<div class="card-menu"><button data-action="poll" data-device="${esc(device.id)}">${esc(tr('actions.read'))}</button><button data-action="rename-device" data-device="${esc(device.id)}">${esc(tr('devices.rename'))}</button>${device.simulated ? '' : `<button data-action="bind" data-device="${esc(device.id)}">${esc(tr('actions.bind'))}</button>`}<button class="danger" data-action="delete-device" data-device="${esc(device.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
@@ -267,8 +267,12 @@ function renderZones() {
|
||||
return `<article class="list-card">
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zoneStrategyLabel(zone))}${zone.ha_entity_id ? ` · ${esc(zone.ha_entity_id)}` : ''}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(zone.setpoint)}</strong></div><div class="card-stat"><small>${esc(tr('zones.demand'))}</small><strong>${esc(tr(zone.demand ? 'common.on' : 'common.off'))}</strong></div></div>
|
||||
<div class="zone-quick-control">
|
||||
<div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5">−</button><div class="target-temp compact">${Number(zone.setpoint).toFixed(1)}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div>
|
||||
<div class="mode-row"><button class="${zone.mode === 'heat' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${zone.mode === 'cool' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
</div>
|
||||
<div class="sensor-detail">${esc(sensorDetails)}</div>
|
||||
<div class="card-footer"><small>${esc(modeLabel(zone.mode))} · ${esc(tr('zones.hysteresis').replace(' °C','').toLowerCase())} ${Number(zone.hysteresis).toFixed(1)}°C</small><div class="card-menu"><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
|
||||
<div class="card-footer"><small>${esc(tr('zones.quickHint'))}</small><div class="card-menu"><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
|
||||
</article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
|
||||
}
|
||||
@@ -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 => `
|
||||
<label class="discovery-name-row">
|
||||
<span><strong>${esc(device.model || 'GREE')}</strong><small>${esc(device.ip)} · ${esc(device.mac)} · ${device.protocol_version === 2 ? 'V2 GCM' : 'V1 ECB'}</small></span>
|
||||
<input data-device-id="${esc(device.id)}" maxlength="80" required value="${esc(device.name)}">
|
||||
</label>`).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;
|
||||
|
||||
+35
-4
@@ -150,20 +150,51 @@
|
||||
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
|
||||
<label><span>MAC / CID</span><input name="mac" required placeholder="AABBCCDDEEFF"></label>
|
||||
<label><span data-i18n="devices.ipAddress">IP address</span><input name="ip" required placeholder="192.168.1.50"></label>
|
||||
<div class="two"><label><span>Port</span><input type="number" name="port" value="7000" min="1" max="65535"></label><label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version"><option value="1">V1 AES-ECB</option><option value="2">V2 AES-GCM</option></select></label></div>
|
||||
<div class="two"><label><span>Port</span><input type="number" name="port" value="7000" min="1" max="65535"></label><label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version"><option value="0" data-i18n="devices.protocolAuto">Auto (V1 + V2)</option><option value="1">V1 AES-ECB</option><option value="2">V2 AES-GCM</option></select></label></div>
|
||||
<label><span data-i18n="devices.keyOptional">Key (optional)</span><input name="key" data-i18n-placeholder="devices.keyPlaceholder" placeholder="Leave empty for automatic bind"></label>
|
||||
<label class="check"><input type="checkbox" name="simulated"> <span data-i18n="devices.simulated">Simulated device</span></label>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.add">Add</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
<dialog id="discoverDialog">
|
||||
<form method="dialog" id="discoverForm" class="dialog-form">
|
||||
<div class="dialog-head"><h2 data-i18n="discovery.title">Discover GREE devices</h2><button type="button" data-close>×</button></div>
|
||||
<p class="field-note" data-i18n="discovery.help">Auto searches both AES-ECB and AES-GCM devices. Multiple passes improve discovery when several Wi-Fi modules answer the same broadcast.</p>
|
||||
<label><span data-i18n="discovery.protocol">Discovery protocol</span><select name="protocol_version"><option value="0" data-i18n="devices.protocolAuto">Auto (V1 + V2)</option><option value="1">V1 AES-ECB</option><option value="2">V2 AES-GCM</option></select></label>
|
||||
<div class="two"><label><span data-i18n="discovery.passes">Scan passes</span><input type="number" name="passes" min="1" max="10" value="3"></label><label><span data-i18n="discovery.timeout">Total scan time (ms)</span><input type="number" name="timeout_ms" min="500" max="30000" value="6000"></label></div>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.discover">Discover</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="discoveryNamesDialog">
|
||||
<form method="dialog" id="discoveryNamesForm" class="dialog-form">
|
||||
<div class="dialog-head"><h2 data-i18n="discovery.nameDevices">Name discovered devices</h2><button type="button" data-close>×</button></div>
|
||||
<p class="field-note" data-i18n="discovery.nameDevicesHelp">Give each new unit a friendly room name. The technical model and MAC remain available in diagnostics.</p>
|
||||
<div id="discoveryNamesList" class="discovery-name-list"></div>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.later">Later</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="renameDeviceDialog">
|
||||
<form method="dialog" id="renameDeviceForm" class="dialog-form">
|
||||
<input type="hidden" name="id">
|
||||
<div class="dialog-head"><h2 data-i18n="devices.rename">Rename device</h2><button type="button" data-close>×</button></div>
|
||||
<label><span data-i18n="common.name">Name</span><input name="name" required maxlength="80"></label>
|
||||
<label><span data-i18n="devices.protocol">Protocol</span><select name="protocol_version"><option value="0" data-i18n="devices.protocolAuto">Auto (V1 + V2)</option><option value="1">V1 AES-ECB</option><option value="2">V2 AES-GCM</option></select></label>
|
||||
<p class="field-note" data-i18n="devices.protocolChangeHint">Changing protocol clears the saved device key and performs a new bind on the next request.</p>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="zoneDialog">
|
||||
<form method="dialog" id="zoneForm" class="dialog-form">
|
||||
<input type="hidden" name="id">
|
||||
<div class="dialog-head"><h2 data-i18n="common.zone">Zone</h2><button type="button" data-close>×</button></div>
|
||||
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
|
||||
<label><span data-i18n="common.device">Device</span><select name="device_id" required></select></label>
|
||||
<div class="two"><label><span data-i18n="common.mode">Mode</span><select name="mode"><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option></select></label><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="32" name="setpoint" value="23"></label></div>
|
||||
<div class="two"><label><span data-i18n="common.mode">Mode</span><select name="mode"><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option></select></label><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="30" name="setpoint" value="23"></label></div>
|
||||
<div class="two"><label><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="number" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
|
||||
<div id="externalSensorFields">
|
||||
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
@@ -184,7 +215,7 @@
|
||||
<label><span data-i18n="common.zone">Zone</span><select name="zone_id" required></select></label>
|
||||
<fieldset><legend data-i18n="schedules.weekdays">Weekdays</legend><div class="days"><label><input type="checkbox" name="weekday" value="1" checked><span data-day="1">Mon</span></label><label><input type="checkbox" name="weekday" value="2" checked><span data-day="2">Tue</span></label><label><input type="checkbox" name="weekday" value="3" checked><span data-day="3">Wed</span></label><label><input type="checkbox" name="weekday" value="4" checked><span data-day="4">Thu</span></label><label><input type="checkbox" name="weekday" value="5" checked><span data-day="5">Fri</span></label><label><input type="checkbox" name="weekday" value="6"><span data-day="6">Sat</span></label><label><input type="checkbox" name="weekday" value="7"><span data-day="7">Sun</span></label></div></fieldset>
|
||||
<div class="two"><label><span data-i18n="common.from">From</span><input type="time" name="start_time" value="22:00" required></label><label><span data-i18n="common.to">To</span><input type="time" name="end_time" value="06:00" required></label></div>
|
||||
<label><span data-i18n="common.temperatureC">Temperature °C</span><input type="number" name="setpoint" step="0.1" min="8" max="32" value="23" required></label>
|
||||
<label><span data-i18n="common.temperatureC">Temperature °C</span><input type="number" name="setpoint" step="0.1" min="8" max="30" value="23" required></label>
|
||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
@@ -201,7 +232,7 @@
|
||||
<hr><h3 data-i18n="automations.action">Action</h3>
|
||||
<label><span data-i18n="common.device">Device</span><select name="action_device_id" required></select></label>
|
||||
<div class="two"><label><span data-i18n="common.power">Power</span><select name="action_power"><option value="" data-i18n="actions.noChange">No change</option><option value="true" data-i18n="actions.turnOn">Turn on</option><option value="false" data-i18n="actions.turnOff">Turn off</option></select></label><label><span data-i18n="common.mode">Mode</span><select name="action_mode"><option value="" data-i18n="actions.noChange">No change</option><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option><option value="dry" data-i18n="mode.dry">Dry</option><option value="fan" data-i18n="mode.fan">Fan</option><option value="auto" data-i18n="mode.auto">Auto</option></select></label></div>
|
||||
<div class="two"><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="32" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
|
||||
<div class="two"><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="30" name="action_target_temperature"></label><label><span data-i18n="common.cooldownSeconds">Cooldown (s)</span><input type="number" min="30" name="cooldown_seconds" value="300"></label></div>
|
||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user