v0.13.9-rework_ha_addon

This commit is contained in:
Mateusz Gruszczyński
2026-09-10 08:24:53 +02:00
parent b905a6fcc4
commit 34e62da372
45 changed files with 405 additions and 1597 deletions
+14 -10
View File
@@ -1,13 +1,17 @@
# OCI registry. Works with standard OCI registries, including Zot.
REGISTRY=oci.linuxiarz.pl
# OCI image
REGISTRY=repo.example.com
IMAGE_NAME=gree-controller
# Build/runtime base: trixie or alpine.
DISTRO=trixie
# Buildx builder name.
DISTRO=alpine
RUST_VERSION=1.89
BUILD_PLATFORMS=linux/amd64,linux/arm64
BUILDER=gree-controller-multiarch
# Optional BuildKit config for a private registry with custom CA / HTTP.
# Example: /etc/buildkit/buildkitd.toml
BUILDER_CONFIG=
# Home Assistant distribution repository
HA_REPO_URL=https://repo.example.com/gree-controller-ha-addon/
HA_REPO_BRANCH=master
HA_REPO_PATH=../gree-controller-ha-addon
# Repository layout
ADDON_DIR=gree-controller
INTEGRATION_DOMAIN=gree_controller
-32
View File
@@ -1,32 +0,0 @@
# Home Assistant add-on: networking, VLANs, and multiple interfaces
This directory contains the full set of materials related to running GREE Controller as a Home Assistant add-on:
- multi-arch build (`build.sh`)
- Dockerfile targets for `trixie` and `alpine`
- add-on repository (`https://git.linuxiarz.pl/gru/gree-controller-ha-addon/`)
- networking documentation (`https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.md`, `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.pl.md`)
- topology diagrams (`docs/topology-ha-vlan-gree-en.png`, `docs/topologia-ha-vlan-gree-pl.png`)
## Quick conclusion
For a Home Assistant add-on, the recommended model is:
- `host_network: true`
- configure VLANs and interfaces on the HA OS host
- the add-on reuses the host interfaces
- do not use `macvlan` inside the add-on
## Where the diagrams are
- `docs/topology-ha-vlan-gree-en.png` - English version
- `docs/topologia-ha-vlan-gree-pl.png` - Polish version
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/topology-ha-vlan-gree-en.png` - repository copy EN
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/topologia-ha-vlan-gree-pl.png` - repository copy PL
## Where the docs are
- `docs/README.en.md`
- `docs/README.pl.md`
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.md`
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.pl.md`
-32
View File
@@ -1,32 +0,0 @@
# Home Assistant add-on: sieć, VLAN-y i wiele interfejsów
Ten katalog zawiera komplet materiałów związanych z uruchomieniem GREE Controller jako dodatku Home Assistant:
- build multi-arch (`build.sh`)
- Dockerfile z targetami `trixie` i `alpine`
- repozytorium dodatku (`https://git.linuxiarz.pl/gru/gree-controller-ha-addon/`)
- dokumentację sieci (`https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.pl.md`, `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.md`)
- obrazy topologii sieci (`docs/topologia-ha-vlan-gree-pl.png`, `docs/topology-ha-vlan-gree-en.png`)
## Szybki wniosek
Dla Home Assistant Add-on zalecany jest model:
- `host_network: true`
- VLAN-y i interfejsy konfigurujesz na hoście HA OS
- dodatek korzysta z interfejsów hosta
- nie używamy `macvlan` w dodatku
## Gdzie są obrazki
- `docs/topologia-ha-vlan-gree-pl.png` - wersja polska
- `docs/topology-ha-vlan-gree-en.png` - wersja angielska
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/topologia-ha-vlan-gree-pl.png` - kopia publikacyjna PL
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/topology-ha-vlan-gree-en.png` - kopia publikacyjna EN
## Gdzie jest opis
- `docs/README.pl.md`
- `docs/README.en.md`
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.pl.md`
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.md`
+52 -89
View File
@@ -1,37 +1,10 @@
# GREE Controller - Home Assistant add-on
# GREE Controller Home Assistant distribution
Everything related to the Home Assistant add-on lives in this directory: build, OCI publishing, custom repository, VLAN/multi-interface docs and diagrams.
This directory contains the OCI build, Home Assistant add-on repository source, custom integration and repository synchronization tooling.
## Topology / topologia
## Configuration
### PL
![Topologia Home Assistant OS + GREE Controller + VLAN](./docs/topologia-ha-vlan-gree-pl.png)
### EN
![Home Assistant OS + GREE Controller + VLAN topology](./docs/topology-ha-vlan-gree-en.png)
## Minimal versioning
There is only **one manually maintained version**: `package.version` in the project root `Cargo.toml`.
```toml
[package]
version = "X.Y.Z"
```
Do not set a version in `.env` and do not create architecture-specific release tags. `./build.sh` automatically:
1. reads the version from `Cargo.toml`,
2. synchronizes the root package entry in `Cargo.lock`,
3. writes the same version to `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/config.yaml`,
4. builds `linux/amd64` and `linux/arm64`,
5. publishes one multi-arch OCI manifest: `REGISTRY/IMAGE_NAME:<version>`.
So a release version is changed in one place only.
## Configure build
Create the local build/repository configuration once:
```bash
cd ha-addon
@@ -39,78 +12,68 @@ cp .env.example .env
$EDITOR .env
```
Example:
`build.sh` and `sync-repository.sh` read all deployment-specific values from `.env`; `.env` is ignored by Git.
Important variables:
```dotenv
REGISTRY=zot.linuxiarz.pl
REGISTRY=repo.example.com
IMAGE_NAME=gree-controller
DISTRO=trixie
DISTRO=alpine
RUST_VERSION=1.89
BUILD_PLATFORMS=linux/amd64,linux/arm64
BUILDER=gree-controller-multiarch
BUILDER_CONFIG=
HA_REPO_URL=https://repo.example.com/gree-controller-ha-addon/
HA_REPO_BRANCH=master
HA_REPO_PATH=../gree-controller-ha-addon
ADDON_DIR=gree-controller
INTEGRATION_DOMAIN=gree_controller
```
Supported base images:
- `DISTRO=trixie` - Debian Trixie builder + `debian:trixie-slim` runtime
- `DISTRO=alpine` - latest Alpine builder + runtime
Registry authentication, when required:
```bash
docker login zot.linuxiarz.pl
```
## Build and publish
Publish one multi-arch image for Home Assistant:
```bash
./build.sh push
```
Result:
```text
zot.linuxiarz.pl/gree-controller:X.Y.Z
├─ linux/amd64
└─ linux/arm64
```
No `-amd64`, `-aarch64`, `-trixie`, `-alpine` or `latest` release tags are required.
Local single-architecture test:
```bash
./build.sh load amd64
# or
./build.sh load aarch64
```
The local test image is always tagged `REGISTRY/IMAGE_NAME:local`.
Only refresh Home Assistant repository metadata:
## OCI image
```bash
./build.sh render
./build.sh load amd64
./build.sh load aarch64
./build.sh push
```
## Home Assistant network model
The project version comes only from root `Cargo.toml`. `render` synchronizes it to `Cargo.lock`, add-on `config.yaml` and the custom integration `manifest.json`, and sets the OCI image from `.env`.
The add-on uses `host_network: true`. VLANs and physical/logical interfaces are configured on Home Assistant OS, not created as Docker `macvlan` networks inside the add-on.
## Home Assistant repository
Recommended model:
```bash
./sync-repository.sh
```
- configure NICs/VLANs on HA OS,
- let the add-on share host networking,
- set `gree_interface` to a specific host interface/IP for one GREE subnet,
- leave `gree_interface` empty for automatic per-device routing across several directly attached subnets,
- run UDP broadcast discovery separately for each VLAN/subnet.
The command updates/clones `HA_REPO_URL`, mirrors the repository and pushes only when content changed. The resulting repository contains:
Detailed documentation:
```text
repository.yaml
gree-controller/ # Home Assistant add-on
custom_components/gree_controller/ # Home Assistant custom integration
```
- `NETWORKING.md` - PL
- `NETWORKING.en.md` - EN
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.pl.md` - PL add-on docs
- `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/gree-controller/DOCS.md` - EN add-on docs
The custom integration source is maintained only in:
## Publish the custom repository
```text
ha-addon/home-assistant/custom_components/gree_controller/
```
Publish `ha-addon/repository/` as a Git repository and add its URL in the Home Assistant add-on/app store as a custom repository. The generated `config.yaml` points Home Assistant at the same OCI repository and version taken from `Cargo.toml`.
It is added to the separate HA repository during synchronization, so it is not duplicated in this project.
Adding the repository to the Home Assistant add-on store installs the add-on only. The bundled custom integration can be copied from the same repository to `/config/custom_components/gree_controller/` when required.
## Layout
```text
.env.example local deployment configuration template
Dockerfile multi-architecture image
build.sh metadata + OCI build/push
run.sh add-on entrypoint
sync-repository.sh separate HA repository synchronization
repository/ add-on repository source
home-assistant/ custom integration source and migration output
```
+112 -53
View File
@@ -3,87 +3,146 @@ set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="${HA_ADDON_ENV_FILE:-$SCRIPT_DIR/.env}"
if [[ -f "$SCRIPT_DIR/.env" ]]; then
set -a
# shellcheck disable=SC1091
source "$SCRIPT_DIR/.env"
set +a
fi
[[ -f "$ENV_FILE" ]] || {
echo "Missing $ENV_FILE. Copy $SCRIPT_DIR/.env.example to $SCRIPT_DIR/.env and edit it." >&2
exit 1
}
REGISTRY="${REGISTRY:-zot.linuxiarz.pl}"
IMAGE_NAME="${IMAGE_NAME:-gree-controller}"
DISTRO="${DISTRO:-trixie}"
BUILDER="${BUILDER:-gree-controller-multiarch}"
BUILDER_CONFIG="${BUILDER_CONFIG:-}"
ACTION="${1:-push}"
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
VERSION="$(awk -F '"' '/^version = "/ {print $2; exit}' "$PROJECT_ROOT/Cargo.toml")"
[[ -n "$VERSION" ]] || { echo "Cannot determine version from Cargo.toml" >&2; exit 1; }
required=(REGISTRY IMAGE_NAME DISTRO RUST_VERSION BUILD_PLATFORMS BUILDER HA_REPO_URL ADDON_DIR INTEGRATION_DOMAIN)
for name in "${required[@]}"; do
[[ -n "${!name:-}" ]] || { echo "Missing $name in $ENV_FILE" >&2; exit 1; }
done
case "$DISTRO" in
trixie|alpine) ;;
*) echo "DISTRO must be one of: trixie, alpine" >&2; exit 2 ;;
esac
ACTION="${1:-push}"
VERSION="$(awk -F '"' '/^version = "/ {print $2; exit}' "$PROJECT_ROOT/Cargo.toml")"
[[ -n "$VERSION" ]] || { echo "Cannot determine version from Cargo.toml" >&2; exit 1; }
IMAGE_REPO="${REGISTRY%/}/${IMAGE_NAME#/}"
IMAGE_TAG="${IMAGE_REPO}:${VERSION}"
CONFIG="$SCRIPT_DIR/repository/$ADDON_DIR/config.yaml"
LOCKFILE="$PROJECT_ROOT/Cargo.lock"
INTEGRATION_MANIFEST="$SCRIPT_DIR/home-assistant/custom_components/$INTEGRATION_DOMAIN/manifest.json"
REPOSITORY_CONFIG="$SCRIPT_DIR/repository/repository.yaml"
"$SCRIPT_DIR/render-repository.sh"
[[ -f "$CONFIG" ]] || { echo "Missing add-on config: $CONFIG" >&2; exit 1; }
[[ -f "$INTEGRATION_MANIFEST" ]] || { echo "Missing integration manifest: $INTEGRATION_MANIFEST" >&2; exit 1; }
[[ -f "$REPOSITORY_CONFIG" ]] || { echo "Missing repository config: $REPOSITORY_CONFIG" >&2; exit 1; }
if [[ "$ACTION" == "render" ]]; then
exit 0
fi
render_metadata() {
command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; }
python3 - "$CONFIG" "$LOCKFILE" "$INTEGRATION_MANIFEST" "$REPOSITORY_CONFIG" "$VERSION" "$IMAGE_REPO" "$HA_REPO_URL" <<'PY'
from pathlib import Path
import re
import sys
command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 1; }
docker buildx version >/dev/null 2>&1 || { echo "docker buildx is required" >&2; exit 1; }
config = Path(sys.argv[1])
lockfile = Path(sys.argv[2])
manifest = Path(sys.argv[3])
repository = Path(sys.argv[4])
version = sys.argv[5]
image = sys.argv[6]
repo_url = sys.argv[7]
lines = config.read_text(encoding="utf-8").splitlines()
out = []
for line in lines:
if line.startswith("version:"):
out.append(f'version: "{version}"')
elif line.startswith("image:"):
out.append(f'image: "{image}"')
elif line.startswith("url:"):
out.append(f'url: "{repo_url}"')
else:
out.append(line)
config.write_text("\n".join(out) + "\n", encoding="utf-8")
lock = lockfile.read_text(encoding="utf-8")
pattern = r'(\[\[package\]\]\nname = "gree-controller"\nversion = ")[^"]+("\n)'
updated, count = re.subn(pattern, rf'\g<1>{version}\g<2>', lock, count=1)
if count != 1:
raise SystemExit("Could not find gree-controller package version in Cargo.lock")
lockfile.write_text(updated, encoding="utf-8")
import json
data = json.loads(manifest.read_text(encoding="utf-8"))
data["version"] = version
manifest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
repo_lines = repository.read_text(encoding="utf-8").splitlines()
repo_out = [f"url: {repo_url}" if line.startswith("url:") else line for line in repo_lines]
repository.write_text("\n".join(repo_out) + "\n", encoding="utf-8")
PY
printf 'Synced version %s; image %s\n' "$VERSION" "$IMAGE_REPO"
}
ensure_builder() {
command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 1; }
docker buildx version >/dev/null 2>&1 || { echo "docker buildx is required" >&2; exit 1; }
if docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
docker buildx use "$BUILDER"
else
args=(create --name "$BUILDER" --driver docker-container --use)
if [[ -n "$BUILDER_CONFIG" ]]; then
args+=(--config "$BUILDER_CONFIG")
fi
[[ -z "${BUILDER_CONFIG:-}" ]] || args+=(--config "$BUILDER_CONFIG")
docker buildx "${args[@]}" >/dev/null
fi
docker buildx inspect --bootstrap >/dev/null
}
ensure_builder
build_push() {
ensure_builder
docker buildx build \
--platform "$BUILD_PLATFORMS" \
-f "$SCRIPT_DIR/Dockerfile" \
--target "runtime-${DISTRO}" \
--build-arg "BUILD_VERSION=$VERSION" \
--build-arg "RUST_VERSION=$RUST_VERSION" \
-t "$IMAGE_TAG" \
--push \
"$PROJECT_ROOT"
printf 'Published %s (%s, %s)\n' "$IMAGE_TAG" "$BUILD_PLATFORMS" "$DISTRO"
}
build_load() {
local arch="${2:-amd64}" platform
case "$arch" in
amd64) platform="linux/amd64" ;;
aarch64|arm64) platform="linux/arm64" ;;
*) echo "Usage: $0 load [amd64|aarch64]" >&2; exit 2 ;;
esac
ensure_builder
docker buildx build \
--platform "$platform" \
-f "$SCRIPT_DIR/Dockerfile" \
--target "runtime-${DISTRO}" \
--build-arg "BUILD_VERSION=$VERSION" \
--build-arg "RUST_VERSION=$RUST_VERSION" \
-t "${IMAGE_REPO}:local" \
--load \
"$PROJECT_ROOT"
printf 'Loaded %s:local for %s (%s)\n' "$IMAGE_REPO" "$arch" "$DISTRO"
}
render_metadata
case "$ACTION" in
push)
docker buildx build \
--platform linux/amd64,linux/arm64 \
-f "$SCRIPT_DIR/Dockerfile" \
--target "runtime-${DISTRO}" \
--build-arg "BUILD_VERSION=$VERSION" \
-t "$IMAGE_TAG" \
--push \
"$PROJECT_ROOT"
printf 'Published %s using %s (amd64 + aarch64)\n' "$IMAGE_TAG" "$DISTRO"
;;
load)
arch="${2:-amd64}"
case "$arch" in
amd64) platform="linux/amd64" ;;
aarch64|arm64) platform="linux/arm64" ;;
*) echo "Usage: $0 load [amd64|aarch64]" >&2; exit 2 ;;
esac
docker buildx build \
--platform "$platform" \
-f "$SCRIPT_DIR/Dockerfile" \
--target "runtime-${DISTRO}" \
--build-arg "BUILD_VERSION=$VERSION" \
-t "${IMAGE_REPO}:local" \
--load \
"$PROJECT_ROOT"
printf 'Loaded %s:local for %s using %s\n' "$IMAGE_REPO" "$arch" "$DISTRO"
;;
render) ;;
push) build_push ;;
load) build_load "${2:-amd64}" ;;
*)
echo "Usage: $0 [push|load [amd64|aarch64]|render]" >&2
echo "Usage: $0 [render|push|load [amd64|aarch64]]" >&2
exit 2
;;
esac
-21
View File
@@ -1,21 +0,0 @@
# Home Assistant add-on - networking and VLAN
This directory contains topology materials for Home Assistant OS, VLANs, and multiple network interfaces for GREE Controller.
## EN diagram
![Home Assistant OS + VLAN + GREE topology](./topology-ha-vlan-gree-en.png)
## PL diagram
![Topologia HA OS + VLAN + GREE](./topologia-ha-vlan-gree-pl.png)
## Network model
- VLANs and IP addresses are configured on the Home Assistant OS host.
- The add-on runs with `host_network: true`, so it uses the host interfaces directly.
- There is no need to create `macvlan` inside the add-on.
- For several directly attached subnets you can leave `gree_interface` empty so the controller picks the interface that best matches the device IP.
- UDP broadcast discovery happens per subnet/VLAN; broadcast usually does not cross a router.
Full details: `../NETWORKING.en.md` and `../repository/gree-controller/DOCS.md`.
-16
View File
@@ -1,16 +0,0 @@
# Home Assistant add-on - diagrams and VLAN notes
This directory stores the Home Assistant add-on topology diagrams in both languages.
- [Polish notes](./README.pl.md)
- [English notes](./README.en.md)
## Diagrams
### Polish
![Topologia HA OS + VLAN + GREE](./topologia-ha-vlan-gree-pl.png)
### English
![Home Assistant OS + VLAN + GREE topology](./topology-ha-vlan-gree-en.png)
-21
View File
@@ -1,21 +0,0 @@
# Home Assistant add-on - sieć i VLAN
Ten katalog zawiera materiały dotyczące topologii Home Assistant OS, VLAN-ów i wielu interfejsów sieciowych dla GREE Controller.
## Diagram PL
![Topologia HA OS + VLAN + GREE](./topologia-ha-vlan-gree-pl.png)
## Diagram EN
![Home Assistant OS + VLAN + GREE topology](./topology-ha-vlan-gree-en.png)
## Model sieciowy
- VLAN-y oraz adresy IP są konfigurowane na hoście Home Assistant OS.
- Add-on działa z `host_network: true`, więc korzysta bezpośrednio z interfejsów hosta.
- Nie ma potrzeby tworzenia `macvlan` wewnątrz dodatku.
- Dla kilku bezpośrednio podłączonych podsieci `gree_interface` można pozostawić puste, aby kontroler dobierał interfejs do adresu urządzenia.
- Discovery UDP broadcast wykonuje się per podsieć/VLAN; broadcast zwykle nie przechodzi przez router.
Pełny opis: `../NETWORKING.md` oraz `../repository/gree-controller/DOCS.pl.md`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

+160
View File
@@ -0,0 +1,160 @@
# Home Assistant integration
The project includes a custom Home Assistant integration under:
```text
ha-addon/home-assistant/custom_components/gree_controller/
```
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.
Automation plan:
- `sensor` for the whole-house plan (house mode, upcoming events and active rules),
- one `sensor` per thermostat zone with current demand, active preset/schedule and upcoming transitions,
- one writable `number` per zone for the controller target temperature (8-30°C, 0.5°C step),
- one `switch` per zone for enabling/disabling the thermostat directly from Home Assistant.
The plan entities can be placed on a normal Home Assistant dashboard; their `next_events` attributes contain the same schedule timeline shown as graphical blocks in the controller Web UI. Changing a zone target number calls the restricted controller zone API and creates the same temporary override as the Web UI. The zone switch uses the same API to enable or disable that thermostat.
## Install the custom integration
Copy the directory into your HA configuration:
```text
/config/custom_components/gree_controller/
```
`sync-repository.sh` also publishes this integration as `custom_components/gree_controller/` in the same Git repository as the add-on. The add-on itself does not install the integration automatically.
Before adding the integration, open the standalone controller Web UI and go to **More -> Home Assistant / Sensors -> Create new token**. Copy the generated secret; it is shown only once.
Restart Home Assistant, then open **Settings -> Devices & services -> Add integration -> GREE Controller** and enter only:
- controller URL, for example `http://192.168.1.20:8787`,
- the generated controller access token.
The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices/control plans/groups and sending device, zone, whole-house or climate-group control commands.
## Preserve an existing entity ID
If existing automations and dashboards use an entity such as:
```text
climate.klima_salon
```
use the migration generator before switching integrations:
```bash
./scripts/generate_ha_migration.py \
--entity climate.klima_salon \
--device gree-aabbccddeeff
```
Copy the generated JSON file to:
```text
/config/gree_controller_entities.json
```
The custom integration reads this file and requests the exact same `climate.*` entity ID.
Home Assistant cannot have two active entities with the same `entity_id`. Therefore the old/default GREE entity must release `climate.klima_salon` before the new integration is loaded. Do not run both integrations against the same entity ID.
Safe order:
1. Configure and test the standalone Rust controller first.
2. Confirm the AC can be controlled from the GREE Controller web UI.
3. Generate and copy `gree_controller_entities.json`.
4. Disable or remove the old/default GREE integration entry in Home Assistant.
5. If its old entity registry record remains, remove that stale entity from HA after the old integration is unloaded.
6. Install/restart the `gree_controller` custom integration.
7. Verify that `climate.klima_salon` exists and controls the AC through the Rust service.
8. Check existing dashboards, scripts and automations. Because the entity ID is unchanged, references to `climate.klima_salon` do not need to be rewritten.
The integration deliberately fails setup on an entity-ID conflict instead of silently creating `climate.klima_salon_2`.
## Multiple devices
Use repeated mappings:
```bash
./scripts/generate_ha_migration.py \
--map climate.klima_salon=gree-aabbccddeeff \
--map climate.klima_sypialnia=gree-112233445566
```
## Optional validation
Validate the target controller device:
```bash
./scripts/generate_ha_migration.py \
--entity climate.klima_salon \
--controller-url http://192.168.1.20:8787 \
--controller-token CONTROLLER_TOKEN
```
Validate that the source HA entity currently exists as well:
```bash
./scripts/generate_ha_migration.py \
--entity climate.klima_salon \
--device gree-aabbccddeeff \
--ha-url http://homeassistant.local:8123 \
--ha-token HOME_ASSISTANT_LONG_LIVED_TOKEN
```
Tokens are used only during validation and are not written to the mapping file.
## HA as an external temperature source
This is independent from the custom climate integration. Each Rust controller zone can assign its own HA room-temperature entity, for example:
```text
Living room -> GREE Living Room + sensor.living_room_temperature
Bedroom -> GREE Bedroom + sensor.bedroom_temperature
```
The recommended `combined` strategy keeps the GREE sensor as the primary input and uses the room sensor as a configurable supporting measurement (40% weight by default). A zone may also select the room sensor as its preferred source. If HA or that entity becomes unavailable, the controller falls back to the corresponding GREE unit, so local control and schedules continue to run.
## Zone climate and optional unit features
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
Each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch reports the configured zone state independently from group control. Turning a group OFF powers its member units down and releases group ownership. Members are not blocked merely because the group remains OFF: an individual thermostat can be turned back on independently. Turning the group ON clears that scoped OFF state and immediately resumes group thermostat arbitration. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone. From 0.7.12, every local OFF creates a fresh backend-owned 15-minute hand-back deadline exposed as `local_thermostat_resume_at`. A direct/pilot takeover suspends expiry of that local timer; if the unit is returned to the previous OFF state, the backend re-arms a fresh 15-minute countdown from that moment. The separate zone Enabled switch remains the technical availability switch.
Zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created.
## Whole-house controls
The integration also exposes three controller-level entities on the **GREE Controller** device:
- **Thermostat mode** (`select`) — Cooling, Heating or **Do not control**. Do not control pauses house-level thermostat commands but leaves direct device control and explicit per-zone Heat/Cool overrides untouched.
- **Work profile** (`select`) — Auto schedule, Comfort, Sleep or Away. If zones have mixed manual profiles, the select has no single current option until a whole-house profile is chosen again.
- Choosing **Cooling/Heating** changes the house rule for zones that inherit the global mode. **Do not control** pauses only that inherited house rule; explicit local/group/manual control remains independent.
- **All air conditioners** (`switch`) performs bulk thermostat power control. OFF powers all enabled units down and leaves their thermostats locally OFF with no hand-back timer; later local/group/global ON can re-enable the selected scope. ON releases local OFF states and respects compressor protection. Its displayed state reflects whether all currently enabled units are physically ON.
These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints.
## Climate groups
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.
## Command state stability
Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. From version 0.7.9 the same guard also covers per-zone enable, HVAC mode, profile and target-temperature commands. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
@@ -0,0 +1,51 @@
"""Home Assistant bridge for the standalone GREE Controller service."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import GreeControllerApiError, GreeControllerClient
from .const import CONF_TOKEN, CONF_URL, PLATFORMS
from .coordinator import GreeControllerCoordinator
from .entity_map import async_load_entity_map
@dataclass
class GreeControllerRuntimeData:
"""Runtime objects kept on the config entry."""
client: GreeControllerClient
coordinator: GreeControllerCoordinator
entity_map: dict[str, str]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up GREE Controller from a config entry."""
client = GreeControllerClient(
async_get_clientsession(hass),
entry.data[CONF_URL],
entry.data.get(CONF_TOKEN, ""),
)
try:
await client.devices()
except GreeControllerApiError as err:
if "authentication" in str(err).lower():
raise ConfigEntryAuthFailed(str(err)) from err
raise ConfigEntryNotReady(str(err)) from err
coordinator = GreeControllerCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
entity_map = await async_load_entity_map(hass)
entry.runtime_data = GreeControllerRuntimeData(client, coordinator, entity_map)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload the config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,158 @@
"""HTTP client for the standalone GREE Controller service."""
from __future__ import annotations
from typing import Any
from aiohttp import ClientError, ClientSession
class GreeControllerApiError(Exception):
"""Raised when the controller API cannot be used."""
class GreeControllerClient:
"""Small async client backed by Home Assistant's shared ClientSession."""
def __init__(self, session: ClientSession, base_url: str, token: str = "") -> None:
self._session = session
self._base_url = base_url.rstrip("/")
self._token = token.strip()
@property
def base_url(self) -> str:
"""Return the normalized controller URL."""
return self._base_url
def _headers(self) -> dict[str, str]:
headers = {"Accept": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
return headers
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
try:
async with self._session.request(
method,
f"{self._base_url}{path}",
headers=self._headers(),
timeout=10,
**kwargs,
) as response:
if response.status == 401:
raise GreeControllerApiError("Controller authentication failed")
if response.status >= 400:
try:
body = await response.json()
message = body.get("error", f"HTTP {response.status}")
except (ValueError, TypeError):
message = f"HTTP {response.status}"
raise GreeControllerApiError(message)
if response.status == 204:
return None
return await response.json()
except GreeControllerApiError:
raise
except (ClientError, TimeoutError) as err:
raise GreeControllerApiError(str(err)) from err
async def health(self) -> dict[str, Any]:
"""Return the public controller health payload."""
return await self._request("GET", "/api/health")
async def snapshot(self) -> dict[str, Any]:
"""Return devices, groups and the control plan in one restricted request."""
data = await self._request("GET", "/api/integrations/home-assistant/snapshot")
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid snapshot payload")
if not isinstance(data.get("devices"), list):
raise GreeControllerApiError("Controller returned invalid snapshot devices")
if not isinstance(data.get("control_plan"), dict):
raise GreeControllerApiError("Controller returned an invalid snapshot control plan")
if not isinstance(data.get("groups"), list):
raise GreeControllerApiError("Controller returned invalid snapshot groups")
return data
async def devices(self) -> list[dict[str, Any]]:
"""Return all controller devices."""
data = await self._request("GET", "/api/integrations/home-assistant/devices")
if not isinstance(data, list):
raise GreeControllerApiError("Controller returned an invalid devices payload")
return data
async def command(self, device_id: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Send a device command and return the updated device state."""
return await self._request(
"POST",
f"/api/integrations/home-assistant/devices/{device_id}/command",
json=payload,
)
async def control_plan(self) -> dict[str, Any]:
"""Return the current whole-house and zone automation plan."""
data = await self._request("GET", "/api/integrations/home-assistant/control-plan")
if not isinstance(data, dict):
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(
"POST",
"/api/integrations/home-assistant/house/control",
json={"mode": mode},
)
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid house control payload")
return data
async def house_preset(self, preset: str) -> dict[str, Any]:
"""Change the whole-house work profile."""
data = await self._request(
"POST",
"/api/integrations/home-assistant/house/preset",
json={"preset": preset},
)
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid house preset payload")
return data
async def house_power(self, power: bool) -> dict[str, Any]:
"""Turn all enabled air conditioners on or off."""
data = await self._request(
"POST",
"/api/integrations/home-assistant/house/power",
json={"power": power},
)
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid house power payload")
return data
async def zone_control(self, zone_id: str, payload: dict[str, Any]) -> dict[str, Any]:
"""Change a zone thermostat override through the controller."""
data = await self._request(
"POST",
f"/api/integrations/home-assistant/zones/{zone_id}/control",
json=payload,
)
if not isinstance(data, dict):
raise GreeControllerApiError("Controller returned an invalid zone payload")
return data
@@ -0,0 +1,388 @@
"""Climate entities proxied through the standalone GREE Controller."""
from __future__ import annotations
import logging
from typing import Any
from homeassistant.components.climate import ClimateEntity
from homeassistant.components.climate.const import (
SWING_OFF,
SWING_ON,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
_LOGGER = logging.getLogger(__name__)
MODE_TO_HA = {
"auto": HVACMode.AUTO,
"cool": HVACMode.COOL,
"dry": HVACMode.DRY,
"fan": HVACMode.FAN_ONLY,
"heat": HVACMode.HEAT,
}
HA_TO_MODE = {value: key for key, value in MODE_TO_HA.items()}
FAN_TO_NAME = {0: "auto", 1: "low", 2: "medium_low", 3: "medium", 4: "medium_high", 5: "high"}
NAME_TO_FAN = {value: key for key, value in FAN_TO_NAME.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create climate entities for all devices exposed by the controller."""
runtime: GreeControllerRuntimeData = entry.runtime_data
registry = er.async_get(hass)
entities: list[ClimateEntity] = []
for device_id, device in runtime.coordinator.data.items():
desired_entity_id = runtime.entity_map.get(device_id)
unique_id = f"{device_id}-climate"
if desired_entity_id:
if not desired_entity_id.startswith("climate."):
raise ConfigEntryError(f"Mapped entity ID must use the climate domain: {desired_entity_id}")
existing = registry.async_get(desired_entity_id)
if existing and not (existing.platform == DOMAIN and existing.unique_id == unique_id):
raise ConfigEntryError(
f"Entity ID {desired_entity_id} is still reserved by integration {existing.platform}. "
"Disable/remove the previous GREE integration and remove its entity registry entry before takeover."
)
if hass.states.get(desired_entity_id) is not None and existing is None:
raise ConfigEntryError(
f"Entity ID {desired_entity_id} is still active in Home Assistant. "
"Unload the previous integration before takeover."
)
entities.append(
GreeControllerClimate(runtime.coordinator, device_id, desired_entity_id)
)
for zone in runtime.coordinator.plan.get("zones", []):
zone_id = str(zone.get("zone_id") or "").strip()
if zone_id:
_ensure_zone_thermostat_entity_id(registry, zone)
entities.append(GreeControllerZoneClimate(runtime.coordinator, zone_id))
async_add_entities(entities)
def _ensure_zone_thermostat_entity_id(
registry: er.EntityRegistry, zone: dict[str, Any]
) -> None:
"""Keep zone climate entity IDs stable and descriptive.
Zone climate entities use ``climate.<zone_name>_thermostat``. Existing
registry entries are migrated to the same scheme, which also fixes stale
IDs left behind after a zone was renamed (for example ``climate.jan_2``
for a zone currently named Igor).
"""
zone_id = str(zone.get("zone_id") or "").strip()
if not zone_id:
return
unique_id = f"{zone_id}-zone-climate"
current_entity_id = registry.async_get_entity_id("climate", DOMAIN, unique_id)
if current_entity_id is None:
return
zone_name = str(zone.get("zone_name") or zone_id).strip() or zone_id
object_id = slugify(zone_name) or slugify(zone_id) or "zone"
desired_entity_id = f"climate.{object_id}_thermostat"
if current_entity_id == desired_entity_id:
return
occupied = registry.async_get(desired_entity_id)
if occupied is not None and occupied.entity_id != current_entity_id:
_LOGGER.warning(
"Cannot rename zone thermostat %s to %s because that entity ID is already in use",
current_entity_id,
desired_entity_id,
)
return
registry.async_update_entity(current_entity_id, new_entity_id=desired_entity_id)
_LOGGER.info(
"Renamed zone thermostat entity %s to %s",
current_entity_id,
desired_entity_id,
)
class GreeControllerClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity):
"""Home Assistant climate entity controlled through the Rust service."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = 8.0
_attr_max_temp = 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]
_attr_swing_horizontal_modes = [SWING_OFF, SWING_ON]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.SWING_MODE
| ClimateEntityFeature.SWING_HORIZONTAL_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(
self,
coordinator: GreeControllerCoordinator,
device_id: str,
requested_entity_id: str | None,
) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._attr_unique_id = f"{device_id}-climate"
self._attr_name = "Direct control"
if requested_entity_id:
# This is intentionally limited to same-domain takeover migrations.
# The setup guard above prevents accidental collisions.
self.entity_id = requested_entity_id
@property
def _device(self) -> dict[str, Any]:
return self.coordinator.data.get(self._device_id, {})
@property
def available(self) -> bool:
return super().available and bool(self._device.get("online", False))
@property
def device_info(self) -> DeviceInfo:
device = self._device
return DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
name=str(device.get("name") or self._device_id),
manufacturer="GREE",
model=str(device.get("model") or "GREE HVAC"),
sw_version=str(device.get("firmware") or "") or None,
)
@property
def current_temperature(self) -> float | None:
value = self._device.get("current_temperature")
return float(value) if value is not None else None
@property
def target_temperature(self) -> float | None:
value = self._device.get("target_temperature")
return float(value) if value is not None else None
@property
def hvac_mode(self) -> HVACMode:
device = self._device
if not device.get("power", False):
return HVACMode.OFF
return MODE_TO_HA.get(str(device.get("mode", "auto")), HVACMode.AUTO)
@property
def fan_mode(self) -> str:
return FAN_TO_NAME.get(int(self._device.get("fan_speed", 0)), "auto")
@property
def swing_mode(self) -> str:
return SWING_ON if self._device.get("swing_vertical", False) else SWING_OFF
@property
def swing_horizontal_mode(self) -> str:
return SWING_ON if self._device.get("swing_horizontal", False) else SWING_OFF
@property
def extra_state_attributes(self) -> dict[str, Any]:
device = self._device
return {
"controller_device_id": self._device_id,
"controller_online": bool(device.get("online", False)),
"quiet": bool(device.get("quiet", False)),
"turbo": bool(device.get("turbo", False)),
"light": bool(device.get("light", False)),
"xfan": bool(device.get("xfan", False)),
"air": bool(device.get("air", False)),
"health": bool(device.get("health", False)),
"sleep": bool(device.get("sleep", False)),
"last_seen": device.get("last_seen"),
}
async def _command(self, payload: dict[str, Any]) -> None:
await self.coordinator.async_device_command(self._device_id, payload)
async def async_turn_on(self) -> None:
await self._command({"power": True})
async def async_turn_off(self) -> None:
await self._command({"power": False})
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await self._command({"power": False})
return
mode = HA_TO_MODE.get(hvac_mode)
if mode is None:
return
await self._command({"power": True, "mode": mode})
async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is not None:
await self._command({"target_temperature": float(temperature)})
async def async_set_fan_mode(self, fan_mode: str) -> None:
if fan_mode in NAME_TO_FAN:
await self._command({"fan_speed": NAME_TO_FAN[fan_mode]})
async def async_set_swing_mode(self, swing_mode: str) -> None:
await self._command({"swing_vertical": swing_mode == SWING_ON})
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
await self._command({"swing_horizontal": swing_horizontal_mode == SWING_ON})
class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity):
"""Full climate entity for a controller thermostat zone."""
_attr_has_entity_name = True
_attr_name = "Thermostat"
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_min_temp = 8.0
_attr_max_temp = 30.0
_attr_target_temperature_step = 0.5
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT]
_attr_preset_modes = ["auto", "comfort", "sleep", "away"]
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-zone-climate"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
@property
def current_temperature(self) -> float | None:
value = self._zone.get("current_temperature")
return float(value) if value is not None else None
@property
def target_temperature(self) -> float | None:
value = self._zone.get("target_temperature")
if value is None:
value = self._zone.get("setpoint")
return float(value) if value is not None else None
@property
def hvac_mode(self) -> HVACMode:
zone = self._zone
if not zone.get("enabled", False) or zone.get("local_thermostat_power") is False:
return HVACMode.OFF
if zone.get("inherit_house_mode", True):
return HVACMode.AUTO
return HVACMode.HEAT if str(zone.get("configured_mode", zone.get("mode", "cool"))) == "heat" else HVACMode.COOL
@property
def preset_mode(self) -> str:
return str(self._zone.get("preset_override") or "auto")
@property
def extra_state_attributes(self) -> dict[str, Any]:
zone = self._zone
return {
"controller_zone_id": self._zone_id,
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
"local_thermostat_power": zone.get("local_thermostat_power"),
"local_thermostat_resume_at": zone.get("local_thermostat_resume_at"),
"effective_mode": zone.get("mode"),
"follows_house_mode": bool(zone.get("inherit_house_mode", True)),
"auto_mode_meaning": "Follow the GREE Controller whole-house heating/cooling mode",
"active_preset": zone.get("preset"),
"preset_override": zone.get("preset_override"),
"demand": bool(zone.get("demand", False)),
"device_setpoint": zone.get("device_setpoint"),
"temperature_source": zone.get("control_source"),
"control_owner": zone.get("control_owner"),
"control_source": zone.get("control_command_source"),
"control_since": zone.get("control_since"),
"resume_at": zone.get("resume_at"),
"control_reason": zone.get("control_reason"),
"blocked_reason": zone.get("blocked_reason"),
"lockout_until": zone.get("lockout_until"),
"desired_power": zone.get("desired_power"),
"desired_mode": zone.get("desired_mode"),
"actual_power": zone.get("actual_power"),
"actual_mode": zone.get("actual_mode"),
"actual_setpoint": zone.get("actual_setpoint"),
"current_schedule": zone.get("current_schedule_name"),
}
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
payload = {"power": False}
elif hvac_mode == HVACMode.AUTO:
payload = {"power": True, "mode": "house"}
elif hvac_mode == HVACMode.COOL:
payload = {"power": True, "mode": "cool"}
elif hvac_mode == HVACMode.HEAT:
payload = {"power": True, "mode": "heat"}
else:
return
await self.coordinator.async_zone_control(self._zone_id, payload)
async def async_set_temperature(self, **kwargs: Any) -> None:
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature is None:
return
await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(temperature)})
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode not in self._attr_preset_modes:
return
await self.coordinator.async_zone_control(self._zone_id, {"preset": preset_mode})
async def async_turn_on(self) -> None:
await self.coordinator.async_zone_control(self._zone_id, {"power": True})
async def async_turn_off(self) -> None:
await self.coordinator.async_zone_control(self._zone_id, {"power": False})
@@ -0,0 +1,46 @@
"""UI configuration flow for GREE Controller."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .api import GreeControllerApiError, GreeControllerClient
from .const import CONF_TOKEN, CONF_URL, DOMAIN
class GreeControllerConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Configure a standalone GREE Controller instance."""
VERSION = 1
async def async_step_user(self, user_input: dict[str, Any] | None = None):
"""Handle the initial connection form."""
errors: dict[str, str] = {}
if user_input is not None:
url = str(user_input[CONF_URL]).strip().rstrip("/")
token = str(user_input[CONF_TOKEN]).strip()
client = GreeControllerClient(async_get_clientsession(self.hass), url, token)
try:
await client.devices()
except GreeControllerApiError as err:
errors["base"] = "invalid_auth" if "authentication" in str(err).lower() else "cannot_connect"
else:
await self.async_set_unique_id("gree-controller")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="GREE Controller",
data={CONF_URL: url, CONF_TOKEN: token},
)
schema = vol.Schema(
{
vol.Required(CONF_URL, default="http://gree-controller:8787"): str,
vol.Required(CONF_TOKEN): str,
}
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
@@ -0,0 +1,11 @@
"""Constants for the GREE Controller Home Assistant integration."""
from homeassistant.const import Platform
DOMAIN = "gree_controller"
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.NUMBER, Platform.SELECT, Platform.SWITCH]
CONF_URL = "url"
CONF_TOKEN = "token"
ENTITY_MAP_FILE = "gree_controller_entities.json"
DEFAULT_SCAN_INTERVAL_SECONDS = 10
@@ -0,0 +1,193 @@
"""Data coordinator for the GREE Controller integration."""
from __future__ import annotations
import asyncio
from datetime import timedelta
import logging
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import GreeControllerApiError, GreeControllerClient
from .const import DEFAULT_SCAN_INTERVAL_SECONDS, DOMAIN
_LOGGER = logging.getLogger(__name__)
class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
"""Poll the standalone controller and cache device states."""
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, client: GreeControllerClient) -> None:
super().__init__(
hass,
logger=_LOGGER,
name=DOMAIN,
config_entry=entry,
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS),
)
self.client = client
self.plan: dict[str, Any] = {}
self.groups: dict[str, dict[str, Any]] = {}
# Protect a just-accepted device command from an overlapping/stale poll.
# GREE units can expose their previous status briefly after acknowledging a write.
self._pending_device_commands: dict[str, tuple[float, dict[str, Any]]] = {}
# Zone switches/climate/profile controls need the same protection. A coordinator refresh
# that started before the POST must not make an accepted zone action visibly bounce back.
self._pending_zone_controls: dict[str, tuple[float, dict[str, Any]]] = {}
async def _async_update_data(self) -> dict[str, dict]:
try:
snapshot = await self.client.snapshot()
devices = snapshot["devices"]
plan = snapshot["control_plan"]
groups = snapshot["groups"]
except GreeControllerApiError as err:
raise UpdateFailed(str(err)) from err
self._overlay_pending_zone_controls(plan)
self.plan = plan
self.groups = {str(group["id"]): group for group in groups if group.get("id")}
device_map = {str(device["id"]): device for device in devices if device.get("id")}
self._overlay_pending_device_commands(device_map)
return device_map
@staticmethod
def _normalized_device_command(payload: dict[str, Any]) -> dict[str, Any]:
"""Normalize writable fields exactly as the standalone controller does."""
allowed = {
"power", "mode", "target_temperature", "fan_speed",
"swing_vertical", "swing_horizontal", "quiet", "turbo",
"light", "air", "xfan", "health", "sleep",
}
expected = {key: value for key, value in payload.items() if key in allowed}
if "target_temperature" in expected:
value = max(8.0, min(30.0, float(expected["target_temperature"])))
expected["target_temperature"] = float(int(value + 0.5))
if "fan_speed" in expected:
expected["fan_speed"] = min(5, max(0, int(expected["fan_speed"])))
return expected
def _overlay_pending_device_commands(self, devices: dict[str, dict[str, Any]]) -> None:
"""Do not let a stale post-command poll make HA controls bounce backwards."""
now = asyncio.get_running_loop().time()
for device_id, (deadline, expected) in list(self._pending_device_commands.items()):
if now >= deadline:
self._pending_device_commands.pop(device_id, None)
continue
device = devices.get(device_id)
if device is not None:
device.update(expected)
@staticmethod
def _normalized_zone_control(payload: dict[str, Any]) -> dict[str, Any]:
"""Map zone-control API fields to the Home Assistant control-plan representation."""
expected: dict[str, Any] = {}
if "enabled" in payload:
expected["enabled"] = bool(payload["enabled"])
if "power" in payload:
expected["local_thermostat_power"] = bool(payload["power"])
if "mode" in payload:
mode = str(payload["mode"])
if mode in {"house", "auto"}:
expected["inherit_house_mode"] = True
elif mode in {"cool", "heat"}:
expected["inherit_house_mode"] = False
expected["configured_mode"] = mode
expected["mode"] = mode
if "preset" in payload:
preset = str(payload["preset"])
expected["preset_override"] = None if preset == "auto" else preset
if preset != "auto":
expected["preset"] = preset
if "setpoint" in payload:
value = max(8.0, min(30.0, float(payload["setpoint"])))
expected["target_temperature"] = round(value * 2.0) / 2.0
if payload.get("clear_override"):
expected["preset_override"] = None
return expected
def _overlay_pending_zone_controls(self, plan: dict[str, Any]) -> None:
"""Overlay accepted zone controls onto stale/in-flight control-plan reads."""
now = asyncio.get_running_loop().time()
zones = plan.get("zones", [])
for zone_id, (deadline, expected) in list(self._pending_zone_controls.items()):
if now >= deadline:
self._pending_zone_controls.pop(zone_id, None)
continue
for zone in zones:
if str(zone.get("zone_id")) == zone_id:
zone.update(expected)
break
async def async_zone_control(self, zone_id: str, payload: dict[str, Any]) -> None:
"""Send a thermostat-zone command with a short optimistic anti-bounce guard."""
expected = self._normalized_zone_control(payload)
loop = asyncio.get_running_loop()
self._pending_zone_controls[zone_id] = (loop.time() + 5.0, expected)
if self.plan:
optimistic_plan = dict(self.plan)
optimistic_plan["zones"] = [dict(zone) for zone in self.plan.get("zones", [])]
self._overlay_pending_zone_controls(optimistic_plan)
self.plan = optimistic_plan
self.async_set_updated_data(dict(self.data or {}))
try:
zone = await self.client.zone_control(zone_id, payload)
except GreeControllerApiError:
self._pending_zone_controls.pop(zone_id, None)
await self.async_request_refresh()
raise
# The backend owns hand-back timing. Keep the exact accepted deadline returned
# by the zone API in the optimistic overlay so an overlapping/stale plan poll
# cannot temporarily resurrect the previous countdown in Home Assistant.
for key in (
"local_thermostat_power",
"local_thermostat_resume_at",
"device_manual_override",
"device_manual_override_until",
):
if key in zone:
expected[key] = zone[key]
self._pending_zone_controls[zone_id] = (loop.time() + 3.0, expected)
await self.async_request_refresh()
async def async_device_command(self, device_id: str, payload: dict[str, Any]) -> None:
"""Send a physical-unit command while keeping HA state monotonic during settling."""
expected = self._normalized_device_command(payload)
loop = asyncio.get_running_loop()
# Register intent before network I/O. A coordinator refresh that was already in flight
# when the user clicked must not be allowed to publish the pre-command state.
self._pending_device_commands[device_id] = (loop.time() + 5.0, expected)
current = dict(self.data or {})
if device_id in current:
optimistic = dict(current[device_id])
optimistic.update(expected)
current[device_id] = optimistic
self.async_set_updated_data(current)
try:
device = await self.client.command(device_id, payload)
except GreeControllerApiError:
# Never mask an actual rejected/failed command. Drop the optimistic guard and
# immediately restore the latest factual controller state.
self._pending_device_commands.pop(device_id, None)
await self.async_request_refresh()
raise
# Keep the guard briefly after a successful ACK so delayed firmware status and an
# overlapping coordinator refresh cannot roll the entity backwards.
self._pending_device_commands[device_id] = (loop.time() + 3.0, expected)
current = dict(self.data or {})
optimistic = dict(device)
optimistic.update(expected)
current[device_id] = optimistic
self.async_set_updated_data(current)
# Refresh plan/group metadata immediately as before. Any stale device snapshot in
# this refresh is overlaid by the short pending-command guard above.
await self.async_request_refresh()
@@ -0,0 +1,34 @@
"""Load optional legacy entity ID mappings for GREE Controller."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from homeassistant.core import HomeAssistant
from .const import ENTITY_MAP_FILE
def _load(path: Path) -> dict[str, str]:
if not path.exists():
return {}
with path.open("r", encoding="utf-8") as handle:
data: Any = json.load(handle)
items = data.get("entities", []) if isinstance(data, dict) else []
result: dict[str, str] = {}
for item in items:
if not isinstance(item, dict):
continue
device_id = str(item.get("device_id", "")).strip()
entity_id = str(item.get("entity_id", "")).strip()
if device_id and entity_id.startswith("climate."):
result[device_id] = entity_id
return result
async def async_load_entity_map(hass: HomeAssistant) -> dict[str, str]:
"""Load the optional mapping file without blocking Home Assistant's loop."""
path = Path(hass.config.path(ENTITY_MAP_FILE))
return await hass.async_add_executor_job(_load, path)
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

@@ -0,0 +1,9 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.13.9",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
"single_config_entry": true
}
@@ -0,0 +1,81 @@
"""Writable zone setpoint numbers for GREE Controller."""
from __future__ import annotations
from typing import Any
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create one writable target-temperature number per zone."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities = [
GreeControllerZoneTargetNumber(runtime.coordinator, str(zone["zone_id"]))
for zone in runtime.coordinator.plan.get("zones", [])
if zone.get("zone_id")
]
async_add_entities(entities)
class GreeControllerZoneTargetNumber(CoordinatorEntity[GreeControllerCoordinator], NumberEntity):
"""Zone target override controlled through the standalone service."""
_attr_has_entity_name = True
_attr_name = "Target temperature"
_attr_device_class = NumberDeviceClass.TEMPERATURE
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_native_min_value = 8.0
_attr_native_max_value = 30.0
_attr_native_step = 0.5
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-target-temperature"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def native_value(self) -> float | None:
value = self._zone.get("target_temperature")
if value is None:
value = self._zone.get("setpoint")
return float(value) if value is not None else None
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
async def async_set_native_value(self, value: float) -> None:
await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(value)})
@@ -0,0 +1,254 @@
"""Whole-house, zone and climate-group thermostat selectors for GREE Controller."""
from __future__ import annotations
from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
HOUSE_MODE_LABELS = {
"off": "Do not control",
"cool": "Cooling",
"heat": "Heating",
}
HOUSE_MODE_VALUES = {label: value for value, label in HOUSE_MODE_LABELS.items()}
HOUSE_PRESET_LABELS = {
"auto": "Auto schedule",
"comfort": "Comfort",
"sleep": "Sleep",
"away": "Away",
}
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,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create whole-house, zone and climate-group thermostat selectors."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities: list[SelectEntity] = [
GreeControllerHouseModeSelect(runtime.coordinator),
GreeControllerHousePresetSelect(runtime.coordinator),
]
for zone in runtime.coordinator.plan.get("zones", []):
zone_id = str(zone.get("zone_id") or "").strip()
if zone_id:
entities.append(GreeControllerZonePresetSelect(runtime.coordinator, zone_id))
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):
"""Base for controller-level selectors."""
_attr_has_entity_name = True
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, "controller")},
name="GREE Controller",
manufacturer="GREE Controller",
model="Local controller",
)
class GreeControllerHouseModeSelect(_GreeControllerHouseSelect):
"""Select the smart thermostat house mode."""
_attr_name = "Thermostat mode"
_attr_unique_id = "house-thermostat-mode"
_attr_options = list(HOUSE_MODE_VALUES)
@property
def current_option(self) -> str | None:
value = str(self.coordinator.plan.get("house_mode") or "")
return HOUSE_MODE_LABELS.get(value)
async def async_select_option(self, option: str) -> None:
value = HOUSE_MODE_VALUES.get(option)
if value is None:
return
await self.coordinator.client.house_control(value)
await self.coordinator.async_request_refresh()
class GreeControllerHousePresetSelect(_GreeControllerHouseSelect):
"""Select the whole-house thermostat work profile."""
_attr_name = "Work profile"
_attr_unique_id = "house-work-profile"
_attr_options = list(HOUSE_PRESET_VALUES)
@property
def current_option(self) -> str | None:
value = self.coordinator.plan.get("house_preset")
return HOUSE_PRESET_LABELS.get(str(value)) if value is not None else None
@property
def extra_state_attributes(self) -> dict[str, Any]:
return {"mixed_zone_profiles": self.coordinator.plan.get("house_preset") is None}
async def async_select_option(self, option: str) -> None:
value = HOUSE_PRESET_VALUES.get(option)
if value is None:
return
await self.coordinator.client.house_preset(value)
await self.coordinator.async_request_refresh()
class GreeControllerZonePresetSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
"""Select the temporary work profile for one thermostat zone."""
_attr_has_entity_name = True
_attr_name = "Work profile"
_attr_options = list(HOUSE_PRESET_VALUES)
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-work-profile"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def current_option(self) -> str | None:
value = str(self._zone.get("preset_override") or "auto")
return HOUSE_PRESET_LABELS.get(value)
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
return {"active_profile": self._zone.get("preset")}
async def async_select_option(self, option: str) -> None:
value = HOUSE_PRESET_VALUES.get(option)
if value is None:
return
await self.coordinator.async_zone_control(self._zone_id, {"preset": value})
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()
@@ -0,0 +1,206 @@
"""House, zone and climate-group plan sensors exposed by GREE Controller."""
from __future__ import annotations
from typing import Any
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create whole-house and per-zone automation-plan sensors."""
runtime: GreeControllerRuntimeData = entry.runtime_data
entities: list[SensorEntity] = [GreeControllerHousePlanSensor(runtime.coordinator)]
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)
class GreeControllerHousePlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity):
"""Summary of the current whole-house control plan."""
_attr_has_entity_name = True
_attr_name = "Automation plan"
_attr_unique_id = "house-control-plan"
_unrecorded_attributes = frozenset({"next_events", "rules"})
@property
def native_value(self) -> str:
return str(self.coordinator.plan.get("house_mode") or "unknown")
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, "controller")},
name="GREE Controller",
manufacturer="GREE Controller",
model="Local controller",
)
@property
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"),
"control_strategy": plan.get("control_strategy"),
"work_profile": plan.get("house_preset"),
"all_units_powered": bool(self.coordinator.data) and all(
bool(device.get("power")) for device in self.coordinator.data.values() if device.get("enabled", True)
),
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
"effective_enabled_zones": sum(bool(zone.get("effective_enabled", zone.get("enabled"))) for zone in zones),
"demanding_zones": sum(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", []),
}
class GreeControllerZonePlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity):
"""Readable automation plan for one controller zone."""
_attr_has_entity_name = True
_attr_name = "Control plan"
_unrecorded_attributes = frozenset({"next_events"})
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-control-plan"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def native_value(self) -> str:
zone = self._zone
if not zone.get("enabled", False):
return "disabled"
if not zone.get("effective_enabled", True):
return "blocked"
return "requesting" if zone.get("demand", False) else "satisfied"
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
@property
def extra_state_attributes(self) -> dict[str, Any]:
zone = self._zone
return {
"zone_id": self._zone_id,
"device_id": zone.get("device_id"),
"device_name": zone.get("device_name"),
"enabled": bool(zone.get("enabled", False)),
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
"mode": zone.get("mode"),
"preset": zone.get("preset"),
"preset_override": zone.get("preset_override"),
"current_temperature": zone.get("current_temperature"),
"target_temperature": zone.get("target_temperature"),
"device_setpoint": zone.get("device_setpoint"),
"control_source": zone.get("control_source"),
"manual_override_until": zone.get("manual_override_until"),
"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 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", []),
}
@@ -0,0 +1,234 @@
"""Writable house, group, zone and device switches for GREE Controller."""
from __future__ import annotations
from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import GreeControllerRuntimeData
from .const import DOMAIN
from .coordinator import GreeControllerCoordinator
DEVICE_FEATURE_SWITCHES = [
("light", "supports_light", "Panel light"),
("quiet", "supports_quiet", "Quiet"),
("turbo", "supports_turbo", "Turbo"),
("xfan", "supports_xfan", "X-FAN"),
("air", "supports_air", "Air"),
("health", "supports_health", "Health"),
("sleep", "supports_sleep", "Sleep"),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create one enabled switch per controller zone."""
runtime: GreeControllerRuntimeData = entry.runtime_data
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")
],
*[
GreeControllerGroupPowerSwitch(runtime.coordinator, group_id)
for group_id in runtime.coordinator.groups
],
]
for device_id, device in runtime.coordinator.data.items():
for field, support_field, name in DEVICE_FEATURE_SWITCHES:
if device.get(support_field) is True:
entities.append(GreeControllerDeviceFeatureSwitch(runtime.coordinator, device_id, field, support_field, name))
async_add_entities(entities)
class GreeControllerHousePowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Master power action for all enabled indoor units."""
_attr_has_entity_name = True
_attr_name = "All air conditioners"
_attr_unique_id = "house-power-all"
@property
def is_on(self) -> bool:
devices = [device for device in self.coordinator.data.values() if device.get("enabled", True)]
return bool(devices) and all(bool(device.get("power")) for device in devices)
@property
def device_info(self) -> DeviceInfo:
return DeviceInfo(
identifiers={(DOMAIN, "controller")},
name="GREE Controller",
manufacturer="GREE Controller",
model="Local controller",
)
async def async_turn_on(self, **kwargs: Any) -> None:
await self.coordinator.client.house_power(True)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.client.house_power(False)
await self.coordinator.async_request_refresh()
class GreeControllerGroupPowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Power a controller climate group on or off."""
_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."""
_attr_has_entity_name = True
_attr_name = "Enabled"
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
super().__init__(coordinator)
self._zone_id = zone_id
self._attr_unique_id = f"{zone_id}-enabled"
@property
def _zone(self) -> dict[str, Any]:
for zone in self.coordinator.plan.get("zones", []):
if str(zone.get("zone_id")) == self._zone_id:
return zone
return {}
@property
def available(self) -> bool:
return super().available and bool(self._zone)
@property
def is_on(self) -> bool:
return bool(self._zone.get("enabled", False))
@property
def device_info(self) -> DeviceInfo:
zone = self._zone
return DeviceInfo(
identifiers={(DOMAIN, f"zone:{self._zone_id}")},
name=str(zone.get("zone_name") or self._zone_id),
manufacturer="GREE Controller",
model="Zone thermostat",
via_device=(DOMAIN, "controller"),
)
async def async_turn_on(self, **kwargs: Any) -> None:
await self.coordinator.async_zone_control(self._zone_id, {"enabled": True})
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.async_zone_control(self._zone_id, {"enabled": False})
class GreeControllerDeviceFeatureSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
"""Optional GREE unit feature exposed only when the controller detected it."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: GreeControllerCoordinator,
device_id: str,
field: str,
support_field: str,
name: str,
) -> None:
super().__init__(coordinator)
self._device_id = device_id
self._field = field
self._support_field = support_field
self._attr_name = name
self._attr_unique_id = f"{device_id}-{field}"
@property
def _device(self) -> dict[str, Any]:
return self.coordinator.data.get(self._device_id, {})
@property
def available(self) -> bool:
return (
super().available
and bool(self._device.get("online", False))
and self._device.get(self._support_field) is True
)
@property
def is_on(self) -> bool:
return bool(self._device.get(self._field, False))
@property
def device_info(self) -> DeviceInfo:
device = self._device
return DeviceInfo(
identifiers={(DOMAIN, self._device_id)},
name=str(device.get("name") or self._device_id),
manufacturer="GREE",
model=str(device.get("model") or "GREE HVAC"),
sw_version=str(device.get("firmware") or "") or None,
)
async def _set(self, value: bool) -> None:
await self.coordinator.async_device_command(self._device_id, {self._field: value})
async def async_turn_on(self, **kwargs: Any) -> None:
await self._set(True)
async def async_turn_off(self, **kwargs: Any) -> None:
await self._set(False)
@@ -0,0 +1,22 @@
{
"title": "GREE Controller",
"config": {
"step": {
"user": {
"title": "Connect to GREE Controller",
"description": "Connect Home Assistant to the standalone Rust controller. Device commands will be proxied through the controller instead of the built-in GREE integration.",
"data": {
"url": "Controller URL",
"token": "API token"
}
}
},
"error": {
"cannot_connect": "Cannot connect to GREE Controller",
"invalid_auth": "Invalid controller API token"
},
"abort": {
"already_configured": "GREE Controller is already configured"
}
}
}
@@ -0,0 +1,22 @@
{
"title": "GREE Controller",
"config": {
"step": {
"user": {
"title": "Połącz z GREE Controller",
"description": "Połącz Home Assistant z niezależnym kontrolerem Rust. Polecenia urządzeń będą przechodziły przez kontroler zamiast wbudowanej integracji GREE.",
"data": {
"url": "Adres URL kontrolera",
"token": "Token API"
}
}
},
"error": {
"cannot_connect": "Nie można połączyć się z GREE Controller",
"invalid_auth": "Nieprawidłowy token API kontrolera"
},
"abort": {
"already_configured": "GREE Controller jest już skonfigurowany"
}
}
}
@@ -0,0 +1,9 @@
{
"version": 1,
"entities": [
{
"entity_id": "climate.klima_salon",
"device_id": "gree-aabbccddeeff"
}
]
}
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [[ -f "$SCRIPT_DIR/.env" ]]; then
set -a
# shellcheck disable=SC1091
source "$SCRIPT_DIR/.env"
set +a
fi
HA_REPO_PATH="${HA_REPO_PATH:-../gree-controller-ha-addon}"
HA_REPO_BRANCH="${HA_REPO_BRANCH:-master}"
if [[ "$HA_REPO_PATH" != /* ]]; then
HA_REPO_PATH="$PROJECT_ROOT/$HA_REPO_PATH"
fi
HA_REPO_PATH="$(realpath -m "$HA_REPO_PATH")"
VERSION="$(
awk -F '"' '/^version = "/ {print $2; exit}' \
"$PROJECT_ROOT/Cargo.toml"
)"
[[ -n "$VERSION" ]] || {
echo "Cannot determine version from Cargo.toml" >&2
exit 1
}
command -v git >/dev/null 2>&1 || {
echo "git is required" >&2
exit 1
}
command -v rsync >/dev/null 2>&1 || {
echo "rsync is required" >&2
exit 1
}
[[ -d "$HA_REPO_PATH/.git" ]] || {
echo "HA_REPO_PATH is not a Git repository: $HA_REPO_PATH" >&2
exit 1
}
echo "==> Building and pushing OCI image ${VERSION}"
"$SCRIPT_DIR/build.sh" push
echo "==> Syncing Home Assistant repository"
rsync -a --delete \
--exclude='.git/' \
"$SCRIPT_DIR/repository/" \
"$HA_REPO_PATH/"
cd "$HA_REPO_PATH"
echo "==> Updating branch ${HA_REPO_BRANCH}"
git checkout "$HA_REPO_BRANCH"
git add -A
if git diff --cached --quiet; then
echo "No Home Assistant repository changes to publish."
exit 0
fi
echo "==> Committing Home Assistant add-on ${VERSION}"
git commit -m "GREE Controller ${VERSION}"
echo "==> Pushing to Gitea"
git push origin "$HA_REPO_BRANCH"
echo
echo "Published:"
echo " image version: ${VERSION}"
echo " HA repository: ${HA_REPO_PATH}"
echo " branch: ${HA_REPO_BRANCH}"
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG="$SCRIPT_DIR/repository/gree-controller/config.yaml"
LOCKFILE="$PROJECT_ROOT/Cargo.lock"
REGISTRY="${REGISTRY:-oci.linuxiarz.pl}"
IMAGE_NAME="${IMAGE_NAME:-gree-controller}"
VERSION="$(awk -F '"' '/^version = "/ {print $2; exit}' "$PROJECT_ROOT/Cargo.toml")"
[[ -n "$VERSION" ]] || { echo "Cannot determine version from Cargo.toml" >&2; exit 1; }
IMAGE_REPO="${REGISTRY%/}/${IMAGE_NAME#/}"
python3 - "$CONFIG" "$LOCKFILE" "$VERSION" "$IMAGE_REPO" <<'PY2'
from pathlib import Path
import re
import sys
config = Path(sys.argv[1])
lockfile = Path(sys.argv[2])
version = sys.argv[3]
image = sys.argv[4]
# Home Assistant metadata is generated from the single version source: Cargo.toml.
lines = config.read_text(encoding="utf-8").splitlines()
out = []
for line in lines:
if line.startswith("version:"):
out.append(f'version: "{version}"')
elif line.startswith("image:"):
out.append(f'image: "{image}"')
else:
out.append(line)
config.write_text("\n".join(out) + "\n", encoding="utf-8")
# Keep Cargo.lock in sync so `cargo build --locked` remains usable after a version bump.
lock = lockfile.read_text(encoding="utf-8")
pattern = r'(\[\[package\]\]\nname = "gree-controller"\nversion = ")[^"]+("\n)'
updated, count = re.subn(pattern, rf'\g<1>{version}\g<2>', lock, count=1)
if count != 1:
raise SystemExit("Could not find gree-controller package version in Cargo.lock")
lockfile.write_text(updated, encoding="utf-8")
PY2
printf 'Synced version %s -> Cargo.lock + Home Assistant metadata; image %s:%s\n' \
"$VERSION" "$IMAGE_REPO" "$VERSION"
@@ -1,12 +1,8 @@
# Changelog
Release version is maintained only in the project root `Cargo.toml`. Home Assistant metadata is generated by `ha-addon/build.sh` / `ha-addon/render-repository.sh`.
## 0.13.9
## Current
- Home Assistant add-on packaging.
- amd64 and aarch64 in one OCI multi-arch manifest.
- Host-network operation for GREE UDP discovery and multi-interface hosts.
- Home Assistant ingress support.
- Polish and English topology diagrams and networking documentation.
- Debian Trixie or latest Alpine build/runtime target.
- Home Assistant add-on with `amd64` + `aarch64` multi-arch OCI image.
- Host networking for GREE UDP discovery and multi-interface/VLAN hosts.
- Home Assistant ingress, watchdog and cold backup support.
- Polish and English documentation consolidated into one `DOCS.md`.
+92 -31
View File
@@ -1,27 +1,90 @@
# GREE Controller - Home Assistant add-on
# GREE Controller Home Assistant add-on
## Installation
Repozytorium: `https://git.linuxiarz.pl/gru/gree-controller-ha-addon/`
Obraz OCI: `zot.linuxiarz.pl/gree-controller:<version>`
Publish the `ha-addon/repository` directory as a Git repository. In Home Assistant open the app/add-on store, add that Git repository URL as a custom repository, refresh the store and install **GREE Controller**.
---
The repository metadata points to a pre-built multi-architecture OCI image. The image tag must be identical to `version` in `config.yaml`.
## PL
## Topology diagrams
### Instalacja
### English
W Home Assistant dodaj jako własne repozytorium:
![Home Assistant OS + GREE Controller + VLAN topology](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/media/branch/master/gree-controller/topology-ha-vlan-gree-en.png)
```text
https://git.linuxiarz.pl/gru/gree-controller-ha-addon/
```
### Polish
Odśwież sklep dodatków/aplikacji i zainstaluj **GREE Controller**. `config.yaml` wskazuje gotowy wieloarchitekturowy obraz OCI; jego tag musi być równy polu `version`.
### Sieć, VLAN i discovery
![Topologia Home Assistant OS + GREE Controller + VLAN](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topologia-ha-vlan-gree-pl.png)
Dodatek celowo używa `host_network: true`, więc korzysta bezpośrednio z interfejsów hosta Home Assistant OS. Nie twórz dla niego Docker `macvlan`.
## Network model
Interfejsy hosta:
The add-on deliberately uses `host_network: true`. Do not attach Docker `macvlan` networks to the add-on. With host networking, the controller shares the Home Assistant host network namespace and sees host ethernet/VLAN interfaces directly.
```bash
ha network info
```
Configure physical NICs and VLANs on Home Assistant OS first. Check the current host interfaces with:
Przykład VLAN 50 na `eth0`, bez dodatkowej bramy domyślnej:
```bash
ha network vlan eth0 50 \
--ipv4-method static \
--ipv4-address 192.168.50.2/24 \
--ipv6-method disabled
```
Dla jednej sieci GREE ustaw `gree_interface` na nazwę interfejsu (np. `eth0.50`) albo jego lokalny IPv4 (np. `192.168.50.2`) oraz `discovery_broadcast` na broadcast tej podsieci, np. `192.168.50.255:7000`.
Dla wielu bezpośrednio podłączonych podsieci pozostaw `gree_interface` puste. Kontroler dobiera lokalny interfejs najlepiej pasujący do IP znanego urządzenia. Discovery nadal wysyła jeden broadcast na skan, więc każdą podsieć/VLAN skanuj osobno, zmieniając `discovery_broadcast`, albo dodaj znane urządzenia ręcznie.
Broadcast zwykle nie przechodzi przez router. Sterowanie unicast może działać przez routing/firewall, jeżeli UDP jest dozwolone. Gdy urządzenia są w routowanym VLAN-ie, zapewnij hostowi HA interfejs w tej sieci, relay broadcast UDP albo wykonuj discovery lokalnie dla każdej podsieci.
### Opcje
| Opcja | Znaczenie |
|---|---|
| `gree_interface` | interfejs lub lokalny IPv4; puste = automatyczny dobór trasy |
| `discovery_broadcast` | cel discovery UDP, np. `192.168.50.255:7000` |
| `simulate` | praca bez fizycznych urządzeń |
| `auto_seed` | przykładowe urządzenie w pustej bazie symulatora |
| `poll_interval_seconds` | interwał odpytywania urządzeń |
| `zone_interval_seconds` | interwał sterowania strefami/termostatem |
| `discovery_timeout_ms` | timeout discovery UDP |
| `app_token` | opcjonalna ochrona bezpośredniego Web UI/API |
| `log_level` | `error`, `warn`, `info`, `debug`, `trace` |
### Dostęp, dane i bezpieczeństwo
Usługa słucha na TCP `8787`; ingress Home Assistant przekazuje Web UI na ten port. Jeżeli `8787` jest osiągalny z niezaufanej sieci, ustaw `app_token` albo zablokuj port firewallem. Baza jest zapisywana w `/data/gree-controller.db`; konfiguracja używa `backup: cold`, więc dane są objęte backupem dodatku.
Watchdog sprawdza `/api/health`. Do diagnostyki sieci najpierw sprawdź `ha network info`, poprawność `gree_interface`, broadcast konkretnej podsieci i reguły UDP/firewalla.
---
## EN
### Installation
Add this custom repository in Home Assistant:
```text
https://git.linuxiarz.pl/gru/gree-controller-ha-addon/
```
Refresh the app/add-on store and install **GREE Controller**. `config.yaml` points to a pre-built multi-architecture OCI image; its tag must match `version`.
### Networking, VLANs and discovery
![Home Assistant OS + GREE Controller + VLAN topology](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topology-ha-vlan-gree-en.png)
The add-on intentionally uses `host_network: true`, so it uses the Home Assistant OS host interfaces directly. Do not attach a Docker `macvlan` network to the add-on.
Inspect host interfaces with:
```bash
ha network info
@@ -36,30 +99,28 @@ ha network vlan eth0 50 \
--ipv6-method disabled
```
Then use one of these add-on configurations:
For one GREE subnet, set `gree_interface` to the host interface name (for example `eth0.50`) or its local IPv4 address (for example `192.168.50.2`), and set `discovery_broadcast` to that subnet broadcast, e.g. `192.168.50.255:7000`.
### One dedicated GREE interface
For multiple directly attached subnets, leave `gree_interface` empty. The controller selects the local interface that best matches a known device IP. Discovery still sends one broadcast per scan, so scan each VLAN/subnet separately by changing `discovery_broadcast`, or add known devices manually.
Set `gree_interface` to the host interface name, for example `eth0.50`, or to its local IPv4 address, for example `192.168.50.2`. Set `discovery_broadcast` to the subnet broadcast, for example `192.168.50.255:7000`.
Broadcast normally does not cross routers. Unicast control may work through routing/firewall rules when UDP is allowed. For routed GREE VLANs, give the HA host an interface in the VLAN, use a suitable UDP broadcast relay, or perform discovery locally per subnet.
### Several host interfaces / several directly attached subnets
### Options
Leave `gree_interface` empty. For already known devices the controller chooses the local IPv4 interface whose subnet best matches the device IP. This allows normal polling and commands to devices on different directly attached networks.
| Option | Meaning |
|---|---|
| `gree_interface` | interface or local IPv4; empty = automatic route selection |
| `discovery_broadcast` | UDP discovery target, e.g. `192.168.50.255:7000` |
| `simulate` | run without physical devices |
| `auto_seed` | create a sample device in an empty simulator database |
| `poll_interval_seconds` | device polling interval |
| `zone_interval_seconds` | thermostat/zone control interval |
| `discovery_timeout_ms` | UDP discovery timeout |
| `app_token` | optional protection for direct Web UI/API access |
| `log_level` | `error`, `warn`, `info`, `debug`, `trace` |
Discovery itself sends one broadcast target per scan. To discover devices on several VLANs, scan each subnet separately by supplying its broadcast address (for example `192.168.50.255:7000`, then `192.168.60.255:7000`) or add known devices manually. After discovery, normal traffic can use automatic per-target interface selection.
### Access, data and security
Do not use `discovery_broadcast=auto` when `gree_interface` is empty; automatic broadcast calculation needs an explicitly selected interface.
The service listens on TCP `8787`; Home Assistant ingress proxies the Web UI to that port. If `8787` is reachable from an untrusted network, configure `app_token` or block the port at the firewall. The database is stored in `/data/gree-controller.db`; `backup: cold` keeps it in the add-on backup.
## Routed VLANs
Unicast UDP commands can traverse routing/firewall rules if permitted. Discovery is broadcast-based and normally does not cross a router. Either give the Home Assistant host an interface in the GREE VLAN, use a suitable UDP broadcast relay, or discover/add devices from each local subnet explicitly.
## Docker limitations
Home Assistant add-on configuration supports host networking, but not arbitrary user-defined Docker networks/macvlan attachments. Giving an add-on Docker API/full host access just to create extra container interfaces is unnecessary here and would substantially weaken isolation. Configure the VLANs/NICs on the HA OS host and let this add-on share them through host networking.
## Direct access and ingress
The service listens on TCP 8787 because the same host network is needed for GREE UDP. Home Assistant ingress proxies the Web UI to that port. If TCP 8787 is reachable from untrusted networks, configure `app_token` or block that port at the network/firewall boundary.
The standalone Home Assistant custom integration can still use the controller API exactly as before; point it at an address that Home Assistant Core can reach and use an integration token generated by GREE Controller.
The watchdog checks `/api/health`. For network troubleshooting, verify `ha network info`, `gree_interface`, the selected subnet broadcast, and UDP/firewall rules first.
@@ -1,65 +0,0 @@
# GREE Controller - dodatek Home Assistant
## Instalacja
Opublikuj katalog `ha-addon/repository` jako repozytorium Git. W Home Assistant otwórz sklep dodatków/aplikacji, dodaj URL tego repozytorium jako repozytorium niestandardowe, odśwież listę i zainstaluj **GREE Controller**.
Metadane repozytorium wskazują na gotowy wieloarchitekturny obraz OCI. Tag obrazu musi być taki sam jak `version` w `config.yaml`.
## Diagramy topologii
### Polski
![Topologia Home Assistant OS + GREE Controller + VLAN](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topologia-ha-vlan-gree-pl.png)
### English
![Home Assistant OS + GREE Controller + VLAN topology](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/media/branch/master/gree-controller/topology-ha-vlan-gree-en.png)
## Model sieciowy
Dodatek celowo używa `host_network: true`. Nie podpinaj do dodatku sieci Docker `macvlan`. Przy host networking kontroler współdzieli przestrzeń sieciową hosta Home Assistanta i widzi bezpośrednio interfejsy ethernet/VLAN hosta.
Najpierw skonfiguruj fizyczne NIC-i i VLAN-y w Home Assistant OS. Aktualne interfejsy hosta sprawdzisz poleceniem:
```bash
ha network info
```
Przykład VLAN 50 na `eth0`, bez dodawania kolejnej domyślnej bramy:
```bash
ha network vlan eth0 50 \
--ipv4-method static \
--ipv4-address 192.168.50.2/24 \
--ipv6-method disabled
```
Następnie użyj jednej z tych konfiguracji dodatku:
### Jeden dedykowany interfejs GREE
Ustaw `gree_interface` na nazwę interfejsu hosta, np. `eth0.50`, albo na jego lokalny adres IPv4, np. `192.168.50.2`. Ustaw `discovery_broadcast` na broadcast tej podsieci, np. `192.168.50.255:7000`.
### Kilka interfejsów hosta / kilka bezpośrednio podłączonych podsieci
Pozostaw `gree_interface` puste. Dla znanych urządzeń kontroler wybiera lokalny interfejs IPv4, którego podsieć najlepiej pasuje do adresu IP urządzenia. Dzięki temu normalne odpytywanie i sterowanie działa dla urządzeń w różnych bezpośrednio podłączonych sieciach.
Samo discovery wysyła jeden broadcast na skan. Aby wykrywać urządzenia w kilku VLAN-ach, skanuj każdą podsieć osobno, podając jej adres broadcast (np. `192.168.50.255:7000`, potem `192.168.60.255:7000`) albo dodaj znane urządzenia ręcznie. Po discovery zwykły ruch może działać z automatycznym doborem interfejsu dla celu.
Nie używaj `discovery_broadcast=auto`, gdy `gree_interface` jest puste; automatyczne wyliczenie broadcastu wymaga jawnie wybranego interfejsu.
## VLAN-y routowane
Polecenia unicast UDP mogą przechodzić przez routing/reguły firewalla, jeśli jest to dozwolone. Discovery jest oparte na broadcast i zwykle nie przechodzi przez router. Albo daj hostowi Home Assistant interfejs w VLAN-ie GREE, albo użyj odpowiedniego relaya UDP broadcast, albo wykrywaj/dodawaj urządzenia osobno z każdej lokalnej podsieci.
## Ograniczenia Dockera
Konfiguracja dodatków Home Assistant wspiera host networking, ale nie dowolne, własne sieci Docker/macvlan przypinane do dodatku. Nadawanie dodatkowi pełnego dostępu do hosta lub API Dockera tylko po to, aby tworzyć dodatkowe interfejsy kontenera, nie jest tu potrzebne i znacząco osłabia izolację. Skonfiguruj VLAN-y/NIC-e na hoście HA OS i pozwól temu dodatkowi współdzielić je przez host networking.
## Bezpośredni dostęp i ingress
Usługa nasłuchuje na TCP 8787, ponieważ ten sam host network jest potrzebny dla GREE UDP. Home Assistant ingress proxy przekazuje Web UI na ten port. Jeżeli TCP 8787 jest osiągalny z niezaufanych sieci, skonfiguruj `app_token` albo zablokuj ten port na granicy sieci/firewalla.
Samodzielna integracja Home Assistant może nadal używać API kontrolera jak wcześniej; wskaż adres osiągalny z Home Assistant Core i użyj tokenu integracji wygenerowanego przez GREE Controller.
+5 -27
View File
@@ -1,35 +1,13 @@
# GREE Controller
Local GREE HVAC controller packaged as a Home Assistant add-on. It uses `host_network: true` so UDP discovery and the host VLAN/ethernet interfaces are directly available to the controller. Persistent data is stored in `/data/gree-controller.db` and is included in Home Assistant backups.
Local GREE HVAC controller packaged as a Home Assistant add-on, with Web UI, local API, UDP discovery/control, `amd64` and `aarch64` support, ingress, and database backup from `/data/gree-controller.db`.
## PL - topologia HA / VLAN / GREE
![Home Assistant OS + GREE + VLAN topology](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topology-ha-vlan-gree-en.png)
![Topologia Home Assistant OS + GREE Controller + VLAN](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/raw/branch/master/gree-controller/topologia-ha-vlan-gree-pl.png)
Najważniejsze:
- VLAN-y konfigurujesz na hoście Home Assistant OS,
- dodatek współdzieli interfejsy hosta przez `host_network`,
- nie trzeba tworzyć Docker `macvlan`,
- przy wielu bezpośrednio podłączonych podsieciach `gree_interface` może pozostać puste,
- discovery UDP broadcast wykonuj osobno dla każdego VLAN-u/podsieci.
Pełna dokumentacja: [DOCS.pl.md](./DOCS.pl.md).
## EN - HA / VLAN / GREE topology
![Home Assistant OS + GREE Controller + VLAN topology](https://git.linuxiarz.pl/gru/gree-controller-ha-addon/media/branch/master/gree-controller/topology-ha-vlan-gree-en.png)
Key points:
- configure VLANs on the Home Assistant OS host,
- the add-on shares host interfaces via `host_network`,
- Docker `macvlan` is not required,
- with multiple directly attached subnets `gree_interface` may be left empty,
- run UDP broadcast discovery separately for each VLAN/subnet.
For VLAN deployments, configure VLAN interfaces on the Home Assistant OS host. The add-on uses `host_network: true`; with multiple directly attached subnets, `gree_interface` may remain empty. Run broadcast discovery separately for each VLAN/subnet.
Full documentation: [DOCS.md](./DOCS.md).
## Versioning
The same Git repository also contains the optional `custom_components/gree_controller` integration. It is distributed with the repository but is not installed automatically with the add-on.
The project version is maintained only in the root `Cargo.toml`. The build script copies it into the Home Assistant `config.yaml` and publishes one `amd64 + aarch64` OCI manifest under that version. No separate architecture release tags are used.
Repository: https://git.linuxiarz.pl/gru/gree-controller-ha-addon/
@@ -2,6 +2,7 @@ name: "GREE Controller"
version: "0.13.9"
slug: "gree_controller"
description: "Local GREE HVAC controller with Web UI and Home Assistant integration"
url: "https://git.linuxiarz.pl/gru/gree-controller-ha-addon/"
arch:
- amd64
- aarch64
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

+1
View File
@@ -1,2 +1,3 @@
name: GREE Controller
url: https://git.linuxiarz.pl/gru/gree-controller-ha-addon/
maintainer: linuxiarz.pl
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="${HA_ADDON_ENV_FILE:-$SCRIPT_DIR/.env}"
[[ -f "$ENV_FILE" ]] || {
echo "Missing $ENV_FILE. Copy $SCRIPT_DIR/.env.example to $SCRIPT_DIR/.env and edit it." >&2
exit 1
}
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
required=(HA_REPO_URL HA_REPO_BRANCH HA_REPO_PATH ADDON_DIR INTEGRATION_DOMAIN)
for name in "${required[@]}"; do
[[ -n "${!name:-}" ]] || { echo "Missing $name in $ENV_FILE" >&2; exit 1; }
done
command -v git >/dev/null 2>&1 || { echo "git is required" >&2; exit 1; }
command -v rsync >/dev/null 2>&1 || { echo "rsync is required" >&2; exit 1; }
if [[ "$HA_REPO_PATH" != /* ]]; then
HA_REPO_PATH="$PROJECT_ROOT/$HA_REPO_PATH"
fi
HA_REPO_PATH="$(realpath -m "$HA_REPO_PATH")"
INTEGRATION_SOURCE="$SCRIPT_DIR/home-assistant/custom_components/$INTEGRATION_DOMAIN"
[[ -d "$SCRIPT_DIR/repository/$ADDON_DIR" ]] || { echo "Missing add-on source" >&2; exit 1; }
[[ -d "$INTEGRATION_SOURCE" ]] || { echo "Missing integration source: $INTEGRATION_SOURCE" >&2; exit 1; }
"$SCRIPT_DIR/build.sh" render
if [[ ! -e "$HA_REPO_PATH" ]]; then
git clone --branch "$HA_REPO_BRANCH" "$HA_REPO_URL" "$HA_REPO_PATH"
elif [[ ! -d "$HA_REPO_PATH/.git" ]]; then
echo "HA_REPO_PATH exists but is not a Git repository: $HA_REPO_PATH" >&2
exit 1
fi
if [[ -n "$(git -C "$HA_REPO_PATH" status --porcelain)" ]]; then
echo "HA repository has uncommitted changes: $HA_REPO_PATH" >&2
exit 1
fi
git -C "$HA_REPO_PATH" checkout "$HA_REPO_BRANCH"
git -C "$HA_REPO_PATH" pull --ff-only origin "$HA_REPO_BRANCH"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
rsync -a "$SCRIPT_DIR/repository/" "$STAGE/"
mkdir -p "$STAGE/custom_components/$INTEGRATION_DOMAIN"
rsync -a "$INTEGRATION_SOURCE/" "$STAGE/custom_components/$INTEGRATION_DOMAIN/"
rsync -a --delete --exclude='.git/' "$STAGE/" "$HA_REPO_PATH/"
git -C "$HA_REPO_PATH" add -A
if git -C "$HA_REPO_PATH" diff --cached --quiet; then
echo "HA repository is already synchronized."
exit 0
fi
VERSION="$(awk -F '"' '/^version = "/ {print $2; exit}' "$PROJECT_ROOT/Cargo.toml")"
[[ -n "$VERSION" ]] || { echo "Cannot determine version from Cargo.toml" >&2; exit 1; }
git -C "$HA_REPO_PATH" commit -m "GREE Controller ${VERSION}"
git -C "$HA_REPO_PATH" push origin "$HA_REPO_BRANCH"
printf 'Synchronized add-on + custom integration -> %s (%s)\n' "$HA_REPO_URL" "$HA_REPO_BRANCH"