diff --git a/custom_components/gree_custom/__init__.py b/custom_components/gree_custom/__init__.py index eb6721e..595e2dc 100755 --- a/custom_components/gree_custom/__init__.py +++ b/custom_components/gree_custom/__init__.py @@ -20,7 +20,11 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.typing import ConfigType @@ -63,7 +67,7 @@ ENCRYPTION_VERSION_AUTO, ) from .coordinator import GreeConfigEntry, GreeCoordinator -from .helpers import try_find_new_ip +from .helpers import get_vrf_controller_mac, reconcile_vrf_controllers, try_find_new_ip from .migration import ( async_migrate_legacy_registry, async_prepare_legacy_migration, @@ -257,9 +261,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: GreeConfigEntry) -> bool # Move the 4.x registry rows before the entities are created moved = await async_migrate_legacy_registry(hass, entry) + # Create the VRF controller devices before their sub-units + reconcile_vrf_controllers(hass, entry, device_configs, link_sub_units=False) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async_remove_unprovided_entities(hass, entry, moved) + + # The sub-unit devices exist now, link them to their controller + reconcile_vrf_controllers(hass, entry, device_configs) return True @@ -273,6 +283,12 @@ async def async_remove_config_entry_device( ) -> bool: """Remove a device from a config entry.""" + # A controller has no config of its own, it goes with its last sub-unit + if get_vrf_controller_mac(device_entry) is not None: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="remove_vrf_controller" + ) + # Find MAC address for this device (from identifiers) mac: str | None = next( ( @@ -308,7 +324,10 @@ async def async_remove_config_entry_device( if new_device_configs: # There are still other devices, update the entry - return hass.config_entries.async_update_entry(config_entry, data=data) + updated = hass.config_entries.async_update_entry(config_entry, data=data) + # Remove the controller at once when this was its last sub-unit + reconcile_vrf_controllers(hass, config_entry, new_device_configs) + return updated # No other devices, remove the entry if local if config_entry.unique_id == CONFENTRY_ID_LOCAL_ONLY: diff --git a/custom_components/gree_custom/aiogree/cloud_api.py b/custom_components/gree_custom/aiogree/cloud_api.py index 29f3f0e..1c394d5 100755 --- a/custom_components/gree_custom/aiogree/cloud_api.py +++ b/custom_components/gree_custom/aiogree/cloud_api.py @@ -23,6 +23,7 @@ from pydantic import BaseModel, ConfigDict, Field from .errors import GreeCloudError, GreeCloudLoginError +from .helpers import redact_str _LOGGER = logging.getLogger(__name__) @@ -416,7 +417,7 @@ async def get_devices(self, home_id: int) -> list[CloudDeviceInfoResponse]: ) decrypted = self._decrypt(base64.b64decode(encrypted_response)) data = json.loads(decrypted) - _LOGGER.debug(data) + # Not logged raw: it holds the device keys. See get_all_devices(). devices = [] for room in data["rooms"]: @@ -444,6 +445,13 @@ async def get_all_devices(self) -> list[CloudDeviceInfoResponse]: devices = await self.get_devices(home.id) all_devices.extend(devices) + # Log the raw list, so users with a cloud VRF can share the pmac of the + # sub-units. A later change can then group them under their gateway. + _LOGGER.debug( + "Raw cloud device list: %s", + [d.model_dump() | {"key": redact_str(d.key)} for d in all_devices], + ) + # Filter duplicates: when same key exists with MACs where one ends with '00' filtered_devices = self._filter_duplicate_devices_complete(all_devices) diff --git a/custom_components/gree_custom/const.py b/custom_components/gree_custom/const.py index 0f8b33d..f4cb6e2 100755 --- a/custom_components/gree_custom/const.py +++ b/custom_components/gree_custom/const.py @@ -16,6 +16,10 @@ DOMAIN = "gree_custom" +# Device identifier prefix of the VRF controller (gateway) device +VRF_CONTROLLER_ID_PREFIX = "controller_" +VRF_CONTROLLER_TRANSLATION_KEY = "vrf_controller" + CURRENT_CONF_VERSION = 3 CONFENTRY_ID_LOCAL_ONLY = "local_only" diff --git a/custom_components/gree_custom/diagnostics.py b/custom_components/gree_custom/diagnostics.py index c7b66bb..0aac90a 100755 --- a/custom_components/gree_custom/diagnostics.py +++ b/custom_components/gree_custom/diagnostics.py @@ -8,8 +8,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import CONF_ENCRYPTION_KEY, DOMAIN +from .const import CONF_DEVICES, CONF_ENCRYPTION_KEY, DOMAIN from .coordinator import GreeConfigEntry, GreeCoordinator +from .helpers import get_vrf_controller_mac, get_vrf_sub_units _LOGGER = logging.getLogger(__name__) @@ -42,6 +43,23 @@ async def async_get_device_diagnostics( """Return diagnostics for a device.""" _LOGGER.debug("Getting device diagnostics") + # A VRF controller has no data of its own, return its sub-units + controller_mac = get_vrf_controller_mac(device) + if controller_mac is not None: + sub_units = get_vrf_sub_units(entry.data.get(CONF_DEVICES, {})) + sub_unit_data: dict[str, Any] = {} + for sub_mac in sorted(sub_units.get(controller_mac, set())): + sub_coordinator: GreeCoordinator | None = entry.runtime_data.get(sub_mac) + sub_unit_data[sub_mac] = ( + sub_coordinator.get_coordinator_diagnostics() if sub_coordinator else "" + ) + + return { + "device": device.dict_repr, + "controller_mac": controller_mac, + "sub_units": sub_unit_data, + } + # Find MAC address for this device (from identifiers) identifiers = device.identifiers mac: str | None = None diff --git a/custom_components/gree_custom/entity.py b/custom_components/gree_custom/entity.py index c1698d6..0a07cec 100755 --- a/custom_components/gree_custom/entity.py +++ b/custom_components/gree_custom/entity.py @@ -43,15 +43,6 @@ def unique_id(self) -> str | None: @override def device_info(self) -> DeviceInfo: """Return the device info.""" - if self.device.mac_address != self.device.mac_address_controller: - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.device.mac_address)}, - identifiers={(DOMAIN, self.device.unique_id)}, - name=self.device.name, - manufacturer="Gree", - sw_version=self.device.firmware_version, - # via_device=(DOMAIN, self.device.mac_address_controller), - ) return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, self.device.mac_address)}, identifiers={(DOMAIN, self.device.unique_id)}, diff --git a/custom_components/gree_custom/helpers.py b/custom_components/gree_custom/helpers.py index f77ce58..c18de13 100755 --- a/custom_components/gree_custom/helpers.py +++ b/custom_components/gree_custom/helpers.py @@ -9,8 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from .aiogree.api import GreeDiscoveredDevice, gree_discover_devices from .aiogree.device import GreeDevice @@ -33,6 +34,8 @@ DEFAULT_DISCOVERY_TIMEOUT, DOMAIN, MAX_UNICAST_SCAN_HOSTS, + VRF_CONTROLLER_ID_PREFIX, + VRF_CONTROLLER_TRANSLATION_KEY, ) _LOGGER = logging.getLogger(__name__) @@ -336,3 +339,119 @@ def get_subdevices_mac_matching_controller( return None return matched_entry, matched_devices + + +def get_vrf_sub_units(device_configs: Mapping[str, Any]) -> dict[str, set[str]]: + """Return the sub-unit macs per local VRF controller mac. + + A sub-unit is a device whose saved local controller mac is set and differs + from its own mac. The runtime controller mac of the bound transport is not + used, because for MQTT it is derived from the cloud mac. + """ + sub_units: dict[str, set[str]] = {} + + for mac, device in device_configs.items(): + controller_local = ( + device.get(CONF_DEVICE_CONNECTION, {}) + .get(CONF_DEVICE_CONNECTION_LOCAL, {}) + .get(CONF_MAC_CONTROLLER_LOCAL) + ) + + if not controller_local or controller_local == mac: + continue + + sub_units.setdefault(controller_local, set()).add(mac) + + return sub_units + + +def get_vrf_controller_mac(device_entry: dr.DeviceEntry) -> str | None: + """Return the controller mac if the device is a VRF controller device.""" + return next( + ( + identifier.removeprefix(VRF_CONTROLLER_ID_PREFIX) + for domain, identifier in device_entry.identifiers + if domain == DOMAIN and identifier.startswith(VRF_CONTROLLER_ID_PREFIX) + ), + None, + ) + + +def reconcile_vrf_controllers( + hass: HomeAssistant, + entry: ConfigEntry, + device_configs: Mapping[str, Any], + link_sub_units: bool = True, +) -> None: + """Create, remove and link the VRF controller devices of a config entry. + + Every local controller mac with at least one sub-unit gets a controller + device without entities. A wanted controller is never recreated, so its + device id, user name and area survive restarts. + + The sub-units are linked from here and not through DeviceInfo, because HA + 2026.3 only has via_device and newer versions only have via_device_id. + The link needs the sub-unit devices, so they must exist first. + """ + device_registry = dr.async_get(hass) + sub_units = get_vrf_sub_units(device_configs) + entry_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + + # Remove the controllers that have no sub-unit anymore + for device_entry in entry_devices: + controller_mac = get_vrf_controller_mac(device_entry) + if controller_mac is not None and controller_mac not in sub_units: + _LOGGER.debug("Removing VRF controller device %s", controller_mac) + device_registry.async_remove_device(device_entry.id) + + controller_ids: dict[str, str] = {} + for controller_mac, sub_macs in sub_units.items(): + # The firmware belongs to the WiFi module of the gateway, so take it + # from a bound sub-unit. Without one, keep what the registry has. + sw_version: str | UndefinedType | None = UNDEFINED + hw_version: str | UndefinedType | None = UNDEFINED + for sub_mac in sorted(sub_macs): + coordinator = entry.runtime_data.get(sub_mac) + if coordinator is not None and coordinator.device.is_bound: + sw_version = coordinator.device.firmware_version + hw_version = coordinator.device.firmware_code + break + + controller = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{VRF_CONTROLLER_ID_PREFIX}{controller_mac}")}, + connections={(dr.CONNECTION_NETWORK_MAC, controller_mac)}, + manufacturer="Gree", + model="VRF gateway", + sw_version=sw_version, + hw_version=hw_version, + translation_key=VRF_CONTROLLER_TRANSLATION_KEY, + translation_placeholders={"mac": controller_mac[-5:]}, + ) + controller_ids[controller_mac] = controller.id + + if not link_sub_units: + return + + # Look up the sub-units in the entry devices, async_get_device is + # deprecated in newer HA versions and its replacement is not in 2026.3 + devices_by_mac: dict[str, dr.DeviceEntry] = { + identifier: device_entry + for device_entry in entry_devices + for domain, identifier in device_entry.identifiers + if domain == DOMAIN + } + + for controller_mac, sub_macs in sub_units.items(): + controller_id = controller_ids[controller_mac] + for sub_mac in sub_macs: + sub_device = devices_by_mac.get(sub_mac) + if sub_device is None or sub_device.via_device_id == controller_id: + continue + + _LOGGER.debug( + "Linking VRF sub-unit %s to controller %s", sub_mac, controller_mac + ) + device_registry.async_update_device( + sub_device.id, via_device_id=controller_id + ) diff --git a/custom_components/gree_custom/translations/en.json b/custom_components/gree_custom/translations/en.json index 62bf40d..7fe574f 100644 --- a/custom_components/gree_custom/translations/en.json +++ b/custom_components/gree_custom/translations/en.json @@ -210,6 +210,11 @@ } } }, + "device": { + "vrf_controller": { + "name": "VRF gateway {mac}" + } + }, "entity": { "sensor": { "indoor_temperature": { @@ -363,6 +368,9 @@ }, "smart_dry_unavailable": { "message": "Smart Dry is only available in Cool mode." + }, + "remove_vrf_controller": { + "message": "This is a VRF controller. Delete its sub-units first, and the controller is deleted automatically." } }, "issues": { diff --git a/custom_components/gree_custom/translations/nl.json b/custom_components/gree_custom/translations/nl.json index 4824269..e2067fe 100644 --- a/custom_components/gree_custom/translations/nl.json +++ b/custom_components/gree_custom/translations/nl.json @@ -210,6 +210,11 @@ } } }, + "device": { + "vrf_controller": { + "name": "VRF-gateway {mac}" + } + }, "entity": { "sensor": { "indoor_temperature": { @@ -363,6 +368,9 @@ }, "smart_dry_unavailable": { "message": "Slim drogen is alleen beschikbaar in de modus Koelen." + }, + "remove_vrf_controller": { + "message": "Dit is een VRF-controller. Verwijder eerst de sub-units, dan wordt de controller automatisch verwijderd." } }, "issues": { diff --git a/custom_components/gree_custom/translations/pt.json b/custom_components/gree_custom/translations/pt.json index 015f91a..86d8d88 100644 --- a/custom_components/gree_custom/translations/pt.json +++ b/custom_components/gree_custom/translations/pt.json @@ -128,6 +128,11 @@ } } }, + "device": { + "vrf_controller": { + "name": "Gateway VRF {mac}" + } + }, "entity": { "binary_sensor": { "faults": { @@ -289,6 +294,9 @@ }, "turbo_availability": { "message": "A função Turbo só está disponível nos modos de Arrefecer ou Aquecer" + }, + "remove_vrf_controller": { + "message": "Este é um controlador VRF. Elimine primeiro as respetivas subunidades e o controlador será eliminado automaticamente." } }, "selector": { diff --git a/docs/actions.md b/docs/actions.md index 54e52ea..4d31580 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -61,4 +61,4 @@ missing: Every config entry and every device has a **Download diagnostics** item in its three dot menu. The download is a JSON file with the configuration and the last known state of each device. Keys and passwords are redacted. -The device diagnostics also list the properties the integration polls and the values the device sent that the integration does not know. Attach the file to a bug report. See [troubleshooting.md](troubleshooting.md#how-to-report-a-bug). +The device diagnostics also list the properties the integration polls and the values the device sent that the integration does not know. The diagnostics of a VRF gateway device hold the diagnostics of each of its indoor units. Attach the file to a bug report. See [troubleshooting.md](troubleshooting.md#how-to-report-a-bug). diff --git a/docs/architecture.md b/docs/architecture.md index 99f44eb..e0285e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,7 +25,7 @@ No Home Assistant imports here. | File | What it does | |---|---| -| `__init__.py` | Entry setup. Builds transports and devices, binds them, starts one `GreeCoordinator` per device. | +| `__init__.py` | Entry setup. Builds transports and devices, binds them, starts one `GreeCoordinator` per device. Creates and links the VRF controller devices, see [VRF controller device](#vrf-controller-device). | | `coordinator.py` | `GreeCoordinator`. Polls on `scan_interval` and listens for status pushed by the device. Polls again every 2 s after a command while the device has not confirmed it, until the hold ends. | | `config_flow.py` | Setup, reconfigure, reauth and YAML import flows. Local discovery, cloud login, device picker, per device options. `async_step_import` turns one validated YAML item into a config entry. | | `config_schema.py` | `CONFIG_SCHEMA` for the `gree_custom:` block in `configuration.yaml`. Validates the YAML, normalizes the MAC addresses and fills the defaults. It also holds the form schemas that the config flow shows. | @@ -34,6 +34,7 @@ No Home Assistant imports here. | `entity.py`, `platform_helpers.py` | Base entity, availability logic, shared helpers. | | `services.py`, `services.yaml` | Services `get_prop_values` and `get_prop_values_all`. | | `diagnostics.py` | Diagnostics download for the entry and for a device. Redacts keys and passwords. | +| `helpers.py` | Discovery addresses, IP recovery, config entry lookups, and `reconcile_vrf_controllers()`. | | `const.py` | Config keys, defaults, mode maps, `CURRENT_CONF_VERSION`. | Also in the repo root: `supported-devices.md`, `manual-configuration.yaml`, `hacs.json`. @@ -73,6 +74,22 @@ After that the coordinator calls `fetch_device_status()` every `scan_interval` s Entities are only created for props the device supports. A device with few features gets few entities. That is by design. +## VRF controller device + +The indoor units behind one local VRF gateway are grouped under a controller device in the device registry. The device page of the gateway then shows the units under **Connected devices**. + +- A sub-unit is a device whose `connection.local.mac_controller_local` is set and differs from its own MAC. The runtime `mac_address_controller` is not used, because for MQTT it comes from the cloud MAC. +- Every local controller MAC with at least one sub-unit gets one controller device. Its identifier is `(gree_custom, "controller_")`, its model is `VRF gateway`, and its name comes from the `vrf_controller` device translation. +- The controller has no entities. Its `connections` hold the gateway MAC. Discovery never returns the gateway itself as a device, so no other device of this integration has that MAC. +- Its `sw_version` and `hw_version` come from the first bound sub-unit, because the firmware belongs to the WiFi module of the gateway. With no bound sub-unit the registry keeps the last known values. +- A cloud-only VRF has no local controller MAC, so it gets no controller device yet. + +`reconcile_vrf_controllers()` in `helpers.py` does the work. Entry setup calls it twice. The first call, before the platforms are set up, creates the wanted controllers and removes the ones without a sub-unit. A wanted controller is never removed and created again, so its device id, user name and area survive a restart. The second call, after the platforms are set up, links each sub-unit with `async_update_device(via_device_id=...)`. The sub-unit devices only exist once their entities are added. + +The link is set from setup code and not through `DeviceInfo`, because the API differs per Home Assistant version. In 2026.3 `DeviceInfo` only has `via_device`, a tuple. In 2026.9 it only has `via_device_id`, and `via_device` is deprecated. `async_update_device(via_device_id=...)` exists in both. The sub-units are looked up in `async_entries_for_config_entry()`, because `async_get_device()` is deprecated in 2026.9 and its replacement is not in 2026.3. + +A user cannot delete the controller. `async_remove_config_entry_device()` raises the `remove_vrf_controller` error for it. After a sub-unit is deleted, the reconcile runs with the new device list, so a controller without sub-units goes away at once. The device diagnostics of a controller list the diagnostics of its sub-units. + ## State model State lives in `DeviceState`, one per device. diff --git a/docs/config-entry.md b/docs/config-entry.md index 8b88bd0..a21a365 100644 --- a/docs/config-entry.md +++ b/docs/config-entry.md @@ -40,6 +40,7 @@ For a cloud entry, `cloud` holds the account email, region, user id and token. T | Field | Meaning | |---|---| +| `connection.local.mac_controller_local` | MAC of the unit that answers UDP. The device MAC for a normal unit, the gateway MAC for a VRF sub-unit. Every distinct gateway MAC with a sub-unit gets a controller device without config of its own, see [architecture.md](architecture.md#vrf-controller-device). | | `connection.local.host`, `port` | Where the unit answers UDP. Port is 7000 for every known unit. | | `connection.local.timeout` | Seconds to wait for one UDP reply. | | `connection.local.encryption_version` | `"0"` auto, `"1"` ECB, `"2"` GCM. After a successful bind the detected version is written back here. | diff --git a/docs/entities.md b/docs/entities.md index 751bd41..e26db85 100644 --- a/docs/entities.md +++ b/docs/entities.md @@ -6,6 +6,8 @@ Every device gets one climate entity and a set of sensors, switches, selects and 2. **What you enabled.** The **Device Features and Modes** option decides which optional switches and selects are created. See [configuration.md](configuration.md#device-features). 3. **The current mode.** Some features only exist in some HVAC modes. The entity then shows as unavailable in the other modes. +A VRF system also gets a **VRF gateway** device for its WiFi gateway. It has no entities. Its device page lists the indoor units under **Connected devices**. You cannot delete it. Delete its indoor units, and the gateway device goes away with the last one. You can rename it and give it an area. A VRF that is only added through the cloud gets no gateway device yet. + Home Assistant chooses the entity IDs, not the integration. By default it builds them from the device name and the translated entity name. A device named `Living Room AC` in an English Home Assistant then gets `climate.living_room_ac` and `switch.living_room_ac_x_fan`. That default depends on your language and on your Home Assistant version, and you can change any ID yourself. Copy the IDs from the device page instead of guessing them. The examples below use `climate.your_ac`. ## Climate diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e10ed22..9386d21 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -74,6 +74,10 @@ The integration raises repair issues under **Settings** > **System** > **Repairs **Not every indoor unit of a VRF system is found.** The controller is asked for its units in three ways, because WiFi modules differ. Turn on [debug logging](#enable-debug-logging) and run the discovery again. The line `Sub-device list per form` shows how many units each way returned. Attach it to an issue. +**My cloud VRF has no gateway device.** Only a VRF with a local gateway MAC gets a gateway device for now. To help group cloud units later, turn on [debug logging](#enable-debug-logging) and run the cloud setup flow again. The line `Raw cloud device list` shows each unit with its `mac` and `pmac`, with the key redacted. Attach it to an issue. + +**The VRF gateway device cannot be deleted.** That is by design. The gateway device has no config of its own. Delete its indoor units, and it goes away with the last one. + **The device is on another VLAN and is not found.** Broadcasts do not cross VLANs. Add the network or the IP under **Extra Networks** or **Extra Hosts**, and allow UDP 7000 in the firewall. **Two Gree integrations show up.** Home Assistant ships its own `gree` integration. This one is called **Gree Climate** in the setup dialog and has the domain `gree_custom`. Both can be installed, but do not add the same device to both.