From 7fe9e2a97594b7243aaefa267af6748d25bd369e Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 12:50:21 +0000 Subject: [PATCH 1/9] fix(registry): route TP1X_DA-AC-CAC boards to airconditioner (#191) The 0.16.0 device-type simplification dropped oneUiVersion detection on the assumption every device it typed was already reachable via a modelNum board token. Cassette AC units (TP1X_DA-AC-CAC-01001_0000) were the one exception -- they only ever resolved through oneUiVersion's "Air conditioner" string, since 'CAC' was never added to the board-token table -- so they silently fell back to common caps and lost their climate entity. --- .../localthings/registry/by_type/__init__.py | 3 ++- tests/test_by_type.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 9f860bd..f4f1672 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -75,13 +75,14 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = { # Air conditioners. Every one of these is a distinct board family with # the same resource surface: room (issues #37, #91), package, Korean # (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial - # (#52), and ARA-WW wall-mount (#115, #116, #117, #120). + # (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120). 'RAC': 'airconditioner', 'PRAC': 'airconditioner', 'KRAC': 'airconditioner', 'WAC': 'airconditioner', 'FAC': 'airconditioner', 'CAWW': 'airconditioner', + 'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000 'ARA': 'airconditioner', 'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate 'TVTL': 'air_purifier', # issue #56 (ARTIK051) diff --git a/tests/test_by_type.py b/tests/test_by_type.py index 1d21435..3828557 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -238,6 +238,19 @@ class TestForDeviceByModel: assert reg is not None assert reg.name == 'airconditioner' + def test_airconditioner_via_cac_token(self): + """Issue #191: a cassette AC (TP1X_DA-AC-CAC-01001_0000) regressed to + 'unknown'/common-caps in 0.16.0 when oneUiVersion detection was + dropped -- 'CAC' had never been added to the modelNum board-token + table, only reachable before via oneUiVersion's '7.0 Air conditioner'.""" + from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( + 'TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00', + 'TP1X_DA-AC-CAC-01001_0000', + ) + assert reg is not None + assert reg.name == 'airconditioner' + def test_dehumidifier_via_dhm_token(self): """Issue #88: a dehumidifier (AY18CG7500GED) shares the DA_AC_ board family with the room-AC models but reports no oneUiVersion and From e8df8f7b0c51999d94ebbf2c3e96b94c51b4b99f Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 12:53:56 +0000 Subject: [PATCH 2/9] fix(config_flow): always retry historically-confirmed DTLS ports (#192) The liveness sweep's ICMP-based verdict isn't trustworthy on every network path -- a segregated-VLAN report showed it calling three closed ports live while missing the one port a concurrent nmap scan found genuinely open|filtered, which also happened to be one of our two historically confirmed DTLS ports. Rather than trust a "not live" verdict against that prior, always give PREFERRED_PROBE_PORTS a real handshake attempt even when the sweep excludes them, bounded to at most those two extra attempts. This is a stop-gap for the reported failure mode, not a full fix for the sweep's underlying unreliability -- left a comment on the issue with the diagnosis and flagged the sweep itself for a deeper redesign. --- custom_components/localthings/config_flow.py | 12 +++++++- tests/localthings/test_config_flow.py | 32 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index c2b50b1..f0ca90b 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -194,7 +194,17 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]: except OSError: pass - return _order_candidates(live) + # The sweep's ICMP-based verdict isn't reliable on every network path -- + # issue #192 captured a segregated-VLAN device where it called three + # ports live that a concurrent nmap scan showed as closed, while the + # port nmap found genuinely open|filtered (49154, one of our historically + # confirmed ports) never showed up as live at all. Rather than trust a + # wrong "not live" verdict on a port we already have strong prior + # evidence for, always give the historically-confirmed ports a real + # handshake attempt too. Bounded cost: at most len(PREFERRED_PROBE_PORTS) + # extra handshakes, only when the sweep disagrees with the prior. + rescued = [p for p in PREFERRED_PROBE_PORTS if p in ports and p not in live] + return _order_candidates(live + rescued) def _is_placeholder_serial(serial: str) -> bool: diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 6808b7d..bafeebc 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -105,6 +105,38 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None: assert result == [live_port] +def test_find_live_ports_rescues_preferred_ports_the_sweep_missed( + socket_enabled, +) -> None: + """Issue #192: a segregated VLAN made the ICMP-based sweep call three + closed ports live while missing the one port (49154, a historically + confirmed DTLS port) that nmap showed as genuinely open|filtered. The + sweep's verdict on a preferred port shouldn't be trusted blindly -- + it must always come back as a candidate even if the sweep marked it + dead, so the config flow gets a real handshake attempt against it.""" + import socket + + from custom_components.localthings.config_flow import _find_live_ports + + # Bind and immediately close a socket on 49154 so loopback refuses + # datagrams to it -- standing in for the sweep wrongly ruling out a + # port we have strong prior evidence for. + reserved = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + reserved.bind(('127.0.0.1', 49154)) + reserved.close() + + live_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + live_sock.bind(('127.0.0.1', 0)) + live_port = live_sock.getsockname()[1] + + try: + result = _find_live_ports('127.0.0.1', [49154, live_port], 0.8) + finally: + live_sock.close() + + assert set(result) == {49154, live_port} + + async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> None: """A device that only answers on 49153 — outside the historical 49154/49155 pair — is found by the liveness sweep and its port is stored From d83733a67085c8d9fd838952805ec322e15cedab Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 13:04:33 +0000 Subject: [PATCH 3/9] fix(airconditioner): scale legacy ARTIK051 cumulativePower correctly (#193) ARTIK051_KRAC_18K-generation boards report /energy/consumption/vs/0's cumulativePower in centiwatt-hours, not the plain Wh every other AC board family reports -- confirmed against the reporter's own SmartThings-app reading (raw 117430000 vs. the app's authoritative 1,174.30 kWh is exactly a /100000 factor, not the shared wh_to_kwh's /1000 alone). Split common.ENERGY_METER into generic/legacy variants on the airconditioner registry, discriminated by the existing is_legacy_board() check, so every other AC family keeps the unmodified shared capability. --- .../registry/by_type/airconditioner.py | 11 +- .../registry/capabilities/airconditioner.py | 41 ++++ ...nditioner_artik051_krac_energy_device.json | 231 ++++++++++++++++++ .../airconditioner_artik051_krac_energy.json | 18 ++ tests/test_airconditioner_capabilities.py | 21 ++ tests/test_golden_regression.py | 19 ++ 6 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/airconditioner_artik051_krac_energy_device.json create mode 100644 tests/fixtures/golden/airconditioner_artik051_krac_energy.json diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index 4d61fff..f6c08c7 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -7,6 +7,13 @@ this registry includes *common.UNIVERSAL but deliberately NOT common.POWER -- on/off is the climate entity's HVACMode.OFF / TURN_ON/OFF. See common.POWER's own comment in capabilities/common.py for why it's excluded. +common.ENERGY_METER itself is also excluded from UNIVERSAL here, replaced by +the ENERGY_METER_GENERIC/ENERGY_METER_LEGACY pair -- the legacy ARTIK051 board +generation (issue #193) reports cumulativePower in a different unit than +every other AC family, so this registry needs two mutually-exclusive variants +of that one capability instead of the single shared one every other registry +uses unconditionally. + Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0. """ from ..capabilities import airconditioner, common, dishwasher, ignored @@ -16,7 +23,9 @@ REGISTRY = DeviceRegistry( name='airconditioner', capabilities=_build([ *ignored.IGNORED, - *common.UNIVERSAL, + *[c for c in common.UNIVERSAL if c is not common.ENERGY_METER], + airconditioner.ENERGY_METER_GENERIC, + airconditioner.ENERGY_METER_LEGACY, dishwasher.DIAGNOSIS, airconditioner.CLIMATE, airconditioner.AIR_PURIFY, diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index f5c8983..d55e81a 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -14,10 +14,13 @@ None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`, different schema (see capabilities/__init__.py). They live only in the AC by_type registry. """ +from dataclasses import replace + from ..capability import Capability from ..entities import ( BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, ) +from . import common from .common import filter_usage_percent, normalize_temp_unit from .laundry import option_write @@ -296,6 +299,44 @@ def is_legacy_board(resources): return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources +# This legacy ARTIK051 board generation (issue #193, model AR12NXWXCWKNEU / +# ARTIK051_KRAC_18K) reports /energy/consumption/vs/0's cumulativePower in +# centiwatt-hours -- raw value 100x the plain Wh every other AC board family +# (and common.wh_to_kwh's assumed unit) reports. Confirmed against the +# reporter's own SmartThings-app reading: raw '117430000' vs the app's +# authoritative 1,174.30 kWh is exactly a /100000 factor (i.e. /100 on top of +# wh_to_kwh's own /1000), not wh_to_kwh's plain /1000 alone. No other field in +# ENERGY_METER's entities is present on this board's dump, so only +# 'energy_kwh' needs a replacement value_fn here; the rest pass through +# unchanged in case a future legacy dump ever reports them. +def _legacy_cumulative_power_kwh(v): + n = _int(v) + return round(n / 100000.0, 2) if n is not None else None + + +ENERGY_METER_LEGACY = Capability( + href=common.ENERGY_METER.href, + poll_tier=common.ENERGY_METER.poll_tier, + match_fn=lambda rep, resources: is_legacy_board(resources), + entities=tuple( + replace(e, value_fn=_legacy_cumulative_power_kwh) + if e.key == 'energy_kwh' else e + for e in common.ENERGY_METER.entities + ), +) + +# The non-legacy counterpart to ENERGY_METER_LEGACY above -- identical to +# common.ENERGY_METER, just excluding the legacy board generation so the two +# capabilities can share /energy/consumption/vs/0 in this registry without +# the 'multiple caps need a discriminator' build check tripping (common. +# ENERGY_METER itself has no match_fn, since every *other* registry includes +# it unconditionally and alone). +ENERGY_METER_GENERIC = replace( + common.ENERGY_METER, + match_fn=lambda rep, resources: not is_legacy_board(resources), +) + + def _has_option_token(prefix): return lambda rep, resources: ( is_legacy_board(resources) and _option_token(rep, prefix) is not None) diff --git a/tests/fixtures/airconditioner_artik051_krac_energy_device.json b/tests/fixtures/airconditioner_artik051_krac_energy_device.json new file mode 100644 index 0000000..174e8e8 --- /dev/null +++ b/tests/fixtures/airconditioner_artik051_krac_energy_device.json @@ -0,0 +1,231 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "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": "/airflow/vs/0", + "rep": { + "x.com.samsung.da.speedLevel": "0", + "x.com.samsung.da.direction": "All" + } + }, + { + "href": "/airflow/0", + "rep": { + "speed": 0, + "direction": "All" + } + }, + { + "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-29T12:29:36", + "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-29T12:29:36", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "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": "23", + "x.com.samsung.da.current": "23", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 23.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 23.0 + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "117430000", + "x.com.samsung.da.cumulativeDate": "1785337200" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Comode_Off", + "Sleep_0", + "OutdoorTemp_85", + "CoolCapa_35", + "WarmCapa_35", + "Spi_Off", + "Autoclean_Off", + "Light_Off", + "Volume_100", + "AirMonitoring_Off", + "AutocleanProgress_0", + "StopAutoClean_Idle", + "FilterTime_1425", + "FilterAlarmTime_500", + "OptionCode_56378", + "ExtendOptionCode_7", + "RacInfo_None", + "UpdateAllow_NotAllowed" + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat" + ], + "modes": [ + "Cool" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010123001111110100000000000000", + "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": "17110700,17110600", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+03:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000" + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_energy.json b/tests/fixtures/golden/airconditioner_artik051_krac_energy.json new file mode 100644 index 0000000..f125489 --- /dev/null +++ b/tests/fixtures/golden/airconditioner_artik051_krac_energy.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" + ] +} \ No newline at end of file diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 851c629..f3e83bb 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -824,3 +824,24 @@ def test_beep_and_tropical_night_stay_off_legacy_krac_board(): assert 'tropical_night_mode' not in state assert state['buzzer_volume'] == 100.0 assert state['good_sleep'] == 0.0 + + +def test_legacy_krac_board_energy_kwh_uses_centiwatt_hour_scale(): + """Issue #193: this legacy ARTIK051 board reports cumulativePower in + centiwatt-hours (raw '117430000'), not the plain Wh common.wh_to_kwh + assumes -- confirmed against the reporter's own SmartThings-app reading + of 1,174.30 kWh. ENERGY_METER_LEGACY's /100000 scale must produce that + exact value, not the /1000-only 117430.0 the generic capability would.""" + reg, resources = _resolve('airconditioner_artik051_krac_energy') + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state['energy_kwh'] == 1174.3 + + +def test_non_legacy_board_energy_kwh_still_uses_plain_wh_scale(): + """The ENERGY_METER_GENERIC/LEGACY split must not change behavior for + every other AC board generation -- same value as plain wh_to_kwh.""" + reg, resources = _ac() + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state['energy_kwh'] == round(1686632 / 1000.0, 2) diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 209a1a2..4b24bed 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -859,6 +859,25 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_krac_energy(): + """AR12NXWXCWKNEU/ARTIK051_KRAC_18K (issue #193) -- same legacy board + generation as the artik051_krac_18k fixture, but this dump has a nonzero + cumulativePower. Locks in state_keys; the actual /100000 scale fix is + asserted separately in test_airconditioner_capabilities.py since golden + only compares key sets, not values.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_artik051_krac_energy') + golden = json.loads( + (GOLDEN / 'airconditioner_artik051_krac_energy.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_artik051_krac_energy', 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 = { From 74f34b91a5ae4d8fe1e63cc091e27237f677a7f2 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 13:06:36 +0000 Subject: [PATCH 4/9] feat(registry): route AVT-WW-TP1-23 air purifier boards (#190) AVT-WW-TP1-23-AXX500 (AX053B810HGD) reported device_type 'unknown' with empty oneUiVersion, falling back to common caps. It's the same BESPOKE Cube Air lineage as A-VTWW-TP2-21-COMMON (issue #151), just with the '-WW-' delimiter shifted one letter left ('A-VTWW-' -> 'AVT-WW-'), which splits into an 'AVT'/'WW' token pair the existing whole-token 'VTWW' entry can't see. Added 'AVT' as its own board token onto the same air_purifier registry -- the resource surface (wind/strength fan, HEPA filter, air quality sensors, alarms) already matched with zero unbound hrefs once routed there, so no new capabilities were needed. --- .../localthings/registry/by_type/__init__.py | 6 + .../registry/by_type/air_purifier.py | 5 + .../fixtures/air_purifier_avt_ww_device.json | 381 ++++++++++++++++++ .../fixtures/golden/air_purifier_avt_ww.json | 19 + tests/test_air_purifier_avt_ww.py | 62 +++ tests/test_golden_regression.py | 17 + 6 files changed, 490 insertions(+) create mode 100644 tests/fixtures/air_purifier_avt_ww_device.json create mode 100644 tests/fixtures/golden/air_purifier_avt_ww.json create mode 100644 tests/test_air_purifier_avt_ww.py diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index f4f1672..9b42c58 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -87,6 +87,12 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = { 'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate 'TVTL': 'air_purifier', # issue #56 (ARTIK051) 'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air) + 'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a + # next-gen BESPOKE Cube Air board; same + # lineage as VTWW above but the '-WW-' + # delimiter now falls one letter to the + # left ('A-VTWW-' -> 'AVT-WW-'), splitting + # into a token the existing entry can't see 'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR) 'WATERPURIFIER': 'water_purifier', # issue #90 'ADW': 'dishwasher', diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index 683496e..50ef840 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -16,6 +16,11 @@ match_fn discriminators that keep them from colliding): token, added for it. Its fan is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on /mode/vs/0 -- see that capability's comment. +- AVT-WW-TP1-23-class (issue #190). A next-gen board in the same VTWW + lineage, resolved via its own 'AVT' board token since the '-WW-' delimiter + falls one letter to the left of 'VTWW's whole-token spelling. Same + resource surface as A-VTWW-TP2-21-COMMON above; no new capabilities + needed. Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write contract). diff --git a/tests/fixtures/air_purifier_avt_ww_device.json b/tests/fixtures/air_purifier_avt_ww_device.json new file mode 100644 index 0000000..588713e --- /dev/null +++ b/tests/fixtures/air_purifier_avt_ww_device.json @@ -0,0 +1,381 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.periodicSensingInterval": "600", + "x.com.samsung.da.startSensingOnce": "On", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1785320820", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.autoExeState": "Off", + "x.com.samsung.da.supportedAutoExeState": [ + "Off", + "Airpurify", + "Alarm" + ], + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "03002300" + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": {} + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "87", + "x.com.samsung.da.supportedModes": [ + "87", + "89", + "90", + "91" + ], + "x.com.samsung.da.modesName": [ + "SMART", + "MAX", + "WINDFREE", + "Sleep" + ] + } + }, + { + "href": "/filter/hepafilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "30", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "8760", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "replaceable" + ] + } + }, + { + "href": "/dnd/autosleep/vs/0", + "rep": { + "x.com.samsung.da.startTime": "15:00:00", + "x.com.samsung.da.endTime": "23:30:00", + "x.com.samsung.da.visible": "true", + "x.com.samsung.da.useTimeSetting": "true", + "x.com.samsung.da.functionState": "true" + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "0000000000000000000000000000", + "x.com.samsung.da.id": "VTL", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "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-29T10:34:52", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "48656", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeDate": "1785353679", + "x.com.samsung.da.cumulativeDateUTC": "1785321279", + "x.com.samsung.da.cumulativeUnit": "Wh" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.options": [ + "CountOfSlave_0", + "SlavePower01_NotSupported", + "Pollution_Off", + "SmartSave_Off", + "OptionCode_23418", + "SmartSleep_Off" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "10", + "1" + ] + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "9", + "1" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Odor", + "x.com.samsung.da.type": "Odor", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "6", + "1" + ] + } + ], + "x.com.samsung.da.total": "5" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "AVT-WW-TP1-23-AXX500|10238841|70000535001611CC0D10090301080000", + "x.com.samsung.da.description": "AVT-WW-TP1-23-AXX500", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "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": "AC0", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "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": "02530A260327", + "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": "22120702,22022301", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "2824500000", + "x.com.samsung.da.airconOptionList": [ + "WELCOMECARE", + "AI_PURIFY" + ], + "x.com.samsung.da.countryCode": "" + } + }, + { + "href": "/humidity/0", + "rep": {} + }, + { + "href": "/humidity/vs/0", + "rep": {} + }, + { + "href": "/devicespecificinfo/vs/0", + "rep": { + "x.com.samsung.da.deviceActive": true + } + }, + { + "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": "AVT-WW-TP1-23-AXX500", + "versions": [ + "12260327" + ], + "visVersion": "260327" + }, + { + "type": "Micom", + "modelId": "", + "versions": [ + "22120702", + "22022301" + ], + "visVersion": "221207" + }, + { + "type": "Micom", + "modelId": "", + "versions": [ + "22120702", + "22022301" + ], + "visVersion": "221207" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09: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": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/wirelessinfo/vs/0", + "rep": { + "macaddressWiFi": "**REDACTED**", + "macaddressBLE": "**REDACTED**", + "connectedApSsid": "ASUS_2.4G" + } + }, + { + "href": "/quickcontrol/info/vs/0", + "rep": { + "supportedVersion": "1.0" + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/air_purifier_avt_ww.json b/tests/fixtures/golden/air_purifier_avt_ww.json new file mode 100644 index 0000000..8f13b0e --- /dev/null +++ b/tests/fixtures/golden/air_purifier_avt_ww.json @@ -0,0 +1,19 @@ +{ + "state_keys": [ + "alarm_code", + "clean_level", + "device_active", + "dust", + "energy_kwh", + "energy_saved_kwh", + "fine_dust", + "firmware_update", + "hepa_filter_status", + "hepa_filter_usage", + "mute_once", + "odor", + "power_switch", + "super_fine_dust", + "wind_strength_fan" + ] +} \ No newline at end of file diff --git a/tests/test_air_purifier_avt_ww.py b/tests/test_air_purifier_avt_ww.py new file mode 100644 index 0000000..83e692b --- /dev/null +++ b/tests/test_air_purifier_avt_ww.py @@ -0,0 +1,62 @@ +"""Tests for the AVT-WW-TP1-23-AXX500 air-purifier profile (issue #190). + +Next-gen BESPOKE Cube Air board -- same resource surface as the +A-VTWW-TP2-21-COMMON family (issue #151), but the '-WW-' delimiter now falls +one letter to the left ('A-VTWW-' -> 'AVT-WW-'), splitting into an 'AVT'/'WW' +token pair the existing 'VTWW' whole-token entry can't see. The reporter's +diagnostics showed device_type 'unknown' with empty oneUiVersion and every +resource unbound -- once routed to the existing air_purifier registry, every +resource here binds with zero gaps. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _resources(): + return _load_device('air_purifier_avt_ww') + + +def _reg(resources): + info = resources['/information/vs/0'] + return for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + + +def test_resolves_to_air_purifier_registry(): + assert _reg(_resources()).name == 'air_purifier' + + +def test_no_unbound_hrefs(): + resources = _resources() + reg = _reg(resources) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_wind_strength_fan_preset_names(): + """This dump's /wind/strength/vs/0 reports the same numeric-code + + modesName shape as the original VTWW fixture (SMART/MAX/WINDFREE/Sleep), + confirming the shared WIND_STRENGTH_FAN capability applies unchanged.""" + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['wind_strength_fan'] == '87' + wind = resources['/wind/strength/vs/0'] + assert wind['x.com.samsung.da.modesName'] == ['SMART', 'MAX', 'WINDFREE', 'Sleep'] + + +def test_air_quality_and_filter_sensors_present(): + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['dust'] == 10 + assert state['fine_dust'] == 9 + assert state['odor'] == 1 + assert state['hepa_filter_usage'] == 0 + assert state['hepa_filter_status'] == 'normal' diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 4b24bed..8502a91 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -859,6 +859,23 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): ) +def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww(): + """AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board; + reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a + whole token doesn't match this board's 'AVT'/'WW' split. Resolved via the + new 'AVT' modelNum board-token fallback into the existing air_purifier + registry. Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_purifier_avt_ww') + golden = json.loads((GOLDEN / 'air_purifier_avt_ww.json').read_text()) + state_keys = _new_state_keys('air_purifier_avt_ww', 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_registry_reproduces_golden_state_keys_for_airconditioner_artik051_krac_energy(): """AR12NXWXCWKNEU/ARTIK051_KRAC_18K (issue #193) -- same legacy board generation as the artik051_krac_18k fixture, but this dump has a nonzero From b5c5c056f4fc3a7ed3a1b1603112de0d25e5a7f5 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 13:11:07 +0000 Subject: [PATCH 5/9] feat(fridge): expose discrete cooler temperature setpoint (#186) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RT42DG6630B1FZ is a single-door "cooler only" fridge that reports its setpoint on /temperature/definite/cooler/vs/0 -- a vendor resource outside both TEMP_CURRENT_GENERIC's '/temperature/current/' and TEMP_SETPOINT's '/temperature/desired/' href prefixes, so it was entirely unbound and the setting stayed app-only. Its supportedList (1/2/3/4/7 °C) isn't a contiguous range, so this is modeled as a select over the device's own live options rather than a NumberDesc that would let a user pick an unsupported value like 5 or 6. --- .../registry/by_type/refrigerator.py | 1 + .../registry/capabilities/fridge.py | 31 ++ .../localthings/translations/en.json | 10 + .../localthings/translations/nl.json | 10 + .../golden/refrigerator_definite_cooler.json | 27 ++ .../refrigerator_definite_cooler_device.json | 401 ++++++++++++++++++ tests/test_fridge_capabilities.py | 38 ++ tests/test_golden_regression.py | 22 + 8 files changed, 540 insertions(+) create mode 100644 tests/fixtures/golden/refrigerator_definite_cooler.json create mode 100644 tests/fixtures/refrigerator_definite_cooler_device.json diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index 4c0abf5..b834820 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -25,6 +25,7 @@ REGISTRY = DeviceRegistry( fridge.DEFROST_DELAY, fridge.DEFROST_DELAY_NATIVE_DUPLICATE, fridge.DEFROST_BLOCK_STATUS, + fridge.DEFINITE_TEMPERATURE_COOLER, fridge.DOORS_FALLBACK, fridge.TEMPERATURES_FALLBACK, fridge.ICEMAKER_STATUS_FALLBACK, diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index 14e7436..e7e08a1 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -108,6 +108,37 @@ TEMP_SETPOINT = Capability( ), ) +# --------------------------------------------------------------------------- +# Discrete cooler setpoint (issue #186) -- some single-door ("cooler only") +# fridges report no /temperature/current|desired/* pair at all (this href +# doesn't match TEMP_CURRENT_GENERIC/TEMP_SETPOINT's '/temperature/current/' +# or '/temperature/desired/' prefixes), only this one vendor resource that +# bundles the live desired value together with the *specific* values the +# unit accepts. That supportedList (e.g. ['1','2','3','4','7'] on the issue +# #186 dump) is not a contiguous range -- 5 and 6 genuinely aren't valid +# setpoints on this model -- so a NumberDesc with a min/max/step would let a +# user pick an unsupported value; modeled as a select reading its own live +# options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above. +# --------------------------------------------------------------------------- + +def _definite_cooler_write(p, rep, href=None): + return (['temperature', 'definite', 'cooler', 'vs', '0'], + {'x.com.samsung.da.definite.desired': p}) + + +DEFINITE_TEMPERATURE_COOLER = Capability( + href='/temperature/definite/cooler/vs/0', + poll_tier='warm', + entities=( + SelectDesc(key='cooler_temperature_setpoint', + field='x.com.samsung.da.definite.desired', + icon='mdi:thermometer', + entity_category='config', + options_field='x.com.samsung.da.definite.supportedList', + write_fn=_definite_cooler_write), + ), +) + # --------------------------------------------------------------------------- # Icemaker nighttime quiet mode # --------------------------------------------------------------------------- diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 7822ab1..c904275 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -214,6 +214,16 @@ "100": "High" } }, + "cooler_temperature_setpoint": { + "name": "Cooler temperature", + "state": { + "1": "1 °C", + "2": "2 °C", + "3": "3 °C", + "4": "4 °C", + "7": "7 °C" + } + }, "day_brightness": { "name": "Cabinet brightness", "state": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 833e3cc..e864461 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -214,6 +214,16 @@ "100": "Hoog" } }, + "cooler_temperature_setpoint": { + "name": "Koeler temperatuur", + "state": { + "1": "1 °C", + "2": "2 °C", + "3": "3 °C", + "4": "4 °C", + "7": "7 °C" + } + }, "day_brightness": { "name": "Helderheid kast", "state": { diff --git a/tests/fixtures/golden/refrigerator_definite_cooler.json b/tests/fixtures/golden/refrigerator_definite_cooler.json new file mode 100644 index 0000000..24d5970 --- /dev/null +++ b/tests/fixtures/golden/refrigerator_definite_cooler.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "ai_energy_level", + "alarm_code", + "auto_door_opener", + "brightness_level", + "cabinet_light_dim", + "cabinet_light_switch", + "cooler_temperature_setpoint", + "day_brightness", + "door_alert", + "door_cooler_open", + "energy_kwh", + "energy_saved_kwh", + "firmware_update", + "fridge_sound", + "night_end", + "night_start", + "power_energy_kwh", + "power_watts", + "rapid_freezing", + "rapid_fridge", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status" + ] +} \ No newline at end of file diff --git a/tests/fixtures/refrigerator_definite_cooler_device.json b/tests/fixtures/refrigerator_definite_cooler_device.json new file mode 100644 index 0000000..384d933 --- /dev/null +++ b/tests/fixtures/refrigerator_definite_cooler_device.json @@ -0,0 +1,401 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "href": "/alarms/vs/0", + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/cabinet/light/total/vs/0", + "rep": { + "x.com.samsung.da.lightLevel": "100", + "x.com.samsung.da.lightResolution": "3", + "x.com.samsung.da.lightControl.off.include": "Off", + "x.com.samsung.da.lightControl": "Off", + "x.com.samsung.da.lightControl.hide": "true", + "light.dimming.status": "On", + "href": "/cabinet/light/total/vs/0", + "rt": [ + "x.com.samsung.da.cabinetlight" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/settings/sound/alert/door/vs/0", + "rep": { + "alert.door": "3", + "supportedAlert.door": [ + "1", + "2", + "3", + "4" + ], + "href": "/settings/sound/alert/door/vs/0", + "rt": [ + "x.com.samsung.alert.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/cabinet/light/enhanced/vs/0", + "rep": { + "light.control.status": "Off", + "level.brightness.daytime": "100", + "level.brightness.nighttime": "100", + "night.starttime": "N/A", + "night.duration.minute": "N/A", + "timezone.offset": "-03:00", + "href": "/cabinet/light/enhanced/vs/0", + "rt": [ + "x.com.samsung.da.light.enhanced" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "", + "x.com.samsung.da.countryCode": "", + "href": "/configuration/vs/0" + } + }, + { + "href": "/temperature/definite/cooler/vs/0", + "rep": { + "x.com.samsung.da.definite.desired": "2", + "x.com.samsung.da.definite.supportedList": [ + "1", + "2", + "3", + "4", + "7" + ], + "x.com.samsung.da.unit": "Celsius", + "href": "/temperature/definite/cooler/vs/0" + } + }, + { + "href": "/door/cooler/0", + "rep": { + "openState": "Close", + "href": "/door/cooler/0", + "rt": [ + "oic.r.door" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door" + }, + { + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Door" + } + ], + "href": "/doors/vs/0" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.override": "Not_Supported", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.realSaving": "Off", + "href": "/drlc/vs/0" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "9", + "x.com.samsung.da.instantaneousPower": "51", + "x.com.samsung.da.cumulativePower": "593883", + "x.com.samsung.da.cumulativeSavedPower": "0", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "href": "/energy/consumption/vs/0" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "-03:00", + "x.com.samsung.supprtedtype": 1, + "href": "/file/information/vs/0" + } + }, + { + "href": "/refrigeration/vs/0", + "rep": { + "x.com.samsung.da.rapidFridge": "Off", + "x.com.samsung.da.rapidFreezing": "Off", + "href": "/refrigeration/vs/0", + "rt": [ + "x.com.samsung.da.fridge" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/energy/ailevel/vs/0", + "rep": { + "aiLevel": "1", + "supportedAiLevel": [ + "1" + ], + "href": "/energy/ailevel/vs/0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K|00174941|0006012A001811304100000020010000", + "x.com.samsung.da.description": "TP1X_REF_21K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "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": "RT1", + "x.com.samsung.da.diagProtocolType": "BLE_OCF", + "x.com.samsung.da.diagMinVersion": "3.0", + "x.com.samsung.da.diagTsId": "DA01", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "WiFi Module", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "260617", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Micom", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "24010202, FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ], + "href": "/information/vs/0" + } + }, + { + "href": "/status/lock/vs/0", + "rep": { + "x.com.samsung.da.device.sound": "On", + "x.com.samsung.da.childlock": "Locked", + "href": "/status/lock/vs/0", + "rt": [ + "x.com.samsung.da.lockstatus" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2", + "ENERGY_REPORT_MODEL", + "18K_REF_OUTDOOR_CONTROL_V2" + ], + "href": "/mode/vs/0", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true", + "href": "/realtimenotiforclient/vs/0" + } + }, + { + "href": "/runningmode/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0, + "href": "/runningmode/vs/0" + } + }, + { + "href": "/selfcheck/vs/0", + "rep": { + "x.com.samsung.da.supportedActions": [ + "Start" + ], + "x.com.samsung.da.status": "Ready", + "x.com.samsung.da.result": "Success", + "x.com.samsung.da.error": [ + "ErrorCode_None" + ], + "href": "/selfcheck/vs/0" + } + }, + { + "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 Refrigerator", + "osVersion": "4.0" + }, + "otnList": [ + { + "type": "WIFI", + "modelId": "A-RFWW-TP1-24-T4-COM", + "versions": [ + "20260617" + ], + "visVersion": "260617" + }, + { + "type": "Micom", + "modelId": "035000174941FFFFFFFF", + "versions": [ + "24010202", + "FFFFFFFF" + ], + "visVersion": "240102" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "America/Sao_Paulo", + "offset": "-03: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": "Pumba_IoT" + } + }, + { + "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 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index 3b6399e..d8d6d80 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -230,6 +230,44 @@ class TestPantryZone: assert body == {'x.com.samsung.da.mode': 'FDR_WINE'} +class TestDefiniteTemperatureCooler: + """Discrete cooler setpoint (RT42DG6630B1FZ, issue #186) -- a single-door + fridge whose /temperature/definite/cooler/vs/0 falls outside both + TEMP_CURRENT_GENERIC and TEMP_SETPOINT's href prefixes, so temperature + control was entirely unbound before this capability was added.""" + + def test_href(self): + assert fridge.DEFINITE_TEMPERATURE_COOLER.href == '/temperature/definite/cooler/vs/0' + + def test_write(self): + desc = fridge.DEFINITE_TEMPERATURE_COOLER.entities[0] + path, body = desc.write_fn('3', {}) + assert path == ['temperature', 'definite', 'cooler', 'vs', '0'] + assert body == {'x.com.samsung.da.definite.desired': '3'} + + def test_no_unbound_hrefs_and_discrete_options(self): + from custom_components.localthings.registry.adapter import flatten + from custom_components.localthings.registry.by_type import refrigerator + from custom_components.localthings.registry.discovery import discover + from tests.conftest import _load_device + + resources = _load_device('refrigerator_definite_cooler') + unbound = [] + bound = discover( + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, + ) + assert unbound == [] + + state = flatten(bound, resources) + assert state['cooler_temperature_setpoint'] == '2' + + rep = resources['/temperature/definite/cooler/vs/0'] + assert rep['x.com.samsung.da.definite.supportedList'] == ['1', '2', '3', '4', '7'] + + class TestKimchiZone: """Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) -- top/middle/bottom each report an identically-shaped currentMode/ diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 8502a91..29303ea 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -895,6 +895,28 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_krac_ ) +def test_registry_reproduces_golden_state_keys_for_refrigerator_definite_cooler(): + """RT42DG6630B1FZ (issue #186) -- a single-door "cooler only" fridge whose + /temperature/definite/cooler/vs/0 doesn't match either + TEMP_CURRENT_GENERIC's '/temperature/current/' or TEMP_SETPOINT's + '/temperature/desired/' href prefix, so temperature control was entirely + unbound. Resolved via the new DEFINITE_TEMPERATURE_COOLER capability + (a select over the device's own discrete supportedList, not a + continuous NumberDesc range -- 5 and 6 aren't valid setpoints on this + model). Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('refrigerator_definite_cooler') + golden = json.loads( + (GOLDEN / 'refrigerator_definite_cooler.json').read_text() + ) + state_keys = _new_state_keys('refrigerator_definite_cooler', 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 = { From 487ac748bd0e221dd2f911cf6185bff266b5ad23 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 13:16:09 +0000 Subject: [PATCH 6/9] fix(airconditioner): model legacy-board beep as a switch, not a volume (#136) Every ARTIK051_KRAC_18K-generation unit confirmed on hardware (three units across two reporters) only ever carries Volume_100 or Volume_Mute in /mode/vs/0's options -- never an intermediate value -- so the existing buzzer_volume Number (0-100, step 10) modeled a control this firmware doesn't have. Worse, its write path could never produce the literal 'Mute' token needed to actually turn the beep off, since it only ever wrote a plain integer string. The 'beep' switch already used on newer boards is the correct model here too; it's no longer gated off the legacy board generation, and the Number entity is removed. The WindFree-preset-not-applying report earlier in this issue self-resolved per the reporter's own follow-up testing, so no code change was needed for that part. --- .../registry/capabilities/airconditioner.py | 24 ++++++------ .../localthings/translations/en.json | 3 -- .../localthings/translations/nl.json | 3 -- .../airconditioner_artik051_krac_18k.json | 4 +- .../airconditioner_artik051_krac_energy.json | 2 +- tests/test_airconditioner_artik051_krac.py | 4 +- tests/test_airconditioner_capabilities.py | 37 ++++++++++++++++--- 7 files changed, 49 insertions(+), 28 deletions(-) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index d55e81a..1d46e4f 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -486,14 +486,19 @@ CLIMATE = Capability( icon='mdi:led-on', entity_category='config'), # Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100). # Single-token option_write; a full options RMW reverts on ARTIK051_PRAC. - # Gated off the legacy ARTIK051 board generation (see is_legacy_board): - # that generation's own Volume_ token is already modeled as the - # buzzer_volume Number below, and both reading the same options[] slot - # into two entities would be redundant. + # + # Applies uniformly across board generations, including legacy + # ARTIK051 boards -- issue #136: this token was originally modeled as + # a 0-100 buzzer_volume Number for legacy boards on the assumption it + # was a real graduated volume level, but every unit confirmed on + # hardware (three units across two reporters) only ever carries + # Volume_100 or Volume_Mute, never an intermediate value, and the + # Number's write path (a plain integer string) could never produce + # the literal 'Mute' token needed to actually turn it off -- writing + # '0' is simply not a token this firmware recognizes. A switch is + # both the correct model and the actual fix. SwitchDesc(key='beep', rep_fn=_beep_on, - exists_fn=lambda rep, resources: ( - not is_legacy_board(resources) - and _option_token(rep, 'Volume') is not None), + exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None, write_fn=_beep_write, icon='mdi:volume-high', entity_category='config'), # Tropical night mode level (0-16) from the `Sleep_` option token. @@ -539,11 +544,6 @@ CLIMATE = Capability( exists_fn=_has_option_token('AirMonitoring'), write_fn=_option_switch_write('AirMonitoring'), icon='mdi:air-filter', entity_category='config'), - NumberDesc(key='buzzer_volume', rep_fn=_option_token_num('Volume'), - exists_fn=_has_option_token('Volume'), - write_fn=_option_number_write('Volume'), - native_min=0, native_max=100, step=10, - icon='mdi:volume-high', entity_category='config'), # "Good Sleep" timer. 0 = off; the upper bound is a guess (the token # carries no range hint and only 0 has been observed on hardware), so a # write above 0 is unverified. diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index c904275..811f2c9 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -158,9 +158,6 @@ } }, "number": { - "buzzer_volume": { - "name": "Beep volume" - }, "cook_time": { "name": "Cook time" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index e864461..10883ed 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -158,9 +158,6 @@ } }, "number": { - "buzzer_volume": { - "name": "Zoemervolume" - }, "cook_time": { "name": "Bereidingstijd" }, diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json index c7fabfb..e525909 100644 --- a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json +++ b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json @@ -3,7 +3,7 @@ "air_monitoring", "alarm_code", "auto_clean_legacy", - "buzzer_volume", + "beep", "climate", "current_temperature_c", "diagnosis_status", @@ -15,4 +15,4 @@ "outdoor_temperature", "spi" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_energy.json b/tests/fixtures/golden/airconditioner_artik051_krac_energy.json index f125489..e525909 100644 --- a/tests/fixtures/golden/airconditioner_artik051_krac_energy.json +++ b/tests/fixtures/golden/airconditioner_artik051_krac_energy.json @@ -3,7 +3,7 @@ "air_monitoring", "alarm_code", "auto_clean_legacy", - "buzzer_volume", + "beep", "climate", "current_temperature_c", "diagnosis_status", diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index d6b4b98..5e314fd 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -102,7 +102,7 @@ def test_token_entities_present_with_calibrated_values(): 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['beep'] is True assert state['good_sleep'] == 0.0 assert state['spi'] is False assert state['auto_clean_legacy'] is False @@ -115,7 +115,7 @@ def test_token_entities_stay_off_newer_boards(): 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', + for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'good_sleep', 'outdoor_temperature', 'filter_time'): assert key not in state, key diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index f3e83bb..e97bb25 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -814,18 +814,45 @@ def test_sensor_item_value_picks_first_value(): def test_beep_and_tropical_night_stay_off_legacy_krac_board(): """ARTIK051_KRAC_18K (issue #136) reports both a Volume_ and a Sleep_ - option token, but they're already modeled as buzzer_volume/good_sleep - (see airconditioner.CLIMATE) -- beep/tropical_night_mode must not also - bind there, or the same options[] slot would surface as two entities.""" + option token. Volume_ is modeled as the shared 'beep' switch (issue #136's + buzzer_volume Number never correctly modeled this board -- see + test_legacy_krac_board_beep_is_a_switch_not_a_volume_number below); + Sleep_ is still good_sleep, not tropical_night_mode, on this board (see + airconditioner.CLIMATE).""" reg, resources = _resolve('airconditioner_artik051_krac_18k') state = flatten( discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert 'beep' not in state + assert 'buzzer_volume' not in state assert 'tropical_night_mode' not in state - assert state['buzzer_volume'] == 100.0 + assert state['beep'] is True assert state['good_sleep'] == 0.0 +def test_legacy_krac_board_beep_is_a_switch_not_a_volume_number(): + """Issue #136: three real units across two reporters only ever reported + Volume_100 or Volume_Mute -- never an intermediate value -- and the old + buzzer_volume Number's write path (a plain integer string) could never + produce the literal 'Mute' token needed to turn it off. 'beep' now + applies uniformly across board generations instead of being gated off + the legacy board.""" + reg, resources = _resolve('airconditioner_artik051_krac_18k') + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state['beep'] is True + + resources['/mode/vs/0']['x.com.samsung.da.options'] = [ + o.replace('Volume_100', 'Volume_Mute') + for o in resources['/mode/vs/0']['x.com.samsung.da.options'] + ] + state = flatten( + discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state['beep'] is False + + path, body = airconditioner._beep_write('Off', resources['/mode/vs/0']) + assert path == ['mode', 'vs', '0'] + assert body == {'x.com.samsung.da.options': ['Volume_Mute']} + + def test_legacy_krac_board_energy_kwh_uses_centiwatt_hour_scale(): """Issue #193: this legacy ARTIK051 board reports cumulativePower in centiwatt-hours (raw '117430000'), not the plain Wh common.wh_to_kwh From 36240b44e6a376c3dae221bd5babf9c5df916fa9 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 13:26:39 +0000 Subject: [PATCH 7/9] fix(oven): gate fast_preheat/natural_steam on their own tokens (#183) Both switches were shipped unconditionally (no exists_fn) as an unverified guess -- the module docstring already flagged them as "unproven." Every range/oven fixture in the corpus, including the new issue #183 dump, reports neither fastpreheat_* nor NaturalSteam_* in /mode/vs/0's options at all, so both were phantom controls: always read as off, and toggling them wrote a token the firmware never recognized in the first place. That matches the reporter's exact complaint ("doesn't appear to do anything"). Also bound two tokens confirmed present on this dump but never modeled at all: EnergySaving_On (the 120-hour energy-saving standby from the app) and BurnerOnAlert_Off (cooktop-on alert), following the same single-token options-merge pattern as the existing lamp/sound/fast_preheat switches. Child lock, the setpoint mismatch, and the missing cook-start control from this issue are not code bugs -- see the issue comment for what was verified and what still needs more information from the reporter. --- .../localthings/registry/capabilities/oven.py | 54 +++++ .../localthings/translations/en.json | 6 + .../localthings/translations/nl.json | 6 + tests/fixtures/golden/oven.json | 4 +- tests/fixtures/golden/range.json | 5 +- tests/fixtures/golden/range_ne63a6511.json | 6 +- tests/fixtures/golden/range_ne6516a.json | 27 +++ tests/fixtures/golden/range_ne8300d.json | 3 +- tests/fixtures/golden/range_no_info.json | 6 +- tests/fixtures/range_ne6516a_device.json | 215 ++++++++++++++++++ tests/test_golden_regression.py | 19 ++ tests/test_range_ne6516a_capabilities.py | 62 +++++ 12 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/golden/range_ne6516a.json create mode 100644 tests/fixtures/range_ne6516a_device.json create mode 100644 tests/test_range_ne6516a_capabilities.py diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 0a28cc9..8c20b7f 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -130,6 +130,21 @@ def _option_value(options, prefix): return None +def _has_option(prefix): + """exists_fn for an options-array switch: bind only when the device's own + options[] actually carries a `_` token. + + fast_preheat/natural_steam were shipped unconditionally (no exists_fn) as + an unverified guess (see module docstring) -- issue #183's dump (model + NE6516A) reports neither `fastpreheat_*` nor `NaturalSteam_*` in its + options[] at all, so both switches were phantom controls: always read as + off, and toggling them wrote a token the firmware never recognized in + the first place, hence "does not appear to do anything." + """ + return lambda rep, resources: _option_value( + rep.get('x.com.samsung.da.options'), prefix) is not None + + def _option_write(prefix, new_value): """A one-token x.com.samsung.da.options write, mirroring laundry.option_write. NOT independently confirmed on an oven -- issue @@ -239,6 +254,26 @@ def _naturalsteam_write(p, rep, href=None): } +def _energysaving_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': _option_write('EnergySaving', p), + } + + +def _burneronalert_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': _option_write('BurnerOnAlert', p), + } + + # --------------------------------------------------------------------------- # Capabilities # --------------------------------------------------------------------------- @@ -385,11 +420,30 @@ OVEN_MODE = Capability( write_fn=_sound_write), SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options', icon='mdi:fire', + exists_fn=_has_option('fastpreheat'), value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On', write_fn=_fastpreheat_write), SwitchDesc(key='natural_steam', field='x.com.samsung.da.options', icon='mdi:kettle-steam', + exists_fn=_has_option('NaturalSteam'), value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On', write_fn=_naturalsteam_write), + # 120-hour energy-saving standby (issue #183): confirmed present in + # this unit's options[] (EnergySaving_On) and directly requested -- + # unlike fast_preheat/natural_steam above, this token is real on this + # hardware, just previously unbound entirely. + SwitchDesc(key='energy_saving', field='x.com.samsung.da.options', + icon='mdi:leaf', entity_category='config', + exists_fn=_has_option('EnergySaving'), + value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On', + write_fn=_energysaving_write), + # Cooktop-on alert (issue #183): also confirmed present + # (BurnerOnAlert_Off) though the reporter noted it mainly matters for + # the SmartThings app's own alerting, not local automation. + SwitchDesc(key='cooktop_on_alert', field='x.com.samsung.da.options', + icon='mdi:alert-circle-outline', entity_category='config', + exists_fn=_has_option('BurnerOnAlert'), + value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On', + write_fn=_burneronalert_write), ), ) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 811f2c9..4e5b414 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -1025,6 +1025,12 @@ "natural_steam": { "name": "Natural steam" }, + "energy_saving": { + "name": "Energy saving" + }, + "cooktop_on_alert": { + "name": "Cooktop-on alert" + }, "power_switch": { "name": "Power" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 10883ed..9e64254 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -1025,6 +1025,12 @@ "natural_steam": { "name": "Natuurlijke stoom" }, + "energy_saving": { + "name": "Energiebesparing" + }, + "cooktop_on_alert": { + "name": "Waarschuwing kookplaat aan" + }, "power_switch": { "name": "Voeding" }, diff --git a/tests/fixtures/golden/oven.json b/tests/fixtures/golden/oven.json index e2f168d..59a059a 100644 --- a/tests/fixtures/golden/oven.json +++ b/tests/fixtures/golden/oven.json @@ -7,12 +7,10 @@ "current_temp_c", "cycle_active", "door_open", - "fast_preheat", "finish_time", "firmware_update", "lamp", "machine_state", - "natural_steam", "operation_time_minutes", "oven_mode", "oven_setpoint", @@ -22,4 +20,4 @@ "remote_control", "sound" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/range.json b/tests/fixtures/golden/range.json index f386342..f7fcf75 100644 --- a/tests/fixtures/golden/range.json +++ b/tests/fixtures/golden/range.json @@ -27,12 +27,11 @@ "current_temp_c", "cycle_active", "door_open", - "fast_preheat", + "energy_saving", "finish_time", "firmware_update", "lamp", "machine_state", - "natural_steam", "operation_time_minutes", "oven_mode", "oven_setpoint", @@ -42,4 +41,4 @@ "remote_control", "sound" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/range_ne63a6511.json b/tests/fixtures/golden/range_ne63a6511.json index a7ef5a5..e43255a 100644 --- a/tests/fixtures/golden/range_ne63a6511.json +++ b/tests/fixtures/golden/range_ne63a6511.json @@ -4,16 +4,16 @@ "child_lock", "cloud_connected", "cook_time", + "cooktop_on_alert", "cooktop_running_state", "current_temp_c", "cycle_active", "door_open", - "fast_preheat", + "energy_saving", "finish_time", "firmware_update", "lamp", "machine_state", - "natural_steam", "operation_time_minutes", "oven_mode", "oven_setpoint", @@ -24,4 +24,4 @@ "sound", "warming_center_state" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/golden/range_ne6516a.json b/tests/fixtures/golden/range_ne6516a.json new file mode 100644 index 0000000..65c82ac --- /dev/null +++ b/tests/fixtures/golden/range_ne6516a.json @@ -0,0 +1,27 @@ +{ + "state_keys": [ + "alarm_code", + "cooktop_on_alert", + "child_lock", + "cloud_connected", + "cook_time", + "cooktop_running_state", + "current_temp_c", + "cycle_active", + "door_open", + "energy_saving", + "finish_time", + "firmware_update", + "lamp", + "machine_state", + "operation_time_minutes", + "oven_mode", + "oven_setpoint", + "oven_state", + "power_switch", + "progress_percentage", + "remote_control", + "sound", + "warming_center_state" + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/range_ne8300d.json b/tests/fixtures/golden/range_ne8300d.json index 49f4d08..b77d20e 100644 --- a/tests/fixtures/golden/range_ne8300d.json +++ b/tests/fixtures/golden/range_ne8300d.json @@ -8,12 +8,11 @@ "current_temp_c", "cycle_active", "door_open", - "fast_preheat", + "energy_saving", "finish_time", "firmware_update", "lamp", "machine_state", - "natural_steam", "operation_time_minutes", "oven_mode", "oven_setpoint", diff --git a/tests/fixtures/golden/range_no_info.json b/tests/fixtures/golden/range_no_info.json index a7ef5a5..e43255a 100644 --- a/tests/fixtures/golden/range_no_info.json +++ b/tests/fixtures/golden/range_no_info.json @@ -4,16 +4,16 @@ "child_lock", "cloud_connected", "cook_time", + "cooktop_on_alert", "cooktop_running_state", "current_temp_c", "cycle_active", "door_open", - "fast_preheat", + "energy_saving", "finish_time", "firmware_update", "lamp", "machine_state", - "natural_steam", "operation_time_minutes", "oven_mode", "oven_setpoint", @@ -24,4 +24,4 @@ "sound", "warming_center_state" ] -} +} \ No newline at end of file diff --git a/tests/fixtures/range_ne6516a_device.json b/tests/fixtures/range_ne6516a_device.json new file mode 100644 index 0000000..474ad22 --- /dev/null +++ b/tests/fixtures/range_ne6516a_device.json @@ -0,0 +1,215 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "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": "OV_E_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-28T23:15:35" + } + ] + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Run" + } + }, + { + "href": "/configuration/vs/0", + "rep": {} + }, + { + "href": "/oven/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "rt": [ + "x.com.samsung.da.oven" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "true", + "rt": [ + "x.com.samsung.da.configuration" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "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": "0", + "x.com.samsung.da.current": "175", + "x.com.samsung.da.increment": "1", + "x.com.samsung.da.unit": "Fahrenheit" + } + ], + "rt": [ + "x.com.samsung.da.temperatures" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Bake", + "Broil", + "ConvectionBake", + "ConvectionRoast", + "KeepWarm", + "BreadProof", + "AirFryer", + "Dehydrate", + "SelfClean", + "SteamClean" + ], + "x.com.samsung.da.modes": [ + "NoOperation" + ], + "x.com.samsung.da.options": [ + "DeviceType_NE6516A-/AA0", + "SettingPossible_0", + "meatprobe_disconnected", + "UpperLamp_Off", + "Sound_On", + "AdjustingTemp_23", + "Sabbath_Off", + "EnergySaving_On", + "BurnerOnAlert_Off" + ], + "x.com.samsung.da.defaultMode": "ConvectionBake", + "x.com.samsung.da.modeSpec": "[{\"mode\":\"Bake\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"175\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"350\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"Broil\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"61441\",\"tempMaxC\":\"61442\",\"tempDefaultC\":\"61441\",\"tempListLengthC\":\"2\",\"tempListDataC\":[\"61441\",\"61442\"],\"tempMinF\":\"61441\",\"tempMaxF\":\"61442\",\"tempDefaultF\":\"61441\",\"tempListLengthF\":\"2\",\"tempListDataF\":[\"61441\",\"61442\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"ConvectionBake\",\"version\":\"0100\",\"default\":\"Default\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"325\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"ConvectionRoast\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"285\",\"tempDefaultC\":\"160\",\"tempListLengthC\":\"0\",\"tempMinF\":\"175\",\"tempMaxF\":\"550\",\"tempDefaultF\":\"325\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"KeepWarm\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"80\",\"tempMaxC\":\"80\",\"tempDefaultC\":\"80\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"80\"],\"tempMinF\":\"175\",\"tempMaxF\":\"175\",\"tempDefaultF\":\"175\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"175\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"BreadProof\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Setting\",\"cavity\":\"Single\",\"tempMinC\":\"35\",\"tempMaxC\":\"35\",\"tempDefaultC\":\"35\",\"tempListLengthC\":\"1\",\"tempListDataC\":[\"35\"],\"tempMinF\":\"95\",\"tempMaxF\":\"95\",\"tempDefaultF\":\"95\",\"tempListLengthF\":\"1\",\"tempListDataF\":[\"95\"],\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"AirFryer\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"175\",\"tempMaxC\":\"260\",\"tempDefaultC\":\"220\",\"tempListLengthC\":\"0\",\"tempMinF\":\"350\",\"tempMaxF\":\"500\",\"tempDefaultF\":\"425\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"Dehydrate\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"Start&Setting\",\"cavity\":\"Single\",\"tempMinC\":\"40\",\"tempMaxC\":\"105\",\"tempDefaultC\":\"65\",\"tempListLengthC\":\"0\",\"tempMinF\":\"100\",\"tempMaxF\":\"225\",\"tempDefaultF\":\"150\",\"tempListLengthF\":\"0\",\"timeMin\":\"00:01:00\",\"timeMax\":\"09:59:00\",\"timeDefault\":\"01:00:00\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"SelfClean\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"},{\"mode\":\"SteamClean\",\"version\":\"0100\",\"default\":\"Normal\",\"control\":\"NotSupported\",\"cavity\":\"Single\",\"tempMinC\":\"NotSupported\",\"tempMaxC\":\"NotSupported\",\"tempDefaultC\":\"NotSupported\",\"tempListLengthC\":\"0\",\"tempMinF\":\"NotSupported\",\"tempMaxF\":\"NotSupported\",\"tempDefaultF\":\"NotSupported\",\"tempListLengthF\":\"0\",\"timeMin\":\"NotSupported\",\"timeMax\":\"NotSupported\",\"timeDefault\":\"NotSupported\",\"probeMinC\":\"NotSupported\",\"probeMaxC\":\"NotSupported\",\"probeDefaultC\":\"NotSupported\",\"probeMinF\":\"NotSupported\",\"probeMaxF\":\"NotSupported\",\"probeDefaultF\":\"NotSupported\",\"powerDefault\":\"NotSupported\",\"powerListLength\":\"0\",\"tempIntervalC\":\"0\",\"tempIntervalF\":\"0\",\"probeIntervalC\":\"0\",\"probeIntervalF\":\"0\"}]", + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.operationTime": "00:00:00", + "x.com.samsung.da.remainingTime": "00:00:00", + "x.com.samsung.da.progressPercentage": "1", + "rt": [ + "x.com.samsung.da.operation" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/doors/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Door", + "x.com.samsung.da.openState": "Close", + "x.com.samsung.da.lock": "Lock" + } + ], + "rt": [ + "x.com.samsung.da.doors" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/connected/vs/0", + "rep": { + "x.com.samsung.da.connected": "On", + "rt": [ + "x.com.samsung.da.connected" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + }, + { + "href": "/cooktopmonitoring/vs/0", + "rep": { + "x.com.samsung.da.warmingCenterState": "Off", + "x.com.samsung.da.cooktopMonitoring": "0", + "x.com.samsung.da.cooktopRunningState": "Ready", + "supportedHoodLampStateList": [ + "Run", + "Ready" + ], + "rt": [ + "x.com.samsung.da.cooktopMonitoring" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 29303ea..4024099 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -917,6 +917,25 @@ def test_registry_reproduces_golden_state_keys_for_refrigerator_definite_cooler( ) +def test_registry_reproduces_golden_state_keys_for_range_ne6516a(): + """NE6516A-class range (issue #183) -- no /information/vs/0, resolved via + the same 'Bake'-in-supportedModes + /cooktopmonitoring/vs/0 signature as + the other no-info range fixtures. Its /mode/vs/0 options[] carries + EnergySaving_On and BurnerOnAlert_Off (previously unbound entirely) but + no fastpreheat_*/NaturalSteam_* tokens at all -- locks in that those two + switches now correctly stay unbound instead of binding as always-off, + does-nothing phantom controls.""" + from tests.conftest import _load_device + resources = _load_device('range_ne6516a') + golden = json.loads((GOLDEN / 'range_ne6516a.json').read_text()) + state_keys = _new_state_keys('range_ne6516a', 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 = { diff --git a/tests/test_range_ne6516a_capabilities.py b/tests/test_range_ne6516a_capabilities.py new file mode 100644 index 0000000..ccf905c --- /dev/null +++ b/tests/test_range_ne6516a_capabilities.py @@ -0,0 +1,62 @@ +"""Tests for the NE6516A-class range (issue #183). + +Same no-/information/vs/0, no-burner-status shape as issues #74/#138. Its +/mode/vs/0 options[] carries EnergySaving_On and BurnerOnAlert_Off -- both +previously unbound entirely -- but no fastpreheat_*/NaturalSteam_* tokens at +all, which is what exposed the pre-existing fast_preheat/natural_steam +phantom-switch bug (both were shipped with no exists_fn, so they bound +unconditionally, always read off, and any write went to a token this +firmware never recognized). +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_resources +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _range(): + resources = _load_device('range_ne6516a') + reg = for_device_by_resources(resources) + return reg, resources + + +def _state(): + reg, resources = _range() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_range_registry(): + reg, _ = _range() + assert reg is not None and reg.name == 'range' + + +def test_no_unbound_hrefs(): + reg, resources = _range() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_fast_preheat_and_natural_steam_absent_without_their_tokens(): + """Neither token is in this dump's options[] -- both switches must stay + unbound rather than silently reading as an always-off phantom control.""" + state = _state() + assert 'fast_preheat' not in state + assert 'natural_steam' not in state + + +def test_energy_saving_and_cooktop_alert_present(): + state = _state() + assert state['energy_saving'] is True + assert state['cooktop_on_alert'] is False + + +def test_child_lock_already_reads_correctly(): + """issue #183 also reported child lock as unreadable -- but this dump's + /kidslock/vs/0 ('Run') and /doors/vs/0's door lock field ('Lock') already + agree the door is locked, and the existing common.KIDS_LOCK_VS_FALLBACK + already reads that correctly with no code change needed.""" + state = _state() + assert state['child_lock'] is True From a003650d31cf12ebe5993dd90ea616e88318d226 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 13:33:26 +0000 Subject: [PATCH 8/9] perf(common): poll power state on the warm tier (#56) /power/0 and /power/vs/0 had no poll_tier, so they only ever refreshed on the once-per-30s summary poll -- everything else that drives real-time switch/climate/fan state (e.g. remote-control enablement) is already on the faster subscribe/subpoll 'warm' cadence for exactly this reason. Scoped to just this one tier bump; the model-specific priority-flip claim in the same issue thread isn't independently verified, so it isn't part of this change. --- .../localthings/registry/capabilities/common.py | 7 +++++++ tests/test_common_capabilities.py | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 4ed9bab..82fe42b 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -259,6 +259,12 @@ def sensor_item_value(items, sensor_type, index=0): POWER_GENERIC = Capability( href='/power/0', + # Neither href of this pair carried a poll_tier before (issue #56's + # follow-up), so power state only ever refreshed on the once-per-30s + # summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot' + # hrefs get -- the same "signal drives real-time state, but sat in the + # slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above. + poll_tier='warm', entities=( # Writable when firmware allows remote power; otherwise a read-only # binary_sensor with the same key keeps HA state without a dead switch. @@ -277,6 +283,7 @@ POWER_GENERIC = Capability( POWER_VS_FALLBACK = Capability( href='/power/vs/0', match_fn=lambda rep, resources: '/power/0' not in resources, + poll_tier='warm', entities=( SwitchDesc(key='power_switch', field='x.com.samsung.da.power', value_fn=lambda v: v == 'On', diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index d536165..37218cf 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -152,6 +152,14 @@ class TestPowerFallback: assert switch.exists_fn(resources['/power/0'], resources) is True assert sensor.exists_fn(resources['/power/0'], resources) is False + def test_polled_warm_so_power_state_reflects_quickly(self): + """issue #56 follow-up: neither href had a poll_tier, so power state + only ever refreshed on the once-per-30s summary poll instead of the + subscribe/subpoll cadence 'warm'/'hot' hrefs get -- same reasoning + as REMOTE_CONTROL_GENERIC/VS_FALLBACK's own 'warm' tier.""" + assert common.POWER_GENERIC.poll_tier == 'warm' + assert common.POWER_VS_FALLBACK.poll_tier == 'warm' + class TestWmSetinfoFlags: def test_washer_fixture_carries_setinfo_from_device0(self): From bd3ca80d3c21d1e1cf067b84f43d125c08b981ef Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Wed, 29 Jul 2026 18:04:23 +0000 Subject: [PATCH 9/9] review: address Opus code-review findings on this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config_flow: the #192 port-rescue made the "every port refused" fast-fail permanently unreachable (PREFERRED_PROBE_PORTS is always non-empty and always rescued), so removed the dead branch instead of leaving it as misleading dead code. A dead host now fails via the handshake loop's own error, which carries the real per-port reason. - oven._has_option: added the is_stub_rep carve-out cooktop.py's identical per-token exists_fn already has on the same kind of href, so a not-yet sub-polled /mode/vs/0 doesn't permanently exclude energy_saving/ cooktop_on_alert before their first real fetch lands. - airconditioner.ENERGY_METER_LEGACY: build via dataclasses.replace() like its ENERGY_METER_GENERIC sibling, instead of hand-copying href/poll_tier (which would silently drift if common.ENERGY_METER ever gains a field). - Fixed two stale comments: is_legacy_board()'s docstring still listed Volume among tokens needing legacy-only gating, though 'beep' now applies unconditionally across board generations; and common.py's UNIVERSAL invariant comment didn't mention that airconditioner also now excludes ENERGY_METER from the wholesale bundle. - #191 (CAC token): added the fixture/golden/capability-test coverage the AVT token in the same branch got, including an honestly-documented list of the ten hrefs this board generation doesn't cover yet. - fridge.cooler_temperature_setpoint: dropped the hardcoded "N °C" state labels -- the resource's own unit field isn't necessarily Celsius on a different model reporting the same href, and the options themselves are already read live via options_field, so a static per-value label risked asserting the wrong unit for a future device sharing this capability. - airconditioner._legacy_cumulative_power_kwh: parse with float (matching common.wh_to_kwh's own numeric parsing) instead of this module's integer-only _int, so a decimal-formatted reading doesn't raise. - test_config_flow: the port-rescue test bound the real 49154 directly, which could collide with an actually-running service on some machine; now monkeypatches PREFERRED_PROBE_PORTS to an OS-assigned port instead. - oven.py: consolidated six byte-for-byte-identical single-token options write_fns (lamp/sound/fast_preheat/natural_steam/energy_saving/ cooktop_on_alert) into one _option_switch_write(prefix) factory. 845 tests passing (up from 841 -- 4 new CAC coverage tests). --- custom_components/localthings/config_flow.py | 15 +- .../registry/capabilities/airconditioner.py | 33 +- .../registry/capabilities/common.py | 15 +- .../localthings/registry/capabilities/oven.py | 93 +- .../localthings/translations/en.json | 9 +- .../localthings/translations/nl.json | 9 +- tests/fixtures/airconditioner_cac_device.json | 919 ++++++++++++++++++ tests/fixtures/golden/airconditioner_cac.json | 30 + tests/localthings/test_config_flow.py | 33 +- tests/test_airconditioner_cac.py | 70 ++ tests/test_golden_regression.py | 20 + 11 files changed, 1129 insertions(+), 117 deletions(-) create mode 100644 tests/fixtures/airconditioner_cac_device.json create mode 100644 tests/fixtures/golden/airconditioner_cac.json create mode 100644 tests/test_airconditioner_cac.py diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index f0ca90b..66cad38 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -248,14 +248,13 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict: candidates = _find_live_ports( host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S ) - if not candidates: - # Every port in the range actively refused: there is no DTLS/CoAP - # listener on this host, so a handshake can't succeed. Fail now with a - # clear message instead of retrying doomed ports. - raise CannotConnect( - f"no live DTLS port found on {host} " - f"in {PROBE_PORT_RANGE[0]}-{PROBE_PORT_RANGE[-1]}" - ) + # No early "every port refused" fast-fail here: _find_live_ports always + # rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never + # empty as long as that table is non-empty and within PROBE_PORT_RANGE -- + # both true today, which made this branch permanently unreachable. A + # genuinely dead host now fails via the handshake loop's own error below, + # which carries the actual per-port timeout/refusal reason instead of a + # generic "no live port found" message. _LOGGER.debug("Live DTLS port candidates on %s: %s", host, candidates) last_exc = None diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 1d46e4f..1ed642a 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -288,13 +288,17 @@ def _option_token(rep, prefix): def is_legacy_board(resources): """True for the board generation whose airflow lives in /airflow/vs/0. - Newer families carry several of the same option tokens (Volume, Sleep, - OutdoorTemp, Autoclean) *alongside* dedicated resources for those settings, - so an ungated token entity would either duplicate an existing one or apply a - scale calibrated elsewhere. Every AC dump on record has one shape or the - other: /airflow/vs/0 with no /wind/* at all, or /wind/strength/vs/0 with no - /airflow/vs/0. Same test as climate.py's _legacy_airflow(), so the entities - below and the climate entity can never disagree about the generation. + Newer families carry several of the same option tokens (Sleep, OutdoorTemp, + Autoclean) *alongside* dedicated resources for those settings, so an + ungated token entity would either duplicate an existing one or apply a + scale calibrated elsewhere. (Volume used to be in this list too, back when + it had its own gated buzzer_volume Number for this board generation -- + issue #136 replaced that with the unified 'beep' switch, which applies + across every generation and isn't gated here at all.) Every AC dump on + record has one shape or the other: /airflow/vs/0 with no /wind/* at all, + or /wind/strength/vs/0 with no /airflow/vs/0. Same test as climate.py's + _legacy_airflow(), so the entities below and the climate entity can never + disagree about the generation. """ return HREF_AIRFLOW in resources and HREF_WIND_STRENGTH not in resources @@ -310,13 +314,18 @@ def is_legacy_board(resources): # 'energy_kwh' needs a replacement value_fn here; the rest pass through # unchanged in case a future legacy dump ever reports them. def _legacy_cumulative_power_kwh(v): - n = _int(v) - return round(n / 100000.0, 2) if n is not None else None + # float, not _int -- matches common.wh_to_kwh's own numeric parsing + # (float via _num) rather than this module's integer-only _int, so a + # decimal-formatted reading doesn't raise and silently go 'unknown'. + try: + n = float(v) + except (TypeError, ValueError): + return None + return round(n / 100000.0, 2) -ENERGY_METER_LEGACY = Capability( - href=common.ENERGY_METER.href, - poll_tier=common.ENERGY_METER.poll_tier, +ENERGY_METER_LEGACY = replace( + common.ENERGY_METER, match_fn=lambda rep, resources: is_legacy_board(resources), entities=tuple( replace(e, value_fn=_legacy_cumulative_power_kwh) diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 82fe42b..45889b4 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -611,15 +611,22 @@ SELF_CHECK = Capability( # level: 2 of 6 families confirmed, blanket-added everywhere else). # # POWER is kept separate -- airconditioner is the one family that opts out -# of it. Canonical reason (see by_type/airconditioner.py and its test for -# pointers back here, not restatements): AC's climate entity already owns -# /power/0 and /power/vs/0 via bare, no-entity Capability objects -# (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ +# of it entirely. Canonical reason (see by_type/airconditioner.py and its +# test for pointers back here, not restatements): AC's climate entity +# already owns /power/0 and /power/vs/0 via bare, no-entity Capability +# objects (airconditioner.COVERAGE), and a second, real POWER_GENERIC/ # POWER_VS_FALLBACK cap on the same href would make _build() raise (a href # with >1 cap must have every cap discriminated by rt_filter/match_fn, and # the bare COVERAGE cap has neither). Kids-lock/remote-control don't have # this conflict -- no AC dump has ever reported those hrefs -- so they stay # in UNIVERSAL. +# +# Airconditioner also partially opts out of UNIVERSAL itself, not just +# POWER: issue #193 needs ENERGY_METER's cumulativePower scale to differ by +# board generation, so by_type/airconditioner.py excludes just that one +# member (`*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER]`) +# and substitutes airconditioner.ENERGY_METER_GENERIC/ENERGY_METER_LEGACY in +# its place -- every other registry still unpacks UNIVERSAL wholesale. # --------------------------------------------------------------------------- UNIVERSAL = ( diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 8c20b7f..252416f 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -24,6 +24,7 @@ but is effectively read-only in practice. """ from datetime import datetime, timezone, timedelta +from ..batch import is_stub_rep from ..capability import Capability from ..entities import ( BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, @@ -140,8 +141,15 @@ def _has_option(prefix): options[] at all, so both switches were phantom controls: always read as off, and toggling them wrote a token the firmware never recognized in the first place, hence "does not appear to do anything." + + `is_stub_rep(rep) or` keeps the same stub carve-out as cooktop.py's + identical per-token exists_fn on its own options[]-array href: a stub + /device/0 seed rep (not yet sub-polled) has no options[] at all, and + without this an entity whose token is genuinely present would never get + a first chance to bind, since exists_fn runs before that first real + fetch lands. """ - return lambda rep, resources: _option_value( + return lambda rep, resources: is_stub_rep(rep) or _option_value( rep.get('x.com.samsung.da.options'), prefix) is not None @@ -214,64 +222,19 @@ def _oven_mode_write(p, rep, href=None): return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} -def _lamp_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('UpperLamp', p), - } - - -def _sound_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('Sound', p), - } - - -def _fastpreheat_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('fastpreheat', p), - } - - -def _naturalsteam_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('NaturalSteam', p), - } - - -def _energysaving_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('EnergySaving', p), - } - - -def _burneronalert_write(p, rep, href=None): - if p not in ('On', 'Off'): - return None - if not rep.get('x.com.samsung.da.options'): - return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': _option_write('BurnerOnAlert', p), - } +def _option_switch_write(prefix): + """Factory for a single-token on/off options-array write -- lamp, sound, + fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all + a byte-for-byte copy of this same shape, one per prefix.""" + def write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + if not rep.get('x.com.samsung.da.options'): + return None + return ['mode', 'vs', '0'], { + 'x.com.samsung.da.options': _option_write(prefix, p), + } + return write # --------------------------------------------------------------------------- @@ -412,22 +375,22 @@ OVEN_MODE = Capability( SwitchDesc(key='lamp', field='x.com.samsung.da.options', icon='mdi:track-light', value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On', - write_fn=_lamp_write), + write_fn=_option_switch_write('UpperLamp')), SwitchDesc(key='sound', field='x.com.samsung.da.options', icon='mdi:volume-high', entity_category='config', value_fn=lambda opts: _option_value(opts, 'Sound') == 'On', - write_fn=_sound_write), + write_fn=_option_switch_write('Sound')), SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options', icon='mdi:fire', exists_fn=_has_option('fastpreheat'), value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On', - write_fn=_fastpreheat_write), + write_fn=_option_switch_write('fastpreheat')), SwitchDesc(key='natural_steam', field='x.com.samsung.da.options', icon='mdi:kettle-steam', exists_fn=_has_option('NaturalSteam'), value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On', - write_fn=_naturalsteam_write), + write_fn=_option_switch_write('NaturalSteam')), # 120-hour energy-saving standby (issue #183): confirmed present in # this unit's options[] (EnergySaving_On) and directly requested -- # unlike fast_preheat/natural_steam above, this token is real on this @@ -436,7 +399,7 @@ OVEN_MODE = Capability( icon='mdi:leaf', entity_category='config', exists_fn=_has_option('EnergySaving'), value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On', - write_fn=_energysaving_write), + write_fn=_option_switch_write('EnergySaving')), # Cooktop-on alert (issue #183): also confirmed present # (BurnerOnAlert_Off) though the reporter noted it mainly matters for # the SmartThings app's own alerting, not local automation. @@ -444,6 +407,6 @@ OVEN_MODE = Capability( icon='mdi:alert-circle-outline', entity_category='config', exists_fn=_has_option('BurnerOnAlert'), value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On', - write_fn=_burneronalert_write), + write_fn=_option_switch_write('BurnerOnAlert')), ), ) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 4e5b414..45382cb 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -212,14 +212,7 @@ } }, "cooler_temperature_setpoint": { - "name": "Cooler temperature", - "state": { - "1": "1 °C", - "2": "2 °C", - "3": "3 °C", - "4": "4 °C", - "7": "7 °C" - } + "name": "Cooler temperature" }, "day_brightness": { "name": "Cabinet brightness", diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 9e64254..777c496 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -212,14 +212,7 @@ } }, "cooler_temperature_setpoint": { - "name": "Koeler temperatuur", - "state": { - "1": "1 °C", - "2": "2 °C", - "3": "3 °C", - "4": "4 °C", - "7": "7 °C" - } + "name": "Koeler temperatuur" }, "day_brightness": { "name": "Helderheid kast", diff --git a/tests/fixtures/airconditioner_cac_device.json b/tests/fixtures/airconditioner_cac_device.json new file mode 100644 index 0000000..1cfe5ff --- /dev/null +++ b/tests/fixtures/airconditioner_cac_device.json @@ -0,0 +1,919 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airlevelcheck/vs/0", + "rep": { + "x.com.samsung.da.periodicSensingInterval": "1800", + "x.com.samsung.da.sensingState": "NonProcessing", + "x.com.samsung.da.lastSensingTime": "1785327477", + "x.com.samsung.da.lastSensingLevel": "Kr1", + "x.com.samsung.da.periodicSensingSkipStatus": "Off", + "x.com.samsung.da.periodicSensingSkipTime": "00000000", + "x.com.samsung.da.periodicSensingActivationState": "On", + "x.com.samsung.da.autoExeState": "Alarm", + "x.com.samsung.da.supportedAutoExeState": [ + "Airpurify", + "Alarm", + "Sensing" + ], + "x.com.samsung.da.startSensingOnce": "Off", + "x.com.samsung.da.autoExeSetting": "On", + "x.com.samsung.da.supportedAutoExeSetting": [ + "On", + "Off" + ] + } + }, + { + "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-29T12:19:55", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "DA_SAC_M_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-29T12:19:55", + "x.com.samsung.da.state": "Deleted" + } + ], + "rt": [ + "x.com.samsung.da.alarms" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000100B4012C0000B20001000000", + "x.com.samsung.da.id": "CAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "4146100000", + "x.com.samsung.da.airconOptionList": [ + "AI_KOREA_COOLONLY_4.0", + "HOMECARE_WIZARD_V2", + "CLEAN_OPTION_SUPPORT", + "DR", + "SingleCommand_1" + ] + } + }, + { + "href": "/da/softreset/vs/0", + "rep": { + "x.com.samsung.da.softwarereset": "false" + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.start": "2026-07-29T07:26:56Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.durationminutes": "1019", + "x.com.samsung.da.drlcLevel": "1", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/edgelighting/vs/0", + "rep": { + "status": "On", + "convenientMode": "On", + "operatingOption": "On", + "welcomeLightStatus": "Off", + "colorOption": "3000K", + "mode": "Smart", + "modeSupportedList": [ + "Smart", + "High", + "Low" + ], + "colorSupportedList": [ + "3000K", + "4000K", + "6500K" + ] + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeDate": "1785327589", + "x.com.samsung.da.instantaneousPower": "15", + "x.com.samsung.da.cumulativeSavedPower": "1611", + "x.com.samsung.da.cumulativePower": "84044", + "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/airdustPM1filter/vs/0", + "rep": { + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterCapacity": "2400", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "11" + } + }, + { + "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": [ + "washable" + ], + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterUsage": "26" + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0", + "x.com.samsung.da.fivepercentHumidity": "59" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.modelNum": "TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00", + "x.com.samsung.da.description": "TP1X_DA-AC-CAC-01001_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": "02706A260401", + "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": "02555A25052600,02556A24040200" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "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": "AS3", + "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/stateful/vs/0", + "rep": { + "status": "On", + "mode": "Smart", + "supportedModes": [ + "Smart", + "Low", + "High" + ] + } + }, + { + "href": "/mds/absenceclean/vs/0", + "rep": { + "mode": "Off", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/mds/absencemonitoring/vs/0", + "rep": { + "status": "Off" + } + }, + { + "href": "/mds/absencepowersaving/vs/0", + "rep": { + "status": "On", + "modes": "Cooling", + "switchPowerSaveMode": "Normal", + "supportedModes": [ + "Standby", + "Cooling" + ], + "supportedSwitchPowerSaveMode": [ + "Eco", + "Normal", + "Comfort" + ] + } + }, + { + "href": "/mds/absencestate/vs/0", + "rep": { + "status": "On", + "absenceTime": "0", + "maxDetectCount": [ + "0", + "0", + "0", + "0", + "0", + "0", + "3", + "3", + "3", + "2", + "3", + "0", + "0", + "3", + "0", + "3", + "3", + "3", + "0", + "3", + "1", + "2", + "3", + "3", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "0", + "1", + "0", + "3", + "2", + "3", + "1", + "2", + "1", + "0", + "0", + "0", + "0" + ], + "supportedTimes": [ + "0", + "30", + "60", + "120" + ] + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Nano", + "LongWind", + "Speed", + "Sleep", + "NanoSleep" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.modes": [ + "Cool" + ], + "workingMode": "Cool", + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Auto", + "Cool", + "Dry", + "Fan" + ], + "x.com.samsung.da.options": [ + "OptionCode_512", + "Sleep_0", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_240", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None", + "welcomecare_Off", + "DiagnosisAI_Off", + "ProgressDiagnosisAI_0", + "ResultDiagnosisAI_Normal", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "FreezeAlarmSetting_On", + "DesiredFreezeAlarm_112", + "ExtendOptionCode_0", + "UpdateAllow_NotAllowed", + "DurationOn_0", + "OutdoorTemp_55", + "KeyInputPermit_On", + "ModePermit_NoLimit" + ], + "rt": [ + "x.com.samsung.da.mode" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.current": "28.5", + "x.com.samsung.da.desired": "28.0", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "0.5", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/option/airpurify/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "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": "On", + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "Off" + ], + "defaultSettingStatus": "On" + } + }, + { + "href": "/option/motiondetectwind/stateful/vs/0", + "rep": { + "status": "On", + "modes": "Indirect", + "supportedModes": [ + "Indirect", + "Direct" + ] + } + }, + { + "href": "/option/muteonce/vs/0", + "rep": { + "muteonce": "Off" + } + }, + { + "href": "/personality/presence/vs/0", + "rep": {} + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off", + "operationNumber": "46" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotedatacontrol/vs/0", + "rep": { + "x.com.samsung.da.status": "Off", + "x.com.samsung.da.connectionStatus": "Disconnected" + } + }, + { + "href": "/remotetemperature/vs/0", + "rep": { + "x.com.samsung.da.temperature": "", + "x.com.samsung.da.unit": "", + "x.com.samsung.da.error": "" + } + }, + { + "href": "/reserverulesets/vs/0", + "rep": { + "x.com.samsung.da.sets": "1F121E121E121E121EFFFFFFFF010001000F0001000E0000001C3E", + "x.com.samsung.da.id": "CAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/sac/installationinfo/vs/0", + "rep": { + "installationOptions": [ + { + "id": "0", + "option": "184449945E0484800017" + }, + { + "id": "1", + "option": "28010000010000000100" + }, + { + "id": "2", + "option": "50000000000000000001" + }, + { + "id": "3", + "option": "30000000000000000000" + } + ], + "installationDeviceNum": [ + { + "id": "0", + "number": "0" + }, + { + "id": "1", + "number": "5" + }, + { + "id": "2", + "number": "0" + } + ], + "outdoorInfo": [ + { + "id": "0", + "info": "02571A250514", + "serial": "**REDACTED**" + }, + { + "id": "1", + "info": "000000000000", + "serial": "**REDACTED**" + }, + { + "id": "2", + "info": "000000000000", + "serial": "**REDACTED**" + }, + { + "id": "3", + "info": "000000000000", + "serial": "**REDACTED**" + } + ], + "mcuInfo": [ + { + "id": "0", + "info": "000000000000" + }, + { + "id": "1", + "info": "000000000000" + }, + { + "id": "2", + "info": "000000000000" + }, + { + "id": "3", + "info": "000000000000" + }, + { + "id": "4", + "info": "000000000000" + }, + { + "id": "5", + "info": "000000000000" + }, + { + "id": "6", + "info": "000000000000" + }, + { + "id": "7", + "info": "000000000000" + }, + { + "id": "8", + "info": "000000000000" + }, + { + "id": "9", + "info": "000000000000" + }, + { + "id": "10", + "info": "000000000000" + }, + { + "id": "11", + "info": "000000000000" + }, + { + "id": "12", + "info": "000000000000" + }, + { + "id": "13", + "info": "000000000000" + }, + { + "id": "14", + "info": "000000000000" + } + ] + } + }, + { + "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": "/sensors/vs/0", + "rep": { + "x.com.samsung.da.cleanLevel": "1", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Sensor for CleanLevel", + "x.com.samsung.da.type": "CleanLevel", + "x.com.samsung.da.value": [ + "1" + ] + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Sensor for Dust", + "x.com.samsung.da.type": "Dust", + "x.com.samsung.da.value": [ + "7", + "1" + ] + }, + { + "x.com.samsung.da.id": "3", + "x.com.samsung.da.description": "Sensor for FineDust", + "x.com.samsung.da.type": "FineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + }, + { + "x.com.samsung.da.id": "4", + "x.com.samsung.da.description": "Sensor for SuperFineDust", + "x.com.samsung.da.type": "SuperFineDust", + "x.com.samsung.da.value": [ + "5", + "1" + ] + } + ] + } + }, + { + "href": "/settings/sound/mode/vs/0", + "rep": { + "mode": "voice", + "supportedModes": [ + "mute", + "tone", + "voice" + ] + } + }, + { + "href": "/settings/sound/optimization/vs/0", + "rep": { + "status": "Off", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/settings/sound/output/vs/0", + "rep": { + "deviceType": "speaker" + } + }, + { + "href": "/settings/sound/volume/vs/0", + "rep": { + "level": "2", + "minLevel": "0", + "maxLevel": "3", + "resolution": "1" + } + }, + { + "href": "/smartsensingcooling/vs/0", + "rep": { + "status": "On", + "supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "0.5" + } + }, + { + "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": "28.5", + "x.com.samsung.da.desired": "28.0", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.increment": "0.5", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/uvled/vs/0", + "rep": { + "x.com.samsung.da.modes": "On", + "x.com.samsung.da.start": "Cancel", + "x.com.samsung.da.pushAlarmStatus": "Off", + "x.com.samsung.da.supportedModes": [ + "On", + "Off" + ] + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "Fix", + "x.com.samsung.da.supportedModes": [ + "Left_And_Right", + "All" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "0", + "x.com.samsung.da.supportedModes": [ + "0", + "1", + "2", + "3" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Low", + "Mid", + "High" + ] + } + }, + { + "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": "ASA-WW-TP1-24-INFSAC", + "versions": [ + "14260401" + ], + "visVersion": "260401" + }, + { + "type": "Micom", + "modelId": "04001025554110255641", + "versions": [ + "25052600", + "24040200" + ], + "visVersion": "250526" + }, + { + "type": "Micom", + "modelId": "FGCAN2", + "versions": [ + "", + "" + ], + "visVersion": "999999" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09: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": "boksil" + } + }, + { + "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 + } + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/golden/airconditioner_cac.json b/tests/fixtures/golden/airconditioner_cac.json new file mode 100644 index 0000000..e26afee --- /dev/null +++ b/tests/fixtures/golden/airconditioner_cac.json @@ -0,0 +1,30 @@ +{ + "state_keys": [ + "absence_power_saving_active", + "absence_power_saving_mode", + "air_filter_status", + "air_filter_usage", + "air_filter_usage_hours", + "air_purify", + "alarm_code", + "auto_clean", + "clean_level", + "climate", + "current_temperature_c", + "dust", + "energy_kwh", + "energy_saved_kwh", + "fine_dust", + "firmware_update", + "humidity", + "motion_detect_wind_active", + "motion_detect_wind_mode", + "mute_once", + "power_watts", + "selfcheck_error", + "selfcheck_result", + "selfcheck_status", + "super_fine_dust", + "tropical_night_mode" + ] +} \ No newline at end of file diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index bafeebc..8d40bd3 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -106,35 +106,44 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None: def test_find_live_ports_rescues_preferred_ports_the_sweep_missed( - socket_enabled, + socket_enabled, monkeypatch, ) -> None: """Issue #192: a segregated VLAN made the ICMP-based sweep call three - closed ports live while missing the one port (49154, a historically - confirmed DTLS port) that nmap showed as genuinely open|filtered. The - sweep's verdict on a preferred port shouldn't be trusted blindly -- - it must always come back as a candidate even if the sweep marked it - dead, so the config flow gets a real handshake attempt against it.""" + closed ports live while missing the one port (a historically confirmed + DTLS port) that nmap showed as genuinely open|filtered. The sweep's + verdict on a preferred port shouldn't be trusted blindly -- it must + always come back as a candidate even if the sweep marked it dead, so the + config flow gets a real handshake attempt against it. + + Uses an OS-assigned port monkeypatched into PREFERRED_PROBE_PORTS rather + than the real 49154/49155, so this doesn't depend on those specific + system ports being free on whatever machine runs the suite. + """ import socket + from custom_components.localthings import config_flow from custom_components.localthings.config_flow import _find_live_ports - # Bind and immediately close a socket on 49154 so loopback refuses - # datagrams to it -- standing in for the sweep wrongly ruling out a - # port we have strong prior evidence for. + # Bind an OS-assigned port and immediately close it, same technique + # test_find_live_ports_detects_silent_port uses for its "closed" ports -- + # once closed, loopback refuses datagrams to it, standing in for the + # sweep wrongly ruling out a port we have strong prior evidence for. reserved = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - reserved.bind(('127.0.0.1', 49154)) + reserved.bind(('127.0.0.1', 0)) + preferred_port = reserved.getsockname()[1] reserved.close() + monkeypatch.setattr(config_flow, 'PREFERRED_PROBE_PORTS', [preferred_port]) live_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) live_sock.bind(('127.0.0.1', 0)) live_port = live_sock.getsockname()[1] try: - result = _find_live_ports('127.0.0.1', [49154, live_port], 0.8) + result = _find_live_ports('127.0.0.1', [preferred_port, live_port], 0.8) finally: live_sock.close() - assert set(result) == {49154, live_port} + assert set(result) == {preferred_port, live_port} async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch) -> None: diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py new file mode 100644 index 0000000..586c365 --- /dev/null +++ b/tests/test_airconditioner_cac.py @@ -0,0 +1,70 @@ +"""Tests for the TP1X_DA-AC-CAC-01001_0000 cassette AC (issue #191). + +0.16.0's device-type simplification dropped oneUiVersion detection on the +assumption every device it typed was already reachable via a modelNum board +token -- this board was the one exception (its oneUiVersion self-reports +"7.0 Air conditioner", but 'CAC' had never been added to the board-token +table), so it silently fell back to common caps and lost its climate entity. + +This dump is NOT fully covered yet -- ten hrefs remain unbound (edge +lighting, PM1 filter, a second stateful light resource, absence-clean, four +`/settings/sound/*` resources, smart-sensing-cooling, UV LED), all genuinely +new to this board generation. That's a real device-support gap, left +documented here rather than guessed at, per the 'don't guess' rule -- fixing +the routing regression was the scope of #191. +""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import for_device_by_model +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + +_STILL_UNBOUND = frozenset({ + '/edgelighting/vs/0', + '/filter/airdustPM1filter/vs/0', + '/light/stateful/vs/0', + '/mds/absenceclean/vs/0', + '/settings/sound/mode/vs/0', + '/settings/sound/optimization/vs/0', + '/settings/sound/output/vs/0', + '/settings/sound/volume/vs/0', + '/smartsensingcooling/vs/0', + '/uvled/vs/0', +}) + + +def _resources(): + return _load_device('airconditioner_cac') + + +def _reg(resources): + info = resources['/information/vs/0'] + return for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + + +def test_resolves_to_airconditioner_registry(): + assert _reg(_resources()).name == 'airconditioner' + + +def test_documented_coverage_gap_is_exactly_this_set(): + """Locks in the current, known-incomplete coverage so a future fix to + any of these hrefs shows up as a golden-regression diff (extra keys) to + update here, rather than silently shrinking this list unnoticed.""" + resources = _resources() + reg = _reg(resources) + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert set(unbound) == _STILL_UNBOUND + + +def test_non_legacy_board_uses_the_generic_energy_scale(): + """This board reports /wind/strength/vs/0 (not /airflow/vs/0), so + is_legacy_board() is False and it must use the plain wh_to_kwh scale, + not the /100000 correction added for the unrelated ARTIK051_KRAC-class + board in issue #193.""" + resources = _resources() + reg = _reg(resources) + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + state = flatten(bound, resources) + assert state['energy_kwh'] == round(84044 / 1000.0, 2) diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 4024099..6510c5d 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -859,6 +859,26 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): ) +def test_registry_reproduces_golden_state_keys_for_airconditioner_cac(): + """TP1X_DA-AC-CAC-01001_0000 (issue #191) -- fell back to 'unknown' in + 0.16.0 when oneUiVersion detection was dropped, since 'CAC' had never + been added to the modelNum board-token table. Resolved via the new 'CAC' + token onto the existing airconditioner registry. Not fully covered yet -- + ten hrefs remain unbound (edge lighting, PM1 filter, stateful light, + absence-clean, four sound-settings resources, smart-sensing-cooling, UV + LED), all genuinely new to this board generation and out of scope for + the routing fix; see test_airconditioner_cac.py for the documented gap.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_cac') + golden = json.loads((GOLDEN / 'airconditioner_cac.json').read_text()) + state_keys = _new_state_keys('airconditioner_cac', 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_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww(): """AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board; reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a