Address review: per-item versions, writable threshold, unit_fn, gating

Per mbillow's review on PR #129 (CHANGES_REQUESTED). Items 1-7 + smaller.

1. INFO: expose each /information/vs/0 version item per (type, ordinal) instead
   of collapsing to a single first-Firmware value. Boards carry 1-3 Firmware
   items (separate MCUs) plus an Outdoor unit and (window AC) a Touch IC item;
   each is a distinct version string. _info_version(items, type_, ordinal)
   + _has_info_version exists_fn gate, so an item with no number (tp2x_rac_20k's
   second Firmware) suppresses the entity rather than binding unknown.

2. air_filter_threshold is locally writable, not cloud-only -- confirmed live
   on ARTIK051_PRAC: POST filterDesiredUsage=700 to /filter/airdustfilter/vs/0
   -> 2.04, read-back 700, persists; restored to 500. Converted from a
   read-only sensor to a SelectDesc keyed to the device's
   supportedFilterDesiredUsage enum (options_field), with a write_fn that
   POSTs the scalar field. Only binds where the enum is advertised; boards
   without it leave this writable field unexposed rather than guess the valid
   set (don't-guess).

3. air_filter_usage_hours uses unit_fn reading filterCapacityUnit ('Hour'->'h')
   instead of a hardcoded unit, so a board advertising a different unit doesn't
   mislabel a duration statistic.

4. air_filter_usage_hours state_class is total_increasing, not measurement --
   filterUsage is a lifetime hour counter that resets on filter replacement.

5. INFO and air_filter_threshold now carry exists_fn (AIR_QUALITY already did),
   so they don't bind a permanently-unknown entity when their item/field is
   absent. Also fixes the caww_tp2 golden nuance (filterDesiredUsage absent
   -> no threshold key, matching what HA would actually create).

6. AIR_QUALITY: CleanLevel is corroborated as numeric by a top-level
   x.com.samsung.da.cleanLevel scalar (tp1x_da_ac_rac_01011 reports both as
   '1'), so clean_level is now an int measurement; odor/dust/fine_dust stay
   string diagnostics. Reinstated the 2-element-array ambiguity note (Dust/
   FineDust/SuperFineDust report ['0','0']; v[1] meaning unconfirmed, v[0]
   taken as the reading). SuperFineDust is now modeled for consistency with
   Dust/FineDust (same shape), rather than skipped without reason.

7. _beep_write restores the last non-Mute Volume level on 'On' instead of
   forcing Volume_100, so an intermediate setting (e.g. Volume_50 set via
   the cloud) survives an off/on cycle; falls back to 100 when no prior level.

Smaller: test_air_quality now asserts the tp1x_da_ac_rac_01011 clean_level==1
non-zero reading (the one value_fn-regression catch in the corpus); renamed
by_type's _AC_IGNORED -> _IGNORED_LESS_INFO to resolve the two-meaning
collision with the capabilities module's _AC_IGNORED (href strings); golden
regenerated for the 8 AC fixtures + dehumidifier.

612 tests pass.
This commit is contained in:
blka
2026-07-27 22:22:56 +02:00
parent 3da01f80d0
commit 51fa1c5ea8
13 changed files with 302 additions and 83 deletions
@@ -15,12 +15,14 @@ from ._base import DeviceRegistry, _build
# /information/vs/0 is globally ignored (serial/model identity plumbing), but
# the AC exposes Software/Firmware version in its items[] -- model those here
# and drop the no-entity coverage entry so INFO is the sole cap on the href.
_AC_IGNORED = [c for c in ignored.IGNORED if c.href != '/information/vs/0']
# Renamed from the capabilities module's _AC_IGNORED (a list of href strings)
# to avoid the two-meaning collision noted in review.
_IGNORED_LESS_INFO = [c for c in ignored.IGNORED if c.href != '/information/vs/0']
REGISTRY = DeviceRegistry(
name='airconditioner',
capabilities=_build([
*_AC_IGNORED,
*_IGNORED_LESS_INFO,
*common.UNIVERSAL,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
@@ -16,7 +16,7 @@ by_type registry.
"""
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ClimateDesc, NumberDesc, SensorDesc, SwitchDesc,
BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
)
from .common import normalize_temp_unit
from .laundry import option_write
@@ -48,11 +48,19 @@ def _beep_on(rep):
def _beep_write(payload, rep, href=None):
"""Toggle beep via a single-token /mode/vs/0 options write (option_write's
one-token merge -- a full options RMW reverts on ARTIK051_PRAC)."""
one-token merge -- a full options RMW reverts on ARTIK051_PRAC). 'On'
restores the last non-Mute level rather than forcing Volume_100, so a
user's intermediate setting (e.g. Volume_50 set via the cloud) survives an
off/on cycle; falls back to 100 when no prior level is known."""
if payload not in ('On', 'Off'):
return None
if payload == 'Off':
token = 'Mute'
else:
prev = _option_token(rep, 'Volume')
token = prev[len('Volume_'):] if (prev and prev != 'Volume_Mute') else '100'
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Volume', '100' if payload == 'On' else 'Mute'),
'x.com.samsung.da.options': option_write('Volume', token),
}
@@ -78,20 +86,59 @@ def _tropical_night_write(value, rep, href=None):
}
def _info_item_number(items, type_):
"""x.com.samsung.da.number of the /information/vs/0 item with the given
x.com.samsung.da.type ('Software'/'Firmware'), else None."""
for it in (items or []):
if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_:
return it.get('x.com.samsung.da.number')
def _info_items_of_type(items, type_):
return [it for it in (items or [])
if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_]
def _info_version(items, type_, ordinal):
"""x.com.samsung.da.number of the ordinal-th /information/vs/0 item of the
given type. Boards carry one Software item but 1-3 Firmware items (separate
MCUs), plus an Outdoor unit and (window AC) a Touch IC item -- each a
distinct version string, so they're exposed per-item rather than collapsed
to a single 'first wins' value. None when the ordinal item is absent or
carries no number (e.g. tp2x_rac_20k's second Firmware item)."""
items = _info_items_of_type(items, type_)
if ordinal < len(items):
return items[ordinal].get('x.com.samsung.da.number')
return None
def _has_info_version(type_, ordinal):
return lambda rep, resources: _info_version(
rep.get('x.com.samsung.da.items'), type_, ordinal) is not None
def _filter_unit(rep):
"""Unit of the filter-usage fields, normalised from filterCapacityUnit
('Hour' -> 'h'). Wired through unit_fn so a board advertising a different
unit doesn't silently mislabel a duration statistic."""
u = rep.get('x.com.samsung.da.filterCapacityUnit')
return {'Hour': 'h', 'Minute': 'min', 'Second': 's'}.get(u, u or 'h')
def _threshold_write(payload, rep, href=None):
"""filterDesiredUsage is locally writable: a plain scalar POST of the
field to /filter/airdustfilter/vs/0 is 2.04-accepted and persists
(confirmed live on ARTIK051_PRAC: POST 700 -> 2.04, read-back 700). The
Select only surfaces where the device advertises
supportedFilterDesiredUsage, so the valid options are known rather than
guessed; boards without that enum leave this writable field unexposed."""
return ['filter', 'airdustfilter', 'vs', '0'], {
'x.com.samsung.da.filterDesiredUsage': payload,
}
def _sensor_item_value(items, type_):
"""First value of the /sensors/vs/0 item with the given
x.com.samsung.da.type, as a diagnostic scalar string. The resource exposes
no unit, so no device_class is set until a populated reading + unit is
observed (the 'don't guess' rule)."""
x.com.samsung.da.type. The resource exposes no unit, so no device_class is
set until a populated reading + unit is observed (the 'don't guess' rule).
Dust/FineDust/SuperFineDust report a 2-element array (['0','0']) while
CleanLevel/Odor report a single element -- the second element's meaning is
unconfirmed, so v[0] is taken as the reading and v[1] is dropped; left as
a string rather than coerced numeric because only CleanLevel has
corroborating evidence (a top-level x.com.samsung.da.cleanLevel scalar)."""
for it in (items or []):
if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_:
v = it.get('x.com.samsung.da.value')
@@ -341,17 +388,28 @@ AIR_FILTER = Capability(
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent,
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic'),
# filterUsage is a lifetime hour counter that only resets on filter
# replacement -- total_increasing so HA's long-term statistics handle
# the reset rather than treating it as a bounded measurement.
SensorDesc(key='air_filter_usage_hours',
field='x.com.samsung.da.filterUsage',
device_class='duration',
state_class='measurement', unit='h',
state_class='total_increasing',
unit_fn=_filter_unit,
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=_int),
SensorDesc(key='air_filter_threshold',
# The alarm threshold (filterDesiredUsage) is a locally writable option:
# see _threshold_write. Surfaces as a Select only where the device
# advertises supportedFilterDesiredUsage; boards without that enum
# leave it unexposed rather than guess the valid set.
SelectDesc(key='air_filter_threshold',
field='x.com.samsung.da.filterDesiredUsage',
device_class='duration',
unit='h', icon='mdi:alarm', entity_category='diagnostic',
value_fn=_int),
options_field='x.com.samsung.da.supportedFilterDesiredUsage',
exists_fn=lambda rep, res: bool(
rep.get('x.com.samsung.da.supportedFilterDesiredUsage')),
icon='mdi:alarm', entity_category='config',
write_fn=_threshold_write,
value_fn=lambda v: str(v) if v is not None else None),
SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus',
device_class='enum',
options=('normal', 'wash', 'replace'),
@@ -454,40 +512,71 @@ HUMIDITY = Capability(
),
)
# /sensors/vs/0 items[] carry live air-quality readings (CleanLevel, Odor, Dust,
# FineDust). The resource exposes no unit, so these are diagnostic scalars with
# no device_class until a populated reading + unit is observed. Removed from
# _AC_IGNORED below so AIR_QUALITY is the sole cap on the href.
# /sensors/vs/0 items[] carry live air-quality readings. Removed from
# _AC_IGNORED below so AIR_QUALITY is the sole cap on the href. CleanLevel is
# corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar
# (tp1x_da_ac_rac_01011 reports both as '1'), so it's a measurement; the others
# are 1- or 2-element arrays with no corroborating scalar, so they stay string
# diagnostics (see _sensor_item_value for the 2-element ambiguity and why only
# v[0] is taken). No unit is advertised on the resource, so no device_class.
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='cold',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items',
icon=icon, entity_category='diagnostic',
exists_fn=_has_sensor_type(type_),
value_fn=lambda items, t=type_: _sensor_item_value(items, t))
for key, icon, type_ in (
('clean_level', 'mdi:broom', 'CleanLevel'),
('odor', 'mdi:weather-windy', 'Odor'),
('dust', 'mdi:cloud', 'Dust'),
('fine_dust', 'mdi:cloud-outline', 'FineDust'),
)
entities=(
SensorDesc(key='clean_level', field='x.com.samsung.da.items',
icon='mdi:broom', entity_category='diagnostic',
state_class='measurement',
exists_fn=_has_sensor_type('CleanLevel'),
value_fn=lambda items: _int(_sensor_item_value(items, 'CleanLevel'))),
*tuple(
SensorDesc(key=key, field='x.com.samsung.da.items',
icon=icon, entity_category='diagnostic',
exists_fn=_has_sensor_type(type_),
value_fn=lambda items, t=type_: _sensor_item_value(items, t))
for key, icon, type_ in (
('odor', 'mdi:weather-windy', 'Odor'),
('dust', 'mdi:cloud', 'Dust'),
('fine_dust', 'mdi:cloud-outline', 'FineDust'),
('super_fine_dust', 'mdi:weather-fog', 'SuperFineDust'),
)
),
),
)
# Software/Firmware version from /information/vs/0 items[] (the href is
# globally ignored as identity plumbing; the AC registry drops that entry so
# INFO is the sole cap on it). Diagnostic, read-only.
# INFO is the sole cap on it). Boards carry one Software item but 1-3 Firmware
# items (separate MCUs), plus an Outdoor unit and (window AC) a Touch IC item
# -- each a distinct version string, exposed per-item (see _info_version)
# rather than collapsed to a single 'first wins' value. Diagnostic, read-only.
INFO = Capability(
href=HREF_INFORMATION,
poll_tier='cold',
entities=(
SensorDesc(key='software_version', field='x.com.samsung.da.items',
icon='mdi:package-variant', entity_category='diagnostic',
value_fn=lambda items: _info_item_number(items, 'Software')),
exists_fn=_has_info_version('Software', 0),
value_fn=lambda items: _info_version(items, 'Software', 0)),
SensorDesc(key='firmware_version', field='x.com.samsung.da.items',
icon='mdi:chip', entity_category='diagnostic',
value_fn=lambda items: _info_item_number(items, 'Firmware')),
exists_fn=_has_info_version('Firmware', 0),
value_fn=lambda items: _info_version(items, 'Firmware', 0)),
SensorDesc(key='firmware_version_2', field='x.com.samsung.da.items',
icon='mdi:chip', entity_category='diagnostic',
exists_fn=_has_info_version('Firmware', 1),
value_fn=lambda items: _info_version(items, 'Firmware', 1)),
SensorDesc(key='firmware_version_3', field='x.com.samsung.da.items',
icon='mdi:chip', entity_category='diagnostic',
exists_fn=_has_info_version('Firmware', 2),
value_fn=lambda items: _info_version(items, 'Firmware', 2)),
SensorDesc(key='outdoor_unit_version', field='x.com.samsung.da.items',
icon='mdi:fan', entity_category='diagnostic',
exists_fn=_has_info_version('Outdoor', 0),
value_fn=lambda items: _info_version(items, 'Outdoor', 0)),
SensorDesc(key='touch_ic_version', field='x.com.samsung.da.items',
icon='mdi:gesture-tap', entity_category='diagnostic',
exists_fn=_has_info_version('Touch IC', 0),
value_fn=lambda items: _info_version(items, 'Touch IC', 0)),
),
)
@@ -158,6 +158,9 @@
"ai_energy_level": {
"name": "AI Energy Mode level"
},
"air_filter_threshold": {
"name": "Filter alarm threshold"
},
"beverage_zone_mode": {
"name": "Beverage zone mode",
"state": {
@@ -484,15 +487,24 @@
"air_filter_usage_hours": {
"name": "Filter usage hours"
},
"air_filter_threshold": {
"name": "Filter alarm threshold"
},
"software_version": {
"name": "Software version"
},
"firmware_version": {
"name": "Firmware version"
},
"firmware_version_2": {
"name": "Firmware version (secondary)"
},
"firmware_version_3": {
"name": "Firmware version (tertiary)"
},
"outdoor_unit_version": {
"name": "Outdoor unit version"
},
"touch_ic_version": {
"name": "Touch IC version"
},
"air_sensing_state": {
"name": "Air sensing state"
},
@@ -158,6 +158,9 @@
"ai_energy_level": {
"name": "Niveau AI Energy Mode"
},
"air_filter_threshold": {
"name": "Filteralarmdrempel"
},
"beverage_zone_mode": {
"name": "Modus drankenzone",
"state": {
@@ -484,15 +487,24 @@
"air_filter_usage_hours": {
"name": "Filterverbruik (uren)"
},
"air_filter_threshold": {
"name": "Filteralarmdrempel"
},
"software_version": {
"name": "Softwareversie"
},
"firmware_version": {
"name": "Firmwareversie"
},
"firmware_version_2": {
"name": "Firmwareversie (secundair)"
},
"firmware_version_3": {
"name": "Firmwareversie (tertiair)"
},
"outdoor_unit_version": {
"name": "Versie buiteneenheid"
},
"touch_ic_version": {
"name": "Touch-IC-versie"
},
"air_sensing_state": {
"name": "Status luchtmeting"
},
+2 -1
View File
@@ -1,7 +1,6 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
@@ -20,8 +19,10 @@
"firmware_version",
"humidity",
"odor",
"outdoor_unit_version",
"power_watts",
"software_version",
"super_fine_dust",
"tropical_night_mode"
]
}
+1 -1
View File
@@ -1,7 +1,6 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
@@ -17,6 +16,7 @@
"firmware_version",
"humidity",
"mute_once",
"outdoor_unit_version",
"power_watts",
"software_version",
"tropical_night_mode"
@@ -17,12 +17,15 @@
"fine_dust",
"firmware_update",
"firmware_version",
"firmware_version_2",
"firmware_version_3",
"humidity",
"mute_once",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"software_version",
"super_fine_dust",
"tropical_night_mode"
]
}
+2
View File
@@ -17,6 +17,8 @@
"energy_saved_kwh",
"firmware_update",
"firmware_version",
"firmware_version_2",
"firmware_version_3",
"mute_once",
"selfcheck_error",
"selfcheck_result",
@@ -1,7 +1,6 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
@@ -14,6 +13,7 @@
"energy_saved_kwh",
"firmware_update",
"firmware_version",
"firmware_version_2",
"humidity",
"mute_once",
"power_watts",
@@ -1,7 +1,6 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
+2 -1
View File
@@ -1,7 +1,6 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"air_purify",
@@ -19,8 +18,10 @@
"firmware_version",
"humidity",
"odor",
"outdoor_unit_version",
"power_watts",
"software_version",
"super_fine_dust",
"tropical_night_mode"
]
}
+3 -2
View File
@@ -1,7 +1,6 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
@@ -16,10 +15,12 @@
"firmware_version",
"humidity",
"mute_once",
"outdoor_unit_version",
"power_watts",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
"software_version"
"software_version",
"touch_ic_version"
]
}
+130 -33
View File
@@ -9,7 +9,7 @@ from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
from custom_components.localthings.registry.capabilities import airconditioner
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from custom_components.localthings.registry.entities import ClimateDesc, SelectDesc
from tests.conftest import _load_device
@@ -440,11 +440,17 @@ def test_beep_read_from_volume_token():
def test_beep_write_is_single_token_options_merge():
"""On writes `['Volume_100']`, Off writes `['Volume_Mute']` -- one-element
options array, not a full RMW (which reverts on ARTIK051_PRAC)."""
"""One-element options array, not a full RMW (which reverts on
ARTIK051_PRAC). 'On' restores the last non-Mute level so an intermediate
setting (e.g. Volume_50) survives an off/on cycle; falls back to 100 when
no prior level is known or the prior token is itself Mute."""
write = _beep_desc().write_fn
assert write('On', {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_100']})
assert write('On', {'x.com.samsung.da.options': ['Volume_50']}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_50']})
assert write('On', {'x.com.samsung.da.options': ['Volume_Mute']}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_100']})
assert write('Off', {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_Mute']})
assert write('Bogus', {}) is None
@@ -520,59 +526,100 @@ def test_tropical_night_state_levels_across_fixtures():
def test_air_filter_usage_hours_reads_raw_count():
"""filterUsage is a raw hour count (41 of 500); the existing
air_filter_usage is the percentage (8%), this one is the raw hours."""
"""filterUsage is a lifetime hour counter (41 of 500) that resets on
filter replacement -- total_increasing, not measurement. Unit comes from
filterCapacityUnit via unit_fn, not a hardcoded 'h'."""
desc = next(e for e in airconditioner.AIR_FILTER.entities
if e.key == 'air_filter_usage_hours')
assert desc.value_fn('41') == 41
assert desc.value_fn(41) == 41
assert desc.value_fn(None) is None
assert desc.value_fn('not-a-number') is None
assert desc.unit == 'h' and desc.device_class == 'duration'
assert desc.device_class == 'duration'
assert desc.state_class == 'total_increasing'
assert desc.unit_fn({'x.com.samsung.da.filterCapacityUnit': 'Hour'}) == 'h'
assert desc.unit_fn({'x.com.samsung.da.filterCapacityUnit': 'Minute'}) == 'min'
assert desc.unit_fn({}) == 'h' # static fallback when the field is absent
def test_air_filter_threshold_reads_desired_usage():
"""filterDesiredUsage is the alarm threshold in hours (read-only local;
setting it is cloud-only via samsungce.dustFilterAlarm)."""
def test_air_filter_threshold_is_writable_select():
"""filterDesiredUsage is a locally writable option (confirmed live on
ARTIK051_PRAC: POST 700 -> 2.04, persisted). Exposed as a Select keyed to
the device's supportedFilterDesiredUsage enum; the write POSTs the scalar
field back to /filter/airdustfilter/vs/0. Only binds where the enum is
advertised -- boards without it leave this writable field unexposed rather
than guess the valid set."""
desc = next(e for e in airconditioner.AIR_FILTER.entities
if e.key == 'air_filter_threshold')
assert desc.value_fn('500') == 500
assert desc.value_fn(500) == 500
assert isinstance(desc, SelectDesc)
assert desc.options_field == 'x.com.samsung.da.supportedFilterDesiredUsage'
assert desc.exists_fn(
{'x.com.samsung.da.supportedFilterDesiredUsage': ['180', '300', '500', '700']},
{}) is True
assert desc.exists_fn({}, {}) is False
# Current value is stringified for option matching.
assert desc.value_fn('500') == '500'
assert desc.value_fn(500) == '500'
assert desc.value_fn(None) is None
assert getattr(desc, 'write_fn', None) is None
assert desc.unit == 'h' and desc.device_class == 'duration'
# Write POSTs the selected option as the scalar field.
assert desc.write_fn('700', {}) == (
['filter', 'airdustfilter', 'vs', '0'],
{'x.com.samsung.da.filterDesiredUsage': '700'})
def test_air_filter_hours_and_threshold_in_state():
def test_air_filter_threshold_absent_without_supported_enum():
"""WindFree (ARTIK051_PRAC) advertises no supportedFilterDesiredUsage, so
the writable threshold Select must not bind there -- even though the
scalar field is present and writable. Don't expose a control whose valid
options aren't known."""
reg, resources = _ac_windfree()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert 'air_filter_threshold' not in state
assert state['air_filter_usage_hours'] == 41
assert state['air_filter_threshold'] == 500
assert state['air_filter_usage'] == 8 # 41/500 -> 8%
def test_air_filter_threshold_binds_on_enum_board():
"""tp1x_rac advertises supportedFilterDesiredUsage -> threshold Select
binds, current value read from filterDesiredUsage."""
reg, resources = _resolve('airconditioner_tp1x_rac')
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['air_filter_threshold'] == '500'
def test_air_quality_sensors_from_sensors_vs_items():
"""/sensors/vs/0 items[] surface as diagnostic scalars (no unit advertised
on the resource, so no device_class until a populated reading + unit is
observed -- the 'don't guess' rule)."""
observed -- the 'don't guess' rule). CleanLevel is corroborated as numeric
by a top-level cleanLevel scalar, so it's an int measurement; the others
are string diagnostics. Dust/FineDust/SuperFineDust carry a 2-element
array whose second element is unconfirmed -- v[0] is taken as the reading
(see _sensor_item_value)."""
reg, resources = _ac_windfree()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
for key in ('clean_level', 'odor', 'dust', 'fine_dust'):
assert key in state, key
assert state[key] == '0'
# SuperFineDust exists in the dump but is deliberately not modeled.
assert 'super_fine_dust' not in state
assert state['clean_level'] == 0 # numeric (int), corroborated
for key in ('odor', 'dust', 'fine_dust', 'super_fine_dust'):
assert state[key] == '0' # string diagnostic
# tp1x_da_ac_rac_01011 is the only fixture with a non-zero air-quality
# reading -- the one that catches a value_fn regression.
reg2, resources2 = _ac_tp1x()
state2 = flatten(
discover(resources2, reg2.capabilities, reg2.pattern_capabilities), resources2)
assert state2['clean_level'] == 1
def test_air_quality_absent_when_no_sensor_items():
"""Boards whose /sensors/vs/0 has no items[] (TP1X_DA-AC-RAC-01001) bind no
air-quality entities -- exists_fn gates each on its item type."""
reg, resources = _resolve('airconditioner_tp1x_rac')
"""A board whose /sensors/vs/0 carries an empty items[] (the cool-only
RAC variant) binds no air-quality entities -- exists_fn gates each on its
item type, not merely on the href being present."""
reg, resources = _resolve('airconditioner_tp1x_rac_coolonly')
assert '/sensors/vs/0' in resources # the href is there, just empty
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
for key in ('clean_level', 'odor', 'dust', 'fine_dust'):
for key in ('clean_level', 'odor', 'dust', 'fine_dust', 'super_fine_dust'):
assert key not in state, key
@@ -592,26 +639,76 @@ def test_sensor_item_value_picks_first_value():
def test_software_and_firmware_version_from_info_items():
"""/information/vs/0 items[] carry Software/Firmware version strings
(the href is otherwise identity plumbing; the AC registry drops the global
ignore so INFO is the sole cap on it)."""
ignore so INFO is the sole cap on it). Each version item is a distinct
MCU; they're exposed per-item rather than collapsed to one 'first wins'."""
reg, resources = _ac_windfree()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['software_version'] == '02181A230313'
assert state['firmware_version'] == '20082000,FFFFFFFF'
assert state['outdoor_unit_version'] == '20091600,10000400'
# windfree has a single Firmware item -> no _2/_3 entities.
assert 'firmware_version_2' not in state
assert 'touch_ic_version' not in state
def test_info_item_number_picks_first_match():
"""_info_item_number returns the number of the first item with the given
type; None when absent. Multiple Firmware items -> first wins."""
def test_info_version_exposes_each_firmware_mcu():
"""tp1x_da_ac_rac_01011 reports three Firmware items (separate MCUs) --
each surfaces as its own diagnostic, not collapsed to the first."""
reg, resources = _ac_tp1x()
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['software_version'] == '02762A260401'
assert state['firmware_version'] == '02756C25082500,FFFFFFFFFFFFFF'
assert state['firmware_version_2'] == '02669A24092600,02636A10001200'
assert state['firmware_version_3'] == '02672A10001000,FFFFFFFFFFFFFF'
def test_info_version_skips_item_with_no_number():
"""tp2x_rac_20k's second Firmware item carries no x.com.samsung.da.number
-- exists_fn suppresses firmware_version_2 rather than binding a
permanently-unknown entity."""
reg, resources = _resolve('airconditioner_tp2x_rac_20k')
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['firmware_version'] == '102296A23012700'
assert 'firmware_version_2' not in state
def test_info_version_touch_ic_on_window_ac():
"""window_ac additionally reports a Touch IC version item."""
resources = _load_device('airconditioner_window_ac')
info = resources['/information/vs/0']
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
state = flatten(
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
assert state['software_version'] == '02545A260601'
assert state['firmware_version'] == '02543A24061800,FFFFFFFFFFFFFF'
assert state['outdoor_unit_version'] == '02580A10000100,FFFFFFFFFFFFFF'
assert state['touch_ic_version'] == '02553A23031600,FFFFFFFFFFFFFF'
def test_info_version_per_ordinal():
"""_info_version returns the ordinal-th item of a type; None when absent
or (via exists_fn) when that item carries no number."""
items = [
{'x.com.samsung.da.type': 'Software', 'x.com.samsung.da.number': 'SW1'},
{'x.com.samsung.da.type': 'Firmware', 'x.com.samsung.da.number': 'FW1'},
{'x.com.samsung.da.type': 'Firmware', 'x.com.samsung.da.number': 'FW2'},
{'x.com.samsung.da.type': 'Firmware'}, # no number
]
assert airconditioner._info_item_number(items, 'Software') == 'SW1'
assert airconditioner._info_item_number(items, 'Firmware') == 'FW1'
assert airconditioner._info_item_number(items, 'Outdoor') is None
assert airconditioner._info_item_number(None, 'Software') is None
assert airconditioner._info_version(items, 'Software', 0) == 'SW1'
assert airconditioner._info_version(items, 'Firmware', 0) == 'FW1'
assert airconditioner._info_version(items, 'Firmware', 1) == 'FW2'
assert airconditioner._info_version(items, 'Firmware', 2) is None # no number
assert airconditioner._info_version(items, 'Firmware', 3) is None # no such ordinal
assert airconditioner._info_version(items, 'Outdoor', 0) is None
assert airconditioner._info_version(None, 'Software', 0) is None
assert airconditioner._has_info_version('Firmware', 0)( # exists_fn
{'x.com.samsung.da.items': items}, {}) is True
assert airconditioner._has_info_version('Firmware', 2)( # item w/o number
{'x.com.samsung.da.items': items}, {}) is False
def test_info_drops_global_ignore_on_information_href():