This commit is contained in:
Mateusz Gruszczyński
2026-08-24 14:05:43 +02:00
parent eb02f66056
commit 04fc91b9f4
30 changed files with 2257 additions and 175 deletions
+10 -1
View File
@@ -10,6 +10,15 @@ It creates HA `climate` entities but sends every command to the standalone Rust
The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing.
Version 0.5.0 additionally exposes the controller's 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:
@@ -25,7 +34,7 @@ Restart Home Assistant, then open **Settings -> Devices & services -> Add integr
- controller URL, for example `http://192.168.1.20:8787`,
- the generated controller access token.
The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices and sending climate commands.
The integration token is required even when the controller Web UI itself is left open on a trusted LAN. It is restricted to reading controller devices/control plans and sending climate, zone target or zone enable/disable commands.
## Preserve an existing entity ID
@@ -74,3 +74,21 @@ class GreeControllerClient:
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 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
@@ -3,7 +3,7 @@
from homeassistant.const import Platform
DOMAIN = "gree_controller"
PLATFORMS = [Platform.CLIMATE]
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.NUMBER, Platform.SWITCH]
CONF_URL = "url"
CONF_TOKEN = "token"
@@ -2,8 +2,10 @@
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
@@ -27,10 +29,15 @@ class GreeControllerCoordinator(DataUpdateCoordinator[dict[str, dict]]):
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS),
)
self.client = client
self.plan: dict[str, Any] = {}
async def _async_update_data(self) -> dict[str, dict]:
try:
devices = await self.client.devices()
devices, plan = await asyncio.gather(
self.client.devices(),
self.client.control_plan(),
)
except GreeControllerApiError as err:
raise UpdateFailed(str(err)) from err
self.plan = plan
return {str(device["id"]): device for device in devices if device.get("id")}
@@ -1,7 +1,7 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.4.5",
"version": "0.5.0",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
@@ -0,0 +1,80 @@
"""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")
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.client.zone_control(self._zone_id, {"setpoint": float(value)})
await self.coordinator.async_request_refresh()
@@ -0,0 +1,126 @@
"""Automation-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"])))
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", [])
return {
"generated_at": plan.get("generated_at"),
"outdoor_temperature": plan.get("outdoor_temperature"),
"control_strategy": plan.get("control_strategy"),
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
"demanding_zones": sum(bool(zone.get("enabled")) and bool(zone.get("demand")) for zone in zones),
"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"
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"),
"mode": zone.get("mode"),
"preset": zone.get("preset"),
"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", []),
}
@@ -0,0 +1,77 @@
"""Writable zone enabled 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
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 = [
GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"]))
for zone in runtime.coordinator.plan.get("zones", [])
if zone.get("zone_id")
]
async_add_entities(entities)
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.client.zone_control(self._zone_id, {"enabled": True})
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
await self.coordinator.client.zone_control(self._zone_id, {"enabled": False})
await self.coordinator.async_request_refresh()