From 4a5d6bca5969c357a7b4b3b0c5178ac9f2a8ade6 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Fri, 24 Jul 2026 15:28:46 +0000 Subject: [PATCH] Parse the wattage out of microwave power_level instead of a raw string "700W"/"0W" was passed straight through as an untyped string sensor. Parse the numeral and set device_class/unit so HA treats it as a power measurement. Add a unit-handling step to the adding-device-support skill so future dumps get this right the first time. --- .claude/skills/adding-device-support/SKILL.md | 36 ++++++++++++++++--- .../registry/capabilities/microwave.py | 25 ++++++++++--- tests/test_microwave_capabilities.py | 11 ++++-- 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index 14a0c85..8dcd2ac 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -103,7 +103,35 @@ 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. Enum selects need translation support +## 5. 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. + +## 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: @@ -114,7 +142,7 @@ settings) must render through translations, not Python: (e.g. `"16": "Cotton"`). Codes with no entry render as the raw code — that's the cue to identify and name them. -## 6. 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`) @@ -128,7 +156,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 +## 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 @@ -137,7 +165,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 +## 9. Lock it in 1. Add a **scrubbed** fixture `tests/fixtures/_device.json` (`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs, diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py index 5256dda..57a7d32 100644 --- a/custom_components/localthings/registry/capabilities/microwave.py +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -11,8 +11,9 @@ re-declared here -- see by_type/microwave.py. 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. +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 @@ -27,6 +28,8 @@ below any plausible cook temperature, with no confirmed write contract. 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 @@ -39,6 +42,18 @@ def _int(v): 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 @@ -111,10 +126,10 @@ MICROWAVE_CAVITY = Capability( 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'), + name='Power level', icon='mdi:flash', device_class='power', + state_class='measurement', unit='W', + value_fn=_power_level_watts), ), ) diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 573cb41..9136790 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -84,9 +84,16 @@ def test_cavity_state_reads_state_field(): assert desc.value_fn('Ready') == 'Ready' -def test_power_level_reads_raw_string(): +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') == '700W' + 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 # ---------------------------------------------------------------------------