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.
116 lines
3.9 KiB
Python
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'}
|