Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions custom_components/gree_custom/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand All @@ -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(
(
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion custom_components/gree_custom/aiogree/cloud_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from pydantic import BaseModel, ConfigDict, Field

from .errors import GreeCloudError, GreeCloudLoginError
from .helpers import redact_str

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions custom_components/gree_custom/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 19 additions & 1 deletion custom_components/gree_custom/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down
9 changes: 0 additions & 9 deletions custom_components/gree_custom/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)},
Expand Down
121 changes: 120 additions & 1 deletion custom_components/gree_custom/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,6 +34,8 @@
DEFAULT_DISCOVERY_TIMEOUT,
DOMAIN,
MAX_UNICAST_SCAN_HOSTS,
VRF_CONTROLLER_ID_PREFIX,
VRF_CONTROLLER_TRANSLATION_KEY,
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -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
)
8 changes: 8 additions & 0 deletions custom_components/gree_custom/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@
}
}
},
"device": {
"vrf_controller": {
"name": "VRF gateway {mac}"
}
},
"entity": {
"sensor": {
"indoor_temperature": {
Expand Down Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions custom_components/gree_custom/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,11 @@
}
}
},
"device": {
"vrf_controller": {
"name": "VRF-gateway {mac}"
}
},
"entity": {
"sensor": {
"indoor_temperature": {
Expand Down Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions custom_components/gree_custom/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@
}
}
},
"device": {
"vrf_controller": {
"name": "Gateway VRF {mac}"
}
},
"entity": {
"binary_sensor": {
"faults": {
Expand Down Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Loading
Loading