diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 15f6ea0..2f766c9 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -58,6 +58,16 @@ REGISTRY = DeviceRegistry( airconditioner.ENERGY_SAVING, airconditioner.EDGE_LIGHTING, airconditioner.LIGHT_STATEFUL, + # System Fresh Air Ventilator (PR #316, ACA-KR-TP2-21-AN9000): + # WINDFREE/WINDSLEEP are this device's own hrefs; HEPA_FILTER/ + # DEVICE_ACTIVE reuse air_purifier.py's identical shapes. + # AIR_LEVEL_CHECK is not this-device-specific -- see its + # removal from _AC_IGNORED above. + airconditioner.WINDFREE, + airconditioner.WINDSLEEP, + air_purifier.HEPA_FILTER, + air_purifier.DEVICE_ACTIVE, + air_purifier.AIR_LEVEL_CHECK, *airconditioner.COVERAGE, ] ), diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index ae5f636..db0b69a 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -206,6 +206,28 @@ def _mode_options(rep): return opts if isinstance(opts, (list, tuple)) else () +# Samsung's "System Fresh Air Ventilator" (PR #316, model +# ACA-KR-TP2-21-AN9000, vid DA-AC-DIFFUSER-01001) self-reports oic.d. +# airconditioner and routes through this same CLIMATE capability, but its +# /mode/vs/0 supportedModes are Purification/Ventilation/SmartVentilation -- +# none of which climate.py's HVAC-mode table knows, so hvac_mode collapses +# to a single stuck value with no way to tell the three apart. Gated to +# devices whose *entire* supported-mode set is this vocabulary, so it can't +# false-positive on a real AC's Cool/Heat/Dry list. +_VENTILATION_MODE_VALUES = frozenset(("Purification", "Ventilation", "SmartVentilation")) + + +def _is_ventilation_mode_device(rep, resources): + supported = rep.get("x.com.samsung.da.supportedModes") + if not isinstance(supported, (list, tuple)) or not supported: + return False + return set(supported) <= _VENTILATION_MODE_VALUES + + +def _ventilation_mode_write(payload, rep, href=None): + return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [payload]} + + def _has_display_light_option(rep, resources): """True when the panel light lives in /mode/vs/0's `Light_*` option token rather than a dedicated /light/vs/0 switch -- the two encodings @@ -532,6 +554,17 @@ CLIMATE = Capability( rep_fn=_first_mode, write_fn=_climate_write, ), + # Purification/Ventilation/SmartVentilation mode select (PR #316) -- + # _is_ventilation_mode_device gates this to devices using that + # vocabulary exclusively, so a real AC's climate card is unaffected. + SelectDesc( + key="ventilation_mode", + rep_fn=_first_mode, + exists_fn=_is_ventilation_mode_device, + options_field="x.com.samsung.da.supportedModes", + icon="mdi:air-filter", + write_fn=_ventilation_mode_write, + ), # Panel light switch for boards that encode it in /mode/vs/0's options # instead of a dedicated /light/vs/0 (see _has_display_light_option). # Shares the switch.display_light translation key with DISPLAY_LIGHT @@ -1364,6 +1397,47 @@ LIGHT_STATEFUL = Capability( ), ) +# Wind-Free / Wind-Sleep mode toggles (PR #316, ACA-KR-TP2-21-AN9000). Each +# on its own dedicated href, so unlike ventilation_mode above these need no +# device gating -- absent on every other family's dump. Write contract +# extrapolated from this file's other plain On/Off options-array fields +# (AIR_PURIFY, AUTO_CLEAN); not confirmed live. +WINDFREE = Capability( + href="/modeoption/windfree/vs/0", + poll_tier="warm", + entities=( + SwitchDesc( + key="windfree", + field="x.com.samsung.da.windfree", + icon="mdi:leaf", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["modeoption", "windfree", "vs", "0"], + {"x.com.samsung.da.windfree": "On" if p == "On" else "Off"}, + ), + ), + ), +) + +WINDSLEEP = Capability( + href="/modeoption/windsleep/vs/0", + poll_tier="warm", + entities=( + SwitchDesc( + key="windsleep", + field="x.com.samsung.da.windsleep", + icon="mdi:sleep", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["modeoption", "windsleep", "vs", "0"], + {"x.com.samsung.da.windsleep": "On" if p == "On" else "Off"}, + ), + ), + ), +) + # /sensors/vs/0 items[] carry live air-quality readings. CleanLevel is # corroborated as numeric by a top-level x.com.samsung.da.cleanLevel scalar, # so it's a measurement; the others stay string diagnostics (see @@ -1400,6 +1474,27 @@ AIR_QUALITY = Capability( ("super_fine_dust", "mdi:weather-fog", "SuperFineDust"), ) ), + # CO2 (PR #316, ACA-KR-TP2-21-AN9000) -- a type this file's other AC + # families don't report. Same field/shape air_monitor.SENSORS + # already models with device_class='carbon_dioxide'/unit='ppm', so + # this matches that descriptor rather than guessing fresh -- unlike + # the pm10/pm25/pm1 mapping air_monitor.py's own docstring + # deliberately rejects for the three dust-type keys above (Samsung's + # two-tier PM10/PM2.5 convention doesn't confirm where a third tier + # or PM1 fits), ppm for a field literally named CO2 isn't a guess of + # that kind. + SensorDesc( + key="co2", + field="x.com.samsung.da.items", + icon="mdi:molecule-co2", + entity_category="diagnostic", + device_class="carbon_dioxide", + state_class="measurement", + unit="ppm", + exists_fn=_has_sensor_type("CO2"), + enabled_default=False, + value_fn=lambda items: _int(_sensor_item_value(items, "CO2")), + ), ), ) @@ -1419,7 +1514,12 @@ _AC_IGNORED = [ # state or documented write contract. /option/muteonce/vs/0 and # /selfcheck/vs/0 are deliberately NOT here -- see MUTE_ONCE above and # common.SELF_CHECK, both of which have a confirmed, modelable contract. - "/airlevelcheck/vs/0", # periodic air-quality sensing scheduler plumbing + # /airlevelcheck/vs/0 is deliberately NOT here either (PR #316): + # despite this list's old description of it as "scheduler plumbing", + # both the CAC and TP1X_DA_AC_RAC_01011 fixtures already carry real, + # populated periodicSensingActivationState/autoExeState values here -- + # the AI-Purify feature air_purifier.AIR_LEVEL_CHECK already models, + # reused below rather than reinvented. "/aisleep/vs/0", # AI-sleep feedback state (no actionable control) "/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap "/da/softreset/vs/0", # soft-reset trigger plumbing diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index b8779b4..3aa10d4 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -692,6 +692,14 @@ "high": "Vysoký", "low": "Nízký" } + }, + "ventilation_mode": { + "name": "Režim", + "state": { + "purification": "Čištění", + "ventilation": "Větrání", + "smartventilation": "Chytré větrání" + } } }, "sensor": { @@ -1246,6 +1254,12 @@ }, "indicator_light": { "name": "Kontrolka" + }, + "windfree": { + "name": "Režim Wind-Free" + }, + "windsleep": { + "name": "Noční režim" } }, "time": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index ced1466..a935f0f 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -692,6 +692,14 @@ "high": "High", "low": "Low" } + }, + "ventilation_mode": { + "name": "Mode", + "state": { + "purification": "Purification", + "ventilation": "Ventilation", + "smartventilation": "Smart Ventilation" + } } }, "sensor": { @@ -1246,6 +1254,12 @@ }, "indicator_light": { "name": "Indicator light" + }, + "windfree": { + "name": "Wind-Free mode" + }, + "windsleep": { + "name": "Sleep mode" } }, "time": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 6b6d579..86afd5f 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -817,6 +817,14 @@ "high": "Alto", "low": "Bajo" } + }, + "ventilation_mode": { + "name": "Modo", + "state": { + "purification": "Purificación", + "ventilation": "Ventilación", + "smartventilation": "Ventilación inteligente" + } } }, "sensor": { @@ -1371,6 +1379,12 @@ }, "indicator_light": { "name": "Luz indicadora" + }, + "windfree": { + "name": "Modo Wind-Free" + }, + "windsleep": { + "name": "Modo nocturno" } }, "time": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index d8bb417..28289a1 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -692,6 +692,14 @@ "high": "Alta", "low": "Bassa" } + }, + "ventilation_mode": { + "name": "Modalità", + "state": { + "purification": "Purificazione", + "ventilation": "Ventilazione", + "smartventilation": "Ventilazione intelligente" + } } }, "sensor": { @@ -1246,6 +1254,12 @@ }, "indicator_light": { "name": "Spia luminosa" + }, + "windfree": { + "name": "Modalità Wind-Free" + }, + "windsleep": { + "name": "Modalità notte" } }, "time": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 15456a7..2fdc93f 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -692,6 +692,14 @@ "high": "높음", "low": "낮음" } + }, + "ventilation_mode": { + "name": "모드", + "state": { + "purification": "청정", + "ventilation": "환기", + "smartventilation": "스마트환기" + } } }, "sensor": { @@ -1246,6 +1254,12 @@ }, "indicator_light": { "name": "표시등" + }, + "windfree": { + "name": "무풍 모드" + }, + "windsleep": { + "name": "취침 모드" } }, "time": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c7703d9..ed23a30 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -692,6 +692,14 @@ "high": "Hoog", "low": "Laag" } + }, + "ventilation_mode": { + "name": "Modus", + "state": { + "purification": "Zuivering", + "ventilation": "Ventilatie", + "smartventilation": "Slimme ventilatie" + } } }, "sensor": { @@ -1246,6 +1254,12 @@ }, "indicator_light": { "name": "Indicatorlampje" + }, + "windfree": { + "name": "Wind-Free modus" + }, + "windsleep": { + "name": "Slaapmodus" } }, "time": { diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json index 754794b..a8819ff 100644 --- a/tests/fixtures/golden/airconditioner_cac.json +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -10,6 +10,7 @@ "air_filter_usage", "air_filter_usage_hours", "air_purify", + "air_sensing_state", "alarm_code", "auto_clean", "auto_clean_progress", @@ -28,15 +29,23 @@ "humidity", "indicator_light", "indicator_light_mode", + "last_air_sensing_level", + "last_air_sensing_time", "motion_detect_wind_active", "motion_detect_wind_mode", "mute_once", "odor_controller_active", "odor_controller_progress", + "periodic_air_sensing", + "periodic_sensing_skip_status", "power_watts", "selfcheck_error", "selfcheck_result", "selfcheck_status", + "sensing_interval", + "sensing_mode", + "sensing_skip_end", + "sensing_skip_start", "sound_mode", "sound_output", "sound_volume", 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 9146f3a..b0d26cd 100644 --- a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -5,6 +5,7 @@ "air_filter_usage", "air_filter_usage_hours", "air_purify", + "air_sensing_state", "alarm_code", "auto_clean", "auto_clean_progress", @@ -19,10 +20,18 @@ "fine_dust", "firmware_update", "humidity", + "last_air_sensing_level", + "last_air_sensing_time", "mute_once", + "periodic_air_sensing", + "periodic_sensing_skip_status", "selfcheck_error", "selfcheck_result", "selfcheck_status", + "sensing_interval", + "sensing_mode", + "sensing_skip_end", + "sensing_skip_start", "super_fine_dust", "tropical_night_mode" ] diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py index ec0346f..966c8cd 100644 --- a/tests/test_airconditioner_cac.py +++ b/tests/test_airconditioner_cac.py @@ -33,6 +33,13 @@ until issue #270 (TP1X_FAC_TIME_23K) added real capabilities for both -- this board's own live filterUsage/filterStatus data on the PM1 filter binds through the same exists_fn-gated entities #270's dump (which has neither field) leaves empty. + +/airlevelcheck/vs/0 was never on this unbound list (this fixture's rep +already carried a full set of periodicSensing*/autoExeState fields), but +until PR #316 it was globally ignored by airconditioner.py's own +_AC_IGNORED as "scheduler plumbing" -- this fixture's own populated values +were the proof that description was wrong. air_purifier.AIR_LEVEL_CHECK +now covers it (see test_airlevelcheck_binds_real_ai_purify_state below). """ from custom_components.localthings.registry.adapter import flatten @@ -85,6 +92,21 @@ def test_mds_absenceclean_shares_csi_absenceclean_key(): assert state["absence_clean"] is False +def test_airlevelcheck_binds_real_ai_purify_state(): + """This fixture's /airlevelcheck/vs/0 has real, populated values -- + periodic_air_sensing on, sensing_mode 'Alarm' -- confirming + air_purifier.AIR_LEVEL_CHECK binds real AI-Purify state here rather + than the inert plumbing _AC_IGNORED used to describe.""" + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state["periodic_air_sensing"] is True + assert state["sensing_mode"] == "Alarm" + assert state["sensing_interval"] == 30 # 1800s + assert state["air_sensing_state"] == "NonProcessing" + + def test_non_legacy_board_uses_the_generic_energy_scale(): """This board reports /wind/strength/vs/0 (not /airflow/vs/0), so is_legacy_board() is False and it must use the plain wh_to_kwh scale, diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index fe3c08f..f4a719e 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -221,6 +221,19 @@ def test_tp1x_climate_entity_is_bound(): assert len(climate) == 1 and climate[0].href == "/mode/vs/0" +def test_tp1x_airlevelcheck_binds_real_ai_purify_state(): + """This fixture's /airlevelcheck/vs/0 has real, populated + periodicSensing*/autoExeState values too (PR #316's finding that + _AC_IGNORED's old "scheduler plumbing" description was wrong wasn't + specific to one board) -- air_purifier.AIR_LEVEL_CHECK now covers it.""" + reg, resources = _ac_tp1x() + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state["periodic_air_sensing"] is True + assert state["sensing_mode"] == "Alarm" + assert state["sensing_interval"] == 30 # 1800s + assert state["air_sensing_state"] == "NonProcessing" + + def test_tp2x_rac_20k_model_resolves_via_model_fallback(): """TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token -- resolved via the '_RAC_' modelNum fallback added for this device.""" diff --git a/tests/test_airconditioner_ventilation_windfree.py b/tests/test_airconditioner_ventilation_windfree.py new file mode 100644 index 0000000..c2b78e2 --- /dev/null +++ b/tests/test_airconditioner_ventilation_windfree.py @@ -0,0 +1,113 @@ +"""Tests for the ventilation-mode/Wind-Free/Wind-Sleep additions extracted +from PR #316 (Samsung "System Fresh Air Ventilator", model +ACA-KR-TP2-21-AN9000, vid DA-AC-DIFFUSER-01001). + +No raw diagnostics dump for this model was available -- PR #316 never +attached one, only Korean code comments describing field shapes the +contributor said they observed. Per this project's fixture-integrity rule +(a fixture must record what hardware actually did, not a third party's +prose about it), there's no `airconditioner_*_device.json` fixture for +this model here. These tests instead exercise the gating logic directly +against hand-built reps matching those quoted shapes, clearly distinct +from this suite's fixture-backed tests, and check the new gate doesn't +false-positive against every real AC fixture already in the corpus. + +If a real diagnostics dump for this model ever surfaces (tracked as a +follow-up device-support issue), replace this file with a proper +fixture + golden + capability test per the usual workflow, and drop the +disclaimer above. +""" + +import glob +import json +import os + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.capabilities.airconditioner import ( + WINDFREE, + WINDSLEEP, + _is_ventilation_mode_device, +) +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + +_FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures") + + +def _all_airconditioner_fixture_names(): + names = [] + for path in glob.glob(os.path.join(_FIXTURES_DIR, "airconditioner*_device.json")): + name = os.path.basename(path)[: -len("_device.json")] + with open(path) as f: + info = json.load(f) + rep = next( + (e["rep"] for e in info.get("device0", []) if e.get("href") == "/information/vs/0"), + None, + ) + if rep is not None: # for_device_by_model needs /information/vs/0 + names.append(name) + return names + + +def test_ventilation_mode_gate_never_false_positives_on_real_ac_fixtures(): + """None of the real air-conditioner fixtures in this corpus use the + Purification/Ventilation/SmartVentilation vocabulary -- confirms + _is_ventilation_mode_device can't turn a real AC's climate card into + this select.""" + for name in _all_airconditioner_fixture_names(): + resources = _load_device(name) + reg = for_device_by_model( + resources["/information/vs/0"]["x.com.samsung.da.modelNum"], + resources["/information/vs/0"]["x.com.samsung.da.description"], + ) + if reg is None or reg.name != "airconditioner": + continue + mode_rep = resources.get("/mode/vs/0") + if not mode_rep: + continue + assert _is_ventilation_mode_device(mode_rep, resources) is False, name + + +def test_ventilation_mode_gate_matches_diffuser_shape(): + """PR #316: supportedModes exactly {Purification, Ventilation, + SmartVentilation} -- the vocabulary that makes climate.py's hvac_mode + collapse to one stuck value with no way to tell the three apart.""" + rep = { + "x.com.samsung.da.modes": ["Purification"], + "x.com.samsung.da.supportedModes": ["Purification", "Ventilation", "SmartVentilation"], + } + assert _is_ventilation_mode_device(rep, {}) is True + + +def test_ventilation_mode_gate_rejects_partial_overlap(): + """A real AC reporting an unrelated mode alongside one of these three + words (coincidence, not this device) must not gate in -- the check is + 'subset of', not 'intersects'.""" + rep = {"x.com.samsung.da.supportedModes": ["Ventilation", "Cool", "Heat"]} + assert _is_ventilation_mode_device(rep, {}) is False + + +def _bind(capability, href, rep): + resources = {href: rep} + bound = discover(resources, {href: [capability]}, []) + return flatten(bound, resources) + + +def test_windfree_and_windsleep_read_their_own_hrefs(): + """PR #316's quoted rep shape: {'x.com.samsung.da.windfree': 'On'/'Off', + 'x.com.samsung.da.displaycondition': 'normal'} (displaycondition is a + read-only UI-visibility flag, deliberately not modeled).""" + windfree_state = _bind( + WINDFREE, + "/modeoption/windfree/vs/0", + {"x.com.samsung.da.windfree": "On", "x.com.samsung.da.displaycondition": "normal"}, + ) + assert windfree_state["windfree"] is True + + windsleep_state = _bind( + WINDSLEEP, + "/modeoption/windsleep/vs/0", + {"x.com.samsung.da.windsleep": "Off", "x.com.samsung.da.displaycondition": "normal"}, + ) + assert windsleep_state["windsleep"] is False