Merge pull request #28 from mbillow/claude/issue-9-dosing-select-format-and-blocking-fix

fix: normalize washer dosing-select codes; stop blocking the loop in diagnostics (#9)
This commit is contained in:
Marc Billow
2026-07-21 19:10:29 -05:00
committed by GitHub
7 changed files with 183 additions and 71 deletions
+4 -3
View File
@@ -16,6 +16,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi
| Type | Registry |
|---|---|
| Air conditioner | `by_type/airconditioner.py` |
| Dryer | `by_type/dryer.py` |
| Oven | `by_type/oven.py` |
| Dishwasher | `by_type/dishwasher.py` |
@@ -99,7 +100,7 @@ custom_components/localthings/
diagnostics.py Redacted diagnostics download (device state + coverage metadata)
const.py Domain, config keys, probe ports
entity.py Base entity wiring capability registry -> HA entity
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py
sensor.py / binary_sensor.py / switch.py / number.py / select.py / button.py / time.py / climate.py
One module per HA platform
strings.json / translations/ Config-flow copy + entity state translations
registry/
@@ -110,8 +111,8 @@ custom_components/localthings/
adapter.py Flattens bound entities into HA-ready state
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, dryer, oven,
dishwasher, fridge, washer, laundry, operational, ignored)
capabilities/ Shared + per-family Capability definitions (common, airconditioner,
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
+43 -33
View File
@@ -30,24 +30,27 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ClimateDesc
# The AC's canonical resource hrefs live in the capability module (the single
# source of truth shared with its COVERAGE caps); power prefers the OCF-standard
# href, falling back to the vendor one, mirroring common.POWER_GENERIC /
# POWER_VS_FALLBACK.
from .registry.capabilities.airconditioner import (
HREF_MODE as MODE_HREF,
HREF_POWER as POWER_HREF,
HREF_POWER_VS as POWER_VS_HREF,
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
# Sibling OCF resources the climate entity reads (the primary bound href is
# /mode/vs/0). Power prefers the OCF-standard href, falling back to the vendor
# one, mirroring common.POWER_GENERIC / POWER_VS_FALLBACK.
POWER_HREF = '/power/0'
POWER_VS_HREF = '/power/vs/0'
MODE_HREF = '/mode/vs/0'
TEMP_CURRENT_HREF = '/temperature/current/0'
TEMP_DESIRED_HREF = '/temperature/desired/0'
TEMP_CONTROL_HREF = '/temperature/control/vs/0'
WIND_STRENGTH_HREF = '/wind/strength/vs/0'
WIND_DIRECTION_HREF = '/wind/direction/vs/0'
CONVENIENT_HREF = '/mode/convenient/vs/0'
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
@@ -159,12 +162,22 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
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)))
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]
# -- temperature --------------------------------------------------------
@property
def temperature_unit(self) -> str:
units = str(self._rep(TEMP_DESIRED_HREF).get('units', 'C')).upper()
return UnitOfTemperature.FAHRENHEIT if units.startswith('F') else UnitOfTemperature.CELSIUS
raw = self._rep(TEMP_DESIRED_HREF).get('units')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
@property
def current_temperature(self):
@@ -214,30 +227,27 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def fan_mode(self):
return _DEVICE_TO_FAN.get(_first(self._rep(WIND_STRENGTH_HREF).get(_MODES_FIELD)))
return self._read_mode(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
@property
def fan_modes(self) -> list[str]:
return [_DEVICE_TO_FAN[c] for c in self._supported(WIND_STRENGTH_HREF)
if c in _DEVICE_TO_FAN]
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
@property
def swing_mode(self):
return _DEVICE_TO_SWING.get(_first(self._rep(WIND_DIRECTION_HREF).get(_MODES_FIELD)))
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
@property
def swing_modes(self) -> list[str]:
return [_DEVICE_TO_SWING[c] for c in self._supported(WIND_DIRECTION_HREF)
if c in _DEVICE_TO_SWING]
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
@property
def preset_mode(self):
return _DEVICE_TO_PRESET.get(_first(self._rep(CONVENIENT_HREF).get(_MODES_FIELD)))
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
@property
def preset_modes(self) -> list[str]:
return [_DEVICE_TO_PRESET[c] for c in self._supported(CONVENIENT_HREF)
if c in _DEVICE_TO_PRESET]
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
# -- writes -------------------------------------------------------------
@@ -263,17 +273,17 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
async def async_turn_off(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
async def async_set_fan_mode(self, fan_mode: str) -> None:
device = _FAN_TO_DEVICE.get(fan_mode)
async def _set_mapped(self, kind: str, mapping: dict, value: str) -> None:
"""Map an HA fan/swing/preset value back to its device code and write it."""
device = mapping.get(value)
if device is not None:
await self.coordinator.async_send_command(self._bound, ('fan', device))
await self.coordinator.async_send_command(self._bound, (kind, device))
async def async_set_fan_mode(self, fan_mode: str) -> None:
await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode)
async def async_set_swing_mode(self, swing_mode: str) -> None:
device = _SWING_TO_DEVICE.get(swing_mode)
if device is not None:
await self.coordinator.async_send_command(self._bound, ('swing', device))
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
async def async_set_preset_mode(self, preset_mode: str) -> None:
device = _PRESET_TO_DEVICE.get(preset_mode)
if device is not None:
await self.coordinator.async_send_command(self._bound, ('preset', device))
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
+6 -1
View File
@@ -26,13 +26,18 @@ async def async_get_config_entry_diagnostics(
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
integration = await async_get_integration(hass, DOMAIN)
# importlib.metadata.version() reads the installed package's metadata off
# disk (listdir + open + read_text), which trips HA's event-loop blocking
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
"integration_version": integration.version,
"smartthings_local_version": pkg_version("smartthings-local"),
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
"observe_subscribed_hrefs": sorted(coordinator._observe.subscribed_hrefs),
"observe_fallback_hrefs": sorted(coordinator._observe.fallback_hrefs),
@@ -17,6 +17,29 @@ by_type registry.
from ..capability import Capability
from ..entities import ClimateDesc, SensorDesc, SwitchDesc
# ---------------------------------------------------------------------------
# Canonical AC resource hrefs. The climate entity (climate.py) binds the
# primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS
# siblings off the coordinator snapshot; those siblings are marked covered
# (no-entity caps) so discover() reports no gap. Declared once here and
# imported by climate.py, so a new sibling read can't drift out of sync with
# its coverage entry.
# ---------------------------------------------------------------------------
HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE)
HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF
HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off
HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature
HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target)
HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step
HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode
HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode
HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode
CLIMATE_CONSUMED_HREFS = [
HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED,
HREF_TEMP_CONTROL, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, HREF_CONVENIENT,
]
def _num(v):
try:
@@ -71,7 +94,7 @@ def _climate_write(payload, rep, href=None):
CLIMATE = Capability(
href='/mode/vs/0',
href=HREF_MODE,
poll_tier='warm',
entities=(
ClimateDesc(key='climate', translation_key='airconditioner',
@@ -124,24 +147,13 @@ AIR_FILTER = Capability(
)
# ---------------------------------------------------------------------------
# AC-scoped coverage: hrefs consumed by the composite climate entity, and
# vendor duplicates / all-zero-ambiguous / plumbing resources. These are NOT in
# the global ignored.IGNORED because several of them (/mode/vs/0 handled above,
# /temperatures/vs/0, /humidity/*) collide with other families' schemas. A
# no-entity Capability still marks the href as bound so discover() reports no
# coverage gap.
# AC-scoped coverage: the CLIMATE_CONSUMED_HREFS above (read by the climate
# entity) plus vendor duplicates / all-zero-ambiguous / plumbing resources.
# These are NOT in the global ignored.IGNORED because several of them
# (/mode/vs/0 handled above, /temperatures/vs/0, /humidity/*) collide with
# other families' schemas. A no-entity Capability still marks the href as
# bound so discover() reports no coverage gap.
# ---------------------------------------------------------------------------
_CONSUMED_BY_CLIMATE = [
'/power/0', # on/off -> climate HVACMode.OFF / TURN_ON/OFF
'/power/vs/0', # vendor fallback for on/off
'/temperature/current/0', # climate current_temperature
'/temperature/desired/0', # climate target_temperature (write target)
'/temperature/control/vs/0', # climate target_temperature_step
'/wind/strength/vs/0', # climate fan_mode
'/wind/direction/vs/0', # climate swing_mode
'/mode/convenient/vs/0', # climate preset_mode
]
_AC_IGNORED = [
# Vendor superset that duplicates the OCF /temperature/current+desired pair.
'/temperatures/vs/0',
@@ -155,4 +167,4 @@ _AC_IGNORED = [
]
# Built as bare no-entity caps; folded into the AC registry (not global).
COVERAGE = [Capability(href=h) for h in (_CONSUMED_BY_CLIMATE + _AC_IGNORED)]
COVERAGE = [Capability(href=h) for h in (CLIMATE_CONSUMED_HREFS + _AC_IGNORED)]
@@ -160,13 +160,50 @@ def _level_options(prefix):
return lambda resources: _supported_level_options(resources, prefix)
def _dosing_level(prefix):
"""Current dose code, normalized to the `Supported<prefix>` code format.
The device reports the selected level as `<prefix>_<code>` with the code
un-padded (e.g. '3'), but the valid codes -- which are also this select's
options and its translation keys -- come from `Supported<prefix>_<hexpairs>`
as zero-padded hex pairs (e.g. '03'). Left as '3', the current value sits
outside the select's own option list, so HA renders it 'unknown' (issue #9).
Resolve it to the supported code with the same integer value so
current_option matches an option (and its translation)."""
def fn(rep):
opts = rep.get('x.com.samsung.da.options')
raw = option_value(opts, prefix)
if raw is None:
return None
supported_raw = option_value(opts, f'Supported{prefix}')
try:
target = int(raw, 16)
except (TypeError, ValueError):
return raw
for code in hex_pairs(supported_raw) if supported_raw else []:
try:
if int(code, 16) == target:
return code
except (TypeError, ValueError):
continue
return raw
return fn
def _level_write(prefix):
def write(p, rep, href=None):
opts = list(rep.get('x.com.samsung.da.options') or [])
if not opts:
return None
# `p` is the zero-padded supported code the UI selected (e.g. '03');
# the device stores the level un-padded (e.g. '3'), matching how it
# reports it, so write it back in that native shape.
try:
native = format(int(p, 16), 'X')
except (TypeError, ValueError):
native = p
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': replace_in_options(opts, prefix, p),
'x.com.samsung.da.options': replace_in_options(opts, prefix, native),
}
return write
@@ -201,8 +238,7 @@ WASHER_COURSE = Capability(
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevelCtrl'),
rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', name='Detergent water hardness',
icon='mdi:water-opacity',
@@ -211,8 +247,7 @@ WASHER_COURSE = Capability(
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'DetergentLevel2Ctrl'),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', name='Softener quantity', icon='mdi:flask-outline',
translation_key='washer_dosing_quantity',
@@ -220,8 +255,7 @@ WASHER_COURSE = Capability(
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevelCtrl'),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', name='Softener concentration',
icon='mdi:flask-plus-outline',
@@ -230,8 +264,7 @@ WASHER_COURSE = Capability(
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=lambda rep: option_value(
rep.get('x.com.samsung.da.options'), 'SoftenerLevel2Ctrl'),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', name='Detergent low',
icon='mdi:alert-circle-outline', device_class='problem',
+26
View File
@@ -2,10 +2,12 @@
from __future__ import annotations
import json
import threading
from pathlib import Path
from homeassistant.core import HomeAssistant
from custom_components.localthings import diagnostics as diagnostics_mod
from custom_components.localthings.const import DOMAIN
from custom_components.localthings.diagnostics import async_get_config_entry_diagnostics
from custom_components.localthings.registry.redact import REDACTED
@@ -49,3 +51,27 @@ async def test_diagnostics_include_observe_mode_fields(
assert diag['observe_fallback_hrefs'] == []
assert 'observe_last_mode_change' in diag
assert diag['observe_href_freshness_s'] == {}
async def test_dependency_version_read_off_the_event_loop(
hass: HomeAssistant, mock_entry, mock_coordinator_session, monkeypatch
) -> None:
"""importlib.metadata.version() does blocking disk I/O, so it must run in
the executor, not on the event loop (issue #9's logs flagged it)."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
loop_thread_id = threading.get_ident() # this coroutine runs on the loop
seen: dict[str, int] = {}
real_pkg_version = diagnostics_mod.pkg_version
def _spy(name: str) -> str:
seen['thread_id'] = threading.get_ident()
return real_pkg_version(name)
monkeypatch.setattr(diagnostics_mod, 'pkg_version', _spy)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['smartthings_local_version']
assert seen['thread_id'] != loop_thread_id
+31 -6
View File
@@ -151,11 +151,32 @@ class TestDetergentSoftenerDosing:
return next(e for e in washer.WASHER_COURSE.entities if e.key == key)
def test_quantity_and_hardness_read(self):
"""The device reports the level un-padded ('3'), but the options and
translation keys are zero-padded supported codes ('03'); rep_fn
normalizes to the supported code so the value is a valid option
(issue #9 -- otherwise HA renders the select 'unknown')."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
assert self._desc('detergent_quantity').rep_fn(rep) == '3'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '2'
assert self._desc('softener_quantity').rep_fn(rep) == '3'
assert self._desc('softener_concentration').rep_fn(rep) == '2'
assert self._desc('detergent_quantity').rep_fn(rep) == '03'
assert self._desc('detergent_water_hardness').rep_fn(rep) == '02'
assert self._desc('softener_quantity').rep_fn(rep) == '03'
assert self._desc('softener_concentration').rep_fn(rep) == '02'
def test_current_value_is_a_valid_option_for_every_dosing_select(self):
"""The core regression: HA shows a select 'unknown' when current_option
is not in options. Each dosing select's value must be one of its own
options."""
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}
for key in ('detergent_quantity', 'detergent_water_hardness',
'softener_quantity', 'softener_concentration'):
desc = self._desc(key)
assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key
def test_read_passes_through_when_no_supported_match(self):
"""A value with no matching supported code is returned as-is rather than
dropped, so an unexpected device stays visible instead of blank."""
rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7',
'SupportedDetergentLevelCtrl_00010203']}
assert self._desc('detergent_quantity').rep_fn(rep) == '7'
def test_translation_keys(self):
"""detergent_quantity and softener_quantity share one translation_key
@@ -181,17 +202,21 @@ class TestDetergentSoftenerDosing:
assert desc.exists_fn({}, _DOSING_RESOURCES) is True
def test_quantity_write(self):
"""The UI selects a padded supported code ('01'); the write posts the
un-padded device code ('1'), mirroring how the device reports it."""
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('detergent_quantity').write_fn('01', rep)
assert path == ['course', 'vs', '0']
assert 'DetergentLevelCtrl_01' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_1' in body['x.com.samsung.da.options']
assert 'DetergentLevelCtrl_3' not in body['x.com.samsung.da.options']
# untouched siblings survive the read-modify-write
assert 'SoftenerLevelCtrl_3' in body['x.com.samsung.da.options']
def test_hardness_write(self):
rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)}
path, body = self._desc('softener_concentration').write_fn('03', rep)
assert path == ['course', 'vs', '0']
assert 'SoftenerLevel2Ctrl_03' in body['x.com.samsung.da.options']
assert 'SoftenerLevel2Ctrl_3' in body['x.com.samsung.da.options']
def test_low_reservoir_off_when_alarm_off(self):
rep = {'x.com.samsung.da.options': _DOSING_OPTIONS}