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"