From 75f2be7aaf63c3f964dc93ea7fbabd49b4a0dbf5 Mon Sep 17 00:00:00 2001 From: perseus177 Date: Tue, 28 Jul 2026 02:28:13 +0200 Subject: [PATCH 1/4] fix(registry): resolve ARTIK051_KRAC_18K to the airconditioner registry Room air conditioners on the ARTIK051 board (ARTIK051_KRAC_18K, issue #136) report no oneUiVersion and carry a '_KRAC_' token in modelNum. The existing '_RAC_' check can't see it -- the 'K' sits between the underscore and 'RAC' -- and the consumer-prefix fallback only covers washers/dryers/dishwashers, so these units fell back to 'unknown': 8 of their 19 resources ended up unbound and the device exposed nothing but a power switch. Same ARTIK051 board family as the '_TVTL_' air purifier handled just below. --- .../localthings/registry/by_type/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 4803a26..1a9b9aa 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -212,6 +212,14 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi key = 'airconditioner' # Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no # oneUiVersion either, and carry the '_TVTL_' board-family token. + # Room air conditioners on the ARTIK051 board (e.g. ARTIK051_KRAC_18K, + # issue #136) report no oneUiVersion and carry a '_KRAC_' token. The '_RAC_' + # check above cannot see it -- the 'K' sits between the underscore and 'RAC' -- + # and the consumer-prefix fallback only covers washers/dryers/dishwashers, so + # these units fell back to 'unknown' and exposed nothing but power. Same + # ARTIK051 board family as the '_TVTL_' air purifier below. + if key is None and '_KRAC_' in (model_num or ''): + key = 'airconditioner' if key is None and '_TVTL_' in (model_num or ''): key = 'air_purifier' model_identity = f'{model_num} {description}'.upper() From 5b30099c42ee5ef131c396efebd1841cc4a951ce Mon Sep 17 00:00:00 2001 From: perseus177 Date: Tue, 28 Jul 2026 02:28:33 +0200 Subject: [PATCH 2/4] feat(airconditioner): fan, swing, presets and option-token settings on ARTIK051 boards This board generation predates every AC dump the registry was built from and differs in three ways, all handled here behind presence checks so no other family's behaviour changes: * No /wind/* resources at all. Fan speed and vane direction share a single /airflow/vs/0 resource, whose speedLevel uses the same 0-4 scale as _DEVICE_TO_FAN and whose direction uses the same codes as _DEVICE_TO_SWING, so the existing maps are reused rather than duplicated. The resource reports no supportedModes, so the full scale is offered. * No /mode/convenient/vs/0. The convenient-mode preset is a Comode_* token in /mode/vs/0's options, synthesised into a convenient-shaped rep so the existing dynamic preset resolver keeps working unchanged. Codes were learned by driving one unit through its cloud integration and reading the token back each time: Nano is what the app calls WindFree, plus Quiet/Comfort/2Step/ Speed (Fast Turbo) and Off. * Several settings that newer boards expose as dedicated resources are options tokens here: SPI, auto clean, air monitoring, beep volume, Good Sleep, outdoor temperature and filter time. Writes reuse option_write's single-token merge, the same mechanism the display light already uses on this href. Newer families carry some of the same tokens *alongside* dedicated resources for those settings, so the token entities are gated on this generation's resource shape (/airflow/vs/0 present, /wind/strength/vs/0 absent -- the same test the climate entity's fan/swing fallback uses, so the two can never disagree). Without that gate they duplicated auto clean on TP1X/TP2X boards and applied a calibration from this board to theirs. Two calibrations, both from hardware rather than from the token names: OutdoorTemp is offset by 55 (token 75 against a 20.3 C outdoor thermometer in the same install, token 74 against a 19.4 C forecast; Fahrenheit fits far worse), and FilterTime is tenths of an hour (token 1710 while the official Samsung app displayed "171 hours 0 minutes" for the same unit's filter). Whether filter time counts up or down is deliberately not claimed: it was seen rising while the unit ran, which contradicts the app's "remaining" wording. Humidity now falls back to the plain x.com.samsung.da.humidity field where fivepercentHumidity is absent, still as one entity rather than two, and 0 reads as "not measuring" rather than 0% -- on this board the field only carries a reading (51%, matching the same unit's cloud integration) while Air monitoring is on, which the unit switches back off by itself after about a minute. Fan, swing, preset, SPI and beep-volume writes were confirmed by read-back on hardware. Good Sleep's upper bound is a guess (only 0 has been observed), and /airflow/0 -- the OCF-standard mirror of the vendor resource -- is ignored rather than modelled, since air_purifier.py found the opposite reliability ordering between these two hrefs on its own family. Refs #136 --- custom_components/localthings/climate.py | 66 ++++++- .../registry/capabilities/airconditioner.py | 184 +++++++++++++++++- .../localthings/translations/en.json | 18 ++ .../localthings/translations/nl.json | 18 ++ 4 files changed, 277 insertions(+), 9 deletions(-) diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 43e9aff..5ba2137 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -50,6 +50,7 @@ from .registry.capabilities.airconditioner import ( HREF_WIND_DIRECTION as WIND_DIRECTION_HREF, HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF, HREF_CONVENIENT as CONVENIENT_HREF, + HREF_AIRFLOW as AIRFLOW_HREF, ) from .registry.capabilities.common import normalize_temp_unit @@ -228,8 +229,45 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # -- resource helpers --------------------------------------------------- + # Preset codes on legacy ARTIK051 boards, learned by driving the same unit + # through its cloud integration and reading the local token back each time: + # Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none. + _LEGACY_PRESET_CODES = ('Off', 'Nano', 'Quiet', 'Comfort', '2Step', 'Speed') + + def _legacy_convenient(self) -> dict: + """A /mode/convenient/vs/0-shaped rep built from the Comode_* token in + /mode/vs/0's options, for boards that have no convenient resource.""" + options = (self.coordinator.resource(MODE_HREF) or {}).get( + 'x.com.samsung.da.options') or [] + for option in options: + if isinstance(option, str) and option.startswith('Comode_'): + return {_MODES_FIELD: [option.split('_', 1)[1]], + _SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES)} + return {} + + def _legacy_airflow(self) -> dict: + """The /airflow/vs/0 rep, but only when it is the fan/swing channel to + use -- i.e. this board has no /wind/strength/vs/0.""" + if self.coordinator.resource(WIND_STRENGTH_HREF): + return {} + return self.coordinator.resource(AIRFLOW_HREF) or {} + + def _legacy_preset(self) -> bool: + """Whether presets come from the Comode_* token rather than a resource. + + Gated on the same board test as _legacy_airflow, not on the convenient + rep being empty alone: newer boards carry Comode tokens too, so a + momentarily empty /mode/convenient/vs/0 there must not silently switch + the preset read (and write) over to the token path. + """ + return (not self.coordinator.resource(CONVENIENT_HREF) + and bool(self._legacy_airflow())) + def _rep(self, href: str) -> dict: - return self.coordinator.resource(href) or {} + rep = self.coordinator.resource(href) or {} + if not rep and href == CONVENIENT_HREF and self._legacy_airflow(): + return self._legacy_convenient() + return rep def _is_on(self) -> bool: # Prefer the vendor /power/vs/0 (present on every observed board and @@ -373,10 +411,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def fan_mode(self): + airflow = self._legacy_airflow() + if airflow: + return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel'))) return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) @property def fan_modes(self) -> list[str]: + if self._legacy_airflow(): + # This resource carries no supportedModes, so the full scale is offered. + return list(_DEVICE_TO_FAN.values()) return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN) def _swing_via_direction(self) -> bool: @@ -387,12 +431,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): @property def swing_mode(self): + airflow = self._legacy_airflow() + if airflow: + return _DEVICE_TO_SWING.get(airflow.get('x.com.samsung.da.direction')) if self._swing_via_direction(): return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF)) @property def swing_modes(self) -> list[str]: + if self._legacy_airflow(): + return list(_SWING_TO_DEVICE.keys()) if self._swing_via_direction(): return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING) if self._rep(WIND_OSCILLATION_HREF): @@ -468,9 +517,21 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): await self.coordinator.async_send_command(self._bound, (kind, device)) async def async_set_fan_mode(self, fan_mode: str) -> None: + if self._legacy_airflow(): + level = _FAN_TO_DEVICE.get(fan_mode) + if level is not None: + await self.coordinator.async_send_command( + self._bound, ('fan_legacy', level)) + return await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode) async def async_set_swing_mode(self, swing_mode: str) -> None: + if self._legacy_airflow(): + code = _SWING_TO_DEVICE.get(swing_mode) + if code is not None: + await self.coordinator.async_send_command( + self._bound, ('swing_legacy', code)) + return if self._swing_via_direction(): await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode) return @@ -489,5 +550,6 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep'). for code in self._supported(CONVENIENT_HREF): if _preset_to_ha(code) == preset_mode: - await self.coordinator.async_send_command(self._bound, ('preset', code)) + kind = 'preset_legacy' if self._legacy_preset() else 'preset' + await self.coordinator.async_send_command(self._bound, (kind, code)) return diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 91f0466..5958018 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -15,7 +15,9 @@ different schema (see capabilities/__init__.py). They live only in the AC by_type registry. """ from ..capability import Capability -from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc +from ..entities import ( + BinarySensorDesc, ClimateDesc, NumberDesc, SensorDesc, SwitchDesc, +) from .common import filter_usage_percent, normalize_temp_unit from .laundry import option_write @@ -43,11 +45,18 @@ HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array) +# Legacy ARTIK051 boards (ARTIK051_KRAC_18K, issue #136) carry no /wind/* resources +# at all: fan speed and vane direction sit together in this one resource, as +# x.com.samsung.da.speedLevel (the same 0-4 scale as _DEVICE_TO_FAN) and +# x.com.samsung.da.direction (the same codes as _DEVICE_TO_SWING). Their +# convenient-mode preset is a Comode_* token in /mode/vs/0's options instead of a +# resource of its own -- see climate.py's _legacy_airflow/_legacy_convenient. +HREF_AIRFLOW = '/airflow/vs/0' # legacy fan_mode + swing_mode CLIMATE_CONSUMED_HREFS = [ HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, - HREF_WIND_OSCILLATION, HREF_CONVENIENT, + HREF_WIND_OSCILLATION, HREF_CONVENIENT, HREF_AIRFLOW, ] @@ -127,6 +136,91 @@ def _display_light_write(payload, rep, href=None): {'x.com.samsung.da.options': option_write('Light', token)}) +# --------------------------------------------------------------------------- +# Legacy ARTIK051 boards keep several settings that newer boards expose as their +# own resources (/option/*, /electriccurrent/vs/0, ...) as `_` +# tokens inside /mode/vs/0's options blob instead. Reads pull the token apart; +# writes reuse option_write's single-token merge, exactly like the display light. +# --------------------------------------------------------------------------- + + +def _option_token(rep, prefix): + """Value part of a `_` token in /mode/vs/0's options.""" + for option in _mode_options(rep): + if isinstance(option, str) and option.startswith(prefix + '_'): + return option.split('_', 1)[1] + return None + + +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. + """ + return ('/airflow/vs/0' in resources + and '/wind/strength/vs/0' not in resources) + + +def _has_option_token(prefix): + return lambda rep, resources: ( + _is_legacy_board(resources) and _option_token(rep, prefix) is not None) + + +def _option_token_on(prefix): + return lambda rep: _option_token(rep, prefix) == 'On' + + +def _option_token_num(prefix, offset=0, divisor=1): + def read(rep): + raw = _option_token(rep, prefix) + try: + return (float(raw) - offset) / divisor + except (TypeError, ValueError): + return None + return read + + +def _option_switch_write(prefix): + def write(payload, rep, href=None): + return (['mode', 'vs', '0'], + {'x.com.samsung.da.options': option_write(prefix, payload)}) + return write + + +def _option_number_write(prefix): + def write(payload, rep, href=None): + return (['mode', 'vs', '0'], + {'x.com.samsung.da.options': + option_write(prefix, str(int(round(float(payload)))))}) + return write + + +def _humidity(rep): + """Relative humidity, preferring the 5%-rounded field where it exists. + + ARTIK051 boards have no fivepercentHumidity field at all and report the + plain x.com.samsung.da.humidity instead -- and only populate it while the + Air monitoring option is on: the unit measures for roughly half a minute + (51% observed, matching what the same unit's cloud integration reported at + that moment), then zeroes the field and switches Air monitoring back off by + itself. So 0 reads as "not measuring" and is reported as unknown rather than + as 0% humidity, which would poison long-term history -- which is also why + the boards that do have fivepercentHumidity read a permanent 0 here. + """ + for field in ('x.com.samsung.da.fivepercentHumidity', + 'x.com.samsung.da.humidity'): + if field in rep: + value = _num(rep[field]) + return value if value else None + return None + + def _climate_write(payload, rep, href=None): """Map a (kind, value) command from the climate platform to the (path_segs, body) for that one sub-write. `value` is already the raw device @@ -180,6 +274,16 @@ def _climate_write(payload, rep, href=None): 'vertical': 'Swing' if value in ('vertical', 'both') else 'Fix', 'horizontal': 'Swing' if value in ('horizontal', 'both') else 'Fix', }) + if kind == 'fan_legacy': + return (['airflow', 'vs', '0'], + {'x.com.samsung.da.speedLevel': str(value)}) + if kind == 'swing_legacy': + return (['airflow', 'vs', '0'], + {'x.com.samsung.da.direction': value}) + if kind == 'preset_legacy': + # Single-token options merge, same mechanism as _display_light_write. + return (['mode', 'vs', '0'], + {'x.com.samsung.da.options': option_write('Comode', value)}) if kind == 'preset': return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value}) return None @@ -204,6 +308,58 @@ CLIMATE = Capability( exists_fn=_has_display_light_option, write_fn=_display_light_write, icon='mdi:led-on', entity_category='config'), + # Settings that this board generation keeps as options[] tokens. + SwitchDesc(key='spi', rep_fn=_option_token_on('Spi'), + exists_fn=_has_option_token('Spi'), + write_fn=_option_switch_write('Spi'), + icon='mdi:air-purifier', entity_category='config'), + # Shares AUTO_CLEAN's catalog entry rather than duplicating it: same + # feature, different board generation (that one is a /option/autoclean/ + # vs/0 field, absent here). Distinct key, so nothing collides if some + # future board ever reported both. + SwitchDesc(key='auto_clean_legacy', translation_key='auto_clean', + rep_fn=_option_token_on('Autoclean'), + exists_fn=_has_option_token('Autoclean'), + write_fn=_option_switch_write('Autoclean'), + icon='mdi:fan-auto', entity_category='config'), + SwitchDesc(key='air_monitoring', rep_fn=_option_token_on('AirMonitoring'), + 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. + NumberDesc(key='good_sleep', rep_fn=_option_token_num('Sleep'), + exists_fn=_has_option_token('Sleep'), + write_fn=_option_number_write('Sleep'), + native_min=0, native_max=12, step=1, unit='h', + icon='mdi:sleep', entity_category='config'), + # Outdoor temperature, offset by 55. Two calibration points on one unit: + # token 75 while an independent outdoor thermometer in the same install + # read 20.3 C, and token 74 against a 19.4 C forecast. Fahrenheit fits far + # worse (74 F = 23.3 C); the issue #136 unit's 81 gives 26 C in a warmer + # climate, which is also plausible. + SensorDesc(key='outdoor_temperature', + rep_fn=_option_token_num('OutdoorTemp', offset=55), + exists_fn=_has_option_token('OutdoorTemp'), + device_class='temperature', state_class='measurement', + unit='°C', icon='mdi:home-thermometer-outline'), + # Filter time in tenths of an hour: the token read 1710 while the official + # Samsung app displayed "171 hours 0 minutes" for the filter on the same + # unit, and the .0 matching the app's "0 minutes" pins the scale. Whether + # it counts up or down is NOT established -- it was seen rising (171.0 -> + # 171.5) while the unit ran, which contradicts the app's "remaining" + # wording, so the entity is deliberately named neutrally. + SensorDesc(key='filter_time', + rep_fn=_option_token_num('FilterTime', divisor=10), + exists_fn=_has_option_token('FilterTime'), + device_class='duration', unit='h', + state_class='measurement', icon='mdi:air-filter'), ), ) @@ -357,15 +513,23 @@ CURRENT_TEMPERATURE_VS = Capability( # Only the vendor resource's `fivepercentHumidity` (current reading, rounded # to the nearest 5%) has live data on the issue #75 dump -- its `humidity` # field, and the OCF-standard /humidity/0 resource entirely, both read a -# stuck "0" there and stay ignored per the 'don't guess' rule (see -# _AC_IGNORED below). +# stuck "0" there, so /humidity/0 stays ignored per the 'don't guess' rule +# (see _AC_IGNORED below). +# +# ARTIK051 boards (issue #136) have no fivepercentHumidity field at all, and +# there the plain `humidity` field is not stuck: it carries a real reading +# (51%, matching the same unit's cloud integration at that moment) for as long +# as the Air monitoring option is on, which the unit itself switches back off +# after roughly a minute -- so most dumps catch it at 0. Hence _humidity's +# fallback, and hence 0 reading as "not measuring" rather than 0%: on both +# board generations a zero here means no measurement, never dry air. +# /humidity/0 stayed 0 throughout that same observation too. HUMIDITY = Capability( href='/humidity/vs/0', poll_tier='warm', entities=( - SensorDesc(key='humidity', field='x.com.samsung.da.fivepercentHumidity', - device_class='humidity', state_class='measurement', unit='%', - value_fn=_num), + SensorDesc(key='humidity', rep_fn=_humidity, + device_class='humidity', state_class='measurement', unit='%'), ), ) @@ -397,6 +561,12 @@ _AC_IGNORED = [ '/humidity/0', # Presence-personalization plumbing (empty item list here). '/personality/presence/vs/0', + # OCF-standard mirror of /airflow/vs/0 ({speed, direction} vs + # {speedLevel, direction}, identical values). The climate entity reads and + # writes the vendor form, which is the one confirmed on hardware here -- + # note that air_purifier.py found the opposite ordering on its family, so + # neither form is reliable sight-unseen and this one stays unmodeled. + '/airflow/0', # --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no # user-actionable state or no documented write contract, so per the # 'don't guess' rule they are ignored rather than modeled. diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index dc92b91..ca6ab24 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -150,6 +150,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Beep volume" + }, "cook_time": { "name": "Cook time" }, @@ -159,6 +162,9 @@ "dispense_capacity": { "name": "Dispense capacity" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "{instance_name} setpoint" }, @@ -569,9 +575,15 @@ "current_limit_level": { "name": "Current limit level" }, + "filter_time": { + "name": "Filter time" + }, "hepa_filter_usage": { "name": "HEPA filter usage" }, + "outdoor_temperature": { + "name": "Outdoor temperature" + }, "panel_status": { "name": "Panel status" }, @@ -820,6 +832,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Air monitoring" + }, "air_purify": { "name": "Air purification" }, @@ -838,6 +853,9 @@ "dustbin_auto_close": { "name": "Dustbin auto-close" }, + "spi": { + "name": "Purifying ion" + }, "uvc_intensive_mode": { "name": "UV-C intensive mode" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index b23b93f..80becb6 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -150,6 +150,9 @@ } }, "number": { + "buzzer_volume": { + "name": "Zoemervolume" + }, "cook_time": { "name": "Bereidingstijd" }, @@ -159,6 +162,9 @@ "dispense_capacity": { "name": "Schenkhoeveelheid" }, + "good_sleep": { + "name": "Good Sleep" + }, "instance_setpoint": { "name": "Instelpunt {instance_name}" }, @@ -569,9 +575,15 @@ "current_limit_level": { "name": "Niveau stroombegrenzing" }, + "filter_time": { + "name": "Filtertijd" + }, "hepa_filter_usage": { "name": "HEPA-filtergebruik" }, + "outdoor_temperature": { + "name": "Buitentemperatuur" + }, "panel_status": { "name": "Paneelstatus" }, @@ -820,6 +832,9 @@ "ai_energy_level": { "name": "AI Energy Mode" }, + "air_monitoring": { + "name": "Luchtmonitoring" + }, "air_purify": { "name": "Luchtzuivering" }, @@ -838,6 +853,9 @@ "dustbin_auto_close": { "name": "Stofreservoir automatisch sluiten" }, + "spi": { + "name": "Ionisatie" + }, "uvc_intensive_mode": { "name": "Intensieve UV-C-modus" }, From 3032b0c0321cf7638dac43c6419839852564c66a Mon Sep 17 00:00:00 2001 From: perseus177 Date: Tue, 28 Jul 2026 02:28:59 +0200 Subject: [PATCH 3/4] test(airconditioner): lock in the ARTIK051_KRAC_18K surface Fixture is a scrubbed diagnostics dump from the unit the writes and calibrations were verified on; the issue #136 unit is the same model with a slightly different token set (no Spi, FilterTime_5460, OutdoorTemp_81), which the presence gating handles the same way. Covers the pieces the golden's state_keys can't: that the token entities stay off newer boards, that an absent token yields no entity, that humidity's zero reads as unknown, that fan/swing/preset read and write through /airflow/vs/0 and the Comode token, and that a board with /wind/* and /mode/convenient/vs/0 still takes the resource paths. Refs #136 --- ...rconditioner_artik051_krac_18k_device.json | 232 ++++++++++++++++++ .../airconditioner_artik051_krac_18k.json | 18 ++ tests/test_airconditioner_artik051_krac.py | 230 +++++++++++++++++ tests/test_golden_regression.py | 21 ++ 4 files changed, 501 insertions(+) create mode 100644 tests/fixtures/airconditioner_artik051_krac_18k_device.json create mode 100644 tests/fixtures/golden/airconditioner_artik051_krac_18k.json create mode 100644 tests/test_airconditioner_artik051_krac.py diff --git a/tests/fixtures/airconditioner_artik051_krac_18k_device.json b/tests/fixtures/airconditioner_artik051_krac_18k_device.json new file mode 100644 index 0000000..a607a2c --- /dev/null +++ b/tests/fixtures/airconditioner_artik051_krac_18k_device.json @@ -0,0 +1,232 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airflow/0", + "rep": { + "speed": 3, + "direction": "Fix" + } + }, + { + "href": "/airflow/vs/0", + "rep": { + "x.com.samsung.da.speedLevel": "3", + "x.com.samsung.da.direction": "Fix" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15", + "x.com.samsung.da.state": "Deleted" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-28T00:18:15", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000" + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativePower": "0" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+01:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "ARTIK051_KRAC_18K|10193441|60010119001111110200000000000000", + "x.com.samsung.da.description": "ARTIK051_KRAC_18K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02016A200825", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "18020800,17120500", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/mode/0", + "rep": { + "supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat", + "HOMECARE_WIZARD_V2" + ], + "modes": [ + "Cool" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "Cool", + "Dry", + "Wind", + "Auto", + "Heat", + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.modes": [ + "Cool" + ], + "x.com.samsung.da.options": [ + "Comode_Off", + "Sleep_0", + "OutdoorTemp_74", + "CoolCapa_25", + "WarmCapa_32", + "Spi_Off", + "Autoclean_Off", + "Light_Off", + "Volume_100", + "AirMonitoring_Off", + "AutocleanProgress_1", + "StopAutoClean_Idle", + "FilterTime_1715", + "FilterAlarmTime_500", + "OptionCode_35882", + "ExtendOptionCode_7", + "RacInfo_None", + "UpdateAllow_NotAllowed" + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 21.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 16.0, + 30.0 + ], + "units": "C", + "temperature": 20.0 + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "20", + "x.com.samsung.da.current": "21", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + } + ] +} diff --git a/tests/fixtures/golden/airconditioner_artik051_krac_18k.json b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json new file mode 100644 index 0000000..c7fabfb --- /dev/null +++ b/tests/fixtures/golden/airconditioner_artik051_krac_18k.json @@ -0,0 +1,18 @@ +{ + "state_keys": [ + "air_monitoring", + "alarm_code", + "auto_clean_legacy", + "buzzer_volume", + "climate", + "current_temperature_c", + "diagnosis_status", + "display_light", + "energy_kwh", + "filter_time", + "good_sleep", + "humidity", + "outdoor_temperature", + "spi" + ] +} diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py new file mode 100644 index 0000000..7c8f942 --- /dev/null +++ b/tests/test_airconditioner_artik051_krac.py @@ -0,0 +1,230 @@ +"""ARTIK051_KRAC_18K room air conditioner (issue #136). + +This board generation predates every AC dump the registry was built from and +differs from all of them in three ways, each covered below: + +* No ``/wind/*`` resources at all -- fan speed and vane direction share one + ``/airflow/vs/0`` resource (``speedLevel`` on the same 0-4 scale as + ``_DEVICE_TO_FAN``, ``direction`` with the same codes as ``_DEVICE_TO_SWING``). +* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*`` + token in ``/mode/vs/0``'s ``options`` array. +* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep, + outdoor temperature, filter time) are ``options`` tokens too, where newer + boards have dedicated resources. + +Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware +by read-back on the unit this fixture is dumped from. The issue #136 unit is +the same model with a slightly different token set (no ``Spi``, +``FilterTime_5460``, ``OutdoorTemp_81``), which the token entities' presence +gating handles the same way it handles newer boards. +""" +from custom_components.localthings.climate import LocalThingsClimate +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import ( + airconditioner, for_device_by_model, +) +from custom_components.localthings.registry.capabilities.airconditioner import ( + HREF_AIRFLOW, _option_number_write, _option_switch_write, +) +from custom_components.localthings.registry.discovery import discover +from custom_components.localthings.registry.entities import ClimateDesc +from tests.conftest import _load_device + +FIXTURE = 'airconditioner_artik051_krac_18k' +MODEL = 'ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000' + + +class _FakeCoordinator: + device_serial = 'TEST-KRAC-SERIAL' + device_info = {} + data = {} + + def __init__(self, resources): + self.last_resources = resources + self.commands = [] + + def resource(self, href): + return self.last_resources.get(href, {}) + + async def async_send_command(self, bound, payload): + self.commands.append((bound, payload)) + + +def _discover(resources, registry=airconditioner.REGISTRY): + unbound = [] + bound = discover(resources, registry.capabilities, + registry.pattern_capabilities, log=unbound.append) + return bound, unbound + + +def _state(fixture=FIXTURE): + resources = _load_device(fixture) + bound, _ = _discover(resources) + return flatten(bound, resources) + + +def _climate(resources, coordinator=None): + bound, _ = _discover(resources) + climate_bound = next( + item for item in bound if isinstance(item.desc, ClimateDesc) + ) + return LocalThingsClimate( + coordinator or _FakeCoordinator(resources), climate_bound, + ) + + +# -- device type -------------------------------------------------------------- + +def test_krac_model_resolves_to_the_airconditioner_registry(): + """The '_RAC_' token check can't see '_KRAC_' -- the 'K' sits between the + underscore and 'RAC' -- and the consumer-prefix fallback only covers + washers/dryers/dishwashers, so this model resolved to 'unknown' and + exposed nothing but power.""" + registry = for_device_by_model(MODEL, 'ARTIK051_KRAC_18K') + assert registry is not None + assert registry.name == 'airconditioner' + + +def test_no_unbound_hrefs(): + _, unbound = _discover(_load_device(FIXTURE)) + assert unbound == [] + + +# -- option-token entities ---------------------------------------------------- + +def test_token_entities_present_with_calibrated_values(): + state = _state() + # token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the + # official app on this unit, which pins the scale (the .5 here is a later + # reading). Whether it counts up or down is deliberately not asserted -- + # see the descriptor comment. + assert state['filter_time'] == 171.5 + # token - 55 == 19 C, against a 19.4 C forecast at the time of the dump. + assert state['outdoor_temperature'] == 19.0 + assert state['buzzer_volume'] == 100.0 + assert state['good_sleep'] == 0.0 + assert state['spi'] is False + assert state['auto_clean_legacy'] is False + assert state['air_monitoring'] is False + + +def test_token_entities_stay_off_newer_boards(): + """Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too, + while also exposing those settings as dedicated resources -- ungated, the + token entities would duplicate them (auto clean) or apply a scale + calibrated on another board generation (outdoor temperature).""" + state = _state('airconditioner_tp1x_rac') + for key in ('spi', 'auto_clean_legacy', 'air_monitoring', 'buzzer_volume', + 'good_sleep', 'outdoor_temperature', 'filter_time'): + assert key not in state, key + + +def test_absent_token_yields_no_entity(): + """The issue #136 unit of this same model reports no Spi token.""" + resources = _load_device(FIXTURE) + options = resources['/mode/vs/0']['x.com.samsung.da.options'] + resources['/mode/vs/0']['x.com.samsung.da.options'] = [ + option for option in options if not option.startswith('Spi_') + ] + bound, _ = _discover(resources) + assert 'spi' not in flatten(bound, resources) + + +def test_humidity_reads_the_vendor_field_and_treats_zero_as_unknown(): + """This board has no fivepercentHumidity field; the plain humidity field + only carries a reading while Air monitoring is on, and the unit switches + that back off by itself after about a minute.""" + resources = _load_device(FIXTURE) + assert resources['/humidity/vs/0']['x.com.samsung.da.humidity'] == '0' + bound, _ = _discover(resources) + assert flatten(bound, resources)['humidity'] is None + + resources['/humidity/vs/0']['x.com.samsung.da.humidity'] = '51' + bound, _ = _discover(resources) + assert flatten(bound, resources)['humidity'] == 51.0 + + +def test_option_writes_carry_one_token(): + """Both go through option_write's single-token merge, the same mechanism + the display light already uses on this href. Confirmed on hardware by + read-back: Spi_On/Spi_Off, and the volume token surviving a write.""" + assert _option_switch_write('Spi')('On', {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Spi_On']}, + ) + # The number platform hands over a float; the device wants an integer token. + assert _option_number_write('Volume')(70.0, {}) == ( + ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_70']}, + ) + + +# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------ + +def test_fan_mode_reads_the_airflow_speed_level(): + entity = _climate(_load_device(FIXTURE)) + assert entity.fan_mode == 'high' # speedLevel 3 in the fixture + # No supportedModes on this resource, so the full 0-4 scale is offered. + assert entity.fan_modes == ['auto', 'low', 'medium', 'high', 'turbo'] + + +def test_swing_mode_reads_the_airflow_direction(): + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.swing_mode == 'off' # 'Fix' in the fixture + + resources[HREF_AIRFLOW]['x.com.samsung.da.direction'] = 'All' + assert _climate(resources).swing_mode == 'both' + assert 'both' in _climate(resources).swing_modes + + +async def test_fan_and_swing_writes_target_the_airflow_resource(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('turbo') + await entity.async_set_swing_mode('both') + + assert [payload for _, payload in coordinator.commands] == [ + ('fan_legacy', '4'), ('swing_legacy', 'All'), + ] + + +def test_preset_comes_from_the_comode_token(): + resources = _load_device(FIXTURE) + entity = _climate(resources) + assert entity.preset_mode == 'none' # Comode_Off in the fixture + # Codes learned by driving this unit through its cloud integration and + # reading the token back: Nano is what the app calls WindFree. + assert 'windfree' in entity.preset_modes + + options = resources['/mode/vs/0']['x.com.samsung.da.options'] + resources['/mode/vs/0']['x.com.samsung.da.options'] = [ + 'Comode_Nano' if option.startswith('Comode_') else option + for option in options + ] + assert _climate(resources).preset_mode == 'windfree' + + +async def test_preset_write_uses_the_token_path(): + resources = _load_device(FIXTURE) + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_preset_mode('windfree') + + assert coordinator.commands[-1][1] == ('preset_legacy', 'Nano') + + +async def test_newer_boards_keep_the_resource_paths(): + """The legacy fallbacks are gated on this board's resource shape, so a + board with /wind/* and /mode/convenient/vs/0 must be untouched by them.""" + resources = _load_device('airconditioner_tp1x_rac') + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + await entity.async_set_fan_mode('high') + await entity.async_set_swing_mode('off') + await entity.async_set_preset_mode('windfree') + + kinds = [payload[0] for _, payload in coordinator.commands] + assert kinds == ['fan', 'swing', 'preset'] diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index c13b45c..eb9e24f 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -630,6 +630,27 @@ def test_registry_reproduces_golden_state_keys_for_vacuum_station(): ) +def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k(): + """ARTIK051_KRAC_18K (issue #136) -- reports no oneUiVersion, and its + '_KRAC_' token was invisible to for_device_by_model's '_RAC_' check (the + 'K' sits between the underscore and 'RAC'), so it fell back to 'unknown' + and exposed nothing but power. Same ARTIK051 board generation as the + '_TVTL_' air purifier: no /wind/* resources at all (fan and vane live in + /airflow/vs/0), no /mode/convenient/vs/0 (the preset is a Comode_* token), + and several settings carried as /mode/vs/0 options[] tokens.""" + from tests.conftest import _load_device + resources = _load_device('airconditioner_artik051_krac_18k') + golden = json.loads( + (GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text() + ) + state_keys = _new_state_keys('airconditioner_artik051_krac_18k', resources) + assert set(state_keys) == set(golden['state_keys']), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + ) + + def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump dump = { From 55c7b88a8f4283a181f9e64863476a1cbf39df25 Mon Sep 17 00:00:00 2001 From: perseus177 Date: Tue, 28 Jul 2026 02:38:28 +0200 Subject: [PATCH 4/4] fix(airconditioner): label the 2Step preset and test presets by their HA value The legacy Comode codes resolve through the same dynamic resolver as a real convenient resource, so Nano lands on the existing 'nano' preset (already labelled WindFree) rather than on a 'windfree' value of its own -- the new tests asserted the label instead of the value. 2Step had no catalog entry in either language and would have surfaced as the raw code. Also updates the existing five-percent-humidity test, which reached into the descriptor's field/value_fn directly, to the rep_fn the fallback needs, and covers the fallback itself. --- custom_components/localthings/translations/en.json | 3 ++- custom_components/localthings/translations/nl.json | 3 ++- tests/test_airconditioner_artik051_krac.py | 14 +++++++++----- tests/test_airconditioner_capabilities.py | 12 +++++++++++- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index ca6ab24..bd41375 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -128,7 +128,8 @@ "longwind": "Long wind", "motionindirect": "Motion indirect", "motiondirect": "Motion direct", - "drycomfort": "Dry comfort" + "drycomfort": "Dry comfort", + "2step": "2-Step" } } } diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 80becb6..b3779f5 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -128,7 +128,8 @@ "longwind": "Lange wind", "motionindirect": "Beweging indirect", "motiondirect": "Beweging direct", - "drycomfort": "Droog comfort" + "drycomfort": "Droog comfort", + "2step": "2-Step" } } } diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 7c8f942..2e71e89 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -194,15 +194,19 @@ def test_preset_comes_from_the_comode_token(): entity = _climate(resources) assert entity.preset_mode == 'none' # Comode_Off in the fixture # Codes learned by driving this unit through its cloud integration and - # reading the token back: Nano is what the app calls WindFree. - assert 'windfree' in entity.preset_modes + # reading the token back. They go through the same dynamic resolver as a + # real convenient resource's supportedModes, so 'Nano' resolves to the + # existing 'nano' preset -- already labelled WindFree in the catalog. + assert entity.preset_modes == [ + 'none', 'nano', 'quiet', 'comfort', '2step', 'speed', + ] options = resources['/mode/vs/0']['x.com.samsung.da.options'] resources['/mode/vs/0']['x.com.samsung.da.options'] = [ 'Comode_Nano' if option.startswith('Comode_') else option for option in options ] - assert _climate(resources).preset_mode == 'windfree' + assert _climate(resources).preset_mode == 'nano' async def test_preset_write_uses_the_token_path(): @@ -210,7 +214,7 @@ async def test_preset_write_uses_the_token_path(): coordinator = _FakeCoordinator(resources) entity = _climate(resources, coordinator) - await entity.async_set_preset_mode('windfree') + await entity.async_set_preset_mode('nano') assert coordinator.commands[-1][1] == ('preset_legacy', 'Nano') @@ -224,7 +228,7 @@ async def test_newer_boards_keep_the_resource_paths(): await entity.async_set_fan_mode('high') await entity.async_set_swing_mode('off') - await entity.async_set_preset_mode('windfree') + await entity.async_set_preset_mode('quiet') # from its own supportedModes kinds = [payload[0] for _, payload in coordinator.commands] assert kinds == ['fan', 'swing', 'preset'] diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 3a63492..f513be7 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -404,4 +404,14 @@ def test_current_temperature_vs_only_binds_when_ocf_href_absent(): def test_humidity_reads_five_percent_field_not_stuck_humidity_field(): desc = airconditioner.HUMIDITY.entities[0] rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'} - assert desc.value_fn(rep.get(desc.field)) == 42.0 + assert desc.rep_fn(rep) == 42.0 + + +def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent(): + """ARTIK051 boards (issue #136) have no fivepercentHumidity field at all. + Their plain field is not stuck -- it carries a reading while Air monitoring + is on -- so 0 means "not measuring" on both generations, not 0% humidity.""" + desc = airconditioner.HUMIDITY.entities[0] + assert desc.rep_fn({'x.com.samsung.da.humidity': '51'}) == 51.0 + assert desc.rep_fn({'x.com.samsung.da.humidity': '0'}) is None + assert desc.rep_fn({}) is None