From 1a646f6011c34e79a1203f34737f5c2fa0c03e65 Mon Sep 17 00:00:00 2001 From: Colin Summers Date: Mon, 20 Jul 2026 12:48:15 -0400 Subject: [PATCH 1/8] Fix KeyError in device cleanup on Python 3.14 async_remove_device raises KeyError when the device has already been removed from the registry (e.g. by a prior iteration of the cleanup loop). Python 3.14's ReadOnlyDict.pop() surfaces this as an unhandled exception, causing the entire integration setup to fail with setup_error. Wrap the call in try/except to handle the race gracefully. Co-Authored-By: Claude Opus 4.6 --- custom_components/eero/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/custom_components/eero/__init__.py b/custom_components/eero/__init__.py index aff8889..615a183 100755 --- a/custom_components/eero/__init__.py +++ b/custom_components/eero/__init__.py @@ -346,7 +346,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b device_entry.name, device_entry.model, ) - device_registry.async_remove_device(device_entry.id) + try: + device_registry.async_remove_device(device_entry.id) + except (KeyError, ValueError): + pass else: for entity_entry in er.async_entries_for_device( entity_registry, device_entry.id From 7488d0783a3f95a24ecbeb46206114f51bd3c2cf Mon Sep 17 00:00:00 2001 From: Colin Summers Date: Mon, 20 Jul 2026 17:48:02 -0400 Subject: [PATCH 2/8] Fix device tracker zone counting by switching to BaseScannerEntity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HA 2026.7 changed zone/person tracking to rely on the in_zones state attribute. The old manual state/state_attributes approach doesn't set in_zones, so zone.home reports 0 persons for router-based trackers. BaseScannerEntity is the intended base class for connection-based trackers — it derives state from is_connected and handles in_zones automatically. The existing is_connected, source_type, ip_address, mac_address, and hostname properties are exactly what it needs. Closes #3 Co-Authored-By: Claude Opus 4.6 --- custom_components/eero/device_tracker.py | 32 +++--------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/custom_components/eero/device_tracker.py b/custom_components/eero/device_tracker.py index 1cc2766..478395f 100755 --- a/custom_components/eero/device_tracker.py +++ b/custom_components/eero/device_tracker.py @@ -5,21 +5,17 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, final +from typing import Any from homeassistant.components.device_tracker import ( - ATTR_HOST_NAME, - ATTR_IP, - ATTR_MAC, - ATTR_SOURCE_TYPE, + BaseScannerEntity, SourceType, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MANUFACTURER, STATE_HOME, STATE_NOT_HOME +from homeassistant.const import ATTR_MANUFACTURER from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util @@ -100,7 +96,7 @@ async def async_setup_entry( async_add_entities(entities) -class EeroDeviceTrackerEntity(EeroEntity): +class EeroDeviceTrackerEntity(EeroEntity, BaseScannerEntity): """Representation of an Eero device tracker entity.""" _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -176,26 +172,6 @@ def hostname(self) -> str | None: return self.resource.hostname return None - @property - def state(self) -> str: - """Return the state of the device.""" - if self.is_connected: - return STATE_HOME - return STATE_NOT_HOME - - @final - @property - def state_attributes(self) -> dict[str, StateType]: - """Return the device state attributes.""" - attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type} - if ip_address := self.ip_address: - attr[ATTR_IP] = ip_address - if mac_address := self.mac_address: - attr[ATTR_MAC] = mac_address - if hostname := self.hostname: - attr[ATTR_HOST_NAME] = hostname - return attr - @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return entity specific state attributes. From be459abb4e7d9abb206a801be5ad459b87345319 Mon Sep 17 00:00:00 2001 From: Colin Summers Date: Mon, 20 Jul 2026 18:03:55 -0400 Subject: [PATCH 3/8] PP pass: fix brightness-0 bug, KeyError guard, dead code, typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api/eero.py:143: set_status_light_brightness(0) returned the bound method instead of calling it — added missing parentheses - __init__.py:401: conf_activity[network_id] → .get(network_id, {}) to guard against KeyError on migrated config entries - __init__.py: fixed copy-pasted "Return the state attributes" docstrings on network and resource properties - api/__init__.py:284: removed dead file.close() after with block - api/network.py:87: "Adblock dasy" → "Adblock day" Co-Authored-By: Claude Opus 4.6 --- custom_components/eero/__init__.py | 6 +++--- custom_components/eero/api/__init__.py | 1 - custom_components/eero/api/eero.py | 2 +- custom_components/eero/api/network.py | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/custom_components/eero/__init__.py b/custom_components/eero/__init__.py index 615a183..6b1607f 100755 --- a/custom_components/eero/__init__.py +++ b/custom_components/eero/__init__.py @@ -398,7 +398,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b conf_update = {} for network_id, resources in conf_resources.items(): conf_update[network_id] = EeroUpdateConfig( - activity=conf_activity[network_id], + activity=conf_activity.get(network_id, {}), profiles=resources[CONF_PROFILES], get_backup_access_points=bool(resources[CONF_BACKUP_NETWORKS]), get_devices=any( @@ -573,7 +573,7 @@ def __init__( @property def network(self) -> EeroNetwork | None: - """Return the state attributes.""" + """Return the network for this entity.""" for network in self.coordinator.data.networks: if network.id == self.network_id: return network @@ -581,7 +581,7 @@ def network(self) -> EeroNetwork | None: @property def resource(self) -> EeroResource | None: - """Return the state attributes.""" + """Return the resource for this entity.""" if self.resource_id: for resource in self.network.resources: if resource.id == self.resource_id: diff --git a/custom_components/eero/api/__init__.py b/custom_components/eero/api/__init__.py index d44a735..5611776 100755 --- a/custom_components/eero/api/__init__.py +++ b/custom_components/eero/api/__init__.py @@ -281,7 +281,6 @@ def save_response(self, response: dict[str, Any] | None, name="response") -> Non default=lambda o: "not-serializable", sort_keys=True, ) - file.close() def update( self, diff --git a/custom_components/eero/api/eero.py b/custom_components/eero/api/eero.py index 1ec3f25..0f2c6af 100755 --- a/custom_components/eero/api/eero.py +++ b/custom_components/eero/api/eero.py @@ -140,7 +140,7 @@ def set_status_light_brightness(self, value: int) -> None: if not isinstance(value, int): return None if not value: - return self.set_status_light_off + return self.set_status_light_off() self.api.call( method=METHOD_PUT, url=self.url_led, diff --git a/custom_components/eero/api/network.py b/custom_components/eero/api/network.py index 8dc1ad3..9bf582d 100755 --- a/custom_components/eero/api/network.py +++ b/custom_components/eero/api/network.py @@ -84,7 +84,7 @@ def ad_block_status(self) -> str: @property def adblock_day(self) -> int | None: - """Adblock dasy.""" + """Adblock day.""" for series in ( self.data.get("activity", {}).get("network", {}).get("adblock_day", []) ): From 22420a4c5d745f2dfe9d8274c9d9404fc7193f9b Mon Sep 17 00:00:00 2001 From: Colin Summers Date: Tue, 21 Jul 2026 18:11:09 -0400 Subject: [PATCH 4/8] Fix AttributeError in channel_width_rx/tx for wired clients Wired clients return None for connectivity sub-keys rather than omitting them. Use `or {}` to guard against explicit None values. Fixes #4 Co-Authored-By: Claude Opus 4.6 --- custom_components/eero/api/client.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/custom_components/eero/api/client.py b/custom_components/eero/api/client.py index 939f077..58da460 100755 --- a/custom_components/eero/api/client.py +++ b/custom_components/eero/api/client.py @@ -95,19 +95,17 @@ def channel(self) -> int | None: def channel_width_rx(self) -> str | None: """Channel width RX.""" return ( - self.data.get("connectivity", {}) - .get("rx_rate_info", {}) - .get("channel_width") - ) + (self.data.get("connectivity") or {}) + .get("rx_rate_info") or {} + ).get("channel_width") @property def channel_width_tx(self) -> str | None: """Channel width TX.""" return ( - self.data.get("connectivity", {}) - .get("tx_rate_info", {}) - .get("channel_width") - ) + (self.data.get("connectivity") or {}) + .get("tx_rate_info") or {} + ).get("channel_width") @property def connected(self) -> bool | None: From e492e681aa8a1677ca3f7025ae95e80a1d5f25eb Mon Sep 17 00:00:00 2001 From: Colin Summers Date: Tue, 21 Jul 2026 18:12:05 -0400 Subject: [PATCH 5/8] Fix duplicated device name in entity IDs Migrate to has_entity_name pattern so Home Assistant does not prepend the device name twice when generating entity IDs. Move network prefix and connection type suffix into device_info.name and return only the entity description name from the name property. Fixes #6 --- custom_components/eero/__init__.py | 40 +++++++++++------------- custom_components/eero/device_tracker.py | 11 ------- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/custom_components/eero/__init__.py b/custom_components/eero/__init__.py index 6b1607f..a3a10c4 100755 --- a/custom_components/eero/__init__.py +++ b/custom_components/eero/__init__.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, Platform +from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, Platform, UNDEFINED from homeassistant.core import HomeAssistant from homeassistant.helpers import ( config_validation as cv, @@ -565,6 +565,7 @@ def __init__( ) -> None: """Initialize device.""" super().__init__(coordinator) + self._attr_has_entity_name = True self.network_id = network_id self.resource_id = resource_id self.entity_description = description @@ -595,13 +596,24 @@ def unique_id(self) -> str: return f"{self.network.id}-{self.entity_description.key}" return f"{self.network.id}-{self.resource.id}-{self.entity_description.key}" + def _device_name(self) -> str: + """Return the Home Assistant device name for this resource.""" + if self.resource.is_client and self.suffix_connection_type: + name = self.resource.name_connection_type + else: + name = self.resource.name + + if not self.resource.is_network and self.prefix_network_name: + return f"{self.network.name} {name}" + return name + @property def device_info(self) -> dr.DeviceInfo: """Return device specific attributes. Implemented by platform classes. """ - name = self.resource.name + name = self._device_name() if self.resource.is_network: model = MODEL_NETWORK elif self.resource.is_backup_network: @@ -614,8 +626,6 @@ def device_info(self) -> dr.DeviceInfo: model = ( MODEL_CLIENT_WIRELESS if self.resource.wireless else MODEL_CLIENT_WIRED ) - if self.suffix_connection_type: - name = self.resource.name_connection_type entry_type, suggested_area, sw_version, hw_version, via_device = ( None, @@ -659,25 +669,11 @@ def device_info(self) -> dr.DeviceInfo: ) @property - def name(self) -> str: + def name(self) -> str | None: """Return the name of the entity.""" - if self.resource.is_client: - name = self.resource.name - if self.suffix_connection_type: - name = self.resource.name_connection_type - if self.prefix_network_name: - name = f"{self.network.name} {name}" - return f"{name} {self.entity_description.name}" - if ( - self.resource.is_backup_network - or self.resource.is_eero - or self.resource.is_profile - ): - name = f"{self.resource.name} {self.entity_description.name}" - if self.prefix_network_name: - name = f"{self.network.name} {name}" - return name - return f"{self.resource.name} {self.entity_description.name}" + if self.entity_description.name in (None, UNDEFINED): + return None + return self.entity_description.name @dataclass diff --git a/custom_components/eero/device_tracker.py b/custom_components/eero/device_tracker.py index 478395f..8854584 100755 --- a/custom_components/eero/device_tracker.py +++ b/custom_components/eero/device_tracker.py @@ -122,17 +122,6 @@ def __init__( ) self.last_seen: datetime | None = None - @property - def name(self) -> str | None: - """Return the name of the entity.""" - if self.resource.is_client and self.suffix_connection_type: - name = self.resource.name_connection_type - else: - name = self.resource.name - if self.prefix_network_name: - return f"{self.network.name} {name}" - return name - @property def is_connected(self) -> bool | None: """Return true if the device is connected to the network.""" From 677b38b4746d382a8c71c87990bec53cfe46552a Mon Sep 17 00:00:00 2001 From: Colin Summers Date: Tue, 21 Jul 2026 18:12:42 -0400 Subject: [PATCH 6/8] Fix TypeError when data usage values are None Offline or inactive devices return (None, None) for data usage. Coalesce None to 0 before summing download and upload bytes. Fixes #5 Co-Authored-By: Claude Opus 4.6 --- custom_components/eero/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/eero/sensor.py b/custom_components/eero/sensor.py index 7cb472f..75d8c3c 100755 --- a/custom_components/eero/sensor.py +++ b/custom_components/eero/sensor.py @@ -157,7 +157,7 @@ class EeroSensorEntityDescription(EeroEntityDescription, SensorEntityDescription device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.TOTAL_INCREASING, native_value=lambda resource, key: ( - getattr(resource, key)[0] + getattr(resource, key)[1] + (getattr(resource, key)[0] or 0) + (getattr(resource, key)[1] or 0) ), native_unit_of_measurement=UnitOfInformation.BYTES, activity_type=True, @@ -168,7 +168,7 @@ class EeroSensorEntityDescription(EeroEntityDescription, SensorEntityDescription device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.TOTAL_INCREASING, native_value=lambda resource, key: ( - getattr(resource, key)[0] + getattr(resource, key)[1] + (getattr(resource, key)[0] or 0) + (getattr(resource, key)[1] or 0) ), native_unit_of_measurement=UnitOfInformation.BYTES, activity_type=True, @@ -179,7 +179,7 @@ class EeroSensorEntityDescription(EeroEntityDescription, SensorEntityDescription device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.TOTAL_INCREASING, native_value=lambda resource, key: ( - getattr(resource, key)[0] + getattr(resource, key)[1] + (getattr(resource, key)[0] or 0) + (getattr(resource, key)[1] or 0) ), native_unit_of_measurement=UnitOfInformation.BYTES, activity_type=True, From 9f1aca9d8808b5e29da85ffaafd42c76bb55a8da Mon Sep 17 00:00:00 2001 From: Colin Summers Date: Wed, 22 Jul 2026 16:07:18 -0400 Subject: [PATCH 7/8] =?UTF-8?q?Fix=20UNDEFINED=20import=20path=20=E2=80=94?= =?UTF-8?q?=20was=20breaking=20integration=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UNDEFINED lives in homeassistant.helpers.typing, not homeassistant.const. The wrong import prevented the entire eero integration from loading on HA 2026.7.x. Co-Authored-By: Claude Opus 4.6 --- custom_components/eero/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/custom_components/eero/__init__.py b/custom_components/eero/__init__.py index a3a10c4..b58f3f4 100755 --- a/custom_components/eero/__init__.py +++ b/custom_components/eero/__init__.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, Platform, UNDEFINED +from homeassistant.const import CONF_NAME, CONF_SCAN_INTERVAL, Platform +from homeassistant.helpers.typing import UNDEFINED from homeassistant.core import HomeAssistant from homeassistant.helpers import ( config_validation as cv, From ed4a5971fd996ef9dd98823705f7b8df1e79658f Mon Sep 17 00:00:00 2001 From: Colin Summers Date: Tue, 4 Aug 2026 08:40:41 -0400 Subject: [PATCH 8/8] Fix duplicate version in update entity title The eero API returns a title like "eeroOS 7.16.1" which already embeds the version number. HA's update card appends latest_version alongside the title, resulting in "eeroOS 7.16.1 v7.16.1". Strip the version suffix from the title so it displays as "eeroOS v7.16.1". Co-Authored-By: Claude Opus 4.6 --- custom_components/eero/update.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/eero/update.py b/custom_components/eero/update.py index bfa50be..3d200de 100755 --- a/custom_components/eero/update.py +++ b/custom_components/eero/update.py @@ -138,7 +138,11 @@ def title(self) -> str | None: This helps to differentiate between the device or entity name versus the title of the software installed. """ - return self.resource.target_firmware.title + # The API returns e.g. "eeroOS 7.16.1" but HA appends latest_version + # alongside the title, causing duplication. Strip the version suffix. + if title := self.resource.target_firmware.title: + return title.rsplit(" ", 1)[0] if " " in title else title + return None def install(self, version: str | None, backup: bool, **kwargs: Any) -> None: """Install an update.