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 = {