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:
@@ -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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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'}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user