diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 063b811..89a7e8b 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -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: diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 7a7c38c..9ea273a 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -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, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 67bec22..c8e9576 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -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- diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 1786d98..14c0a01 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index a6e73ed..454bb86 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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" }, diff --git a/tests/fixtures/airconditioner_windfree_oscillation_device.json b/tests/fixtures/airconditioner_windfree_oscillation_device.json new file mode 100644 index 0000000..3bc13ee --- /dev/null +++ b/tests/fixtures/airconditioner_windfree_oscillation_device.json @@ -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 + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_windfree_oscillation.json b/tests/fixtures/golden/airconditioner_windfree_oscillation.json new file mode 100644 index 0000000..000bed7 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_windfree_oscillation.json @@ -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" + ] +} \ No newline at end of file diff --git a/tests/test_climate_wind_oscillation_fallback.py b/tests/test_climate_wind_oscillation_fallback.py new file mode 100644 index 0000000..d9f5292 --- /dev/null +++ b/tests/test_climate_wind_oscillation_fallback.py @@ -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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 9cebe46..f56a9c4 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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 = {