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