From 3da01f80d083b2097febe2b44e5c51ac68eaf20f Mon Sep 17 00:00:00 2001 From: blka Date: Mon, 27 Jul 2026 14:43:07 +0200 Subject: [PATCH 1/4] feat(airconditioner): add beep, tropical night, filter hours/threshold, air-quality, sw/fw version Layer the ARTIK051_PRAC additive entities onto the upstream registry on a fresh branch (additive-only; the round-1 display-light/mute-once/WindFree work was independently shipped upstream and is not redone here). New entities, all driven by single-token option_write or item reads: - beep (switch): Volume_Mute/Volume_100 option token; single-token merge -- a full options RMW reverts on ARTIK051_PRAC. Cloud counterpart: samsungce.airConditionerAudioFeedback (on/off only; level is cloud-only). - tropical_night_mode (number 0-16): Sleep_ option token. Cloud: custom.airConditionerTropicalNightMode. - air_filter_usage_hours / air_filter_threshold (duration sensors, h): raw filterUsage count and filterDesiredUsage alarm threshold. Threshold SET is cloud-only (samsungce.dustFilterAlarm); local read-only. - clean_level / odor / dust / fine_dust (diagnostic sensors): /sensors/vs/0 items[]. No unit advertised on the resource, so no device_class until a populated reading + unit is observed (don't-guess rule). SuperFineDust intentionally not modeled. - software_version / firmware_version (diagnostic sensors): /information/vs/0 items[]. The href is globally ignored as identity plumbing; the AC registry drops that ignore so INFO is the sole cap on it. Translations: en.json + nl.json (every-language-mirrors-english invariant). Golden regression: regenerated the 8 AC fixtures + dehumidifier (reuses AIR_FILTER) for the new state keys. 608 tests pass. --- .../registry/by_type/airconditioner.py | 9 +- .../registry/capabilities/airconditioner.py | 155 +++++++++++- .../localthings/translations/en.json | 18 ++ .../localthings/translations/nl.json | 18 ++ tests/fixtures/golden/airconditioner.json | 12 +- .../golden/airconditioner_caww_tp2.json | 8 +- .../airconditioner_tp1x_da_ac_rac_01011.json | 10 +- .../golden/airconditioner_tp1x_rac.json | 8 +- .../airconditioner_tp1x_rac_coolonly.json | 8 +- .../golden/airconditioner_tp2x_rac_20k.json | 8 +- .../golden/airconditioner_windfree.json | 12 +- .../golden/airconditioner_window_ac.json | 7 +- tests/fixtures/golden/dehumidifier.json | 2 + tests/test_airconditioner_capabilities.py | 223 +++++++++++++++++- 14 files changed, 483 insertions(+), 15 deletions(-) diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 7a7c38c..306579b 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -12,16 +12,23 @@ Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0. from ..capabilities import airconditioner, common, dishwasher, ignored 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'] + REGISTRY = DeviceRegistry( name='airconditioner', capabilities=_build([ - *ignored.IGNORED, + *_AC_IGNORED, *common.UNIVERSAL, dishwasher.DIAGNOSIS, airconditioner.CLIMATE, airconditioner.AIR_PURIFY, airconditioner.AUTO_CLEAN, airconditioner.AIR_FILTER, + airconditioner.AIR_QUALITY, + airconditioner.INFO, airconditioner.DISPLAY_LIGHT, airconditioner.MUTE_ONCE, airconditioner.CURRENT_LIMIT, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 67bec22..be9fb01 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -15,10 +15,98 @@ different schema (see capabilities/__init__.py). They live only in the AC by_type registry. """ from ..capability import Capability -from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc +from ..entities import ( + BinarySensorDesc, ClimateDesc, NumberDesc, SensorDesc, SwitchDesc, +) from .common import normalize_temp_unit from .laundry import option_write + +def _int(v): + try: + return int(v) + except (TypeError, ValueError): + return None + + +def _option_token(rep, prefix): + """Return the `_` token from /mode/vs/0 options, else None.""" + for o in _mode_options(rep): + if isinstance(o, str) and o.startswith(prefix + '_'): + return o + return None + + +def _beep_on(rep): + """Beep on/off from the `Volume_*` option token: Volume_Mute = off, + Volume_100 (and any non-Mute) = on. None when no Volume_ slot.""" + tok = _option_token(rep, 'Volume') + if tok is None: + return None + return tok != 'Volume_Mute' + + +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).""" + if payload not in ('On', 'Off'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': option_write('Volume', '100' if payload == 'On' else 'Mute'), + } + + +def _tropical_night_value(rep): + """Tropical night mode level (0-16) from the `Sleep_` option token.""" + tok = _option_token(rep, 'Sleep') + if tok is None or '_' not in tok: + return None + return _int(tok.split('_', 1)[1]) + + +def _tropical_night_write(value, rep, href=None): + """Set tropical night level via a single-token `Sleep_` options write. + Samsung cloud counterpart: custom.airConditionerTropicalNightMode (0-16).""" + try: + level = int(round(float(value))) + except (TypeError, ValueError): + return None + if not 0 <= level <= 16: + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': option_write('Sleep', str(level)), + } + + +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') + return None + + +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).""" + 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') + if isinstance(v, list) and v: + return str(v[0]) + return None + return None + + +def _has_sensor_type(type_): + def fn(rep, resources): + return any(isinstance(i, dict) and i.get('x.com.samsung.da.type') == type_ + for i in (rep.get('x.com.samsung.da.items') or [])) + return fn + # --------------------------------------------------------------------------- # Canonical AC resource hrefs. The climate entity (climate.py) binds the # primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS @@ -37,6 +125,7 @@ HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array) +HREF_INFORMATION = '/information/vs/0' # model/serial + Software/Firmware version items CLIMATE_CONSUMED_HREFS = [ HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, @@ -201,6 +290,19 @@ CLIMATE = Capability( exists_fn=_has_display_light_option, write_fn=_display_light_write, icon='mdi:led-on', entity_category='config'), + # Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100). + # Single-token option_write; a full options RMW reverts on ARTIK051_PRAC. + SwitchDesc(key='beep', rep_fn=_beep_on, + exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None, + write_fn=_beep_write, + icon='mdi:volume-high', entity_category='config'), + # Tropical night mode level (0-16) from the `Sleep_` option token. + # Single-token option_write. Cloud: custom.airConditionerTropicalNightMode. + NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value, + exists_fn=lambda rep, resources: _option_token(rep, 'Sleep') is not None, + write_fn=_tropical_night_write, + native_min=0, native_max=16, step=1, + icon='mdi:weather-night', entity_category='config'), ), ) @@ -239,6 +341,17 @@ AIR_FILTER = Capability( SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent, unit='%', state_class='measurement', icon='mdi:air-filter', entity_category='diagnostic'), + SensorDesc(key='air_filter_usage_hours', + field='x.com.samsung.da.filterUsage', + device_class='duration', + state_class='measurement', unit='h', + icon='mdi:air-filter', entity_category='diagnostic', + value_fn=_int), + SensorDesc(key='air_filter_threshold', + field='x.com.samsung.da.filterDesiredUsage', + device_class='duration', + unit='h', icon='mdi:alarm', entity_category='diagnostic', + value_fn=_int), SensorDesc(key='air_filter_status', field='x.com.samsung.da.filterStatus', device_class='enum', options=('normal', 'wash', 'replace'), @@ -341,6 +454,43 @@ 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. +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'), + ) + ), +) + +# 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 = 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')), + 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')), + ), +) + # --------------------------------------------------------------------------- # AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate # entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. @@ -360,9 +510,6 @@ HUMIDITY = Capability( # of waiting on the summary sweep. # --------------------------------------------------------------------------- _AC_IGNORED = [ - # All-zero and ambiguously encoded on this model (2-value arrays); the - # 'don't guess' rule -- leave unmodeled rather than invent entities. - '/sensors/vs/0', # Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor # resource's usable fivepercentHumidity field instead; this OCF-standard # one has no corresponding live value confirmed yet. diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 1786d98..7fa2a4f 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -149,6 +149,9 @@ }, "target_humidity": { "name": "Target humidity" + }, + "tropical_night_mode": { + "name": "Tropical night mode" } }, "select": { @@ -478,6 +481,18 @@ "air_filter_usage": { "name": "Filter usage" }, + "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" + }, "air_sensing_state": { "name": "Air sensing state" }, @@ -729,6 +744,9 @@ "auto_clean": { "name": "Auto clean" }, + "beep": { + "name": "Beep" + }, "auto_door_opener": { "name": "Auto door opener" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index a6e73ed..58b92ce 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -149,6 +149,9 @@ }, "target_humidity": { "name": "Doelvochtigheid" + }, + "tropical_night_mode": { + "name": "Tropische nachtmodus" } }, "select": { @@ -478,6 +481,18 @@ "air_filter_usage": { "name": "Filterverbruik" }, + "air_filter_usage_hours": { + "name": "Filterverbruik (uren)" + }, + "air_filter_threshold": { + "name": "Filteralarmdrempel" + }, + "software_version": { + "name": "Softwareversie" + }, + "firmware_version": { + "name": "Firmwareversie" + }, "air_sensing_state": { "name": "Status luchtmeting" }, @@ -729,6 +744,9 @@ "auto_clean": { "name": "Automatisch reinigen" }, + "beep": { + "name": "Piep" + }, "auto_door_opener": { "name": "Automatische deuropener" }, diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index e0355d8..e594110 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -1,17 +1,27 @@ { "state_keys": [ "air_filter_status", + "air_filter_threshold", "air_filter_usage", + "air_filter_usage_hours", "air_purify", "alarm_code", "auto_clean", + "beep", + "clean_level", "climate", "current_temperature_c", "diagnosis_status", "display_light", + "dust", "energy_kwh", "energy_saved_kwh", + "fine_dust", + "firmware_version", "humidity", - "power_watts" + "odor", + "power_watts", + "software_version", + "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json index 456fd82..eecfb07 100644 --- a/tests/fixtures/golden/airconditioner_caww_tp2.json +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -1,9 +1,12 @@ { "state_keys": [ "air_filter_status", + "air_filter_threshold", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "diagnosis_status", @@ -11,8 +14,11 @@ "energy_kwh", "energy_saved_kwh", "firmware_update", + "firmware_version", "humidity", "mute_once", - "power_watts" + "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 af71051..a16008e 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -1,20 +1,28 @@ { "state_keys": [ "air_filter_status", + "air_filter_threshold", "air_filter_usage", + "air_filter_usage_hours", "air_purify", "alarm_code", "auto_clean", + "clean_level", "climate", "current_temperature_c", "display_light", + "dust", "energy_kwh", "energy_saved_kwh", + "fine_dust", "firmware_update", + "firmware_version", "humidity", "mute_once", "selfcheck_error", "selfcheck_result", - "selfcheck_status" + "selfcheck_status", + "software_version", + "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac.json b/tests/fixtures/golden/airconditioner_tp1x_rac.json index b192cdf..90ff4f7 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac.json @@ -1,10 +1,13 @@ { "state_keys": [ "air_filter_status", + "air_filter_threshold", "air_filter_usage", + "air_filter_usage_hours", "air_purify", "alarm_code", "auto_clean", + "beep", "climate", "current_limit_enabled", "current_limit_level", @@ -13,9 +16,12 @@ "energy_kwh", "energy_saved_kwh", "firmware_update", + "firmware_version", "mute_once", "selfcheck_error", "selfcheck_result", - "selfcheck_status" + "selfcheck_status", + "software_version", + "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json index 7b498eb..86e7d8b 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json @@ -1,17 +1,23 @@ { "state_keys": [ "air_filter_status", + "air_filter_threshold", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", "firmware_update", + "firmware_version", "humidity", "mute_once", - "power_watts" + "power_watts", + "software_version", + "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json index cca934d..452c9b6 100644 --- a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -1,16 +1,22 @@ { "state_keys": [ "air_filter_status", + "air_filter_threshold", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "display_light", "energy_kwh", "firmware_update", + "firmware_version", "humidity", "mute_once", - "power_watts" + "power_watts", + "software_version", + "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_windfree.json b/tests/fixtures/golden/airconditioner_windfree.json index f18e143..57d05d0 100644 --- a/tests/fixtures/golden/airconditioner_windfree.json +++ b/tests/fixtures/golden/airconditioner_windfree.json @@ -1,16 +1,26 @@ { "state_keys": [ "air_filter_status", + "air_filter_threshold", "air_filter_usage", + "air_filter_usage_hours", "air_purify", "alarm_code", "auto_clean", + "beep", + "clean_level", "climate", "current_temperature_c", "diagnosis_status", "display_light", + "dust", "energy_kwh", + "fine_dust", + "firmware_version", "humidity", - "power_watts" + "odor", + "power_watts", + "software_version", + "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json index 5b4b5e4..bc51a28 100644 --- a/tests/fixtures/golden/airconditioner_window_ac.json +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -1,20 +1,25 @@ { "state_keys": [ "air_filter_status", + "air_filter_threshold", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "display_light", "energy_kwh", "energy_saved_kwh", "firmware_update", + "firmware_version", "humidity", "mute_once", "power_watts", "selfcheck_error", "selfcheck_result", - "selfcheck_status" + "selfcheck_status", + "software_version" ] } diff --git a/tests/fixtures/golden/dehumidifier.json b/tests/fixtures/golden/dehumidifier.json index cb1a015..ddaeb9d 100644 --- a/tests/fixtures/golden/dehumidifier.json +++ b/tests/fixtures/golden/dehumidifier.json @@ -1,7 +1,9 @@ { "state_keys": [ "air_filter_status", + "air_filter_threshold", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", "energy_kwh", diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 3a63492..b47d8aa 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -131,11 +131,12 @@ def test_climate_consumed_hrefs_declared_as_coverage(): (as no-entity coverage caps) so they don't leak as gaps -- but produce no standalone entities. /temperature/current/0 and /temperatures/vs/0 are NOT in this list -- CURRENT_TEMPERATURE / CURRENT_TEMPERATURE_VS give - those two real sensor entities (issue #75).""" + those two real sensor entities (issue #75). /sensors/vs/0 is also NOT + here -- AIR_QUALITY gives it real entity sensors.""" reg, _ = _ac() for href in ('/power/0', '/power/vs/0', '/temperature/desired/0', '/wind/strength/vs/0', '/mode/convenient/vs/0', - '/sensors/vs/0', '/humidity/0'): + '/humidity/0'): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href @@ -405,3 +406,221 @@ def test_humidity_reads_five_percent_field_not_stuck_humidity_field(): desc = airconditioner.HUMIDITY.entities[0] rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'} assert desc.value_fn(rep.get(desc.field)) == 42.0 + + +# --------------------------------------------------------------------------- +# Additive entities layered on the ARTIK051_PRAC family on top of the upstream +# registry: beep (Volume_* option), tropical night mode (Sleep_ option), +# filter usage hours + alarm threshold (filterUsage / filterDesiredUsage), +# air-quality sensors (/sensors/vs/0 items), and software/firmware version +# (/information/vs/0 items). Beep and tropical night use the single-token +# option_write merge -- a full options RMW reverts on ARTIK051_PRAC (see the +# [[samsung-ac-local-vs-cloud-control]] memory). +# --------------------------------------------------------------------------- + +def _beep_desc(): + return next(e for e in airconditioner.CLIMATE.entities if e.key == 'beep') + + +def _tropical_desc(): + return next(e for e in airconditioner.CLIMATE.entities + if e.key == 'tropical_night_mode') + + +def test_beep_read_from_volume_token(): + """Volume_100 (and any non-Mute) -> on; Volume_Mute -> off; no Volume_ slot + -> None (entity won't bind via exists_fn).""" + assert airconditioner._beep_on( + {'x.com.samsung.da.options': ['Volume_100']}) is True + assert airconditioner._beep_on( + {'x.com.samsung.da.options': ['Volume_Mute']}) is False + assert airconditioner._beep_on( + {'x.com.samsung.da.options': ['Light_Off']}) is None + assert airconditioner._beep_on({}) is None + + +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).""" + write = _beep_desc().write_fn + assert write('On', {}) == ( + ['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 + + +def test_beep_absent_when_no_volume_token(): + """TP1X_DA-AC-RAC-01011 carries no Volume_ option -- beep must not bind.""" + reg, resources = _ac_tp1x() + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert 'beep' not in state + + +def test_beep_state_on_windfree(): + """The WindFree fixture reports Volume_100 -> beep reads True.""" + reg, resources = _ac_windfree() + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state['beep'] is True + + +def test_tropical_night_read_from_sleep_token(): + """Sleep_ -> N; absent -> None.""" + assert airconditioner._tropical_night_value( + {'x.com.samsung.da.options': ['Sleep_0']}) == 0 + assert airconditioner._tropical_night_value( + {'x.com.samsung.da.options': ['Sleep_16']}) == 16 + assert airconditioner._tropical_night_value( + {'x.com.samsung.da.options': ['Volume_100']}) is None + assert airconditioner._tropical_night_value({}) is None + + +def test_tropical_night_write_is_single_token_options_merge(): + """Valid 0-16 -> `['Sleep_']`; out of range / non-numeric -> None (no + write). Cloud counterpart: custom.airConditionerTropicalNightMode (0-16).""" + write = _tropical_desc().write_fn + assert write(0, {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_0']}) + assert write(16, {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_16']}) + assert write(17, {}) is None + assert write(-1, {}) is None + assert write('not-a-number', {}) is None + # Float rounds to nearest int within range. + assert write(5.6, {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_6']}) + + +def test_tropical_night_absent_when_no_sleep_token(): + """TP1X_DA-AC-WAC (window AC) carries no Sleep_ option -- tropical night + mode must not bind.""" + 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 'tropical_night_mode' not in state + + +def test_tropical_night_state_levels_across_fixtures(): + """Sleep_0 / Sleep_6 / Sleep_16 surface as 0 / 6 / 16 respectively.""" + def level(name): + res = _load_device(name) + info = res['/information/vs/0'] + r = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + return flatten(discover(res, r.capabilities, r.pattern_capabilities), res).get( + 'tropical_night_mode') + assert level('airconditioner_windfree') == 0 + assert level('airconditioner_tp1x_da_ac_rac_01011') == 6 + assert level('airconditioner_tp2x_rac_20k') == 16 + + +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.""" + 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' + + +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).""" + 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 desc.value_fn(None) is None + assert getattr(desc, 'write_fn', None) is None + assert desc.unit == 'h' and desc.device_class == 'duration' + + +def test_air_filter_hours_and_threshold_in_state(): + reg, resources = _ac_windfree() + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + 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_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).""" + 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 + + +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') + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + for key in ('clean_level', 'odor', 'dust', 'fine_dust'): + assert key not in state, key + + +def test_sensor_item_value_picks_first_value(): + """_sensor_item_value returns the first element of the value list, as a + string; None when the item is absent or its value is empty.""" + items = [ + {'x.com.samsung.da.type': 'Dust', 'x.com.samsung.da.value': ['0', '0']}, + {'x.com.samsung.da.type': 'Odor', 'x.com.samsung.da.value': []}, + ] + assert airconditioner._sensor_item_value(items, 'Dust') == '0' + assert airconditioner._sensor_item_value(items, 'Odor') is None + assert airconditioner._sensor_item_value(items, 'Missing') is None + assert airconditioner._sensor_item_value(None, 'Dust') is None + + +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).""" + 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' + + +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.""" + 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'}, + ] + 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 + + +def test_info_drops_global_ignore_on_information_href(): + """The AC registry drops /information/vs/0 from ignored.IGNORED so INFO is + the sole capability on the href (no no-entity coverage cap duplicates).""" + reg, _ = _ac() + caps = reg.capabilities.get('/information/vs/0') + assert caps, '/information/vs/0' + # INFO is the only cap on the href, and it carries real entities. + assert len(caps) == 1 + assert caps[0] is airconditioner.INFO + assert airconditioner.INFO.entities != () From 51fa1c5ea812327f9f265337c8da6c5405ed496b Mon Sep 17 00:00:00 2001 From: blka Date: Mon, 27 Jul 2026 22:22:56 +0200 Subject: [PATCH 2/4] 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. --- .../registry/by_type/airconditioner.py | 6 +- .../registry/capabilities/airconditioner.py | 159 +++++++++++++---- .../localthings/translations/en.json | 18 +- .../localthings/translations/nl.json | 18 +- tests/fixtures/golden/airconditioner.json | 3 +- .../golden/airconditioner_caww_tp2.json | 2 +- .../airconditioner_tp1x_da_ac_rac_01011.json | 3 + .../golden/airconditioner_tp1x_rac.json | 2 + .../airconditioner_tp1x_rac_coolonly.json | 2 +- .../golden/airconditioner_tp2x_rac_20k.json | 1 - .../golden/airconditioner_windfree.json | 3 +- .../golden/airconditioner_window_ac.json | 5 +- tests/test_airconditioner_capabilities.py | 163 ++++++++++++++---- 13 files changed, 302 insertions(+), 83 deletions(-) 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(): From 1bbecfa5c3342a9b5cf1e660dd4a3df9d1fdd2d6 Mon Sep 17 00:00:00 2001 From: blka Date: Tue, 28 Jul 2026 10:07:39 +0200 Subject: [PATCH 3/4] Drop /information/vs/0 version entities per review mbillow's follow-up review: exposing read-only Software/Firmware (and Outdoor/Touch IC) version strings is "data for the sake of exposing it" -- no user control, just clutter, and every other registry leaves /information/vs/0 in the global ignore as identity plumbing. Conforming to that stance rather than expanding the entity surface for no user story. - by_type/airconditioner.py: revert to *ignored.IGNORED (no _IGNORED_LESS_INFO filter); drop airconditioner.INFO from the registry. - capabilities/airconditioner.py: remove the INFO capability, the _info_version /_info_items_of_type /_has_info_version helpers, and HREF_INFORMATION. - translations/{en,nl}.json: drop software_version, firmware_version, firmware_version_2/3, outdoor_unit_version, touch_ic_version. - tests: drop the six INFO tests; golden regenerated for 8 AC + dehumidifier. 625 tests pass. --- .../registry/by_type/airconditioner.py | 10 +-- .../registry/capabilities/airconditioner.py | 60 ------------- .../localthings/translations/en.json | 21 ----- .../localthings/translations/nl.json | 21 ----- tests/fixtures/golden/airconditioner.json | 3 - .../golden/airconditioner_ara_ww_tp1_22.json | 3 - .../golden/airconditioner_caww_tp2.json | 3 - .../airconditioner_tp1x_da_ac_rac_01011.json | 4 - .../golden/airconditioner_tp1x_rac.json | 4 - .../airconditioner_tp1x_rac_coolonly.json | 3 - .../golden/airconditioner_tp2x_rac_20k.json | 2 - .../golden/airconditioner_windfree.json | 3 - .../airconditioner_windfree_oscillation.json | 4 - .../golden/airconditioner_window_ac.json | 6 +- tests/test_airconditioner_capabilities.py | 87 ------------------- 15 files changed, 2 insertions(+), 232 deletions(-) diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 222221f..94e4797 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -12,17 +12,10 @@ Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0. from ..capabilities import airconditioner, common, dishwasher, ignored 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. -# 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([ - *_IGNORED_LESS_INFO, + *ignored.IGNORED, *common.UNIVERSAL, dishwasher.DIAGNOSIS, airconditioner.CLIMATE, @@ -30,7 +23,6 @@ REGISTRY = DeviceRegistry( airconditioner.AUTO_CLEAN, airconditioner.AIR_FILTER, airconditioner.AIR_QUALITY, - airconditioner.INFO, airconditioner.DISPLAY_LIGHT, airconditioner.MUTE_ONCE, airconditioner.CURRENT_LIMIT, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 470d723..4355155 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -86,29 +86,6 @@ def _tropical_night_write(value, rep, href=None): } -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 @@ -178,7 +155,6 @@ HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array) -HREF_INFORMATION = '/information/vs/0' # model/serial + Software/Firmware version items CLIMATE_CONSUMED_HREFS = [ HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, @@ -571,42 +547,6 @@ AIR_QUALITY = Capability( ), ) -# 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). 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', - 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', - 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)), - ), -) # --------------------------------------------------------------------------- # AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index af5e72c..4c8cf80 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -522,24 +522,6 @@ "air_filter_usage_hours": { "name": "Filter usage hours" }, - "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" }, @@ -695,9 +677,6 @@ "hood_filter_usage": { "name": "Filter usage" }, - "humidity": { - "name": "Humidity" - }, "instance_temperature": { "name": "{instance_name} temperature" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 15ba9fd..b74eb7d 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -522,24 +522,6 @@ "air_filter_usage_hours": { "name": "Filterverbruik (uren)" }, - "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" }, @@ -695,9 +677,6 @@ "hood_filter_usage": { "name": "Filterverbruik" }, - "humidity": { - "name": "Luchtvochtigheid" - }, "instance_temperature": { "name": "Temperatuur {instance_name}" }, diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index 42360a8..ffb4c56 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -16,12 +16,9 @@ "energy_kwh", "energy_saved_kwh", "fine_dust", - "firmware_version", "humidity", "odor", - "outdoor_unit_version", "power_watts", - "software_version", "super_fine_dust", "tropical_night_mode" ] diff --git a/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json b/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json index 6997876..a138056 100644 --- a/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json +++ b/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json @@ -12,12 +12,9 @@ "energy_kwh", "energy_saved_kwh", "firmware_update", - "firmware_version", - "firmware_version_2", "humidity", "mute_once", "power_watts", - "software_version", "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json index 983ee35..dc60454 100644 --- a/tests/fixtures/golden/airconditioner_caww_tp2.json +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -13,12 +13,9 @@ "energy_kwh", "energy_saved_kwh", "firmware_update", - "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 a8e81e3..0fb30d7 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -16,15 +16,11 @@ "energy_saved_kwh", "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 b5f36df..b46a854 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac.json @@ -16,14 +16,10 @@ "energy_kwh", "energy_saved_kwh", "firmware_update", - "firmware_version", - "firmware_version_2", - "firmware_version_3", "mute_once", "selfcheck_error", "selfcheck_result", "selfcheck_status", - "software_version", "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json index 6997876..a138056 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json @@ -12,12 +12,9 @@ "energy_kwh", "energy_saved_kwh", "firmware_update", - "firmware_version", - "firmware_version_2", "humidity", "mute_once", "power_watts", - "software_version", "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json index 60a6469..cf11345 100644 --- a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -11,11 +11,9 @@ "display_light", "energy_kwh", "firmware_update", - "firmware_version", "humidity", "mute_once", "power_watts", - "software_version", "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_windfree.json b/tests/fixtures/golden/airconditioner_windfree.json index e7446ae..2407a44 100644 --- a/tests/fixtures/golden/airconditioner_windfree.json +++ b/tests/fixtures/golden/airconditioner_windfree.json @@ -15,12 +15,9 @@ "dust", "energy_kwh", "fine_dust", - "firmware_version", "humidity", "odor", - "outdoor_unit_version", "power_watts", - "software_version", "super_fine_dust", "tropical_night_mode" ] diff --git a/tests/fixtures/golden/airconditioner_windfree_oscillation.json b/tests/fixtures/golden/airconditioner_windfree_oscillation.json index a210877..99aa641 100644 --- a/tests/fixtures/golden/airconditioner_windfree_oscillation.json +++ b/tests/fixtures/golden/airconditioner_windfree_oscillation.json @@ -13,9 +13,6 @@ "energy_kwh", "energy_saved_kwh", "firmware_update", - "firmware_version", - "firmware_version_2", - "firmware_version_3", "humidity", "mute_once", "overload_protection_active", @@ -23,7 +20,6 @@ "selfcheck_error", "selfcheck_result", "selfcheck_status", - "software_version", "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json index 76cb555..904800b 100644 --- a/tests/fixtures/golden/airconditioner_window_ac.json +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -12,15 +12,11 @@ "energy_kwh", "energy_saved_kwh", "firmware_update", - "firmware_version", "humidity", "mute_once", - "outdoor_unit_version", "power_watts", "selfcheck_error", "selfcheck_result", - "selfcheck_status", - "software_version", - "touch_ic_version" + "selfcheck_status" ] } diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index ebddc1f..630ccb2 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -634,90 +634,3 @@ def test_sensor_item_value_picks_first_value(): assert airconditioner._sensor_item_value(items, 'Odor') is None assert airconditioner._sensor_item_value(items, 'Missing') is None assert airconditioner._sensor_item_value(None, 'Dust') is None - - -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). 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_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_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(): - """The AC registry drops /information/vs/0 from ignored.IGNORED so INFO is - the sole capability on the href (no no-entity coverage cap duplicates).""" - reg, _ = _ac() - caps = reg.capabilities.get('/information/vs/0') - assert caps, '/information/vs/0' - # INFO is the only cap on the href, and it carries real entities. - assert len(caps) == 1 - assert caps[0] is airconditioner.INFO - assert airconditioner.INFO.entities != () From 5c962801b4590309920368dc1734e9bf4a83d09d Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 13:29:49 +0000 Subject: [PATCH 4/4] Fix _tropical_night_value, which had the same stale _option_token assumption Same issue as the beep fix in the previous commit: this also assumed _option_token returned the full 'Sleep_' token and tried to split off the prefix itself. With the canonical value-half _option_token, that always returned None. Read the value directly instead. --- .../registry/capabilities/airconditioner.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 5ccfae7..07ff46e 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -63,11 +63,15 @@ def _beep_write(payload, rep, href=None): def _tropical_night_value(rep): - """Tropical night mode level (0-16) from the `Sleep_` option token.""" + """Tropical night mode level (0-16) from the `Sleep_` option token. + + _option_token returns the token's value half already (e.g. '16' for + 'Sleep_16'), same convention as _beep_on above. + """ tok = _option_token(rep, 'Sleep') - if tok is None or '_' not in tok: + if tok is None: return None - return _int(tok.split('_', 1)[1]) + return _int(tok) def _tropical_night_write(value, rep, href=None):