v0.3.6
This commit is contained in:
+21
-33
@@ -1,57 +1,45 @@
|
|||||||
# GREE Controller v0.3.5 - build and validation report
|
# GREE Controller v0.3.6 - build and validation report
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Version 0.3.5 focuses on mixed-generation GREE networks and mobile day-to-day control.
|
Version 0.3.6 fixes real-device binding failures observed on a mixed five-unit GREE network in a multi-NIC LXC.
|
||||||
|
|
||||||
### GREE protocol fixes
|
### Protocol fixes
|
||||||
|
|
||||||
- corrected the standard AES-128-ECB generic key,
|
- GREE AES-GCM decoding now accepts non-canonical Base64 trailing bits and optional padding. This matches the forgiving decoding behavior used by established Python GREE implementations and fixes `Invalid last symbol ... offset 21` errors from real Wi-Fi modules.
|
||||||
- corrected the standard AES-128-GCM generic key,
|
- GCM decrypted payloads discard `0xff` filler bytes used by some modules.
|
||||||
- GCM now uses the fixed 12-byte nonce and `qualcomm-test` AAD used by EWPE/GREE Wi-Fi modules,
|
- ECB decoding keeps normal PKCS#7 validation but has a compatibility fallback that trims decrypted data at the last JSON `}` when a legacy module returns non-standard padding.
|
||||||
- discovery detects GCM by the response `tag` and decrypts it with the GCM generic key,
|
- Bind waits ignore late discovery packets instead of accidentally treating a `dev` packet as the bind result.
|
||||||
- discovery supports Auto/V1/V2 filtering and 1-10 repeated scan passes,
|
- Before each bind, the controller refreshes the short GREE bind window with a subnet broadcast scan when the device is on a directly connected IPv4 network. The same UDP socket is then used immediately for the bind request.
|
||||||
- protocol packets use `cid="app"`, `i=1` for bind and `i=0` for status/commands,
|
- ECB/GCM fallback remains enabled and successful binding persists the detected protocol and device key.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Availability behavior
|
### Multi-NIC behavior
|
||||||
|
|
||||||
- one UDP timeout no longer immediately marks a device offline,
|
- If `GREE_CONTROLLER_GREE_INTERFACE` is configured, all GREE UDP sockets are bound to the current IPv4 address of that interface.
|
||||||
- a device is marked offline after 3 consecutive communication failures,
|
- If no GREE interface is configured, the controller now automatically selects the local IPv4 address whose subnet contains the target GREE device. For the reported LXC this resolves `10.87.65.x` traffic to the `10.87.65.27/25` interface instead of leaving the socket bound to `0.0.0.0`.
|
||||||
- successful bind/poll/command resets the failure counter.
|
- The automatically selected subnet broadcast is also used to refresh the bind window (for example `10.87.65.127:7000`).
|
||||||
|
|
||||||
### Web UI
|
|
||||||
|
|
||||||
- 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
|
## Validation performed
|
||||||
|
|
||||||
| Check | Result |
|
| Check | Result |
|
||||||
|---|---|
|
|---|---|
|
||||||
| JavaScript syntax (`node --check web/app.js`) | PASS |
|
| JavaScript syntax (`node --check web/app.js`) | PASS |
|
||||||
| JSON parsing for EN/PL language packs | PASS |
|
| EN/PL JSON parsing | PASS |
|
||||||
| Shell syntax (`bash -n scripts/*.sh`) | PASS |
|
| Shell syntax (`bash -n scripts/*.sh`) | PASS |
|
||||||
| Python syntax for scripts and HA integration | PASS |
|
| Python syntax for scripts and HA integration | PASS |
|
||||||
| SQL remains centralized in `src/queries.rs` | PASS |
|
| SQL remains centralized in `src/queries.rs` | PASS |
|
||||||
|
| No operator `.sh`/`.py` scripts in the project root | PASS |
|
||||||
| Package naming remains `gree_controller` / `GREE_CONTROLLER_*` | PASS |
|
| Package naming remains `gree_controller` / `GREE_CONTROLLER_*` | PASS |
|
||||||
|
| ZIP integrity | performed during release packaging |
|
||||||
|
|
||||||
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 does not contain the Rust toolchain, so the final Rust type-check, tests and optimized build are intentionally performed by `scripts/update.sh` on the target LXC before the installed binary is replaced.
|
||||||
|
|
||||||
Recommended LXC update:
|
## Recommended LXC test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./scripts/update.sh
|
sudo ./scripts/update.sh
|
||||||
./scripts/service.sh health
|
sudo ./scripts/configure-gree-network.sh eth1
|
||||||
journalctl -u gree-controller -n 150 --no-pager
|
journalctl -u gree-controller -f
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
Expected request logs should show a concrete local address such as `10.87.65.27:<port>` rather than `0.0.0.0:<port>`.
|
||||||
|
|||||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.3.5"
|
version = "0.3.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "gree-controller"
|
name = "gree-controller"
|
||||||
version = "0.3.5"
|
version = "0.3.6"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["GREE Controller contributors"]
|
authors = ["GREE Controller contributors"]
|
||||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
6b10bf7903a32a6cdd902d990911105589690deafe7da12e5b3b9c994c1fd4f6 ./.env.example
|
6b10bf7903a32a6cdd902d990911105589690deafe7da12e5b3b9c994c1fd4f6 ./.env.example
|
||||||
2fe1cf4e544fead5ae58436145a5b45e7a5a105143dfd815d28e307c94d5d19b ./.gitignore
|
2fe1cf4e544fead5ae58436145a5b45e7a5a105143dfd815d28e307c94d5d19b ./.gitignore
|
||||||
6a321094970809e697c2bab8b7861abc0808496b1c143a667dec30bded1a1758 ./BUILD_REPORT.md
|
d75023129d63417a5af399567944d99a65c4570e1cef671ba2f68fdd734fd8d1 ./BUILD_REPORT.md
|
||||||
0a3b167534311a32306c567814562adefe2c5f9d1302a1b3426288801915cf65 ./Cargo.toml
|
baad3cddff5a160ac1e5f5200838c83bd1edc2a3d4dec09b95b8858a1a4bfeb7 ./Cargo.toml
|
||||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||||
c8f9e1cbc54329e2dad226245a8bbd3f17de416c5e6cbb3a43469d77e6006916 ./README.md
|
349344b41f5bb6680079c5ed95f6294c9c9df6a73bdbda452d351033abf4768a ./README.md
|
||||||
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
a4fa9bfee9735ed8ed95ea31456e0cce503d82502ae3f550108ffca51b0f0c3d ./build.rs
|
||||||
37453ecce2c7290ecae5ada4249cfd36bc70fb50c479aa680b74e4f1bc707251 ./docs/API.md
|
37453ecce2c7290ecae5ada4249cfd36bc70fb50c479aa680b74e4f1bc707251 ./docs/API.md
|
||||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||||
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
||||||
26ca44b749930ee15cdfde8106198e621b322ae1e5c60a7bd8a1680489b83de9 ./docs/LXC.md
|
456b65737d3b4579e40edba2655c0cb1a63620d8a7bfa0f8844acf81b21fc374 ./docs/LXC.md
|
||||||
a728ddc324613671932bd83522155b09c7503d4fbcd0d04d8e217676ea9e10e4 ./docs/PROJECT_SPEC.md
|
a728ddc324613671932bd83522155b09c7503d4fbcd0d04d8e217676ea9e10e4 ./docs/PROJECT_SPEC.md
|
||||||
33214270b96ac4c64e3db41c11e54158792b4a87ee5668c651bad571766b591a ./home-assistant/README.md
|
33214270b96ac4c64e3db41c11e54158792b4a87ee5668c651bad571766b591a ./home-assistant/README.md
|
||||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||||
@@ -18,7 +18,7 @@ fde31b8e020fd36be2d9d9b1e554254f5da8a1cc593240ebb8d78820154dd790 ./home-assista
|
|||||||
e1821b74859bc40773a6ee39e6ccc9650980b62af50d6426b46cdb3e3a90d200 ./home-assistant/custom_components/gree_controller/const.py
|
e1821b74859bc40773a6ee39e6ccc9650980b62af50d6426b46cdb3e3a90d200 ./home-assistant/custom_components/gree_controller/const.py
|
||||||
2d27d7cb67c53e819b99a0302cd0bd339c27e3e2dfc85e07d30c31f505f740b5 ./home-assistant/custom_components/gree_controller/coordinator.py
|
2d27d7cb67c53e819b99a0302cd0bd339c27e3e2dfc85e07d30c31f505f740b5 ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||||
4eca7d2c224282fde1db22a5010bcf0a3d17f2342c206371a9202314be0d7b56 ./home-assistant/custom_components/gree_controller/manifest.json
|
23fc6d9d7136492db8dc046e3f54c65070e5f65e1c6303ecab1bac5ef686661d ./home-assistant/custom_components/gree_controller/manifest.json
|
||||||
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
|
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
|
||||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||||
@@ -44,8 +44,8 @@ ae3b496749a3fd723b243d9bea92e5d76249f52814c80359ad9bac53abacb074 ./src/error.rs
|
|||||||
f85cb4ba6435843431d93779aa0653fd24ec8987e06ef36efde096cbea321d7a ./src/home_assistant.rs
|
f85cb4ba6435843431d93779aa0653fd24ec8987e06ef36efde096cbea321d7a ./src/home_assistant.rs
|
||||||
27eca87a9078aad7a7a4ef1fc39ce7ddaffcf05466acb995d571e0a35dfe898c ./src/main.rs
|
27eca87a9078aad7a7a4ef1fc39ce7ddaffcf05466acb995d571e0a35dfe898c ./src/main.rs
|
||||||
a5923f8fb72ebac68c13e4767dd35012041e2783167969521e757bf502fafabd ./src/models.rs
|
a5923f8fb72ebac68c13e4767dd35012041e2783167969521e757bf502fafabd ./src/models.rs
|
||||||
286a38d9e5a2380fad26f8fdb0393dfbcf22d208de8b753a4ba65282eec4ce40 ./src/protocol/crypto.rs
|
7fc31fbf8841a073a1544b8c7a6390f1a15b56087486ca0596a8418340fa232a ./src/protocol/crypto.rs
|
||||||
e940d3344ca34a7c8c0821b054044cfec0ef12637e0104f31cbd5de69c3e1c8a ./src/protocol/gree.rs
|
53e6fdb575da4790cdf88013073b973461fb9ace362fbe51dda95c0eaadf2365 ./src/protocol/gree.rs
|
||||||
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
a910bd9432a393740c0f6fab52bfcb551f0ea756718d66d290fd2610767cf07c ./src/protocol/mod.rs
|
||||||
c80164631bba476db29469fa43b28d73f68412c06bafabe2143ab8d4cd330a52 ./src/queries.rs
|
c80164631bba476db29469fa43b28d73f68412c06bafabe2143ab8d4cd330a52 ./src/queries.rs
|
||||||
521070a4c63bec73372cf4873f20c5fad23c37db4df7199bcc5bf6f6e566b3f6 ./src/state.rs
|
521070a4c63bec73372cf4873f20c5fad23c37db4df7199bcc5bf6f6e566b3f6 ./src/state.rs
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
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.
|
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.5**.
|
Current version: **0.3.6**.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`do
|
|||||||
On Debian, Ubuntu or an LXC container:
|
On Debian, Ubuntu or an LXC container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
unzip gree-controller-v0.3.5.zip
|
unzip gree-controller-v0.3.6.zip
|
||||||
cd gree-controller
|
cd gree-controller
|
||||||
chmod +x scripts/*.sh
|
chmod +x scripts/*.sh
|
||||||
./scripts/dev.sh
|
./scripts/dev.sh
|
||||||
@@ -92,7 +92,7 @@ The selected appearance is stored in the `gree_controller_theme` cookie. The int
|
|||||||
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.
|
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.
|
6. If LXC/VLAN broadcast does not pass, add the unit manually with IP and MAC/CID.
|
||||||
|
|
||||||
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.
|
Version 0.3.6 additionally uses forgiving GREE Base64 decoding for non-canonical GCM tags seen on real Wi-Fi modules, automatically selects the directly connected local IPv4 interface for unicast GREE traffic, and refreshes the short bind window with a subnet broadcast immediately before bind. A single lost UDP response no longer marks a device offline; three consecutive communication failures are required.
|
||||||
|
|
||||||
## LXC/systemd installation and updates
|
## LXC/systemd installation and updates
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -117,6 +117,8 @@ When the LXC has a management interface and a dedicated GREE network, configure
|
|||||||
```env
|
```env
|
||||||
GREE_CONTROLLER_GREE_INTERFACE=eth1
|
GREE_CONTROLLER_GREE_INTERFACE=eth1
|
||||||
GREE_CONTROLLER_DISCOVERY_BROADCAST=auto
|
GREE_CONTROLLER_DISCOVERY_BROADCAST=auto
|
||||||
|
|
||||||
|
This explicit interface configuration is recommended for predictable LXC deployments. Version 0.3.6 also automatically selects the directly connected local IPv4 address for a GREE device when the interface variable is omitted; for example, a target in `10.87.65.0/25` selects the local address on that subnet.
|
||||||
GREE_CONTROLLER_SIMULATE=false
|
GREE_CONTROLLER_SIMULATE=false
|
||||||
GREE_CONTROLLER_AUTO_SEED=false
|
GREE_CONTROLLER_AUTO_SEED=false
|
||||||
```
|
```
|
||||||
@@ -139,6 +141,6 @@ Use `scripts/network-debug.sh` for routing diagnostics.
|
|||||||
|
|
||||||
## Mixed GREE model generations
|
## 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.
|
Version 0.3.6 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.
|
A single command/status timeout no longer immediately flips a device offline; offline requires three consecutive communication failures.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "gree_controller",
|
"domain": "gree_controller",
|
||||||
"name": "GREE Controller",
|
"name": "GREE Controller",
|
||||||
"version": "0.3.5",
|
"version": "0.3.6",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
|||||||
+46
-8
@@ -1,7 +1,14 @@
|
|||||||
use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}};
|
use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}};
|
||||||
use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}};
|
use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}};
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
use base64::{
|
||||||
|
alphabet,
|
||||||
|
engine::{
|
||||||
|
general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD},
|
||||||
|
DecodePaddingMode,
|
||||||
|
},
|
||||||
|
Engine,
|
||||||
|
};
|
||||||
|
|
||||||
/// Shared discovery/bind key used by the original AES-128-ECB protocol.
|
/// Shared discovery/bind key used by the original AES-128-ECB protocol.
|
||||||
pub const GENERIC_GREE_V1_KEY: &str = "a3K8Bx%2r8Y7#xDh";
|
pub const GENERIC_GREE_V1_KEY: &str = "a3K8Bx%2r8Y7#xDh";
|
||||||
@@ -11,6 +18,17 @@ pub const GENERIC_GREE_V2_KEY: &str = "{yxAHAY_Lm6pbC/<";
|
|||||||
const GCM_NONCE: [u8; 12] = [0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13];
|
const GCM_NONCE: [u8; 12] = [0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13];
|
||||||
const GCM_AAD: &[u8] = b"qualcomm-test";
|
const GCM_AAD: &[u8] = b"qualcomm-test";
|
||||||
|
|
||||||
|
// Some GREE Wi-Fi modules emit technically non-canonical Base64: padding may
|
||||||
|
// be omitted and unused trailing bits can be set. Python's base64 decoder,
|
||||||
|
// used by the established GREE implementations, accepts those packets. Keep
|
||||||
|
// encoding canonical but use a forgiving decoder for device-originated data.
|
||||||
|
const GREE_BASE64_DECODE: GeneralPurpose = GeneralPurpose::new(
|
||||||
|
&alphabet::STANDARD,
|
||||||
|
GeneralPurposeConfig::new()
|
||||||
|
.with_decode_padding_mode(DecodePaddingMode::Indifferent)
|
||||||
|
.with_decode_allow_trailing_bits(true),
|
||||||
|
);
|
||||||
|
|
||||||
pub fn normalize_key(key: &str) -> Result<[u8; 16]> {
|
pub fn normalize_key(key: &str) -> Result<[u8; 16]> {
|
||||||
let bytes = key.as_bytes();
|
let bytes = key.as_bytes();
|
||||||
if bytes.len() == 16 {
|
if bytes.len() == 16 {
|
||||||
@@ -44,7 +62,7 @@ pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result<String> {
|
|||||||
pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
|
pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
|
||||||
let key = normalize_key(key)?;
|
let key = normalize_key(key)?;
|
||||||
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
|
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
|
||||||
let mut data = STANDARD.decode(ciphertext_b64).context("invalid base64 packet")?;
|
let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid base64 packet")?;
|
||||||
if data.is_empty() || data.len() % 16 != 0 {
|
if data.is_empty() || data.len() % 16 != 0 {
|
||||||
bail!("invalid AES-ECB ciphertext length")
|
bail!("invalid AES-ECB ciphertext length")
|
||||||
}
|
}
|
||||||
@@ -52,10 +70,19 @@ pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
|
|||||||
cipher.decrypt_block(GenericArray::from_mut_slice(block));
|
cipher.decrypt_block(GenericArray::from_mut_slice(block));
|
||||||
}
|
}
|
||||||
let pad = *data.last().ok_or_else(|| anyhow!("empty plaintext"))? as usize;
|
let pad = *data.last().ok_or_else(|| anyhow!("empty plaintext"))? as usize;
|
||||||
if pad == 0 || pad > 16 || data.len() < pad || data[data.len() - pad..].iter().any(|v| *v as usize != pad) {
|
let valid_padding = pad > 0
|
||||||
bail!("invalid PKCS#7 padding")
|
&& pad <= 16
|
||||||
|
&& data.len() >= pad
|
||||||
|
&& data[data.len() - pad..].iter().all(|v| *v as usize == pad);
|
||||||
|
if valid_padding {
|
||||||
|
data.truncate(data.len() - pad);
|
||||||
|
} else if let Some(last_json_byte) = data.iter().rposition(|byte| *byte == b'}') {
|
||||||
|
// Legacy GREE implementations are deliberately tolerant here: some
|
||||||
|
// modules return non-standard padding but the JSON itself is valid.
|
||||||
|
data.truncate(last_json_byte + 1);
|
||||||
|
} else {
|
||||||
|
bail!("invalid AES-ECB padding and no JSON terminator")
|
||||||
}
|
}
|
||||||
data.truncate(data.len() - pad);
|
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,13 +108,15 @@ pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
|
|||||||
pub fn decrypt_v2(key: &str, ciphertext_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 key = normalize_key(key)?;
|
||||||
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?;
|
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?;
|
||||||
let tag_bytes = STANDARD.decode(tag_b64).context("invalid GCM tag")?;
|
let tag_bytes = GREE_BASE64_DECODE.decode(tag_b64).context("invalid GCM tag")?;
|
||||||
if tag_bytes.len() != 16 { bail!("invalid GCM tag length") }
|
if tag_bytes.len() != 16 { bail!("invalid GCM tag length: {} bytes", tag_bytes.len()) }
|
||||||
let mut data = STANDARD.decode(ciphertext_b64).context("invalid GCM ciphertext")?;
|
let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid GCM ciphertext")?;
|
||||||
let nonce = Nonce::from_slice(&GCM_NONCE);
|
let nonce = Nonce::from_slice(&GCM_NONCE);
|
||||||
let tag = GenericArray::from_slice(&tag_bytes);
|
let tag = GenericArray::from_slice(&tag_bytes);
|
||||||
cipher.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag)
|
cipher.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag)
|
||||||
.map_err(|_| anyhow!("AES-GCM authentication failed"))?;
|
.map_err(|_| anyhow!("AES-GCM authentication failed"))?;
|
||||||
|
// A few modules append 0xff filler bytes to decrypted JSON.
|
||||||
|
data.retain(|byte| *byte != 0xff);
|
||||||
Ok(data)
|
Ok(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,4 +137,13 @@ mod tests {
|
|||||||
let encrypted = encrypt_v2(GENERIC_GREE_V2_KEY, value).unwrap();
|
let encrypted = encrypt_v2(GENERIC_GREE_V2_KEY, value).unwrap();
|
||||||
assert_eq!(decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(), value);
|
assert_eq!(decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gree_base64_accepts_noncanonical_trailing_bits() {
|
||||||
|
// 16 zero bytes canonically end with `A==`. `B==` carries the same
|
||||||
|
// useful two bits but has non-zero unused trailing bits. Python's
|
||||||
|
// base64.b64decode accepts it and real GREE modules emit this form.
|
||||||
|
let decoded = GREE_BASE64_DECODE.decode("AAAAAAAAAAAAAAAAAAAAAB==").unwrap();
|
||||||
|
assert_eq!(decoded, vec![0_u8; 16]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-9
@@ -27,11 +27,24 @@ impl GreeClient {
|
|||||||
Self { controller_id, interface }
|
Self { controller_id, interface }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn udp_socket(&self, broadcast: bool) -> Result<UdpSocket> {
|
async fn udp_socket(&self, broadcast: bool, target_hint: Option<Ipv4Addr>) -> Result<UdpSocket> {
|
||||||
let socket = if let Some(interface) = self.interface.as_deref() {
|
let socket = if let Some(interface) = self.interface.as_deref() {
|
||||||
let ip = interface_ipv4(interface)?;
|
let ip = interface_ipv4(interface)?;
|
||||||
UdpSocket::bind(SocketAddrV4::new(ip, 0)).await
|
UdpSocket::bind(SocketAddrV4::new(ip, 0)).await
|
||||||
.with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))?
|
.with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))?
|
||||||
|
} else if let Some(target) = target_hint {
|
||||||
|
if let Some(config) = local_ipv4_config_for_target(target)? {
|
||||||
|
tracing::debug!(
|
||||||
|
target = %target,
|
||||||
|
interface = %config.interface,
|
||||||
|
local_ip = %config.ip,
|
||||||
|
"Automatically selected local interface for GREE UDP"
|
||||||
|
);
|
||||||
|
UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await
|
||||||
|
.with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))?
|
||||||
|
} else {
|
||||||
|
UdpSocket::bind("0.0.0.0:0").await?
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
UdpSocket::bind("0.0.0.0:0").await?
|
UdpSocket::bind("0.0.0.0:0").await?
|
||||||
};
|
};
|
||||||
@@ -39,6 +52,19 @@ impl GreeClient {
|
|||||||
Ok(socket)
|
Ok(socket)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bind_scan_target(&self, target: SocketAddr) -> Result<SocketAddr> {
|
||||||
|
let SocketAddr::V4(target_v4) = target else { return Ok(target); };
|
||||||
|
let broadcast = if let Some(interface) = self.interface.as_deref() {
|
||||||
|
let (_, broadcast) = interface_ipv4_config(interface)?;
|
||||||
|
Some(broadcast)
|
||||||
|
} else {
|
||||||
|
local_ipv4_config_for_target(*target_v4.ip())?.map(|config| config.broadcast)
|
||||||
|
};
|
||||||
|
Ok(broadcast
|
||||||
|
.map(|ip| SocketAddr::V4(SocketAddrV4::new(ip, target_v4.port())))
|
||||||
|
.unwrap_or(target))
|
||||||
|
}
|
||||||
|
|
||||||
fn discovery_target(&self, configured: &str) -> Result<SocketAddr> {
|
fn discovery_target(&self, configured: &str) -> Result<SocketAddr> {
|
||||||
let value = configured.trim();
|
let value = configured.trim();
|
||||||
if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") {
|
if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") {
|
||||||
@@ -57,7 +83,8 @@ impl GreeClient {
|
|||||||
/// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only.
|
/// 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>> {
|
pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result<Vec<Device>> {
|
||||||
let target = self.discovery_target(broadcast)?;
|
let target = self.discovery_target(broadcast)?;
|
||||||
let socket = self.udp_socket(true).await?;
|
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
||||||
|
let socket = self.udp_socket(true, target_hint).await?;
|
||||||
let local = socket.local_addr()?;
|
let local = socket.local_addr()?;
|
||||||
let passes = passes.clamp(1, 10);
|
let passes = passes.clamp(1, 10);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -219,13 +246,30 @@ impl GreeClient {
|
|||||||
|
|
||||||
async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> {
|
async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> {
|
||||||
let target = self.device_target(device)?;
|
let target = self.device_target(device)?;
|
||||||
let socket = self.udp_socket(false).await?;
|
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
||||||
|
let socket = self.udp_socket(true, target_hint).await?;
|
||||||
|
|
||||||
// Some Wi-Fi modules only accept bind shortly after a scan. A direct scan
|
// Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the
|
||||||
// refreshes that window and works across routed/VLAN deployments too.
|
// bind window with a subnet broadcast when the target is on a directly
|
||||||
socket.send_to(br#"{"t":"scan"}"#, target).await?;
|
// connected network. A unicast scan remains the fallback for routed
|
||||||
|
// deployments. Keep the same UDP socket for scan + bind.
|
||||||
|
let scan_target = self.bind_scan_target(target)?;
|
||||||
|
tracing::debug!(device=%device.id, target=%target, scan_target=%scan_target, local=%socket.local_addr()?, "Refreshing GREE bind window");
|
||||||
|
socket.send_to(br#"{"t":"scan"}"#, scan_target).await?;
|
||||||
let mut scan_buf = vec![0_u8; 16 * 1024];
|
let mut scan_buf = vec![0_u8; 16 * 1024];
|
||||||
let _ = timeout(Duration::from_millis(900), socket.recv_from(&mut scan_buf)).await;
|
let scan_deadline = Instant::now() + Duration::from_millis(1500);
|
||||||
|
while Instant::now() < scan_deadline {
|
||||||
|
let remaining = scan_deadline.saturating_duration_since(Instant::now());
|
||||||
|
match timeout(remaining, socket.recv_from(&mut scan_buf)).await {
|
||||||
|
Ok(Ok((_size, source))) if source.ip() == target.ip() => {
|
||||||
|
tracing::debug!(device=%device.id, source=%source, "Received scan response immediately before bind");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(Ok(_)) => continue,
|
||||||
|
Ok(Err(err)) => return Err(err.into()),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let inner = json!({"mac": device.mac, "t": "bind", "uid": 0});
|
let inner = json!({"mac": device.mac, "t": "bind", "uid": 0});
|
||||||
let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY };
|
let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY };
|
||||||
@@ -328,7 +372,9 @@ impl GreeClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
|
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
|
||||||
let socket = self.udp_socket(false).await?;
|
let target = self.device_target(device)?;
|
||||||
|
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
|
||||||
|
let socket = self.udp_socket(false, target_hint).await?;
|
||||||
self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await
|
self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +417,16 @@ impl GreeClient {
|
|||||||
Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; }
|
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) {
|
if let Some(pack) = response.get("pack").and_then(Value::as_object) {
|
||||||
return Ok(Value::Object(pack.clone()));
|
let decoded = Value::Object(pack.clone());
|
||||||
|
if binding {
|
||||||
|
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
if !response_type.eq_ignore_ascii_case("bindok") {
|
||||||
|
tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
|
||||||
|
return Ok(decoded);
|
||||||
}
|
}
|
||||||
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; };
|
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; };
|
||||||
let clear = if version == 2 {
|
let clear = if version == 2 {
|
||||||
@@ -409,6 +464,58 @@ impl GreeClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct LocalIpv4Config {
|
||||||
|
interface: String,
|
||||||
|
ip: Ipv4Addr,
|
||||||
|
broadcast: Ipv4Addr,
|
||||||
|
prefix_len: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> {
|
||||||
|
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 best: Option<LocalIpv4Config> = None;
|
||||||
|
while !current.is_null() {
|
||||||
|
let ifa = &*current;
|
||||||
|
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() && !ifa.ifa_netmask.is_null()
|
||||||
|
&& (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET
|
||||||
|
{
|
||||||
|
let interface = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned();
|
||||||
|
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
|
||||||
|
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
|
||||||
|
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
|
||||||
|
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
|
||||||
|
let ip_u32 = u32::from(ip);
|
||||||
|
let mask_u32 = u32::from(mask);
|
||||||
|
let target_u32 = u32::from(target);
|
||||||
|
if !ip.is_loopback() && (ip_u32 & mask_u32) == (target_u32 & mask_u32) {
|
||||||
|
let prefix_len = mask_u32.count_ones();
|
||||||
|
let candidate = LocalIpv4Config {
|
||||||
|
interface,
|
||||||
|
ip,
|
||||||
|
broadcast: Ipv4Addr::from(ip_u32 | !mask_u32),
|
||||||
|
prefix_len,
|
||||||
|
};
|
||||||
|
if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) {
|
||||||
|
best = Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = ifa.ifa_next;
|
||||||
|
}
|
||||||
|
libc::freeifaddrs(addrs);
|
||||||
|
Ok(best)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> { Ok(None) }
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
|
||||||
use std::{ffi::CStr, ptr};
|
use std::{ffi::CStr, ptr};
|
||||||
|
|||||||
Reference in New Issue
Block a user