Files
localthings/tests/test_air_monitor_capabilities.py
T
Marc Billow a7d57086e9 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.
2026-07-31 02:40:02 +00:00

116 lines
3.9 KiB
Python

"""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'}