Read AC fan speeds from the device's own modesName, not just 0-4 (#155)

TP1X_DA-AC-RAC-01001_0000 (model AR07C9150HZN) reports /wind/strength/vs/0
supportedModes as "0"/"31"-"35" instead of the "0"-"4" scale climate.py's
_DEVICE_TO_FAN was built from. Only "0" matched, so fan_mode/fan_modes
silently dropped every speed but Auto -- exactly the reported symptom.

Rather than hardcoding a second numeric scale, codes _DEVICE_TO_FAN doesn't
cover now fall back to the device's own modesName label (parallel-indexed
with supportedModes), mirroring how preset_mode already resolves dynamically
off a device's own supportedModes instead of a per-model table. Boards using
the standard 0-4 scale are unaffected -- _DEVICE_TO_FAN is still tried
first, so existing auto/low/medium/high/turbo labels don't change.
This commit is contained in:
Marc Billow
2026-07-28 13:38:42 +00:00
parent 57e06c5ccb
commit aa9cdd83b7
7 changed files with 712 additions and 5 deletions
+40 -3
View File
@@ -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():
@@ -120,7 +120,8 @@
"state_attributes": {
"fan_mode": {
"state": {
"turbo": "Turbo"
"turbo": "Turbo",
"max": "Max"
}
},
"preset_mode": {
@@ -120,7 +120,8 @@
"state_attributes": {
"fan_mode": {
"state": {
"turbo": "Turbo"
"turbo": "Turbo",
"max": "Max"
}
},
"preset_mode": {
+525
View File
@@ -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"
}
}
]
}
@@ -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"
]
}
@@ -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')
+21
View File
@@ -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 = {