diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 9ea273a..94e4797 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -22,6 +22,7 @@ REGISTRY = DeviceRegistry( airconditioner.AIR_PURIFY, airconditioner.AUTO_CLEAN, airconditioner.AIR_FILTER, + airconditioner.AIR_QUALITY, 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 4dc5a61..d2cafa3 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -16,11 +16,123 @@ by_type registry. """ from ..capability import Capability from ..entities import ( - BinarySensorDesc, ClimateDesc, NumberDesc, SensorDesc, SwitchDesc, + BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, ) from .common import filter_usage_percent, normalize_temp_unit from .laundry import option_write + +def _int(v): + try: + return int(v) + except (TypeError, ValueError): + 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. + + _option_token (defined further below, alongside the legacy-board token + helpers) returns the token's *value* half (e.g. 'Mute', '100'), not the + full 'Volume_100' token -- shared with _option_token_num/_option_token_on, + which this module's other options[] readers already rely on. + """ + tok = _option_token(rep, 'Volume') + if tok is None: + return None + return tok != '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). '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 if (prev and prev != 'Mute') else '100' + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': option_write('Volume', token), + } + + +def _tropical_night_value(rep): + """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: + return None + return _int(tok) + + +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 _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. 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') + 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 @@ -313,6 +425,29 @@ 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. + # Gated off the legacy ARTIK051 board generation (see is_legacy_board): + # that generation's own Volume_ token is already modeled as the + # buzzer_volume Number below, and both reading the same options[] slot + # into two entities would be redundant. + SwitchDesc(key='beep', rep_fn=_beep_on, + exists_fn=lambda rep, resources: ( + not is_legacy_board(resources) + and _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. + # Gated off the legacy board for the same reason as beep above -- its + # Sleep_ token is already the good_sleep Number below. + NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value, + exists_fn=lambda rep, resources: ( + not is_legacy_board(resources) + and _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'), # Settings that this board generation keeps as options[] tokens. SwitchDesc(key='spi', rep_fn=_option_token_on('Spi'), exists_fn=_has_option_token('Spi'), @@ -403,6 +538,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='total_increasing', + unit_fn=_filter_unit, + icon='mdi:air-filter', entity_category='diagnostic', + value_fn=_int), + # 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', + 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'), @@ -538,6 +695,38 @@ HUMIDITY = Capability( ), ) +# /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=( + 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'), + ) + ), + ), +) + + # --------------------------------------------------------------------------- # AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate # entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. @@ -557,9 +746,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 6b9fe90..e92e5ec 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -187,12 +187,18 @@ }, "target_humidity": { "name": "Target humidity" + }, + "tropical_night_mode": { + "name": "Tropical night mode" } }, "select": { "ai_energy_level": { "name": "AI Energy Mode level" }, + "air_filter_threshold": { + "name": "Filter alarm threshold" + }, "beverage_zone_mode": { "name": "Beverage zone mode", "state": { @@ -580,6 +586,9 @@ "air_filter_usage": { "name": "Filter usage" }, + "air_filter_usage_hours": { + "name": "Filter usage hours" + }, "air_sensing_state": { "name": "Air sensing state" }, @@ -750,9 +759,6 @@ "hood_filter_usage": { "name": "Filter usage" }, - "humidity": { - "name": "Humidity" - }, "instance_temperature": { "name": "{instance_name} temperature" }, @@ -892,6 +898,9 @@ "auto_clean": { "name": "Auto clean" }, + "beep": { + "name": "Beep" + }, "display": { "name": "Display" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 87e3e18..4e53716 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -187,12 +187,18 @@ }, "target_humidity": { "name": "Doelvochtigheid" + }, + "tropical_night_mode": { + "name": "Tropische nachtmodus" } }, "select": { "ai_energy_level": { "name": "Niveau AI Energy Mode" }, + "air_filter_threshold": { + "name": "Filteralarmdrempel" + }, "beverage_zone_mode": { "name": "Modus drankenzone", "state": { @@ -580,6 +586,9 @@ "air_filter_usage": { "name": "Filterverbruik" }, + "air_filter_usage_hours": { + "name": "Filterverbruik (uren)" + }, "air_sensing_state": { "name": "Status luchtmeting" }, @@ -750,9 +759,6 @@ "hood_filter_usage": { "name": "Filterverbruik" }, - "humidity": { - "name": "Luchtvochtigheid" - }, "instance_temperature": { "name": "Temperatuur {instance_name}" }, @@ -892,6 +898,9 @@ "auto_clean": { "name": "Automatisch reinigen" }, + "beep": { + "name": "Piep" + }, "display": { "name": "Display" }, diff --git a/tests/fixtures/golden/airconditioner.json b/tests/fixtures/golden/airconditioner.json index e0355d8..ffb4c56 100644 --- a/tests/fixtures/golden/airconditioner.json +++ b/tests/fixtures/golden/airconditioner.json @@ -2,16 +2,24 @@ "state_keys": [ "air_filter_status", "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", "humidity", - "power_watts" + "odor", + "power_watts", + "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 a2b459c..a138056 100644 --- a/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json +++ b/tests/fixtures/golden/airconditioner_ara_ww_tp1_22.json @@ -2,8 +2,10 @@ "state_keys": [ "air_filter_status", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "display_light", @@ -12,6 +14,7 @@ "firmware_update", "humidity", "mute_once", - "power_watts" + "power_watts", + "tropical_night_mode" ] -} \ No newline at end of file +} diff --git a/tests/fixtures/golden/airconditioner_caww_tp2.json b/tests/fixtures/golden/airconditioner_caww_tp2.json index 456fd82..dc60454 100644 --- a/tests/fixtures/golden/airconditioner_caww_tp2.json +++ b/tests/fixtures/golden/airconditioner_caww_tp2.json @@ -2,8 +2,10 @@ "state_keys": [ "air_filter_status", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "diagnosis_status", @@ -13,6 +15,7 @@ "firmware_update", "humidity", "mute_once", - "power_watts" + "power_watts", + "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_fac_bora.json b/tests/fixtures/golden/airconditioner_fac_bora.json index 5bedb6d..aca70e7 100644 --- a/tests/fixtures/golden/airconditioner_fac_bora.json +++ b/tests/fixtures/golden/airconditioner_fac_bora.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,6 +14,7 @@ "firmware_update", "humidity", "power_energy_kwh", - "power_watts" + "power_watts", + "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..0fb30d7 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,27 @@ { "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", "humidity", "mute_once", "selfcheck_error", "selfcheck_result", - "selfcheck_status" + "selfcheck_status", + "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 b192cdf..b46a854 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", @@ -16,6 +19,7 @@ "mute_once", "selfcheck_error", "selfcheck_result", - "selfcheck_status" + "selfcheck_status", + "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json index 7b498eb..a138056 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json @@ -2,8 +2,10 @@ "state_keys": [ "air_filter_status", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "display_light", @@ -12,6 +14,7 @@ "firmware_update", "humidity", "mute_once", - "power_watts" + "power_watts", + "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..a138056 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_coolonly.json @@ -2,8 +2,10 @@ "state_keys": [ "air_filter_status", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "display_light", @@ -12,6 +14,7 @@ "firmware_update", "humidity", "mute_once", - "power_watts" + "power_watts", + "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..cf11345 100644 --- a/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json +++ b/tests/fixtures/golden/airconditioner_tp2x_rac_20k.json @@ -2,8 +2,10 @@ "state_keys": [ "air_filter_status", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "display_light", @@ -11,6 +13,7 @@ "firmware_update", "humidity", "mute_once", - "power_watts" + "power_watts", + "tropical_night_mode" ] } diff --git a/tests/fixtures/golden/airconditioner_windfree.json b/tests/fixtures/golden/airconditioner_windfree.json index f18e143..2407a44 100644 --- a/tests/fixtures/golden/airconditioner_windfree.json +++ b/tests/fixtures/golden/airconditioner_windfree.json @@ -2,15 +2,23 @@ "state_keys": [ "air_filter_status", "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", "humidity", - "power_watts" + "odor", + "power_watts", + "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 000bed7..99aa641 100644 --- a/tests/fixtures/golden/airconditioner_windfree_oscillation.json +++ b/tests/fixtures/golden/airconditioner_windfree_oscillation.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", "display_light", @@ -16,6 +19,7 @@ "overload_protection_mode", "selfcheck_error", "selfcheck_result", - "selfcheck_status" + "selfcheck_status", + "tropical_night_mode" ] -} \ No newline at end of file +} diff --git a/tests/fixtures/golden/airconditioner_window_ac.json b/tests/fixtures/golden/airconditioner_window_ac.json index 5b4b5e4..904800b 100644 --- a/tests/fixtures/golden/airconditioner_window_ac.json +++ b/tests/fixtures/golden/airconditioner_window_ac.json @@ -2,8 +2,10 @@ "state_keys": [ "air_filter_status", "air_filter_usage", + "air_filter_usage_hours", "alarm_code", "auto_clean", + "beep", "climate", "current_temperature_c", "display_light", 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 1749412..282dd23 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 @@ -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 @@ -470,3 +471,245 @@ def test_fac_bora_climate_entity_present(): def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed(): assert '/subdevices/vs/0' in airconditioner._AC_IGNORED assert '/runn/vs/0' in airconditioner._AC_IGNORED + + +# --------------------------------------------------------------------------- +# 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(): + """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 + + +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 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.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_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 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 + # 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_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_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). 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) + 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(): + """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', 'super_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_beep_and_tropical_night_stay_off_legacy_krac_board(): + """ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_ + option token, but they're already modeled as buzzer_volume/good_sleep + (see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also + bind there, or the same options[] slot would surface as two entities.""" + reg, resources = _resolve('airconditioner_artik051_krac_18k') + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert 'beep' not in state + assert 'tropical_night_mode' not in state + assert state['buzzer_volume'] == 100.0 + assert state['good_sleep'] == 0.0