From bc9d4f4cf55ff877bb9b2f180f058b37e4fe4799 Mon Sep 17 00:00:00 2001 From: paddawan2002 <56457404+paddawan2002@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:26:01 +0200 Subject: [PATCH] Fix AC coverage for TP1X-class (oneUiVersion "7.0 Air conditioner") --- custom_components/localthings/climate.py | 45 +- .../registry/by_type/airconditioner.py | 1 + .../registry/capabilities/airconditioner.py | 38 +- ...nditioner_tp1x_da_ac_rac_01011_device.json | 595 ++++++++++++++++++ .../airconditioner_tp1x_da_ac_rac_01011.json | 14 + tests/test_airconditioner_capabilities.py | 55 +- tests/test_golden_regression.py | 18 + 7 files changed, 758 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json create mode 100644 tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 08dbf3a..34b1bc3 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -41,6 +41,7 @@ from .registry.capabilities.airconditioner import ( HREF_TEMP_CURRENT as TEMP_CURRENT_HREF, HREF_TEMP_DESIRED as TEMP_DESIRED_HREF, HREF_TEMP_CONTROL as TEMP_CONTROL_HREF, + HREF_TEMPS_VS as TEMPS_VS_HREF, HREF_WIND_STRENGTH as WIND_STRENGTH_HREF, HREF_WIND_DIRECTION as WIND_DIRECTION_HREF, HREF_CONVENIENT as CONVENIENT_HREF, @@ -125,6 +126,22 @@ def _num(value): return None +def _temps_vs_item(rep: dict) -> dict: + """First item of the vendor `/temperatures/vs/0` items[] array. + + Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner", e.g. + model TP1X_DA-AC-RAC-01011) does NOT expose the OCF-standard + /temperature/current/0 + /temperature/desired/0 pair; it reports current + and target under a single `/temperatures/vs/0` resource whose + `x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/ + increment/unit. Returns {} when absent, so callers fall through cleanly. + """ + items = rep.get('x.com.samsung.da.items') + if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict): + return items[0] + return {} + + class LocalThingsClimate(LocalThingsEntity, ClimateEntity): """Composite climate entity for a Samsung air conditioner.""" @@ -172,24 +189,41 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # -- temperature -------------------------------------------------------- + def _temps_vs(self) -> dict: + """Vendor `/temperatures/vs/0` items[0] (empty {} when absent).""" + return _temps_vs_item(self._rep(TEMPS_VS_HREF)) + @property def temperature_unit(self) -> str: raw = self._rep(TEMP_DESIRED_HREF).get('units') + if raw is None: + raw = self._temps_vs().get('x.com.samsung.da.unit') return (UnitOfTemperature.FAHRENHEIT if normalize_temp_unit(raw, '°C') == '°F' else UnitOfTemperature.CELSIUS) @property def current_temperature(self): - return _num(self._rep(TEMP_CURRENT_HREF).get('temperature')) + v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature')) + if v is None: + v = _num(self._temps_vs().get('x.com.samsung.da.current')) + return v @property def target_temperature(self): - return _num(self._rep(TEMP_DESIRED_HREF).get('temperature')) + v = _num(self._rep(TEMP_DESIRED_HREF).get('temperature')) + if v is None: + v = _num(self._temps_vs().get('x.com.samsung.da.desired')) + return v def _range(self) -> list | None: r = self._rep(TEMP_DESIRED_HREF).get('range') - return r if (isinstance(r, (list, tuple)) and len(r) == 2) else None + if isinstance(r, (list, tuple)) and len(r) == 2: + return r + item = self._temps_vs() + lo = _num(item.get('x.com.samsung.da.minimum')) + hi = _num(item.get('x.com.samsung.da.maximum')) + return [lo, hi] if (lo is not None and hi is not None) else None @property def min_temp(self) -> float: @@ -203,7 +237,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def target_temperature_step(self) -> float: - return _num(self._rep(TEMP_CONTROL_HREF).get('increment')) or 1.0 + return (_num(self._rep(TEMP_CONTROL_HREF).get('increment')) + or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment')) + or _num(self._temps_vs().get('x.com.samsung.da.increment')) + or 1.0) # -- hvac mode ---------------------------------------------------------- diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 7d27cc8..a7cbf36 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -24,6 +24,7 @@ REGISTRY = DeviceRegistry( airconditioner.AIR_PURIFY, airconditioner.AUTO_CLEAN, airconditioner.AIR_FILTER, + airconditioner.DISPLAY_LIGHT, *airconditioner.COVERAGE, ]), ) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 7454b09..0a43e3c 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -34,10 +34,12 @@ HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode +HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array) CLIMATE_CONSUMED_HREFS = [ HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, - HREF_TEMP_CONTROL, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, HREF_CONVENIENT, + HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, + HREF_CONVENIENT, ] @@ -146,6 +148,20 @@ AIR_FILTER = Capability( ), ) +DISPLAY_LIGHT = Capability( + href='/light/vs/0', + poll_tier='cold', + entities=( + SwitchDesc(key='display_light', field='mode', + name='Display light', icon='mdi:led-on', + entity_category='config', + value_fn=lambda v: v == 'On', + write_fn=lambda p, rep, href=None: ( + ['light', 'vs', '0'], + {'mode': 'On' if p == 'On' else 'Off'})), + ), +) + # --------------------------------------------------------------------------- # AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate # entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources. @@ -155,8 +171,6 @@ AIR_FILTER = Capability( # bound so discover() reports no coverage gap. # --------------------------------------------------------------------------- _AC_IGNORED = [ - # Vendor superset that duplicates the OCF /temperature/current+desired pair. - '/temperatures/vs/0', # All-zero and ambiguously encoded on this model (2-value arrays); the # 'don't guess' rule -- leave unmodeled rather than invent entities. '/sensors/vs/0', @@ -164,6 +178,24 @@ _AC_IGNORED = [ '/humidity/vs/0', # Presence-personalization plumbing (empty item list here). '/personality/presence/vs/0', + # --- TP1X-class (oneUiVersion "7.0 Air conditioner") housekeeping / opaque + # blobs. These carry no user-actionable state or no documented write + # contract, so per the 'don't guess' rule they are ignored rather than + # modeled. (Temperature for this class lives at /temperatures/vs/0, now + # consumed by the climate entity -- see CLIMATE_CONSUMED_HREFS.) + '/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing + '/aisleep/vs/0', # AI-sleep feedback state (no actionable control) + '/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap + '/da/softreset/vs/0', # soft-reset trigger plumbing + '/keepnormalstate/vs/0', # internal keep-normal flag + '/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here) + '/mds/absencestate/vs/0', # motion-detection state (empty here) + '/option/muteonce/vs/0', # one-shot buzzer mute (no persistent state) + '/remotedatacontrol/vs/0', # remote data-control session status + '/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit) + '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob + '/selfcheck/vs/0', # self-diagnosis action (start/cancel, no simple state) + '/welcome/temperature/vs/0', # welcome-cooling plumbing ] # Built as bare no-entity caps; folded into the AC registry (not global). diff --git a/tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json b/tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json new file mode 100644 index 0000000..9960b19 --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_da_ac_rac_01011_device.json @@ -0,0 +1,595 @@ +{ + "meta": { + "note": "Samsung Room A/C, model TP1X_DA-AC-RAC-01011 (oneUiVersion '7.0 Air conditioner', Tizen Lite). Scrubbed real /device/0 dump. Adds temperature via /temperatures/vs/0 + display light. Issue #17 (newer model class).", + "issue": 17, + "model": "TP1X_DA-AC-RAC-01011", + "oneUiVersion": "7.0 Air conditioner" + }, + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingInterval": "1800", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1784508715", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "00000000", + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Alarm", + "x.com.samsung.da.supportedAutoExeState": [ + "Airpurify", + "Alarm", + "Sensing" + ], + "x.com.samsung.da.startSensingOnce": "Off", + "x.com.samsung.da.autoExeSetting": "Off", + "x.com.samsung.da.supportedAutoExeSetting": [ + "On", + "Off" + ] + } + }, + { + "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": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-22T07:13:19", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000A0012C016102490F0F0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.airconOptionList": [ + "PRODUCT_GLOBAL", + "AI_GLOBAL_HEATPUMP_4.0", + "AI_3.0", + "Auto_To_AI", + "AI_Heat", + "ENERGY_2.0", + "HOMECARE_WIZARD_V2", + "DR", + "SingleCommand_1" + ] + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1784704371", + "x.com.samsung.da.cumulativeSavedPower": "-1", + "x.com.samsung.da.cumulativePower": "75025", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "total" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+02:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "42", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "180", + "300", + "500", + "700" + ] + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "53" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01011_0000|10275643|60010523001911F58E0049320092E900", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-01011_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": "02762A260401", + "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": "02756C25082500,FFFFFFFFFFFFFF" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02669A24092600,02636A10001200" + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0", + "x.com.samsung.da.number": "02672A10001000,FFFFFFFFFFFFFF" + } + ], + "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": "AR4", + "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": 1 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencemonitoring/vs/0", + "rep": {} + }, + { + "href": "/mds/absencestate/vs/0", + "rep": {} + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Speed", + "MotionIndirect", + "Nano", + "NanoSleep", + "DryComfort" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.modes": [ + "Heat" + ], + "workingMode": "Heat", + "x.com.samsung.da.supportedModes": [ + "Auto", + "Cool", + "Dry", + "Heat", + "Wind" + ], + "x.com.samsung.da.options": [ + "OptionCode_56378", + "Sleep_6", + "ArtificialWorking_Off", + "Off", + "OutdoorTemp_68", + "OutdoorConnection_Connected" + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.option.airpurify" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "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.supportedStatus": [ + "Start", + "Stop" + ], + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ], + "defaultSettingStatus": "On" + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "operationNumber": "39", + "displayCondition": "Enable" + } + }, + { + "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": "3EFFFFFFFF101E121EFFFF101EFFFF000001001F0001001F001F0000009CACFFFF0000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.result": "NA", + "x.com.samsung.da.supportedActions": [ + "Start", + "Cancel" + ], + "x.com.samsung.da.error": [ + "DA_ERROR_NONE" + ] + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.cleanLevel": "1", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "0", + "0" + ] + } + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "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": "17.0", + "x.com.samsung.da.desired": "25.0", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/welcome/temperature/vs/0", + "rep": { + "operatingStatus": "None", + "requestId": "0000" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Fix", + "Up_And_Low", + "Left_And_Right", + "All" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High", + "Turbo" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "otnStatus": "None", + "flashingProgress": "0", + "otnCompleteDate": "noHistory", + "scheduledTime": "None", + "swVersionInfo": { + "platform": "Tizen Lite", + "oneUiVersion": "7.0 Air conditioner", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "ARA-KR-TP1-25-ARXX00", + "versions": [ + "11260401" + ], + "visVersion": "260401" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Africa/Johannesburg", + "offset": "+02: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" + } + }, + { + "href": "/dginformation/vs/0", + "rep": { + "enrolmentstatus": "Unknown", + "devicestate": "Unknown", + "lockstatus": "Normal", + "nextduedate": "", + "workingminutes": 0, + "paymentinfo": { + "emiplan": "Unknown", + "currency": "Unknown", + "totalemi": 0, + "totalemipaid": 0 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json new file mode 100644 index 0000000..e640332 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_da_ac_rac_01011.json @@ -0,0 +1,14 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "air_purify", + "alarm_code", + "auto_clean", + "climate", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update" + ] +} \ No newline at end of file diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index efcdbce..328fbc7 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -6,7 +6,7 @@ climate entity itself lives in climate.py (imports homeassistant) and is not importable here -- consistent with how the other HA platform files are untested. """ 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.by_type import for_device, for_device_by_model from custom_components.localthings.registry.capabilities import airconditioner from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import ClimateDesc @@ -106,3 +106,56 @@ def test_climate_consumed_hrefs_declared_as_coverage(): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href + + +# --------------------------------------------------------------------------- +# TP1X_DA-AC-RAC-01011 (oneUiVersion "7.0 Air conditioner", Tizen Lite) -- a +# newer model class than the ARTIK051_PRAC dump above. It has no OCF-standard +# /temperature/current+desired pair (temperature lives on the vendor +# /temperatures/vs/0 items[] resource), exposes a /light/vs/0 display light, and +# carries 13 extra vendor housekeeping hrefs. Issue #17 for this class. +# --------------------------------------------------------------------------- + +def _ac_tp1x(): + resources = _load_device('airconditioner_tp1x_da_ac_rac_01011') + one_ui = resources['/otninformation/vs/0']['swVersionInfo']['oneUiVersion'] + return for_device(one_ui), resources + + +def test_tp1x_resolves_to_airconditioner_registry(): + reg, _ = _ac_tp1x() + assert reg is not None and reg.name == 'airconditioner' + + +def test_tp1x_no_unbound_hrefs(): + """Every resource in the TP1X dump binds or is covered -- including + /temperatures/vs/0, /light/vs/0 and the 13 housekeeping hrefs absent from + the ARTIK051 dump. Clears the coverage-gap repair.""" + reg, resources = _ac_tp1x() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_tp1x_display_light_switch_present(): + """/light/vs/0 (mode On/Off) surfaces as the display-light switch.""" + reg, resources = _ac_tp1x() + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state.get('display_light') is True # device reports mode == 'On' + + +def test_tp1x_vendor_temperature_and_light_covered(): + """The vendor temperature resource (read by the climate entity) and the + display-light resource both resolve in the registry -- no gap.""" + reg, _ = _ac_tp1x() + assert reg.capabilities.get('/temperatures/vs/0'), '/temperatures/vs/0' + assert reg.capabilities.get('/light/vs/0'), '/light/vs/0' + + +def test_tp1x_climate_entity_is_bound(): + """The composite climate entity still binds the primary /mode/vs/0.""" + reg, resources = _ac_tp1x() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] + assert len(climate) == 1 and climate[0].href == '/mode/vs/0' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 265f226..85c1eb1 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -153,6 +153,24 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k(): ) +def test_registry_reproduces_golden_state_keys_for_ac_tp1x_da_ac_rac_01011(): + """Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model + TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0 + items[] resource and adds a /light/vs/0 display light, with 13 extra vendor + housekeeping hrefs -- issue #17 for this model class.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp1x_da_ac_rac_01011') + golden = json.loads( + (GOLDEN / 'airconditioner_tp1x_da_ac_rac_01011.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_tp1x_da_ac_rac_01011', 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 dump = {