v0.5.0
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user