diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index febd247..1055668 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -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', } diff --git a/custom_components/localthings/registry/by_type/air_monitor.py b/custom_components/localthings/registry/by_type/air_monitor.py new file mode 100644 index 0000000..b7165ac --- /dev/null +++ b/custom_components/localthings/registry/by_type/air_monitor.py @@ -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, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py new file mode 100644 index 0000000..fab646a --- /dev/null +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -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] diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 64b7809..b5b645a 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b48539a..8da541c 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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" }, diff --git a/tests/fixtures/air_monitor_device.json b/tests/fixtures/air_monitor_device.json new file mode 100644 index 0000000..99010a8 --- /dev/null +++ b/tests/fixtures/air_monitor_device.json @@ -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" + ] + } + ] + } + } + ] +} diff --git a/tests/fixtures/golden/air_monitor.json b/tests/fixtures/golden/air_monitor.json new file mode 100644 index 0000000..2f1556a --- /dev/null +++ b/tests/fixtures/golden/air_monitor.json @@ -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" + ] +} diff --git a/tests/test_air_monitor_capabilities.py b/tests/test_air_monitor_capabilities.py new file mode 100644 index 0000000..ec3ea00 --- /dev/null +++ b/tests/test_air_monitor_capabilities.py @@ -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'} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 17f15f2..cdb97d2 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 372aaab..90a1d2e 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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