Support 2-axis wind oscillation and overload-protection status on newer WindFree boards (issue #126)

Newer TP1X_DA-AC-RAC-01011_0000 firmware (Bespoke AI WindFree Deluxe,
AR60H10D1JWNME) drops /wind/direction/vs/0 entirely and reports swing
via a separate vertical/horizontal Swing|Fix pair on
/wind/oscillation/vs/0 instead, which left swing_mode/swing_modes
silently empty and the href unbound. climate.py now falls back to the
oscillation resource when /wind/direction/vs/0 is absent, mapping the
same off/vertical/horizontal/both vocabulary the existing swing control
already uses.

Also binds the board's /anomalyload/vs/0 overload-response resource as
read-only diagnostic sensors (operation state + mode) -- the same
"don't guess" precedent as the existing CURRENT_LIMIT capability, since
nothing in the dump confirms the exact behavioral difference between
its 'Alarm' and 'PowerSaving' modes or whether toggling it is safe on
live HVAC hardware.

Most of the other gaps this issue reported (Fan-only mode, WindFree
preset labels, target-temperature channel selection, power on/off via
the vendor resource) turned out to already be fixed by the just-merged
cool-only global RAC work.
This commit is contained in:
Marc Billow committed 2026-07-27 13:41:04 +00:00
1 parent 2c8a765036
commit 6f10a3b796
9 files changed
+770 -4

No files matched your search

+41 -3
View File
@@ -48,6 +48,7 @@ from .registry.capabilities.airconditioner import (
HREF_TEMPS_VS as TEMPS_VS_HREF,
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
@@ -120,6 +121,26 @@ _DEVICE_TO_SWING: dict[str, str] = {
}
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without
# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING
# above (off/vertical/horizontal/both), just read from/written to a pair
# of fields rather than a single one.
def _oscillation_swing(rep: dict) -> str | None:
vertical = rep.get('vertical')
horizontal = rep.get('horizontal')
if vertical is None and horizontal is None:
return None
v = vertical == 'Swing'
h = horizontal == 'Swing'
if v and h:
return 'both'
if v:
return 'vertical'
if h:
return 'horizontal'
return 'off'
# 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
@@ -358,13 +379,25 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def fan_modes(self) -> list[str]:
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
def _swing_via_direction(self) -> bool:
"""True when WIND_DIRECTION_HREF is the swing channel to use --
signalled by its presence. Boards without it (issue #126) report
the 2-axis oscillation resource instead; see _oscillation_swing."""
return bool(self._rep(WIND_DIRECTION_HREF))
@property
def swing_mode(self):
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
if self._swing_via_direction():
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
@property
def swing_modes(self) -> list[str]:
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
if self._swing_via_direction():
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
if self._rep(WIND_OSCILLATION_HREF):
return list(_SWING_TO_DEVICE.keys())
return []
@property
def preset_mode(self):
@@ -428,7 +461,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode)
async def async_set_swing_mode(self, swing_mode: str) -> None:
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
if self._swing_via_direction():
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
return
if self._rep(WIND_OSCILLATION_HREF):
await self.coordinator.async_send_command(
self._bound, ('oscillation', swing_mode))
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode == PRESET_AI_COMFORT:
@@ -25,6 +25,7 @@ REGISTRY = DeviceRegistry(
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
@@ -35,13 +35,19 @@ HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write ta
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
# Newer boards (issue #126, TP1X_DA-AC-RAC-01011 WindFree variant) report no
# HREF_WIND_DIRECTION at all and instead carry a 2-axis oscillation resource
# (separate vertical/horizontal Swing|Fix toggles, each with its own angle).
# climate.py falls back to this when HREF_WIND_DIRECTION is absent, the same
# duality pattern as the OCF/vendor temperature channel below.
HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback
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_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION,
HREF_CONVENIENT,
HREF_WIND_OSCILLATION, HREF_CONVENIENT,
]
@@ -177,6 +183,15 @@ def _climate_write(payload, rep, href=None):
return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value})
if kind == 'swing':
return (['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': value})
if kind == 'oscillation':
# value is the HA swing_mode string ('off'/'vertical'/'horizontal'/
# 'both'); both axes are independent Swing|Fix toggles on this
# resource, so one HA value maps to a pair of fields written
# together (see climate.py's oscillation fallback).
return (['wind', 'oscillation', 'vs', '0'], {
'vertical': 'Swing' if value in ('vertical', 'both') else 'Fix',
'horizontal': 'Swing' if value in ('horizontal', 'both') else 'Fix',
})
if kind == 'preset':
return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value})
return None
@@ -299,6 +314,31 @@ CURRENT_LIMIT = Capability(
),
)
# Overload-response setting (issue #126, TP1X_DA-AC-RAC-01011 WindFree
# variant): `operation` toggles the feature and `mode` picks 'Alarm' vs
# 'PowerSaving' out of `supportedModes`, with a `savingTime` duration
# ('20'/'40'/'60' minutes) alongside. Plausible shape (compressor-overload
# alarm vs. automatic power throttling), but nothing in the dump confirms
# the exact behavioral difference between the two modes or whether writing
# 'operation' is safe on live HVAC hardware -- exposed read-only per the
# 'don't guess' rule, same precedent as CURRENT_LIMIT above.
ANOMALY_LOAD = Capability(
href='/anomalyload/vs/0',
poll_tier='cold',
entities=(
BinarySensorDesc(key='overload_protection_active', field='operation',
icon='mdi:flash-alert',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
SensorDesc(key='overload_protection_mode', field='mode',
device_class='enum',
options=('alarm', 'powersaving'),
translation_key='overload_protection_mode',
icon='mdi:flash-alert', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
),
)
# The climate entity already surfaces current_temperature as a card
# attribute, but that's not enough for history graphs/automations/
# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard-
@@ -25,6 +25,9 @@
"current_limit_enabled": {
"name": "Current limit enabled"
},
"overload_protection_active": {
"name": "Overload protection active"
},
"cycle_active": {
"name": "Running"
},
@@ -515,6 +518,13 @@
"current_limit_level": {
"name": "Current limit level"
},
"overload_protection_mode": {
"name": "Overload protection mode",
"state": {
"alarm": "Alarm",
"powersaving": "Power saving"
}
},
"current_temp_c": {
"name": "Temperature"
},
@@ -25,6 +25,9 @@
"current_limit_enabled": {
"name": "Stroombegrenzing ingeschakeld"
},
"overload_protection_active": {
"name": "Overbelastingsbeveiliging actief"
},
"cycle_active": {
"name": "Actief"
},
@@ -515,6 +518,13 @@
"current_limit_level": {
"name": "Niveau stroombegrenzing"
},
"overload_protection_mode": {
"name": "Modus overbelastingsbeveiliging",
"state": {
"alarm": "Alarm",
"powersaving": "Energiebesparing"
}
},
"current_temp_c": {
"name": "Temperatuur"
},
@@ -0,0 +1,590 @@
{
"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.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-27T11:59:06",
"x.com.samsung.da.state": "Deleted"
}
],
"rt": [
"x.com.samsung.da.alarms"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/anomalyload/vs/0",
"rep": {
"operation": "Off",
"mode": "Alarm",
"supportedModes": [
"Alarm",
"PowerSaving"
],
"savingTime": "20",
"supportedSavingTime": [
"20",
"40",
"60"
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000A0012C01610CC90C0F0000",
"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_COOLONLY_4.0",
"AI_3.0",
"Auto_To_AI",
"ENERGY_2.0",
"HOMECARE_WIZARD_V2",
"DR",
"SingleCommand_1"
],
"InstalledRoomSize": "0.0",
"SkipInstalledRoomSize": "false",
"RoomSizeMenuNumber": "Manual"
}
},
{
"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": "1785153515",
"x.com.samsung.da.cumulativeSavedPower": "0",
"x.com.samsung.da.cumulativePower": "47536",
"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": "+08:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterCapacity": "180",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable",
"washable"
],
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterUsage": "44",
"x.com.samsung.da.filterDesiredUsage": "180",
"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": "46"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.modelNum": "TP1X_DA-AC-RAC-01011_0000|10281241|6001051A001911018E0049300002A900",
"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": "02825A260401",
"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": "02812A25111800,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": "02813A25101500,FFFFFFFFFFFFFF"
},
{
"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": "FFFFFFFFFFFFFF,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": "AR2",
"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": 0
}
},
{
"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",
"Nano",
"NanoSleep",
"DryComfort"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.modes": [
"Cool"
],
"workingMode": "Cool",
"x.com.samsung.da.supportedModes": [
"Auto",
"Cool",
"Dry",
"Fan"
],
"x.com.samsung.da.options": [
"OptionCode_56416",
"Sleep_0",
"SleepRemainTime_0",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_240",
"Volume_100",
"DiagnosisAI_Off",
"ProgressDiagnosisAI_0",
"ResultDiagnosisAI_Normal",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"OutDoorVentil_Off",
"InDoorVentil_Off",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"WashAlarm_Off",
"ExtendOptionCode_246413",
"UpdateAllow_NotAllowed",
"DurationOn_0",
"WelcomeCoolingState_Off"
],
"rt": [
"x.com.samsung.da.mode"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"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": "Off",
"x.com.samsung.da.supportedSettingStatus": [
"On",
"Off"
],
"defaultSettingStatus": "On"
}
},
{
"href": "/option/muteonce/vs/0",
"rep": {
"muteonce": "Off",
"rt": [
"x.com.samsung.da.option.muteonce"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off",
"operationNumber": "2",
"displayCondition": "Enable"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/reserverulesets/vs/0",
"rep": {
"x.com.samsung.da.sets": "8EFFFFFFFF101E121EFFFFFFFFFFFF000001001F0001000000000000009C00FFFF1E00",
"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": "/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": "29.0",
"x.com.samsung.da.desired": "18.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": "/wind/oscillation/vs/0",
"rep": {
"vertical": "Fix",
"supportedVertical": [
"Swing",
"Fix"
],
"verticalAngle": "4",
"supportedVerticalAngle": [
"1",
"2",
"3",
"4",
"5",
"6"
],
"horizontal": "Fix",
"supportedHorizontal": [
"Swing",
"Fix"
],
"horizontalSwingMode": "AllSwing",
"supportedHorizontalSwingMode": [
"AllSwing",
"LeftSwing",
"RightSwing"
],
"horizontalAngle": "3",
"supportedHorizontalAngle": [
"1",
"2",
"3",
"4",
"5"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "1",
"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-WW-TP1-26-AR6X00MG",
"versions": [
"11260401"
],
"visVersion": "260401"
},
{
"type": "Micom",
"modelId": "845241131741FFFFFFFF",
"versions": [
"25102900",
"FFFFFFFF"
],
"visVersion": "251029"
},
{
"type": "Micom",
"modelId": "045210281241FFFFFFFF",
"versions": [
"25111800",
"FFFFFFFF"
],
"visVersion": "251118"
},
{
"type": "Micom",
"modelId": "045210281341FFFFFFFF",
"versions": [
"25101500",
"FFFFFFFF"
],
"visVersion": "251015"
},
{
"type": "Micom",
"modelId": "0452FFFFFFFFFFFFFFFF",
"versions": [
"FFFFFFFF",
"FFFFFFFF"
],
"visVersion": "999999"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Kuala_Lumpur",
"offset": "+08: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": "K9 Unit"
}
},
{
"href": "/quickcontrol/info/vs/0",
"rep": {
"supportedVersion": "1.0"
}
},
{
"href": "/dginformation/vs/0",
"rep": {
"enrolmentstatus": "Unknown",
"devicestate": "Unknown",
"lockstatus": "Unknown",
"nextduedate": "",
"workingminutes": 0,
"paymentinfo": {
"emiplan": "Unknown",
"currency": "Unknown",
"totalemi": 0,
"totalemipaid": 0
}
}
},
{
"href": "/temperature/desired/0",
"rep": {
"temperature": 25
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
}
]
}
@@ -0,0 +1,21 @@
{
"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",
"overload_protection_active",
"overload_protection_mode",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status"
]
}
@@ -0,0 +1,33 @@
"""Tests for the /wind/oscillation/vs/0 swing fallback helper in climate.py
(issue #126).
`_oscillation_swing` is a pure module-level function, like `_temps_vs_item`
in test_climate_temperature_fallback.py -- testable directly without a
coordinator/entity. This covers the fallback-selection logic that the
registry-level "no unbound hrefs" test in test_golden_regression.py doesn't
reach: newer WindFree firmware reports no /wind/direction/vs/0 at all, so
swing_mode/swing_modes would silently be None/empty without this fallback.
"""
from custom_components.localthings.climate import _oscillation_swing
def test_oscillation_swing_off_when_both_fixed():
assert _oscillation_swing({'vertical': 'Fix', 'horizontal': 'Fix'}) == 'off'
def test_oscillation_swing_vertical_only():
assert _oscillation_swing({'vertical': 'Swing', 'horizontal': 'Fix'}) == 'vertical'
def test_oscillation_swing_horizontal_only():
assert _oscillation_swing({'vertical': 'Fix', 'horizontal': 'Swing'}) == 'horizontal'
def test_oscillation_swing_both():
assert _oscillation_swing({'vertical': 'Swing', 'horizontal': 'Swing'}) == 'both'
def test_oscillation_swing_none_when_resource_absent():
"""Boards with /wind/direction/vs/0 instead don't populate this
resource at all -- callers must fall through cleanly, not raise."""
assert _oscillation_swing({}) is None
+23
View File
@@ -545,6 +545,29 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_ara_ww_tp1_22(
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree_oscillation():
"""TP1X_DA-AC-RAC-01011_0000 Bespoke AI WindFree Deluxe (model
AR60H10D1JWNME, issue #126) -- the same board family as the
airconditioner_tp1x_da_ac_rac_01011 fixture, but a newer firmware
variant reporting no /wind/direction/vs/0 at all: swing lives on the
2-axis /wind/oscillation/vs/0 resource instead (airconditioner.
HREF_WIND_OSCILLATION, climate.py's oscillation fallback), and it adds
an /anomalyload/vs/0 overload-response resource (airconditioner.
ANOMALY_LOAD, read-only per the 'don't guess' rule). Binds cleanly
with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('airconditioner_windfree_oscillation')
golden = json.loads(
(GOLDEN / 'airconditioner_windfree_oscillation.json').read_text()
)
state_keys = _new_state_keys('airconditioner_windfree_oscillation', 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 = {