From 3da01f80d083b2097febe2b44e5c51ac68eaf20f Mon Sep 17 00:00:00 2001 From: blka Date: Mon, 27 Jul 2026 14:43:07 +0200 Subject: [PATCH] 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 != ()