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:
@@ -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: (
|
||||||
|
|||||||
@@ -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 == []
|
||||||
Reference in New Issue
Block a user