fix: address code review feedback on issue-triage batch PR (#181, #183, #189, #196, #207, #208, #210)

common:
- Make KIDS_LOCK_GENERIC also a read-only BinarySensorDesc (device_class='lock'),
  flipping value_fn to not bool(v) so /kidslock/0 value=False and
  /kidslock/vs/0 kidsLock='Ready' render with the same polarity ('On'
  means open/unlocked per HA's lock device class). The old SwitchDesc
  form never honored device_class='lock' -- HA's switch platform only
  accepts 'outlet'/'switch' -- so the surface was a plain switch whose
  'On' meaning drifted across boards. Tests updated.

air_monitor:
- Add state_class='measurement' to dust/fine_dust/super_fine_dust so
  the readings feed HA long-term statistics (co2 already had it).
- Import _AIR_QUALITY_SENSORS from air_purifier instead of duplicating
  it byte-for-byte; update common.sensor_item_value's docstring to
  mention the third caller.

by_type/__init__: drop trailing whitespace on the new 'ASM' line.

translations/en.json + nl.json: move the new 'dnd' switch entry to its
correct alphabetical position (after display_light, before fast_preheat).

SKILL.md: add an explicit read-side rule to §5's educated-guesses
section -- guessed unit/device_class/state_class on a SensorDesc
silently mislabels readings forever with no 4.xx to catch it (unlike
guessed writes, which the device rejects). The prior air_monitor
docstring cited this carve-out as if it existed; now it does.

tests/water_purifier (issue #196): change the ailite fixture's
favorite.defaultTemperature from '85' to '50' so the test actually
reproduces the reported bug -- '50' is in showList only, so a
descriptor reading from supportedList would fail the assertion that
the current default is in its options list.
This commit is contained in:
Marc Billow committed 2026-07-31 15:17:18 -05:00
1 parent a7d57086e9
commit b7d3f86ec7
9 files changed
+83 -56

No files matched your search

@@ -114,7 +114,7 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
'DF': 'air_dresser', # issue #162
'VSWW': 'vacuum_station', # issue #219
'ASM': 'air_monitor', # issue #210 -- Air Monitor Plus
'ASM': 'air_monitor', # issue #210 -- Air Monitor Plus
}
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
@@ -19,40 +19,32 @@ rule. Same reasoning `air_purifier.AIR_QUALITY` already applies to this
shape; index 0 is the only slot any family has ever read.
Dust/FineDust/SuperFineDust aren't assigned an HA `device_class`
(pm10/pm25/pm1) despite the values reading like plausible ug/m3
particulate readings in a physically consistent order (coarser >= finer):
Samsung's own two-tier Korean convention (i.e. "fine dust"/"ultra-fine
dust") maps only to a PM10/PM2.5 pair, and this board's three-tier
naming doesn't confirm where the extra tier or a PM1 reading actually
fits. Guessing wrong here would silently mislabel what unit a user reads
on a graph forever, not just fail one write -- the "worst case is
rejected" case for a flagged write guess doesn't cover a read-side
device_class/unit guess, per the skill's own carve-out. Exposed as plain
measurement sensors named after the device's own field instead (matching
air_purifier.AIR_QUALITY's existing precedent).
(pm10/pm25/pm1) or `unit` despite the values reading like plausible
ug/m3 particulate readings in a physically consistent order (coarser
>= finer): Samsung's own two-tier Korean convention (i.e. "fine dust"/
"ultra-fine dust") maps only to a PM10/PM2.5 pair, and this board's
three-tier naming doesn't confirm where the extra tier or a PM1 reading
actually fits. The adding-device-support skill's read-side rule says
leave unit/device_class unset when the dump gives no field that
nominates one -- a wrong guess would silently mislabel every reading
forever, and the write-side rejection safety net doesn't cover reads.
Exposed as plain `measurement` sensors named after the device's own
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
"""
from datetime import time as dt_time
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
_AIR_QUALITY_SENSORS = (
('dust', 'mdi:blur', 'Dust'),
('fine_dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'mdi:scent', 'Odor'),
('clean_level', 'mdi:air-filter', 'CleanLevel'),
)
SENSORS = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
# No state_class here, matching air_purifier.AIR_QUALITY's existing
# descriptors exactly -- these keys are shared catalog entries with
# that capability, so the two should behave identically.
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
state_class='measurement',
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
) + (
@@ -228,8 +228,9 @@ def sensor_item_value(items, sensor_type, index=0):
"""Pull one reading out of a `/sensors/vs/0`-style items[] list -- each
item is `{type, value: [...]}`; `index` picks which slot of a possibly
multi-value reading to read (index 0 is the raw measurement on every
family seen so far). Shared by range_hood.AIR_QUALITY and
air_purifier.AIR_QUALITY, which read the same resource shape."""
family seen so far). Shared by range_hood.AIR_QUALITY,
air_purifier.AIR_QUALITY, and air_monitor.SENSORS, which all read the
same resource shape against the same {type, sensor_type} keys."""
for item in items or ():
if not isinstance(item, dict):
continue
@@ -301,11 +302,19 @@ POWER_VS_FALLBACK = Capability(
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
entities=(
SwitchDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: bool(v),
write_fn=lambda p, rep, href=None: (
['kidslock', '0'], {'value': p == 'On'})),
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a
# SwitchDesc. SwitchDesc's `device_class='lock'` was never honored
# by HA (its switch platform only accepts 'outlet'/'switch'),
# leaving a plain switch whose 'On' state meant different things
# on different boards. As a BinarySensorDesc with `device_class='lock'`,
# both kids-lock surfaces read with the same polarity: 'On' means
# open/unlocked, per HA's lock device_class. The inversion in
# value_fn here (and in the fallback below) keeps the on-the-wire
# truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0
# both mean kids lock NOT active) consistent with that polarity.
BinarySensorDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: not bool(v)),
),
)
@@ -320,12 +329,12 @@ KIDS_LOCK_VS_FALLBACK = Capability(
# confirmed this directly: writing the *correct* value ('Run')
# still 4.05s, and the SmartThings app itself has no control for
# it either -- the resource is genuinely read-only on this
# hardware, not just wrong-valued. binary_sensor's 'lock' device
# class is inverted from the switch reading below it used to be:
# On means open/unlocked, so value_fn flips to `v == 'Ready'`.
# hardware, not just wrong-valued. Polarity matches
# KIDS_LOCK_GENERIC above -- 'On' means open/unlocked, so
# kidsLock='Ready' (kids lock NOT active) renders as 'On'.
BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v == 'Ready'),
device_class='lock',
value_fn=lambda v: v == 'Ready'),
),
)
@@ -1008,12 +1008,12 @@
"defrost_delay": {
"name": "Defrost delay"
},
"dnd": {
"name": "Do not disturb"
},
"display_light": {
"name": "Display light"
},
"dnd": {
"name": "Do not disturb"
},
"fast_preheat": {
"name": "Fast preheat"
},
@@ -1008,12 +1008,12 @@
"defrost_delay": {
"name": "Ontdooien uitstellen"
},
"dnd": {
"name": "Niet storen"
},
"display_light": {
"name": "Displayverlichting"
},
"dnd": {
"name": "Niet storen"
},
"fast_preheat": {
"name": "Snel voorverwarmen"
},