Compare commits

..
Author SHA1 Message Date
Claude eb07af3c9a Warn against hard-coding device-specific values in adding-device-support skill
Dumps capture one unit's snapshot, not the field's universe. Add a
dedicated section pointing at the existing options_field/range_field/
native_min_fn machinery for deriving select options and number
ranges/steps live from the device rep instead of transcribing values
from a single dump into Python literals.
2026-07-26 23:48:20 +00:00
12 changed files with 7 additions and 643 deletions
+4 -32
View File
@@ -134,35 +134,7 @@ When you do hard-code something (a genuinely fixed enum, a spec constant), that'
a judgement call worth a one-line comment saying why it's safe — the default
assumption should be "derive it," not "copy it."
## 6. Parse units out of the value — don't ship them embedded in a string
Samsung reps sometimes encode a numeral and its unit as one string
(`x.com.samsung.da.powerLevel: "700W"`; a `desired`/`current` temperature whose
unit lives in a sibling field instead). The lazy fix — a plain `SensorDesc`
with no `device_class`/`unit` that passes the raw string straight through —
binds the href and looks done, but HA then sees text, not a measurement: no
unit conversion, no long-term statistics, no graphing. This shipped once
already (microwave `power_level` went out as the literal string `"700W"`
before being caught in review) — treat that as the standard failure mode to
check for, not a one-off.
Before wiring up a numeric-looking field:
- **Check where the unit actually lives.** Embedded in the same string
(`powerLevel`)? A fixed, undocumented assumption (most wattages/energy
fields)? Or reported live in a sibling field (`/temperatures/vs/0`'s
per-item `unit`, `'Celsius'`/`'Fahrenheit'`)? Don't guess C vs F or W vs kW
without checking the dump.
- **Parse the numeral in `value_fn`** (regex or strip the unit suffix) so the
entity's state is a number, not `"700W"`.
- **Set `unit`** (static) **or `unit_fn`** (reads a live sibling field — see
`common.normalize_temp_unit`, used by both `fridge.py` and `oven.py` for a
per-device C/F reading) **plus the matching `device_class`/`state_class`**
so HA treats it as a real measurement.
- A plain string `SensorDesc` (no unit/device_class) is still correct for
genuinely non-numeric state (mode names, enum-like text) — reserve it for
that, not as a shortcut past parsing a numeral.
## 7. Names and enum labels live in translations, never in Python
## 6. Names and enum labels live in translations, never in Python
Descriptors have **no `name` field**. Every entity is named from the shipped
catalog, keyed by `translation_key` — which defaults to the descriptor's own
@@ -201,7 +173,7 @@ no `[%key:...%]` resolution (that's Core build tooling). Every other language
must mirror `en.json` key for key — also enforced by
`tests/test_translations.py`.
## 8. Coverage discipline: bound or ignored
## 7. Coverage discipline: bound or ignored
Every href in the dump must resolve, or the repair fires. If a resource isn't
worth an entity, add it to `capabilities/ignored.py` (a no-entity `Capability`)
@@ -215,7 +187,7 @@ friendlier href**.
ignored because washers bind it. When only one family should ignore an href
that another binds, scope the ignore to that family's registry.
## 9. Reuse before writing new code
## 8. Reuse before writing new code
Check `common.py` (generic OCF: power, energy, alarms, water) and `laundry.py`
(shared washer/dryer/dishwasher: buzzer, job status, `cycle_select` + course
@@ -224,7 +196,7 @@ registry uses `fridge.FIRMWARE_UPDATE`; all three laundry families share
`laundry.cycle_select`. If two families hand-roll the same helper, hoist it to a
shared module rather than copying.
## 10. Lock it in
## 9. Lock it in
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
+2 -3
View File
@@ -24,7 +24,6 @@ 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` |
@@ -125,8 +124,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, microwave, oven, dishwasher, fridge,
washer, laundry, operational, ignored)
cooktop, range_hood, dryer, 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, microwave, oven,
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
range as _range, range_hood, refrigerator, washer,
)
@@ -21,7 +21,6 @@ _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,
@@ -142,11 +141,6 @@ 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
@@ -1,24 +0,0 @@
"""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,11 +131,4 @@ 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'),
]
@@ -1,138 +0,0 @@
"""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 select reads its options live from supportedModes
(MicroWave/MicroWaveGrill/Grill/Autocook on this dump -- no oven-style
Bake/Broil vocabulary applies, and another model's supported set may
differ) and its 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 (a wattage with the unit embedded in the string,
e.g. "700W") 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.
"""
import re
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
_POWER_LEVEL_RE = re.compile(r'^(-?\d+)W$')
def _power_level_watts(v):
"""'0'/'700W' -> 0/700 -- the unit is embedded in the string on every
dump seen so far; strip it so the sensor reports a plain wattage."""
if not isinstance(v, str):
return None
m = _POWER_LEVEL_RE.match(v)
return int(m.group(1)) if m else 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),
}
def _mode_write(p, rep, href=None):
# rep is this capability's own /mode/vs/0 rep, so the live
# supportedModes list is right here -- no static vocabulary to keep in
# sync with devices whose mode set differs (grill-less models, etc.).
if p not in (rep.get('x.com.samsung.da.supportedModes') or ()):
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',
icon='mdi:tune',
options_field='x.com.samsung.da.supportedModes',
value_fn=lambda v: v[0] if v else None,
write_fn=_mode_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options',
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'),
SensorDesc(key='power_level', field='x.com.samsung.da.powerLevel',
icon='mdi:flash', device_class='power',
state_class='measurement', unit='W',
value_fn=_power_level_watts),
),
)
MICROWAVE_TEMPERATURE = Capability(
href='/temperatures/vs/0',
poll_tier='warm',
entities=(
SensorDesc(key='cavity_temp', field='x.com.samsung.da.items',
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))),
),
)
@@ -154,17 +154,6 @@
"on": "On"
}
},
"cook_mode": {
"name": "Cook mode",
"state": {
"no_operation": "No operation",
"micro_wave": "Microwave",
"micro_wave_grill": "Microwave + grill",
"grill": "Grill",
"autocook": "Autocook",
"autocook_custom": "Autocook custom"
}
},
"cycle": {
"name": "Cycle"
},
@@ -449,12 +438,6 @@
"burner_state": {
"name": "Burner {number} state"
},
"cavity_state": {
"name": "Cavity state"
},
"cavity_temp": {
"name": "Cavity temperature"
},
"clean_level": {
"name": "Clean level"
},
@@ -588,9 +571,6 @@
"power_energy_kwh": {
"name": "Power energy"
},
"power_level": {
"name": "Power level"
},
"power_watts": {
"name": "Power"
},
@@ -154,17 +154,6 @@
"on": "Aan"
}
},
"cook_mode": {
"name": "Kookmodus",
"state": {
"no_operation": "Niet actief",
"micro_wave": "Magnetron",
"micro_wave_grill": "Magnetron + grill",
"grill": "Grill",
"autocook": "Automatisch koken",
"autocook_custom": "Automatisch koken (aangepast)"
}
},
"cycle": {
"name": "Programma"
},
@@ -449,12 +438,6 @@
"burner_state": {
"name": "Status brander {number}"
},
"cavity_state": {
"name": "Status ovenruimte"
},
"cavity_temp": {
"name": "Temperatuur ovenruimte"
},
"clean_level": {
"name": "Reinigingsniveau"
},
@@ -588,9 +571,6 @@
"power_energy_kwh": {
"name": "Energieverbruik"
},
"power_level": {
"name": "Vermogensniveau"
},
"power_watts": {
"name": "Vermogen"
},
-22
View File
@@ -1,22 +0,0 @@
{
"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
@@ -1,217 +0,0 @@
{
"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,23 +294,6 @@ 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 = {
-136
View File
@@ -1,136 +0,0 @@
"""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 reads its options live from supportedModes
# ---------------------------------------------------------------------------
_SUPPORTED_MODES_REP = {'x.com.samsung.da.supportedModes': [
'NoOperation', 'MicroWave', 'MicroWaveGrill', 'Grill', 'Autocook', 'AutocookCustom',
]}
def test_microwave_mode_options_field():
desc = microwave.MICROWAVE_MODE.entities[0]
assert desc.options_field == 'x.com.samsung.da.supportedModes'
def test_microwave_mode_write_round_trips():
desc = microwave.MICROWAVE_MODE.entities[0]
path, body = desc.write_fn('MicroWave', _SUPPORTED_MODES_REP)
assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == ['MicroWave']
def test_microwave_mode_rejects_unknown():
desc = microwave.MICROWAVE_MODE.entities[0]
assert desc.write_fn('SpaghettiMode', _SUPPORTED_MODES_REP) is None
def test_microwave_mode_rejects_when_not_in_live_supported_list():
"""A mode absent from *this* device's live supportedModes is rejected
even if another microwave model supports it -- the write must not fall
back to a static vocabulary."""
desc = microwave.MICROWAVE_MODE.entities[0]
narrower_rep = {'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave']}
assert desc.write_fn('Grill', narrower_rep) 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_parses_watts():
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
assert desc.value_fn('700W') == 700
assert desc.value_fn('0W') == 0
def test_power_level_rejects_unparseable():
desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level')
assert desc.value_fn('') is None
assert desc.value_fn(None) is None
# ---------------------------------------------------------------------------
# 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'