diff --git a/custom_components/xtool/__init__.py b/custom_components/xtool/__init__.py index 23dbadb..688a31c 100644 --- a/custom_components/xtool/__init__.py +++ b/custom_components/xtool/__init__.py @@ -193,6 +193,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: XtoolConfigEntry) -> boo entry.runtime_data = coordinator + # Register the parent before platforms add accessory entities concurrently. + # Reuse the laser entities' metadata and keep the registry id for via_device_id. + from .entity import XtoolEntity + + laser_device = dr.async_get(hass).async_get_or_create( + config_entry_id=entry.entry_id, + **XtoolEntity(coordinator).device_info, + ) + coordinator.laser_device_id = laser_device.id + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_options_updated)) diff --git a/custom_components/xtool/coordinator.py b/custom_components/xtool/coordinator.py index e6eb1b6..d08babb 100644 --- a/custom_components/xtool/coordinator.py +++ b/custom_components/xtool/coordinator.py @@ -124,6 +124,7 @@ def __init__( self.protocol = protocol self.device_name = device_name self.serial_number = serial_number + self.laser_device_id: str | None = None self.firmware_version = firmware_version self.model = model self.laser = LaserInfo() diff --git a/custom_components/xtool/protocols/accessories/entities.py b/custom_components/xtool/protocols/accessories/entities.py index 1109052..0a61f22 100644 --- a/custom_components/xtool/protocols/accessories/entities.py +++ b/custom_components/xtool/protocols/accessories/entities.py @@ -11,7 +11,7 @@ - Sits under a **child device** in the HA device registry (``identifiers={(DOMAIN, "::")}``, - ``via_device=(DOMAIN, "")``) so the laser device + ``via_device_id=``) so the laser device groups its accessories visually. - Reports ``available=False`` whenever the accessory is no longer in ``state.connected_accessories`` — entities aren't deleted so @@ -64,6 +64,14 @@ _LOGGER = logging.getLogger(__name__) +def _via_device_info(coordinator: XtoolCoordinator) -> DeviceInfo: + """Link accessories to the laser across supported HA registry versions.""" + if "via_device_id" in DeviceInfo.__annotations__: + return DeviceInfo(via_device_id=coordinator.laser_device_id) + # Home Assistant before 2026.8 only accepts the identifier tuple. + return {"via_device": (DOMAIN, coordinator.serial_number)} + + def _entity_category(value: str | None) -> EntityCategory | None: if value == "diagnostic": return EntityCategory.DIAGNOSTIC @@ -149,7 +157,7 @@ def device_info(self) -> DeviceInfo: ) return DeviceInfo( identifiers={(DOMAIN, f"{laser_sid}:{self._accessory_key}")}, - via_device=(DOMAIN, laser_sid), + **_via_device_info(self.coordinator), name=self._definition.friendly_name, manufacturer="xTool", model=self._definition.friendly_name, @@ -540,7 +548,7 @@ def device_info(self) -> DeviceInfo: ) return DeviceInfo( identifiers={(DOMAIN, f"{laser_sid}:{self._accessory_key}")}, - via_device=(DOMAIN, laser_sid), + **_via_device_info(self.coordinator), name=self._definition.friendly_name, manufacturer="xTool", model=self._definition.friendly_name, diff --git a/tests/test_accessory_device_info.py b/tests/test_accessory_device_info.py new file mode 100644 index 0000000..5e503c3 --- /dev/null +++ b/tests/test_accessory_device_info.py @@ -0,0 +1,85 @@ +"""Accessory parent-link regression tests; run with Home Assistant installed. + +python -m unittest discover -s tests +""" + +from pathlib import Path +import sys +from types import ModuleType, SimpleNamespace +import unittest +from unittest.mock import patch + +# Load the entity modules without running integration setup. The protocol +# package eagerly imports every model/coordinator and otherwise forms an +# import cycle outside Home Assistant's integration loader. +_ROOT = Path.cwd() / "custom_components" / "xtool" +for _name, _path in ( + ("custom_components.xtool", _ROOT), + ("custom_components.xtool.protocols", _ROOT / "protocols"), +): + if _name not in sys.modules: + _package = ModuleType(_name) + _package.__path__ = [str(_path)] + sys.modules[_name] = _package + +from custom_components.xtool.protocols.accessories import entities +from custom_components.xtool.protocols.accessories.fire_extinguisher import ( + FIRE_EXTINGUISHER_V1_5, +) +from custom_components.xtool.protocols.base import AccessoryState + + +class AccessoryDeviceInfoTests(unittest.TestCase): + """The firmware-only extinguisher must link without deprecated HA APIs.""" + + def setUp(self): + self.coordinator = SimpleNamespace( + serial_number="laser-serial", + laser_device_id="laser-registry-id", + model=SimpleNamespace(model_id="S1"), + enable_firmware_updates=False, + data=SimpleNamespace(connected_accessories={ + "FireExtinguisherV1_5:slot4": AccessoryState( + type_id="FireExtinguisherV1_5", sn="slot4", + fields={"version": "V40.203.002.3224.01B01"}, + ), + }), + ) + + def test_firmware_only_accessory_uses_registry_parent_id(self): + with patch.object(entities.DeviceInfo, "__annotations__", {"via_device_id": str}): + accessory = self.coordinator.data.connected_accessories[ + "FireExtinguisherV1_5:slot4" + ] + built = entities.build_accessory_entities(self.coordinator, accessory) + self.assertEqual(len(built), 1) + entity = built[0] + self.assertIsInstance(entity, entities._AccessoryUpdate) + self.assertEqual(entity.device_info["via_device_id"], "laser-registry-id") + self.assertNotIn("via_device", entity.device_info) + self.assertEqual(entity.installed_version, "V40.203.002.3224.01B01") + self.assertEqual(entity.device_info["identifiers"], { + ("xtool", "laser-serial:FireExtinguisherV1_5:slot4"), + }) + self.assertIsNone(entity.device_info["serial_number"]) + + def test_regular_accessory_entities_use_same_parent(self): + with patch.object(entities.DeviceInfo, "__annotations__", {"via_device_id": str}): + entity = entities._AccessorySensor( + self.coordinator, FIRE_EXTINGUISHER_V1_5, "slot4", + FIRE_EXTINGUISHER_V1_5.entities[0], + ) + self.assertEqual(entity.device_info["via_device_id"], "laser-registry-id") + self.assertNotIn("via_device", entity.device_info) + + def test_older_home_assistant_keeps_legacy_parent_link(self): + with patch.object(entities.DeviceInfo, "__annotations__", {"via_device": tuple}): + entity = entities._AccessoryUpdate( + self.coordinator, FIRE_EXTINGUISHER_V1_5, "slot4", + ) + self.assertEqual(entity.device_info["via_device"], ("xtool", "laser-serial")) + self.assertNotIn("via_device_id", entity.device_info) + + +if __name__ == "__main__": + unittest.main()