Merge pull request #143 from mbillow/claude/microwave-fanspeed-coverage-nmvc86
Microwave vent fan and kimchi-refrigerator compartment coverage (#137, #142, #26)
This commit is contained in:
@@ -37,6 +37,8 @@ POWER_HREF = '/power/0'
|
||||
POWER_VS_HREF = '/power/vs/0'
|
||||
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
|
||||
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
|
||||
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
|
||||
_OFF_SPEED_CODE = '0'
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
@@ -62,7 +64,24 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
"""A hood fan combining sibling power and fan-speed resources."""
|
||||
"""A hood fan combining sibling power and fan-speed resources.
|
||||
|
||||
Some boards that reuse this capability (built-in microwave vent fans,
|
||||
issues #137/#142) report no sibling `/power/0` or `/power/vs/0`
|
||||
resource at all -- fan speed 0 is itself the off state there, with no
|
||||
separate power toggle to write. `_speed_zero_is_off` detects that
|
||||
shape from the hood resource's own settableMinFanSpeed/
|
||||
supportedFanSpeed fields and switches every method below to drive
|
||||
off/on purely through the fanSpeed field, including '0' in the
|
||||
ordered speed codes as the off step instead of assuming every
|
||||
advertised code is an active speed.
|
||||
|
||||
This is deliberately not the same question as `_has_separate_power`,
|
||||
which only proves *some* power resource exists on the device -- on a
|
||||
combi appliance (e.g. an over-the-range microwave) that resource can
|
||||
belong to the cavity, not the vent fan, and toggling it from here
|
||||
would turn off the whole appliance instead of just the fan.
|
||||
"""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
@@ -78,15 +97,35 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _has_separate_power(self) -> bool:
|
||||
return bool(self._rep(POWER_HREF)) or bool(self._rep(POWER_VS_HREF))
|
||||
|
||||
def _speed_zero_is_off(self) -> bool:
|
||||
"""Whether fan speed '0' is itself this hood's off step, with no
|
||||
separate power resource to toggle. The board says so directly:
|
||||
settableMinFanSpeed '0', or '0' inside supportedFanSpeed. The
|
||||
standalone hood's codes start at 14 and it carries a real /power
|
||||
resource instead, so this is False there."""
|
||||
rep = self._rep(self._bound.href)
|
||||
return (
|
||||
str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE
|
||||
or _OFF_SPEED_CODE in self._all_speed_codes()
|
||||
)
|
||||
|
||||
def _all_speed_codes(self) -> list[str]:
|
||||
rep = self._rep(self._bound.href)
|
||||
return [str(value) for value in rep.get(_SUPPORTED_FAN_SPEED_FIELD, ())]
|
||||
|
||||
def _active_speed_codes(self) -> list[str]:
|
||||
# Power is carried by the separate /power resource. fanSpeed retains
|
||||
# the selected setting while power is off (as the lamp's `current`
|
||||
# field does), so every advertised code is an active ordered speed.
|
||||
return self._all_speed_codes()
|
||||
codes = self._all_speed_codes()
|
||||
if self._speed_zero_is_off():
|
||||
# No separate power resource: '0' is the off step, not a speed.
|
||||
return [code for code in codes if code != _OFF_SPEED_CODE]
|
||||
# Power is carried by the separate /power resource. fanSpeed
|
||||
# retains the selected setting while power is off (as the
|
||||
# lamp's `current` field does), so every advertised code is an
|
||||
# active ordered speed.
|
||||
return codes
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this hood actually exposes."""
|
||||
@@ -96,6 +135,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
if self._speed_zero_is_off():
|
||||
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
|
||||
return current not in ('', _OFF_SPEED_CODE)
|
||||
rep = self._rep(POWER_HREF)
|
||||
if 'value' in rep:
|
||||
return bool(rep.get('value'))
|
||||
@@ -121,6 +163,18 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
if self._speed_zero_is_off():
|
||||
if percentage is not None:
|
||||
await self.async_set_percentage(percentage)
|
||||
return
|
||||
if self.is_on:
|
||||
# Already running: no percentage given means "just turn on",
|
||||
# not "reset to the lowest speed".
|
||||
return
|
||||
codes = self._active_speed_codes()
|
||||
if codes:
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
|
||||
return
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
)
|
||||
@@ -128,6 +182,9 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
if self._speed_zero_is_off():
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
|
||||
return
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(False),
|
||||
)
|
||||
@@ -139,7 +196,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
codes = self._active_speed_codes()
|
||||
if not codes:
|
||||
return
|
||||
if not self.is_on:
|
||||
if not self._speed_zero_is_off() and not self.is_on:
|
||||
await self.coordinator.async_send_command(
|
||||
self._bound, self._power_payload(True),
|
||||
)
|
||||
|
||||
@@ -7,8 +7,16 @@ Cooking mode, setpoint, cavity power level, and lamp are genuinely
|
||||
different for this family (different mode vocabulary, different setpoint
|
||||
bounds, an extra powerLevel field, a differently-named lamp option) and are
|
||||
defined fresh in capabilities/microwave.py -- see that module's docstring.
|
||||
|
||||
Some combi units (built-in over-the-range microwaves, issues #137/#142)
|
||||
also carry the vent fan's `/hood/fanspeed/vs/0` resource, in the exact same
|
||||
shape a standalone range hood reports it in -- reused directly from
|
||||
range_hood.py rather than duplicated. Unlike a standalone hood, this dump
|
||||
has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
|
||||
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
|
||||
case (see its `_speed_zero_is_off` check).
|
||||
"""
|
||||
from ..capabilities import common, ignored, microwave, oven
|
||||
from ..capabilities import common, ignored, microwave, oven, range_hood
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
@@ -24,5 +32,6 @@ REGISTRY = DeviceRegistry(
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
range_hood.HOOD_FAN,
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -36,5 +36,7 @@ REGISTRY = DeviceRegistry(
|
||||
fridge.TEMP_SETPOINT,
|
||||
fridge.ICEMAKER_GENERIC,
|
||||
fridge.DOOR_GENERIC,
|
||||
fridge.KIMCHI_ZONE,
|
||||
fridge.KIMCHI_DOOR_GENERIC,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -590,8 +590,17 @@ FLEX_ZONE = Capability(
|
||||
translation_key='flex_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportedOptions',
|
||||
exists_fn=lambda rep, resources: bool(
|
||||
rep.get('x.com.samsung.da.supportedOptions')),
|
||||
# A nonempty supportedOptions alone isn't sufficient: the
|
||||
# kimchi-refrigerator family (issue #26) also populates
|
||||
# /mode/vs/0's modes/supportedOptions with real data, but
|
||||
# its tokens carry a "_[n]:[n]" parameter suffix on
|
||||
# supportedOptions that modes never repeats, so no item
|
||||
# ever overlaps -- the RF9000/Bespoke-class overlap this
|
||||
# capability was built for never happens there. Require an
|
||||
# actual resolvable value instead of just a populated
|
||||
# list, so this stays absent on that family rather than
|
||||
# showing a select permanently stuck on "unknown".
|
||||
exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None,
|
||||
rep_fn=_flex_zone_current,
|
||||
write_fn=_flex_zone_write),
|
||||
),
|
||||
@@ -625,6 +634,105 @@ DOOR_GENERIC = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kimchi refrigerator compartments (TP2X_REF_20K-class 3-compartment kimchi
|
||||
# units, issue #26) -- top/middle/bottom each report their own storage mode
|
||||
# plus a ripening status/timer on /status/kimchi/<slot>/vs/0, all three in
|
||||
# an identical shape; modeled as a pattern capability the same way
|
||||
# DOOR_GENERIC/TEMP_CURRENT_GENERIC above are, deriving the per-compartment
|
||||
# key and {instance_name} from the href's top/middle/bottom segment. Only
|
||||
# the top compartment's door has been seen reported separately (kimchidoors);
|
||||
# middle/bottom apparently have no contact switch of their own, so that's
|
||||
# its own narrower pattern cap rather than assumed universal.
|
||||
#
|
||||
# The same state is also mirrored -- packed into single tokens like
|
||||
# "KIMCHIT_KIMCHI_STORAGE_NORMAL" (T/M/B prefix per compartment) with
|
||||
# bracketed parameters -- on /mode/vs/0, the same resource FLEX_ZONE reads
|
||||
# for RF9000-class fridges. /status/kimchi/<slot>/vs/0's plain currentMode/
|
||||
# supportMode fields are unpacked and self-describing, so that's what this
|
||||
# binds to instead.
|
||||
#
|
||||
# Write path is unconfirmed (no live write against a real unit) -- same
|
||||
# "write the same field back to the entity's own href" convention as
|
||||
# PANTRY_ZONE/BEVERAGE_ZONE above, first real-world write is also the test.
|
||||
#
|
||||
# translations/en.json's kimchi_zone_mode state labels were translated
|
||||
# directly from the reporter's own (Korean-language) SmartThings app
|
||||
# screenshots, not guessed from the codes or from their English paraphrase.
|
||||
# Cross-checking the screenshots against supportMode confirms the on-screen
|
||||
# option order matches the array order everywhere it's verifiable: the top
|
||||
# compartment's freezer triplet (표준/강냉/약냉 = Standard/Strong/Weak, at
|
||||
# -19/-21/-17°C) lines up 1:1 with STORAGE_FREEZER_NORMAL/COLD/WARM, and the
|
||||
# middle/bottom compartments' full 8-entry kimchi-storage list, 2-entry
|
||||
# ripening list, and 4-entry custom-storage list each line up 1:1 with their
|
||||
# supportMode order too -- so COLD/WARM consistently means Strong/Weak (a
|
||||
# colder or warmer preset around the NORMAL setpoint) everywhere that suffix
|
||||
# appears, including on STORAGE_FRIDGE_* and the low-salt kimchi variants,
|
||||
# which weren't directly screenshotted but share the same NORMAL/COLD/WARM
|
||||
# vocabulary as the two confirmed triplets. CRUNFCH (아삭, "crisp/crunchy")
|
||||
# and BUY (구입, "purchased") are also confirmed exact matches, not
|
||||
# abbreviation guesses.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _kimchi_mode_write(p, rep, href=None):
|
||||
if not href or p not in (rep.get('x.com.samsung.da.supportMode') or ()):
|
||||
return None
|
||||
return [s for s in href.strip('/').split('/') if s], {
|
||||
'x.com.samsung.da.currentMode': p,
|
||||
}
|
||||
|
||||
|
||||
KIMCHI_ZONE = Capability(
|
||||
href=None,
|
||||
href_prefix='/status/kimchi/',
|
||||
strip_prefix_in_key=True,
|
||||
poll_tier='warm',
|
||||
entities=(
|
||||
SelectDesc(key='mode', field='x.com.samsung.da.currentMode',
|
||||
use_instance_name=True, icon='mdi:fridge-outline',
|
||||
translation_key='kimchi_zone_mode',
|
||||
entity_category='config',
|
||||
options_field='x.com.samsung.da.supportMode',
|
||||
write_fn=_kimchi_mode_write),
|
||||
SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus',
|
||||
use_instance_name=True, icon='mdi:progress-clock',
|
||||
translation_key='kimchi_ripening_status',
|
||||
entity_category='diagnostic'),
|
||||
SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime',
|
||||
use_instance_name=True, icon='mdi:timer-sand',
|
||||
translation_key='kimchi_ripening_remaining',
|
||||
entity_category='diagnostic',
|
||||
# No dump has this nonzero (ripeStatus is always "Off" so
|
||||
# far) -- device-reported unit unconfirmed, so this stays
|
||||
# a bare number rather than asserting minutes or hours.
|
||||
value_fn=_int),
|
||||
SensorDesc(key='rack_count', field='x.com.samsung.da.rackCount',
|
||||
use_instance_name=True, icon='mdi:tray-full',
|
||||
translation_key='kimchi_rack_count',
|
||||
entity_category='diagnostic', enabled_default=False,
|
||||
value_fn=_int),
|
||||
),
|
||||
)
|
||||
|
||||
KIMCHI_DOOR_GENERIC = Capability(
|
||||
href=None,
|
||||
href_prefix='/kimchidoors/',
|
||||
strip_prefix_in_key=True,
|
||||
poll_tier='hot',
|
||||
entities=(
|
||||
# Not deduped against DOORS_FALLBACK below: on the one reporter
|
||||
# (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the
|
||||
# /doors/vs/0 aggregate carries a single generic item (id "4", no
|
||||
# /door/<instance> siblings for DOORS_FALLBACK's match_fn to see)
|
||||
# that doesn't share this compartment's "top" instance numbering --
|
||||
# a distinct main-cabinet door, not this kimchi drawer's own contact
|
||||
# switch reported twice.
|
||||
BinarySensorDesc(key='open', rep_fn=_door_open_state,
|
||||
translation_key='instance_open',
|
||||
use_instance_name=True, device_class='door'),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aggregate-resource fallbacks
|
||||
#
|
||||
|
||||
@@ -99,6 +99,11 @@ HOOD_FAN = Capability(
|
||||
field='x.com.samsung.da.hood.autoOperation',
|
||||
icon='mdi:fan-auto',
|
||||
entity_category='diagnostic',
|
||||
# Absent on the microwave family's built-in vent fan (issue
|
||||
# #137) -- this board has no auto-ventilation mode, unlike the
|
||||
# standalone range hood this capability was written for.
|
||||
exists_fn=lambda rep, resources: (
|
||||
not rep or 'x.com.samsung.da.hood.autoOperation' in rep),
|
||||
value_fn=lambda value: str(value).lower() == 'on',
|
||||
),
|
||||
),
|
||||
|
||||
@@ -381,6 +381,37 @@
|
||||
"operating_mode": {
|
||||
"name": "Operating mode"
|
||||
},
|
||||
"kimchi_zone_mode": {
|
||||
"name": "{instance_name} storage mode",
|
||||
"state": {
|
||||
"off": "Off",
|
||||
"kimchi_storage_normal": "Kimchi",
|
||||
"kimchi_storage_cold": "Kimchi, strong",
|
||||
"kimchi_storage_warm": "Kimchi, weak",
|
||||
"kimchi_storage_low_salt_normal": "Low-salt kimchi",
|
||||
"kimchi_storage_low_salt_cold": "Low-salt kimchi, strong",
|
||||
"kimchi_storage_low_salt_warm": "Low-salt kimchi, weak",
|
||||
"kimchi_storage_crunfch": "Crisp kimchi",
|
||||
"kimchi_storage_buy": "Purchased kimchi",
|
||||
"storage_fridge_normal": "Fridge",
|
||||
"storage_fridge_cold": "Fridge, strong",
|
||||
"storage_fridge_warm": "Fridge, weak",
|
||||
"storage_freezer_normal": "Freezer",
|
||||
"storage_freezer_cold": "Freezer, strong",
|
||||
"storage_freezer_warm": "Freezer, weak",
|
||||
"kimchi_ripe_low_temp": "Kimchi ripening, low temperature",
|
||||
"kimchi_ripe_normal_temp": "Kimchi ripening, room temperature",
|
||||
"kimchi_ripe_kkakdugi": "Kkakdugi ripening",
|
||||
"kimchi_ripe_dongchimi": "Dongchimi ripening",
|
||||
"meat_ripe_normal": "Meat ripening",
|
||||
"storage_meat": "Meat & fish",
|
||||
"storage_fridge_vegetables_fruit": "Fruit & vegetables",
|
||||
"storage_fresh_cereal": "Grains",
|
||||
"storage_fridge_drink": "Beverages",
|
||||
"storage_fresh_wine": "Wine",
|
||||
"storage_fresh_potato_banana": "Potato & banana"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
"name": "Pantry zone mode",
|
||||
"state": {
|
||||
@@ -678,6 +709,15 @@
|
||||
"fridge_temperature": {
|
||||
"name": "Fridge temperature"
|
||||
},
|
||||
"kimchi_ripening_status": {
|
||||
"name": "{instance_name} ripening status"
|
||||
},
|
||||
"kimchi_ripening_remaining": {
|
||||
"name": "{instance_name} ripening time remaining"
|
||||
},
|
||||
"kimchi_rack_count": {
|
||||
"name": "{instance_name} rack count"
|
||||
},
|
||||
"hood_filter_capacity": {
|
||||
"name": "Filter capacity"
|
||||
},
|
||||
|
||||
@@ -381,6 +381,37 @@
|
||||
"operating_mode": {
|
||||
"name": "Bedrijfsmodus"
|
||||
},
|
||||
"kimchi_zone_mode": {
|
||||
"name": "{instance_name} opslagmodus",
|
||||
"state": {
|
||||
"off": "Uit",
|
||||
"kimchi_storage_normal": "Kimchi",
|
||||
"kimchi_storage_cold": "Kimchi, sterk",
|
||||
"kimchi_storage_warm": "Kimchi, zwak",
|
||||
"kimchi_storage_low_salt_normal": "Kimchi met weinig zout",
|
||||
"kimchi_storage_low_salt_cold": "Kimchi met weinig zout, sterk",
|
||||
"kimchi_storage_low_salt_warm": "Kimchi met weinig zout, zwak",
|
||||
"kimchi_storage_crunfch": "Knapperige kimchi",
|
||||
"kimchi_storage_buy": "Gekochte kimchi",
|
||||
"storage_fridge_normal": "Koelkast",
|
||||
"storage_fridge_cold": "Koelkast, sterk",
|
||||
"storage_fridge_warm": "Koelkast, zwak",
|
||||
"storage_freezer_normal": "Vriezer",
|
||||
"storage_freezer_cold": "Vriezer, sterk",
|
||||
"storage_freezer_warm": "Vriezer, zwak",
|
||||
"kimchi_ripe_low_temp": "Kimchi rijpen, lage temperatuur",
|
||||
"kimchi_ripe_normal_temp": "Kimchi rijpen, kamertemperatuur",
|
||||
"kimchi_ripe_kkakdugi": "Kkakdugi rijpen",
|
||||
"kimchi_ripe_dongchimi": "Dongchimi rijpen",
|
||||
"meat_ripe_normal": "Vlees rijpen",
|
||||
"storage_meat": "Vlees en vis",
|
||||
"storage_fridge_vegetables_fruit": "Groenten en fruit",
|
||||
"storage_fresh_cereal": "Granen",
|
||||
"storage_fridge_drink": "Dranken",
|
||||
"storage_fresh_wine": "Wijn",
|
||||
"storage_fresh_potato_banana": "Aardappelen en bananen"
|
||||
}
|
||||
},
|
||||
"pantry_zone_mode": {
|
||||
"name": "Modus voorraadzone",
|
||||
"state": {
|
||||
@@ -678,6 +709,15 @@
|
||||
"fridge_temperature": {
|
||||
"name": "Koelkasttemperatuur"
|
||||
},
|
||||
"kimchi_ripening_status": {
|
||||
"name": "{instance_name} rijpingsstatus"
|
||||
},
|
||||
"kimchi_ripening_remaining": {
|
||||
"name": "{instance_name} resterende rijptijd"
|
||||
},
|
||||
"kimchi_rack_count": {
|
||||
"name": "{instance_name} aantal rekken"
|
||||
},
|
||||
"hood_filter_capacity": {
|
||||
"name": "Filtercapaciteit"
|
||||
},
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"cavity_state",
|
||||
"child_lock",
|
||||
"cloud_connected",
|
||||
"cook_time",
|
||||
"cooking_mode",
|
||||
"cycle_active",
|
||||
"door_open",
|
||||
"energy_kwh",
|
||||
"fan",
|
||||
"finish_time",
|
||||
"firmware_update",
|
||||
"lamp",
|
||||
"machine_state",
|
||||
"operation_time_minutes",
|
||||
"power_level",
|
||||
"progress_percentage",
|
||||
"sound"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"state_keys": [
|
||||
"alarm_code",
|
||||
"bottom_mode",
|
||||
"bottom_rack_count",
|
||||
"bottom_ripening_remaining",
|
||||
"bottom_ripening_status",
|
||||
"diagnosis_status",
|
||||
"door_open",
|
||||
"energy_kwh",
|
||||
"firmware_update",
|
||||
"middle_mode",
|
||||
"middle_rack_count",
|
||||
"middle_ripening_remaining",
|
||||
"middle_ripening_status",
|
||||
"power_energy_kwh",
|
||||
"power_watts",
|
||||
"top_mode",
|
||||
"top_open",
|
||||
"top_rack_count",
|
||||
"top_ripening_remaining",
|
||||
"top_ripening_status"
|
||||
]
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
|
||||
"if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "",
|
||||
"x.com.samsung.da.newVersionAvailable": "false",
|
||||
"flashingProgress": "",
|
||||
"otnStatus": "None",
|
||||
"otnList": [
|
||||
{
|
||||
"type": "WIFI",
|
||||
"modelId": "AKS-WW-TP1-23-MICROWAVE-OTR",
|
||||
"versions": ["40241114"],
|
||||
"visVersion": "241114"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "07424047334140473241",
|
||||
"versions": ["24071000", "23120700"],
|
||||
"visVersion": "240710"
|
||||
},
|
||||
{
|
||||
"type": "Micom",
|
||||
"modelId": "074240475443FFFFFFFF",
|
||||
"versions": ["23120800", "FFFFFFFF"],
|
||||
"visVersion": "231208"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/connected/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.connected": "On",
|
||||
"rt": ["x.com.samsung.da.connected"],
|
||||
"if": ["oic.if.baseline", "oic.if.s"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
],
|
||||
"rt": ["x.com.samsung.da.doors"],
|
||||
"if": ["oic.if.baseline", "oic.if.s"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP1X_DA-KS-MICROWAVE-01051|40473341|50040000011811000A00000000000000",
|
||||
"x.com.samsung.da.description": "ME7500D-/AA1",
|
||||
"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": "24111400",
|
||||
"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": "04733A24071000, 04732A23120700",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "2",
|
||||
"x.com.samsung.da.description": "Version",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "04754C23120800",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
],
|
||||
"x.com.samsung.da.diagProtocolType": "BLE_OCF",
|
||||
"x.com.samsung.da.diagLogType": ["errCode", "dump"],
|
||||
"x.com.samsung.da.diagDumpType": "file",
|
||||
"x.com.samsung.da.diagEndPoint": "SSM",
|
||||
"x.com.samsung.da.diagMnid": "0AJT",
|
||||
"x.com.samsung.da.diagSetupid": "KM5",
|
||||
"x.com.samsung.da.diagMinVersion": "3.0",
|
||||
"x.com.samsung.da.diagTsId": "DA01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"],
|
||||
"x.com.samsung.da.modes": ["NoOperation"],
|
||||
"x.com.samsung.da.options": [
|
||||
"DeviceType_ME7500D-/AA1",
|
||||
"TimeAutoSync_On",
|
||||
"weight_LBS",
|
||||
"TimeSystem_12",
|
||||
"Sound_On",
|
||||
"RemindBeep_On",
|
||||
"FilterRemind_Off",
|
||||
"Lamp_Off"
|
||||
],
|
||||
"x.com.samsung.da.defaultMode": "MicroWave",
|
||||
"rt": ["x.com.samsung.da.mode"],
|
||||
"if": ["oic.if.baseline", "oic.if.a"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/operational/state/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.operationTime": "00:00:00",
|
||||
"x.com.samsung.da.remainingTime": "00:00:00",
|
||||
"x.com.samsung.da.progressPercentage": "0",
|
||||
"rt": ["x.com.samsung.da.operation"],
|
||||
"if": ["oic.if.baseline", "oic.if.a"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {
|
||||
"rt": ["x.com.samsung.da.alarms"],
|
||||
"if": ["oic.if.baseline", "oic.if.s"],
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "0",
|
||||
"x.com.samsung.da.description": "Alarm",
|
||||
"x.com.samsung.da.alarmType": "Device",
|
||||
"x.com.samsung.da.code": "OV_E_OFF",
|
||||
"x.com.samsung.da.triggeredTime": "2026-07-27T20:03:10"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/oven/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.state": "Ready",
|
||||
"x.com.samsung.da.recipe": "00000000000000",
|
||||
"x.com.samsung.da.powerLevel": "0",
|
||||
"rt": ["x.com.samsung.da.oven"],
|
||||
"if": ["oic.if.baseline", "oic.if.s"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/timezone/vs/0",
|
||||
"rep": {
|
||||
"timezoneid": "America/New_York",
|
||||
"offset": "-04:00",
|
||||
"DST": "ON"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.instantaneousPower": "-500",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W",
|
||||
"x.com.samsung.da.cumulativePower": "3400",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/hood/fanspeed/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.hood.fanSpeed": "0",
|
||||
"x.com.samsung.da.hood.supportedFanSpeed": ["0", "1", "2", "3", "4"],
|
||||
"x.com.samsung.da.hood.settableMaxFanSpeed": "4",
|
||||
"x.com.samsung.da.hood.settableMinFanSpeed": "0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kidslock/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.kidsLock": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/wirelessinfo/vs/0",
|
||||
"rep": {
|
||||
"macaddressWiFi": "**REDACTED**",
|
||||
"macaddressBLE": "**REDACTED**"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/quickcontrol/info/vs/0",
|
||||
"rep": {
|
||||
"supportedVersion": "1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
{
|
||||
"device0": [
|
||||
{
|
||||
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
|
||||
"if": ["oic.if.baseline", "oic.if.ll", "oic.if.b"]
|
||||
},
|
||||
{
|
||||
"href": "/alarms/vs/0",
|
||||
"rep": {}
|
||||
},
|
||||
{
|
||||
"href": "/diagnosis/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.diagnosisStart": "Ready"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/energy/consumption/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.cumulativeConsumption": "5",
|
||||
"x.com.samsung.da.cumulativePower": "1733313",
|
||||
"x.com.samsung.da.cumulativeUnit": "Wh",
|
||||
"x.com.samsung.da.instantaneousPower": "61",
|
||||
"x.com.samsung.da.instantaneousPowerUnit": "W"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_KIMCHI_OUTDOOR_CONTROL"
|
||||
],
|
||||
"x.com.samsung.da.modes": [
|
||||
"KIMCHIT_STORAGE_FREEZER_NORMAL",
|
||||
"KIMCHIT_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHIM_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHIB_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIT_BOX_COUNT_[6]",
|
||||
"KIMCHIM_BOX_COUNT_[4]",
|
||||
"KIMCHIB_BOX_COUNT_[2]",
|
||||
"KIMCHI_BOX_COUNT_[0]"
|
||||
],
|
||||
"x.com.samsung.da.supportedOptions": [
|
||||
"KIMCHIT_KIMCHI_STORAGE_NORMAL_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_COLD_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_WARM_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FRIDGE_NORMAL_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FRIDGE_COLD_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FRIDGE_WARM_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FREEZER_COLD_[0]:[0]",
|
||||
"KIMCHIT_STORAGE_FREEZER_WARM_[0]:[0]",
|
||||
"KIMCHIT_OFF_[0]:[0]",
|
||||
"KIMCHIT_KIMCHI_RIPE_LOW_TEMP_[5]:[17]",
|
||||
"KIMCHIT_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_NORMAL_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_COLD_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_WARM_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_CRUNFCH_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_BUY_[0]:[0]",
|
||||
"KIMCHIM_MEAT_RIPE_NORMAL_[3]:[0]",
|
||||
"KIMCHIM_STORAGE_MEAT_[0]:[0]",
|
||||
"KIMCHIM_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]",
|
||||
"KIMCHIM_STORAGE_FRESH_CEREAL_[0]:[0]",
|
||||
"KIMCHIM_OFF_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_RIPE_LOW_TEMP_[5]:[17]",
|
||||
"KIMCHIM_KIMCHI_RIPE_NORMAL_TEMP_[2]:[12]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_NORMAL_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_COLD_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_WARM_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_LOW_SALT_NORMAL_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_LOW_SALT_COLD_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_LOW_SALT_WARM_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_CRUNFCH_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_BUY_[0]:[0]",
|
||||
"KIMCHIB_STORAGE_FRIDGE_VEGETABLES_FRUIT_[0]:[0]",
|
||||
"KIMCHIB_STORAGE_FRIDGE_DRINK_[0]:[0]",
|
||||
"KIMCHIB_STORAGE_FRESH_WINE_[0]:[0]",
|
||||
"KIMCHIB_STORAGE_FRESH_POTATO_BANANA_[0]:[0]",
|
||||
"KIMCHIB_OFF_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_RIPE_KKAKDUGI_[4]:[5]",
|
||||
"KIMCHIB_KIMCHI_RIPE_DONGCHIMI_[7]:[6]"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/mode/0",
|
||||
"rep": {
|
||||
"supportedModes": [
|
||||
"HOMECARE_WIZARD_V2",
|
||||
"ENERGY_REPORT_MODEL",
|
||||
"18K_KIMCHI_OUTDOOR_CONTROL"
|
||||
],
|
||||
"modes": [
|
||||
"KIMCHIT_STORAGE_FREEZER_NORMAL",
|
||||
"KIMCHIT_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIM_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHIM_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIB_KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHIB_RIPE_REMAIN_[0]:[0]",
|
||||
"KIMCHIT_BOX_COUNT_[6]",
|
||||
"KIMCHIM_BOX_COUNT_[4]",
|
||||
"KIMCHIB_BOX_COUNT_[2]",
|
||||
"KIMCHI_BOX_COUNT_[0]"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/realtimenotiforclient/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.timeforshortnoti": "0",
|
||||
"x.com.samsung.da.periodicnotisubscription": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.modelNum": "TP2X_REF_20K|00135941|10010221011411010103642021000000",
|
||||
"x.com.samsung.da.description": "TP2X_REF_20K",
|
||||
"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": "WiFi Module",
|
||||
"x.com.samsung.da.type": "Software",
|
||||
"x.com.samsung.da.number": "02144A220110",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
},
|
||||
{
|
||||
"x.com.samsung.da.id": "1",
|
||||
"x.com.samsung.da.description": "Micom",
|
||||
"x.com.samsung.da.type": "Firmware",
|
||||
"x.com.samsung.da.number": "20082109,FFFFFFFF",
|
||||
"x.com.samsung.da.newVersionAvailable": "0"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/file/information/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.timeoffset": "+09:00",
|
||||
"x.com.samsung.supprtedtype": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/doors/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.items": [
|
||||
{
|
||||
"x.com.samsung.da.id": "4",
|
||||
"x.com.samsung.da.description": "Door",
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/kimchidoors/top/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.openState": "Close"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/configuration/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.countryCode": "",
|
||||
"x.com.samsung.da.region": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/bespoke/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.BespokeProduct": "On"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/otninformation/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.target": "Micom",
|
||||
"x.com.samsung.da.newVersionAvailable": "false"
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/kimchi/top/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentMode": "STORAGE_FREEZER_NORMAL",
|
||||
"x.com.samsung.da.ripeStatus": "Off",
|
||||
"x.com.samsung.da.ripeRemaintime": "0",
|
||||
"x.com.samsung.da.rackCount": "6",
|
||||
"x.com.samsung.da.supportMode": [
|
||||
"KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_STORAGE_COLD",
|
||||
"KIMCHI_STORAGE_WARM",
|
||||
"KIMCHI_STORAGE_LOW_SALT_NORMAL",
|
||||
"KIMCHI_STORAGE_LOW_SALT_COLD",
|
||||
"KIMCHI_STORAGE_LOW_SALT_WARM",
|
||||
"STORAGE_FRIDGE_NORMAL",
|
||||
"STORAGE_FRIDGE_COLD",
|
||||
"STORAGE_FRIDGE_WARM",
|
||||
"STORAGE_FREEZER_NORMAL",
|
||||
"STORAGE_FREEZER_COLD",
|
||||
"STORAGE_FREEZER_WARM",
|
||||
"OFF",
|
||||
"KIMCHI_RIPE_LOW_TEMP",
|
||||
"KIMCHI_RIPE_NORMAL_TEMP"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/kimchi/middle/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL",
|
||||
"x.com.samsung.da.ripeStatus": "Off",
|
||||
"x.com.samsung.da.ripeRemaintime": "0",
|
||||
"x.com.samsung.da.rackCount": "4",
|
||||
"x.com.samsung.da.supportMode": [
|
||||
"KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_STORAGE_COLD",
|
||||
"KIMCHI_STORAGE_WARM",
|
||||
"KIMCHI_STORAGE_LOW_SALT_NORMAL",
|
||||
"KIMCHI_STORAGE_LOW_SALT_COLD",
|
||||
"KIMCHI_STORAGE_LOW_SALT_WARM",
|
||||
"KIMCHI_STORAGE_CRUNFCH",
|
||||
"KIMCHI_STORAGE_BUY",
|
||||
"MEAT_RIPE_NORMAL",
|
||||
"STORAGE_MEAT",
|
||||
"STORAGE_FRIDGE_VEGETABLES_FRUIT",
|
||||
"STORAGE_FRESH_CEREAL",
|
||||
"OFF",
|
||||
"KIMCHI_RIPE_LOW_TEMP",
|
||||
"KIMCHI_RIPE_NORMAL_TEMP"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"href": "/status/kimchi/bottom/vs/0",
|
||||
"rep": {
|
||||
"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_NORMAL",
|
||||
"x.com.samsung.da.ripeStatus": "Off",
|
||||
"x.com.samsung.da.ripeRemaintime": "0",
|
||||
"x.com.samsung.da.rackCount": "2",
|
||||
"x.com.samsung.da.supportMode": [
|
||||
"KIMCHI_STORAGE_NORMAL",
|
||||
"KIMCHI_STORAGE_COLD",
|
||||
"KIMCHI_STORAGE_WARM",
|
||||
"KIMCHI_STORAGE_LOW_SALT_NORMAL",
|
||||
"KIMCHI_STORAGE_LOW_SALT_COLD",
|
||||
"KIMCHI_STORAGE_LOW_SALT_WARM",
|
||||
"KIMCHI_STORAGE_CRUNFCH",
|
||||
"KIMCHI_STORAGE_BUY",
|
||||
"STORAGE_FRIDGE_VEGETABLES_FRUIT",
|
||||
"STORAGE_FRIDGE_DRINK",
|
||||
"STORAGE_FRESH_WINE",
|
||||
"STORAGE_FRESH_POTATO_BANANA",
|
||||
"OFF",
|
||||
"KIMCHI_RIPE_KKAKDUGI",
|
||||
"KIMCHI_RIPE_DONGCHIMI"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -163,6 +163,26 @@ class TestFlexZone:
|
||||
assert 'CV_FDR_MEAT' not in modes
|
||||
assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes
|
||||
|
||||
def test_exists_only_when_a_current_value_resolves(self):
|
||||
"""Issue #26's kimchi-refrigerator family also populates /mode/vs/0's
|
||||
modes/supportedOptions, but its supportedOptions tokens carry a
|
||||
"_[n]:[n]" suffix modes never repeats, so no item ever overlaps --
|
||||
the entity used to bind anyway (supportedOptions is nonempty) and
|
||||
get stuck on "unknown" forever."""
|
||||
no_overlap_rep = {
|
||||
'x.com.samsung.da.modes': ['KIMCHIT_STORAGE_FREEZER_NORMAL'],
|
||||
'x.com.samsung.da.supportedOptions': [
|
||||
'KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]'],
|
||||
}
|
||||
desc = fridge.FLEX_ZONE.entities[0]
|
||||
assert desc.exists_fn(no_overlap_rep, {}) is False
|
||||
|
||||
overlap_rep = {
|
||||
'x.com.samsung.da.modes': ['CV_FDR_MEAT'],
|
||||
'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
|
||||
}
|
||||
assert desc.exists_fn(overlap_rep, {}) is True
|
||||
|
||||
|
||||
class TestTp1xNativeDuplicateResources:
|
||||
"""The US TP1X_REF_21K publishes two native mirrors in addition to the
|
||||
@@ -210,6 +230,97 @@ class TestPantryZone:
|
||||
assert body == {'x.com.samsung.da.mode': 'FDR_WINE'}
|
||||
|
||||
|
||||
class TestKimchiZone:
|
||||
"""Kimchi-refrigerator compartments (TP2X_REF_20K-class, issue #26) --
|
||||
top/middle/bottom each report an identically-shaped currentMode/
|
||||
supportMode resource under /status/kimchi/<slot>/vs/0."""
|
||||
|
||||
def test_href_prefix(self):
|
||||
assert fridge.KIMCHI_ZONE.href_prefix == '/status/kimchi/'
|
||||
assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == '/kimchidoors/'
|
||||
|
||||
def test_write_derives_path_from_href(self):
|
||||
desc = fridge.KIMCHI_ZONE.entities[0]
|
||||
rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
|
||||
path, body = desc.write_fn(
|
||||
'KIMCHI_STORAGE_COLD', rep, href='/status/kimchi/middle/vs/0')
|
||||
assert path == ['status', 'kimchi', 'middle', 'vs', '0']
|
||||
assert body == {'x.com.samsung.da.currentMode': 'KIMCHI_STORAGE_COLD'}
|
||||
|
||||
def test_write_without_href_is_rejected(self):
|
||||
desc = fridge.KIMCHI_ZONE.entities[0]
|
||||
rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
|
||||
assert desc.write_fn('KIMCHI_STORAGE_COLD', rep) is None
|
||||
|
||||
def test_write_rejects_value_outside_supportmode(self):
|
||||
"""A value the compartment never advertised is rejected rather than
|
||||
written blind -- this write path is unconfirmed against real
|
||||
hardware (module docstring above KIMCHI_ZONE), so a bad value here
|
||||
is a food-safety-adjacent outcome, not just a cosmetic one."""
|
||||
desc = fridge.KIMCHI_ZONE.entities[0]
|
||||
rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']}
|
||||
assert desc.write_fn(
|
||||
'KIMCHI_STORAGE_WARM', rep, href='/status/kimchi/middle/vs/0',
|
||||
) is None
|
||||
|
||||
def test_ripening_status_passes_through_device_value(self):
|
||||
"""No device_class='enum' catalog entry exists for this sensor, so
|
||||
lowercasing it would only make the raw device token un-translatable
|
||||
by HA -- pass the device's own casing straight through instead."""
|
||||
desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == 'ripening_status')
|
||||
assert desc.value_fn('Off') == 'Off'
|
||||
|
||||
def test_door_reuses_open_state_helper(self):
|
||||
desc = fridge.KIMCHI_DOOR_GENERIC.entities[0]
|
||||
assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True
|
||||
assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False
|
||||
|
||||
async def test_zone_mode_select_round_trips_through_display_casing(self):
|
||||
"""kimchi_zone_mode's displayed value (lowercase, catalog-translated)
|
||||
and the raw device code it writes back can silently drift apart --
|
||||
this is the one place that casing conversion could break. Runs
|
||||
through the real discovery/select pipeline against the tp2x_ref_20k
|
||||
kimchi fixture rather than a hand-built descriptor, so it also
|
||||
catches use_instance_name key derivation going wrong."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import refrigerator
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import SelectDesc
|
||||
from custom_components.localthings.select import LocalThingsSelect
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator_tp2x_ref_20k_kimchi')
|
||||
bound = discover(
|
||||
resources, refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities,
|
||||
)
|
||||
mode_bound = next(
|
||||
b for b in bound
|
||||
if isinstance(b.desc, SelectDesc) and b.href == '/status/kimchi/middle/vs/0'
|
||||
)
|
||||
|
||||
class _FakeCoordinator:
|
||||
device_serial = 'TEST-SERIAL'
|
||||
|
||||
def __init__(self, resources, data):
|
||||
self.last_resources = resources
|
||||
self.data = data
|
||||
self.commands = []
|
||||
|
||||
async def async_send_command(self, bound, value):
|
||||
self.commands.append(value)
|
||||
|
||||
coordinator = _FakeCoordinator(resources, flatten(bound, resources))
|
||||
entity = LocalThingsSelect(coordinator, mode_bound)
|
||||
|
||||
assert entity.current_option == 'kimchi_storage_normal'
|
||||
assert 'kimchi_storage_cold' in entity.options
|
||||
|
||||
await entity.async_select_option('kimchi_storage_cold')
|
||||
|
||||
assert coordinator.commands == ['KIMCHI_STORAGE_COLD']
|
||||
|
||||
|
||||
class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
|
||||
"""issue #20 (ARTIK051_REF_17K) and #26 (TP2X_REF_20K) both triggered
|
||||
the incomplete-capability-coverage repair; both must resolve to zero
|
||||
@@ -253,6 +364,33 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage:
|
||||
state = flatten(bound, resources)
|
||||
assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE'
|
||||
|
||||
def test_tp2x_ref_20k_kimchi(self):
|
||||
"""A different physical unit sharing the same modelNum string (issue
|
||||
#26, second reporter) -- a 3-compartment kimchi refrigerator instead
|
||||
of the flex-zone fridge above. /kimchidoors/top/vs/0 and
|
||||
/status/kimchi/{top,middle,bottom}/vs/0 were previously unbound."""
|
||||
from custom_components.localthings.registry.adapter import flatten
|
||||
from custom_components.localthings.registry.by_type import refrigerator
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from tests.conftest import _load_device
|
||||
|
||||
resources = _load_device('refrigerator_tp2x_ref_20k_kimchi')
|
||||
unbound = []
|
||||
bound = discover(
|
||||
resources,
|
||||
refrigerator.REGISTRY.capabilities,
|
||||
refrigerator.REGISTRY.pattern_capabilities,
|
||||
log=unbound.append,
|
||||
)
|
||||
assert unbound == []
|
||||
state = flatten(bound, resources)
|
||||
assert state['top_mode'] == 'STORAGE_FREEZER_NORMAL'
|
||||
assert state['middle_mode'] == 'KIMCHI_STORAGE_NORMAL'
|
||||
assert state['bottom_mode'] == 'KIMCHI_STORAGE_NORMAL'
|
||||
assert state['top_open'] is False
|
||||
assert 'middle_open' not in state # no /kimchidoors/middle/vs/0 reported
|
||||
assert 'flex_zone_mode' not in state # no resolvable overlap on this family
|
||||
|
||||
|
||||
class TestArtik051DongleRefFixtureCoverage:
|
||||
"""Issues #77/#83: the ARTIK051_DONGLE_REF standalone freezer resolves
|
||||
|
||||
@@ -300,6 +300,23 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k_kimchi():
|
||||
"""A different physical unit reporting the same "TP2X_REF_20K" modelNum
|
||||
string as the fixture above (issue #26's second reporter) -- a
|
||||
3-compartment kimchi refrigerator with no flex zone, doors/icemaker, or
|
||||
freezer/cooler split, but its own /status/kimchi/<slot>/vs/0 and
|
||||
/kimchidoors/top/vs/0 resources (fridge.KIMCHI_ZONE/KIMCHI_DOOR_GENERIC)."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('refrigerator_tp2x_ref_20k_kimchi')
|
||||
golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k_kimchi.json').read_text())
|
||||
state_keys = _new_state_keys('refrigerator_tp2x_ref_20k_kimchi', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_ac_tp1x_da_ac_rac_01011():
|
||||
"""Newer AC firmware (Tizen Lite, oneUiVersion "7.0 Air conditioner"; model
|
||||
TP1X_DA-AC-RAC-01011) reports temperature via the vendor /temperatures/vs/0
|
||||
@@ -589,6 +606,30 @@ def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b():
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_microwave_me7500d():
|
||||
"""TP1X_DA-KS-MICROWAVE-01051 plain microwave (model ME7500D, issues
|
||||
#137/#142) -- the same microwave registry as MW7300B above, but this
|
||||
board also reports the built-in vent fan's `/hood/fanspeed/vs/0`
|
||||
resource, previously unbound. Bound via range_hood.HOOD_FAN (reused
|
||||
directly, same resource shape a standalone range hood reports); unlike
|
||||
a standalone hood this board has no sibling `/power/0` or
|
||||
`/power/vs/0` resource, so fan.py's LocalThingsRangeHoodFan treats
|
||||
fan speed 0 as the off state instead of writing a separate power
|
||||
resource -- see its `_speed_zero_is_off` check. This board also has
|
||||
no `/temperatures/vs/0` or `x.com.samsung.da.hood.autoOperation`
|
||||
field, unlike MW7300B, so `setpoint`/`current_temp_c` and
|
||||
`automatic_operation` are correctly absent here."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('microwave_me7500d')
|
||||
golden = json.loads((GOLDEN / 'microwave_me7500d.json').read_text())
|
||||
state_keys = _new_state_keys('microwave_me7500d', resources)
|
||||
assert set(state_keys) == set(golden['state_keys']), (
|
||||
f"state_keys mismatch:\n"
|
||||
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
|
||||
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_reproduces_golden_state_keys_for_air_purifier_tp1x_da_ac_air():
|
||||
"""TP1X_DA-AC-AIR-01031_0000 (issue #130) self-reports oneUiVersion
|
||||
'7.0 Air purifier' and resolves via for_device() onto the existing
|
||||
|
||||
@@ -23,6 +23,22 @@ def test_microwave_fixture_resolves_and_has_no_unbound_hrefs():
|
||||
assert unbound == []
|
||||
|
||||
|
||||
def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs():
|
||||
"""Issues #137/#142: `/hood/fanspeed/vs/0` (the combi unit's built-in
|
||||
vent fan) was previously unbound on this family."""
|
||||
from tests.conftest import _load_device
|
||||
resources = _load_device('microwave_me7500d')
|
||||
info = resources['/information/vs/0']
|
||||
reg = for_device_by_model(
|
||||
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
||||
assert reg is not None
|
||||
assert reg.name == 'microwave'
|
||||
|
||||
unbound = []
|
||||
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
||||
assert unbound == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MICROWAVE_SETPOINT — NumberDesc with RMW write semantics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""HA fan-entity mapping tests for the range hood."""
|
||||
|
||||
from custom_components.localthings.fan import LocalThingsRangeHoodFan
|
||||
from custom_components.localthings.registry.by_type import range_hood
|
||||
from custom_components.localthings.registry.by_type import microwave, range_hood
|
||||
from custom_components.localthings.registry.discovery import discover
|
||||
from custom_components.localthings.registry.entities import FanDesc
|
||||
from tests.conftest import _load_device
|
||||
@@ -23,11 +23,11 @@ class _FakeCoordinator:
|
||||
self.commands.append((bound, payload))
|
||||
|
||||
|
||||
def _entity(resources, coordinator=None):
|
||||
def _entity(resources, coordinator=None, registry=range_hood.REGISTRY):
|
||||
bound = discover(
|
||||
resources,
|
||||
range_hood.REGISTRY.capabilities,
|
||||
range_hood.REGISTRY.pattern_capabilities,
|
||||
registry.capabilities,
|
||||
registry.pattern_capabilities,
|
||||
)
|
||||
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
|
||||
return LocalThingsRangeHoodFan(
|
||||
@@ -35,6 +35,10 @@ def _entity(resources, coordinator=None):
|
||||
)
|
||||
|
||||
|
||||
def _microwave_entity(resources, coordinator=None):
|
||||
return _entity(resources, coordinator, registry=microwave.REGISTRY)
|
||||
|
||||
|
||||
def test_power_off_maps_to_zero_percent_and_four_retained_speeds():
|
||||
entity = _entity(_load_device('range_hood'))
|
||||
assert entity.is_on is False
|
||||
@@ -86,3 +90,131 @@ async def test_power_write_falls_back_to_vendor_resource():
|
||||
await entity.async_turn_off()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Microwave built-in vent fan (issues #137/#142): reuses HOOD_FAN, but this
|
||||
# board has no sibling /power/0 or /power/vs/0 resource -- fan speed 0 is
|
||||
# itself the off state.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_standalone_hood_has_separate_power():
|
||||
"""Explicit converse of the microwave case below: guards the
|
||||
discriminator itself, not just its downstream effects, so a future
|
||||
change to it fails loudly here instead of only via behavioral drift."""
|
||||
entity = _entity(_load_device('range_hood'))
|
||||
assert entity._has_separate_power() is True
|
||||
assert entity._speed_zero_is_off() is False
|
||||
|
||||
|
||||
def test_microwave_vent_fan_has_no_separate_power_resource():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
assert '/power/0' not in resources
|
||||
assert '/power/vs/0' not in resources
|
||||
entity = _microwave_entity(resources)
|
||||
assert entity._has_separate_power() is False
|
||||
assert entity._speed_zero_is_off() is True
|
||||
|
||||
|
||||
async def test_combi_microwave_with_cavity_power_still_treats_zero_speed_as_fan_off():
|
||||
"""A combi over-the-range microwave can report a /power/0 resource for
|
||||
the cavity while the vent fan still has no power resource of its own
|
||||
(settableMinFanSpeed '0' -- same board shape as microwave_me7500d).
|
||||
_speed_zero_is_off must key off the hood resource itself, not merely
|
||||
"some power resource exists on this device", so turning the fan off
|
||||
writes fan speed rather than the shared cavity power resource."""
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/power/0'] = {'value': True}
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
assert entity._has_separate_power() is True
|
||||
assert entity._speed_zero_is_off() is True
|
||||
|
||||
await entity.async_turn_off()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', '0')
|
||||
|
||||
|
||||
def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes():
|
||||
"""supportedFanSpeed reports ['0'..'4'] with 0 meaning off -- unlike the
|
||||
standalone hood's codes, which never include an off entry."""
|
||||
resources = _load_device('microwave_me7500d')
|
||||
entity = _microwave_entity(resources)
|
||||
assert entity.is_on is False
|
||||
assert entity.percentage == 0
|
||||
assert entity.speed_count == 4 # ['1', '2', '3', '4'], '0' excluded
|
||||
|
||||
|
||||
def test_microwave_vent_fan_nonzero_speed_reads_as_on():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
||||
entity = _microwave_entity(resources)
|
||||
assert entity.is_on is True
|
||||
assert entity.percentage == 50 # 2nd of ['1', '2', '3', '4']
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_turn_off_writes_zero_speed_not_power():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_off()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', '0')
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_turn_on_without_percentage_picks_lowest_speed():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_on()
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', '1')
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_set_percentage_writes_speed_only():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_percentage(100)
|
||||
|
||||
assert coordinator.commands == [(entity._bound, ('speed', '4'))]
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_turn_on_with_percentage_writes_speed_directly():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_on(percentage=75)
|
||||
|
||||
assert coordinator.commands == [(entity._bound, ('speed', '3'))]
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_turn_on_without_percentage_when_already_on_is_a_noop():
|
||||
"""A scene or automation calling fan.turn_on on an already-running vent
|
||||
fan must not reset it to the lowest speed."""
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '3'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_turn_on()
|
||||
|
||||
assert coordinator.commands == []
|
||||
|
||||
|
||||
async def test_microwave_vent_fan_set_percentage_zero_turns_off():
|
||||
resources = _load_device('microwave_me7500d')
|
||||
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
||||
coordinator = _FakeCoordinator(resources)
|
||||
entity = _microwave_entity(resources, coordinator)
|
||||
|
||||
await entity.async_set_percentage(0)
|
||||
|
||||
assert coordinator.commands[-1][1] == ('speed', '0')
|
||||
|
||||
@@ -210,3 +210,27 @@ def test_every_ac_convenient_mode_code_has_a_preset_label():
|
||||
missing.append((path.name, code))
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_every_kimchi_zone_supportmode_code_has_a_state_label():
|
||||
"""Same guard as the AC preset one above, for KIMCHI_ZONE's
|
||||
kimchi_zone_mode select (fridge.py, issue #26): the write path resolved
|
||||
from options_field is fully dynamic too, so an unlabelled supportMode
|
||||
code across any /status/kimchi/<slot>/vs/0 resource would silently
|
||||
render as its raw device token instead of the translated state.
|
||||
"""
|
||||
state_labels = set(
|
||||
_load("en")["entity"]["select"]["kimchi_zone_mode"]["state"]
|
||||
)
|
||||
fixtures_dir = Path(__file__).parent / "fixtures"
|
||||
missing = []
|
||||
for path in sorted(fixtures_dir.glob("*_device.json")):
|
||||
dump = json.loads(path.read_text())
|
||||
for item in dump.get("device0", []):
|
||||
href = item.get("href", "")
|
||||
if not (href.startswith("/status/kimchi/") and href.endswith("/vs/0")):
|
||||
continue
|
||||
for code in item["rep"].get("x.com.samsung.da.supportMode", []):
|
||||
if code.lower() not in state_labels:
|
||||
missing.append((path.name, href, code))
|
||||
assert missing == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user