water_purifier, range: drop invalid device_class='lock' from switches

SwitchDeviceClass only ever supported 'outlet'/'switch', not 'lock'.
switch.py passes desc.device_class straight to SwitchDeviceClass(...),
so any board with these hrefs raised ValueError during switch platform
setup and lost every switch entity for the device, not just the lock
ones (issue #349, TP2X_WATERPURIFIER_20K).

KIDS_LOCK_GENERIC/_VS_FALLBACK dodged this same bug (issues #181/#183)
by moving to a read-only BinarySensorDesc, but the water-purifier and
cooktop locks are genuinely writable, so they stay SwitchDesc and just
drop the invalid device_class (with an mdi:lock icon standing in for
the one entity_category=config gave them for free).

Added a registry-wide test that instantiates SwitchDeviceClass for
every SwitchDesc.device_class across every by_type registry, so a
future capability can't reintroduce the same crash.
This commit is contained in:
Marc Billow
2026-08-10 16:33:39 +00:00
parent 711a71876d
commit 84465aee72
3 changed files with 61 additions and 7 deletions
@@ -148,11 +148,13 @@ COOKTOP_STATUS = Capability(
value_fn=lambda v: str(v).lower() == "on", value_fn=lambda v: str(v).lower() == "on",
), ),
# Safe to write -- a lock toggle, not a heat control -- via a # 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( SwitchDesc(
key="cooktop_child_lock", key="cooktop_child_lock",
field="childLock", field="childLock",
device_class="lock",
entity_category="config", entity_category="config",
icon="mdi:lock", icon="mdi:lock",
value_fn=lambda v: str(v).lower() == "on", value_fn=lambda v: str(v).lower() == "on",
@@ -182,10 +182,15 @@ FAVORITE_HOTWATER = Capability(
# adapter.flatten() only ever honors exists_fn, not entity.py's # adapter.flatten() only ever honors exists_fn, not entity.py's
# implicit field-presence default -- without it, whichever # implicit field-presence default -- without it, whichever
# same-keyed descriptor is processed last would silently win. # 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( SwitchDesc(
key="hotwater_lock", key="hotwater_lock",
field="x.com.samsung.da.switchHotwater", field="x.com.samsung.da.switchHotwater",
device_class="lock", icon="mdi:lock",
entity_category="config", entity_category="config",
value_fn=lambda v: v != "Unlocked", value_fn=lambda v: v != "Unlocked",
exists_fn=lambda rep, resources: ( exists_fn=lambda rep, resources: (
@@ -354,11 +359,13 @@ LOCK = Capability(
entities=( entities=(
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback # Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
# above (issue #144); see the comment there. A stub rep ({}) still # 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( SwitchDesc(
key="hotwater_lock", key="hotwater_lock",
field="x.com.samsung.da.hotwaterLock", field="x.com.samsung.da.hotwaterLock",
device_class="lock", icon="mdi:lock",
entity_category="config", entity_category="config",
value_fn=lambda v: v != "Unlocked", value_fn=lambda v: v != "Unlocked",
exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep, exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep,
@@ -370,7 +377,7 @@ LOCK = Capability(
SwitchDesc( SwitchDesc(
key="coldwater_lock", key="coldwater_lock",
field="x.com.samsung.da.coldwaterLock", field="x.com.samsung.da.coldwaterLock",
device_class="lock", icon="mdi:lock",
entity_category="config", entity_category="config",
value_fn=lambda v: v != "Unlocked", value_fn=lambda v: v != "Unlocked",
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
@@ -381,7 +388,7 @@ LOCK = Capability(
SwitchDesc( SwitchDesc(
key="buzz_lock", key="buzz_lock",
field="x.com.samsung.da.buzzLock", field="x.com.samsung.da.buzzLock",
device_class="lock", icon="mdi:lock",
entity_category="config", entity_category="config",
value_fn=lambda v: v != "Unlocked", value_fn=lambda v: v != "Unlocked",
write_fn=lambda p, rep, href=None: ( write_fn=lambda p, rep, href=None: (
+45
View File
@@ -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 == []