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.
This commit is contained in:
Marc Billow
2026-07-24 15:28:46 +00:00
parent ede8719636
commit 4a5d6bca59
3 changed files with 61 additions and 11 deletions
+32 -4
View File
@@ -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/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
@@ -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),
),
)
+9 -2
View File
@@ -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
# ---------------------------------------------------------------------------