Compare commits

..
Author SHA1 Message Date
Marc Billow 45931332d4 Rework AIComfort as an HVACMode.AUTO + preset overlay, add unmapped-mode warning (issue #93)
AIComfort isn't a distinct thermodynamic operation like Cool/Dry/Heat --
it's an AI-driven overlay on top of the device's own 'Auto' behavior,
confirmed by A-CAWW-TP2-20-COMMON reporting both 'Auto' and 'AIComfort'
as separate, mutually-exclusive entries in /mode/vs/0's supportedModes.
Modeled the idiomatic HA way instead of a flat _DEVICE_TO_HVAC entry:
hvac_mode reports AUTO and a new 'ai_comfort' preset carries the
distinction. Entered/left only via the preset (writes the primary mode
resource, not the convenient one) -- there's no dedicated HVACMode
value for it, so it's not offered in the hvac_mode dropdown directly.

Also adds a once-per-(href, code) warning log when a device-reported
mode has no entry in the relevant map, so a future gap like this one
surfaces in the log instead of silently vanishing -- the exact failure
mode issue #93 called out ("this class of gap is invisible without
diffing against supportedModes").
2026-07-27 02:01:51 +00:00
Marc Billow 8c3250b163 Map AC's AIComfort mode to HVACMode.AUTO (issue #93)
A-CAWW-TP2-20-COMMON (and likely other CAWW/TP2X-class boards) reports
'AIComfort' as a distinct entry in /mode/vs/0's supportedModes,
alongside 'Auto' (already mapped to HEAT_COOL). _read_modes() silently
drops any code missing from _DEVICE_TO_HVAC, so AIComfort was
unreachable -- one of HA's five other AC hvac_modes, unused by this
device family until now.

The other two gaps in issue #93 are already addressed elsewhere and
not duplicated here:
- Fan-only via the 'Fan' device code, and the WindFree/LongWind/
  NanoSleep preset codes, are covered by the still-open PR #91, which
  replaces the static preset table with a fully dynamic resolver over
  the device's own supportedModes.
- The 'Left_And_Right' -> horizontal swing mapping is already on the
  still-open claude/device-support-issue-75-windfree-ac branch.
2026-07-27 01:43:22 +00:00
14 changed files with 116 additions and 678 deletions

No files matched your search

+4 -63
View File
@@ -103,66 +103,7 @@ 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. 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
## 5. 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
@@ -201,7 +142,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`.
## 8. Coverage discipline: bound or ignored
## 6. 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`)
@@ -215,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.
## 9. Reuse before writing new code
## 7. 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
@@ -224,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.
## 10. Lock it in
## 8. Lock it in
1. Add a **scrubbed** fixture `tests/fixtures/<type>_device.json`
(`{"device0": [ {devcol rep}, {href, rep}, ... ]}`) — replace serials, MACs,
+2 -3
View File
@@ -24,7 +24,6 @@ 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` |
@@ -125,8 +124,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, microwave, oven, dishwasher, fridge,
washer, laundry, operational, ignored)
cooktop, range_hood, dryer, 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
+67 -4
View File
@@ -20,6 +20,8 @@ back for, power, mode, temperature and wind resources alike.
"""
from __future__ import annotations
import logging
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
@@ -53,6 +55,8 @@ from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
@@ -68,6 +72,19 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = {
}
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
# the idiomatic HA way instead: hvac_mode reports AUTO (same as plain 'Auto'
# would if it were ever mapped there) and a dedicated 'ai_comfort' preset
# carries the distinction a bare hvac_mode can't. Not reachable via
# async_set_hvac_mode -- entered/left only through the preset, since there's
# no dedicated HVACMode value for it to write back to.
_AI_COMFORT_MODE = 'AIComfort'
PRESET_AI_COMFORT = 'ai_comfort'
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
# a clean match exists so they auto-localize; "turbo" is custom (translated).
_DEVICE_TO_FAN: dict[str, str] = {
@@ -164,6 +181,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
# (href, raw device code) pairs already logged by _warn_unmapped --
# these properties are read on every coordinator refresh, so an
# un-deduped warning would spam the log for any device with a
# genuinely unrecognized code.
self._warned_unmapped: set[tuple[str, str]] = set()
# -- resource helpers ---------------------------------------------------
@@ -180,13 +202,34 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
def _warn_unmapped(self, href: str, code: str) -> None:
"""Log once per (href, code) when a device-reported mode has no
entry in the relevant device<->HA map, so a real device gap surfaces
in the log instead of silently vanishing (issue #93)."""
key = (href, code)
if key in self._warned_unmapped:
return
self._warned_unmapped.add(key)
_LOGGER.warning(
"%s: device mode %r on %s has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id, code, href,
)
def _read_mode(self, href: str, mapping: dict):
"""Current mode of a wind/convenient resource, mapped to its HA value."""
return mapping.get(_first(self._rep(href).get(_MODES_FIELD)))
raw = _first(self._rep(href).get(_MODES_FIELD))
if raw is not None and raw not in mapping:
self._warn_unmapped(href, raw)
return mapping.get(raw)
def _read_modes(self, href: str, mapping: dict) -> list[str]:
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
return [mapping[c] for c in self._supported(href) if c in mapping]
supported = self._supported(href)
for c in supported:
if c not in mapping:
self._warn_unmapped(href, c)
return [mapping[c] for c in supported if c in mapping]
# -- temperature --------------------------------------------------------
@@ -250,14 +293,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
if not self._is_on():
return HVACMode.OFF
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
if device == _AI_COMFORT_MODE:
return HVACMode.AUTO
if device is not None and device not in _DEVICE_TO_HVAC:
self._warn_unmapped(MODE_HREF, device)
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
@property
def hvac_modes(self) -> list[HVACMode]:
modes = [HVACMode.OFF]
for m in self._supported(MODE_HREF):
if m == _AI_COMFORT_MODE:
continue
mapped = _DEVICE_TO_HVAC.get(m)
if mapped is not None and mapped not in modes:
if mapped is None:
self._warn_unmapped(MODE_HREF, m)
continue
if mapped not in modes:
modes.append(mapped)
return modes
@@ -281,11 +333,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def preset_mode(self):
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE:
return PRESET_AI_COMFORT
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
@property
def preset_modes(self) -> list[str]:
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
modes = self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
if _AI_COMFORT_MODE in self._supported(MODE_HREF):
modes.append(PRESET_AI_COMFORT)
return modes
# -- writes -------------------------------------------------------------
@@ -324,4 +381,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode == PRESET_AI_COMFORT:
# Writes the primary mode resource, not the convenient one --
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
return
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
@@ -3,7 +3,7 @@ from typing import Optional
from ._base import DeviceRegistry
from . import (
air_purifier, airconditioner, cooktop, dishwasher, dryer, microwave, oven,
air_purifier, airconditioner, cooktop, dishwasher, dryer, oven,
range as _range, range_hood, refrigerator, washer,
)
@@ -21,7 +21,6 @@ _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,
@@ -142,11 +141,6 @@ 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
@@ -1,24 +0,0 @@
"""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,11 +131,4 @@ 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'),
]
@@ -1,138 +0,0 @@
"""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))),
),
)
@@ -95,6 +95,7 @@
},
"preset_mode": {
"state": {
"ai_comfort": "AI Comfort",
"quiet": "Quiet",
"smart": "Smart",
"speed": "Speed"
@@ -154,17 +155,6 @@
"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"
},
@@ -449,12 +439,6 @@
"burner_state": {
"name": "Burner {number} state"
},
"cavity_state": {
"name": "Cavity state"
},
"cavity_temp": {
"name": "Cavity temperature"
},
"clean_level": {
"name": "Clean level"
},
@@ -588,9 +572,6 @@
"power_energy_kwh": {
"name": "Power energy"
},
"power_level": {
"name": "Power level"
},
"power_watts": {
"name": "Power"
},
@@ -95,6 +95,7 @@
},
"preset_mode": {
"state": {
"ai_comfort": "AI-comfort",
"quiet": "Stil",
"smart": "Slim",
"speed": "Snel"
@@ -154,17 +155,6 @@
"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"
},
@@ -449,12 +439,6 @@
"burner_state": {
"name": "Status brander {number}"
},
"cavity_state": {
"name": "Status ovenruimte"
},
"cavity_temp": {
"name": "Temperatuur ovenruimte"
},
"clean_level": {
"name": "Reinigingsniveau"
},
@@ -588,9 +572,6 @@
"power_energy_kwh": {
"name": "Energieverbruik"
},
"power_level": {
"name": "Vermogensniveau"
},
"power_watts": {
"name": "Vermogen"
},
-22
View File
@@ -1,22 +0,0 @@
{
"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"
]
}
-217
View File
@@ -1,217 +0,0 @@
{
"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"
]
}
}
]
}
+40
View File
@@ -0,0 +1,40 @@
"""Tests for the AC HVAC-mode/preset device<->HA maps in climate.py (issue #93).
Module-level dicts/constants with no coordinator/entity dependency, so --
like `_temps_vs_item` in test_climate_temperature_fallback.py -- they're
testable directly.
"""
from homeassistant.components.climate import HVACMode
from custom_components.localthings.climate import (
_AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT,
)
def test_auto_still_maps_to_heat_cool():
"""'Auto' is unchanged -- AIComfort is handled separately, not folded
into this map."""
assert _DEVICE_TO_HVAC['Auto'] == HVACMode.HEAT_COOL
def test_aicomfort_not_in_flat_hvac_map():
"""AIComfort isn't a flat _DEVICE_TO_HVAC entry -- it's an AI overlay on
top of 'Auto', modeled as hvac_mode=AUTO + a dedicated preset instead of
a distinct HVACMode value (see the climate.py module comment)."""
assert _AI_COMFORT_MODE not in _DEVICE_TO_HVAC
def test_hvac_auto_not_writable_via_hvac_mode():
"""HVACMode.AUTO has no _DEVICE_TO_HVAC entry, so it's unreachable via
async_set_hvac_mode -- entered/left only through the ai_comfort preset."""
assert HVACMode.AUTO not in _HVAC_TO_DEVICE
def test_fan_only_still_reachable_via_wind():
"""Guard against regressing the existing 'Wind' -> FAN_ONLY entry while
editing this map."""
assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY
def test_preset_ai_comfort_constant():
assert PRESET_AI_COMFORT == 'ai_comfort'
-17
View File
@@ -294,23 +294,6 @@ 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 = {
-136
View File
@@ -1,136 +0,0 @@
"""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'