diff --git a/tests/fixtures/airconditioner_artik051_krac_18k_device.json b/tests/fixtures/airconditioner_artik051_krac_18k_device.json new file mode 100644 index 0000000..a607a2c --- /dev/null +++ b/tests/fixtures/airconditioner_artik051_krac_18k_device.json @@ -0,0 +1,232 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airflow/0", + "rep": { + "speed": 3, + "direction": "Fix" + } + }, + { + "href": "/airflow/vs/0", + "rep": { + "x.com.samsung.da.speedLevel": "3", + "x.com.samsung.da.direction": "Fix" + } + }, + { + "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": "2026-07-28T00:18:15", + "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": "2026-07-28T00:18:15", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000" + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "0" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000", + "x.com.samsung.da.description": "ARTIK051_KRAC_18K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**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": "02016A200825", + "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": "18020800,17120500", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat", + "HOMECARE_WIZARD_V2" + ], + "modes": [ + "Cool" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat", + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Comode_Off", + "Sleep_0", + "OutdoorTemp_74", + "CoolCapa_25", + "WarmCapa_32", + "Spi_Off", + "Autoclean_Off", + "Light_Off", + "Volume_100", + "AirMonitoring_Off", + "AutocleanProgress_1", + "StopAutoClean_Idle", + "FilterTime_1715", + "FilterAlarmTime_500", + "OptionCode_35882", + "ExtendOptionCode_7", + "RacInfo_None", + "UpdateAllow_NotAllowed" + ] + } + }, + { + "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": "/temperature/current/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 21.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 20.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": "20", + "x.com.samsung.da.current": "21", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json new file mode 100644 index 0000000..c7fabfb --- /dev/null +++ b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json @@ -0,0 +1,18 @@ +{ + "state_keys": [ + "air_monitoring", + "alarm_code", + "auto_clean_legacy", + "buzzer_volume", + "climate", + "current_temperature_c", + "diagnosis_status", + "display_light", + "energy_kwh", + "filter_time", + "good_sleep", + "humidity", + "outdoor_temperature", + "spi" + ] +} diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py new file mode 100644 index 0000000..7c8f942 --- /dev/null +++ b/tests/test_airconditioner_artik051_krac.py @@ -0,0 +1,230 @@ +"""ARTIK051_KRAC_18K room air conditioner (issue #136). + +This board generation predates every AC dump the registry was built from and +differs from all of them in three ways, each covered below: + +* No ``/wind/*`` resources at all -- fan speed and vane direction share one + ``/airflow/vs/0`` resource (``speedLevel`` on the same 0-4 scale as + ``_DEVICE_TO_FAN``, ``direction`` with the same codes as ``_DEVICE_TO_SWING``). +* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*`` + token in ``/mode/vs/0``'s ``options`` array. +* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep, + outdoor temperature, filter time) are ``options`` tokens too, where newer + boards have dedicated resources. + +Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware +by read-back on the unit this fixture is dumped from. The issue #136 unit is +the same model with a slightly different token set (no ``Spi``, +``FilterTime_5460``, ``OutdoorTemp_81``), which the token entities' presence +gating handles the same way it handles newer boards. +""" +from custom_components.localthings.climate import LocalThingsClimate +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import ( + airconditioner, for_device_by_model, +) +from custom_components.localthings.registry.capabilities.airconditioner import ( + HREF_AIRFLOW, _option_number_write, _option_switch_write, +) +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import ClimateDesc +from tests.conftest import _load_device + +FIXTURE = 'airconditioner_artik051_krac_18k' +MODEL = 'ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000' + + +class _FakeCoordinator: + device_serial = 'TEST-KRAC-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 _discover(resources, registry=airconditioner.REGISTRY): + unbound = [] + bound = discover(resources, registry.capabilities, + registry.pattern_capabilities, log=unbound.append) + return bound, unbound + + +def _state(fixture=FIXTURE): + resources = _load_device(fixture) + bound, _ = _discover(resources) + return flatten(bound, resources) + + +def _climate(resources, coordinator=None): + bound, _ = _discover(resources) + climate_bound = next( + item for item in bound if isinstance(item.desc, ClimateDesc) + ) + return LocalThingsClimate( + coordinator or _FakeCoordinator(resources), climate_bound, + ) + + +# -- device type -------------------------------------------------------------- + +def test_krac_model_resolves_to_the_airconditioner_registry(): + """The '_RAC_' token check can't see '_KRAC_' -- the 'K' sits between the + underscore and 'RAC' -- and the consumer-prefix fallback only covers + washers/dryers/dishwashers, so this model resolved to 'unknown' and + exposed nothing but power.""" + registry = for_device_by_model(MODEL, 'ARTIK051_KRAC_18K') + assert registry is not None + assert registry.name == 'airconditioner' + + +def test_no_unbound_hrefs(): + _, unbound = _discover(_load_device(FIXTURE)) + assert unbound == [] + + +# -- option-token entities ---------------------------------------------------- + +def test_token_entities_present_with_calibrated_values(): + state = _state() + # token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the + # official app on this unit, which pins the scale (the .5 here is a later + # reading). Whether it counts up or down is deliberately not asserted -- + # see the descriptor comment. + assert state['filter_time'] == 171.5 + # token - 55 == 19 C, against a 19.4 C forecast at the time of the dump. + assert state['outdoor_temperature'] == 19.0 + assert state['buzzer_volume'] == 100.0 + assert state['good_sleep'] == 0.0 + assert state['spi'] is False + assert state['auto_clean_legacy'] is False + assert state['air_monitoring'] is False + + +def test_token_entities_stay_off_newer_boards(): + """Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too, + while also exposing those settings as dedicated resources -- ungated, the + token entities would duplicate them (auto clean) or apply a scale + calibrated on another board generation (outdoor temperature).""" + state = _state('airconditioner_tp1x_rac') + for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'buzzer_volume', + 'good_sleep', 'outdoor_temperature', 'filter_time'): + assert key not in state, key + + +def test_absent_token_yields_no_entity(): + """The issue #136 unit of this same model reports no Spi token.""" + resources = _load_device(FIXTURE) + options = resources['/mode/vs/0']['x.com.samsung.da.options'] + resources['/mode/vs/0']['x.com.samsung.da.options'] = [ + option for option in options if not option.startswith('Spi_') + ] + bound, _ = _discover(resources) + assert 'spi' not in flatten(bound, resources) + + +def test_humidity_reads_the_vendor_field_and_treats_zero_as_unknown(): + """This board has no fivepercentHumidity field; the plain humidity field + only carries a reading while Air monitoring is on, and the unit switches + that back off by itself after about a minute.""" + resources = _load_device(FIXTURE) + assert resources['/humidity/vs/0']['x.com.samsung.da.humidity'] == '0' + bound, _ = _discover(resources) + assert flatten(bound, resources)['humidity'] is None + + resources['/humidity/vs/0']['x.com.samsung.da.humidity'] = '51' + bound, _ = _discover(resources) + assert flatten(bound, resources)['humidity'] == 51.0 + + +def test_option_writes_carry_one_token(): + """Both go through option_write's single-token merge, the same mechanism + the display light already uses on this href. Confirmed on hardware by + read-back: Spi_On/Spi_Off, and the volume token surviving a write.""" + assert _option_switch_write('Spi')('On', {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Spi_On']}, + ) + # The number platform hands over a float; the device wants an integer token. + assert _option_number_write('Volume')(70.0, {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_70']}, + ) + + +# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------ + +def test_fan_mode_reads_the_airflow_speed_level(): + entity = _climate(_load_device(FIXTURE)) + assert entity.fan_mode == 'high' # speedLevel 3 in the fixture + # No supportedModes on this resource, so the full 0-4 scale is offered. + assert entity.fan_modes == ['auto', 'low', 'medium', 'high', 'turbo'] + + +def test_swing_mode_reads_the_airflow_direction(): + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.swing_mode == 'off' # 'Fix' in the fixture + + resources[HREF_AIRFLOW]['x.com.samsung.da.direction'] = 'All' + assert _climate(resources).swing_mode == 'both' + assert 'both' in _climate(resources).swing_modes + + +async def test_fan_and_swing_writes_target_the_airflow_resource(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('turbo') + await entity.async_set_swing_mode('both') + + assert [payload for _, payload in coordinator.commands] == [ + ('fan_legacy', '4'), ('swing_legacy', 'All'), + ] + + +def test_preset_comes_from_the_comode_token(): + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.preset_mode == 'none' # Comode_Off in the fixture + # Codes learned by driving this unit through its cloud integration and + # reading the token back: Nano is what the app calls WindFree. + assert 'windfree' in entity.preset_modes + + options = resources['/mode/vs/0']['x.com.samsung.da.options'] + resources['/mode/vs/0']['x.com.samsung.da.options'] = [ + 'Comode_Nano' if option.startswith('Comode_') else option + for option in options + ] + assert _climate(resources).preset_mode == 'windfree' + + +async def test_preset_write_uses_the_token_path(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_preset_mode('windfree') + + assert coordinator.commands[-1][1] == ('preset_legacy', 'Nano') + + +async def test_newer_boards_keep_the_resource_paths(): + """The legacy fallbacks are gated on this board's resource shape, so a + board with /wind/* and /mode/convenient/vs/0 must be untouched by them.""" + resources = _load_device('airconditioner_tp1x_rac') + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('high') + await entity.async_set_swing_mode('off') + await entity.async_set_preset_mode('windfree') + + kinds = [payload[0] for _, payload in coordinator.commands] + assert kinds == ['fan', 'swing', 'preset'] diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index c13b45c..eb9e24f 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -630,6 +630,27 @@ def test_registry_reproduces_golden_state_keys_for_vacuum_station(): ) +def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k(): + """ARTIK051_KRAC_18K (issue #136) -- reports no oneUiVersion, and its + '_KRAC_' token was invisible to for_device_by_model's '_RAC_' check (the + 'K' sits between the underscore and 'RAC'), so it fell back to 'unknown' + and exposed nothing but power. Same ARTIK051 board generation as the + '_TVTL_' air purifier: no /wind/* resources at all (fan and vane live in + /airflow/vs/0), no /mode/convenient/vs/0 (the preset is a Comode_* token), + and several settings carried as /mode/vs/0 options[] tokens.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_artik051_krac_18k') + golden = json.loads( + (GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_artik051_krac_18k', 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 = {