diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index 9941c19..c54fde1 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -148,11 +148,13 @@ COOKTOP_STATUS = Capability( value_fn=lambda v: str(v).lower() == "on", ), # Safe to write -- a lock toggle, not a heat control -- via a - # direct single-field PUT, no RMW needed. + # direct single-field PUT, no RMW needed. No device_class: + # SwitchDeviceClass only has 'outlet'/'switch', not 'lock' -- + # passing it crashed switch platform setup for the whole device + # (issue #349, same bug as water_purifier.py's lock switches). SwitchDesc( key="cooktop_child_lock", field="childLock", - device_class="lock", entity_category="config", icon="mdi:lock", value_fn=lambda v: str(v).lower() == "on", diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py index 671a375..e9a523b 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -182,10 +182,15 @@ FAVORITE_HOTWATER = Capability( # adapter.flatten() only ever honors exists_fn, not entity.py's # implicit field-presence default -- without it, whichever # same-keyed descriptor is processed last would silently win. + # No device_class: SwitchDeviceClass only has 'outlet'/'switch', + # not 'lock' -- passing it crashed switch platform setup entirely + # for the whole device (issue #349), same bug KIDS_LOCK_GENERIC + # dodged by switching to BinarySensorDesc (issues #181/#183). This + # entity stays a SwitchDesc since it's genuinely writable. SwitchDesc( key="hotwater_lock", field="x.com.samsung.da.switchHotwater", - device_class="lock", + icon="mdi:lock", entity_category="config", value_fn=lambda v: v != "Unlocked", exists_fn=lambda rep, resources: ( @@ -354,11 +359,13 @@ LOCK = Capability( entities=( # Shares its key with FAVORITE_HOTWATER's switchHotwater fallback # above (issue #144); see the comment there. A stub rep ({}) still - # counts as "present" here, matching entity.py's own default. + # counts as "present" here, matching entity.py's own default. No + # device_class on any of the three locks below -- see the + # device_class note on FAVORITE_HOTWATER's hotwater_lock (issue #349). SwitchDesc( key="hotwater_lock", field="x.com.samsung.da.hotwaterLock", - device_class="lock", + icon="mdi:lock", entity_category="config", value_fn=lambda v: v != "Unlocked", exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep, @@ -370,7 +377,7 @@ LOCK = Capability( SwitchDesc( key="coldwater_lock", field="x.com.samsung.da.coldwaterLock", - device_class="lock", + icon="mdi:lock", entity_category="config", value_fn=lambda v: v != "Unlocked", write_fn=lambda p, rep, href=None: ( @@ -381,7 +388,7 @@ LOCK = Capability( SwitchDesc( key="buzz_lock", field="x.com.samsung.da.buzzLock", - device_class="lock", + icon="mdi:lock", entity_category="config", value_fn=lambda v: v != "Unlocked", write_fn=lambda p, rep, href=None: ( diff --git a/tests/test_switch_device_class.py b/tests/test_switch_device_class.py new file mode 100644 index 0000000..653df47 --- /dev/null +++ b/tests/test_switch_device_class.py @@ -0,0 +1,45 @@ +"""Guards against a SwitchDesc.device_class HA's SwitchDeviceClass doesn't +recognize -- switch.py passes it straight to SwitchDeviceClass(...), and an +invalid value raises out of the whole switch platform's async_setup_entry, +taking down every switch entity for the device, not just the offending one +(issue #349: water_purifier.py's lock switches used device_class='lock', +which SwitchDeviceClass only ever supported as 'outlet'/'switch'). + +Scans every by_type registry's declared capabilities rather than a specific +fixture, so a new capability introducing the same mistake fails here instead +of only surfacing as a live crash report. +""" + +import importlib +import pkgutil + +from homeassistant.components.switch import SwitchDeviceClass + +from custom_components.localthings.registry import by_type +from custom_components.localthings.registry.entities import SwitchDesc + + +def _all_registries(): + for mod_info in pkgutil.iter_modules(by_type.__path__): + if mod_info.name.startswith("_"): + continue + mod = importlib.import_module( + f"custom_components.localthings.registry.by_type.{mod_info.name}" + ) + reg = getattr(mod, "REGISTRY", None) + if reg is not None: + yield reg + + +def test_every_switchdesc_device_class_is_valid_for_ha(): + bad = [] + for reg in _all_registries(): + caps = [c for cs in reg.capabilities.values() for c in cs] + list(reg.pattern_capabilities) + for cap in caps: + for entity in cap.entities: + if isinstance(entity, SwitchDesc) and entity.device_class is not None: + try: + SwitchDeviceClass(entity.device_class) + except ValueError: + bad.append((reg.name, entity.key, entity.device_class)) + assert bad == []