From cf09247e3917962d4fac812e6c4a046d87e93ad0 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 3 Aug 2026 19:07:03 +0000 Subject: [PATCH] Add AC UV LED, ventilation alarm, and PM1 filter support (issue #270) TP1X_FAC_TIME_23K reports three previously unbound hrefs: a UV-C sterilization LED and a ventilation-reminder alarm (both plain On/Off toggles), and a second PM1-rated dust filter with no live usage/status fields on this particular dump. The PM1 filter capability gates each entity on its own field's presence rather than a blanket ignore, since the TP1X_DA-AC-CAC-01001_0000 cassette AC (issue #191) reports the same href with full live data -- this also closes two of that device's ten documented coverage gaps (UV LED and the PM1 filter) as a side effect. --- .../registry/by_type/airconditioner.py | 3 + .../registry/capabilities/airconditioner.py | 119 ++++ .../localthings/translations/cs.json | 15 + .../localthings/translations/en.json | 15 + .../localthings/translations/es.json | 15 + .../localthings/translations/it.json | 15 + .../localthings/translations/nl.json | 15 + ...rconditioner_tp1x_fac_time_23k_device.json | 641 ++++++++++++++++++ tests/fixtures/golden/airconditioner_cac.json | 6 +- .../airconditioner_tp1x_fac_time_23k.json | 33 + tests/test_airconditioner_cac.py | 20 +- .../test_airconditioner_tp1x_fac_time_23k.py | 83 +++ tests/test_golden_regression.py | 20 + 13 files changed, 991 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/airconditioner_tp1x_fac_time_23k_device.json create mode 100644 tests/fixtures/golden/airconditioner_tp1x_fac_time_23k.json create mode 100644 tests/test_airconditioner_tp1x_fac_time_23k.py diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 5a84e16..e76e193 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -33,8 +33,11 @@ REGISTRY = DeviceRegistry( airconditioner.AIR_PURIFY, airconditioner.AUTO_CLEAN, airconditioner.AIR_FILTER, + airconditioner.AIR_FILTER_PM1, airconditioner.AIR_QUALITY, airconditioner.DISPLAY_LIGHT, + airconditioner.UV_LED, + airconditioner.VENTILATION_ALARM, airconditioner.MUTE_ONCE, airconditioner.CURRENT_LIMIT, airconditioner.ANOMALY_LOAD, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 3da18bd..d0de28d 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -909,6 +909,80 @@ AIR_FILTER = Capability( ), ) + +def _pm1_threshold_write(payload, rep, href=None): + """Same contract as _threshold_write, against this filter's own href -- + not yet confirmed live (no dump seen advertises + supportedFilterDesiredUsage for this href), so the Select this backs + stays gated behind that field's presence, same as AIR_FILTER's.""" + return ["filter", "airdustPM1filter", "vs", "0"], { + "x.com.samsung.da.filterDesiredUsage": payload, + } + + +def _has_filter_field(field): + return lambda rep, resources: rep.get(field) is not None + + +# Second, PM1-rated dust filter some TP1X_FAC-class boards report alongside +# AIR_FILTER's /filter/airdustfilter/vs/0. TP1X_FAC_TIME_23K (issue #270) +# reports only filterCapacity/filterCapacityUnit/filterResetType +# ('notresetable') on this href, with no live filterUsage/filterStatus at +# all -- but the TP1X_DA-AC-CAC-01001_0000 cassette AC (issue #191) reports +# the identical href with full live fields. So this stays a real capability +# rather than a blanket ignore, with every entity individually gated on its +# own field's presence per the 'don't guess' rule: no entity on a board that +# has nothing behind it, real readings on one that does. +AIR_FILTER_PM1 = Capability( + href="/filter/airdustPM1filter/vs/0", + poll_tier="cold", + entities=( + SensorDesc( + key="air_filter_pm1_usage", + rep_fn=filter_usage_percent, + unit="%", + state_class="measurement", + icon="mdi:air-filter", + entity_category="diagnostic", + exists_fn=_has_filter_field("x.com.samsung.da.filterUsage"), + ), + SensorDesc( + key="air_filter_pm1_usage_hours", + field="x.com.samsung.da.filterUsage", + device_class="duration", + state_class="total_increasing", + unit_fn=_filter_unit, + icon="mdi:air-filter", + entity_category="diagnostic", + value_fn=_int, + exists_fn=_has_filter_field("x.com.samsung.da.filterUsage"), + ), + SelectDesc( + key="air_filter_pm1_threshold", + field="x.com.samsung.da.filterDesiredUsage", + options_field="x.com.samsung.da.supportedFilterDesiredUsage", + exists_fn=lambda rep, res: bool( + rep.get("x.com.samsung.da.supportedFilterDesiredUsage") + ), + icon="mdi:alarm", + entity_category="config", + write_fn=_pm1_threshold_write, + value_fn=lambda v: str(v) if v is not None else None, + ), + SensorDesc( + key="air_filter_pm1_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, + exists_fn=_has_filter_field("x.com.samsung.da.filterStatus"), + ), + ), +) + DISPLAY_LIGHT = Capability( href="/light/vs/0", poll_tier="cold", @@ -927,6 +1001,51 @@ DISPLAY_LIGHT = Capability( ), ) +# UV-C sterilization LED (issue #270, TP1X_FAC_TIME_23K). Same On/Off +# convention as everything else on this board, and (unlike VENTILATION_ALARM +# below) a real supportedModes list confirms the value set. +UV_LED = Capability( + href="/uvled/vs/0", + poll_tier="cold", + entities=( + SwitchDesc( + key="uv_led", + field="x.com.samsung.da.modes", + icon="mdi:lightbulb-fluorescent-tube", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["uvled", "vs", "0"], + {"x.com.samsung.da.modes": "On" if p == "On" else "Off"}, + ), + ), + ), +) + +# Ventilation-reminder alarm toggle (issue #270). Only a bare `alarm` field +# is present -- no supportedModes list to confirm the value set against, +# unlike UV_LED above. Modeled as a switch on the strength of the same +# On/Off convention used everywhere else in this API; a rejected write is +# the worst case for a wrong guess (see the adding-device-support skill), +# but this hasn't been round-trip confirmed on real hardware. +VENTILATION_ALARM = Capability( + href="/ventilation/setting/vs/0", + poll_tier="cold", + entities=( + SwitchDesc( + key="ventilation_alarm", + field="alarm", + icon="mdi:bell-alert", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["ventilation", "setting", "vs", "0"], + {"alarm": "On" if p == "On" else "Off"}, + ), + ), + ), +) + # Confirmed against issue #38's dump (TP1X_DA-AC-RAC-01001_0000): a single # boolean field, no vendor prefix, mirroring the On/Off convention used # throughout the rest of this API. diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 95de516..1b3b139 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -637,6 +637,9 @@ }, "watertank_light_brightness": { "name": "Jas osvětlení nádržky na vodu" + }, + "air_filter_pm1_threshold": { + "name": "Práh alarmu PM1 filtru" } }, "sensor": { @@ -988,6 +991,12 @@ }, "watertank_full_alarm_status": { "name": "Stav alarmu plné nádržky na vodu" + }, + "air_filter_pm1_usage": { + "name": "Využití PM1 filtru" + }, + "air_filter_pm1_usage_hours": { + "name": "Hodiny využití PM1 filtru" } }, "switch": { @@ -1158,6 +1167,12 @@ }, "watertank_light": { "name": "Osvětlení nádržky na vodu" + }, + "uv_led": { + "name": "UV LED" + }, + "ventilation_alarm": { + "name": "Alarm větrání" } }, "time": { diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 8495f04..4f85217 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -637,6 +637,9 @@ }, "watertank_light_brightness": { "name": "Water tank light brightness" + }, + "air_filter_pm1_threshold": { + "name": "PM1 filter alarm threshold" } }, "sensor": { @@ -988,6 +991,12 @@ }, "watertank_full_alarm_status": { "name": "Water tank full alarm status" + }, + "air_filter_pm1_usage": { + "name": "PM1 filter usage" + }, + "air_filter_pm1_usage_hours": { + "name": "PM1 filter usage hours" } }, "switch": { @@ -1158,6 +1167,12 @@ }, "watertank_light": { "name": "Water tank light" + }, + "uv_led": { + "name": "UV LED" + }, + "ventilation_alarm": { + "name": "Ventilation alarm" } }, "time": { diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index 30493b4..7a053a7 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -751,6 +751,9 @@ }, "watertank_light_brightness": { "name": "Brillo de la luz del depósito de agua" + }, + "air_filter_pm1_threshold": { + "name": "Umbral de alarma del filtro PM1" } }, "sensor": { @@ -1102,6 +1105,12 @@ }, "watertank_full_alarm_status": { "name": "Estado de alarma de depósito de agua lleno" + }, + "air_filter_pm1_usage": { + "name": "Uso del filtro PM1" + }, + "air_filter_pm1_usage_hours": { + "name": "Horas de uso del filtro PM1" } }, "switch": { @@ -1272,6 +1281,12 @@ }, "watertank_light": { "name": "Luz del depósito de agua" + }, + "uv_led": { + "name": "LED UV" + }, + "ventilation_alarm": { + "name": "Alarma de ventilación" } }, "time": { diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 55f8bdf..cbedc3c 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -637,6 +637,9 @@ }, "watertank_light_brightness": { "name": "Luminosità della luce del serbatoio dell'acqua" + }, + "air_filter_pm1_threshold": { + "name": "Soglia di allarme del filtro PM1" } }, "sensor": { @@ -988,6 +991,12 @@ }, "watertank_full_alarm_status": { "name": "Stato allarme serbatoio d'acqua pieno" + }, + "air_filter_pm1_usage": { + "name": "Utilizzo del filtro PM1" + }, + "air_filter_pm1_usage_hours": { + "name": "Ore di utilizzo del filtro PM1" } }, "switch": { @@ -1158,6 +1167,12 @@ }, "watertank_light": { "name": "Luce del serbatoio dell'acqua" + }, + "uv_led": { + "name": "LED UV" + }, + "ventilation_alarm": { + "name": "Allarme ventilazione" } }, "time": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index f25be37..eac94b7 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -637,6 +637,9 @@ }, "watertank_light_brightness": { "name": "Helderheid van het waterreservoirlicht" + }, + "air_filter_pm1_threshold": { + "name": "PM1-filteralarmdrempel" } }, "sensor": { @@ -988,6 +991,12 @@ }, "watertank_full_alarm_status": { "name": "Status alarm waterreservoir vol" + }, + "air_filter_pm1_usage": { + "name": "PM1-filtergebruik" + }, + "air_filter_pm1_usage_hours": { + "name": "PM1-filtergebruik in uren" } }, "switch": { @@ -1158,6 +1167,12 @@ }, "watertank_light": { "name": "Waterreservoirlicht" + }, + "uv_led": { + "name": "Uv-led" + }, + "ventilation_alarm": { + "name": "Ventilatiealarm" } }, "time": { diff --git a/tests/fixtures/airconditioner_tp1x_fac_time_23k_device.json b/tests/fixtures/airconditioner_tp1x_fac_time_23k_device.json new file mode 100644 index 0000000..dad24a2 --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_fac_time_23k_device.json @@ -0,0 +1,641 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "224", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "224", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "112", + "224", + "336", + "448" + ] + } + }, + { + "href": "/filter/airdustPM1filter/vs/0", + "rep": { + "x.com.samsung.da.filterCapacity": "3", + "x.com.samsung.da.filterCapacityUnit": "Month", + "x.com.samsung.da.filterResetType": [ + "notresetable" + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "0.5" + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Nano", + "NanoSleep" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "SpeedClean", + "QuietClean", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "SpeedClean", + "QuietClean", + "Off" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "12", + "x.com.samsung.da.supportedModes": [ + "0", + "11", + "12", + "13", + "14", + "15", + "16" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "1", + "2", + "3", + "4", + "5", + "Max" + ] + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "NotSupported", + "x.com.samsung.da.supportedModes": [ + "NotSupported" + ] + } + }, + { + "href": "/subdevices/vs/0", + "rep": { + "x.com.samsung.da.subdeviceIdList": "**REDACTED**" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "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": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "AC_V_0002_OFF", + "x.com.samsung.da.triggeredTime": "2026-08-03T14:14:25" + } + ] + } + }, + { + "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": "27.0", + "x.com.samsung.da.current": "28.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.increment": "0.5", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 28.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 27.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "400.000000", + "x.com.samsung.da.instantaneousPower": "14.000000", + "x.com.samsung.da.cumulativePower": "882861", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeDate": "1785798000", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "individual" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "energy": 400.0, + "power": 14.0 + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "ArtificialWorking_Off", + "welcomecare_Off", + "Volume_Mute", + "Operation_Family", + "ComfortAICooling_Off", + "OnTimer_0", + "OffTimer_0", + "Sleep_0", + "Booster_Off", + "OutdoorSilence_Off", + "AiTempChanged_Off", + "AiTemp_250", + "StopAutoClean_Idle", + "Weather_Off", + "Service_Off", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "OutDoorVentil_Off", + "InDoorVentil_Off", + "FreezeAlarmSetting_Off", + "DesiredFreezeAlarm_240", + "ProtectOperation_Off", + "EnergySaveIcon_Off", + "ExtendOptionCode_16975", + "OptionCode_2589", + "RacInfo_First", + "ModelInfo_16K_BORA_VENT2", + "RacInfo_None_Second", + "OutdoorConnection_Connected", + "DurationOn_0", + "UpdateAllow_NotAllowed", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "causeSource": "SMTS" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_FAC_TIME_23K|10250841|600007110017112BCE00CE2200937000", + "x.com.samsung.da.description": "TP1X_FAC_TIME_23K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "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": "AF2", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "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": "02517A260330", + "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": "02508A23020200,02509A24030701", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "02502A23011300,02299A10000300" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "LED", + "x.com.samsung.da.number": "02510A22121900,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "BLERemoteController", + "x.com.samsung.da.number": "22021760" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "1138000000", + "x.com.samsung.da.airconOptionList": [ + "HOMECARE_WIZARD_V2", + "ENERGY_2.0", + "AI_2.0", + "DeviceTypeMaster", + "CLEAN_OPTION_SUPPORT", + "DR", + "SingleCommand_1" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0.000000", + "x.com.samsung.da.fivepercentHumidity": "71" + } + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "durationminutes": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/runn/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0 + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000B4012C0000000D04000000", + "x.com.samsung.da.id": "FAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/uvled/vs/0", + "rep": { + "x.com.samsung.da.modes": "On", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "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": "/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": "/remotedeviceinfo/vs/0", + "rep": { + "x.com.samsung.da.didList": "" + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "AFA-WW-TP1-23-AFXX00", + "versions": [ + "10260330" + ], + "visVersion": "260330" + }, + { + "type": "Micom", + "modelId": "845110250241FFFFFFFF", + "versions": [ + "23011300", + "FFFFFFFF" + ], + "visVersion": "230113" + }, + { + "type": "Micom", + "modelId": "04511025084110250941", + "versions": [ + "23020200", + "24030701" + ], + "visVersion": "240307" + }, + { + "type": "Micom", + "modelId": "04511025024110229941", + "versions": [ + "23011300", + "10000300" + ], + "visVersion": "230113" + }, + { + "type": "Micom", + "modelId": "045110240441FFFFFFFF", + "versions": [ + "23121900", + "FFFFFFFF" + ], + "visVersion": "231219" + }, + { + "type": "Micom", + "modelId": "045110251041FFFFFFFF", + "versions": [ + "22121900", + "FFFFFFFF" + ], + "visVersion": "221219" + } + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "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": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1DFFFFFFFF121E121EFFFFFFFFFFFF01000000013F0100013F000000001C0C", + "x.com.samsung.da.id": "FAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/ventilation/setting/vs/0", + "rep": { + "alarm": "Off" + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "Off", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "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": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "**REDACTED**" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json index c00c271..30ee055 100644 --- a/tests/fixtures/golden/airconditioner_cac.json +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -2,6 +2,9 @@ "state_keys": [ "absence_power_saving_active", "absence_power_saving_mode", + "air_filter_pm1_status", + "air_filter_pm1_usage", + "air_filter_pm1_usage_hours", "air_filter_status", "air_filter_usage", "air_filter_usage_hours", @@ -29,6 +32,7 @@ "selfcheck_result", "selfcheck_status", "super_fine_dust", - "tropical_night_mode" + "tropical_night_mode", + "uv_led" ] } diff --git a/tests/fixtures/golden/airconditioner_tp1x_fac_time_23k.json b/tests/fixtures/golden/airconditioner_tp1x_fac_time_23k.json new file mode 100644 index 0000000..a61e80e --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_fac_time_23k.json @@ -0,0 +1,33 @@ +{ + "state_keys": [ + "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", + "diagnosis_status", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "odor_controller_active", + "odor_controller_progress", + "power_energy_kwh", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "tropical_night_mode", + "uv_led", + "ventilation_alarm" + ] +} diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py index e1e5bc7..99e1c27 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 -- ten hrefs remain unbound (edge -lighting, PM1 filter, a second stateful light resource, absence-clean, four -`/settings/sound/*` resources, smart-sensing-cooling, UV LED), 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 -- 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. + +/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 +through the same exists_fn-gated entities #270's dump (which has neither +field) leaves empty. """ from custom_components.localthings.registry.adapter import flatten @@ -22,7 +28,6 @@ from tests.conftest import _load_device _STILL_UNBOUND = frozenset( { "/edgelighting/vs/0", - "/filter/airdustPM1filter/vs/0", "/light/stateful/vs/0", "/mds/absenceclean/vs/0", "/settings/sound/mode/vs/0", @@ -30,7 +35,6 @@ _STILL_UNBOUND = frozenset( "/settings/sound/output/vs/0", "/settings/sound/volume/vs/0", "/smartsensingcooling/vs/0", - "/uvled/vs/0", } ) diff --git a/tests/test_airconditioner_tp1x_fac_time_23k.py b/tests/test_airconditioner_tp1x_fac_time_23k.py new file mode 100644 index 0000000..2b677fb --- /dev/null +++ b/tests/test_airconditioner_tp1x_fac_time_23k.py @@ -0,0 +1,83 @@ +"""Tests for the TP1X_FAC_TIME_23K air conditioner (issue #270). + +A 2-in-1 system (x.com.samsung.da.numofsubdevice='2') whose UUID-prefixed +sibling materializes via the flat fallback (issue #205) -- unrelated to the +three capability gaps this fixture actually locks in (UV LED, ventilation +alarm, and the unmodeled PM1 filter), which live entirely on canonical +hrefs already exercised by the master's own resources. +""" + +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.discovery import discover +from custom_components.localthings.registry.entities import SwitchDesc +from tests.conftest import _load_device + + +def _airconditioner(): + resources = _load_device("airconditioner_tp1x_fac_time_23k") + info = resources["/information/vs/0"] + reg = for_device_by_model( + info["x.com.samsung.da.modelNum"], + info["x.com.samsung.da.description"], + ) + return reg, resources + + +def _bound(): + reg, resources = _airconditioner() + return discover(resources, reg.capabilities, reg.pattern_capabilities), resources + + +def _state(): + bound, resources = _bound() + return flatten(bound, resources) + + +def _desc(key): + bound, _ = _bound() + return next(b.desc for b in bound if b.desc.key == key) + + +def test_model_resolves_to_airconditioner_registry(): + reg, _ = _airconditioner() + assert reg is not None and reg.name == "airconditioner" + + +def test_no_unbound_hrefs(): + reg, resources = _airconditioner() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_uv_led_switch_reads_on_and_has_write_contract(): + state = _state() + assert state["uv_led"] is True + desc = _desc("uv_led") + assert isinstance(desc, SwitchDesc) + path, body = desc.write_fn("Off", {}) + assert path == ["uvled", "vs", "0"] + assert body == {"x.com.samsung.da.modes": "Off"} + + +def test_ventilation_alarm_switch_reads_off_and_has_write_contract(): + state = _state() + assert state["ventilation_alarm"] is False + desc = _desc("ventilation_alarm") + assert isinstance(desc, SwitchDesc) + path, body = desc.write_fn("On", {}) + assert path == ["ventilation", "setting", "vs", "0"] + assert body == {"alarm": "On"} + + +def test_pm1_filter_has_no_live_fields_so_no_state_is_reported(): + """This dump's /filter/airdustPM1filter/vs/0 has only + filterCapacity/filterCapacityUnit/filterResetType -- no filterUsage or + filterStatus -- so every AIR_FILTER_PM1 entity's exists_fn gates it out. + The href still covers (see test_no_unbound_hrefs above) without + fabricating a reading from nothing (the 'don't guess' rule); contrast + with test_airconditioner_cac.py, whose dump has live data on the same + href and does get real air_filter_pm1_* state.""" + state = _state() + assert not any(k.startswith("air_filter_pm1_") for k in state) diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 3877446..3181bb9 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -1243,6 +1243,26 @@ def test_registry_reproduces_golden_state_keys_for_dehumidifier_tp1x_dhm01001(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_fac_time_23k(): + """TP1X_FAC_TIME_23K (issue #270) reports a UV-C LED, a ventilation-alarm + toggle, and a second (PM1-rated, no live usage field) dust filter, none + previously modeled. Also a 2-in-1 system on this dump (numofsubdevice=2, + a UUID-prefixed sibling materializes via the flat fallback) -- the three + new/ignored hrefs are fixed on their canonical form so the sibling picks + them up too, per the adding-device-support skill's 'never index or + prefix a registry href' rule.""" + from tests.conftest import _load_device + + resources = _load_device("airconditioner_tp1x_fac_time_23k") + golden = json.loads((GOLDEN / "airconditioner_tp1x_fac_time_23k.json").read_text()) + state_keys = _new_state_keys("airconditioner_tp1x_fac_time_23k", 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_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump