first commit
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# Home Assistant integration
|
||||
|
||||
The project includes a custom Home Assistant integration under:
|
||||
|
||||
```text
|
||||
home-assistant/custom_components/gree_controller/
|
||||
```
|
||||
|
||||
It creates HA `climate` entities but sends every command to the standalone Rust controller. Home Assistant therefore becomes a client, while UDP/AES GREE communication remains outside HA.
|
||||
|
||||
The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing.
|
||||
|
||||
## Install the custom integration
|
||||
|
||||
Copy the directory into your HA configuration:
|
||||
|
||||
```text
|
||||
/config/custom_components/gree_controller/
|
||||
```
|
||||
|
||||
Before adding the integration, open the standalone controller Web UI and go to **Settings -> Home Assistant integration access -> 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 and sending climate 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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"entities": [
|
||||
{
|
||||
"entity_id": "climate.klima_salon",
|
||||
"device_id": "gree-aabbccddeeff"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user