diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 3de683b..8ab93ac 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -143,6 +143,22 @@ def _oscillation_swing(rep: dict) -> str | None: return 'horizontal' return 'off' +def _wind_strength_label(code, rep: dict) -> str: + """Human label for a /wind/strength/vs/0 code from the device's own + modesName array (parallel-indexed with supportedModes), lowercased for + HA -- used only for codes _DEVICE_TO_FAN doesn't already cover (issue + #155, TP1X_DA-AC-RAC-01001_0000: codes "0"/"31"-"35" instead of the + "0"-"4" scale _DEVICE_TO_FAN was built from, with modesName giving + "Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors + preset_mode's dynamic code->str resolution. Falls back to the raw code + lowercased when modesName is absent or misaligned.""" + supported = rep.get('x.com.samsung.da.supportedModes') or [] + names = rep.get('x.com.samsung.da.modesName') or [] + if code in supported and len(names) == len(supported): + return str(names[supported.index(code)]).lower() + return str(code).lower() + + # Preset (convenient mode): resolved dynamically from the device's own # /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off' # code maps to HA's PRESET_NONE ("no preset active"); every other code is @@ -428,14 +444,24 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): airflow = self._legacy_airflow() if airflow: return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel'))) - return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) + rep = self._rep(WIND_STRENGTH_HREF) + code = _first(rep.get(_MODES_FIELD)) + if code is None: + return None + return _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep) @property def fan_modes(self) -> list[str]: if self._legacy_airflow(): # This resource carries no supportedModes, so the full scale is offered. return list(_DEVICE_TO_FAN.values()) - return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) + rep = self._rep(WIND_STRENGTH_HREF) + modes = [] + for code in self._supported(WIND_STRENGTH_HREF): + mode = _DEVICE_TO_FAN.get(code) or _wind_strength_label(code, rep) + if mode not in modes: + modes.append(mode) + return modes def _swing_via_direction(self) -> bool: """True when WIND_DIRECTION_HREF is the swing channel to use -- @@ -537,7 +563,18 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): await self.coordinator.async_send_command( self._bound, ('fan_legacy', level)) return - await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode) + device = _FAN_TO_DEVICE.get(fan_mode) + if device is None: + # fan_mode came from _wind_strength_label's dynamic path (issue + # #155) -- resolve back to the device's own code the same way + # async_set_preset_mode does for its dynamic codes. + rep = self._rep(WIND_STRENGTH_HREF) + for code in self._supported(WIND_STRENGTH_HREF): + if code not in _DEVICE_TO_FAN and _wind_strength_label(code, rep) == fan_mode: + device = code + break + if device is not None: + await self.coordinator.async_send_command(self._bound, ('fan', device)) async def async_set_swing_mode(self, swing_mode: str) -> None: if self._legacy_airflow(): diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index c875314..6b9fe90 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -120,7 +120,8 @@ "state_attributes": { "fan_mode": { "state": { - "turbo": "Turbo" + "turbo": "Turbo", + "max": "Max" } }, "preset_mode": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 693e806..87e3e18 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -120,7 +120,8 @@ "state_attributes": { "fan_mode": { "state": { - "turbo": "Turbo" + "turbo": "Turbo", + "max": "Max" } }, "preset_mode": { diff --git a/tests/fixtures/airconditioner_tp1x_rac_01001_device.json b/tests/fixtures/airconditioner_tp1x_rac_01001_device.json new file mode 100644 index 0000000..a1c2001 --- /dev/null +++ b/tests/fixtures/airconditioner_tp1x_rac_01001_device.json @@ -0,0 +1,525 @@ +{ + "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.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1DBF00A0012C3F61024804000000", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "1150000000", + "x.com.samsung.da.airconOptionList": [ + "SingleCommand_1", + "DR", + "HOMECARE_WIZARD_V2", + "AI_RAC_KOREA_COOLONLY_3.0", + "AI_3.0", + "Auto_To_AI" + ] + } + }, + { + "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": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.duration": "00:00:00", + "x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "power": 338.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPower": "338.000000", + "x.com.samsung.da.cumulativePower": "1362848", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W" + } + }, + { + "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.filterUsage": "2", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "500", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "500", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable", + "washable" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "42" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01001_0000|10250041|6001051C001711014E00482200912000", + "x.com.samsung.da.description": "TP1X_DA-AC-RAC-01001_0000", + "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": "AR1", + "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": "02646A260327", + "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": "102500A23012700", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "102472A22120800,102528A10000100", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 0 + } + }, + { + "href": "/light/vs/0", + "rep": { + "mode": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Smart", + "Nano", + "NanoSleep" + ] + } + }, + { + "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": [ + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_280", + "welcomecare_Off", + "OutdoorConnection_Connected", + "OutdoorTemp_86", + "CoolCapa_28", + "WarmCapa_0", + "Volume_100", + "StopAutoClean_Set", + "Autoclean_On", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "FreezeAlarmSetting_Off", + "DesiredFreezeAlarm_240", + "WashAlarm_Off", + "OptionCode_56440", + "ExtendOptionCode_246479", + "RacInfo_None", + "UpdateAllow_NotAllowed", + "DurationOn_115", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None" + ] + } + }, + { + "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", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "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": "ARA-WW-TP1-24-ARXX00", + "versions": [ + "11260327" + ], + "visVersion": "260327" + }, + { + "type": "Micom", + "modelId": "045210250041FFFFFFFF", + "versions": [ + "23012700", + "FFFFFFFF" + ], + "visVersion": "230127" + }, + { + "type": "Micom", + "modelId": "04521024724110252841", + "versions": [ + "22120800", + "10000100" + ], + "visVersion": "221208" + } + ] + } + }, + { + "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": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On", + "causeSource": "SMTS" + } + }, + { + "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": "/remotedeviceinfo/vs/0", + "rep": { + "x.com.samsung.da.didList": "" + } + }, + { + "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": "1DFFFFFFFF101E121EFFFFFFFFFFFF01000000012F0100000F000000001C00", + "x.com.samsung.da.id": "RAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/sensors/vs/0", + "rep": {} + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 28.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16, + 30 + ], + "units": "C", + "temperature": 25.0 + } + }, + { + "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": "25.0", + "x.com.samsung.da.current": "28.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "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": "32", + "x.com.samsung.da.supportedModes": [ + "0", + "31", + "32", + "33", + "34", + "35" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "1", + "2", + "3", + "4", + "MAX" + ] + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "REDACTED", + "macaddressBLE": "REDACTED", + "connectedApSsid": "REDACTED" + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json new file mode 100644 index 0000000..7b498eb --- /dev/null +++ b/tests/fixtures/golden/airconditioner_tp1x_rac_01001.json @@ -0,0 +1,17 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "display_light", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "humidity", + "mute_once", + "power_watts" + ] +} diff --git a/tests/test_airconditioner_tp1x_rac_01001_fan.py b/tests/test_airconditioner_tp1x_rac_01001_fan.py new file mode 100644 index 0000000..5a5dea6 --- /dev/null +++ b/tests/test_airconditioner_tp1x_rac_01001_fan.py @@ -0,0 +1,105 @@ +"""TP1X_DA-AC-RAC-01001_0000 fan-strength codes (model AR07C9150HZN, issue +#155). + +Its /wind/strength/vs/0 reports supportedModes "0"/"31"/"32"/"33"/"34"/"35" +instead of the "0"-"4" scale climate.py's _DEVICE_TO_FAN was built from +(every other AC fixture in this repo uses "0"-"4", some with a 6th "5" -- +see airconditioner_window_ac_device.json). Only "0" matched _DEVICE_TO_FAN, +so fan_modes silently dropped every speed but Auto. The fix reads the +device's own modesName labels (parallel-indexed with supportedModes) for +any code _DEVICE_TO_FAN doesn't already cover, instead of hardcoding a +second numeric scale. +""" +from custom_components.localthings.climate import ( + LocalThingsClimate, _DEVICE_TO_FAN, _wind_strength_label, +) +from custom_components.localthings.registry import by_type +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import ClimateDesc + +from tests.conftest import _load_device + +FIXTURE = 'airconditioner_tp1x_rac_01001' + + +class _FakeCoordinator: + device_serial = 'TEST-RAC-01001-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + self.commands = [] + + def resource(self, href): + return self.last_resources.get(href, {}) + + async def async_send_command(self, bound, payload): + self.commands.append((bound, payload)) + + +def _climate(resources, coordinator=None): + info = resources['/information/vs/0'] + reg = by_type.for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc)) + return LocalThingsClimate(coordinator or _FakeCoordinator(resources), climate_bound) + + +def test_wind_strength_label_reads_the_devices_own_modes_name(): + rep = { + 'x.com.samsung.da.supportedModes': ['0', '31', '32', '33', '34', '35'], + 'x.com.samsung.da.modesName': ['Auto', '1', '2', '3', '4', 'MAX'], + } + assert _wind_strength_label('32', rep) == '2' + assert _wind_strength_label('35', rep) == 'max' + + +def test_wind_strength_label_falls_back_to_raw_code_when_names_absent(): + assert _wind_strength_label('32', {}) == '32' + + +def test_fan_modes_include_every_supported_speed_not_just_auto(): + """Before the fix, only '0' matched _DEVICE_TO_FAN and fan_modes was + ['auto'] -- exactly the reported symptom.""" + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.fan_modes == ['auto', '1', '2', '3', '4', 'max'] + + +def test_fan_mode_reads_the_current_dynamic_code(): + """Fixture's /wind/strength/vs/0 modes is '32' -> modesName '2'.""" + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.fan_mode == '2' + + +def test_standard_scale_codes_still_use_device_to_fan(): + """A code _DEVICE_TO_FAN already covers keeps its existing friendly + label rather than falling through to the device's own (blunter) one -- + no regression for boards using the standard "0"-"4" scale.""" + resources = _load_device(FIXTURE) + resources['/wind/strength/vs/0']['x.com.samsung.da.modes'] = '0' + entity = _climate(resources) + assert entity.fan_mode == _DEVICE_TO_FAN['0'] == 'auto' + + +async def test_set_fan_mode_resolves_a_dynamic_label_back_to_its_code(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('max') + + assert coordinator.commands[-1][1] == ('fan', '35') + + +async def test_set_fan_mode_still_resolves_standard_scale_labels(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('auto') + + assert coordinator.commands[-1][1] == ('fan', '0') diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 7846277..089e036 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -709,6 +709,27 @@ def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001(): + """TP1X_DA-AC-RAC-01001_0000 (model AR07C9150HZN, issue #155) -- binds + cleanly against the existing airconditioner registry with zero unbound + hrefs (the registry/discovery side was never the gap here). Its + /wind/strength/vs/0 reports supportedModes "0"/"31"-"35" instead of the + "0"-"4" scale climate.py's _DEVICE_TO_FAN was built from, which silently + dropped every fan speed but Auto -- see + test_airconditioner_tp1x_rac_01001_fan.py for the climate-level fix.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_tp1x_rac_01001') + golden = json.loads( + (GOLDEN / 'airconditioner_tp1x_rac_01001.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_tp1x_rac_01001', 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 = {