diff --git a/CODEOWNERS b/CODEOWNERS index a473d07af9d..d2ad6ef2307 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1238,8 +1238,8 @@ build.json @home-assistant/supervisor /tests/components/zeroconf/ @bdraco /homeassistant/components/zerproc/ @emlove /tests/components/zerproc/ @emlove -/homeassistant/components/zha/ @dmulcahey @adminiuga -/tests/components/zha/ @dmulcahey @adminiuga +/homeassistant/components/zha/ @dmulcahey @adminiuga @puddly +/tests/components/zha/ @dmulcahey @adminiuga @puddly /homeassistant/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant /homeassistant/components/zone/ @home-assistant/core diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 80956117ff9..7fd9495635f 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -119,10 +119,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, - connections={ - (dr.CONNECTION_ZIGBEE, str(zha_gateway.application_controller.ieee)) - }, - identifiers={(DOMAIN, str(zha_gateway.application_controller.ieee))}, + connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))}, + identifiers={(DOMAIN, str(zha_gateway.coordinator_ieee))}, name="Zigbee Coordinator", manufacturer="ZHA", model=zha_gateway.radio_description, diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index cc4dd45689e..89d360577d4 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -1091,10 +1091,7 @@ def async_load_api(hass: HomeAssistant) -> None: zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee: EUI64 = service.data[ATTR_IEEE] zha_device: ZHADevice | None = zha_gateway.get_device(ieee) - if zha_device is not None and ( - zha_device.is_coordinator - and zha_device.ieee == zha_gateway.application_controller.ieee - ): + if zha_device is not None and zha_device.is_active_coordinator: _LOGGER.info("Removing the coordinator (%s) is not allowed", ieee) return _LOGGER.info("Removing node %s", ieee) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index c2d9e926453..0d6dec2d816 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -17,6 +17,7 @@ import zigpy_znp.zigbee.application from homeassistant.const import Platform import homeassistant.helpers.config_validation as cv +ATTR_ACTIVE_COORDINATOR = "active_coordinator" ATTR_ARGS = "args" ATTR_ATTRIBUTE = "attribute" ATTR_ATTRIBUTE_ID = "attribute_id" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index afd4f647ecb..4719b6bf585 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -30,6 +30,7 @@ from homeassistant.helpers.event import async_track_time_interval from . import channels from .const import ( + ATTR_ACTIVE_COORDINATOR, ATTR_ARGS, ATTR_ATTRIBUTE, ATTR_AVAILABLE, @@ -252,12 +253,20 @@ class ZHADevice(LogMixin): @property def is_coordinator(self) -> bool | None: - """Return true if this device represents the coordinator.""" + """Return true if this device represents a coordinator.""" if self._zigpy_device.node_desc is None: return None return self._zigpy_device.node_desc.is_coordinator + @property + def is_active_coordinator(self) -> bool: + """Return true if this device is the active coordinator.""" + if not self.is_coordinator: + return False + + return self.ieee == self.gateway.coordinator_ieee + @property def is_end_device(self) -> bool | None: """Return true if this device is an end device.""" @@ -499,6 +508,7 @@ class ZHADevice(LogMixin): """Get ZHA device information.""" device_info: dict[str, Any] = {} device_info.update(self.device_info) + device_info[ATTR_ACTIVE_COORDINATOR] = self.is_active_coordinator device_info["entities"] = [ { "entity_id": entity_ref.reference_id, diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 7782d8ef7fd..9efb6e99550 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -178,9 +178,7 @@ class ZHAGateway: self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self - self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str( - self.application_controller.ieee - ) + self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) self.async_load_devices() self.async_load_groups() @@ -189,7 +187,7 @@ class ZHAGateway: """Restore ZHA devices from zigpy application state.""" for zigpy_device in self.application_controller.devices.values(): zha_device = self._async_get_or_create_device(zigpy_device, restored=True) - if zha_device.ieee == self.application_controller.ieee: + if zha_device.ieee == self.coordinator_ieee: self.coordinator_zha_device = zha_device delta_msg = "not known" if zha_device.last_seen is not None: @@ -435,6 +433,11 @@ class ZHAGateway: ) self.ha_entity_registry.async_remove(entry.entity_id) + @property + def coordinator_ieee(self) -> EUI64: + """Return the active coordinator's IEEE address.""" + return self.application_controller.state.node_info.ieee + @property def devices(self) -> dict[EUI64, ZHADevice]: """Return devices.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 78aea7c9fb6..c97cca8deb3 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -76,7 +76,7 @@ "known_devices": ["Bitron Video AV2010/10"] } ], - "codeowners": ["@dmulcahey", "@adminiuga"], + "codeowners": ["@dmulcahey", "@adminiuga", "@puddly"], "zeroconf": [ { "type": "_esphomelib._tcp.local.", diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py index f05a5cd1872..733a8e99e4b 100644 --- a/tests/components/zha/test_device.py +++ b/tests/components/zha/test_device.py @@ -6,7 +6,9 @@ from unittest.mock import patch import pytest import zigpy.profiles.zha +import zigpy.types import zigpy.zcl.clusters.general as general +import zigpy.zdo.types as zdo_t from homeassistant.components.zha.core.const import ( CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, @@ -42,7 +44,7 @@ def required_platforms_only(): def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" - def _dev(with_basic_channel: bool = True): + def _dev(with_basic_channel: bool = True, **kwargs): in_clusters = [general.OnOff.cluster_id] if with_basic_channel: in_clusters.append(general.Basic.cluster_id) @@ -54,7 +56,7 @@ def zigpy_device(zigpy_device_mock): SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, } } - return zigpy_device_mock(endpoints) + return zigpy_device_mock(endpoints, **kwargs) return _dev @@ -321,3 +323,31 @@ async def test_device_restore_availability( assert hass.states.get(entity_id).state == STATE_OFF else: assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_device_is_active_coordinator(hass, zha_device_joined, zigpy_device): + """Test that the current coordinator is uniquely detected.""" + + current_coord_dev = zigpy_device(ieee="aa:bb:cc:dd:ee:ff:00:11", nwk=0x0000) + current_coord_dev.node_desc = current_coord_dev.node_desc.replace( + logical_type=zdo_t.LogicalType.Coordinator + ) + + old_coord_dev = zigpy_device(ieee="aa:bb:cc:dd:ee:ff:00:12", nwk=0x0000) + old_coord_dev.node_desc = old_coord_dev.node_desc.replace( + logical_type=zdo_t.LogicalType.Coordinator + ) + + # The two coordinators have different IEEE addresses + assert current_coord_dev.ieee != old_coord_dev.ieee + + current_coordinator = await zha_device_joined(current_coord_dev) + stale_coordinator = await zha_device_joined(old_coord_dev) + + # Ensure the current ApplicationController's IEEE matches our coordinator's + current_coordinator.gateway.application_controller.state.node_info.ieee = ( + current_coord_dev.ieee + ) + + assert current_coordinator.is_active_coordinator + assert not stale_coordinator.is_active_coordinator