From daa7546208945cab358fee3431ac562479997be1 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Tue, 28 Jul 2026 14:09:21 +0000 Subject: [PATCH] Add device support for Wind-Free 2-in-1 AC TP2X_FAC_BORA_21K (#150, #153) This board (a floor-standing + wall-mounted indoor unit pair sharing one outdoor unit and one local IP) reports no oneUiVersion and carries the '_FAC_' modelNum token, which no existing routing rule matched -- it fell back to 'unknown' and exposed nothing but a power switch, with no climate entity generated at all (both issues' reported symptom). Once routed to the existing airconditioner registry, it binds cleanly against the exact same CLIMATE composite every other room-AC family uses -- same Cool/Dry/Wind/AIComfort mode vocabulary, same wind-strength/humidity/ filter/energy resource shapes already modeled. Only two hrefs are unique to this board: /subdevices/vs/0 (an opaque paired-subdevice id list -- issue #150 asked whether the second indoor unit can be controlled separately; it can't through this or any other resource in the dump, the same "remote device ids, not locally actionable" role as the existing /remotedeviceinfo/vs/0 ignore) and /runn/vs/0 (a single undocumented int with no supported-values list to interpret). Both added to _AC_IGNORED rather than guessed at. --- .../localthings/registry/by_type/__init__.py | 7 + .../registry/capabilities/airconditioner.py | 13 + .../airconditioner_fac_bora_device.json | 446 ++++++++++++++++++ .../golden/airconditioner_fac_bora.json | 16 + tests/test_airconditioner_capabilities.py | 44 ++ tests/test_climate_ac_modes.py | 14 +- tests/test_golden_regression.py | 20 + 7 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/airconditioner_fac_bora_device.json create mode 100644 tests/fixtures/golden/airconditioner_fac_bora.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 3860fb1..c38e89a 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -201,6 +201,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # here) -- no WAC-specific resources needed. if key is None and '_WAC_' in (model_num or ''): key = 'airconditioner' + # Wind-Free 2-in-1 systems (floor-standing + wall-mounted indoor units + # sharing one outdoor unit and one local IP, e.g. TP2X_FAC_BORA_21K, + # issues #150/#153) report no oneUiVersion and carry the '_FAC_' token + # instead of '_RAC_'/'_PRAC_'. Same TP1X/TP2X-class resource surface as + # the room-AC models above. + if key is None and '_FAC_' in (model_num or ''): + key = 'airconditioner' # ARA-WW-class wall-mount RACs (e.g. ARA-WW-TP1-22-COMMON, issues #115/ # #116/#117/#120) report no oneUiVersion and no '_RAC_'/'-RAC-' token at # all -- the board family is spelled 'ARA-WW-' instead. Same resource diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index f060d75..4dc5a61 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -602,6 +602,19 @@ _AC_IGNORED = [ # topology -- indoor/outdoor unit pairing, per-unit serials, MCU info. # Commissioning-time plumbing, not user-actionable appliance state. '/sac/installationinfo/vs/0', + # Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing + # *and* a wall-mounted indoor unit over one shared local IP, e.g. + # TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id + # list, same "remote device ids, not user-actionable locally" role as + # /remotedeviceinfo/vs/0 above. This integration talks to whichever + # single local endpoint the config entry was set up against; the + # second indoor unit isn't independently reachable through this + # resource (or any other in the dump) -- it would need its own local + # DTLS session/IP, which SmartThings pairing doesn't expose here. + '/subdevices/vs/0', + # Undocumented single int (runningMode: 0 on every dump seen), no + # supported-values list to interpret it against -- 'don't guess'. + '/runn/vs/0', ] # Built as bare no-entity caps; folded into the AC registry (not global). diff --git a/tests/fixtures/airconditioner_fac_bora_device.json b/tests/fixtures/airconditioner_fac_bora_device.json new file mode 100644 index 0000000..f1deb08 --- /dev/null +++ b/tests/fixtures/airconditioner_fac_bora_device.json @@ -0,0 +1,446 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "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.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "AC_V_0002_OFF", + "x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000B4012C0000404B0C000000", + "x.com.samsung.da.id": "FAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2824500000", + "x.com.samsung.da.airconOptionList": [ + "HOMECARE_WIZARD_V2", + "ENERGY_2.0", + "AI_2.0", + "DeviceTypeMaster", + "SingleCommand_1" + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": {} + }, + { + "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" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "energy": 100.0, + "power": 65278.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "100.000000", + "x.com.samsung.da.instantaneousPower": "65278.000000", + "x.com.samsung.da.usageThreshold": "0.000000", + "x.com.samsung.da.cumulativePower": "1278818", + "x.com.samsung.da.cumulativeDate": "1785243600", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "individual" + } + }, + { + "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": "100", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "112", + "x.com.samsung.da.filterStatus": "wash", + "x.com.samsung.da.filterCapacity": "112", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "112", + "224", + "336", + "448" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0.000000", + "x.com.samsung.da.fivepercentHumidity": "73" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000", + "x.com.samsung.da.description": "TP2X_FAC_BORA_21K", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "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": "000", + "x.com.samsung.da.diagMinVersion": "1.0", + "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": "02337A260424", + "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": "2102240021022200", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "2103300110000300" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 1 + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Speed" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.modes": [ + "AIComfort" + ], + "x.com.samsung.da.options": [ + "Operation_Family", + "Blooming_0", + "OnTimer_0", + "OffTimer_0", + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_270", + "welcomecare_Off", + "Panel_Close", + "Weather_Off", + "Volume_100", + "StopAutoClean_Idle", + "DiagnosisAI_Off", + "Display_Off", + "ProgressDiagnosisAI_1", + "ResultDiagnosisAI_Normal", + "Service_Off", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "OutDoorVentil_Off", + "FreezeAlarmSetting_On", + "DesiredFreezeAlarm_112", + "OptionCode_529", + "ExtendOptionCode_16975", + "RacInfo_First", + "RacInfo_None_Second", + "ModelInfo_16K_BORA_VENT2", + "UpdateAllow_NotAllowed", + "EnergySaveIcon_Off", + "DurationOn_0", + "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", + "SpeedClean", + "QuietClean", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "SpeedClean", + "QuietClean", + "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": "AFA-KR-TP2-21-AF9X00", + "versions": [ + "10260424" + ], + "visVersion": "260424" + }, + { + "type": "Micom", + "modelId": "04511023304110232941", + "versions": [ + "21022400", + "21022200" + ], + "visVersion": "210224" + }, + { + "type": "Micom", + "modelId": "04511022974110229941", + "versions": [ + "21033001", + "10000300" + ], + "visVersion": "210330" + }, + { + "type": "Micom", + "modelId": "045110230741FFFFFFFF", + "versions": [ + "22050300", + "FFFFFFFF" + ], + "visVersion": "220503" + } + ] + } + }, + { + "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": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/runn/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0 + } + }, + { + "href": "/subdevices/vs/0", + "rep": { + "x.com.samsung.da.subdeviceIdList": "REDACTED" + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 31.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 27.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": "27.0", + "x.com.samsung.da.current": "31.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "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": "NotSupported", + "x.com.samsung.da.supportedModes": [ + "NotSupported" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Mid", + "High", + "Turbo" + ] + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_fac_bora.json b/tests/fixtures/golden/airconditioner_fac_bora.json new file mode 100644 index 0000000..5bedb6d --- /dev/null +++ b/tests/fixtures/golden/airconditioner_fac_bora.json @@ -0,0 +1,16 @@ +{ + "state_keys": [ + "air_filter_status", + "air_filter_usage", + "alarm_code", + "auto_clean", + "climate", + "current_temperature_c", + "diagnosis_status", + "energy_kwh", + "firmware_update", + "humidity", + "power_energy_kwh", + "power_watts" + ] +} diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 8bbb1d1..1749412 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -426,3 +426,47 @@ def test_humidity_five_percent_field_passes_a_genuine_zero_through(): reading.""" desc = airconditioner.HUMIDITY.entities[0] assert desc.rep_fn({'x.com.samsung.da.fivepercentHumidity': '0'}) == 0.0 + + +# --------------------------------------------------------------------------- +# Wind-Free 2-in-1 (TP2X_FAC_BORA_21K, issues #150/#153): a floor-standing + +# wall-mounted indoor unit pair sharing one outdoor unit and one local IP. +# Reported "no climate entity is generated, only power" -- the device simply +# fell back to 'unknown' for lack of a '_FAC_' modelNum routing token; once +# routed, it binds against the exact same CLIMATE composite every other RAC +# family uses, zero unbound hrefs, no new capabilities needed beyond ignoring +# the two hrefs (/subdevices/vs/0, /runn/vs/0) unique to this board. +# --------------------------------------------------------------------------- + +def _ac_fac_bora(): + resources = _load_device('airconditioner_fac_bora') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'], + ) + return reg, resources + + +def test_fac_bora_resolves_to_airconditioner_registry(): + reg, _ = _ac_fac_bora() + assert reg is not None and reg.name == 'airconditioner' + + +def test_fac_bora_no_unbound_hrefs(): + reg, resources = _ac_fac_bora() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_fac_bora_climate_entity_present(): + """The actual reported gap: only a power switch existed before, no + climate entity at all.""" + reg, resources = _ac_fac_bora() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + assert any(isinstance(item.desc, ClimateDesc) for item in bound) + + +def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed(): + assert '/subdevices/vs/0' in airconditioner._AC_IGNORED + assert '/runn/vs/0' in airconditioner._AC_IGNORED diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index e9905b9..df28521 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -7,8 +7,8 @@ testable directly. from homeassistant.components.climate import HVACMode from custom_components.localthings.climate import ( - _AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT, - _preset_to_ha, + _AI_COMFORT_MODE, _DEVICE_TO_FAN, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, + PRESET_AI_COMFORT, _preset_to_ha, ) @@ -74,3 +74,13 @@ def test_preset_to_ha_lowercases_other_codes(): assert _preset_to_ha('Sleep') == 'sleep' assert _preset_to_ha('NanoSleep') == 'nanosleep' assert _preset_to_ha('MotionIndirect') == 'motionindirect' + + +def test_fac_bora_wind_strength_codes_fit_the_standard_scale(): + """TP2X_FAC_BORA_21K's (issues #150/#153) /wind/strength/vs/0 codes + (0/2/3/4, skipping 1/'low') and modesName (Auto/Mid/High/Turbo) already + match _DEVICE_TO_FAN's own mapping exactly -- no dynamic modesName + fallback needed for this particular board, unlike issue #155's + TP1X_DA-AC-RAC-01001_0000.""" + for code in ('0', '2', '3', '4'): + assert code in _DEVICE_TO_FAN diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 5385931..7c206d1 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -786,6 +786,26 @@ def test_registry_reproduces_golden_state_keys_for_air_purifier_vtww(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): + """TP2X_FAC_BORA_21K Wind-Free 2-in-1 (floor-standing + wall-mounted + indoor units sharing one outdoor unit and one local IP, issues + #150/#153) -- reports no oneUiVersion; resolved via the '_FAC_' + modelNum fallback. Binds cleanly against the existing airconditioner + registry (including a real climate entity, the actual reported gap) + with zero unbound hrefs -- /subdevices/vs/0 and /runn/vs/0 are the only + hrefs this board reports that no other AC family does, both added to + _AC_IGNORED as undocumented/not-locally-actionable.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_fac_bora') + golden = json.loads((GOLDEN / 'airconditioner_fac_bora.json').read_text()) + state_keys = _new_state_keys('airconditioner_fac_bora', 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 = {