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:
Marc Billow
2026-07-27 21:13:03 -05:00
committed by GitHub
16 changed files with 1146 additions and 13 deletions
+63 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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"
]
}
}
]
}
+138
View File
@@ -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
+41
View File
@@ -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
+16
View File
@@ -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
# ---------------------------------------------------------------------------
+136 -4
View File
@@ -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')
+24
View File
@@ -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 == []