diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 306579b..76a848c 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -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, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index be9fb01..39b0708 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -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)), ), ) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 7fa2a4f..2dd280d 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 58b92ce..3f66ba8 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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" }, diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index e594110..42360a8 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -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" ] } diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json index eecfb07..983ee35 100644 --- a/tests/fixtures/golden/airconditioner_caww_tp2.json +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -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" diff --git a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json index a16008e..a8e81e3 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -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" ] } diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac.json b/tests/fixtures/golden/airconditioner_tp1x_rac.json index 90ff4f7..b5f36df 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac.json @@ -17,6 +17,8 @@ "energy_saved_kwh", "firmware_update", "firmware_version", + "firmware_version_2", + "firmware_version_3", "mute_once", "selfcheck_error", "selfcheck_result", diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json index 86e7d8b..6997876 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json @@ -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", diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json index 452c9b6..60a6469 100644 --- a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -1,7 +1,6 @@ { "state_keys": [ "air_filter_status", - "air_filter_threshold", "air_filter_usage", "air_filter_usage_hours", "alarm_code", diff --git a/tests/fixtures/golden/airconditioner_windfree.json b/tests/fixtures/golden/airconditioner_windfree.json index 57d05d0..e7446ae 100644 --- a/tests/fixtures/golden/airconditioner_windfree.json +++ b/tests/fixtures/golden/airconditioner_windfree.json @@ -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" ] } diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json index bc51a28..76cb555 100644 --- a/tests/fixtures/golden/airconditioner_window_ac.json +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -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" ] } diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index b47d8aa..ebddc1f 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -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():