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