first commit

This commit is contained in:
Mateusz Gruszczyński
2026-08-23 21:34:07 +02:00
commit 1d3dcba1a9
62 changed files with 12456 additions and 0 deletions
@@ -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,76 @@
"""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 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,
)
@@ -0,0 +1,204 @@
"""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 . 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[GreeControllerClimate] = []
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)
)
async_add_entities(entities)
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 = 32.0
_attr_target_temperature_step = 0.5
_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 = None
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)),
"last_seen": device.get("last_seen"),
}
async def _command(self, payload: dict[str, Any]) -> None:
await self.coordinator.client.command(self._device_id, payload)
await self.coordinator.async_request_refresh()
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})
@@ -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]
CONF_URL = "url"
CONF_TOKEN = "token"
ENTITY_MAP_FILE = "gree_controller_entities.json"
DEFAULT_SCAN_INTERVAL_SECONDS = 10
@@ -0,0 +1,36 @@
"""Data coordinator for the GREE Controller integration."""
from __future__ import annotations
from datetime import timedelta
import logging
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
async def _async_update_data(self) -> dict[str, dict]:
try:
devices = await self.client.devices()
except GreeControllerApiError as err:
raise UpdateFailed(str(err)) from err
return {str(device["id"]): device for device in devices if device.get("id")}
@@ -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)
@@ -0,0 +1,9 @@
{
"domain": "gree_controller",
"name": "GREE Controller",
"version": "0.3.3",
"config_flow": true,
"integration_type": "hub",
"iot_class": "local_polling",
"single_config_entry": true
}
@@ -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"
}
}
}