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
2026-07-31 02:40:02 +00:00
parent 4afd11a357
commit a7d57086e9
10 changed files with 588 additions and 4 deletions
@@ -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"
},
+184
View File
@@ -0,0 +1,184 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airqualitystandard/vs/0",
"rep": {
"x.com.samsung.da.standard": "AirCleanAssociation"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T02:42:57",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/dnd/vs/0",
"rep": {
"x.com.samsung.da.value": "false",
"x.com.samsung.da.startTime": "14:00:00",
"x.com.samsung.da.endTime": "22:00:00",
"x.com.samsung.da.visible": "true"
}
},
{
"href": "/energy/battery/vs/0",
"rep": {
"x.com.samsung.da.battery": "80",
"x.com.samsung.da.batteryResolution": "1",
"x.com.samsung.da.charging": "Off"
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "57",
"x.com.samsung.da.validity": "Valid"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000",
"x.com.samsung.da.description": "ASM-KR-TP1-22-ACMB1M",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02443A260310",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "22050300",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "AM0",
"x.com.samsung.da.diagMinVersion": "1.0"
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 0
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensordatasinks/vs/0",
"rep": {
"remove": ""
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.cleanLevel": "2",
"x.com.samsung.da.refresh": "Off",
"x.com.samsung.da.lastSensingTime": "1785379376",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"2"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"31",
"2"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"23",
"2"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"18",
"2"
]
},
{
"x.com.samsung.da.id": "5",
"x.com.samsung.da.description": "Sensor for CO2",
"x.com.samsung.da.type": "CO2",
"x.com.samsung.da.value": [
"498",
"1"
]
}
]
}
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"state_keys": [
"air_quality_standard",
"alarm_code",
"battery",
"battery_charging",
"clean_level",
"co2",
"dnd",
"dnd_end",
"dnd_start",
"dust",
"fine_dust",
"humidity",
"odor",
"super_fine_dust"
]
}
+115
View File
@@ -0,0 +1,115 @@
"""Tests for the Samsung Air Monitor Plus family (ASM-KR-TP1-22-ACMB1M,
issue #210) -- a standalone, battery-powered air-quality sensor puck. No
controllable appliance state at all beyond the do-not-disturb window.
"""
from datetime import time as dt_time
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_monitor():
resources = _load_device('air_monitor')
reg = resolve(resources)
return reg, resources
def _bound():
reg, resources = _air_monitor()
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
def _state():
bound, resources = _bound()
return flatten(bound, resources)
def _desc(key):
bound, _ = _bound()
return next(b.desc for b in bound if b.desc.key == key)
def test_resolves_to_air_monitor_registry():
reg, _ = _air_monitor()
assert reg is not None and reg.name == 'air_monitor'
def test_resolves_via_oic_type_too():
"""This board's real dump carries /oic/d's device type
('x.com.st.d.airqualitysensor') as well as the 'ASM' modelNum token --
both paths must agree."""
reg = for_device_by_oic_type(('x.com.st.d.airqualitysensor',))
assert reg is not None and reg.name == 'air_monitor'
def test_no_unbound_hrefs():
reg, resources = _air_monitor()
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_air_quality_sensors_read_from_shared_items_decode():
"""Same /sensors/vs/0 {type, value} shape and common.sensor_item_value
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
which neither air_purifier nor range_hood report."""
state = _state()
assert state['dust'] == 31
assert state['fine_dust'] == 23
assert state['super_fine_dust'] == 18
assert state['odor'] == 1
assert state['clean_level'] == 2
assert state['co2'] == 498
def test_second_value_slot_is_not_exposed():
"""Dust/fine_dust/super_fine_dust each carry a second `value[1]` grade
code (e.g. Dust's ['31', '2']) with no supported-values field anywhere
in the dump to explain its scale -- per the 'still never invent... from
nothing' rule, only the raw reading (value[0]) is bound, not a second
'dust_level' or similar entity for that code."""
state = _state()
assert 'dust_level' not in state
assert 'dust_grade' not in state
def test_humidity_and_battery_present():
state = _state()
assert state['humidity'] == 57
assert state['battery'] == 80
assert state['battery_charging'] is False
def test_air_quality_standard_present():
state = _state()
assert state['air_quality_standard'] == 'AirCleanAssociation'
def test_dnd_read_contract():
state = _state()
assert state['dnd'] is False
assert state['dnd_start'] == dt_time(14, 0)
assert state['dnd_end'] == dt_time(22, 0)
def test_dnd_write_contracts_are_flagged_educated_guesses():
"""Issue #210: no idle-vs-active dump pair exists for /dnd/vs/0 (DND
was never toggled in the one dump this board has), so these write
contracts are educated guesses -- symmetric with the read side's own
string/time-format shape, not invented from nothing, but still
unconfirmed on real hardware. Only the wiring is tested here; whether
the device actually accepts these writes needs a reporter to confirm."""
dnd = _desc('dnd')
path, body = dnd.write_fn('On', {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.value': 'true'}
path, body = dnd.write_fn('Off', {})
assert body == {'x.com.samsung.da.value': 'false'}
start = _desc('dnd_start')
path, body = start.write_fn(dt_time(9, 30), {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.startTime': '09:30:00'}
+14
View File
@@ -151,6 +151,7 @@ class TestForDeviceByOicType:
('oic.d.washer', 'washer'),
('x.com.st.d.stickcleaner', 'vacuum_station'),
('x.com.st.d.steamcloset', 'air_dresser'),
('x.com.st.d.airqualitysensor', 'air_monitor'),
])
def test_known_oic_types_resolve(self, oic_type, expected):
from custom_components.localthings.registry.by_type import for_device_by_oic_type
@@ -434,6 +435,19 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'vacuum_station'
def test_air_monitor_via_asm_token(self):
"""Issue #210: a standalone air-quality sensor puck
(ASM-KR-TP1-22-ACMB1M) reports no oneUiVersion and no
washer/dryer/dishwasher consumer prefix; falls back to the 'ASM'
token in modelNum onto the air_monitor registry."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000',
'ASM-KR-TP1-22-ACMB1M',
)
assert reg is not None
assert reg.name == 'air_monitor'
def test_cooktop_via_legacy_model_description(self):
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
from custom_components.localthings.registry.by_type import for_device_by_model
+21
View File
@@ -871,6 +871,27 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser_tp1_21():
)
def test_registry_reproduces_golden_state_keys_for_air_monitor():
"""Samsung Air Monitor Plus (ASM-KR-TP1-22-ACMB1M, issue #210) -- a
standalone battery-powered air-quality sensor puck, the first device
this integration has no controllable-appliance concept for at all (no
/power/*, only /energy/battery/vs/0). Routes via both /oic/d
('x.com.st.d.airqualitysensor') and the 'ASM' modelNum board token.
Reuses air_purifier.AIR_QUALITY's /sensors/vs/0 decode
(common.sensor_item_value) for dust/fine_dust/super_fine_dust/odor/
clean_level, adding a CO2 reading those families don't report. Binds
cleanly with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('air_monitor')
golden = json.loads((GOLDEN / 'air_monitor.json').read_text())
state_keys = _new_state_keys('air_monitor', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_air_purifier_vtww():
"""A-VTWW-TP2-21-COMMON BESPOKE Cube Air (issue #151) -- reports no
oneUiVersion; resolved via the '-VTWW-' modelNum token fallback into