first commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user