Skip to content

Commit b1189ce

Browse files
carlossgclaude
andcommitted
fix(docker): register hw listener and match by-id paths for options-based devices
Two bugs caused a crash loop when a USB device re-enumerates to a different minor number (e.g. ttyACM0→ttyACM1) after a HAOS reboot: 1. _hw_listener was only registered when addon.static_devices was non-empty. Addons that expose a device via the options schema (e.g. Z-Wave JS `device:` option) never had the listener registered, so add_devices_allowed was never called when the device reappeared at a new minor. 2. _hardware_events matched only device.path and device.sysfs against static_devices. When static_devices (or the new options path) contains a by-id symlink, the match always failed because by-id paths live in device.links. Fix: extend the listener registration condition to also cover addon.devices (options-based), and expand the path-matching set to include device.links so by-id paths resolve correctly. For options-based devices, compare the incoming Device against addon.devices (which re-evaluates options.json against the live hardware list, picking up the new minor number automatically). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b11eeef commit b1189ce

2 files changed

Lines changed: 83 additions & 6 deletions

File tree

supervisor/docker/app.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -618,8 +618,9 @@ async def run(self) -> None:
618618
_LOGGER.warning("Can't update DNS for %s", self.name)
619619
await async_capture_exception(err)
620620

621-
# Hardware Access
622-
if self.app.static_devices:
621+
# Hardware Access — register listener for both manifest static_devices and
622+
# options-based devices (e.g. Z-Wave JS `device:` option).
623+
if self.app.static_devices or self.app.devices:
623624
self._hw_listener = self.sys_bus.register_event(
624625
BusEvent.HARDWARE_NEW_DEVICE, self._hardware_events
625626
)
@@ -887,10 +888,16 @@ async def stop(self, remove_container: bool = True) -> None:
887888
)
888889
async def _hardware_events(self, device: Device) -> None:
889890
"""Process Hardware events for adjust device access."""
890-
if not any(
891-
device_path in (device.path, device.sysfs)
892-
for device_path in self.app.static_devices
893-
):
891+
# Build the full set of paths this device is known by (path, sysfs, and all
892+
# by-id / by-path symlinks), so we match even when static_devices stores a
893+
# by-id path rather than the raw /dev/ttyACMx path.
894+
device_all_paths = {device.path, device.sysfs} | set(device.links)
895+
static_match = bool(device_all_paths & set(self.app.static_devices))
896+
# Also check options-based devices (e.g. Z-Wave JS `device:` option).
897+
# addon.devices re-evaluates from options.json against the current hardware
898+
# list, so it will resolve to the newly enumerated device (e.g. ttyACM1).
899+
options_match = device in self.app.devices
900+
if not static_match and not options_match:
894901
return
895902

896903
try:

tests/docker/test_app.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,3 +572,73 @@ async def test_ulimits_integration(coresys: CoreSys, install_app_ssh: App):
572572
assert core_limit is not None
573573
assert core_limit.soft == 0
574574
assert core_limit.hard == 0
575+
576+
577+
# --- Tests for fix: register hw listener and match by-id paths for options-based devices ---
578+
579+
TEST_BY_ID_PATH = str(TEST_HW_DEVICE.links[0])
580+
581+
582+
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
583+
async def test_app_new_device_by_id_link(
584+
coresys: CoreSys,
585+
install_app_ssh: App,
586+
container: MagicMock,
587+
docker: DockerAPI,
588+
):
589+
"""Test hardware event matches a by-id symlink stored in static_devices."""
590+
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
591+
# Configure the device via its by-id symlink, not the raw /dev/ttyACMx path.
592+
# Before the fix, _hardware_events only checked device.path / device.sysfs and
593+
# would never match a by-id path, so add_devices_allowed was never called.
594+
install_app_ssh.data["devices"] = [TEST_BY_ID_PATH]
595+
container.id = 123
596+
597+
with (
598+
patch.object(App, "write_options"),
599+
patch.object(OSManager, "available", new=PropertyMock(return_value=True)),
600+
patch.object(
601+
CGroup, "add_devices_allowed", new_callable=AsyncMock
602+
) as add_devices,
603+
):
604+
await install_app_ssh.start()
605+
await fire_bus_event(coresys, BusEvent.HARDWARE_NEW_DEVICE, TEST_HW_DEVICE)
606+
607+
add_devices.assert_called_once_with(123, "c 0:0 rwm")
608+
609+
610+
@pytest.mark.usefixtures("path_extern", "tmp_supervisor_data")
611+
async def test_app_options_device_hw_listener(
612+
coresys: CoreSys,
613+
install_app_ssh: App,
614+
container: MagicMock,
615+
docker: DockerAPI,
616+
):
617+
"""Test hw_listener is registered and fires for options-based devices.
618+
619+
Before the fix, _hw_listener was only registered when addon.static_devices
620+
was non-empty. Add-ons like Z-Wave JS that configure the device via the
621+
options schema (addon.devices) never had a listener, so cgroup permissions
622+
were never updated after a USB re-enumeration.
623+
"""
624+
coresys.hardware.disk.get_disk_free_space = lambda x: 5000
625+
install_app_ssh.data["devices"] = [] # no static devices
626+
container.id = 123
627+
docker._info = replace(docker.info, cgroup="1") # pylint: disable=protected-access
628+
629+
with (
630+
patch.object(App, "write_options"),
631+
# HAOS not available: proactive cgroup call is skipped, so add_devices is
632+
# called exactly once — from the hardware event via the options listener.
633+
patch.object(OSManager, "available", new=PropertyMock(return_value=False)),
634+
patch.object(
635+
App, "devices", new_callable=PropertyMock, return_value={TEST_HW_DEVICE}
636+
),
637+
patch.object(
638+
CGroup, "add_devices_allowed", new_callable=AsyncMock
639+
) as add_devices,
640+
):
641+
await install_app_ssh.start()
642+
await fire_bus_event(coresys, BusEvent.HARDWARE_NEW_DEVICE, TEST_HW_DEVICE)
643+
644+
add_devices.assert_called_once_with(123, "c 0:0 rwm")

0 commit comments

Comments
 (0)