Merge pull request #146 from perseus177/artik051-krac-legacy-ac

Support ARTIK051_KRAC_18K air conditioners (issue #136)
This commit is contained in:
Marc Billow
2026-07-28 08:00:48 -05:00
committed by GitHub
10 changed files with 805 additions and 12 deletions
+64 -2
View File
@@ -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
@@ -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()
@@ -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 `<Prefix>_<value>`
# 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 `<prefix>_<value>` 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.
@@ -134,7 +134,8 @@
"longwind": "Long wind",
"motionindirect": "Motion indirect",
"motiondirect": "Motion direct",
"drycomfort": "Dry comfort"
"drycomfort": "Dry comfort",
"2step": "2-Step"
}
}
}
@@ -156,6 +157,9 @@
}
},
"number": {
"buzzer_volume": {
"name": "Beep volume"
},
"cook_time": {
"name": "Cook time"
},
@@ -165,6 +169,9 @@
"dispense_capacity": {
"name": "Dispense capacity"
},
"good_sleep": {
"name": "Good Sleep"
},
"instance_setpoint": {
"name": "{instance_name} setpoint"
},
@@ -609,9 +616,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"
},
@@ -869,6 +882,9 @@
"ai_energy_level": {
"name": "AI Energy Mode"
},
"air_monitoring": {
"name": "Air monitoring"
},
"air_purify": {
"name": "Air purification"
},
@@ -887,6 +903,9 @@
"dustbin_auto_close": {
"name": "Dustbin auto-close"
},
"spi": {
"name": "Purifying ion"
},
"uvc_intensive_mode": {
"name": "UV-C intensive mode"
},
@@ -134,7 +134,8 @@
"longwind": "Lange wind",
"motionindirect": "Beweging indirect",
"motiondirect": "Beweging direct",
"drycomfort": "Droog comfort"
"drycomfort": "Droog comfort",
"2step": "2-Step"
}
}
}
@@ -156,6 +157,9 @@
}
},
"number": {
"buzzer_volume": {
"name": "Zoemervolume"
},
"cook_time": {
"name": "Bereidingstijd"
},
@@ -165,6 +169,9 @@
"dispense_capacity": {
"name": "Schenkhoeveelheid"
},
"good_sleep": {
"name": "Good Sleep"
},
"instance_setpoint": {
"name": "Instelpunt {instance_name}"
},
@@ -609,9 +616,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"
},
@@ -869,6 +882,9 @@
"ai_energy_level": {
"name": "AI Energy Mode"
},
"air_monitoring": {
"name": "Luchtmonitoring"
},
"air_purify": {
"name": "Luchtzuivering"
},
@@ -887,6 +903,9 @@
"dustbin_auto_close": {
"name": "Stofreservoir automatisch sluiten"
},
"spi": {
"name": "Ionisatie"
},
"uvc_intensive_mode": {
"name": "Intensieve UV-C-modus"
},
@@ -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"
}
]
}
}
]
}
@@ -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"
]
}
+234
View File
@@ -0,0 +1,234 @@
"""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. 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 == 'nano'
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('nano')
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('quiet') # from its own supportedModes
kinds = [payload[0] for _, payload in coordinator.commands]
assert kinds == ['fan', 'swing', 'preset']
+11 -1
View File
@@ -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
+21
View File
@@ -671,6 +671,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 = {