From edf77309ba32d3efb9794f6cc6e76f85a0699487 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 7 Aug 2026 14:10:13 +0000 Subject: [PATCH 1/9] Add device support for AILP_DA-AC-FAC-02011 air conditioner (issue #319) This board routes purely via /oic/d's oic.d.airconditioner type (no board-token match) and reports several resources the sibling TP1X_DA-AC-CAC-01001 board (issue #191) left as a documented gap: - /display/vs/0, /settings/sound/output/vs/0, /settings/sound/volume/vs/0 now reuse air_purifier.py's identical-shape capabilities instead of duplicating them. - /settings/sound/mode/vs/0 gets a new airconditioner.SOUND_MODE reading the live supportedModes field, sharing laundry.py's existing voice/tone/mute translation catalog since the value vocabulary matches. - /csi/absenceclean/vs/0 and /csi/energysaving/vs/0 are new, genuinely useful controls (absence auto-clean toggle, energy-saving mode select plus its state/operatingStatus diagnostics). - /dnd/autosleep/vs/0, /outdoorsharing/vs/0, /lifestyle/survey/vs/0, /settings/sound/voice/vs/0 and /csi/information/vs/0 are ignored as plumbing/unconfirmed data with no user-actionable state. Also fixes a latent bug in air_purifier.SOUND_VOLUME: boards that report minLevel/resolution but no maxLevel (this one) would have produced a min=0/max=0 number entity instead of self-gating off. Updates test_airconditioner_cac.py's documented coverage gap now that sound_mode/sound_output/sound_volume are covered there too. --- .../registry/by_type/airconditioner.py | 11 +- .../registry/capabilities/air_purifier.py | 4 + .../registry/capabilities/airconditioner.py | 99 ++ .../localthings/translations/cs.json | 12 + .../localthings/translations/en.json | 12 + .../localthings/translations/es.json | 12 + .../localthings/translations/it.json | 12 + .../localthings/translations/ko.json | 12 + .../localthings/translations/nl.json | 12 + .../airconditioner_ailp_fac_device.json | 1056 +++++++++++++++++ .../golden/airconditioner_ailp_fac.json | 39 + tests/fixtures/golden/airconditioner_cac.json | 3 + ...st_airconditioner_ailp_fac_capabilities.py | 70 ++ tests/test_airconditioner_cac.py | 21 +- tests/test_golden_regression.py | 30 +- 15 files changed, 1389 insertions(+), 16 deletions(-) create mode 100644 tests/fixtures/airconditioner_ailp_fac_device.json create mode 100644 tests/fixtures/golden/airconditioner_ailp_fac.json create mode 100644 tests/test_airconditioner_ailp_fac_capabilities.py diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index e76e193..9792766 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -17,7 +17,7 @@ uses unconditionally. Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0. """ -from ..capabilities import airconditioner, common, dishwasher, ignored +from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( @@ -46,6 +46,15 @@ REGISTRY = DeviceRegistry( airconditioner.CURRENT_TEMPERATURE, airconditioner.CURRENT_TEMPERATURE_VS, airconditioner.HUMIDITY, + # TP1X_DA-AC-FAC-class (issue #319): shares its /display/vs/0, + # /settings/sound/output/vs/0 and /settings/sound/volume/vs/0 + # shape with the sibling TP1X_DA-AC-AIR board in air_purifier.py. + air_purifier.DISPLAY, + air_purifier.SOUND_OUTPUT, + air_purifier.SOUND_VOLUME, + airconditioner.SOUND_MODE, + airconditioner.ABSENCE_CLEAN, + airconditioner.ENERGY_SAVING, *airconditioner.COVERAGE, ] ), diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index ff2e4da..6949d2b 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -441,6 +441,10 @@ SOUND_VOLUME = Capability( field="level", icon="mdi:volume-medium", entity_category="config", + # Some boards (issue #319's AC) report minLevel/resolution but no + # maxLevel -- native_max_fn would silently collapse to 0, giving a + # slider with no real range instead of no entity at all. + exists_fn=lambda rep, resources: "maxLevel" in rep, native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0, native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0, step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 8faaa94..5ce5adc 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -1167,6 +1167,91 @@ HUMIDITY = Capability( ), ) +# TP1X_DA-AC-FAC-class additions (issue #319): resources the CAC-class board +# (issue #191) also reports but left as a documented gap -- this dump gives +# the live supportedModes/mode shapes those needed. + +# Same {mode, supportedModes: [On, Off]} shape as air_purifier.DISPLAY on the +# sibling TP1X_DA-AC-AIR board; shares that capability rather than +# duplicating it. +SOUND_MODE = Capability( + href="/settings/sound/mode/vs/0", + poll_tier="cold", + entities=( + # Values seen (mute/tone/voice) are exactly laundry.SOUND_MODE's + # vocabulary, so this shares that catalog entry -- but reads the + # live supportedModes field rather than laundry's static tuple, + # since this resource carries one (issue #319). + SelectDesc( + key="sound_mode", + field="mode", + icon="mdi:volume-high", + entity_category="config", + options_field="supportedModes", + write_fn=lambda p, rep, href=None: ( + ["settings", "sound", "mode", "vs", "0"], + {"mode": p}, + ), + ), + ), +) + +# Absence-detection auto air clean (issue #319) -- a plain On/Off toggle, +# sibling feature to ABSENCE_POWER_SAVING above but on its own href. +ABSENCE_CLEAN = Capability( + href="/csi/absenceclean/vs/0", + poll_tier="cold", + entities=( + SwitchDesc( + key="absence_clean", + field="mode", + icon="mdi:broom", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["csi", "absenceclean", "vs", "0"], + {"mode": "On" if p == "On" else "Off"}, + ), + ), + ), +) + +# Energy-saving schedule (issue #319). `mode` is a device-chosen preset +# (e.g. Cooling_60/Off_180) with no confirmed unit for the trailing number +# (minutes seen elsewhere on this board are unprefixed ints, not +# underscore-suffixed) -- exposed as a select over the live options rather +# than translating labels we can't confirm. `state`/`operatingStatus` stay +# bare diagnostic passthroughs for the same reason. +ENERGY_SAVING = Capability( + href="/csi/energysaving/vs/0", + poll_tier="cold", + entities=( + SelectDesc( + key="energy_saving_mode", + field="mode", + icon="mdi:leaf", + entity_category="config", + options_field="supportedModes", + write_fn=lambda p, rep, href=None: ( + ["csi", "energysaving", "vs", "0"], + {"mode": p}, + ), + ), + SensorDesc( + key="energy_saving_state", + field="state", + icon="mdi:leaf", + entity_category="diagnostic", + ), + SensorDesc( + key="energy_saving_operating_status", + field="operatingStatus", + icon="mdi:leaf", + entity_category="diagnostic", + ), + ), +) + # /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 @@ -1250,6 +1335,20 @@ _AC_IGNORED = [ # registry.subdevices.enumerate_subdevices, hence the entry here rather # than a coverage gap. "/multidevice/vs/0", + # TP1X_DA-AC-FAC-class-only (issue #319), same reasoning as + # air_purifier.COVERAGE's duplicate of these hrefs -- scoped here rather + # than promoted to the global ignore list since it'd collide with + # families that do bind some of these. + "/dnd/autosleep/vs/0", # every field its inert default; needs a schedule editor + "/outdoorsharing/vs/0", # empty on this dump -- outdoor-unit sharing plumbing + "/lifestyle/survey/vs/0", # {list: [""]} placeholder, nothing to expose + # supportedVoices list only -- no live selection field to read a current + # value from, unlike SOUND_MODE/SOUND_OUTPUT above. + "/settings/sound/voice/vs/0", + # rssi/wifiFrequency (network housekeeping) plus an unconfirmed + # 48-slot absenceInfo P/A history blob with no documented meaning -- + # don't guess what it encodes. + "/csi/information/vs/0", ] # Built as bare no-entity caps; folded into the AC registry (not global). diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index d63d69f..b98538e 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -538,6 +538,9 @@ "mute": "Ztlumeno" } }, + "energy_saving_mode": { + "name": "Režim úspory energie" + }, "air_purifier_sound_mode": { "name": "Zvukový režim", "state": { @@ -787,6 +790,12 @@ "indirect": "Nepřímý" } }, + "energy_saving_state": { + "name": "Stav úspory energie" + }, + "energy_saving_operating_status": { + "name": "Provozní stav úspory energie" + }, "current_temp_c": { "name": "Teplota" }, @@ -1040,6 +1049,9 @@ "absence_power_saving_active": { "name": "Úspora energie při nepřítomnosti aktivní" }, + "absence_clean": { + "name": "Čištění při nepřítomnosti" + }, "motion_detect_wind_active": { "name": "Vyhýbání se proudu vzduchu při pohybu aktivní" }, diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 82f1082..0efa41c 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -538,6 +538,9 @@ "mute": "Mute" } }, + "energy_saving_mode": { + "name": "Energy saving mode" + }, "air_purifier_sound_mode": { "name": "Sound mode", "state": { @@ -787,6 +790,12 @@ "indirect": "Indirect" } }, + "energy_saving_state": { + "name": "Energy saving state" + }, + "energy_saving_operating_status": { + "name": "Energy saving operating status" + }, "current_temp_c": { "name": "Temperature" }, @@ -1040,6 +1049,9 @@ "absence_power_saving_active": { "name": "Absence power saving active" }, + "absence_clean": { + "name": "Absence clean" + }, "motion_detect_wind_active": { "name": "Motion-detect wind avoidance active" }, diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 76146d1..470ef17 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -663,6 +663,9 @@ "mute": "Silencio" } }, + "energy_saving_mode": { + "name": "Modo de ahorro de energía" + }, "air_purifier_sound_mode": { "name": "Modo de sonido", "state": { @@ -909,6 +912,12 @@ "indirect": "Indirecto" } }, + "energy_saving_state": { + "name": "Estado de ahorro de energía" + }, + "energy_saving_operating_status": { + "name": "Estado de funcionamiento del ahorro de energía" + }, "current_temp_c": { "name": "Temperatura" }, @@ -1165,6 +1174,9 @@ "absence_power_saving_active": { "name": "Ahorro por ausencia activo" }, + "absence_clean": { + "name": "Limpieza por ausencia" + }, "motion_detect_wind_active": { "name": "Evitación de viento por detección de movimiento activa" }, diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 55ec69f..17412a0 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -538,6 +538,9 @@ "mute": "Muto" } }, + "energy_saving_mode": { + "name": "Modalità risparmio energetico" + }, "air_purifier_sound_mode": { "name": "Modalità audio", "state": { @@ -787,6 +790,12 @@ "indirect": "Indiretto" } }, + "energy_saving_state": { + "name": "Stato risparmio energetico" + }, + "energy_saving_operating_status": { + "name": "Stato di funzionamento del risparmio energetico" + }, "current_temp_c": { "name": "Temperatura" }, @@ -1040,6 +1049,9 @@ "absence_power_saving_active": { "name": "Risparmio energetico in assenza attivo" }, + "absence_clean": { + "name": "Pulizia in assenza" + }, "motion_detect_wind_active": { "name": "Elusione flusso d'aria a rilevamento movimento attiva" }, diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index bcdf078..62a0886 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -538,6 +538,9 @@ "mute": "음소거" } }, + "energy_saving_mode": { + "name": "절전 모드" + }, "air_purifier_sound_mode": { "name": "소리 모드", "state": { @@ -787,6 +790,12 @@ "indirect": "간접풍" } }, + "energy_saving_state": { + "name": "절전 상태" + }, + "energy_saving_operating_status": { + "name": "절전 작동 상태" + }, "current_temp_c": { "name": "온도" }, @@ -1040,6 +1049,9 @@ "absence_power_saving_active": { "name": "부재 절전 작동" }, + "absence_clean": { + "name": "부재 청소" + }, "motion_detect_wind_active": { "name": "모션 감지 바람 회피 작동" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 07a6b05..b9d03a5 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -538,6 +538,9 @@ "mute": "Stil" } }, + "energy_saving_mode": { + "name": "Energiebesparingsmodus" + }, "air_purifier_sound_mode": { "name": "Geluidsmodus", "state": { @@ -787,6 +790,12 @@ "indirect": "Indirect" } }, + "energy_saving_state": { + "name": "Energiebesparing status" + }, + "energy_saving_operating_status": { + "name": "Bedrijfsstatus energiebesparing" + }, "current_temp_c": { "name": "Temperatuur" }, @@ -1040,6 +1049,9 @@ "absence_power_saving_active": { "name": "Energiebesparing bij afwezigheid actief" }, + "absence_clean": { + "name": "Reiniging bij afwezigheid" + }, "motion_detect_wind_active": { "name": "Bewegingsdetectie luchtstroomvermijding actief" }, diff --git a/tests/fixtures/airconditioner_ailp_fac_device.json b/tests/fixtures/airconditioner_ailp_fac_device.json new file mode 100644 index 0000000..e44b141 --- /dev/null +++ b/tests/fixtures/airconditioner_ailp_fac_device.json @@ -0,0 +1,1056 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/aisleep/vs/0", + "rep": { + "x.com.samsung.da.displayNightMode": "Off", + "x.com.samsung.da.elapsedTime": "0", + "x.com.samsung.da.requestFeedback": "Off", + "x.com.samsung.da.resultFeedback": 0, + "x.com.samsung.da.statusFeedback": "Idle", + "x.com.samsung.da.sleepTime": "14002200" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-08-07T00:32:32", + "x.com.samsung.da.state": "Deleted" + } + ], + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1D3F00B4012C010046E8040E0000", + "x.com.samsung.da.id": "FAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/bixby/status/vs/0", + "rep": { + "x.com.samsung.da.enable": "true" + } + }, + { + "href": "/bixby/vs/0", + "rep": { + "x.com.samsung.da.voiceWakeup": "Disable", + "x.com.samsung.da.multidevWakeup": "true" + } + }, + { + "href": "/bixbyterms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "AudioReview", + "x.com.samsung.da.termsStatus": "Unchecked", + "x.com.samsung.da.termsType": "Optional", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.1/1773704680415/kor/kor_kor_AudioRecordingReview.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "Datasharing", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.1/1690269284216/kor/kor_kor_DataSharingAgreement_v3.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "Personalization", + "x.com.samsung.da.termsStatus": "Unchecked", + "x.com.samsung.da.termsType": "Optional", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.2/1773644167282/kor/kor_kor_PersonalizationAgreement.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "PrivacyNotice", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.0.2/1726806774110/kor/kor_kor_pn.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "PrivacyPolicy", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://d264isyiyrfhr3.cloudfront.net/storage/tos/kor/1.7.8/1783668586319/kor/kor_kor_pp.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "SamsungLocation", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://static.bada.com/contents/legal/kor/kor/locationinfortnc.html" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "FMMPrivacyPolicy", + "x.com.samsung.da.termsStatus": "Agreed", + "x.com.samsung.da.termsType": "Mandatory", + "x.com.samsung.da.termsURL": "https://account.samsung.com/membership/policy/privacy/service/o12ht525e6" + }, + { + "x.com.samsung.da.accountInfo": "**REDACTED**", + "x.com.samsung.da.termsName": "BixbyPrivacy", + "x.com.samsung.da.termsStatus": "Unchecked", + "x.com.samsung.da.termsType": "Optional", + "x.com.samsung.da.termsURL": "" + } + ], + "x.com.samsung.da.lastCheckedTime": "1786062735" + } + }, + { + "href": "/bixbyuservalidate/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.loginId": "**REDACTED**", + "x.com.samsung.da.isExpired": "false" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "4157000000", + "x.com.samsung.da.airconOptionList": [ + "AI_KOREA_COOLONLY_4.0", + "AI_2.0", + "HOMECARE_WIZARD_V2", + "ENERGY_2.0", + "DeviceTypeMaster", + "DR", + "SingleCommand_1", + "OutdoorComfortVentilationAlarm", + "Remote_Not_included_4" + ], + "InstalledRoomSize": "0.0", + "SkipInstalledRoomSize": "false" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G", + "5G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/csi/absenceclean/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/csi/energysaving/vs/0", + "rep": { + "mode": "Off_100", + "supportedModes": [ + "Cooling_60", + "Cooling_100", + "Cooling_180", + "Off_100", + "Off_60", + "Off_180" + ], + "state": "Off", + "operatingStatus": "None" + } + }, + { + "href": "/csi/information/vs/0", + "rep": { + "rssi": "-36", + "wifiFrequency": "5", + "lastEnergySavingTime": "1900-01-00T00:00:00", + "cleaningStartTime": "1900-01-00T00:00:00", + "absenceInfo": [ + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "A", + "P", + "A", + "P", + "A", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "A", + "P", + "P", + "P", + "P", + "P", + "P", + "P", + "A", + "P", + "A", + "A", + "A", + "A", + "A", + "P", + "P", + "P", + "P", + "P" + ], + "supportedWifiFrequencies": [ + "2.4", + "5" + ] + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/display/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/dnd/autosleep/vs/0", + "rep": { + "x.com.samsung.da.visible": "false", + "x.com.samsung.da.startTime": "00:00:00", + "x.com.samsung.da.endTime": "00:00:00", + "x.com.samsung.da.useTimeSetting": "false", + "x.com.samsung.da.functionState": "false" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "2026-08-07T00:00:41Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "1441", + "x.com.samsung.da.drlcLevel": "1", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": [ + "1", + "2" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1786062735", + "x.com.samsung.da.cumulativeConsumption": "2200", + "x.com.samsung.da.instantaneousPower": "118", + "x.com.samsung.da.cumulativeSavedPower": "3082", + "x.com.samsung.da.cumulativePower": "683759", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativeDateUTC": "1786060800" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "224", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterDesiredUsage": "224", + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "112", + "224", + "336", + "448" + ] + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "49", + "rt": [ + "x.com.samsung.da.humidity" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "AILP_DA-AC-FAC-02011_0000|10275841|6000031100191301DE00CC220193FB00", + "x.com.samsung.da.description": "AILP_DA-AC-FAC-02011_0000", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.serialNumOption": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02761A250903", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02758A25011500,02753A24123000" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02755A24123100,02780A10000200" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "LED", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02759A24123100,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Touch IC", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02531A23012000,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "5", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Fan", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02781A10000100,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "6", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Bixby", + "x.com.samsung.da.number": "1.2.0-rc.20.hotfix-ac.9" + } + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "AF6", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 5 + } + }, + { + "href": "/lifestyle/survey/vs/0", + "rep": { + "list": [ + "" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Nano", + "NanoSleep", + "Quiet", + "Speed" + ], + "rt": [ + "x.com.samsung.da.mode.convenient" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": [ + "Cool" + ], + "workingMode": "Cool", + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.options": [ + "OptionCode_665", + "Operation_Family", + "Blooming_3", + "OnTimer_0", + "OffTimer_0", + "Sleep_0", + "SleepRemainTime_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_230", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None", + "welcomecare_Off", + "Panel_Close", + "Weather_Off", + "Volume_Mute", + "StopAutoClean_Idle", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_1", + "ResultDiagnosisAI_Normal", + "Service_Off", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "OutDoorVentil_Off", + "FreezeAlarmSetting_Off", + "DesiredFreezeAlarm_240", + "WashAlarm_Off", + "ExtendOptionCode_17103", + "RacInfo_WiFi", + "RacInfo_None_Second", + "ModelInfo_16K_BORA_VENT2", + "UpdateAllow_NotAllowed", + "EnergySaveIcon_Off", + "DurationOn_68", + "OutdoorTemp_92", + "aiver_ai-ac-24k-space" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ], + "rt": [ + "x.com.samsung.da.option.airpurify" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.supportedStatus": [ + "Start", + "SpeedClean", + "QuietClean", + "Stop", + "TimedClean" + ], + "x.com.samsung.da.progress": "0", + "minCleaningTime": "20", + "maxCleaningTime": "120", + "cleaningTimeUnit": "10", + "cleaningTime": "120", + "x.com.samsung.da.settingStatus": "QuietClean", + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "SpeedClean", + "QuietClean", + "Off", + "TimedClean" + ], + "defaultSettingStatus": "QuietClean" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off", + "rt": [ + "x.com.samsung.da.option.muteonce" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Air conditioner", + "osVersion": "5.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "AFA-WW-AILP-25-AFXX00", + "versions": [ + "10250903" + ], + "visVersion": "250903" + }, + { + "type": "Micom", + "modelId": "845110275541FFFFFFFF", + "versions": [ + "24123100", + "FFFFFFFF" + ], + "visVersion": "241231" + }, + { + "type": "Micom", + "modelId": "845110275341FFFFFFFF", + "versions": [ + "24123000", + "FFFFFFFF" + ], + "visVersion": "241230" + }, + { + "type": "Micom", + "modelId": "845110275841FFFFFFFF", + "versions": [ + "25011500", + "FFFFFFFF" + ], + "visVersion": "250115" + }, + { + "type": "Micom", + "modelId": "04511027584110275341", + "versions": [ + "25011500", + "24123000" + ], + "visVersion": "250115" + }, + { + "type": "Micom", + "modelId": "04511027554110278041", + "versions": [ + "24123100", + "10000200" + ], + "visVersion": "241231" + }, + { + "type": "Micom", + "modelId": "FGCAN3", + "versions": [ + "", + "" + ], + "visVersion": "999999" + }, + { + "type": "Micom", + "modelId": "045110275941FFFFFFFF", + "versions": [ + "24123100", + "FFFFFFFF" + ], + "visVersion": "241231" + }, + { + "type": "Micom", + "modelId": "045110253141FFFFFFFF", + "versions": [ + "23012000", + "FFFFFFFF" + ], + "visVersion": "230120" + }, + { + "type": "Micom", + "modelId": "045110278141FFFFFFFF", + "versions": [ + "10000100", + "FFFFFFFF" + ], + "visVersion": "100001" + } + ] + } + }, + { + "href": "/outdoorsharing/vs/0", + "rep": {} + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "operationNumber": "239", + "displayCondition": "Enable", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1DFFFFFFFF121E121EFFFFFFFFFFFF010000001D0001001D00000000001C00FFFF0000", + "x.com.samsung.da.id": "FAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0 + } + }, + { + "href": "/sec/networkaudio/audio/vs/0", + "rep": { + "volume": "7", + "mute": "false", + "maxVolume": "15", + "timestamp": "0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.progress": "1", + "x.com.samsung.da.result": "NA", + "x.com.samsung.da.supportedActions": [ + "Start", + "Cancel" + ], + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ] + } + }, + { + "href": "/settings/sound/mode/vs/0", + "rep": { + "supportedModes": [ + "mute", + "tone", + "voice" + ] + } + }, + { + "href": "/settings/sound/output/vs/0", + "rep": { + "deviceType": "speaker" + } + }, + { + "href": "/settings/sound/voice/vs/0", + "rep": { + "supportedVoices": [ + "100", + "101" + ] + } + }, + { + "href": "/settings/sound/volume/vs/0", + "rep": { + "minLevel": "0", + "resolution": "1" + } + }, + { + "href": "/subdevices/vs/0", + "rep": {} + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1", + "rt": [ + "x.com.samsung.da.temperature.control" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/current/0", + "rep": { + "temperature": 25.0, + "units": "C", + "range": [ + 18.0, + 30.0 + ], + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "temperature": 24.0, + "units": "C", + "range": [ + 18.0, + 30.0 + ], + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.current": "25.0", + "x.com.samsung.da.desired": "24.0", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/uvled/vs/0", + "rep": { + "x.com.samsung.da.modes": "On", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/ventilation/setting/vs/0", + "rep": { + "alarm": "On" + } + }, + { + "href": "/voice/feature/vs/0", + "rep": { + "voice.feature": { + "ContinuousConversation": { + "support": "true", + "value": "false", + "supportedValues": [ + "true", + "false" + ] + }, + "DiscoverBixby": { + "support": "false" + }, + "LLMBixby": { + "support": "true" + }, + "SimpleCommand": { + "support": "false", + "value": "false", + "supportedValues": [ + "true", + "false" + ], + "description": [ + "\ub0c9\uc7a5\uace0 \ubb38 \uc5f4\uc5b4\uc918" + ] + } + } + } + }, + { + "href": "/voice/provisioning/vs/0", + "rep": { + "voice.provisioning.headers": { + "login_id": "**REDACTED**", + "voice.provisioning.headers.bixbyLanguage": "ko_KR", + "voice.provisioning.headers.bixbyType": "BixbyAgent", + "voice.provisioning.headers.bixbyVersion": "1.2.0-rc.20.hotfix-ac.9", + "voice.provisioning.headers.companioncountry": "KR", + "voice.provisioning.headers.csc": "920", + "voice.provisioning.headers.csciso": "KR", + "voice.provisioning.headers.deviceLanguage": "ko-KR", + "voice.provisioning.headers.deviceModelNameAsh": "AF70F17D24IN", + "voice.provisioning.headers.deviceModelNamePro": "BIXBY-LITE", + "voice.provisioning.headers.deviceSubType": "floor_ac_lite", + "voice.provisioning.headers.deviceTypeAsh": "appliance", + "voice.provisioning.headers.deviceTypePro": "appliance", + "voice.provisioning.headers.mcc": "450", + "voice.provisioning.headers.osType": "Tizen Lite", + "voice.provisioning.headers.osVersion": "Tizen Lite", + "voice.provisioning.headers.requestTrackId": "1786062735866", + "voice.provisioning.headers.sspServerUrl": "us-auth2.samsungosp.com", + "voice.provisioning.headers.storecountry": "KR", + "voice.provisioning.headers.userType": "COMMERCIAL" + }, + "voice.provisioning.termsstatechange": { + "voice.provisioning.termsstatechange.refresh": "", + "voice.provisioning.termsstatechange.state": "Unchecked", + "voice.provisioning.termsstatechange.terms": "" + }, + "voice.provisioning.deviceinfo": { + "voice.provisioning.deviceinfo.accesstoken": "**REDACTED**", + "voice.provisioning.deviceinfo.bixbyserver": "bxb3PrdUS", + "voice.provisioning.deviceinfo.bixbyserviceid": " ", + "voice.provisioning.deviceinfo.deviceid": "**REDACTED**", + "voice.provisioning.deviceinfo.gdprcountry": "false", + "voice.provisioning.deviceinfo.isSupportedCountry": "true", + "voice.provisioning.deviceinfo.userid": "**REDACTED**" + } + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Not Supported" + ], + "rt": [ + "x.com.samsung.da.wind.direction" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Mid", + "High", + "Turbo" + ], + "rt": [ + "x.com.samsung.da.wind.strength" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**" + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_ailp_fac.json b/tests/fixtures/golden/airconditioner_ailp_fac.json new file mode 100644 index 0000000..67d3199 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_ailp_fac.json @@ -0,0 +1,39 @@ +{ + "state_keys": [ + "absence_clean", + "ai_energy_level", + "air_filter_status", + "air_filter_threshold", + "air_filter_usage", + "air_filter_usage_hours", + "air_purify", + "alarm_code", + "auto_clean", + "auto_clean_progress", + "auto_clean_running", + "beep", + "climate", + "current_temperature_c", + "display", + "energy_kwh", + "energy_saved_kwh", + "energy_saving_mode", + "energy_saving_operating_status", + "energy_saving_state", + "firmware_update", + "humidity", + "mute_once", + "odor_controller_active", + "odor_controller_progress", + "power_energy_kwh", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "sound_mode", + "sound_output", + "tropical_night_mode", + "uv_led", + "ventilation_alarm" + ] +} diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json index 30ee055..585c344 100644 --- a/tests/fixtures/golden/airconditioner_cac.json +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -31,6 +31,9 @@ "selfcheck_error", "selfcheck_result", "selfcheck_status", + "sound_mode", + "sound_output", + "sound_volume", "super_fine_dust", "tropical_night_mode", "uv_led" diff --git a/tests/test_airconditioner_ailp_fac_capabilities.py b/tests/test_airconditioner_ailp_fac_capabilities.py new file mode 100644 index 0000000..b98869b --- /dev/null +++ b/tests/test_airconditioner_ailp_fac_capabilities.py @@ -0,0 +1,70 @@ +"""Tests for the AILP_DA-AC-FAC-02011_0000 air conditioner (issue #319). + +This board has no `_BOARD_TOKEN_TO_KEY` entry (routes via `/oic/d`'s +`oic.d.airconditioner` type only) and reports several resources the sibling +TP1X_DA-AC-CAC-01001 board (issue #191) left as a documented gap: +/display/vs/0 and the /settings/sound/* trio (now shared with +air_purifier.py's identical shapes), plus two genuinely new hrefs +(/csi/absenceclean/vs/0, /csi/energysaving/vs/0). +""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + +_DEVICE_TYPES = ("oic.wk.d", "oic.d.airconditioner") + + +def _resources(): + return _load_device("airconditioner_ailp_fac") + + +def _bound(): + resources = _resources() + reg = resolve(resources, device_types=_DEVICE_TYPES) + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def test_oic_device_type_resolves_to_airconditioner_registry(): + reg = for_device_by_oic_type(_DEVICE_TYPES) + assert reg is not None and reg.name == "airconditioner" + + +def test_no_unbound_hrefs(): + resources = _resources() + reg = resolve(resources, device_types=_DEVICE_TYPES) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_sound_mode_bound_with_live_supported_modes(): + """This dump has no current `mode` value yet, only supportedModes + (mute/tone/voice) -- confirms the select is bound (present in state, + even if unknown) and its options come from the live field rather than + a static tuple (see laundry.SOUND_MODE's docstring for why that's + preferred whenever the resource carries one).""" + bound, resources = _bound() + desc = next(b.desc for b in bound if b.desc.key == "sound_mode") + assert desc.options_field == "supportedModes" + state = flatten(bound, resources) + assert "sound_mode" in state + assert state["sound_mode"] is None + + +def test_absence_clean_and_energy_saving_bound(): + bound, resources = _bound() + state = flatten(bound, resources) + assert state["absence_clean"] is True + assert state["energy_saving_mode"] == "Off_100" + assert state["energy_saving_state"] == "Off" + + +def test_sound_volume_self_gates_off_without_max_level(): + """This board's /settings/sound/volume/vs/0 reports minLevel/resolution + but no maxLevel -- air_purifier.SOUND_VOLUME's exists_fn should keep it + unbound-but-covered rather than shipping a min=0/max=0 slider.""" + bound, resources = _bound() + state = flatten(bound, resources) + assert "sound_volume" not in state diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py index 99e1c27..8887ea1 100644 --- a/tests/test_airconditioner_cac.py +++ b/tests/test_airconditioner_cac.py @@ -6,12 +6,18 @@ token -- this board was the one exception (its oneUiVersion self-reports "7.0 Air conditioner", but 'CAC' had never been added to the board-token table), so it silently fell back to common caps and lost its climate entity. -This dump is NOT fully covered yet -- eight hrefs remain unbound (edge -lighting, a second stateful light resource, absence-clean, four -`/settings/sound/*` resources, smart-sensing-cooling), all genuinely new to -this board generation. That's a real device-support gap, left documented -here rather than guessed at, per the 'don't guess' rule -- fixing the -routing regression was the scope of #191. +This dump is NOT fully covered yet -- five hrefs remain unbound (edge +lighting, a second stateful light resource, absence-clean, +`/settings/sound/optimization/vs/0`, smart-sensing-cooling), all genuinely +new to this board generation. That's a real device-support gap, left +documented here rather than guessed at, per the 'don't guess' rule -- +fixing the routing regression was the scope of #191. + +/settings/sound/mode/vs/0, /settings/sound/output/vs/0 and +/settings/sound/volume/vs/0 used to be on this list too, until issue #319 +(a sibling TP1X_DA-AC-FAC-class board) supplied a live dump for them -- +airconditioner.SOUND_MODE and the reused air_purifier.SOUND_OUTPUT/ +SOUND_VOLUME now cover all three here as well. /uvled/vs/0 and /filter/airdustPM1filter/vs/0 used to be on this list too, until issue #270 (TP1X_FAC_TIME_23K) added real capabilities for both -- @@ -30,10 +36,7 @@ _STILL_UNBOUND = frozenset( "/edgelighting/vs/0", "/light/stateful/vs/0", "/mds/absenceclean/vs/0", - "/settings/sound/mode/vs/0", "/settings/sound/optimization/vs/0", - "/settings/sound/output/vs/0", - "/settings/sound/volume/vs/0", "/smartsensingcooling/vs/0", } ) diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index a39cd94..0522d4b 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -6,12 +6,12 @@ import pytest GOLDEN = Path(__file__).parent / "fixtures" / "golden" -def _new_state_keys(name, resources): +def _new_state_keys(name, resources, device_types=()): from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import resolve from custom_components.localthings.registry.discovery import discover - reg = resolve(resources) + reg = resolve(resources, device_types=device_types) if reg is None: from custom_components.localthings.registry.registry import CAPABILITIES @@ -134,6 +134,24 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_ailp_fac(): + """AILP_DA-AC-FAC-02011_0000 (issue #319) has no board-token match -- + resolves purely via /oic/d's oic.d.airconditioner type, exercising the + device_types-only path through resolve().""" + from tests.conftest import _load_device + + resources = _load_device("airconditioner_ailp_fac") + golden = json.loads((GOLDEN / "airconditioner_ailp_fac.json").read_text()) + state_keys = _new_state_keys( + "airconditioner_ailp_fac", resources, device_types=("oic.wk.d", "oic.d.airconditioner") + ) + assert set(state_keys) == set(golden["state_keys"]), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_dehumidifier(): """TP1X_DA_AC_DHM_01001_0000 (issue #88, AY18CG7500GED) shares the DA_AC_ board family with the room-AC models but carries the '_DHM_' token; @@ -1095,10 +1113,10 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac(): 0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never been added to the modelNum board-token table. Resolved via the new 'CAC' token onto the existing airconditioner registry. Not fully covered yet -- - ten hrefs remain unbound (edge lighting, PM1 filter, stateful light, - absence-clean, four sound-settings resources, smart-sensing-cooling, UV - LED), all genuinely new to this board generation and out of scope for - the routing fix; see test_airconditioner_cac.py for the documented gap.""" + five hrefs remain unbound (edge lighting, stateful light, absence-clean, + sound-optimization, smart-sensing-cooling), all genuinely new to this + board generation and out of scope for the routing fix; see + test_airconditioner_cac.py for the documented gap.""" from tests.conftest import _load_device resources = _load_device("airconditioner_cac") From 26c9168fb7d6dac23bec2f0d8eb3651314cd7f86 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 7 Aug 2026 14:13:38 +0000 Subject: [PATCH 2/9] Add internal air-filter support for TP1X_REF_21K refrigerators (issue #318) /filter/airdustfilter/vs/0 was the dump's only unbound href -- this board's internal deodorizing filter, same filterUsage/filterStatus field pair as common.WATER_FILTER, but filterUsage here is already a 0-100 percentage with no filterCapacity to divide by (confirmed by filterStatus=="wash" at filterUsage=="100"). Uses air_-prefixed keys so a fridge with both a water and an air filter gets two distinct entities. --- .../registry/by_type/refrigerator.py | 1 + .../registry/capabilities/fridge.py | 35 +- .../refrigerator_tp1x_ref_21k_airfilter.json | 36 + ...gerator_tp1x_ref_21k_airfilter_device.json | 623 ++++++++++++++++++ tests/test_golden_regression.py | 18 + ...tor_tp1x_ref_21k_airfilter_capabilities.py | 33 + 6 files changed, 745 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/golden/refrigerator_tp1x_ref_21k_airfilter.json create mode 100644 tests/fixtures/refrigerator_tp1x_ref_21k_airfilter_device.json create mode 100644 tests/test_refrigerator_tp1x_ref_21k_airfilter_capabilities.py diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 8e4f684..bd3e52c 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -13,6 +13,7 @@ REGISTRY = DeviceRegistry( fridge.STATUS_LOCK, fridge.DOOR_ALERT, common.WATER_FILTER, + fridge.AIR_FILTER, dishwasher.DIAGNOSIS, fridge.ICEMAKER_NIGHTTIME, fridge.FLEX_ZONE, diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 3182738..52a42e5 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -25,7 +25,7 @@ from ..entities import ( SwitchDesc, TimeDesc, ) -from .common import normalize_temp_unit +from .common import int_or_none, normalize_temp_unit # Display names for the beverage zone, flex zone, ice type, and # ice-making-status enums below live in translations/en.json, keyed by the @@ -266,6 +266,39 @@ DOOR_ALERT = Capability( ) +# Internal deodorizing filter (issue #318, TP1X_REF_21K). Same +# filterUsage/filterStatus field pair as common.WATER_FILTER, but +# filterUsage here is already a 0-100 percentage with no filterCapacity to +# divide by (confirmed by filterStatus=="wash" at filterUsage=="100") -- +# 'air_'-prefixed keys so a fridge with both a water and an air filter gets +# two distinct entities rather than a unique_id collision. +AIR_FILTER = Capability( + href="/filter/airdustfilter/vs/0", + poll_tier="cold", + entities=( + SensorDesc( + key="air_filter_usage", + field="x.com.samsung.da.filterUsage", + unit="%", + state_class="measurement", + icon="mdi:air-filter", + entity_category="diagnostic", + value_fn=int_or_none, + ), + SensorDesc( + key="air_filter_status", + field="x.com.samsung.da.filterStatus", + device_class="enum", + options=("normal", "wash", "replace"), + translation_key="filter_status", + icon="mdi:air-filter", + entity_category="diagnostic", + value_fn=lambda v: v.lower() if isinstance(v, str) else v, + ), + ), +) + + def _status_lock_write(field): return lambda p, rep, href=None: ( ["status", "lock", "vs", "0"], diff --git a/tests/fixtures/golden/refrigerator_tp1x_ref_21k_airfilter.json b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_airfilter.json new file mode 100644 index 0000000..0acf308 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_tp1x_ref_21k_airfilter.json @@ -0,0 +1,36 @@ +{ + "state_keys": [ + "ai_energy_level", + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_door_opener", + "brightness_level", + "cabinet_light_dim", + "cabinet_light_switch", + "cooler_setpoint", + "cooler_temperature", + "day_brightness", + "door_alert", + "door_cooler_open", + "door_freezer_open", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "freezer_setpoint", + "freezer_temperature", + "fridge_sound", + "ice_night_mode", + "icemaker_one_enabled", + "icemaker_one_making_status", + "night_end", + "night_start", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} diff --git a/tests/fixtures/refrigerator_tp1x_ref_21k_airfilter_device.json b/tests/fixtures/refrigerator_tp1x_ref_21k_airfilter_device.json new file mode 100644 index 0000000..ca6ef30 --- /dev/null +++ b/tests/fixtures/refrigerator_tp1x_ref_21k_airfilter_device.json @@ -0,0 +1,623 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "href": "/alarms/vs/0", + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/bespoke/vs/0", + "rep": { + "x.com.samsung.da.BespokeProduct": "On", + "href": "/bespoke/vs/0" + } + }, + { + "href": "/cabinet/light/enhanced/vs/0", + "rep": { + "light.control.status": "On", + "level.brightness.daytime": "100", + "level.brightness.nighttime": "33", + "night.starttime": "2026-08-07T12:00:00", + "night.duration.minute": "540", + "timezone.offset": "+09:00", + "href": "/cabinet/light/enhanced/vs/0", + "rt": [ + "x.com.samsung.da.light.enhanced" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/cabinet/light/total/vs/0", + "rep": { + "x.com.samsung.da.lightLevel": "100", + "x.com.samsung.da.lightResolution": "3", + "x.com.samsung.da.lightControl.off.include": "Off", + "x.com.samsung.da.lightControl": "Off", + "x.com.samsung.da.lightControl.hide": "true", + "light.dimming.status": "On", + "href": "/cabinet/light/total/vs/0", + "rt": [ + "x.com.samsung.da.cabinetlight" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "", + "x.com.samsung.da.countryCode": "", + "href": "/configuration/vs/0" + } + }, + { + "href": "/connectionconfig/vs/0", + "rep": { + "autoReconnectionMinVersion": "1.0", + "autoReconnection": "true", + "autoReconnectionProtocolType": [ + "helper_hotspot", + "ble_ocf" + ], + "supportedWiFiAuthType": [ + "OPEN", + "WEP", + "WPA-PSK", + "WPA2-PSK", + "SAE" + ], + "supportedWiFiCryptoType": [ + "TKIP", + "AES", + "WEP-64", + "WEP-128" + ], + "supportedWiFiFreq": [ + "2.4G" + ], + "calmConnectionCare": { + "version": "1.0", + "role": [ + "things" + ] + } + } + }, + { + "href": "/defrost/prediction/vs/0", + "rep": { + "ai.cooling.care": "Off", + "href": "/defrost/prediction/vs/0" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Normal", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close", + "href": "/door/cooler/0", + "rt": [ + "oic.r.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/door/freezer/0", + "rep": { + "openState": "Close", + "href": "/door/freezer/0", + "rt": [ + "oic.r.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door" + }, + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Door" + } + ], + "href": "/doors/vs/0" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "2", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "1441", + "x.com.samsung.da.start": "2026-08-07T00:00:41Z", + "x.com.samsung.da.realSaving": "On", + "href": "/drlc/vs/0" + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": [ + "1", + "2" + ], + "href": "/energy/ailevel/vs/0" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "25344", + "x.com.samsung.da.instantaneousPower": "48", + "x.com.samsung.da.cumulativePower": "413385", + "x.com.samsung.da.cumulativeSavedPower": "50694", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "href": "/energy/consumption/vs/0", + "x.com.samsung.da.cumulativeDateUTC": "1786020360" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1, + "href": "/file/information/vs/0" + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.filterStatus": "wash", + "href": "/filter/airdustfilter/vs/0" + } + }, + { + "href": "/icemaker/nighttime/vs/0", + "rep": { + "ice.night.status": "On", + "ice.night.starttime": "2026-08-07T12:00:00", + "ice.night.duration": "540", + "ice.night.timezone": "+09:00", + "href": "/icemaker/nighttime/vs/0", + "rt": [ + "x.com.samsung.da.ice.night" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/icemaker/one/vs/0", + "rep": { + "x.com.samsung.da.iceMaker.name": "ICE_MAKER", + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceType.desired": "NORMAL", + "x.com.samsung.da.iceMaker.iceMakingStatus": "ICESTATUS_STOP", + "x.com.samsung.da.iceMaker.type": "toggle", + "href": "/icemaker/one/vs/0", + "rt": [ + "x.com.samsung.da.icemaker" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/icemaker/status/vs/0", + "rep": { + "x.com.samsung.da.iceMaker": "On", + "href": "/icemaker/status/vs/0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|70664141|0000033C011913114100000041FB5F00", + "x.com.samsung.da.description": "TP1X_REF_21K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "RR7", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "260618", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2605071D, 24120906, 26040701, FFFFFFFF, 25082208, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "href": "/information/vs/0" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ], + "href": "/mode/vs/0", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "26040701", + "x.com.samsung.da.currentVersionInfo": "10000000", + "otnStatus": "None", + "flashingProgress": "0", + "otnTarget": "inverter", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Refrigerator", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "A-RFWW-TP1-24-T4-RE1", + "versions": [ + "20260618" + ], + "visVersion": "260618" + }, + { + "type": "Micom", + "modelId": "823070664141FFFFFFFF", + "versions": [ + "2605071D", + "FFFFFFFF" + ], + "visVersion": "260507" + }, + { + "type": "Micom", + "modelId": "823070664041FFFFFFFF", + "versions": [ + "24120906", + "FFFFFFFF" + ], + "visVersion": "241209" + }, + { + "type": "Micom", + "modelId": "02307066414170664041", + "versions": [ + "2605071D", + "24120906" + ], + "visVersion": "260507" + }, + { + "type": "Micom", + "modelId": "023070680641FFFFFFFF", + "versions": [ + "26040701", + "FFFFFFFF" + ], + "visVersion": "260407" + }, + { + "type": "Micom", + "modelId": "023070668841FFFFFFFF", + "versions": [ + "25082208", + "FFFFFFFF" + ], + "visVersion": "250822" + } + ] + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true", + "href": "/realtimenotiforclient/vs/0" + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off", + "href": "/refrigeration/vs/0", + "rt": [ + "x.com.samsung.da.fridge" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/rm/control/vs/0", + "rep": { + "minPeriod": "9000", + "href": "/rm/control/vs/0" + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0, + "href": "/runningmode/vs/0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.supportedActions": [ + "Start" + ], + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "ErrorCode_None" + ], + "href": "/selfcheck/vs/0" + } + }, + { + "href": "/settings/sound/alert/door/vs/0", + "rep": { + "alert.door": "1", + "supportedAlert.door": [ + "1", + "2", + "3", + "4" + ], + "href": "/settings/sound/alert/door/vs/0", + "rt": [ + "x.com.samsung.alert.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.device.sound": "On", + "x.com.samsung.da.preciseCooling": "On", + "x.com.samsung.da.doorAlarmSound": "On", + "cleaning.status": "On", + "cleaning.type": "SPI_AND_UV", + "href": "/status/lock/vs/0", + "rt": [ + "x.com.samsung.da.lockstatus" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/current/cooler/0", + "rep": { + "temperature": 2.0, + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "href": "/temperature/current/cooler/0", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/current/freezer/0", + "rep": { + "temperature": -19.0, + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "href": "/temperature/current/freezer/0", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/desired/cooler/0", + "rep": { + "temperature": 2.0, + "range": [ + 1.0, + 7.0 + ], + "units": "C", + "href": "/temperature/desired/cooler/0", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperature/desired/freezer/0", + "rep": { + "temperature": -19.0, + "range": [ + -23.0, + -15.0 + ], + "units": "C", + "href": "/temperature/desired/freezer/0", + "rt": [ + "oic.r.temperature" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "temperature.unit.control": "true", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.desired": "-19", + "x.com.samsung.da.current": "-19", + "x.com.samsung.da.maximum": "-15", + "x.com.samsung.da.minimum": "-23", + "x.com.samsung.da.unit": "Celsius" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.desired": "2", + "x.com.samsung.da.current": "2", + "x.com.samsung.da.maximum": "7", + "x.com.samsung.da.minimum": "1", + "x.com.samsung.da.unit": "Celsius" + } + ], + "href": "/temperatures/vs/0" + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "eomkim_IoT" + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 0522d4b..e84d0ea 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -277,6 +277,24 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_eu(): ) +def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_airfilter(): + """TP1X_REF_21K, air-filter-equipped variant (issue #318) -- reports + /filter/airdustfilter/vs/0 (internal deodorizing filter), the one + unbound href on this dump. Unlike airconditioner.AIR_FILTER's + filterUsage/filterCapacity pair, this board's filterUsage is already a + 0-100 percentage with no filterCapacity to divide by.""" + from tests.conftest import _load_device + + resources = _load_device("refrigerator_tp1x_ref_21k_airfilter") + golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_airfilter.json").read_text()) + state_keys = _new_state_keys("refrigerator_tp1x_ref_21k_airfilter", resources) + assert set(state_keys) == set(golden["state_keys"]), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_range_hood(): from tests.conftest import _load_device diff --git a/tests/test_refrigerator_tp1x_ref_21k_airfilter_capabilities.py b/tests/test_refrigerator_tp1x_ref_21k_airfilter_capabilities.py new file mode 100644 index 0000000..4a05fe2 --- /dev/null +++ b/tests/test_refrigerator_tp1x_ref_21k_airfilter_capabilities.py @@ -0,0 +1,33 @@ +"""Tests for the TP1X_REF_21K air-filter-equipped variant (issue #318). + +The dump's only unbound href was /filter/airdustfilter/vs/0, this board's +internal deodorizing filter -- same filterUsage/filterStatus field pair as +common.WATER_FILTER, but filterUsage here is already a 0-100 percentage +(no filterCapacity to divide by, unlike airconditioner.AIR_FILTER). +""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import resolve +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + + +def _bound(): + resources = _load_device("refrigerator_tp1x_ref_21k_airfilter") + reg = resolve(resources) + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def test_no_unbound_hrefs(): + _, resources = _bound() + unbound = [] + reg = resolve(resources) + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_air_filter_usage_and_status(): + bound, resources = _bound() + state = flatten(bound, resources) + assert state["air_filter_usage"] == 100 + assert state["air_filter_status"] == "wash" From df5b704f3e34b3878b1b1c6056100459f28eeed1 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 7 Aug 2026 14:18:08 +0000 Subject: [PATCH 3/9] Close gas-cooktop coverage gap for TP2X_DA-KS-COOKTOP-000001 (issue #314) /alarms/vs/0 and /kidslock/vs/0 were the dump's two unbound hrefs -- both are the exact shapes common.UNIVERSAL already models elsewhere (common.ALARMS, common.KIDS_LOCK_VS_FALLBACK), picked individually rather than pulling in all of UNIVERSAL to match this registry's existing hand-picked-common style. The six-vs-three burner count the reporter originally asked about is expected behavior (the board's own /mode/vs/0 options genuinely advertise six OperationState slots on hardware with three physical burners, with no per-device signal to tell real slots from phantom ones) -- already explained on the issue; this commit is scoped to the coverage warning. --- .../localthings/registry/by_type/cooktop.py | 6 + .../fixtures/gas_cooktop_tp2x_ks_device.json | 138 ++++++++++++++++++ .../fixtures/golden/gas_cooktop_tp2x_ks.json | 24 +++ .../test_gas_cooktop_tp2x_ks_capabilities.py | 55 +++++++ tests/test_golden_regression.py | 19 +++ 5 files changed, 242 insertions(+) create mode 100644 tests/fixtures/gas_cooktop_tp2x_ks_device.json create mode 100644 tests/fixtures/golden/gas_cooktop_tp2x_ks.json create mode 100644 tests/test_gas_cooktop_tp2x_ks_capabilities.py diff --git a/custom_components/localthings/registry/by_type/cooktop.py b/custom_components/localthings/registry/by_type/cooktop.py index b2fcb83..81cdf54 100644 --- a/custom_components/localthings/registry/by_type/cooktop.py +++ b/custom_components/localthings/registry/by_type/cooktop.py @@ -22,6 +22,12 @@ REGISTRY = DeviceRegistry( cooktop.COOKTOP_CONNECTED, cooktop.PAIRED_HOOD_STATUS, common.FIRMWARE_UPDATE, + # issue #314: /alarms/vs/0 and /kidslock/vs/0 are the same + # generic shapes common.UNIVERSAL already models elsewhere -- + # picked individually rather than pulling in all of UNIVERSAL, + # matching this registry's existing hand-picked-common style. + common.ALARMS, + common.KIDS_LOCK_VS_FALLBACK, ] ), ) diff --git a/tests/fixtures/gas_cooktop_tp2x_ks_device.json b/tests/fixtures/gas_cooktop_tp2x_ks_device.json new file mode 100644 index 0000000..88cf7c5 --- /dev/null +++ b/tests/fixtures/gas_cooktop_tp2x_ks_device.json @@ -0,0 +1,138 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "CT_E", + "x.com.samsung.da.triggeredTime": "2026-08-06T14:24:02" + } + ] + } + }, + { + "href": "/bluetooth/hood/status/vs/0", + "rep": { + "connectionState": "disconnected", + "micomModelId": "", + "firmwareVersion": "", + "power": "off", + "fanSpeed": 0, + "lampState": "off", + "timer": {}, + "rt": [ + "bluetoothHoodStatus" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On", + "rt": [ + "x.com.samsung.da.connected" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.options": [ + "DeviceType_NV8000T-/KO0", + "Pause_Off", + "SyncFlex_Off", + "FlexCoil_0", + "MainTimerCurrent_0", + "MainTimerSet_0", + "MainTimerState_Ready", + "IndependentTimerCheck_Enable", + "OperationState0_Ready", + "HotSurface0_Normal", + "PowerLevel0_0", + "OperationState1_Ready", + "HotSurface1_Normal", + "PowerLevel1_0", + "OperationState2_Ready", + "HotSurface2_Normal", + "PowerLevel2_0", + "OperationState3_Ready", + "HotSurface3_Normal", + "PowerLevel3_0", + "OperationState4_Ready", + "HotSurface4_Normal", + "PowerLevel4_0", + "OperationState5_Ready", + "HotSurface5_Normal", + "PowerLevel5_0" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + } + ] +} diff --git a/tests/fixtures/golden/gas_cooktop_tp2x_ks.json b/tests/fixtures/golden/gas_cooktop_tp2x_ks.json new file mode 100644 index 0000000..0492b0a --- /dev/null +++ b/tests/fixtures/golden/gas_cooktop_tp2x_ks.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "alarm_code", + "any_burner_active", + "burner_0_state", + "burner_1_state", + "burner_2_state", + "burner_3_state", + "burner_4_state", + "burner_5_state", + "child_lock", + "cloud_connected", + "firmware_update", + "main_timer_current", + "main_timer_state", + "paired_hood_connected", + "paired_hood_fan_speed", + "paired_hood_firmware", + "paired_hood_light", + "paired_hood_model", + "paired_hood_power", + "power_state" + ] +} diff --git a/tests/test_gas_cooktop_tp2x_ks_capabilities.py b/tests/test_gas_cooktop_tp2x_ks_capabilities.py new file mode 100644 index 0000000..7677618 --- /dev/null +++ b/tests/test_gas_cooktop_tp2x_ks_capabilities.py @@ -0,0 +1,55 @@ +"""Tests for the TP2X_DA-KS-COOKTOP-000001 gas cooktop (issue #314). + +The reporter's actual complaint (six advertised burner slots when only +three are physically present) is expected: the registry declares a +generous static superset of slots and gates each on the live options array +per cooktop.py's own comment; there's no per-device signal that +distinguishes an advertised-but-nonexistent slot from a real one, so the +extra entities are disabled by the user, not filtered by the integration. +This file locks in the actual gap from that issue -- /alarms/vs/0 and +/kidslock/vs/0 -- now bound via common.ALARMS/KIDS_LOCK_VS_FALLBACK. +""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import resolve +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + + +def _bound(): + resources = _load_device("gas_cooktop_tp2x_ks") + reg = resolve(resources) + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def test_resolves_to_gas_cooktop_registry(): + resources = _load_device("gas_cooktop_tp2x_ks") + reg = resolve(resources) + assert reg is not None and reg.name == "gas_cooktop" + + +def test_no_unbound_hrefs(): + _, resources = _bound() + reg = resolve(resources) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_alarm_code_and_child_lock_bound(): + bound, resources = _bound() + state = flatten(bound, resources) + assert state["alarm_code"] == "CT_E" + # x.com.samsung.da.kidsLock == "Ready" reads as unlocked, same polarity + # as every other board KIDS_LOCK_VS_FALLBACK covers. + assert state["child_lock"] is True + + +def test_advertises_six_burner_slots_only_three_of_which_are_real(): + """Not a bug to fix -- the board's own /mode/vs/0 options genuinely + list six OperationState slots (issue #314); the reporter's physical + cooktop only has three. Locks in the current, documented behavior.""" + bound, resources = _bound() + state = flatten(bound, resources) + for i in range(6): + assert f"burner_{i}_state" in state diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index e84d0ea..19b0983 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -231,6 +231,25 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier_coffee(): ) +def test_registry_reproduces_golden_state_keys_for_gas_cooktop_tp2x_ks(): + """TP2X_DA-KS-COOKTOP-000001 (issue #314) -- routes via + for_device_by_resources' DeviceType_/OperationState signature, same as + the original cooktop fixture. /alarms/vs/0 and /kidslock/vs/0 were the + dump's only unbound hrefs; both are common.UNIVERSAL shapes this + registry now picks up individually (common.ALARMS, + common.KIDS_LOCK_VS_FALLBACK).""" + from tests.conftest import _load_device + + resources = _load_device("gas_cooktop_tp2x_ks") + golden = json.loads((GOLDEN / "gas_cooktop_tp2x_ks.json").read_text()) + state_keys = _new_state_keys("gas_cooktop_tp2x_ks", resources) + assert set(state_keys) == set(golden["state_keys"]), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_cooktop(): from tests.conftest import _load_device From 42fd9c2aa43e5e5bb2c1acdd8693e98daeca999c Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 7 Aug 2026 14:22:32 +0000 Subject: [PATCH 4/9] Close coverage gap and fix phantom lamp switch for TP2X_DA-KS-WALLOVEN (issue #300) /diagnosis/vs/0 was the dump's only unbound href, now covered via dishwasher.DIAGNOSIS (same shape already reused by airconditioner.py). This steam-oven-class board's /mode/vs/0 options[] carries no UpperLamp_ token at all, unlike the NV7000BS-class board LAMP was proven against -- LAMP had no exists_fn, so it registered anyway, always read Off, and any write to it was a no-op the device had no reason to honor. Gives it the same options-token exists_fn gate issue #183 already added to fast_preheat/natural_steam/energy_saving/cooktop_on_alert. --- .../localthings/registry/by_type/oven.py | 5 +- .../localthings/registry/capabilities/oven.py | 7 + .../golden/oven_tp2x_ks_walloven.json | 24 ++ .../oven_tp2x_ks_walloven_device.json | 260 ++++++++++++++++++ tests/test_golden_regression.py | 23 ++ ...test_oven_tp2x_ks_walloven_capabilities.py | 54 ++++ 6 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/golden/oven_tp2x_ks_walloven.json create mode 100644 tests/fixtures/oven_tp2x_ks_walloven_device.json create mode 100644 tests/test_oven_tp2x_ks_walloven_capabilities.py diff --git a/custom_components/localthings/registry/by_type/oven.py b/custom_components/localthings/registry/by_type/oven.py index 107ca9c..ea1973f 100644 --- a/custom_components/localthings/registry/by_type/oven.py +++ b/custom_components/localthings/registry/by_type/oven.py @@ -1,6 +1,6 @@ """Oven device registry.""" -from ..capabilities import common, ignored, oven +from ..capabilities import common, dishwasher, ignored, oven from ._base import DeviceRegistry, _build REGISTRY = DeviceRegistry( @@ -18,6 +18,9 @@ REGISTRY = DeviceRegistry( oven.OVEN_CONNECTED, oven.OVEN_SPEC, oven.OVEN_RECIPE_COOK, + # issue #300: /diagnosis/vs/0 is the same diagnosisStart shape + # dishwasher.py and airconditioner.py already reuse. + dishwasher.DIAGNOSIS, ] ), ) diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 50d3847..d340483 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -401,10 +401,17 @@ OVEN_MODE = Capability( value_fn=lambda v: v[0] if v else None, write_fn=_oven_mode_write, ), + # No exists_fn on the NV7000BS-class board this was proven against + # (UpperLamp_ is always in its options[]) -- but issue #300's + # steam-oven-class WALLOVEN board's options[] has no UpperLamp_ + # token at all, so this was a phantom, always-off, write-does- + # nothing switch there. Same fastpreheat/NaturalSteam-class gap + # issue #183 already fixed on the other switches below. SwitchDesc( key="lamp", field="x.com.samsung.da.options", icon="mdi:track-light", + exists_fn=_has_option("UpperLamp"), value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On", write_fn=_option_switch_write("UpperLamp"), ), diff --git a/tests/fixtures/golden/oven_tp2x_ks_walloven.json b/tests/fixtures/golden/oven_tp2x_ks_walloven.json new file mode 100644 index 0000000..5c4cb1b --- /dev/null +++ b/tests/fixtures/golden/oven_tp2x_ks_walloven.json @@ -0,0 +1,24 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "cloud_connected", + "cook_time", + "current_temp_c", + "cycle_active", + "diagnosis_status", + "door_open", + "energy_saving", + "finish_time", + "firmware_update", + "machine_state", + "operation_time_minutes", + "oven_mode", + "oven_setpoint", + "oven_state", + "power_switch", + "progress_percentage", + "remote_control", + "sound" + ] +} diff --git a/tests/fixtures/oven_tp2x_ks_walloven_device.json b/tests/fixtures/oven_tp2x_ks_walloven_device.json new file mode 100644 index 0000000..f5a183c --- /dev/null +++ b/tests/fixtures/oven_tp2x_ks_walloven_device.json @@ -0,0 +1,260 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2026-08-05T14:20:14" + } + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On", + "rt": [ + "x.com.samsung.da.connected" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.lock": "Unlock" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_DA-KS-WALLOVEN-000002|40441841|5002011E021011150100000000000000", + "x.com.samsung.da.description": "NW9000KD/AA1", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "240205", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "DE92-04418A_20062500, DE92-04011A_17041300", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "610", + "x.com.samsung.da.diagMinVersion": "1.0" + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "ConvectionBake", + "ConvectionRoast", + "Bake", + "Broil", + "SteamBake", + "SteamRoast", + "Easycook3", + "Descale", + "PyroFree", + "Drain", + "SelfClean", + "NoOperation" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_NW9000KD/AA1", + "keepWarmReservation_Off", + "meatprobe_disconnected", + "NoPreheat_Off", + "waterInlet_Closed", + "steamAddLevel_0", + "descaleAlarm_Normal", + "descaleNewWaterAlarm_Off", + "descaleEmptyWaterAlarm_Off", + "steamGeneratorLevel_Empty", + "steamUsingTime_66", + "drainRequired_00", + "steamState_Standby", + "descaleState_Standby", + "waterTankInSwitch_On", + "pyroFreeState_Standby", + "waterTankOutSwitch_Off", + "Sound_On", + "AdjustingTemp_0", + "Sabbath_Off", + "EnergySaving_On" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "1", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "0", + "x.com.samsung.da.increment": "0", + "x.com.samsung.da.unit": "Fahrenheit" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "0", + "x.com.samsung.da.current": "0", + "x.com.samsung.da.increment": "0", + "x.com.samsung.da.unit": "Fahrenheit" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + } + ] +} diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 19b0983..568169f 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -152,6 +152,29 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_ailp_fac(): ) +def test_registry_reproduces_golden_state_keys_for_oven_tp2x_ks_walloven(): + """TP2X_DA-KS-WALLOVEN-000002 (issue #300), a steam-oven-class board + (water tank, descale, pyro-free) quite unlike the NV7000BS-class board + oven.py's write comments were written against. /diagnosis/vs/0 was the + dump's only unbound href, now covered via dishwasher.DIAGNOSIS. The + board's options[] also has no UpperLamp_ token at all -- confirms + LAMP's new exists_fn keeps it from registering a phantom switch here, + same fastpreheat/NaturalSteam-class gap issue #183 fixed on its + siblings.""" + from tests.conftest import _load_device + + resources = _load_device("oven_tp2x_ks_walloven") + golden = json.loads((GOLDEN / "oven_tp2x_ks_walloven.json").read_text()) + state_keys = _new_state_keys( + "oven_tp2x_ks_walloven", resources, device_types=("oic.wk.d", "oic.d.oven") + ) + assert set(state_keys) == set(golden["state_keys"]), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_registry_reproduces_golden_state_keys_for_dehumidifier(): """TP1X_DA_AC_DHM_01001_0000 (issue #88, AY18CG7500GED) shares the DA_AC_ board family with the room-AC models but carries the '_DHM_' token; diff --git a/tests/test_oven_tp2x_ks_walloven_capabilities.py b/tests/test_oven_tp2x_ks_walloven_capabilities.py new file mode 100644 index 0000000..016e363 --- /dev/null +++ b/tests/test_oven_tp2x_ks_walloven_capabilities.py @@ -0,0 +1,54 @@ +"""Tests for the TP2X_DA-KS-WALLOVEN-000002 steam oven (issue #300). + +The reporter's "not fully supported" notification was /diagnosis/vs/0, +now covered via dishwasher.DIAGNOSIS. Their "doesn't respond to commands" +complaint is broader: oven.py's own module docstring already documents +setpoint/cook-time/mode writes as unproven on this local-OCF firmware, and +this board's options[] carries no UpperLamp_ token at all -- confirming +`lamp` was previously a phantom, write-does-nothing switch here (same gap +issue #183 fixed for fast_preheat/natural_steam on a different board). +""" + +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve +from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_device + +_DEVICE_TYPES = ("oic.wk.d", "oic.d.oven") + + +def _bound(): + resources = _load_device("oven_tp2x_ks_walloven") + reg = resolve(resources, device_types=_DEVICE_TYPES) + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def test_oic_device_type_resolves_to_oven_registry(): + reg = for_device_by_oic_type(_DEVICE_TYPES) + assert reg is not None and reg.name == "oven" + + +def test_no_unbound_hrefs(): + resources = _load_device("oven_tp2x_ks_walloven") + reg = resolve(resources, device_types=_DEVICE_TYPES) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_lamp_not_bound_without_an_upperlamp_token(): + """This board's options[] has no UpperLamp_ entry -- confirms the new + exists_fn keeps `lamp` from registering a switch that would always + read Off and never actually do anything on write.""" + bound, resources = _bound() + state = flatten(bound, resources) + assert "lamp" not in state + + +def test_sound_and_energy_saving_bound_from_real_options_tokens(): + """Unlike lamp, this board's options[] does carry Sound_On and + EnergySaving_On -- these switches should reflect real state.""" + bound, resources = _bound() + state = flatten(bound, resources) + assert state["sound"] is True + assert state["energy_saving"] is True From c203bd42c5456e3f9c08c11d4196fc1897cd214b Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 7 Aug 2026 14:27:25 +0000 Subject: [PATCH 5/9] Add edge-lighting and indicator-light support for TP1X_DA-AC-CAC-01001 (issue #288) Six System A/C cassette units on the same board test_airconditioner_cac.py already documented as having an incomplete coverage gap gave real dump evidence for two of its remaining unbound hrefs: - /edgelighting/vs/0: an accent-light strip with on/off, a Smart/High/Low mode, and a Kelvin color-temperature select (3000K/4000K/6500K), all read from the device's own live supported-value lists. - /light/stateful/vs/0: a second, distinct light resource with its own on/off and Smart/Low/High mode -- not to be confused with EDGE_LIGHTING or DISPLAY_LIGHT's ambient mood light. convenientMode/operatingOption on /edgelighting/vs/0 stay unexposed: present on every dump but no evidence of what either actually controls. Only three hrefs remain in test_airconditioner_cac.py's documented gap now (absence-clean, sound-optimization, smart-sensing-cooling). --- .../registry/by_type/airconditioner.py | 2 + .../registry/capabilities/airconditioner.py | 80 +++++++++++++++++++ .../localthings/translations/cs.json | 15 ++++ .../localthings/translations/en.json | 15 ++++ .../localthings/translations/es.json | 15 ++++ .../localthings/translations/it.json | 15 ++++ .../localthings/translations/ko.json | 15 ++++ .../localthings/translations/nl.json | 15 ++++ tests/fixtures/golden/airconditioner_cac.json | 5 ++ tests/test_airconditioner_cac.py | 18 +++-- tests/test_golden_regression.py | 8 +- 11 files changed, 191 insertions(+), 12 deletions(-) diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 9792766..0ca07c3 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -55,6 +55,8 @@ REGISTRY = DeviceRegistry( airconditioner.SOUND_MODE, airconditioner.ABSENCE_CLEAN, airconditioner.ENERGY_SAVING, + airconditioner.EDGE_LIGHTING, + airconditioner.LIGHT_STATEFUL, *airconditioner.COVERAGE, ] ), diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 5ce5adc..482f4e7 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -1252,6 +1252,86 @@ ENERGY_SAVING = Capability( ), ) +# TP1X_DA-AC-CAC-01001-class additions (issue #288, six System A/C cassette +# units on the same board test_airconditioner_cac.py's coverage-gap test +# documents). `convenientMode`/`operatingOption` stay unexposed -- present +# on every dump seen but no evidence of what either actually controls. +EDGE_LIGHTING = Capability( + href="/edgelighting/vs/0", + poll_tier="cold", + entities=( + SwitchDesc( + key="edge_lighting", + field="status", + icon="mdi:led-strip", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["edgelighting", "vs", "0"], + {"status": "On" if p == "On" else "Off"}, + ), + ), + SelectDesc( + key="edge_lighting_mode", + field="mode", + icon="mdi:led-strip-variant", + entity_category="config", + options_field="modeSupportedList", + write_fn=lambda p, rep, href=None: ( + ["edgelighting", "vs", "0"], + {"mode": p}, + ), + ), + # Color temperature in Kelvin (3000K/4000K/6500K), not a hue -- a + # select over the live-reported codes rather than a light color_temp + # entity, consistent with this project's other Kelvin-coded selects. + SelectDesc( + key="edge_lighting_color", + field="colorOption", + icon="mdi:palette", + entity_category="config", + options_field="colorSupportedList", + write_fn=lambda p, rep, href=None: ( + ["edgelighting", "vs", "0"], + {"colorOption": p}, + ), + ), + ), +) + +# Second, distinct light resource on this board generation -- an +# always-on-style indicator light with its own status/mode, not to be +# confused with EDGE_LIGHTING (a different href/rep entirely) or +# DISPLAY_LIGHT (/light/vs/0's ambient mood light). +LIGHT_STATEFUL = Capability( + href="/light/stateful/vs/0", + poll_tier="cold", + entities=( + SwitchDesc( + key="indicator_light", + field="status", + icon="mdi:led-on", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["light", "stateful", "vs", "0"], + {"status": "On" if p == "On" else "Off"}, + ), + ), + SelectDesc( + key="indicator_light_mode", + field="mode", + icon="mdi:led-variant-on", + entity_category="config", + options_field="supportedModes", + write_fn=lambda p, rep, href=None: ( + ["light", "stateful", "vs", "0"], + {"mode": p}, + ), + ), + ), +) + # /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 diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index b98538e..6853ed7 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -668,6 +668,15 @@ }, "freezer_temperature_setpoint": { "name": "Teplota mrazicí zóny" + }, + "edge_lighting_mode": { + "name": "Režim okrajového osvětlení" + }, + "edge_lighting_color": { + "name": "Barva okrajového osvětlení" + }, + "indicator_light_mode": { + "name": "Režim kontrolky" } }, "sensor": { @@ -1216,6 +1225,12 @@ }, "ventilation_alarm": { "name": "Alarm větrání" + }, + "edge_lighting": { + "name": "Okrajové osvětlení" + }, + "indicator_light": { + "name": "Kontrolka" } }, "time": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 0efa41c..b1f3e08 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -668,6 +668,15 @@ }, "freezer_temperature_setpoint": { "name": "Freezer temperature" + }, + "edge_lighting_mode": { + "name": "Edge lighting mode" + }, + "edge_lighting_color": { + "name": "Edge lighting color" + }, + "indicator_light_mode": { + "name": "Indicator light mode" } }, "sensor": { @@ -1216,6 +1225,12 @@ }, "ventilation_alarm": { "name": "Ventilation alarm" + }, + "edge_lighting": { + "name": "Edge lighting" + }, + "indicator_light": { + "name": "Indicator light" } }, "time": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 470ef17..84a86ae 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -793,6 +793,15 @@ }, "freezer_temperature_setpoint": { "name": "Temperatura del congelador" + }, + "edge_lighting_mode": { + "name": "Modo de iluminación perimetral" + }, + "edge_lighting_color": { + "name": "Color de iluminación perimetral" + }, + "indicator_light_mode": { + "name": "Modo de luz indicadora" } }, "sensor": { @@ -1341,6 +1350,12 @@ }, "ventilation_alarm": { "name": "Alarma de ventilación" + }, + "edge_lighting": { + "name": "Iluminación perimetral" + }, + "indicator_light": { + "name": "Luz indicadora" } }, "time": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 17412a0..df2c7b8 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -668,6 +668,15 @@ }, "freezer_temperature_setpoint": { "name": "Temperatura freezer" + }, + "edge_lighting_mode": { + "name": "Modalità illuminazione perimetrale" + }, + "edge_lighting_color": { + "name": "Colore illuminazione perimetrale" + }, + "indicator_light_mode": { + "name": "Modalità spia luminosa" } }, "sensor": { @@ -1216,6 +1225,12 @@ }, "ventilation_alarm": { "name": "Allarme ventilazione" + }, + "edge_lighting": { + "name": "Illuminazione perimetrale" + }, + "indicator_light": { + "name": "Spia luminosa" } }, "time": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 62a0886..c018827 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -668,6 +668,15 @@ }, "freezer_temperature_setpoint": { "name": "냉동실 온도" + }, + "edge_lighting_mode": { + "name": "엣지 라이팅 모드" + }, + "edge_lighting_color": { + "name": "엣지 라이팅 색상" + }, + "indicator_light_mode": { + "name": "표시등 모드" } }, "sensor": { @@ -1216,6 +1225,12 @@ }, "ventilation_alarm": { "name": "환기 알림" + }, + "edge_lighting": { + "name": "엣지 라이팅" + }, + "indicator_light": { + "name": "표시등" } }, "time": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b9d03a5..8bb5965 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -668,6 +668,15 @@ }, "freezer_temperature_setpoint": { "name": "Temperatuur vriesgedeelte" + }, + "edge_lighting_mode": { + "name": "Randverlichtingsmodus" + }, + "edge_lighting_color": { + "name": "Randverlichtingskleur" + }, + "indicator_light_mode": { + "name": "Indicatorlampjemodus" } }, "sensor": { @@ -1216,6 +1225,12 @@ }, "ventilation_alarm": { "name": "Ventilatiealarm" + }, + "edge_lighting": { + "name": "Randverlichting" + }, + "indicator_light": { + "name": "Indicatorlampje" } }, "time": { diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json index 585c344..2426c0e 100644 --- a/tests/fixtures/golden/airconditioner_cac.json +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -17,11 +17,16 @@ "climate", "current_temperature_c", "dust", + "edge_lighting", + "edge_lighting_color", + "edge_lighting_mode", "energy_kwh", "energy_saved_kwh", "fine_dust", "firmware_update", "humidity", + "indicator_light", + "indicator_light_mode", "motion_detect_wind_active", "motion_detect_wind_mode", "mute_once", diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py index 8887ea1..7aa0a64 100644 --- a/tests/test_airconditioner_cac.py +++ b/tests/test_airconditioner_cac.py @@ -6,12 +6,11 @@ token -- this board was the one exception (its oneUiVersion self-reports "7.0 Air conditioner", but 'CAC' had never been added to the board-token table), so it silently fell back to common caps and lost its climate entity. -This dump is NOT fully covered yet -- five hrefs remain unbound (edge -lighting, a second stateful light resource, absence-clean, -`/settings/sound/optimization/vs/0`, smart-sensing-cooling), all genuinely -new to this board generation. That's a real device-support gap, left -documented here rather than guessed at, per the 'don't guess' rule -- -fixing the routing regression was the scope of #191. +This dump is NOT fully covered yet -- three hrefs remain unbound +(absence-clean, `/settings/sound/optimization/vs/0`, smart-sensing-cooling), +all genuinely new to this board generation. That's a real device-support +gap, left documented here rather than guessed at, per the 'don't guess' +rule -- fixing the routing regression was the scope of #191. /settings/sound/mode/vs/0, /settings/sound/output/vs/0 and /settings/sound/volume/vs/0 used to be on this list too, until issue #319 @@ -19,6 +18,11 @@ fixing the routing regression was the scope of #191. airconditioner.SOUND_MODE and the reused air_purifier.SOUND_OUTPUT/ SOUND_VOLUME now cover all three here as well. +/edgelighting/vs/0 and /light/stateful/vs/0 used to be on this list too, +until issue #288 (six System A/C cassette units on this same board) gave +real dump evidence for both -- airconditioner.EDGE_LIGHTING and +LIGHT_STATEFUL now cover them. + /uvled/vs/0 and /filter/airdustPM1filter/vs/0 used to be on this list too, 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 @@ -33,8 +37,6 @@ from tests.conftest import _load_device _STILL_UNBOUND = frozenset( { - "/edgelighting/vs/0", - "/light/stateful/vs/0", "/mds/absenceclean/vs/0", "/settings/sound/optimization/vs/0", "/smartsensingcooling/vs/0", diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 568169f..dd6932d 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -1173,10 +1173,10 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac(): 0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never been added to the modelNum board-token table. Resolved via the new 'CAC' token onto the existing airconditioner registry. Not fully covered yet -- - five hrefs remain unbound (edge lighting, stateful light, absence-clean, - sound-optimization, smart-sensing-cooling), all genuinely new to this - board generation and out of scope for the routing fix; see - test_airconditioner_cac.py for the documented gap.""" + three hrefs remain unbound (absence-clean, sound-optimization, + smart-sensing-cooling), all genuinely new to this board generation and + out of scope for the routing fix; see test_airconditioner_cac.py for + the documented gap.""" from tests.conftest import _load_device resources = _load_device("airconditioner_cac") From e2dcc75ed5c61e86b7ba7816ea75cb98796f283f Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 7 Aug 2026 14:50:24 +0000 Subject: [PATCH 6/9] Address Opus review findings on the device-support commits above - Fix a real bug: airconditioner.SOUND_MODE had no exists_fn, so on boards (issue #319's FAC) that never report a live 'mode' value, entity.py's default field-presence gate silently kept the select from ever registering in HA -- while adapter.flatten() (what the golden/tests read) has no such gate, so the tests passed while documenting behavior the opposite of what shipped. Gate on supportedModes' presence instead. - Add airconditioner.MDS_ABSENCE_CLEAN for the CAC-class board's /mds/absenceclean/vs/0 -- byte-identical shape to issue #319's /csi/absenceclean/vs/0, confirmed rather than guessed, closing one more of that board's documented coverage-gap hrefs. - Add missing translation state labels (all 6 languages) for edge_lighting_mode/edge_lighting_color/indicator_light_mode's raw device codes, so they render as real words instead of a raw '3000K' -> '3000 K' fallback. - Fix an orphaned comment above SOUND_MODE that actually described the unrelated DISPLAY reuse, and correct two inaccurate rationale comments: the sound/voice ignore reason claimed a distinction from SOUND_MODE that this same dump contradicts, and the /csi/* ignore block's 'same reasoning as air_purifier.COVERAGE' precedent only actually covers 1 of its 5 hrefs. - Correct the false 'no board-token match' claim in the FAC test file and golden-regression docstring -- 'FAC' is a real _BOARD_TOKEN_TO_KEY entry (for_device_by_model alone already resolves this board); add a test that actually exercises that path, which nothing previously did despite the docstring's claim. - Drop a tautological burner-slot test that only re-asserted what the golden regression test already covers via the same code path. --- .../registry/by_type/airconditioner.py | 1 + .../registry/capabilities/airconditioner.py | 66 ++++++++++++++----- .../localthings/translations/cs.json | 21 +++++- .../localthings/translations/en.json | 21 +++++- .../localthings/translations/es.json | 21 +++++- .../localthings/translations/it.json | 21 +++++- .../localthings/translations/ko.json | 21 +++++- .../localthings/translations/nl.json | 21 +++++- tests/fixtures/golden/airconditioner_cac.json | 1 + ...st_airconditioner_ailp_fac_capabilities.py | 65 ++++++++++++++---- tests/test_airconditioner_cac.py | 27 ++++++-- .../test_gas_cooktop_tp2x_ks_capabilities.py | 17 ++--- tests/test_golden_regression.py | 15 +++-- 13 files changed, 250 insertions(+), 68 deletions(-) diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 0ca07c3..15f6ea0 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -54,6 +54,7 @@ REGISTRY = DeviceRegistry( air_purifier.SOUND_VOLUME, airconditioner.SOUND_MODE, airconditioner.ABSENCE_CLEAN, + airconditioner.MDS_ABSENCE_CLEAN, airconditioner.ENERGY_SAVING, airconditioner.EDGE_LIGHTING, airconditioner.LIGHT_STATEFUL, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 482f4e7..ae5f636 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -1167,13 +1167,11 @@ HUMIDITY = Capability( ), ) -# TP1X_DA-AC-FAC-class additions (issue #319): resources the CAC-class board -# (issue #191) also reports but left as a documented gap -- this dump gives -# the live supportedModes/mode shapes those needed. +# TP1X_DA-AC-FAC-class additions (issue #319): most of these hrefs are the +# same shapes air_purifier.py already models on the sibling TP1X_DA-AC-AIR +# board (DISPLAY/SOUND_OUTPUT/SOUND_VOLUME, reused directly in the +# registry); SOUND_MODE and the two below are genuinely new. -# Same {mode, supportedModes: [On, Off]} shape as air_purifier.DISPLAY on the -# sibling TP1X_DA-AC-AIR board; shares that capability rather than -# duplicating it. SOUND_MODE = Capability( href="/settings/sound/mode/vs/0", poll_tier="cold", @@ -1182,12 +1180,22 @@ SOUND_MODE = Capability( # vocabulary, so this shares that catalog entry -- but reads the # live supportedModes field rather than laundry's static tuple, # since this resource carries one (issue #319). + # + # exists_fn is required, not optional here: this board's rep never + # reports a live 'mode' value ({"supportedModes": [...]} only), and + # entity.py's default field-presence gate would otherwise keep the + # select from ever registering -- adapter.flatten() (what the + # golden/tests read) has no such gate, so it would look bound while + # silently absent from HA. Register on supportedModes' presence + # instead; current_option reads unknown until the device reports + # 'mode' live. SelectDesc( key="sound_mode", field="mode", icon="mdi:volume-high", entity_category="config", options_field="supportedModes", + exists_fn=lambda rep, resources: bool(rep.get("supportedModes")), write_fn=lambda p, rep, href=None: ( ["settings", "sound", "mode", "vs", "0"], {"mode": p}, @@ -1216,6 +1224,30 @@ ABSENCE_CLEAN = Capability( ), ) +# The CAC-class board (issue #191) reports the identical {mode, +# supportedModes: [On, Off]} shape under /mds/absenceclean/vs/0 instead -- +# confirmed against that board's own fixture, not guessed. Shares +# ABSENCE_CLEAN's key/translation: no dump has ever reported both hrefs +# together, so there's nothing for the two to collide over in +# adapter.flatten(). +MDS_ABSENCE_CLEAN = Capability( + href="/mds/absenceclean/vs/0", + poll_tier="cold", + entities=( + SwitchDesc( + key="absence_clean", + field="mode", + icon="mdi:broom", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["mds", "absenceclean", "vs", "0"], + {"mode": "On" if p == "On" else "Off"}, + ), + ), + ), +) + # Energy-saving schedule (issue #319). `mode` is a device-chosen preset # (e.g. Cooling_60/Off_180) with no confirmed unit for the trailing number # (minutes seen elsewhere on this board are unprefixed ints, not @@ -1415,19 +1447,23 @@ _AC_IGNORED = [ # registry.subdevices.enumerate_subdevices, hence the entry here rather # than a coverage gap. "/multidevice/vs/0", - # TP1X_DA-AC-FAC-class-only (issue #319), same reasoning as - # air_purifier.COVERAGE's duplicate of these hrefs -- scoped here rather - # than promoted to the global ignore list since it'd collide with - # families that do bind some of these. + # TP1X_DA-AC-FAC-class-only (issue #319) -- scoped here rather than + # promoted to the global ignore list since it'd collide with families + # that do bind some of these. Only /dnd/autosleep/vs/0 has a precedent + # (air_purifier.COVERAGE ignores the same href for the same reason); + # the rest are new, each with its own reason below. "/dnd/autosleep/vs/0", # every field its inert default; needs a schedule editor "/outdoorsharing/vs/0", # empty on this dump -- outdoor-unit sharing plumbing "/lifestyle/survey/vs/0", # {list: [""]} placeholder, nothing to expose - # supportedVoices list only -- no live selection field to read a current - # value from, unlike SOUND_MODE/SOUND_OUTPUT above. + # supportedVoices carries opaque numeric voice-pack IDs ("100"/"101") + # with no live current-selection field and, unlike SOUND_MODE's + # self-descriptive mute/tone/voice codes, no confirmed human-readable + # meaning to expose them under -- don't guess. "/settings/sound/voice/vs/0", - # rssi/wifiFrequency (network housekeeping) plus an unconfirmed - # 48-slot absenceInfo P/A history blob with no documented meaning -- - # don't guess what it encodes. + # rssi/wifiFrequency (network housekeeping); lastEnergySavingTime and + # cleaningStartTime are inert '1900-01-00' placeholders on this dump; + # absenceInfo is an unconfirmed 48-slot P/A history blob with no + # documented meaning -- don't guess what it encodes. "/csi/information/vs/0", ] diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 6853ed7..b8779b4 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -670,13 +670,28 @@ "name": "Teplota mrazicí zóny" }, "edge_lighting_mode": { - "name": "Režim okrajového osvětlení" + "name": "Režim okrajového osvětlení", + "state": { + "smart": "Chytrý", + "high": "Vysoký", + "low": "Nízký" + } }, "edge_lighting_color": { - "name": "Barva okrajového osvětlení" + "name": "Barva okrajového osvětlení", + "state": { + "3000k": "3000 K", + "4000k": "4000 K", + "6500k": "6500 K" + } }, "indicator_light_mode": { - "name": "Režim kontrolky" + "name": "Režim kontrolky", + "state": { + "smart": "Chytrý", + "high": "Vysoký", + "low": "Nízký" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index b1f3e08..ced1466 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -670,13 +670,28 @@ "name": "Freezer temperature" }, "edge_lighting_mode": { - "name": "Edge lighting mode" + "name": "Edge lighting mode", + "state": { + "smart": "Smart", + "high": "High", + "low": "Low" + } }, "edge_lighting_color": { - "name": "Edge lighting color" + "name": "Edge lighting color", + "state": { + "3000k": "3000 K", + "4000k": "4000 K", + "6500k": "6500 K" + } }, "indicator_light_mode": { - "name": "Indicator light mode" + "name": "Indicator light mode", + "state": { + "smart": "Smart", + "high": "High", + "low": "Low" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 84a86ae..6b6d579 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -795,13 +795,28 @@ "name": "Temperatura del congelador" }, "edge_lighting_mode": { - "name": "Modo de iluminación perimetral" + "name": "Modo de iluminación perimetral", + "state": { + "smart": "Inteligente", + "high": "Alto", + "low": "Bajo" + } }, "edge_lighting_color": { - "name": "Color de iluminación perimetral" + "name": "Color de iluminación perimetral", + "state": { + "3000k": "3000 K", + "4000k": "4000 K", + "6500k": "6500 K" + } }, "indicator_light_mode": { - "name": "Modo de luz indicadora" + "name": "Modo de luz indicadora", + "state": { + "smart": "Inteligente", + "high": "Alto", + "low": "Bajo" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index df2c7b8..d8bb417 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -670,13 +670,28 @@ "name": "Temperatura freezer" }, "edge_lighting_mode": { - "name": "Modalità illuminazione perimetrale" + "name": "Modalità illuminazione perimetrale", + "state": { + "smart": "Smart", + "high": "Alta", + "low": "Bassa" + } }, "edge_lighting_color": { - "name": "Colore illuminazione perimetrale" + "name": "Colore illuminazione perimetrale", + "state": { + "3000k": "3000 K", + "4000k": "4000 K", + "6500k": "6500 K" + } }, "indicator_light_mode": { - "name": "Modalità spia luminosa" + "name": "Modalità spia luminosa", + "state": { + "smart": "Smart", + "high": "Alta", + "low": "Bassa" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index c018827..15456a7 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -670,13 +670,28 @@ "name": "냉동실 온도" }, "edge_lighting_mode": { - "name": "엣지 라이팅 모드" + "name": "엣지 라이팅 모드", + "state": { + "smart": "스마트", + "high": "높음", + "low": "낮음" + } }, "edge_lighting_color": { - "name": "엣지 라이팅 색상" + "name": "엣지 라이팅 색상", + "state": { + "3000k": "3000 K", + "4000k": "4000 K", + "6500k": "6500 K" + } }, "indicator_light_mode": { - "name": "표시등 모드" + "name": "표시등 모드", + "state": { + "smart": "스마트", + "high": "높음", + "low": "낮음" + } } }, "sensor": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 8bb5965..c7703d9 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -670,13 +670,28 @@ "name": "Temperatuur vriesgedeelte" }, "edge_lighting_mode": { - "name": "Randverlichtingsmodus" + "name": "Randverlichtingsmodus", + "state": { + "smart": "Slim", + "high": "Hoog", + "low": "Laag" + } }, "edge_lighting_color": { - "name": "Randverlichtingskleur" + "name": "Randverlichtingskleur", + "state": { + "3000k": "3000 K", + "4000k": "4000 K", + "6500k": "6500 K" + } }, "indicator_light_mode": { - "name": "Indicatorlampjemodus" + "name": "Indicatorlampjemodus", + "state": { + "smart": "Slim", + "high": "Hoog", + "low": "Laag" + } } }, "sensor": { diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json index 2426c0e..754794b 100644 --- a/tests/fixtures/golden/airconditioner_cac.json +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -1,5 +1,6 @@ { "state_keys": [ + "absence_clean", "absence_power_saving_active", "absence_power_saving_mode", "air_filter_pm1_status", diff --git a/tests/test_airconditioner_ailp_fac_capabilities.py b/tests/test_airconditioner_ailp_fac_capabilities.py index b98869b..5af0f20 100644 --- a/tests/test_airconditioner_ailp_fac_capabilities.py +++ b/tests/test_airconditioner_ailp_fac_capabilities.py @@ -1,21 +1,42 @@ """Tests for the AILP_DA-AC-FAC-02011_0000 air conditioner (issue #319). -This board has no `_BOARD_TOKEN_TO_KEY` entry (routes via `/oic/d`'s -`oic.d.airconditioner` type only) and reports several resources the sibling -TP1X_DA-AC-CAC-01001 board (issue #191) left as a documented gap: -/display/vs/0 and the /settings/sound/* trio (now shared with -air_purifier.py's identical shapes), plus two genuinely new hrefs -(/csi/absenceclean/vs/0, /csi/energysaving/vs/0). +This board resolves to the airconditioner registry two independent ways -- +modelNum's 'FAC' board token (for_device_by_model) and /oic/d's +oic.d.airconditioner type (for_device_by_oic_type) agree -- and reports +several resources the sibling TP1X_DA-AC-CAC-01001 board (issue #191) left +as a documented gap: /display/vs/0 and the /settings/sound/* trio (now +shared with air_purifier.py's identical shapes), plus two genuinely new +hrefs (/csi/absenceclean/vs/0, /csi/energysaving/vs/0). """ +from typing import cast + +from custom_components.localthings.coordinator import LocalThingsCoordinator +from custom_components.localthings.entity import _is_included from custom_components.localthings.registry.adapter import flatten -from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve +from custom_components.localthings.registry.by_type import ( + for_device_by_model, + for_device_by_oic_type, + resolve, +) from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device _DEVICE_TYPES = ("oic.wk.d", "oic.d.airconditioner") +class _FakeCoordinator: + """Minimal stand-in for entity._is_included's coordinator dependency -- + same shape as test_entity.py's own fake, kept local rather than shared + across test modules.""" + + def __init__(self, last_resources): + self.last_resources = last_resources + + def canonical_resources(self, subdevice): + return self.last_resources + + def _resources(): return _load_device("airconditioner_ailp_fac") @@ -31,6 +52,19 @@ def test_oic_device_type_resolves_to_airconditioner_registry(): assert reg is not None and reg.name == "airconditioner" +def test_board_token_resolves_to_airconditioner_registry(): + """'FAC' is a real _BOARD_TOKEN_TO_KEY entry -- for_device_by_model + alone (no device_types) already resolves this board correctly, same as + for_device_by_oic_type above; resolve()'s device_types-agreement isn't + covering an otherwise-unreachable path.""" + resources = _resources() + info = resources["/information/vs/0"] + reg = for_device_by_model( + info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"] + ) + assert reg is not None and reg.name == "airconditioner" + + def test_no_unbound_hrefs(): resources = _resources() reg = resolve(resources, device_types=_DEVICE_TYPES) @@ -39,15 +73,18 @@ def test_no_unbound_hrefs(): assert unbound == [] -def test_sound_mode_bound_with_live_supported_modes(): +def test_sound_mode_registers_despite_no_live_mode_value(): """This dump has no current `mode` value yet, only supportedModes - (mute/tone/voice) -- confirms the select is bound (present in state, - even if unknown) and its options come from the live field rather than - a static tuple (see laundry.SOUND_MODE's docstring for why that's - preferred whenever the resource carries one).""" + (mute/tone/voice). entity.py's default field-presence gate would + otherwise keep the select from ever being created in HA even though + adapter.flatten() (checked below) has no such gate and would look + bound regardless -- exercise the real _is_included gate, not just + flatten(), so a regression here can't hide behind that gap again.""" bound, resources = _bound() - desc = next(b.desc for b in bound if b.desc.key == "sound_mode") - assert desc.options_field == "supportedModes" + entity = next(b for b in bound if b.desc.key == "sound_mode") + assert entity.desc.options_field == "supportedModes" + coord = cast(LocalThingsCoordinator, _FakeCoordinator(resources)) + assert _is_included(entity, coord) is True state = flatten(bound, resources) assert "sound_mode" in state assert state["sound_mode"] is None diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py index 7aa0a64..ec0346f 100644 --- a/tests/test_airconditioner_cac.py +++ b/tests/test_airconditioner_cac.py @@ -6,11 +6,11 @@ token -- this board was the one exception (its oneUiVersion self-reports "7.0 Air conditioner", but 'CAC' had never been added to the board-token table), so it silently fell back to common caps and lost its climate entity. -This dump is NOT fully covered yet -- three hrefs remain unbound -(absence-clean, `/settings/sound/optimization/vs/0`, smart-sensing-cooling), -all genuinely new to this board generation. That's a real device-support -gap, left documented here rather than guessed at, per the 'don't guess' -rule -- fixing the routing regression was the scope of #191. +This dump is NOT fully covered yet -- two hrefs remain unbound +(`/settings/sound/optimization/vs/0`, smart-sensing-cooling), both +genuinely new to this board generation. That's a real device-support gap, +left documented here rather than guessed at, per the 'don't guess' rule -- +fixing the routing regression was the scope of #191. /settings/sound/mode/vs/0, /settings/sound/output/vs/0 and /settings/sound/volume/vs/0 used to be on this list too, until issue #319 @@ -23,6 +23,11 @@ until issue #288 (six System A/C cassette units on this same board) gave real dump evidence for both -- airconditioner.EDGE_LIGHTING and LIGHT_STATEFUL now cover them. +/mds/absenceclean/vs/0 used to be on this list too -- its {mode, +supportedModes: [On, Off]} shape is byte-identical to issue #319's +/csi/absenceclean/vs/0, confirmed on that sibling board rather than +guessed, so airconditioner.MDS_ABSENCE_CLEAN now covers it too. + /uvled/vs/0 and /filter/airdustPM1filter/vs/0 used to be on this list too, 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 @@ -37,7 +42,6 @@ from tests.conftest import _load_device _STILL_UNBOUND = frozenset( { - "/mds/absenceclean/vs/0", "/settings/sound/optimization/vs/0", "/smartsensingcooling/vs/0", } @@ -70,6 +74,17 @@ def test_documented_coverage_gap_is_exactly_this_set(): assert set(unbound) == _STILL_UNBOUND +def test_mds_absenceclean_shares_csi_absenceclean_key(): + """/mds/absenceclean/vs/0's mode=='Off' on this dump -- confirms + MDS_ABSENCE_CLEAN actually binds (not just that the href stops + reporting as unbound).""" + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state["absence_clean"] is False + + 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_gas_cooktop_tp2x_ks_capabilities.py b/tests/test_gas_cooktop_tp2x_ks_capabilities.py index 7677618..340675e 100644 --- a/tests/test_gas_cooktop_tp2x_ks_capabilities.py +++ b/tests/test_gas_cooktop_tp2x_ks_capabilities.py @@ -45,11 +45,12 @@ def test_alarm_code_and_child_lock_bound(): assert state["child_lock"] is True -def test_advertises_six_burner_slots_only_three_of_which_are_real(): - """Not a bug to fix -- the board's own /mode/vs/0 options genuinely - list six OperationState slots (issue #314); the reporter's physical - cooktop only has three. Locks in the current, documented behavior.""" - bound, resources = _bound() - state = flatten(bound, resources) - for i in range(6): - assert f"burner_{i}_state" in state +# The reporter's original complaint -- six advertised burner slots when +# only three are physically present -- isn't tested here beyond what the +# golden regression test already locks in (burner_0_state..burner_5_state +# all present). It's expected, not a bug: the registry declares a generous +# static superset of slots per cooktop.py's own comment, and this board's +# /mode/vs/0 rep carries no field distinguishing a real slot from an +# advertised-but-nonexistent one for a test to assert against -- the user +# disabling the three extra entities is the correct fix, not something the +# integration can filter automatically. diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index dd6932d..1d453f0 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -135,9 +135,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner(): def test_registry_reproduces_golden_state_keys_for_airconditioner_ailp_fac(): - """AILP_DA-AC-FAC-02011_0000 (issue #319) has no board-token match -- - resolves purely via /oic/d's oic.d.airconditioner type, exercising the - device_types-only path through resolve().""" + """AILP_DA-AC-FAC-02011_0000 (issue #319) resolves both ways -- + modelNum's 'FAC' board token and /oic/d's oic.d.airconditioner type + independently agree on the airconditioner registry. Passes + device_types through resolve() to exercise that agreement, not because + the board token is absent.""" from tests.conftest import _load_device resources = _load_device("airconditioner_ailp_fac") @@ -1173,10 +1175,9 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac(): 0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never been added to the modelNum board-token table. Resolved via the new 'CAC' token onto the existing airconditioner registry. Not fully covered yet -- - three hrefs remain unbound (absence-clean, sound-optimization, - smart-sensing-cooling), all genuinely new to this board generation and - out of scope for the routing fix; see test_airconditioner_cac.py for - the documented gap.""" + two hrefs remain unbound (sound-optimization, smart-sensing-cooling), + both genuinely new to this board generation and out of scope for the + routing fix; see test_airconditioner_cac.py for the documented gap.""" from tests.conftest import _load_device resources = _load_device("airconditioner_cac") From 8551974719fbc74a67b36ef093fa0a61089d6e48 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 7 Aug 2026 15:52:46 +0000 Subject: [PATCH 7/9] Fix ty type-check failures in new test files resolve()/for_device_by_model() return DeviceRegistry | None; four new test files used reg.capabilities/reg.pattern_capabilities without narrowing away None first. Add the same 'assert reg is not None' idiom test_dehumidifier_tp1x_dhm01001_capabilities.py already uses. Verified against a clean venv running the exact CI commands (ruff format --check, ruff check, ty check, pytest) rather than trusting a stale local venv that had picked up a mismatched python3.11/3.13 site-packages split. --- tests/test_airconditioner_ailp_fac_capabilities.py | 2 ++ tests/test_gas_cooktop_tp2x_ks_capabilities.py | 2 ++ tests/test_oven_tp2x_ks_walloven_capabilities.py | 2 ++ tests/test_refrigerator_tp1x_ref_21k_airfilter_capabilities.py | 2 ++ 4 files changed, 8 insertions(+) diff --git a/tests/test_airconditioner_ailp_fac_capabilities.py b/tests/test_airconditioner_ailp_fac_capabilities.py index 5af0f20..9316c8f 100644 --- a/tests/test_airconditioner_ailp_fac_capabilities.py +++ b/tests/test_airconditioner_ailp_fac_capabilities.py @@ -44,6 +44,7 @@ def _resources(): def _bound(): resources = _resources() reg = resolve(resources, device_types=_DEVICE_TYPES) + assert reg is not None return discover(resources, reg.capabilities, reg.pattern_capabilities), resources @@ -68,6 +69,7 @@ def test_board_token_resolves_to_airconditioner_registry(): def test_no_unbound_hrefs(): resources = _resources() reg = resolve(resources, device_types=_DEVICE_TYPES) + assert reg is not None unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] diff --git a/tests/test_gas_cooktop_tp2x_ks_capabilities.py b/tests/test_gas_cooktop_tp2x_ks_capabilities.py index 340675e..695f303 100644 --- a/tests/test_gas_cooktop_tp2x_ks_capabilities.py +++ b/tests/test_gas_cooktop_tp2x_ks_capabilities.py @@ -19,6 +19,7 @@ from tests.conftest import _load_device def _bound(): resources = _load_device("gas_cooktop_tp2x_ks") reg = resolve(resources) + assert reg is not None return discover(resources, reg.capabilities, reg.pattern_capabilities), resources @@ -31,6 +32,7 @@ def test_resolves_to_gas_cooktop_registry(): def test_no_unbound_hrefs(): _, resources = _bound() reg = resolve(resources) + assert reg is not None unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] diff --git a/tests/test_oven_tp2x_ks_walloven_capabilities.py b/tests/test_oven_tp2x_ks_walloven_capabilities.py index 016e363..62eecc4 100644 --- a/tests/test_oven_tp2x_ks_walloven_capabilities.py +++ b/tests/test_oven_tp2x_ks_walloven_capabilities.py @@ -20,6 +20,7 @@ _DEVICE_TYPES = ("oic.wk.d", "oic.d.oven") def _bound(): resources = _load_device("oven_tp2x_ks_walloven") reg = resolve(resources, device_types=_DEVICE_TYPES) + assert reg is not None return discover(resources, reg.capabilities, reg.pattern_capabilities), resources @@ -31,6 +32,7 @@ def test_oic_device_type_resolves_to_oven_registry(): def test_no_unbound_hrefs(): resources = _load_device("oven_tp2x_ks_walloven") reg = resolve(resources, device_types=_DEVICE_TYPES) + assert reg is not None unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] diff --git a/tests/test_refrigerator_tp1x_ref_21k_airfilter_capabilities.py b/tests/test_refrigerator_tp1x_ref_21k_airfilter_capabilities.py index 4a05fe2..cf5867e 100644 --- a/tests/test_refrigerator_tp1x_ref_21k_airfilter_capabilities.py +++ b/tests/test_refrigerator_tp1x_ref_21k_airfilter_capabilities.py @@ -15,6 +15,7 @@ from tests.conftest import _load_device def _bound(): resources = _load_device("refrigerator_tp1x_ref_21k_airfilter") reg = resolve(resources) + assert reg is not None return discover(resources, reg.capabilities, reg.pattern_capabilities), resources @@ -22,6 +23,7 @@ def test_no_unbound_hrefs(): _, resources = _bound() unbound = [] reg = resolve(resources) + assert reg is not None discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] From a7dc1db8ffc09970b4cf35a9a262f8b9781d6324 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 7 Aug 2026 16:38:09 +0000 Subject: [PATCH 8/9] Extract usable parts of PR #316 (System Fresh Air Ventilator support) PR #316 (fork stale by several months, most of its ~2200-line diff was drift against main rather than real changes) proposed device support for the Samsung System Fresh Air Ventilator (ACA-KR-TP2-21-AN9000). Extracted what holds up, adapted to this project's conventions, and left out what doesn't: Extracted: - ventilation_mode select on CLIMATE's own href, gated via _is_ventilation_mode_device so it can only ever bind on a device whose entire supportedModes set is Purification/Ventilation/SmartVentilation -- verified against every real AC fixture in the corpus to confirm it can't false-positive on an actual air conditioner's climate card. - WINDFREE / WINDSLEEP switches on their own dedicated hrefs. - A CO2 sensor on AIR_QUALITY, matching air_monitor.SENSORS' already-bound device_class='carbon_dioxide'/unit='ppm' descriptor for the same field shape rather than guessing fresh. - HEPA_FILTER / DEVICE_ACTIVE reuse from air_purifier.py. - Removing /airlevelcheck/vs/0 from _AC_IGNORED and binding air_purifier.AIR_LEVEL_CHECK in its place: the PR's claim that this project's old "scheduler plumbing" description was wrong turned out to be independently verifiable against two of our own existing fixtures (airconditioner_cac and airconditioner_tp1x_da_ac_rac_01011 both already carry real, populated periodicSensingActivationState/autoExeState values), so this benefits existing users, not just the one new device. Left out: - Unit/device_class ('ug/m3', pm10/pm25/pm1) on the existing dust/ fine_dust/super_fine_dust sensors, sourced from an unverified third-party screenshot description. air_monitor.py already has an explicit, reasoned rejection of this exact mapping for the exact same three fields: Samsung's PM10/PM2.5 convention doesn't confirm where a third tier or a PM1 reading fits, and a wrong guess mislabels the reading forever. - A standalone common.POWER switch -- contradicts this registry's own documented design (power is deliberately the climate entity's job) and would affect every AC user, not just this device. - Promoting wind/swing to independent selects for every AC user -- a UX opinion, not a coverage necessity, and out of scope for this device's own support. - A model-name diagnostic sensor -- /information/vs/0 is already covered via the global ignore list, so this wasn't closing an actual gap. No raw diagnostics dump for this model was ever attached to PR #316, so there's no fixture for it here (fabricating one would violate this project's fixture-integrity rule) -- see tests/test_airconditioner_ventilation_windfree.py's module docstring. --- .../registry/by_type/airconditioner.py | 10 ++ .../registry/capabilities/airconditioner.py | 102 +++++++++++++++- .../localthings/translations/cs.json | 14 +++ .../localthings/translations/en.json | 14 +++ .../localthings/translations/es.json | 14 +++ .../localthings/translations/it.json | 14 +++ .../localthings/translations/ko.json | 14 +++ .../localthings/translations/nl.json | 14 +++ tests/fixtures/golden/airconditioner_cac.json | 9 ++ .../airconditioner_tp1x_da_ac_rac_01011.json | 9 ++ tests/test_airconditioner_cac.py | 22 ++++ tests/test_airconditioner_capabilities.py | 13 ++ ...est_airconditioner_ventilation_windfree.py | 113 ++++++++++++++++++ 13 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 tests/test_airconditioner_ventilation_windfree.py 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 From ce60b6967baf675ec9af6562644d46faea7638eb Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 7 Aug 2026 17:05:39 +0000 Subject: [PATCH 9/9] Clarify why windfree/windsleep are plain switches, not climate presets Same feature name as the WindFree already modeled via climate.py's preset system on regular AC boards, but a genuinely different wire mechanism -- this device's fields live on their own dedicated hrefs with no evidenced coupling to hvac_mode, unlike the Comode_Nano token's real gating rules on legacy boards. Recorded in-line so this doesn't come up as a 'why isn't this a preset' question again without the answer already being there. --- .../localthings/registry/capabilities/airconditioner.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index db0b69a..0f0d32b 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -1402,6 +1402,15 @@ LIGHT_STATEFUL = Capability( # 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. +# +# NOT the same WindFree already modeled elsewhere: on regular AC boards, +# WindFree is a `Comode_Nano` token inside /mode/vs/0's options[], surfaced +# as a climate preset (climate.py's _LEGACY_PRESET_CODES/preset_mode) with +# real coupling to hvac_mode (disabled in Heat/AIComfort/Auto, timing rules +# on legacy boards). This device's windfree/windsleep are bare booleans on +# their own hrefs with no such coupling evidenced -- same feature name, +# different wire mechanism, so plain switches rather than folding into +# climate.py's preset machinery. WINDFREE = Capability( href="/modeoption/windfree/vs/0", poll_tier="warm",