Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb07af3c9a |
@@ -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. Names and enum labels live in translations, never in Python
|
||||
## 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. 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
|
||||
@@ -142,7 +173,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`.
|
||||
|
||||
## 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`)
|
||||
@@ -156,7 +187,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
|
||||
@@ -165,7 +196,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,
|
||||
|
||||
@@ -20,8 +20,6 @@ back for, power, mode, temperature and wind resources alike.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
@@ -55,8 +53,6 @@ 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'
|
||||
|
||||
@@ -72,19 +68,6 @@ _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] = {
|
||||
@@ -181,11 +164,6 @@ 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 ---------------------------------------------------
|
||||
|
||||
@@ -202,34 +180,13 @@ 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."""
|
||||
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)
|
||||
return mapping.get(_first(self._rep(href).get(_MODES_FIELD)))
|
||||
|
||||
def _read_modes(self, href: str, mapping: dict) -> list[str]:
|
||||
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
|
||||
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]
|
||||
return [mapping[c] for c in self._supported(href) if c in mapping]
|
||||
|
||||
# -- temperature --------------------------------------------------------
|
||||
|
||||
@@ -293,23 +250,14 @@ 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 None:
|
||||
self._warn_unmapped(MODE_HREF, m)
|
||||
continue
|
||||
if mapped not in modes:
|
||||
if mapped is not None and mapped not in modes:
|
||||
modes.append(mapped)
|
||||
return modes
|
||||
|
||||
@@ -333,16 +281,11 @@ 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]:
|
||||
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
|
||||
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
|
||||
# -- writes -------------------------------------------------------------
|
||||
|
||||
@@ -381,10 +324,4 @@ 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)
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"ai_comfort": "AI Comfort",
|
||||
"quiet": "Quiet",
|
||||
"smart": "Smart",
|
||||
"speed": "Speed"
|
||||
|
||||
@@ -95,7 +95,6 @@
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"ai_comfort": "AI-comfort",
|
||||
"quiet": "Stil",
|
||||
"smart": "Slim",
|
||||
"speed": "Snel"
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
"""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'
|
||||
Reference in New Issue
Block a user