Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afea1fd9ca | ||
|
|
a8eb4ff98a | ||
|
|
26ca6179bd | ||
|
|
92d64bd4f1 | ||
|
|
4a5d6bca59 | ||
|
|
ede8719636 |
@@ -103,7 +103,66 @@ sub-polled between summary polls. Pick descriptor types from `entities.py`
|
||||
as a gap for a human, or ignore it with a documented reason — never invent an
|
||||
entity on a hunch (`ignored.py`'s rule).
|
||||
|
||||
## 5. Names and enum labels live in translations, never in Python
|
||||
## 5. Never hard-code the one dump's values
|
||||
|
||||
A single `/device/0` dump is **one device on one firmware** — its select options,
|
||||
temperature range/increment, and any other "what values are valid here" data are
|
||||
**that unit's snapshot**, not the field's universe. Other units of the same model
|
||||
(different region, firmware, board revision) can support more, fewer, or
|
||||
differently-stepped values. If the dump reports the live option/range list, wire
|
||||
the descriptor to read it live — don't transcribe what you saw into a Python
|
||||
literal:
|
||||
|
||||
- **Selects**: use `options_field` (a resource field holding the live options
|
||||
list, e.g. `supportedWaterTemperature`, `iceType.supported`) so `select.py`
|
||||
reads the current device's real options every time, not `options=(...)` typed
|
||||
from the dump. Reach for a callable `options` only when the values require
|
||||
cross-resource computation the field alone can't give you — a static tuple is
|
||||
right only for genuinely fixed, spec-defined enums (e.g. an OCF-standard field
|
||||
with a closed value set), never for vendor `supported*` lists.
|
||||
- **Number ranges/steps**: use `range_field` (a `[min, max]`-shaped field) or
|
||||
`native_min_fn`/`native_max_fn`/`step_fn` to read bounds from the live rep —
|
||||
see `oven.py`'s `_setpoint_bounds`. Only fall back to static `native_min`/
|
||||
`native_max`/`step` when the dump has no such field and the bound is genuinely
|
||||
fixed by spec, not just "the only value this one unit happened to report."
|
||||
- **Anywhere else** a field's presence, count, or shape looks like it could vary
|
||||
by model/config (course lists, capability flags, supported-mode arrays):
|
||||
check whether the resource carries its own `supported*` companion field before
|
||||
assuming the observed value is exhaustive.
|
||||
|
||||
When you do hard-code something (a genuinely fixed enum, a spec constant), that's
|
||||
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
|
||||
|
||||
Descriptors have **no `name` field**. Every entity is named from the shipped
|
||||
catalog, keyed by `translation_key` — which defaults to the descriptor's own
|
||||
@@ -142,7 +201,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`.
|
||||
|
||||
## 6. Coverage discipline: bound or ignored
|
||||
## 8. 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`)
|
||||
@@ -156,7 +215,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.
|
||||
|
||||
## 7. Reuse before writing new code
|
||||
## 9. 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
|
||||
@@ -165,7 +224,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.
|
||||
|
||||
## 8. Lock it in
|
||||
## 10. Lock it in
|
||||
|
||||
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
|
||||
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
|
||||
|
||||
@@ -24,6 +24,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` |
|
||||
@@ -124,8 +125,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,138 @@
|
||||
"""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,6 +154,17 @@
|
||||
"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"
|
||||
},
|
||||
@@ -438,6 +449,12 @@
|
||||
"burner_state": {
|
||||
"name": "Burner {number} state"
|
||||
},
|
||||
"cavity_state": {
|
||||
"name": "Cavity state"
|
||||
},
|
||||
"cavity_temp": {
|
||||
"name": "Cavity temperature"
|
||||
},
|
||||
"clean_level": {
|
||||
"name": "Clean level"
|
||||
},
|
||||
@@ -571,6 +588,9 @@
|
||||
"power_energy_kwh": {
|
||||
"name": "Power energy"
|
||||
},
|
||||
"power_level": {
|
||||
"name": "Power level"
|
||||
},
|
||||
"power_watts": {
|
||||
"name": "Power"
|
||||
},
|
||||
|
||||
@@ -154,6 +154,17 @@
|
||||
"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"
|
||||
},
|
||||
@@ -438,6 +449,12 @@
|
||||
"burner_state": {
|
||||
"name": "Status brander {number}"
|
||||
},
|
||||
"cavity_state": {
|
||||
"name": "Status ovenruimte"
|
||||
},
|
||||
"cavity_temp": {
|
||||
"name": "Temperatuur ovenruimte"
|
||||
},
|
||||
"clean_level": {
|
||||
"name": "Reinigingsniveau"
|
||||
},
|
||||
@@ -571,6 +588,9 @@
|
||||
"power_energy_kwh": {
|
||||
"name": "Energieverbruik"
|
||||
},
|
||||
"power_level": {
|
||||
"name": "Vermogensniveau"
|
||||
},
|
||||
"power_watts": {
|
||||
"name": "Vermogen"
|
||||
},
|
||||
|
||||
Vendored
+22
@@ -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"
|
||||
]
|
||||
}
|
||||
Vendored
+217
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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'
|
||||
Reference in New Issue
Block a user