v0.7.2
This commit is contained in:
+23
-1
@@ -1,4 +1,4 @@
|
||||
# GREE Controller v0.7.2 - build and validation report
|
||||
# GREE Controller v0.7.3 - build and validation report
|
||||
|
||||
## Scope
|
||||
|
||||
@@ -260,3 +260,25 @@ Validation for the 0.7.1 package:
|
||||
- Restored `tokio-util = 0.7.19`, which satisfies `tower-http 0.6.11` (`^0.7`) and is present in the crates.io index used by the target LXC.
|
||||
- No application source, database schema, scheduler, automation, device-control or Web UI logic changed in this hotfix.
|
||||
|
||||
|
||||
|
||||
## 0.7.3 Home Assistant climate-group support
|
||||
|
||||
- Added restricted HA group discovery and group-control endpoints.
|
||||
- Added one Home Assistant device per configured climate group.
|
||||
- Added group Power switch, Thermostat mode select, Work profile select and Control plan sensor.
|
||||
- Group reporting derives common mode/profile from member-zone configuration and reports mixed states without coercing them.
|
||||
- Group plan attributes include members, online unit count, average current temperature, demand count and upcoming zone schedule events.
|
||||
- Existing group-control engine remains the single command path, including global-master-power and overlapping-disabled-group protections.
|
||||
|
||||
### 0.7.3 validation
|
||||
|
||||
- `python3 -m py_compile home-assistant/custom_components/gree_controller/*.py` passed.
|
||||
- Home Assistant manifest and PL/EN translation JSON parsed successfully.
|
||||
- `Cargo.toml` parses successfully and both Cargo package metadata and HA manifest report `0.7.3`.
|
||||
- `Cargo.lock` keeps the packaging hotfix at `tokio-util = 0.7.19`.
|
||||
- Static assertions confirm the restricted HA router exposes group discovery/control, the API client polls groups, and select/switch/sensor group entities are registered.
|
||||
- `node --check web/app.js` and `node --check web/sw.js` passed.
|
||||
- `bash -n scripts/*.sh` passed.
|
||||
- HTML parsing and CSS brace-balance checks passed.
|
||||
- The validation environment does not provide `cargo`/`rustc`, so the Rust test/build step must run on the target host/LXC (the normal `scripts/update.sh` performs `cargo test --all-targets` before installation).
|
||||
|
||||
Generated
+1
-1
@@ -633,7 +633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gree-controller"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gree-controller"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
edition = "2021"
|
||||
authors = ["GREE Controller contributors"]
|
||||
description = "Standalone local GREE HVAC controller with Web UI, SQLite and Home Assistant sensor support"
|
||||
|
||||
+14
-14
@@ -1,33 +1,33 @@
|
||||
d67af429e4da9ce08e9d2f2a8472849ffbd70d135b1c5da535a076026794d04c ./.env.example
|
||||
a4ec3874a2e3ab1bad28fb40bb620f7b01f64d01ad9b699306bf70ada31227db ./.gitignore
|
||||
0bd13fab94fb9c1aeabc4e1f1c0f08a583b562ac1424daf381d325ac423a4f1a ./BUILD_REPORT.md
|
||||
c52a8cbea1144ea62698b2e2c21ff2ba4dc7a3201a65d17d4fb20ebe68cfe4ab ./Cargo.lock
|
||||
fac8e8ca6bad6375be66e3da712deb943fa3bb44c78e84cfc99aef2ee3ea21c9 ./Cargo.toml
|
||||
f6c797abd321abcf1ad9ab454e2a2949d588ed1465b12fd3383530c71bda9070 ./BUILD_REPORT.md
|
||||
cff434b43ec947285275a72653bde756b57987d7191ec4093f9218bd16653936 ./Cargo.lock
|
||||
f3e0db0deb28cda7669b9344436d883529143f64e8dc89624986fe14b9fa502d ./Cargo.toml
|
||||
19b2943504acb8f8de280f873a8dbec4bb6ebbe3870b158f5655d4fb8c298f5f ./LICENSE
|
||||
40257d2ace2752e53c6119ef5d09e4b98433cb1b86b5a3d0a69e62cc888a8a48 ./README.md
|
||||
b526a6b28e1b8cb68be8bd92627a8588c0964de109f99b83c173ecec151ae543 ./README.md
|
||||
f2c8eb28fedb26bb8f5636e1805e7a782cc472b87d882f6752d520f16216a248 ./build.rs
|
||||
cd45c288c3e002925656f7e06656034fd07013774f4ba0a00ba2bfb1fe60195a ./docs/API.md
|
||||
b396c7016569a5817ed740abadd0e5685a8e7d50300bfce607cc3c15009ba78d ./docs/API.md
|
||||
234dd200e380a13ecd3e61b4ea455f6f08d64ce89382077dee80684acadb9703 ./docs/HOME_ASSISTANT_MIGRATION.md
|
||||
7a88d6e76fda21e5d34ab351e26bc10dc1f8f7b3055505aefad1df7c56d65ae4 ./docs/LOCALIZATION.md
|
||||
10a0722e1100fb4a05e3067daeb67dc47b0c0a096b43b1cbf2bf002967ce7d98 ./docs/LXC.md
|
||||
2ef5cb04cc74be3aab77a9d117b9040f186c73a7dcf24375f1cca2d73e428449 ./docs/PROJECT_SPEC.md
|
||||
7748b1230029439a405139750b2e0607b8e463bfd56ad86982394105069697c5 ./docs/REVERSE_PROXY.md
|
||||
28f1403d84b01b37bebb72548aa39a75bb14bdbb611a43bfa761319957cd6f39 ./docs/SECURITY.md
|
||||
1192c5442cb0439723294bbed8b4610728c51bca23e1d8c0ef030270d1c697ba ./home-assistant/README.md
|
||||
a7889fa5880da4c6d6d1b1f63a139051185cfb04769a735ee72c9fd536f1cb62 ./home-assistant/README.md
|
||||
f8e8559fe10fe523ac5bc9aac25c6e26e862f679d502e8f3c39f38a0a8e40911 ./home-assistant/custom_components/gree_controller/__init__.py
|
||||
001215412d77abd393f34c414ff6d0ebbaf419f140d76037b7f114a7955df6c3 ./home-assistant/custom_components/gree_controller/api.py
|
||||
6910589f27960a28d4de9735884a7e5376e455cd885f19ce2b55947fcd135114 ./home-assistant/custom_components/gree_controller/api.py
|
||||
62547d17114dd2ae47881b0789279f2d595285b630af33830244c31074801961 ./home-assistant/custom_components/gree_controller/climate.py
|
||||
5e4aef2143e81bedb5a15dd4be5c71b64a3ab448ec6d33a20851edd098e5f529 ./home-assistant/custom_components/gree_controller/config_flow.py
|
||||
b7f0873109c52be9d7f09bea3dffc416103c50085e1f0680d11661a969479898 ./home-assistant/custom_components/gree_controller/const.py
|
||||
8dd51322798cad28d4d19938fe459aa2a4d3b64b075532149f3c8b62049a6c8b ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||
2eb70cafaeccaf2e62a8de650fed90bac5c8b12be70cf33f80a2f7d8a274a606 ./home-assistant/custom_components/gree_controller/coordinator.py
|
||||
5a96fe8f5c035c34f1339370270cd078056202d09e236dec75735be11de92a7d ./home-assistant/custom_components/gree_controller/entity_map.py
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/icon.png
|
||||
c4fb75c246db651087900ebfc2291ff41ac87652cd6194fc0b776b0005c1cbcf ./home-assistant/custom_components/gree_controller/logo.png
|
||||
a8cd36e29f59a4df7bc9c37098539a143db18f383c1ae5e657129e14452cd6b0 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
61cb322659bf9cdc2490b20ad3c5aab92def81660d6a01dbcd978ddebc162813 ./home-assistant/custom_components/gree_controller/manifest.json
|
||||
d0206766d76f70ea1c75a618e41bf1e8e8c0429f42ee97f40daac1ea12f16658 ./home-assistant/custom_components/gree_controller/number.py
|
||||
459233ccefa7827fc1c6887146d8a69e01149a497635fd5e1802c220f418eb28 ./home-assistant/custom_components/gree_controller/select.py
|
||||
ec35191e27a018a72cb341914e2abd7fecd25f6b641ea25bebb9e809dd8ecaff ./home-assistant/custom_components/gree_controller/sensor.py
|
||||
915119bcf400e0da6df777b0a2c13fe33b96daca91f5d8c145acf4831b62098d ./home-assistant/custom_components/gree_controller/switch.py
|
||||
a697d18173087621a666781137f07617d23c1fb2720d8b246637a804634d5299 ./home-assistant/custom_components/gree_controller/select.py
|
||||
047f3db03f0db54a40d63092370e5861510d611edfb0a0659e4c25b949d47854 ./home-assistant/custom_components/gree_controller/sensor.py
|
||||
31b8462e00644ce12aa37b849341a95c7498909961d6c35be3d43867904c6c81 ./home-assistant/custom_components/gree_controller/switch.py
|
||||
6bddb7b4620021ecd2099a86a77ef5c7f2c2dcd3d07d5db4e7b4c4ce6d3e8c03 ./home-assistant/custom_components/gree_controller/translations/en.json
|
||||
13f30e2dcdcedbd1b6c3f99c2335e0487108fd72c8e86922368b84f2fa2038ae ./home-assistant/custom_components/gree_controller/translations/pl.json
|
||||
4513070521d3dda0efb0d974a86ba674494cfb2b66fe9e5cac5b1b0430dede97 ./home-assistant/generated/gree_controller_entities.example.json
|
||||
@@ -46,7 +46,7 @@ e00d211e3885e30d7fed1e43b44e6fdad40a67019060156c0641816a93e3365f ./scripts/netw
|
||||
81345b6a0b51736bdbc98fd23199b62e4c721b4e7437e02dab7ea79b97dff29a ./scripts/service.sh
|
||||
b48fc84d79aab381226363ac8473f981bcba5e4911c4cc0011261182debf4250 ./scripts/smoke.sh
|
||||
b50782b3742dfbf8a319c60571c968e93fdf8547db747c759edcffae68cb98bf ./scripts/update.sh
|
||||
13ba625f9b0db3caed3a8c09f2a18d7485f2574e23daa5375a407ab63ece169e ./src/api.rs
|
||||
7b4309a30eed56af51c175db00e12649921edc6f515df477c68bc09c750929c9 ./src/api.rs
|
||||
32b1d0a7ac2060fd18fe7f1ca1d110ee45beaf67e81514d24d6bde3d36301387 ./src/config.rs
|
||||
5dfda2f4dc540c502885b0cd7017dc77768684588acf528f01d1fd88f1af4aec ./src/db.rs
|
||||
0f818e27983476ad41bf8f647749bc5a7dc7924731bb436ec996b0c8e595f93d ./src/engine.rs
|
||||
@@ -67,5 +67,5 @@ e98bdd7204349cce1ec6f57283509697af0bbc72280622a6c3efa6fed242db4f ./web/favicon.
|
||||
57949bed2229ceb180ea681b0126b56f6e75dc325d7a89d384f823282736bdc7 ./web/index.html
|
||||
fd26156e9f1d6713d3def564ad000553d9a16a24376059701db2ee762c99ee6c ./web/manifest.webmanifest
|
||||
e4ed0381250352f752e3b36133a89947db1adb08dec9cae7d2467684dbd1a807 ./web/styles.css
|
||||
0e7a083b418c651fb37d121b978fd974206dfc804aaab868b1f2e4294924be3f ./web/sw.js
|
||||
4a60c17e07f1c17d79d5316cb4a34418087f7428cc0326cf536c043261e926a4 ./web/sw.js
|
||||
d505d793ce7cc9485b45b78bba1c0d51887adc7451ab59a42702946e5b991382 ./web/theme-init.js
|
||||
|
||||
@@ -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.
|
||||
|
||||
Current version: **0.7.2**.
|
||||
Current version: **0.7.3**.
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -37,7 +37,7 @@ Current version: **0.7.2**.
|
||||
- optional Bearer-token authentication,
|
||||
- simulator mode for development without physical hardware,
|
||||
- Debian/Ubuntu LXC systemd installer,
|
||||
- Home Assistant custom integration that proxies device `climate` commands, exposes full zone `climate` entities with selectable HVAC mode, zone plan sensors, writable target-temperature numbers and enable switches,
|
||||
- Home Assistant custom integration that proxies device `climate` commands, exposes full zone thermostat entities and publishes every climate group as a separate HA device with power, mode, profile and control-plan entities,
|
||||
- migration mapping generator for retaining existing HA entity IDs such as `climate.klima_salon`.
|
||||
|
||||
See [`BUILD_REPORT.md`](BUILD_REPORT.md) for package validation details and [`docs/LXC.md`](docs/LXC.md) for the LXC deployment/update workflow.
|
||||
@@ -47,7 +47,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.7.2.zip
|
||||
unzip gree-controller-v0.7.3.zip
|
||||
cd gree-controller
|
||||
chmod +x scripts/*.sh
|
||||
./scripts/dev.sh
|
||||
@@ -324,6 +324,8 @@ POST /api/integrations/home-assistant/test
|
||||
GET /api/integrations/home-assistant/devices
|
||||
POST /api/integrations/home-assistant/devices/{id}/command
|
||||
GET /api/integrations/home-assistant/control-plan
|
||||
GET /api/integrations/home-assistant/groups
|
||||
POST /api/integrations/home-assistant/groups/{id}/control
|
||||
POST /api/integrations/home-assistant/house/control
|
||||
POST /api/integrations/home-assistant/house/preset
|
||||
POST /api/integrations/home-assistant/house/power
|
||||
@@ -433,3 +435,14 @@ Zones now use setpoint modulation, global seasonal house mode, cooling/heating C
|
||||
- The **Control** button opens the Dashboard, expands **Quick thermostats** and scrolls to the matching zone.
|
||||
- The zone form no longer exposes the legacy/base `setpoint` as a live-looking control; profile temperatures remain configurable.
|
||||
- Live zone temperature, preset, mode and enable/disable controls remain available in **Dashboard -> Quick thermostats**.
|
||||
|
||||
## 0.7.3 Home Assistant climate groups
|
||||
|
||||
- Added climate groups to the restricted Home Assistant API.
|
||||
- Each configured group is exposed as its own Home Assistant device with **Power**, **Thermostat mode**, **Work profile** and **Control plan** entities.
|
||||
- Group mode options are **Global**, **Cooling** and **Heating**; Global means the member zones inherit the controller-wide Heating/Cooling selection.
|
||||
- Group profile options are **Auto schedule**, **Comfort**, **Sleep** and **Away**.
|
||||
- The group control-plan sensor reports member zones, online unit count, current average temperature, demand count and upcoming member schedule events.
|
||||
- Mixed member modes/profiles are reported without inventing a false common value; choosing a group option normalizes all member zones through the existing group-control engine.
|
||||
- Group commands use the restricted Home Assistant access token and the dedicated `/api/integrations/home-assistant/groups/*` endpoints.
|
||||
- After adding/removing/renaming groups, reload the GREE Controller integration in Home Assistant so its entity/device registry is rebuilt.
|
||||
|
||||
+13
-1
@@ -26,6 +26,8 @@ The Home Assistant custom integration uses a restricted API surface:
|
||||
GET /api/integrations/home-assistant/devices
|
||||
POST /api/integrations/home-assistant/devices/{id}/command
|
||||
GET /api/integrations/home-assistant/control-plan
|
||||
GET /api/integrations/home-assistant/groups
|
||||
POST /api/integrations/home-assistant/groups/{id}/control
|
||||
POST /api/integrations/home-assistant/house/control
|
||||
POST /api/integrations/home-assistant/house/preset
|
||||
POST /api/integrations/home-assistant/house/power
|
||||
@@ -193,7 +195,17 @@ This setting is opt-in and applies only to the controller's outbound Home Assist
|
||||
curl "$BASE/api/control-plan" -H "$AUTH"
|
||||
```
|
||||
|
||||
The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may also change house mode, house preset and whole-house master power through the dedicated `/api/integrations/home-assistant/house/*` endpoints. HA may change a zone target/preset/mode/enabled state through:
|
||||
The restricted Home Assistant equivalent is `GET /api/integrations/home-assistant/control-plan`. HA may also read derived group state from `GET /api/integrations/home-assistant/groups`, control a group through `POST /api/integrations/home-assistant/groups/{id}/control`, change house mode/preset/master power through `/api/integrations/home-assistant/house/*`, and change a zone target/preset/mode/enabled state through:
|
||||
|
||||
The group list reports the configured group power state, common mode/profile (or `mixed`), member zones, online device count, average room temperature, demand count and upcoming member schedule events. Group control accepts the same semantics as the Web UI:
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE/api/integrations/home-assistant/groups/GROUP_ID/control" \
|
||||
-H 'Authorization: Bearer HA_TOKEN' -H 'Content-Type: application/json' \
|
||||
-d '{"mode":"house","preset":"comfort"}'
|
||||
```
|
||||
|
||||
Valid group modes are `house` (inherit global), `cool` and `heat`; valid profiles are `auto`, `comfort`, `sleep` and `away`. `power` can be sent independently as a boolean.
|
||||
|
||||
```bash
|
||||
curl -X POST "$BASE/api/integrations/home-assistant/zones/ZONE_ID/control" \
|
||||
|
||||
@@ -6,7 +6,7 @@ The project includes a custom Home Assistant integration under:
|
||||
home-assistant/custom_components/gree_controller/
|
||||
```
|
||||
|
||||
It creates HA `climate` entities but sends every command to the standalone Rust controller. Home Assistant therefore becomes a client, while UDP/AES GREE communication remains outside HA.
|
||||
It creates HA entities for physical units, thermostat zones, whole-house controls and climate groups, while sending every command to the standalone Rust controller. Home Assistant therefore remains a client and UDP/AES GREE communication stays outside HA.
|
||||
|
||||
The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing.
|
||||
|
||||
@@ -34,7 +34,7 @@ Restart Home Assistant, then open **Settings -> Devices & services -> Add integr
|
||||
- controller URL, for example `http://192.168.1.20:8787`,
|
||||
- the generated controller access token.
|
||||
|
||||
The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices/control plans and sending climate, zone target or zone enable/disable commands.
|
||||
The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices/control plans/groups and sending device, zone, whole-house or climate-group control commands.
|
||||
|
||||
## Preserve an existing entity ID
|
||||
|
||||
@@ -136,3 +136,17 @@ The integration also exposes three controller-level entities on the **GREE Contr
|
||||
- **All air conditioners** (`switch`) — separate master power. Turning it off powers every enabled unit down and prevents zones/controller automations from restarting them. Turning it on powers all enabled units on again without changing the selected thermostat mode or profile.
|
||||
|
||||
These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints.
|
||||
|
||||
|
||||
## Climate groups (0.7.3)
|
||||
|
||||
Every group configured in **GREE Controller -> Groups** is published as a separate Home Assistant device. A group does not receive a fake common target temperature because its member zones can legitimately use different profile temperatures. Instead, the group device exposes the controls that exactly match the controller model:
|
||||
|
||||
- **Power** (`switch`) — enables/disables only this group.
|
||||
- **Thermostat mode** (`select`) — **Global**, Cooling or Heating. **Global** means all member zones inherit the controller-wide Heating/Cooling selection.
|
||||
- **Work profile** (`select`) — Auto schedule, Comfort, Sleep or Away.
|
||||
- **Control plan** (`sensor`) — group status plus member zone IDs/names, average current temperature, enabled/demanding zone counts, online unit count and upcoming member schedule events.
|
||||
|
||||
If member zones have been changed individually and no longer share one mode or profile, the matching select has no current common value instead of reporting a misleading state. Selecting a group mode/profile applies it to every member through the existing controller group logic.
|
||||
|
||||
After upgrading the custom integration, restart Home Assistant or reload **Settings -> Devices & services -> GREE Controller**. Also reload the integration after adding/removing/renaming groups so new group devices/entities are created.
|
||||
|
||||
@@ -82,6 +82,24 @@ class GreeControllerClient:
|
||||
raise GreeControllerApiError("Controller returned an invalid control plan payload")
|
||||
return data
|
||||
|
||||
async def groups(self) -> list[dict[str, Any]]:
|
||||
"""Return climate groups exposed to Home Assistant."""
|
||||
data = await self._request("GET", "/api/integrations/home-assistant/groups")
|
||||
if not isinstance(data, list):
|
||||
raise GreeControllerApiError("Controller returned an invalid groups payload")
|
||||
return data
|
||||
|
||||
async def group_control(self, group_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Change one controller climate group."""
|
||||
data = await self._request(
|
||||
"POST",
|
||||
f"/api/integrations/home-assistant/groups/{group_id}/control",
|
||||
json=payload,
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid group control payload")
|
||||
return data
|
||||
|
||||
async def house_control(self, mode: str) -> dict[str, Any]:
|
||||
"""Change the whole-house thermostat mode."""
|
||||
data = await self._request(
|
||||
|
||||
@@ -30,14 +30,17 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
|
||||
)
|
||||
self.client = client
|
||||
self.plan: dict[str, Any] = {}
|
||||
self.groups: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict]:
|
||||
try:
|
||||
devices, plan = await asyncio.gather(
|
||||
devices, plan, groups = await asyncio.gather(
|
||||
self.client.devices(),
|
||||
self.client.control_plan(),
|
||||
self.client.groups(),
|
||||
)
|
||||
except GreeControllerApiError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
self.plan = plan
|
||||
self.groups = {str(group["id"]): group for group in groups if group.get("id")}
|
||||
return {str(device["id"]): device for device in devices if device.get("id")}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.7.2",
|
||||
"version": "0.7.3",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Whole-house thermostat controls for GREE Controller."""
|
||||
"""Whole-house and climate-group thermostat selectors for GREE Controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -30,6 +30,15 @@ HOUSE_PRESET_LABELS = {
|
||||
}
|
||||
HOUSE_PRESET_VALUES = {label: value for value, label in HOUSE_PRESET_LABELS.items()}
|
||||
|
||||
GROUP_MODE_LABELS = {
|
||||
"house": "Global",
|
||||
"cool": "Cooling",
|
||||
"heat": "Heating",
|
||||
}
|
||||
GROUP_MODE_VALUES = {label: value for value, label in GROUP_MODE_LABELS.items()}
|
||||
GROUP_PRESET_LABELS = HOUSE_PRESET_LABELS
|
||||
GROUP_PRESET_VALUES = HOUSE_PRESET_VALUES
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -38,12 +47,14 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Create whole-house thermostat mode and work-profile selectors."""
|
||||
runtime: GreeControllerRuntimeData = entry.runtime_data
|
||||
async_add_entities(
|
||||
[
|
||||
GreeControllerHouseModeSelect(runtime.coordinator),
|
||||
GreeControllerHousePresetSelect(runtime.coordinator),
|
||||
]
|
||||
)
|
||||
entities: list[SelectEntity] = [
|
||||
GreeControllerHouseModeSelect(runtime.coordinator),
|
||||
GreeControllerHousePresetSelect(runtime.coordinator),
|
||||
]
|
||||
for group_id in runtime.coordinator.groups:
|
||||
entities.append(GreeControllerGroupModeSelect(runtime.coordinator, group_id))
|
||||
entities.append(GreeControllerGroupPresetSelect(runtime.coordinator, group_id))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class _GreeControllerHouseSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
|
||||
@@ -103,3 +114,87 @@ class GreeControllerHousePresetSelect(_GreeControllerHouseSelect):
|
||||
return
|
||||
await self.coordinator.client.house_preset(value)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class _GreeControllerGroupSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
|
||||
"""Base class for climate-group selectors."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._group_id = group_id
|
||||
|
||||
@property
|
||||
def _group(self) -> dict[str, Any]:
|
||||
return self.coordinator.groups.get(self._group_id, {})
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and bool(self._group)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
group = self._group
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"group:{self._group_id}")},
|
||||
name=str(group.get("name") or self._group_id),
|
||||
manufacturer="GREE Controller",
|
||||
model="Climate group",
|
||||
via_device=(DOMAIN, "controller"),
|
||||
)
|
||||
|
||||
|
||||
class GreeControllerGroupModeSelect(_GreeControllerGroupSelect):
|
||||
"""Select the thermostat mode policy for one climate group."""
|
||||
|
||||
_attr_name = "Thermostat mode"
|
||||
_attr_options = list(GROUP_MODE_VALUES)
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator, group_id)
|
||||
self._attr_unique_id = f"{group_id}-group-thermostat-mode"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
return GROUP_MODE_LABELS.get(str(self._group.get("mode") or ""))
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
return {
|
||||
"mixed_group_mode": self._group.get("mode") == "mixed",
|
||||
"global_house_mode": self._group.get("house_mode"),
|
||||
}
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
value = GROUP_MODE_VALUES.get(option)
|
||||
if value is None:
|
||||
return
|
||||
await self.coordinator.client.group_control(self._group_id, {"mode": value})
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class GreeControllerGroupPresetSelect(_GreeControllerGroupSelect):
|
||||
"""Select the work profile for one climate group."""
|
||||
|
||||
_attr_name = "Work profile"
|
||||
_attr_options = list(GROUP_PRESET_VALUES)
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator, group_id)
|
||||
self._attr_unique_id = f"{group_id}-group-work-profile"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
return GROUP_PRESET_LABELS.get(str(self._group.get("preset") or ""))
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
return {"mixed_group_profiles": self._group.get("preset") == "mixed"}
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
value = GROUP_PRESET_VALUES.get(option)
|
||||
if value is None:
|
||||
return
|
||||
await self.coordinator.client.group_control(self._group_id, {"preset": value})
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Automation-plan sensors exposed by GREE Controller."""
|
||||
"""House, zone and climate-group plan sensors exposed by GREE Controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -27,6 +27,8 @@ async def async_setup_entry(
|
||||
for zone in runtime.coordinator.plan.get("zones", []):
|
||||
if zone.get("zone_id"):
|
||||
entities.append(GreeControllerZonePlanSensor(runtime.coordinator, str(zone["zone_id"])))
|
||||
for group_id in runtime.coordinator.groups:
|
||||
entities.append(GreeControllerGroupPlanSensor(runtime.coordinator, group_id))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
@@ -55,6 +57,7 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator]
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
plan = self.coordinator.plan
|
||||
zones = plan.get("zones", [])
|
||||
groups = list(self.coordinator.groups.values())
|
||||
return {
|
||||
"generated_at": plan.get("generated_at"),
|
||||
"outdoor_temperature": plan.get("outdoor_temperature"),
|
||||
@@ -63,6 +66,9 @@ class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator]
|
||||
"master_power": bool(plan.get("house_power", False)),
|
||||
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
|
||||
"demanding_zones": sum(bool(zone.get("enabled")) and bool(zone.get("demand")) for zone in zones),
|
||||
"groups": len(groups),
|
||||
"enabled_groups": sum(bool(group.get("power_enabled")) for group in groups),
|
||||
"demanding_groups": sum(int(group.get("demanding_zones") or 0) > 0 for group in groups),
|
||||
"next_events": plan.get("next_events", []),
|
||||
"rules": plan.get("rules", []),
|
||||
}
|
||||
@@ -126,3 +132,69 @@ class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator],
|
||||
"current_schedule": zone.get("current_schedule_name"),
|
||||
"next_events": zone.get("next_events", []),
|
||||
}
|
||||
|
||||
|
||||
class GreeControllerGroupPlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity):
|
||||
"""Readable status and automation plan for one climate group."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Control plan"
|
||||
_unrecorded_attributes = frozenset({"next_events", "zone_ids", "zone_names", "members"})
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._group_id = group_id
|
||||
self._attr_unique_id = f"{group_id}-group-control-plan"
|
||||
|
||||
@property
|
||||
def _group(self) -> dict[str, Any]:
|
||||
return self.coordinator.groups.get(self._group_id, {})
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and bool(self._group)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
group = self._group
|
||||
if not group.get("power_enabled", False):
|
||||
return "off"
|
||||
if not group.get("effective_power", False):
|
||||
return "master_off"
|
||||
if group.get("mode") == "house" and group.get("house_mode") == "off":
|
||||
return "paused"
|
||||
return "requesting" if int(group.get("demanding_zones") or 0) > 0 else "satisfied"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
group = self._group
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"group:{self._group_id}")},
|
||||
name=str(group.get("name") or self._group_id),
|
||||
manufacturer="GREE Controller",
|
||||
model="Climate group",
|
||||
via_device=(DOMAIN, "controller"),
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
group = self._group
|
||||
return {
|
||||
"group_id": self._group_id,
|
||||
"power_enabled": bool(group.get("power_enabled", False)),
|
||||
"effective_power": bool(group.get("effective_power", False)),
|
||||
"mode": group.get("mode"),
|
||||
"global_house_mode": group.get("house_mode"),
|
||||
"work_profile": group.get("preset"),
|
||||
"zone_count": group.get("zone_count"),
|
||||
"enabled_zones": group.get("enabled_zones"),
|
||||
"active_zones": group.get("active_zones"),
|
||||
"demanding_zones": group.get("demanding_zones"),
|
||||
"device_count": group.get("device_count"),
|
||||
"online_devices": group.get("online_devices"),
|
||||
"current_temperature": group.get("current_temperature"),
|
||||
"zone_ids": group.get("zone_ids", []),
|
||||
"zone_names": group.get("zone_names", []),
|
||||
"members": group.get("members", []),
|
||||
"next_events": group.get("next_events", []),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Writable zone enabled switches for GREE Controller."""
|
||||
"""Writable house, group, zone and device switches for GREE Controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -37,9 +37,13 @@ async def async_setup_entry(
|
||||
entities: list[SwitchEntity] = [
|
||||
GreeControllerHousePowerSwitch(runtime.coordinator),
|
||||
*[
|
||||
GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"]))
|
||||
for zone in runtime.coordinator.plan.get("zones", [])
|
||||
if zone.get("zone_id")
|
||||
GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"]))
|
||||
for zone in runtime.coordinator.plan.get("zones", [])
|
||||
if zone.get("zone_id")
|
||||
],
|
||||
*[
|
||||
GreeControllerGroupPowerSwitch(runtime.coordinator, group_id)
|
||||
for group_id in runtime.coordinator.groups
|
||||
],
|
||||
]
|
||||
for device_id, device in runtime.coordinator.data.items():
|
||||
@@ -78,6 +82,56 @@ class GreeControllerHousePowerSwitch(CoordinatorEntity[GreeControllerCoordinator
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class GreeControllerGroupPowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
|
||||
"""Enable or disable one controller climate group."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Power"
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._group_id = group_id
|
||||
self._attr_unique_id = f"{group_id}-group-power"
|
||||
|
||||
@property
|
||||
def _group(self) -> dict[str, Any]:
|
||||
return self.coordinator.groups.get(self._group_id, {})
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and bool(self._group)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return bool(self._group.get("power_enabled", False))
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
group = self._group
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"group:{self._group_id}")},
|
||||
name=str(group.get("name") or self._group_id),
|
||||
manufacturer="GREE Controller",
|
||||
model="Climate group",
|
||||
via_device=(DOMAIN, "controller"),
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
return {
|
||||
"effective_power": bool(self._group.get("effective_power", False)),
|
||||
"zone_count": self._group.get("zone_count"),
|
||||
}
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.client.group_control(self._group_id, {"power": True})
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.client.group_control(self._group_id, {"power": False})
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class GreeControllerZoneEnabledSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
|
||||
"""Enable or disable a controller thermostat zone."""
|
||||
|
||||
|
||||
+123
@@ -80,6 +80,8 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/integrations/home-assistant/devices", get(list_devices))
|
||||
.route("/api/integrations/home-assistant/devices/:id/command", post(command_device))
|
||||
.route("/api/integrations/home-assistant/control-plan", get(control_plan))
|
||||
.route("/api/integrations/home-assistant/groups", get(list_home_assistant_groups))
|
||||
.route("/api/integrations/home-assistant/groups/:id/control", post(update_home_assistant_group_control))
|
||||
.route("/api/integrations/home-assistant/house/control", post(update_house_control))
|
||||
.route("/api/integrations/home-assistant/house/preset", post(update_house_preset))
|
||||
.route("/api/integrations/home-assistant/house/power", post(update_house_power))
|
||||
@@ -737,6 +739,127 @@ async fn update_group_control(State(state): State<AppState>, Path(id): Path<Stri
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "group.quick_control").await?))
|
||||
}
|
||||
|
||||
fn home_assistant_group_mode(zones: &[&Zone]) -> String {
|
||||
let mut value: Option<&str> = None;
|
||||
for zone in zones {
|
||||
let current = if zone.inherit_house_mode { "house" } else { zone.mode.as_str() };
|
||||
if !matches!(current, "house" | "cool" | "heat") {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
}
|
||||
value.unwrap_or("mixed").to_string()
|
||||
}
|
||||
|
||||
fn home_assistant_group_preset(zones: &[&Zone]) -> String {
|
||||
let mut value: Option<&str> = None;
|
||||
for zone in zones {
|
||||
let current = zone.manual_preset.as_deref().unwrap_or("auto");
|
||||
if !matches!(current, "auto" | "comfort" | "sleep" | "away") {
|
||||
return "mixed".into();
|
||||
}
|
||||
if let Some(previous) = value {
|
||||
if previous != current { return "mixed".into(); }
|
||||
} else {
|
||||
value = Some(current);
|
||||
}
|
||||
}
|
||||
value.unwrap_or("mixed").to_string()
|
||||
}
|
||||
|
||||
async fn list_home_assistant_groups(State(state): State<AppState>) -> Result<Json<Vec<Value>>, AppError> {
|
||||
let groups = state.db.list_groups()?;
|
||||
let zones = state.db.list_zones()?;
|
||||
let devices = state.db.list_devices()?;
|
||||
let plan = engine::build_control_plan(&state).await?;
|
||||
let settings = state.settings.read().await.clone();
|
||||
let mut output = Vec::with_capacity(groups.len());
|
||||
|
||||
for group in groups {
|
||||
let members = zones.iter()
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.id))
|
||||
.collect::<Vec<_>>();
|
||||
let planned_members = plan.zones.iter()
|
||||
.filter(|zone| group.zone_ids.iter().any(|zone_id| zone_id == &zone.zone_id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let zone_names = members.iter().map(|zone| zone.name.clone()).collect::<Vec<_>>();
|
||||
let member_device_ids = members.iter().map(|zone| zone.device_id.as_str()).collect::<std::collections::HashSet<_>>();
|
||||
let online_devices = devices.iter()
|
||||
.filter(|device| member_device_ids.contains(device.id.as_str()) && device.online)
|
||||
.count();
|
||||
let current_temperatures = planned_members.iter()
|
||||
.filter_map(|zone| zone.current_temperature)
|
||||
.collect::<Vec<_>>();
|
||||
let current_temperature = if current_temperatures.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(current_temperatures.iter().sum::<f64>() / current_temperatures.len() as f64)
|
||||
};
|
||||
let mut next_events = Vec::new();
|
||||
for zone in &planned_members {
|
||||
for event in &zone.next_events {
|
||||
let mut event = event.clone();
|
||||
event.label = format!("{}: {}", zone.zone_name, event.label);
|
||||
next_events.push(event);
|
||||
}
|
||||
}
|
||||
next_events.sort_by_key(|event| event.at);
|
||||
next_events.truncate(8);
|
||||
let member_states = planned_members.iter().map(|zone| json!({
|
||||
"zone_id": zone.zone_id,
|
||||
"zone_name": zone.zone_name,
|
||||
"device_id": zone.device_id,
|
||||
"device_name": zone.device_name,
|
||||
"enabled": zone.enabled,
|
||||
"mode": zone.mode,
|
||||
"configured_mode": zone.configured_mode,
|
||||
"inherit_house_mode": zone.inherit_house_mode,
|
||||
"preset": zone.preset,
|
||||
"current_temperature": zone.current_temperature,
|
||||
"target_temperature": zone.target_temperature,
|
||||
"demand": zone.demand,
|
||||
"control_source": zone.control_source,
|
||||
"current_schedule": zone.current_schedule_name,
|
||||
})).collect::<Vec<_>>();
|
||||
|
||||
output.push(json!({
|
||||
"id": group.id,
|
||||
"name": group.name,
|
||||
"zone_ids": group.zone_ids,
|
||||
"zone_names": zone_names,
|
||||
"power_enabled": group.power_enabled,
|
||||
"effective_power": settings.house_power_enabled && group.power_enabled,
|
||||
"mode": home_assistant_group_mode(&members),
|
||||
"preset": home_assistant_group_preset(&members),
|
||||
"house_mode": settings.house_mode,
|
||||
"zone_count": members.len(),
|
||||
"enabled_zones": members.iter().filter(|zone| zone.enabled).count(),
|
||||
"active_zones": planned_members.iter().filter(|zone| zone.enabled).count(),
|
||||
"demanding_zones": planned_members.iter().filter(|zone| zone.demand).count(),
|
||||
"device_count": member_device_ids.len(),
|
||||
"online_devices": online_devices,
|
||||
"current_temperature": current_temperature,
|
||||
"members": member_states,
|
||||
"next_events": next_events,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Json(output))
|
||||
}
|
||||
|
||||
async fn update_home_assistant_group_control(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(patch): Json<GroupControlPatch>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
Ok(Json(engine::control_group(&state, &id, patch, "home_assistant.group_control").await?))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HouseControlPatch { mode: String }
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'gree-controller-v072-zone-config';
|
||||
const CACHE = 'gree-controller-v073-ha-groups';
|
||||
const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, '');
|
||||
const path = value => `${SCOPE}${value.startsWith('/') ? value : `/${value}`}` || '/';
|
||||
const ASSETS = [path('/'), path('/styles.css'), path('/app.js'), path('/theme-init.js'), path('/favicon.svg'), path('/manifest.webmanifest'), path('/lang/index.json'), path('/lang/en.json')];
|
||||
|
||||
Reference in New Issue
Block a user