feat: add Samsung Air Monitor Plus support (#210)

New device family: a standalone, battery-powered air-quality sensor
puck (ASM-KR-TP1-22-* board) with no controllable-appliance state at
all -- no /power/*, only /energy/battery/vs/0. Routes via both /oic/d
('x.com.st.d.airqualitysensor', confirmed against the real dump) and
the 'ASM' modelNum board token as a fallback.

/sensors/vs/0 reuses air_purifier.AIR_QUALITY's existing {type, value}
items-list decode (common.sensor_item_value) for dust/fine_dust/
super_fine_dust/odor/clean_level, sharing those capabilities' catalog
entries, and adds a CO2 reading those families don't report. The
particulate sensors deliberately get no pm10/pm25/pm1 device_class:
the values read as physically consistent (coarser >= finer) but
Samsung's own two-tier dust naming doesn't confirm where this board's
three-tier split actually maps, and mislabeling a read-side unit is a
standing, not one-shot, kind of wrong -- exposed as plain named
sensors instead. Humidity, battery/charging, and the informational air
quality standard are otherwise straightforward field reads.

/dnd/vs/0 (do-not-disturb window) is a flagged educated guess: the
write contract mirrors the read side's own string/time-format shape
(the safest kind of guess) but has no idle-vs-active dump to confirm
it end-to-end, so it's called out as such in code and will need a
reporter to verify on real hardware. /keepnormalstate/vs/0 and
/sensordatasinks/vs/0 are genuinely opaque (single unexplained value,
no supported-values list) and are ignored rather than guessed at.

Locked in with a scrubbed fixture, golden, and capability tests.
This commit is contained in:
Marc Billow committed 2026-07-31 02:40:02 +00:00
1 parent 4afd11a357
commit a7d57086e9
10 files changed
+588 -4

No files matched your search

@@ -4,9 +4,10 @@ from typing import Optional, Sequence
from ._base import DeviceRegistry
from . import (
air_dresser, air_purifier, airconditioner, cooktop, dehumidifier,
dishwasher, dryer, induction_cooktop, microwave, oven, range as _range,
range_hood, refrigerator, vacuum_station, washer, water_purifier,
air_dresser, air_monitor, air_purifier, airconditioner, cooktop,
dehumidifier, dishwasher, dryer, induction_cooktop, microwave, oven,
range as _range, range_hood, refrigerator, vacuum_station, washer,
water_purifier,
)
__all__ = [
@@ -19,6 +20,7 @@ __all__ = [
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_dresser': air_dresser.REGISTRY,
'air_monitor': air_monitor.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
@@ -111,7 +113,8 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
'CT': 'cooktop',
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
'DF': 'air_dresser', # issue #162
'VSWW': 'vacuum_station', # issue #219
'VSWW': 'vacuum_station', # issue #219
'ASM': 'air_monitor', # issue #210 -- Air Monitor Plus
}
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
@@ -219,6 +222,7 @@ _OIC_TYPE_TO_KEY: dict[str, str] = {
'oic.d.oven': 'oven',
'oic.d.refrigerator': 'refrigerator',
'oic.d.washer': 'washer',
'x.com.st.d.airqualitysensor': 'air_monitor',
'x.com.st.d.stickcleaner': 'vacuum_station',
'x.com.st.d.steamcloset': 'air_dresser',
}
@@ -0,0 +1,23 @@
"""Air Monitor Plus device registry (issue #210).
A standalone, battery-powered air-quality sensor puck (ASM-KR-TP1-22-*
board) -- no controllable state at all beyond the do-not-disturb window,
so this registry is almost entirely sensors. No common.POWER: there's no
`/power/*` resource on this board, only `/energy/battery/vs/0`.
"""
from ..capabilities import air_monitor, common, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_monitor',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]),
)
@@ -0,0 +1,163 @@
"""Capabilities for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-*
board, issue #210) -- a small battery-powered standalone air-quality sensor
puck, not a controllable appliance. No `/power/*` resource at all (battery
only); this registry deliberately doesn't include common.POWER.
`/sensors/vs/0` is the same {type, value: [...]} items-list shape
air_purifier.AIR_QUALITY and range_hood.AIR_QUALITY already read via
common.sensor_item_value -- reused here rather than re-decoded, including
the same dust/fine_dust/super_fine_dust/odor/clean_level keys so this
device shares those capabilities' catalog entries. This board additionally
reports a CO2 reading the other two families don't.
A second `value` list element on the particulate-matter types (e.g. Dust's
`['31', '2']`) reads like a coarse quality-grade code, but nothing on this
board (no `supportedGrades`/similar field, no repeated dump to compare
against) confirms what its scale means -- left unbound rather than guessed,
per the adding-device-support skill's "still never invent... from nothing"
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).
"""
from datetime import time as dt_time
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
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,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
) + (
SensorDesc(key='co2', field='x.com.samsung.da.items',
device_class='carbon_dioxide', state_class='measurement',
unit='ppm',
value_fn=lambda items: sensor_item_value(items, 'CO2')),
),
)
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', state_class='measurement',
unit='%', value_fn=int_or_none),
),
)
BATTERY = Capability(
href='/energy/battery/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='battery', field='x.com.samsung.da.battery',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic',
value_fn=int_or_none),
BinarySensorDesc(key='battery_charging', field='x.com.samsung.da.charging',
device_class='battery_charging',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
),
)
AIR_QUALITY_STANDARD = Capability(
href='/airqualitystandard/vs/0',
poll_tier='cold',
entities=(
SensorDesc(key='air_quality_standard', field='x.com.samsung.da.standard',
entity_category='diagnostic'),
),
)
def _parse_hms(v):
"""'HH:MM:SS' -> datetime.time, same contract as laundry._parse_hm but
tolerant of the trailing ':SS' this board's dnd start/end times carry."""
if not v:
return None
try:
parts = v.split(':')
return dt_time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
def _dnd_time_write(field):
def _write(p, rep, href=None):
return ['dnd', 'vs', '0'], {field: f'{p.hour:02d}:{p.minute:02d}:00'}
return _write
# Issue #210: no idle-vs-active dump pair exists for this href (only one
# dump total, DND never toggled in it), so this write contract is an
# educated guess, not a confirmed one -- symmetric with the read side
# (writing the same 'true'/'false' string shape and 'HH:MM:SS' format the
# device itself reports back) rather than invented from nothing, but still
# needs a reporter to actually flip it on real hardware and confirm. See
# the adding-device-support skill's "Educated guesses are fine" section.
DND = Capability(
href='/dnd/vs/0',
poll_tier='cold',
entities=(
SwitchDesc(key='dnd', field='x.com.samsung.da.value',
icon='mdi:sleep',
entity_category='config',
value_fn=lambda v: v == 'true',
write_fn=lambda p, rep, href=None: (
['dnd', 'vs', '0'],
{'x.com.samsung.da.value': 'true' if p == 'On' else 'false'})),
TimeDesc(key='dnd_start', field='x.com.samsung.da.startTime',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.startTime')),
TimeDesc(key='dnd_end', field='x.com.samsung.da.endTime',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.endTime')),
),
)
# ---------------------------------------------------------------------------
# Air-monitor-scoped coverage: hrefs with no explainable live state,
# following the 'don't guess' rule.
# ---------------------------------------------------------------------------
_AM_IGNORED = [
# A single bare integer ('keepnormal': 0) with no description, no
# supported-values list, and no second dump to compare against -- opaque.
'/keepnormalstate/vs/0',
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
# live user-facing field.
'/sensordatasinks/vs/0',
]
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
@@ -10,6 +10,9 @@
"automatic_operation": {
"name": "Automatic operation"
},
"battery_charging": {
"name": "Charging"
},
"burner_hot_surface": {
"name": "Burner {number} hot surface"
},
@@ -603,6 +606,9 @@
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"air_quality_standard": {
"name": "Air quality standard"
},
"air_sensing_state": {
"name": "Air sensing state"
},
@@ -615,12 +621,18 @@
"automatic_ventilation_state": {
"name": "Automatic ventilation state"
},
"battery": {
"name": "Battery"
},
"burner_state": {
"name": "Burner {number} state"
},
"clean_level": {
"name": "Clean level"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Coffee brew status"
},
@@ -996,6 +1008,9 @@
"defrost_delay": {
"name": "Defrost delay"
},
"dnd": {
"name": "Do not disturb"
},
"display_light": {
"name": "Display light"
},
@@ -1082,6 +1097,12 @@
}
},
"time": {
"dnd_end": {
"name": "Do not disturb end"
},
"dnd_start": {
"name": "Do not disturb start"
},
"led_night_end": {
"name": "Door LED night end"
},
@@ -10,6 +10,9 @@
"automatic_operation": {
"name": "Automatische werking"
},
"battery_charging": {
"name": "Opladen"
},
"burner_hot_surface": {
"name": "Brander {number} heet oppervlak"
},
@@ -603,6 +606,9 @@
"air_filter_usage_hours": {
"name": "Filterverbruik (uren)"
},
"air_quality_standard": {
"name": "Luchtkwaliteitsnorm"
},
"air_sensing_state": {
"name": "Status luchtmeting"
},
@@ -615,12 +621,18 @@
"automatic_ventilation_state": {
"name": "Status automatische ventilatie"
},
"battery": {
"name": "Batterij"
},
"burner_state": {
"name": "Status brander {number}"
},
"clean_level": {
"name": "Reinigingsniveau"
},
"co2": {
"name": "CO2"
},
"coffee_brew_status": {
"name": "Koffiezetstatus"
},
@@ -996,6 +1008,9 @@
"defrost_delay": {
"name": "Ontdooien uitstellen"
},
"dnd": {
"name": "Niet storen"
},
"display_light": {
"name": "Displayverlichting"
},
@@ -1082,6 +1097,12 @@
}
},
"time": {
"dnd_end": {
"name": "Einde niet storen"
},
"dnd_start": {
"name": "Start niet storen"
},
"led_night_end": {
"name": "Einde nachtverlichting deur-LED"
},