Merge pull request #146 from perseus177/artik051-krac-legacy-ac
Support ARTIK051_KRAC_18K air conditioners (issue #136)
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user