diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index ce880a5..48d21be 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -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 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 (`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 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='_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..state.`, 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 Descriptors have **no `name` field**. Every entity is named from the shipped diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py index a8ea1d9..7d33fe3 100644 --- a/custom_components/localthings/registry/capabilities/microwave.py +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -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 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 +/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. @@ -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): - 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 ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]} @@ -109,7 +102,7 @@ MICROWAVE_MODE = Capability( entities=( SelectDesc(key='cook_mode', field='x.com.samsung.da.modes', icon='mdi:tune', - options=_MICROWAVE_MODES, + 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', diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..4eadccf 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -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" }, diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..91a6d7c 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -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" }, diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 9136790..10d3226 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -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(): - assert len(microwave.MICROWAVE_MODE.entities[0].options) > 0 +_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] - valid_mode = desc.options[1] # e.g. 'MicroWave' - path, body = desc.write_fn(valid_mode, {}) + path, body = desc.write_fn('MicroWave', _SUPPORTED_MODES_REP) 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(): 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 # ---------------------------------------------------------------------------