Add Samsung microwave support (TP2X_DA-KS-MICROWAVE-01011-class, issue #66)

Reuses the oven family's door/connected/operational-state capabilities
wholesale (identical resource shapes) and adds microwave-specific mode/
cavity/temperature capabilities for the fields that differ (cook-mode
vocabulary, powerLevel, no setpoint write). Resolves via a new '-MICROWAVE-'
modelNum token fallback, mirroring the existing '-OVEN-'/'-RANGE-' rules.
This commit is contained in:
Marc Billow committed 2026-07-24 15:26:21 +00:00
1 parent 77cc875608
commit ede8719636
9 files changed
+544 -3

No files matched your search

+3 -2
View File
@@ -19,6 +19,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
| Air conditioner | `by_type/airconditioner.py` |
| Air purifier | `by_type/air_purifier.py` |
| Dryer | `by_type/dryer.py` |
| Microwave | `by_type/microwave.py` |
| Oven | `by_type/oven.py` |
| Cooktop (read-only burner status) | `by_type/cooktop.py` |
| Range hood | `by_type/range_hood.py` |
@@ -116,8 +117,8 @@ custom_components/localthings/
identity.py Reads device identity for type detection
redact.py Strips account/identity data before diagnostics leave HA
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
cooktop, range_hood, dryer, oven, dishwasher, fridge, washer,
laundry, operational, ignored)
cooktop, range_hood, dryer, microwave, oven, dishwasher, fridge,
washer, laundry, operational, ignored)
by_type/ One DeviceRegistry per appliance type, composed from capabilities/
tests/ Registry composition, discovery, entity descriptors, coordinator/observe
behavior, and golden-file regression against captured device dumps
@@ -3,7 +3,7 @@ from typing import Optional
from ._base import DeviceRegistry
from . import (
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
air_purifier, airconditioner, cooktop, dishwasher, dryer, microwave, oven,
range as _range, range_hood, refrigerator, washer,
)
@@ -21,6 +21,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'cooktop': cooktop.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
'hood': range_hood.REGISTRY,
'range': _range.REGISTRY,
@@ -141,6 +142,11 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
if key is None and '-OVEN-' in (model_num or '').upper():
key = 'oven'
# Microwaves (e.g. TP2X_DA-KS-MICROWAVE-01011, issue #66) -- same
# board-family naming as the range/oven combos above; reports no
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
if key is None and '-MICROWAVE-' in (model_num or '').upper():
key = 'microwave'
return _REGISTRY_BY_KEY.get(key) if key else None
@@ -0,0 +1,24 @@
"""Microwave device registry — issue #66.
Reuses the oven family's door/connected/operational-state capabilities
wholesale (byte-for-byte identical resource shapes, all generically-keyed)
and adds microwave-specific mode/cavity/temperature capabilities for the
fields that differ. See capabilities/microwave.py's module docstring.
"""
from ..capabilities import common, ignored, microwave, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='microwave',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_OPERATIONAL_STATE,
microwave.MICROWAVE_MODE,
microwave.MICROWAVE_CAVITY,
microwave.MICROWAVE_TEMPERATURE,
]),
)
@@ -131,4 +131,11 @@ IGNORED: list[Capability] = [
# parallel write-capable capability around an unverified generic OCF
# write contract.
Capability(href='/operational/state/0'),
# Microwave (issue #66): JSON-encoded {language, menu, servingSize,
# option} display string for the active cook recipe/program. Every
# field is empty on the only dump seen so far (device idle, no active
# cook program) -- no confirmed shape to build an entity around.
# Revisit if a dump with an active cook program surfaces.
Capability(href='/recipe/cook/vs/0'),
]
@@ -0,0 +1,132 @@
"""Capabilities for the microwave family (Samsung MW5300A-class, issue #66).
/doors/vs/0, /connected/vs/0, and /operational/state/vs/0 report the exact
same field shapes as the oven family's resources of the same name (all
generically-keyed: door_open, cloud_connected, machine_state, cycle_active,
progress_percentage, operation_time_minutes, finish_time, cook_time, stop),
so those capabilities are reused directly from oven.py rather than
re-declared here -- see by_type/microwave.py.
/mode/vs/0's cook-mode vocabulary (MicroWave/MicroWaveGrill/Grill/Autocook)
and options-array toggles (only a Sound_On/Off slot on this dump -- no
UpperLamp/fastpreheat/NaturalSteam like the oven family) are microwave-
specific, so it gets its own capability here. /oven/vs/0 additionally
reports a powerLevel field the oven family's cavity capability doesn't
carry, so it also gets its own.
Cycle start/pause is deliberately not modeled: unlike the oven family
(whose module docstring documents confirmed-unreliable local cycle-start
writes from live testing), no live microwave has exercised writes at all --
this module is built from a single diagnostics dump. The shared
STOP_BUTTON's state='Ready' RMW is safe and reused via
oven.OVEN_OPERATIONAL_STATE. The /temperatures/vs/0 reading is exposed
read-only for the same reason: desired='0' on the only dump seen, well
below any plausible cook temperature, with no confirmed write contract.
/recipe/cook/vs/0 (a JSON-encoded {language, menu, servingSize, option}
display string) is not modeled here -- every field is empty on this dump
(device idle, no active cook program); see ignored.py.
"""
from ..capability import Capability
from ..entities import SelectDesc, SensorDesc, SwitchDesc
from .common import normalize_temp_unit
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
def _cavity_temp_unit(rep):
items = rep.get('x.com.samsung.da.items') or []
unit = items[0].get('x.com.samsung.da.unit') if items else None
return normalize_temp_unit(unit, default='°C')
def _option_value(options, prefix):
"""Find `<prefix>_<value>` in an options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
return None
def _replace_in_options(options, prefix, new_value):
"""Return a new options list with the `<prefix>_*` slot replaced."""
return [f"{prefix}_{new_value}" if o.startswith(prefix + '_') else o
for o in options]
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
return None
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _replace_in_options(opts, 'Sound', p),
}
# Cook modes as reported by /mode/vs/0's supportedModes (issue #66 dump) --
# no oven-style Bake/Broil vocabulary applies to a microwave.
_MICROWAVE_MODES = (
'NoOperation',
'MicroWave',
'MicroWaveGrill',
'Grill',
'Autocook',
'AutocookCustom',
)
def _mode_write(p, rep, href=None):
if p not in _MICROWAVE_MODES:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
MICROWAVE_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
entities=(
SelectDesc(key='cook_mode', field='x.com.samsung.da.modes',
name='Cooking mode', icon='mdi:tune',
options=_MICROWAVE_MODES,
value_fn=lambda v: v[0] if v else None,
write_fn=_mode_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
name='Sound', icon='mdi:volume-high',
entity_category='config',
value_fn=lambda opts: _option_value(opts, 'Sound') == 'On',
write_fn=_sound_write),
),
)
MICROWAVE_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
entities=(
SensorDesc(key='cavity_state', field='x.com.samsung.da.state',
name='Cavity state'),
# e.g. "0W"/"700W" -- unit is embedded in the string on every dump
# seen so far; shown as reported rather than parsed apart.
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
name='Power level', icon='mdi:flash'),
),
)
MICROWAVE_TEMPERATURE = Capability(
href='/temperatures/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='cavity_temp', field='x.com.samsung.da.items',
name='Cavity temperature', device_class='temperature',
state_class='measurement', entity_category='diagnostic',
unit_fn=_cavity_temp_unit,
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
),
)
+22
View File
@@ -0,0 +1,22 @@
{
"state_keys": [
"alarm_code",
"cavity_state",
"cavity_temp",
"child_lock",
"cloud_connected",
"cook_mode",
"cook_time",
"cycle_active",
"door_open",
"energy_kwh",
"finish_time",
"firmware_update",
"machine_state",
"operation_time_minutes",
"power_level",
"power_switch",
"progress_percentage",
"sound"
]
}
+217
View File
@@ -0,0 +1,217 @@
{
"meta": {
"model": "TP2X_DA-KS-MICROWAVE-01011 (MW5300A-/KO0)",
"device_type": "microwave",
"source": "issue #66 diagnostics (scrubbed)",
"note": "Microwave reports no oneUiVersion; resolved via the '-MICROWAVE-' modelNum token fallback in for_device_by_model, mirroring the '-OVEN-'/'-RANGE-' fallbacks added for issues #55/#44."
},
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/connected/vs/0",
"rep": {
"x.com.samsung.da.connected": "On"
}
},
{
"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": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.instantaneousPower": "-500",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePower": "12500",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_DA-KS-MICROWAVE-01011|40444841|50040100021511000200000000000000",
"x.com.samsung.da.description": "MW5300A-/KO0",
"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": "240717",
"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": "DE92-04448A_21010500",
"x.com.samsung.da.newVersionAvailable": "0"
}
],
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"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": "622",
"x.com.samsung.da.diagMinVersion": "1.0"
}
},
{
"href": "/kidslock/vs/0",
"rep": {
"x.com.samsung.da.kidsLock": "Ready"
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"Autocook",
"NoOperation",
"MicroWave",
"MicroWaveGrill",
"Grill",
"AutocookCustom"
],
"x.com.samsung.da.modes": [
"NoOperation"
],
"x.com.samsung.da.options": [
"DeviceType_MW5300A-/KO0",
"ScreenTimeOut_10min",
"Sound_On"
],
"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": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false"
}
},
{
"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-24T13:38:49"
}
]
}
},
{
"href": "/oven/vs/0",
"rep": {
"x.com.samsung.da.state": "Ready",
"x.com.samsung.da.recipe": "00000000000000",
"x.com.samsung.da.powerLevel": "0W",
"rt": [
"x.com.samsung.da.oven"
],
"if": [
"oic.if.baseline",
"oic.if.s"
]
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/recipe/cook/vs/0",
"rep": {
"x.com.samsung.da.recipeDisplay": "{\"language\":\"None\",\"menu\":\"\",\"servingSize\":\"\",\"option\":\"\"}"
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "0",
"x.com.samsung.da.current": "1",
"x.com.samsung.da.increment": "5",
"x.com.samsung.da.unit": "Celsius"
}
],
"rt": [
"x.com.samsung.da.temperatures"
],
"if": [
"oic.if.baseline",
"oic.if.a"
]
}
}
]
}
+17
View File
@@ -294,6 +294,23 @@ def test_registry_reproduces_golden_state_keys_for_oven():
)
def test_registry_reproduces_golden_state_keys_for_microwave():
"""Microwave (model TP2X_DA-KS-MICROWAVE-01011, issue #66) -- reports no
oneUiVersion; resolved via the '-MICROWAVE-' modelNum token fallback in
for_device_by_model, mirroring the '-RANGE-'/'-OVEN-' fallbacks. Reuses
the oven family's door/connected/operational-state capabilities and adds
microwave-specific mode/cavity/temperature capabilities."""
from tests.conftest import _load_device
resources = _load_device('microwave')
golden = json.loads((GOLDEN / 'microwave.json').read_text())
state_keys = _new_state_keys('microwave', resources)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_resources_from_batch_preferred_over_flat():
from tests.conftest import _resources_from_dump
dump = {
+115
View File
@@ -0,0 +1,115 @@
"""Unit tests for microwave-family capabilities (issue #66)."""
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import microwave
from custom_components.localthings.registry.discovery import discover
# ---------------------------------------------------------------------------
# Device-type detection + full-dump coverage
# ---------------------------------------------------------------------------
def test_microwave_fixture_resolves_and_has_no_unbound_hrefs():
"""The issue #66 dump previously came back device_type='unknown' with
/connected/vs/0 and /recipe/cook/vs/0 unbound -- resolving via the
'-MICROWAVE-' modelNum token fallback must leave every href in the
microwave registry bound or ignored."""
from tests.conftest import _load_device
resources = _load_device('microwave')
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_MODE -- SelectDesc with non-empty options
# ---------------------------------------------------------------------------
def test_microwave_mode_options_nonempty():
assert len(microwave.MICROWAVE_MODE.entities[0].options) > 0
def test_microwave_mode_write_round_trips():
desc = microwave.MICROWAVE_MODE.entities[0]
valid_mode = desc.options[1] # e.g. 'MicroWave'
path, body = desc.write_fn(valid_mode, {})
assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == [valid_mode]
def test_microwave_mode_rejects_unknown():
desc = microwave.MICROWAVE_MODE.entities[0]
assert desc.write_fn('SpaghettiMode', {}) is None
# ---------------------------------------------------------------------------
# MICROWAVE_MODE -- sound options-array RMW
# ---------------------------------------------------------------------------
def _mode_rep(*extra_opts):
return {'x.com.samsung.da.options': [
'DeviceType_MW5300A-/KO0', 'ScreenTimeOut_10min', 'Sound_On', *extra_opts,
]}
def test_sound_write_preserves_other_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound')
path, body = desc.write_fn('Off', _mode_rep())
opts = body['x.com.samsung.da.options']
assert 'Sound_Off' in opts
assert 'ScreenTimeOut_10min' in opts # other slot unchanged
def test_sound_write_requires_existing_options():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound')
assert desc.write_fn('On', {}) is None
def test_sound_write_rejects_invalid_payload():
desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound')
assert desc.write_fn('Loud', _mode_rep()) is None
# ---------------------------------------------------------------------------
# MICROWAVE_CAVITY -- plain sensors, no write path
# ---------------------------------------------------------------------------
def test_cavity_state_reads_state_field():
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'cavity_state')
assert desc.value_fn('Ready') == 'Ready'
def test_power_level_reads_raw_string():
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
assert desc.value_fn('700W') == '700W'
# ---------------------------------------------------------------------------
# MICROWAVE_TEMPERATURE -- read-only current-temperature sensor
# ---------------------------------------------------------------------------
def test_cavity_temp_reads_current_field():
desc = microwave.MICROWAVE_TEMPERATURE.entities[0]
items = [{'x.com.samsung.da.current': '1', 'x.com.samsung.da.desired': '0'}]
assert desc.value_fn(items) == 1
def test_cavity_temp_missing_items_is_none():
desc = microwave.MICROWAVE_TEMPERATURE.entities[0]
assert desc.value_fn([]) is None
def test_cavity_temp_unit_defaults_celsius():
from custom_components.localthings.registry.capabilities.microwave import _cavity_temp_unit
rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]}
assert _cavity_temp_unit(rep) == '°C'
def test_cavity_temp_unit_falls_back_when_absent():
from custom_components.localthings.registry.capabilities.microwave import _cavity_temp_unit
assert _cavity_temp_unit({'x.com.samsung.da.items': []}) == '°C'