Skip to content
Open
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
10 changes: 10 additions & 0 deletions custom_components/xtool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions custom_components/xtool/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 11 additions & 3 deletions custom_components/xtool/protocols/accessories/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

- Sits under a **child device** in the HA device registry
(``identifiers={(DOMAIN, "<laser_serial>:<type>:<acc_serial>")}``,
``via_device=(DOMAIN, "<laser_serial>")``) so the laser device
``via_device_id=<laser device registry 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
85 changes: 85 additions & 0 deletions tests/test_accessory_device_info.py
Original file line number Diff line number Diff line change
@@ -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()