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")