v0.13.9-rework_ha_addon
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
# Home Assistant integration
|
||||
|
||||
The project includes a custom Home Assistant integration under:
|
||||
|
||||
```text
|
||||
ha-addon/home-assistant/custom_components/gree_controller/
|
||||
```
|
||||
|
||||
It creates HA entities for physical units, thermostat zones, whole-house controls and climate groups, while sending every command to the standalone Rust controller. Home Assistant therefore remains a client and UDP/AES GREE communication stays outside HA.
|
||||
|
||||
The climate proxy supports power/turn on/off, HVAC modes, target temperature, fan mode, vertical swing and horizontal swing.
|
||||
|
||||
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:
|
||||
|
||||
```text
|
||||
/config/custom_components/gree_controller/
|
||||
```
|
||||
|
||||
`sync-repository.sh` also publishes this integration as `custom_components/gree_controller/` in the same Git repository as the add-on. The add-on itself does not install the integration automatically.
|
||||
|
||||
Before adding the integration, open the standalone controller Web UI and go to **More -> Home Assistant / Sensors -> 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/control plans/groups and sending device, zone, whole-house or climate-group control 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.
|
||||
## Zone climate and optional unit features
|
||||
|
||||
Each controller zone is exposed as a full Home Assistant `climate` entity with current/target temperature and HVAC modes: Off, Auto (follow the controller house mode), Cool and Heat. For a **zone thermostat**, Auto does not mean the GREE unit's native automatic heat/cool algorithm: it means **inherit the whole-house Heating/Cooling selection from GREE Controller**. Direct physical-device climate entities still use the native GREE Auto mode. The target temperature remains published while a zone is Off, so Home Assistant can display the configured setpoint instead of `unknown`. The existing zone target `number` and enabled `switch` remain available for compatibility.
|
||||
|
||||
Each zone climate also supports preset modes `auto`, `comfort`, `sleep` and `away`, and the same choices are exposed as a separate **Work profile** `select` on the zone device. `auto` removes the temporary per-zone profile override and returns the zone to its schedule. The zone enable switch reports the configured zone state independently from group control. Turning a group OFF powers its member units down and releases group ownership. Members are not blocked merely because the group remains OFF: an individual thermostat can be turned back on independently. Turning the group ON clears that scoped OFF state and immediately resumes group thermostat arbitration. From version 0.7.10, climate entity ON/OFF controls local thermostat power rather than technically enabling/disabling the zone. From 0.7.12, every local OFF creates a fresh backend-owned 15-minute hand-back deadline exposed as `local_thermostat_resume_at`. A direct/pilot takeover suspends expiry of that local timer; if the unit is returned to the previous OFF state, the backend re-arms a fresh 15-minute countdown from that moment. The separate zone Enabled switch remains the technical availability switch.
|
||||
|
||||
Zone climate entities use `climate.<zone_name>_thermostat`, for example `climate.igor_thermostat`. On integration reload, existing zone climate registry entries are migrated to this scheme using the zone's current name. If the target entity ID is already occupied, the old ID is retained and Home Assistant logs a warning.
|
||||
|
||||
When the controller detects optional GREE properties, the integration also creates switches for supported features such as panel light, Quiet, Turbo, X-FAN, Air, Health and native Sleep. Reload the integration (or restart Home Assistant) after upgrading so newly added entity types are created.
|
||||
|
||||
## Whole-house controls
|
||||
|
||||
The integration also exposes three controller-level entities on the **GREE Controller** device:
|
||||
|
||||
- **Thermostat mode** (`select`) — Cooling, Heating or **Do not control**. Do not control pauses house-level thermostat commands but leaves direct device control and explicit per-zone Heat/Cool overrides untouched.
|
||||
- **Work profile** (`select`) — Auto schedule, Comfort, Sleep or Away. If zones have mixed manual profiles, the select has no single current option until a whole-house profile is chosen again.
|
||||
- Choosing **Cooling/Heating** changes the house rule for zones that inherit the global mode. **Do not control** pauses only that inherited house rule; explicit local/group/manual control remains independent.
|
||||
- **All air conditioners** (`switch`) performs bulk thermostat power control. OFF powers all enabled units down and leaves their thermostats locally OFF with no hand-back timer; later local/group/global ON can re-enable the selected scope. ON releases local OFF states and respects compressor protection. Its displayed state reflects whether all currently enabled units are physically ON.
|
||||
|
||||
These controls use the Home Assistant integration token and the dedicated `/api/integrations/home-assistant/house/*` endpoints.
|
||||
|
||||
|
||||
## Climate groups
|
||||
|
||||
Every group configured in **GREE Controller -> Groups** is published as a separate Home Assistant device. A group does not receive a fake common target temperature because its member zones can legitimately use different profile temperatures. Instead, the group device exposes the controls that exactly match the controller model:
|
||||
|
||||
- **Power** (`switch`) — enables/disables only this group.
|
||||
- **Thermostat mode** (`select`) — **Global**, Cooling or Heating. **Global** means all member zones inherit the controller-wide Heating/Cooling selection.
|
||||
- **Work profile** (`select`) — Auto schedule, Comfort, Sleep or Away.
|
||||
- **Control plan** (`sensor`) — group status plus member zone IDs/names, average current temperature, enabled/demanding zone counts, online unit count and upcoming member schedule events.
|
||||
|
||||
If member zones have been changed individually and no longer share one mode or profile, the matching select has no current common value instead of reporting a misleading state. Selecting a group mode/profile applies it to every member through the existing controller group logic.
|
||||
|
||||
After upgrading the custom integration, restart Home Assistant or reload **Settings -> Devices & services -> GREE Controller**. Also reload the integration after adding/removing/renaming groups so new group devices/entities are created.
|
||||
|
||||
## Command state stability
|
||||
|
||||
Direct physical-unit commands use a short pending-state guard in the Home Assistant coordinator. Some GREE firmware acknowledges a command before its status endpoint stops returning the previous value; the guard prevents that transient stale read from rendering as an `ON -> OFF -> ON` (or reverse) bounce. From version 0.7.9 the same guard also covers per-zone enable, HVAC mode, profile and target-temperature commands. The standalone controller also retries post-command verification for a bounded settling window. Failed commands drop the guard immediately and refresh factual state.
|
||||
@@ -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,158 @@
|
||||
"""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 snapshot(self) -> dict[str, Any]:
|
||||
"""Return devices, groups and the control plan in one restricted request."""
|
||||
data = await self._request("GET", "/api/integrations/home-assistant/snapshot")
|
||||
if not isinstance(data, dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid snapshot payload")
|
||||
if not isinstance(data.get("devices"), list):
|
||||
raise GreeControllerApiError("Controller returned invalid snapshot devices")
|
||||
if not isinstance(data.get("control_plan"), dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid snapshot control plan")
|
||||
if not isinstance(data.get("groups"), list):
|
||||
raise GreeControllerApiError("Controller returned invalid snapshot groups")
|
||||
return data
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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 groups(self) -> list[dict[str, Any]]:
|
||||
"""Return climate groups exposed to Home Assistant."""
|
||||
data = await self._request("GET", "/api/integrations/home-assistant/groups")
|
||||
if not isinstance(data, list):
|
||||
raise GreeControllerApiError("Controller returned an invalid groups payload")
|
||||
return data
|
||||
|
||||
async def group_control(self, group_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Change one controller climate group."""
|
||||
data = await self._request(
|
||||
"POST",
|
||||
f"/api/integrations/home-assistant/groups/{group_id}/control",
|
||||
json=payload,
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid group control payload")
|
||||
return data
|
||||
|
||||
async def house_control(self, mode: str) -> dict[str, Any]:
|
||||
"""Change the whole-house thermostat mode."""
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/api/integrations/home-assistant/house/control",
|
||||
json={"mode": mode},
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid house control payload")
|
||||
return data
|
||||
|
||||
async def house_preset(self, preset: str) -> dict[str, Any]:
|
||||
"""Change the whole-house work profile."""
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/api/integrations/home-assistant/house/preset",
|
||||
json={"preset": preset},
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid house preset payload")
|
||||
return data
|
||||
|
||||
async def house_power(self, power: bool) -> dict[str, Any]:
|
||||
"""Turn all enabled air conditioners on or off."""
|
||||
data = await self._request(
|
||||
"POST",
|
||||
"/api/integrations/home-assistant/house/power",
|
||||
json={"power": power},
|
||||
)
|
||||
if not isinstance(data, dict):
|
||||
raise GreeControllerApiError("Controller returned an invalid house power 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
|
||||
@@ -0,0 +1,388 @@
|
||||
"""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 homeassistant.util import slugify
|
||||
|
||||
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[ClimateEntity] = []
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
for zone in runtime.coordinator.plan.get("zones", []):
|
||||
zone_id = str(zone.get("zone_id") or "").strip()
|
||||
if zone_id:
|
||||
_ensure_zone_thermostat_entity_id(registry, zone)
|
||||
entities.append(GreeControllerZoneClimate(runtime.coordinator, zone_id))
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
def _ensure_zone_thermostat_entity_id(
|
||||
registry: er.EntityRegistry, zone: dict[str, Any]
|
||||
) -> None:
|
||||
"""Keep zone climate entity IDs stable and descriptive.
|
||||
|
||||
Zone climate entities use ``climate.<zone_name>_thermostat``. Existing
|
||||
registry entries are migrated to the same scheme, which also fixes stale
|
||||
IDs left behind after a zone was renamed (for example ``climate.jan_2``
|
||||
for a zone currently named Igor).
|
||||
"""
|
||||
zone_id = str(zone.get("zone_id") or "").strip()
|
||||
if not zone_id:
|
||||
return
|
||||
|
||||
unique_id = f"{zone_id}-zone-climate"
|
||||
current_entity_id = registry.async_get_entity_id("climate", DOMAIN, unique_id)
|
||||
if current_entity_id is None:
|
||||
return
|
||||
|
||||
zone_name = str(zone.get("zone_name") or zone_id).strip() or zone_id
|
||||
object_id = slugify(zone_name) or slugify(zone_id) or "zone"
|
||||
desired_entity_id = f"climate.{object_id}_thermostat"
|
||||
if current_entity_id == desired_entity_id:
|
||||
return
|
||||
|
||||
occupied = registry.async_get(desired_entity_id)
|
||||
if occupied is not None and occupied.entity_id != current_entity_id:
|
||||
_LOGGER.warning(
|
||||
"Cannot rename zone thermostat %s to %s because that entity ID is already in use",
|
||||
current_entity_id,
|
||||
desired_entity_id,
|
||||
)
|
||||
return
|
||||
|
||||
registry.async_update_entity(current_entity_id, new_entity_id=desired_entity_id)
|
||||
_LOGGER.info(
|
||||
"Renamed zone thermostat entity %s to %s",
|
||||
current_entity_id,
|
||||
desired_entity_id,
|
||||
)
|
||||
|
||||
|
||||
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 = 30.0
|
||||
_attr_target_temperature_step = 1.0
|
||||
_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 = "Direct control"
|
||||
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)),
|
||||
"xfan": bool(device.get("xfan", False)),
|
||||
"air": bool(device.get("air", False)),
|
||||
"health": bool(device.get("health", False)),
|
||||
"sleep": bool(device.get("sleep", False)),
|
||||
"last_seen": device.get("last_seen"),
|
||||
}
|
||||
|
||||
async def _command(self, payload: dict[str, Any]) -> None:
|
||||
await self.coordinator.async_device_command(self._device_id, payload)
|
||||
|
||||
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})
|
||||
|
||||
class GreeControllerZoneClimate(CoordinatorEntity[GreeControllerCoordinator], ClimateEntity):
|
||||
"""Full climate entity for a controller thermostat zone."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Thermostat"
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_min_temp = 8.0
|
||||
_attr_max_temp = 30.0
|
||||
_attr_target_temperature_step = 0.5
|
||||
_attr_hvac_modes = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT]
|
||||
_attr_preset_modes = ["auto", "comfort", "sleep", "away"]
|
||||
_attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.PRESET_MODE
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._zone_id = zone_id
|
||||
self._attr_unique_id = f"{zone_id}-zone-climate"
|
||||
|
||||
@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 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 current_temperature(self) -> float | None:
|
||||
value = self._zone.get("current_temperature")
|
||||
return float(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float | None:
|
||||
value = self._zone.get("target_temperature")
|
||||
if value is None:
|
||||
value = self._zone.get("setpoint")
|
||||
return float(value) if value is not None else None
|
||||
|
||||
@property
|
||||
def hvac_mode(self) -> HVACMode:
|
||||
zone = self._zone
|
||||
if not zone.get("enabled", False) or zone.get("local_thermostat_power") is False:
|
||||
return HVACMode.OFF
|
||||
if zone.get("inherit_house_mode", True):
|
||||
return HVACMode.AUTO
|
||||
return HVACMode.HEAT if str(zone.get("configured_mode", zone.get("mode", "cool"))) == "heat" else HVACMode.COOL
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str:
|
||||
return str(self._zone.get("preset_override") or "auto")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
zone = self._zone
|
||||
return {
|
||||
"controller_zone_id": self._zone_id,
|
||||
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
|
||||
"local_thermostat_power": zone.get("local_thermostat_power"),
|
||||
"local_thermostat_resume_at": zone.get("local_thermostat_resume_at"),
|
||||
"effective_mode": zone.get("mode"),
|
||||
"follows_house_mode": bool(zone.get("inherit_house_mode", True)),
|
||||
"auto_mode_meaning": "Follow the GREE Controller whole-house heating/cooling mode",
|
||||
"active_preset": zone.get("preset"),
|
||||
"preset_override": zone.get("preset_override"),
|
||||
"demand": bool(zone.get("demand", False)),
|
||||
"device_setpoint": zone.get("device_setpoint"),
|
||||
"temperature_source": zone.get("control_source"),
|
||||
"control_owner": zone.get("control_owner"),
|
||||
"control_source": zone.get("control_command_source"),
|
||||
"control_since": zone.get("control_since"),
|
||||
"resume_at": zone.get("resume_at"),
|
||||
"control_reason": zone.get("control_reason"),
|
||||
"blocked_reason": zone.get("blocked_reason"),
|
||||
"lockout_until": zone.get("lockout_until"),
|
||||
"desired_power": zone.get("desired_power"),
|
||||
"desired_mode": zone.get("desired_mode"),
|
||||
"actual_power": zone.get("actual_power"),
|
||||
"actual_mode": zone.get("actual_mode"),
|
||||
"actual_setpoint": zone.get("actual_setpoint"),
|
||||
"current_schedule": zone.get("current_schedule_name"),
|
||||
}
|
||||
|
||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
payload = {"power": False}
|
||||
elif hvac_mode == HVACMode.AUTO:
|
||||
payload = {"power": True, "mode": "house"}
|
||||
elif hvac_mode == HVACMode.COOL:
|
||||
payload = {"power": True, "mode": "cool"}
|
||||
elif hvac_mode == HVACMode.HEAT:
|
||||
payload = {"power": True, "mode": "heat"}
|
||||
else:
|
||||
return
|
||||
await self.coordinator.async_zone_control(self._zone_id, payload)
|
||||
|
||||
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temperature is None:
|
||||
return
|
||||
await self.coordinator.async_zone_control(self._zone_id, {"setpoint": float(temperature)})
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
if preset_mode not in self._attr_preset_modes:
|
||||
return
|
||||
await self.coordinator.async_zone_control(self._zone_id, {"preset": preset_mode})
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
await self.coordinator.async_zone_control(self._zone_id, {"power": True})
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
await self.coordinator.async_zone_control(self._zone_id, {"power": False})
|
||||
|
||||
@@ -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, Platform.SENSOR, Platform.NUMBER, Platform.SELECT, Platform.SWITCH]
|
||||
|
||||
CONF_URL = "url"
|
||||
CONF_TOKEN = "token"
|
||||
ENTITY_MAP_FILE = "gree_controller_entities.json"
|
||||
DEFAULT_SCAN_INTERVAL_SECONDS = 10
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Data coordinator for the GREE Controller integration."""
|
||||
|
||||
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
|
||||
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
|
||||
self.plan: dict[str, Any] = {}
|
||||
self.groups: dict[str, dict[str, Any]] = {}
|
||||
# Protect a just-accepted device command from an overlapping/stale poll.
|
||||
# GREE units can expose their previous status briefly after acknowledging a write.
|
||||
self._pending_device_commands: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
# Zone switches/climate/profile controls need the same protection. A coordinator refresh
|
||||
# that started before the POST must not make an accepted zone action visibly bounce back.
|
||||
self._pending_zone_controls: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
|
||||
async def _async_update_data(self) -> dict[str, dict]:
|
||||
try:
|
||||
snapshot = await self.client.snapshot()
|
||||
devices = snapshot["devices"]
|
||||
plan = snapshot["control_plan"]
|
||||
groups = snapshot["groups"]
|
||||
except GreeControllerApiError as err:
|
||||
raise UpdateFailed(str(err)) from err
|
||||
self._overlay_pending_zone_controls(plan)
|
||||
self.plan = plan
|
||||
self.groups = {str(group["id"]): group for group in groups if group.get("id")}
|
||||
device_map = {str(device["id"]): device for device in devices if device.get("id")}
|
||||
self._overlay_pending_device_commands(device_map)
|
||||
return device_map
|
||||
|
||||
@staticmethod
|
||||
def _normalized_device_command(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize writable fields exactly as the standalone controller does."""
|
||||
allowed = {
|
||||
"power", "mode", "target_temperature", "fan_speed",
|
||||
"swing_vertical", "swing_horizontal", "quiet", "turbo",
|
||||
"light", "air", "xfan", "health", "sleep",
|
||||
}
|
||||
expected = {key: value for key, value in payload.items() if key in allowed}
|
||||
if "target_temperature" in expected:
|
||||
value = max(8.0, min(30.0, float(expected["target_temperature"])))
|
||||
expected["target_temperature"] = float(int(value + 0.5))
|
||||
if "fan_speed" in expected:
|
||||
expected["fan_speed"] = min(5, max(0, int(expected["fan_speed"])))
|
||||
return expected
|
||||
|
||||
def _overlay_pending_device_commands(self, devices: dict[str, dict[str, Any]]) -> None:
|
||||
"""Do not let a stale post-command poll make HA controls bounce backwards."""
|
||||
now = asyncio.get_running_loop().time()
|
||||
for device_id, (deadline, expected) in list(self._pending_device_commands.items()):
|
||||
if now >= deadline:
|
||||
self._pending_device_commands.pop(device_id, None)
|
||||
continue
|
||||
device = devices.get(device_id)
|
||||
if device is not None:
|
||||
device.update(expected)
|
||||
|
||||
@staticmethod
|
||||
def _normalized_zone_control(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map zone-control API fields to the Home Assistant control-plan representation."""
|
||||
expected: dict[str, Any] = {}
|
||||
if "enabled" in payload:
|
||||
expected["enabled"] = bool(payload["enabled"])
|
||||
if "power" in payload:
|
||||
expected["local_thermostat_power"] = bool(payload["power"])
|
||||
if "mode" in payload:
|
||||
mode = str(payload["mode"])
|
||||
if mode in {"house", "auto"}:
|
||||
expected["inherit_house_mode"] = True
|
||||
elif mode in {"cool", "heat"}:
|
||||
expected["inherit_house_mode"] = False
|
||||
expected["configured_mode"] = mode
|
||||
expected["mode"] = mode
|
||||
if "preset" in payload:
|
||||
preset = str(payload["preset"])
|
||||
expected["preset_override"] = None if preset == "auto" else preset
|
||||
if preset != "auto":
|
||||
expected["preset"] = preset
|
||||
if "setpoint" in payload:
|
||||
value = max(8.0, min(30.0, float(payload["setpoint"])))
|
||||
expected["target_temperature"] = round(value * 2.0) / 2.0
|
||||
if payload.get("clear_override"):
|
||||
expected["preset_override"] = None
|
||||
return expected
|
||||
|
||||
def _overlay_pending_zone_controls(self, plan: dict[str, Any]) -> None:
|
||||
"""Overlay accepted zone controls onto stale/in-flight control-plan reads."""
|
||||
now = asyncio.get_running_loop().time()
|
||||
zones = plan.get("zones", [])
|
||||
for zone_id, (deadline, expected) in list(self._pending_zone_controls.items()):
|
||||
if now >= deadline:
|
||||
self._pending_zone_controls.pop(zone_id, None)
|
||||
continue
|
||||
for zone in zones:
|
||||
if str(zone.get("zone_id")) == zone_id:
|
||||
zone.update(expected)
|
||||
break
|
||||
|
||||
async def async_zone_control(self, zone_id: str, payload: dict[str, Any]) -> None:
|
||||
"""Send a thermostat-zone command with a short optimistic anti-bounce guard."""
|
||||
expected = self._normalized_zone_control(payload)
|
||||
loop = asyncio.get_running_loop()
|
||||
self._pending_zone_controls[zone_id] = (loop.time() + 5.0, expected)
|
||||
|
||||
if self.plan:
|
||||
optimistic_plan = dict(self.plan)
|
||||
optimistic_plan["zones"] = [dict(zone) for zone in self.plan.get("zones", [])]
|
||||
self._overlay_pending_zone_controls(optimistic_plan)
|
||||
self.plan = optimistic_plan
|
||||
self.async_set_updated_data(dict(self.data or {}))
|
||||
|
||||
try:
|
||||
zone = await self.client.zone_control(zone_id, payload)
|
||||
except GreeControllerApiError:
|
||||
self._pending_zone_controls.pop(zone_id, None)
|
||||
await self.async_request_refresh()
|
||||
raise
|
||||
|
||||
# The backend owns hand-back timing. Keep the exact accepted deadline returned
|
||||
# by the zone API in the optimistic overlay so an overlapping/stale plan poll
|
||||
# cannot temporarily resurrect the previous countdown in Home Assistant.
|
||||
for key in (
|
||||
"local_thermostat_power",
|
||||
"local_thermostat_resume_at",
|
||||
"device_manual_override",
|
||||
"device_manual_override_until",
|
||||
):
|
||||
if key in zone:
|
||||
expected[key] = zone[key]
|
||||
self._pending_zone_controls[zone_id] = (loop.time() + 3.0, expected)
|
||||
await self.async_request_refresh()
|
||||
|
||||
async def async_device_command(self, device_id: str, payload: dict[str, Any]) -> None:
|
||||
"""Send a physical-unit command while keeping HA state monotonic during settling."""
|
||||
expected = self._normalized_device_command(payload)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Register intent before network I/O. A coordinator refresh that was already in flight
|
||||
# when the user clicked must not be allowed to publish the pre-command state.
|
||||
self._pending_device_commands[device_id] = (loop.time() + 5.0, expected)
|
||||
current = dict(self.data or {})
|
||||
if device_id in current:
|
||||
optimistic = dict(current[device_id])
|
||||
optimistic.update(expected)
|
||||
current[device_id] = optimistic
|
||||
self.async_set_updated_data(current)
|
||||
|
||||
try:
|
||||
device = await self.client.command(device_id, payload)
|
||||
except GreeControllerApiError:
|
||||
# Never mask an actual rejected/failed command. Drop the optimistic guard and
|
||||
# immediately restore the latest factual controller state.
|
||||
self._pending_device_commands.pop(device_id, None)
|
||||
await self.async_request_refresh()
|
||||
raise
|
||||
|
||||
# Keep the guard briefly after a successful ACK so delayed firmware status and an
|
||||
# overlapping coordinator refresh cannot roll the entity backwards.
|
||||
self._pending_device_commands[device_id] = (loop.time() + 3.0, expected)
|
||||
current = dict(self.data or {})
|
||||
optimistic = dict(device)
|
||||
optimistic.update(expected)
|
||||
current[device_id] = optimistic
|
||||
self.async_set_updated_data(current)
|
||||
|
||||
# Refresh plan/group metadata immediately as before. Any stale device snapshot in
|
||||
# this refresh is overlaid by the short pending-command guard above.
|
||||
await self.async_request_refresh()
|
||||
@@ -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)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"domain": "gree_controller",
|
||||
"name": "GREE Controller",
|
||||
"version": "0.13.9",
|
||||
"config_flow": true,
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"single_config_entry": true
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""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")
|
||||
if value is None:
|
||||
value = self._zone.get("setpoint")
|
||||
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.async_zone_control(self._zone_id, {"setpoint": float(value)})
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Whole-house, zone and climate-group thermostat selectors for GREE Controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
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
|
||||
|
||||
HOUSE_MODE_LABELS = {
|
||||
"off": "Do not control",
|
||||
"cool": "Cooling",
|
||||
"heat": "Heating",
|
||||
}
|
||||
HOUSE_MODE_VALUES = {label: value for value, label in HOUSE_MODE_LABELS.items()}
|
||||
|
||||
HOUSE_PRESET_LABELS = {
|
||||
"auto": "Auto schedule",
|
||||
"comfort": "Comfort",
|
||||
"sleep": "Sleep",
|
||||
"away": "Away",
|
||||
}
|
||||
HOUSE_PRESET_VALUES = {label: value for value, label in HOUSE_PRESET_LABELS.items()}
|
||||
|
||||
GROUP_MODE_LABELS = {
|
||||
"house": "Global",
|
||||
"cool": "Cooling",
|
||||
"heat": "Heating",
|
||||
}
|
||||
GROUP_MODE_VALUES = {label: value for value, label in GROUP_MODE_LABELS.items()}
|
||||
GROUP_PRESET_LABELS = HOUSE_PRESET_LABELS
|
||||
GROUP_PRESET_VALUES = HOUSE_PRESET_VALUES
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Create whole-house, zone and climate-group thermostat selectors."""
|
||||
runtime: GreeControllerRuntimeData = entry.runtime_data
|
||||
entities: list[SelectEntity] = [
|
||||
GreeControllerHouseModeSelect(runtime.coordinator),
|
||||
GreeControllerHousePresetSelect(runtime.coordinator),
|
||||
]
|
||||
for zone in runtime.coordinator.plan.get("zones", []):
|
||||
zone_id = str(zone.get("zone_id") or "").strip()
|
||||
if zone_id:
|
||||
entities.append(GreeControllerZonePresetSelect(runtime.coordinator, zone_id))
|
||||
for group_id in runtime.coordinator.groups:
|
||||
entities.append(GreeControllerGroupModeSelect(runtime.coordinator, group_id))
|
||||
entities.append(GreeControllerGroupPresetSelect(runtime.coordinator, group_id))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class _GreeControllerHouseSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
|
||||
"""Base for controller-level selectors."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, "controller")},
|
||||
name="GREE Controller",
|
||||
manufacturer="GREE Controller",
|
||||
model="Local controller",
|
||||
)
|
||||
|
||||
|
||||
class GreeControllerHouseModeSelect(_GreeControllerHouseSelect):
|
||||
"""Select the smart thermostat house mode."""
|
||||
|
||||
_attr_name = "Thermostat mode"
|
||||
_attr_unique_id = "house-thermostat-mode"
|
||||
_attr_options = list(HOUSE_MODE_VALUES)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
value = str(self.coordinator.plan.get("house_mode") or "")
|
||||
return HOUSE_MODE_LABELS.get(value)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
value = HOUSE_MODE_VALUES.get(option)
|
||||
if value is None:
|
||||
return
|
||||
await self.coordinator.client.house_control(value)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class GreeControllerHousePresetSelect(_GreeControllerHouseSelect):
|
||||
"""Select the whole-house thermostat work profile."""
|
||||
|
||||
_attr_name = "Work profile"
|
||||
_attr_unique_id = "house-work-profile"
|
||||
_attr_options = list(HOUSE_PRESET_VALUES)
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
value = self.coordinator.plan.get("house_preset")
|
||||
return HOUSE_PRESET_LABELS.get(str(value)) if value is not None else None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
return {"mixed_zone_profiles": self.coordinator.plan.get("house_preset") is None}
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
value = HOUSE_PRESET_VALUES.get(option)
|
||||
if value is None:
|
||||
return
|
||||
await self.coordinator.client.house_preset(value)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class GreeControllerZonePresetSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
|
||||
"""Select the temporary work profile for one thermostat zone."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Work profile"
|
||||
_attr_options = list(HOUSE_PRESET_VALUES)
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, zone_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._zone_id = zone_id
|
||||
self._attr_unique_id = f"{zone_id}-work-profile"
|
||||
|
||||
@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 current_option(self) -> str | None:
|
||||
value = str(self._zone.get("preset_override") or "auto")
|
||||
return HOUSE_PRESET_LABELS.get(value)
|
||||
|
||||
@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]:
|
||||
return {"active_profile": self._zone.get("preset")}
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
value = HOUSE_PRESET_VALUES.get(option)
|
||||
if value is None:
|
||||
return
|
||||
await self.coordinator.async_zone_control(self._zone_id, {"preset": value})
|
||||
|
||||
|
||||
class _GreeControllerGroupSelect(CoordinatorEntity[GreeControllerCoordinator], SelectEntity):
|
||||
"""Base class for climate-group selectors."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._group_id = group_id
|
||||
|
||||
@property
|
||||
def _group(self) -> dict[str, Any]:
|
||||
return self.coordinator.groups.get(self._group_id, {})
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and bool(self._group)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
group = self._group
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"group:{self._group_id}")},
|
||||
name=str(group.get("name") or self._group_id),
|
||||
manufacturer="GREE Controller",
|
||||
model="Climate group",
|
||||
via_device=(DOMAIN, "controller"),
|
||||
)
|
||||
|
||||
|
||||
class GreeControllerGroupModeSelect(_GreeControllerGroupSelect):
|
||||
"""Select the thermostat mode policy for one climate group."""
|
||||
|
||||
_attr_name = "Thermostat mode"
|
||||
_attr_options = list(GROUP_MODE_VALUES)
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator, group_id)
|
||||
self._attr_unique_id = f"{group_id}-group-thermostat-mode"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
return GROUP_MODE_LABELS.get(str(self._group.get("mode") or ""))
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
return {
|
||||
"mixed_group_mode": self._group.get("mode") == "mixed",
|
||||
"global_house_mode": self._group.get("house_mode"),
|
||||
}
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
value = GROUP_MODE_VALUES.get(option)
|
||||
if value is None:
|
||||
return
|
||||
await self.coordinator.client.group_control(self._group_id, {"mode": value})
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class GreeControllerGroupPresetSelect(_GreeControllerGroupSelect):
|
||||
"""Select the work profile for one climate group."""
|
||||
|
||||
_attr_name = "Work profile"
|
||||
_attr_options = list(GROUP_PRESET_VALUES)
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator, group_id)
|
||||
self._attr_unique_id = f"{group_id}-group-work-profile"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
return GROUP_PRESET_LABELS.get(str(self._group.get("preset") or ""))
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
return {"mixed_group_profiles": self._group.get("preset") == "mixed"}
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
value = GROUP_PRESET_VALUES.get(option)
|
||||
if value is None:
|
||||
return
|
||||
await self.coordinator.client.group_control(self._group_id, {"preset": value})
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""House, zone and climate-group 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"])))
|
||||
for group_id in runtime.coordinator.groups:
|
||||
entities.append(GreeControllerGroupPlanSensor(runtime.coordinator, group_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", [])
|
||||
groups = list(self.coordinator.groups.values())
|
||||
return {
|
||||
"generated_at": plan.get("generated_at"),
|
||||
"outdoor_temperature": plan.get("outdoor_temperature"),
|
||||
"control_strategy": plan.get("control_strategy"),
|
||||
"work_profile": plan.get("house_preset"),
|
||||
"all_units_powered": bool(self.coordinator.data) and all(
|
||||
bool(device.get("power")) for device in self.coordinator.data.values() if device.get("enabled", True)
|
||||
),
|
||||
"enabled_zones": sum(bool(zone.get("enabled")) for zone in zones),
|
||||
"effective_enabled_zones": sum(bool(zone.get("effective_enabled", zone.get("enabled"))) for zone in zones),
|
||||
"demanding_zones": sum(bool(zone.get("demand")) for zone in zones),
|
||||
"groups": len(groups),
|
||||
"enabled_groups": sum(bool(group.get("power_enabled")) for group in groups),
|
||||
"demanding_groups": sum(int(group.get("demanding_zones") or 0) > 0 for group in groups),
|
||||
"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"
|
||||
if not zone.get("effective_enabled", True):
|
||||
return "blocked"
|
||||
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"),
|
||||
"enabled": bool(zone.get("enabled", False)),
|
||||
"effective_enabled": bool(zone.get("effective_enabled", zone.get("enabled", False))),
|
||||
"mode": zone.get("mode"),
|
||||
"preset": zone.get("preset"),
|
||||
"preset_override": zone.get("preset_override"),
|
||||
"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", []),
|
||||
}
|
||||
|
||||
|
||||
class GreeControllerGroupPlanSensor(CoordinatorEntity[GreeControllerCoordinator], SensorEntity):
|
||||
"""Readable status and automation plan for one climate group."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Control plan"
|
||||
_unrecorded_attributes = frozenset({"next_events", "zone_ids", "zone_names", "members"})
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._group_id = group_id
|
||||
self._attr_unique_id = f"{group_id}-group-control-plan"
|
||||
|
||||
@property
|
||||
def _group(self) -> dict[str, Any]:
|
||||
return self.coordinator.groups.get(self._group_id, {})
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and bool(self._group)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str:
|
||||
group = self._group
|
||||
if not group.get("power_enabled", False):
|
||||
return "off"
|
||||
if group.get("mode") == "house" and group.get("house_mode") == "off":
|
||||
return "paused"
|
||||
return "requesting" if int(group.get("demanding_zones") or 0) > 0 else "satisfied"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
group = self._group
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"group:{self._group_id}")},
|
||||
name=str(group.get("name") or self._group_id),
|
||||
manufacturer="GREE Controller",
|
||||
model="Climate group",
|
||||
via_device=(DOMAIN, "controller"),
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
group = self._group
|
||||
return {
|
||||
"group_id": self._group_id,
|
||||
"power_enabled": bool(group.get("power_enabled", False)),
|
||||
"effective_power": bool(group.get("effective_power", False)),
|
||||
"mode": group.get("mode"),
|
||||
"global_house_mode": group.get("house_mode"),
|
||||
"work_profile": group.get("preset"),
|
||||
"zone_count": group.get("zone_count"),
|
||||
"enabled_zones": group.get("enabled_zones"),
|
||||
"active_zones": group.get("active_zones"),
|
||||
"demanding_zones": group.get("demanding_zones"),
|
||||
"device_count": group.get("device_count"),
|
||||
"online_devices": group.get("online_devices"),
|
||||
"current_temperature": group.get("current_temperature"),
|
||||
"zone_ids": group.get("zone_ids", []),
|
||||
"zone_names": group.get("zone_names", []),
|
||||
"members": group.get("members", []),
|
||||
"next_events": group.get("next_events", []),
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Writable house, group, zone and device 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
|
||||
|
||||
|
||||
DEVICE_FEATURE_SWITCHES = [
|
||||
("light", "supports_light", "Panel light"),
|
||||
("quiet", "supports_quiet", "Quiet"),
|
||||
("turbo", "supports_turbo", "Turbo"),
|
||||
("xfan", "supports_xfan", "X-FAN"),
|
||||
("air", "supports_air", "Air"),
|
||||
("health", "supports_health", "Health"),
|
||||
("sleep", "supports_sleep", "Sleep"),
|
||||
]
|
||||
|
||||
|
||||
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: list[SwitchEntity] = [
|
||||
GreeControllerHousePowerSwitch(runtime.coordinator),
|
||||
*[
|
||||
GreeControllerZoneEnabledSwitch(runtime.coordinator, str(zone["zone_id"]))
|
||||
for zone in runtime.coordinator.plan.get("zones", [])
|
||||
if zone.get("zone_id")
|
||||
],
|
||||
*[
|
||||
GreeControllerGroupPowerSwitch(runtime.coordinator, group_id)
|
||||
for group_id in runtime.coordinator.groups
|
||||
],
|
||||
]
|
||||
for device_id, device in runtime.coordinator.data.items():
|
||||
for field, support_field, name in DEVICE_FEATURE_SWITCHES:
|
||||
if device.get(support_field) is True:
|
||||
entities.append(GreeControllerDeviceFeatureSwitch(runtime.coordinator, device_id, field, support_field, name))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class GreeControllerHousePowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
|
||||
"""Master power action for all enabled indoor units."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "All air conditioners"
|
||||
_attr_unique_id = "house-power-all"
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
devices = [device for device in self.coordinator.data.values() if device.get("enabled", True)]
|
||||
return bool(devices) and all(bool(device.get("power")) for device in devices)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, "controller")},
|
||||
name="GREE Controller",
|
||||
manufacturer="GREE Controller",
|
||||
model="Local controller",
|
||||
)
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.client.house_power(True)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.client.house_power(False)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
class GreeControllerGroupPowerSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
|
||||
"""Power a controller climate group on or off."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Power"
|
||||
|
||||
def __init__(self, coordinator: GreeControllerCoordinator, group_id: str) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._group_id = group_id
|
||||
self._attr_unique_id = f"{group_id}-group-power"
|
||||
|
||||
@property
|
||||
def _group(self) -> dict[str, Any]:
|
||||
return self.coordinator.groups.get(self._group_id, {})
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return super().available and bool(self._group)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return bool(self._group.get("power_enabled", False))
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
group = self._group
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"group:{self._group_id}")},
|
||||
name=str(group.get("name") or self._group_id),
|
||||
manufacturer="GREE Controller",
|
||||
model="Climate group",
|
||||
via_device=(DOMAIN, "controller"),
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
return {
|
||||
"effective_power": bool(self._group.get("effective_power", False)),
|
||||
"zone_count": self._group.get("zone_count"),
|
||||
}
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.client.group_control(self._group_id, {"power": True})
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.client.group_control(self._group_id, {"power": False})
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
|
||||
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.async_zone_control(self._zone_id, {"enabled": True})
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
await self.coordinator.async_zone_control(self._zone_id, {"enabled": False})
|
||||
|
||||
class GreeControllerDeviceFeatureSwitch(CoordinatorEntity[GreeControllerCoordinator], SwitchEntity):
|
||||
"""Optional GREE unit feature exposed only when the controller detected it."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: GreeControllerCoordinator,
|
||||
device_id: str,
|
||||
field: str,
|
||||
support_field: str,
|
||||
name: str,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._device_id = device_id
|
||||
self._field = field
|
||||
self._support_field = support_field
|
||||
self._attr_name = name
|
||||
self._attr_unique_id = f"{device_id}-{field}"
|
||||
|
||||
@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))
|
||||
and self._device.get(self._support_field) is True
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
return bool(self._device.get(self._field, 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,
|
||||
)
|
||||
|
||||
async def _set(self, value: bool) -> None:
|
||||
await self.coordinator.async_device_command(self._device_id, {self._field: value})
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
await self._set(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
await self._set(False)
|
||||
|
||||
@@ -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