Clean up microwave PR after main merge: dedupe skill doc, derive cook modes live

The merge from main had left the adding-device-support skill with a stale
duplicate "Enum selects need translation support" section (referencing a
nonexistent strings.json) sitting alongside the canonical version, and
main's new "never hard-code the dump" guidance hadn't landed on this
branch at all -- restore it and drop the duplicate.

Apply that guidance to the microwave capability itself: MICROWAVE_MODE's
cook_mode select was carrying a hardcoded _MICROWAVE_MODES tuple lifted
from the single issue #66 dump, even though /mode/vs/0 reports its own
live supportedModes list. Switch to options_field and validate writes
against the live rep instead of the static vocabulary, so a microwave
with a narrower or wider mode set (grill-less models, etc.) isn't stuck
with this one unit's options.

The microwave entities also shipped with no translations/en.json entries
at all (cook_mode, cavity_state, power_level, cavity_temp) -- add them,
mirrored into nl.json, and update the affected tests.
This commit is contained in:
Marc Billow
2026-07-27 00:03:55 +00:00
parent a8eb4ff98a
commit afea1fd9ca
5 changed files with 104 additions and 37 deletions
+32 -12
View File
@@ -103,7 +103,38 @@ 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 as a gap for a human, or ignore it with a documented reason — never invent an
entity on a hunch (`ignored.py`'s rule). entity on a hunch (`ignored.py`'s rule).
## 5. Parse units out of the value — don't ship them embedded in a string ## 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 Samsung reps sometimes encode a numeral and its unit as one string
(`x.com.samsung.da.powerLevel: "700W"`; a `desired`/`current` temperature whose (`x.com.samsung.da.powerLevel: "700W"`; a `desired`/`current` temperature whose
@@ -131,17 +162,6 @@ Before wiring up a numeric-looking field:
genuinely non-numeric state (mode names, enum-like text) — reserve it for genuinely non-numeric state (mode names, enum-like text) — reserve it for
that, not as a shortcut past parsing a numeral. that, not as a shortcut past parsing a numeral.
## 6. Enum selects need translation support
Any select whose options are raw device codes (course/cycle, and code-valued
settings) must render through translations, not Python:
- Set `translation_key='<family>_cycle'` (or similar) on the `SelectDesc`;
`options`/`options_field` supply the **raw** codes.
- Add the labels to **both** `strings.json` and `translations/en.json` under
`entity.select.<translation_key>.state.<code>`, with the code **lowercased**
(e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's
the cue to identify and name them.
## 7. Names and enum labels live in translations, never in Python ## 7. Names and enum labels live in translations, never in Python
Descriptors have **no `name` field**. Every entity is named from the shipped Descriptors have **no `name` field**. Every entity is named from the shipped
@@ -7,10 +7,12 @@ progress_percentage, operation_time_minutes, finish_time, cook_time, stop),
so those capabilities are reused directly from oven.py rather than so those capabilities are reused directly from oven.py rather than
re-declared here -- see by_type/microwave.py. re-declared here -- see by_type/microwave.py.
/mode/vs/0's cook-mode vocabulary (MicroWave/MicroWaveGrill/Grill/Autocook) /mode/vs/0's cook-mode select reads its options live from supportedModes
and options-array toggles (only a Sound_On/Off slot on this dump -- no (MicroWave/MicroWaveGrill/Grill/Autocook on this dump -- no oven-style
UpperLamp/fastpreheat/NaturalSteam like the oven family) are microwave- Bake/Broil vocabulary applies, and another model's supported set may
specific, so it gets its own capability here. /oven/vs/0 additionally 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, 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 e.g. "700W") the oven family's cavity capability doesn't carry, so it also
gets its own. gets its own.
@@ -85,20 +87,11 @@ def _sound_write(p, rep, href=None):
} }
# 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): def _mode_write(p, rep, href=None):
if p not in _MICROWAVE_MODES: # 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 None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
@@ -109,7 +102,7 @@ MICROWAVE_MODE = Capability(
entities=( entities=(
SelectDesc(key='cook_mode', field='x.com.samsung.da.modes', SelectDesc(key='cook_mode', field='x.com.samsung.da.modes',
icon='mdi:tune', icon='mdi:tune',
options=_MICROWAVE_MODES, options_field='x.com.samsung.da.supportedModes',
value_fn=lambda v: v[0] if v else None, value_fn=lambda v: v[0] if v else None,
write_fn=_mode_write), write_fn=_mode_write),
SwitchDesc(key='sound', field='x.com.samsung.da.options', SwitchDesc(key='sound', field='x.com.samsung.da.options',
@@ -154,6 +154,17 @@
"on": "On" "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": { "cycle": {
"name": "Cycle" "name": "Cycle"
}, },
@@ -438,6 +449,12 @@
"burner_state": { "burner_state": {
"name": "Burner {number} state" "name": "Burner {number} state"
}, },
"cavity_state": {
"name": "Cavity state"
},
"cavity_temp": {
"name": "Cavity temperature"
},
"clean_level": { "clean_level": {
"name": "Clean level" "name": "Clean level"
}, },
@@ -571,6 +588,9 @@
"power_energy_kwh": { "power_energy_kwh": {
"name": "Power energy" "name": "Power energy"
}, },
"power_level": {
"name": "Power level"
},
"power_watts": { "power_watts": {
"name": "Power" "name": "Power"
}, },
@@ -154,6 +154,17 @@
"on": "Aan" "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": { "cycle": {
"name": "Programma" "name": "Programma"
}, },
@@ -438,6 +449,12 @@
"burner_state": { "burner_state": {
"name": "Status brander {number}" "name": "Status brander {number}"
}, },
"cavity_state": {
"name": "Status ovenruimte"
},
"cavity_temp": {
"name": "Temperatuur ovenruimte"
},
"clean_level": { "clean_level": {
"name": "Reinigingsniveau" "name": "Reinigingsniveau"
}, },
@@ -571,6 +588,9 @@
"power_energy_kwh": { "power_energy_kwh": {
"name": "Energieverbruik" "name": "Energieverbruik"
}, },
"power_level": {
"name": "Vermogensniveau"
},
"power_watts": { "power_watts": {
"name": "Vermogen" "name": "Vermogen"
}, },
+21 -7
View File
@@ -27,24 +27,38 @@ def test_microwave_fixture_resolves_and_has_no_unbound_hrefs():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MICROWAVE_MODE -- SelectDesc with non-empty options # MICROWAVE_MODE -- SelectDesc reads its options live from supportedModes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_microwave_mode_options_nonempty(): _SUPPORTED_MODES_REP = {'x.com.samsung.da.supportedModes': [
assert len(microwave.MICROWAVE_MODE.entities[0].options) > 0 '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(): def test_microwave_mode_write_round_trips():
desc = microwave.MICROWAVE_MODE.entities[0] desc = microwave.MICROWAVE_MODE.entities[0]
valid_mode = desc.options[1] # e.g. 'MicroWave' path, body = desc.write_fn('MicroWave', _SUPPORTED_MODES_REP)
path, body = desc.write_fn(valid_mode, {})
assert path == ['mode', 'vs', '0'] assert path == ['mode', 'vs', '0']
assert body['x.com.samsung.da.modes'] == [valid_mode] assert body['x.com.samsung.da.modes'] == ['MicroWave']
def test_microwave_mode_rejects_unknown(): def test_microwave_mode_rejects_unknown():
desc = microwave.MICROWAVE_MODE.entities[0] desc = microwave.MICROWAVE_MODE.entities[0]
assert desc.write_fn('SpaghettiMode', {}) is None 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------