Add ruff (lint + format) and ty (type checking) to the project

Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated
ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET,
PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree
with `ruff format`, and fixes the pre-existing lint and type-check debt
those tools surfaced so both run clean.

Production-code type fixes include: HA's ConfigFlowResult vs. the
generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its
platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via
cast() instead of an unchecked annotation, converting HA device_class
strings to their proper enum types, a resolve_registry callback typed
as `object` instead of `DeviceRegistry | None`, and a couple of other
narrow correctness fixes (CA key type validation, an index-out-of-bounds
false positive from an empty-tuple fallback, a bool/dict argument swap).

Test-file fixes are mechanical: narrowing SamsungEntityDescription to
the correct subclass via isinstance()/cast() before accessing
subclass-only fields, and asserting Optional write_fn/unit_fn fields
are set before calling them.
This commit is contained in:
Marc Billow
2026-08-02 23:56:38 +00:00
parent b0eab93b58
commit daf7e3787f
147 changed files with 9977 additions and 7584 deletions
+7 -6
View File
@@ -1,4 +1,5 @@
"""Local Things — Samsung appliance local control integration."""
from __future__ import annotations
import logging
@@ -27,7 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry,
hass: HomeAssistant,
entry: ConfigEntry,
device: dr.DeviceEntry,
) -> bool:
"""Allow deleting a device this entry no longer provides (issue #214).
@@ -51,16 +54,14 @@ async def async_remove_config_entry_device(
area and automation references on a transient miss. The user gets the
button; the integration doesn't guess.
"""
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(
entry.entry_id
)
coordinator: LocalThingsCoordinator | None = hass.data.get(DOMAIN, {}).get(entry.entry_id)
if coordinator is None:
# Entry not loaded (or already unloaded) -- nothing is claiming this
# device, so there's nothing to protect it from being removed.
return True
live = set(coordinator.device_info.get('identifiers') or set())
live = set(coordinator.device_info.get("identifiers") or set())
for subdevice in coordinator.subdevices:
live |= set(coordinator.device_info_for(subdevice).get('identifiers') or set())
live |= set(coordinator.device_info_for(subdevice).get("identifiers") or set())
return not (device.identifiers & live)
@@ -1,16 +1,16 @@
"""Binary sensor platform for Local Things."""
from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.components.binary_sensor import BinarySensorDeviceClass, BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import BinarySensorDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import BinarySensorDesc
async def async_setup_entry(
@@ -27,11 +27,12 @@ async def async_setup_entry(
class LocalThingsBinarySensor(LocalThingsEntity, BinarySensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: BinarySensorDesc = bound.desc
self._attr_device_class = desc.device_class
self._attr_device_class = (
BinarySensorDeviceClass(desc.device_class) if desc.device_class else None
)
@property
def is_on(self):
+2 -3
View File
@@ -1,4 +1,5 @@
"""Button platform for Local Things."""
from __future__ import annotations
from homeassistant.components.button import ButtonEntity
@@ -6,11 +7,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ButtonDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import ButtonDesc
async def async_setup_entry(
@@ -27,7 +27,6 @@ async def async_setup_entry(
class LocalThingsButton(LocalThingsEntity, ButtonEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
self._payload = bound.desc.payload
+4 -3
View File
@@ -20,6 +20,7 @@ states exist, which is something the Python side genuinely needs to know:
Reading it from the catalog instead of restating it in Python means adding a
state or a course table is a one-file change, and the two can't drift.
"""
from __future__ import annotations
import json
@@ -28,8 +29,8 @@ from pathlib import Path
# Read at import, not lazily: custom integrations are imported in an executor
# thread, so this stays off the event loop no matter who asks first.
_ENTITY_CATALOG: dict[str, dict[str, dict]] = json.loads(
(Path(__file__).parent / 'translations' / 'en.json').read_text(encoding='utf-8')
).get('entity', {})
(Path(__file__).parent / "translations" / "en.json").read_text(encoding="utf-8")
).get("entity", {})
def has_entity_translation(platform: str, translation_key: str) -> bool:
@@ -46,4 +47,4 @@ def translated_states(platform: str, translation_key: str) -> frozenset[str]:
leave the device's value untouched.
"""
entry = _ENTITY_CATALOG.get(platform, {}).get(translation_key)
return frozenset(entry.get('state', ())) if entry else frozenset()
return frozenset(entry.get("state", ())) if entry else frozenset()
+120 -89
View File
@@ -18,58 +18,83 @@ applies the optimistic value/settle guard to that same href -- not the bound
`/mode/vs/0` href -- so one descriptor drives writes to, and gets fresh state
back for, power, mode, temperature and wind resources alike.
"""
from __future__ import annotations
import logging
from homeassistant.components.climate import (
PRESET_NONE,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
PRESET_NONE,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import ClimateDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.airconditioner import (
HREF_AIRFLOW as AIRFLOW_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_CONVENIENT as CONVENIENT_HREF,
)
# 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,
)
from .registry.capabilities.airconditioner import (
HREF_POWER as POWER_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_POWER_VS as POWER_VS_HREF,
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_CONTROL as TEMP_CONTROL_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_CURRENT as TEMP_CURRENT_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMP_DESIRED as TEMP_DESIRED_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_TEMPS_VS as TEMPS_VS_HREF,
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
HREF_CONVENIENT as CONVENIENT_HREF,
HREF_AIRFLOW as AIRFLOW_HREF,
)
from .registry.capabilities.airconditioner import (
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
)
from .registry.capabilities.airconditioner import (
is_legacy_board,
)
from .registry.capabilities.common import normalize_temp_unit
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import ClimateDesc
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# --- device code <-> HA value maps -----------------------------------------
# HVAC mode: Samsung /mode/vs/0 modes <-> HA HVACMode (excluding OFF, which is
# driven by the power resource).
_DEVICE_TO_HVAC: dict[str, HVACMode] = {
'Cool': HVACMode.COOL,
'Dry': HVACMode.DRY,
"Cool": HVACMode.COOL,
"Dry": HVACMode.DRY,
# Fan-only is spelled 'Wind' on some boards (e.g. TP1X_DA-AC-RAC-01001) and
# 'Fan' on others (e.g. TP1X_DA-AC-RAC-01011); both map to FAN_ONLY. The
# reverse write can't rely on this map alone (two codes, one HA value) --
@@ -79,14 +104,14 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = {
# comprehension below has 'Wind' win that fallback (last-key-wins),
# preserving the original single-spelling behavior rather than silently
# flipping it when 'Fan' was added.
'Fan': HVACMode.FAN_ONLY,
'Wind': HVACMode.FAN_ONLY,
"Fan": HVACMode.FAN_ONLY,
"Wind": HVACMode.FAN_ONLY,
# The device's 'Auto' is a single-setpoint "device decides" mode -> HA
# HVACMode.AUTO (renders "Auto"). Not HEAT_COOL: that renders "Heat/cool"
# and implies a two-setpoint heat+cool range these single-setpoint units
# (including cool-only models) don't have.
'Auto': HVACMode.AUTO,
'Heat': HVACMode.HEAT,
"Auto": HVACMode.AUTO,
"Heat": HVACMode.HEAT,
}
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
@@ -100,48 +125,50 @@ _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
# 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'
_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] = {
'0': 'auto',
'1': 'low',
'2': 'medium',
'3': 'high',
'4': 'turbo',
"0": "auto",
"1": "low",
"2": "medium",
"3": "high",
"4": "turbo",
}
_FAN_TO_DEVICE = {v: k for k, v in _DEVICE_TO_FAN.items()}
# Swing (wind direction): all map onto HA standard swing constants (auto-localize).
_DEVICE_TO_SWING: dict[str, str] = {
'Fix': 'off',
'All': 'both',
'Up_And_Low': 'vertical',
'Left_And_Right': 'horizontal', # issue #75
"Fix": "off",
"All": "both",
"Up_And_Low": "vertical",
"Left_And_Right": "horizontal", # issue #75
}
_SWING_TO_DEVICE = {v: k for k, v in _DEVICE_TO_SWING.items()}
# Swing fallback via /wind/oscillation/vs/0 (issue #126) -- boards without
# WIND_DIRECTION_HREF at all report two independent Swing|Fix toggles
# instead of one combined code. Same HA vocabulary as _DEVICE_TO_SWING
# above (off/vertical/horizontal/both), just read from/written to a pair
# of fields rather than a single one.
def _oscillation_swing(rep: dict) -> str | None:
vertical = rep.get('vertical')
horizontal = rep.get('horizontal')
vertical = rep.get("vertical")
horizontal = rep.get("horizontal")
if vertical is None and horizontal is None:
return None
v = vertical == 'Swing'
h = horizontal == 'Swing'
v = vertical == "Swing"
h = horizontal == "Swing"
if v and h:
return 'both'
return "both"
if v:
return 'vertical'
return "vertical"
if h:
return 'horizontal'
return 'off'
return "horizontal"
return "off"
def _wind_strength_label(code, rep: dict) -> str:
"""Human label for a /wind/strength/vs/0 code from the device's own
@@ -152,8 +179,8 @@ def _wind_strength_label(code, rep: dict) -> str:
"Auto"/"1"/"2"/"3"/"4"/"MAX"). No per-model numeric map -- mirrors
preset_mode's dynamic code->str resolution. Falls back to the raw code
lowercased when modesName is absent or misaligned."""
supported = rep.get('x.com.samsung.da.supportedModes') or []
names = rep.get('x.com.samsung.da.modesName') or []
supported = rep.get("x.com.samsung.da.supportedModes") or []
names = rep.get("x.com.samsung.da.modesName") or []
if code in supported and len(names) == len(supported):
return str(names[supported.index(code)]).lower()
return str(code).lower()
@@ -170,7 +197,7 @@ def _wind_strength_label(code, rep: dict) -> str:
# 'NanoSleep' codes on cool-only global RAC boards -- that's just a
# translation label, not a hard-coded mode.)
def _preset_to_ha(code) -> str:
return PRESET_NONE if code == 'Off' else str(code).lower()
return PRESET_NONE if code == "Off" else str(code).lower()
async def async_setup_entry(
@@ -211,7 +238,7 @@ def _temps_vs_item(rep: dict) -> dict:
`x.com.samsung.da.items[0]` carries current/desired/minimum/maximum/
increment/unit. Returns {} when absent, so callers fall through cleanly.
"""
items = rep.get('x.com.samsung.da.items')
items = rep.get("x.com.samsung.da.items")
if isinstance(items, (list, tuple)) and items and isinstance(items[0], dict):
return items[0]
return {}
@@ -249,16 +276,18 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# Preset codes on legacy ARTIK051 boards, learned by driving the same unit
# through its cloud integration and reading the local token back each time:
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
_LEGACY_PRESET_CODES = ('Off', 'Nano', 'Quiet', 'Comfort', '2Step', 'Speed')
_LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed")
def _legacy_convenient(self) -> dict:
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
/mode/vs/0's options, for boards that have no convenient resource."""
options = self._rep(MODE_HREF).get('x.com.samsung.da.options') or []
options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or []
for option in options:
if isinstance(option, str) and option.startswith('Comode_'):
return {_MODES_FIELD: [option.split('_', 1)[1]],
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES)}
if isinstance(option, str) and option.startswith("Comode_"):
return {
_MODES_FIELD: [option.split("_", 1)[1]],
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES),
}
return {}
def _legacy_airflow(self) -> dict:
@@ -304,8 +333,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
never look empty and this always resolve to the wrong side.
"""
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
return (not self.coordinator.resource(convenient_href)
and bool(self._legacy_airflow()))
return not self.coordinator.resource(convenient_href) and bool(self._legacy_airflow())
def _rep(self, href: str) -> dict:
"""`href` is one of this module's canonical HREF_* constants --
@@ -324,10 +352,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# The OCF /power/0 is absent on many boards and a stale mirror on
# some, so reading it first showed pre-write state after a power
# toggle (issue #53: "can turn on but not off").
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
if power is not None:
return str(power).lower() == 'on'
return bool(self._rep(POWER_HREF).get('value'))
return str(power).lower() == "on"
return bool(self._rep(POWER_HREF).get("value"))
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
@@ -343,7 +371,9 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
_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,
self.entity_id,
code,
href,
)
def _read_mode(self, href: str, mapping: dict):
@@ -378,18 +408,20 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMP_DESIRED_HREF).get('units')
raw = self._rep(TEMP_DESIRED_HREF).get("units")
if raw is None:
raw = self._temps_vs().get('x.com.samsung.da.unit')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
raw = self._temps_vs().get("x.com.samsung.da.unit")
return (
UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, "°C") == "°F"
else UnitOfTemperature.CELSIUS
)
@property
def current_temperature(self):
v = _num(self._rep(TEMP_CURRENT_HREF).get('temperature'))
v = _num(self._rep(TEMP_CURRENT_HREF).get("temperature"))
if v is None:
v = _num(self._temps_vs().get('x.com.samsung.da.current'))
v = _num(self._temps_vs().get("x.com.samsung.da.current"))
return v
@property
@@ -397,19 +429,19 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# Read from the same channel writes go to (see async_set_temperature):
# OCF /temperature/desired/0 on boards with the full OCF pair, vendor
# /temperatures/vs/0 otherwise -- with the other as fallback.
ocf = _num(self._rep(TEMP_DESIRED_HREF).get('temperature'))
vs = _num(self._temps_vs().get('x.com.samsung.da.desired'))
ocf = _num(self._rep(TEMP_DESIRED_HREF).get("temperature"))
vs = _num(self._temps_vs().get("x.com.samsung.da.desired"))
if self._ocf_temp_authoritative():
return ocf if ocf is not None else vs
return vs if vs is not None else ocf
def _range(self) -> list | None:
r = self._rep(TEMP_DESIRED_HREF).get('range')
r = self._rep(TEMP_DESIRED_HREF).get("range")
if isinstance(r, (list, tuple)) and len(r) == 2:
return r
item = self._temps_vs()
lo = _num(item.get('x.com.samsung.da.minimum'))
hi = _num(item.get('x.com.samsung.da.maximum'))
lo = _num(item.get("x.com.samsung.da.minimum"))
hi = _num(item.get("x.com.samsung.da.maximum"))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
@@ -424,10 +456,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
@property
def target_temperature_step(self) -> float:
return (_num(self._rep(TEMP_CONTROL_HREF).get('increment'))
or _num(self._rep(TEMP_CONTROL_HREF).get('x.com.samsung.da.increment'))
or _num(self._temps_vs().get('x.com.samsung.da.increment'))
or 1.0)
return (
_num(self._rep(TEMP_CONTROL_HREF).get("increment"))
or _num(self._rep(TEMP_CONTROL_HREF).get("x.com.samsung.da.increment"))
or _num(self._temps_vs().get("x.com.samsung.da.increment"))
or 1.0
)
# -- hvac mode ----------------------------------------------------------
@@ -462,7 +496,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def fan_mode(self):
airflow = self._legacy_airflow()
if airflow:
return _DEVICE_TO_FAN.get(str(airflow.get('x.com.samsung.da.speedLevel')))
return _DEVICE_TO_FAN.get(str(airflow.get("x.com.samsung.da.speedLevel")))
rep = self._rep(WIND_STRENGTH_HREF)
code = _first(rep.get(_MODES_FIELD))
if code is None:
@@ -492,7 +526,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def swing_mode(self):
airflow = self._legacy_airflow()
if airflow:
return _DEVICE_TO_SWING.get(airflow.get('x.com.samsung.da.direction'))
return _DEVICE_TO_SWING.get(airflow.get("x.com.samsung.da.direction"))
if self._swing_via_direction():
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
@@ -539,35 +573,35 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# matching the climate contract other integrations follow. Without this a
# set_temperature call carrying hvac_mode (e.g. a dashboard "turn on to
# Auto 24" button) set the setpoint but never changed mode or powered on.
hvac_mode = kwargs.get('hvac_mode')
hvac_mode = kwargs.get("hvac_mode")
if hvac_mode is not None:
await self.async_set_hvac_mode(hvac_mode)
if hvac_mode == HVACMode.OFF:
return
temp = kwargs.get('temperature')
temp = kwargs.get("temperature")
if temp is None:
return
# OCF-pair boards write /temperature/desired/0; vendor boards write
# /temperatures/vs/0 (see airconditioner._climate_write).
kind = 'temperature_ocf' if self._ocf_temp_authoritative() else 'temperature'
kind = "temperature_ocf" if self._ocf_temp_authoritative() else "temperature"
await self.coordinator.async_send_command(self._bound, (kind, temp))
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await self.coordinator.async_send_command(self._bound, ('power', False))
await self.coordinator.async_send_command(self._bound, ("power", False))
return
device = self._device_code_for_hvac(hvac_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ('mode', device))
await self.coordinator.async_send_command(self._bound, ("power", True))
await self.coordinator.async_send_command(self._bound, ("mode", device))
async def async_turn_on(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ("power", True))
async def async_turn_off(self) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
await self.coordinator.async_send_command(self._bound, ("power", False))
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."""
@@ -579,8 +613,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
if self._legacy_airflow():
level = _FAN_TO_DEVICE.get(fan_mode)
if level is not None:
await self.coordinator.async_send_command(
self._bound, ('fan_legacy', level))
await self.coordinator.async_send_command(self._bound, ("fan_legacy", level))
return
supported = self._supported(WIND_STRENGTH_HREF)
device = _FAN_TO_DEVICE.get(fan_mode)
@@ -598,33 +631,31 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
device = code
break
if device is not None:
await self.coordinator.async_send_command(self._bound, ('fan', device))
await self.coordinator.async_send_command(self._bound, ("fan", device))
async def async_set_swing_mode(self, swing_mode: str) -> None:
if self._legacy_airflow():
code = _SWING_TO_DEVICE.get(swing_mode)
if code is not None:
await self.coordinator.async_send_command(
self._bound, ('swing_legacy', code))
await self.coordinator.async_send_command(self._bound, ("swing_legacy", code))
return
if self._swing_via_direction():
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
await self._set_mapped("swing", _SWING_TO_DEVICE, swing_mode)
return
if self._rep(WIND_OSCILLATION_HREF):
await self.coordinator.async_send_command(
self._bound, ('oscillation', swing_mode))
await self.coordinator.async_send_command(self._bound, ("oscillation", 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))
await self.coordinator.async_send_command(self._bound, ("mode", _AI_COMFORT_MODE))
return
# Reverse-resolve against the unit's own supportedModes (codes aren't
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
for code in self._supported(CONVENIENT_HREF):
if _preset_to_ha(code) == preset_mode:
kind = 'preset_legacy' if self._legacy_preset() else 'preset'
kind = "preset_legacy" if self._legacy_preset() else "preset"
await self.coordinator.async_send_command(self._bound, (kind, code))
return
+147 -122
View File
@@ -1,6 +1,8 @@
"""Config flow for Local Things integration."""
from __future__ import annotations
import contextlib
import datetime
import json
import logging
@@ -13,8 +15,8 @@ from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
@@ -29,25 +31,36 @@ from homeassistant.helpers.selector import (
)
from .const import (
DOMAIN,
CONF_HOST, CONF_PORT,
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL,
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
PROBE_PORT_RANGE, PREFERRED_PROBE_PORTS, LIVENESS_PROBE_TIMEOUT_S,
CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM,
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM,
CONF_PORT,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DOMAIN,
LIVENESS_PROBE_TIMEOUT_S,
PREFERRED_PROBE_PORTS,
PROBE_GET_TIMEOUT_S,
PROBE_PORT_RANGE,
)
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
_HYSTERESIS_MINUTES = NumberSelector(NumberSelectorConfig(
min=0, max=30, step=1, mode=NumberSelectorMode.BOX,
))
_HYSTERESIS_MINUTES = NumberSelector(
NumberSelectorConfig(
min=0,
max=30,
step=1,
mode=NumberSelectorMode.BOX,
)
)
_LOGGER = logging.getLogger(__name__)
_SAMSUNG_CLOUD_HOST = 'connect-v2.samsungiotcloud.com'
_SAMSUNG_CLOUD_HOST = "connect-v2.samsungiotcloud.com"
class CannotConnect(Exception):
@@ -65,16 +78,21 @@ def _fetch_samsung_uuid() -> str:
We only need to read the UUID from the cert subject, not verify its trust.
"""
from cryptography import x509 as _x509
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((_SAMSUNG_CLOUD_HOST, 443), timeout=15) as raw:
with ctx.wrap_socket(raw, server_hostname=_SAMSUNG_CLOUD_HOST) as tls:
der = tls.getpeercert(binary_form=True)
with (
socket.create_connection((_SAMSUNG_CLOUD_HOST, 443), timeout=15) as raw,
ctx.wrap_socket(raw, server_hostname=_SAMSUNG_CLOUD_HOST) as tls,
):
der = tls.getpeercert(binary_form=True)
if der is None:
raise RuntimeError(f"No certificate received from {_SAMSUNG_CLOUD_HOST}")
cert = _x509.load_der_x509_certificate(der)
for attr in cert.subject:
if attr.oid == _x509.oid.NameOID.ORGANIZATIONAL_UNIT_NAME:
m = re.search(r'uuid:([0-9a-f-]+)', attr.value, re.IGNORECASE)
m = re.search(r"uuid:([0-9a-f-]+)", attr.value, re.IGNORECASE)
if m:
return m.group(1)
raise RuntimeError(f"UUID not found in {_SAMSUNG_CLOUD_HOST} certificate subject")
@@ -87,13 +105,15 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
followed by the full CA PEM, suitable for use_certificate_chain_file.
"""
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed448, ed25519
from cryptography.hazmat.primitives.asymmetric import rsa as _rsa
from cryptography.x509.oid import NameOID
m = re.search(
r'(-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----)',
ca_cert_pem, re.DOTALL,
r"(-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----)",
ca_cert_pem,
re.DOTALL,
)
if not m:
raise InvalidCA("No certificate found in CA cert PEM")
@@ -102,18 +122,33 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
ca_key = serialization.load_pem_private_key(ca_key_pem.encode(), password=None)
except Exception as exc:
raise InvalidCA(f"Failed to load CA credentials: {exc}") from exc
if not isinstance(
ca_key,
(
_rsa.RSAPrivateKey,
ec.EllipticCurvePrivateKey,
ed25519.Ed25519PrivateKey,
ed448.Ed448PrivateKey,
dsa.DSAPrivateKey,
),
):
raise InvalidCA(f"CA key is not a signing key (got {type(ca_key).__name__})")
leaf_key = _rsa.generate_private_key(public_exponent=65537, key_size=2048)
now = datetime.datetime.now(datetime.timezone.utc)
now = datetime.datetime.now(datetime.UTC)
leaf_cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, 'KR'),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Samsung Electronics'),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, f'uuid:{uuid}'),
x509.NameAttribute(NameOID.COMMON_NAME, f'urn:uuid:{uuid}'),
]))
.subject_name(
x509.Name(
[
x509.NameAttribute(NameOID.COUNTRY_NAME, "KR"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Samsung Electronics"),
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, f"uuid:{uuid}"),
x509.NameAttribute(NameOID.COMMON_NAME, f"urn:uuid:{uuid}"),
]
)
)
.issuer_name(ca_cert.subject)
.public_key(leaf_key.public_key())
.serial_number(x509.random_serial_number())
@@ -131,9 +166,9 @@ def _mint_leaf_cert(ca_cert_pem: str, ca_key_pem: str, uuid: str) -> tuple[str,
# Ensure a newline separates the leaf and CA blocks regardless of
# whether the user's pasted CA PEM had a trailing newline.
fullchain_pem = leaf_cert_pem.rstrip('\n') + '\n' + ca_cert_pem
if not fullchain_pem.endswith('\n'):
fullchain_pem += '\n'
fullchain_pem = leaf_cert_pem.rstrip("\n") + "\n" + ca_cert_pem
if not fullchain_pem.endswith("\n"):
fullchain_pem += "\n"
return fullchain_pem, leaf_key_pem
@@ -186,20 +221,19 @@ def _find_live_ports(host: str, ports: list[int], timeout: float) -> list[int]:
if remaining <= 0:
break
for key, _ in sel.select(timeout=remaining):
sock = sockets[key.data]
try:
# Data back means live; ECONNREFUSED (or any other socket
# error) means the port is closed/unusable — rule it out.
key.fileobj.recv(1)
sock.recv(1)
except OSError:
sel.unregister(key.fileobj)
sel.unregister(sock)
live = [key.data for key in sel.get_map().values()]
finally:
sel.close()
for sock in sockets.values():
try:
with contextlib.suppress(OSError):
sock.close()
except OSError:
pass
# The sweep's ICMP-based verdict isn't reliable on every network path --
# issue #192 captured a segregated-VLAN device where it called three
@@ -231,20 +265,17 @@ def _is_placeholder_serial(serial: str) -> bool:
config flow aborted as already configured.
"""
s = serial.strip()
if s.lower().startswith('nothing'):
if s.lower().startswith("nothing"):
return True
upper = s.upper()
return (
len(upper) >= 8
and len(set(upper)) == 1
and upper[0] in '0123456789ABCDEF'
)
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
"""Fetch UUID, mint leaf cert, probe each port. Returns config entry data dict."""
import cbor2
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .registry.batch import parse_device0_batch
from .registry.by_type import resolve as resolve_registry
from .registry.identity import read_identity
@@ -268,9 +299,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
raise CannotConnect(f"Failed to mint leaf cert: {exc}") from exc
_LOGGER.debug("Leaf cert minted successfully")
candidates = _find_live_ports(
host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S
)
candidates = _find_live_ports(host, PROBE_PORT_RANGE, LIVENESS_PROBE_TIMEOUT_S)
# No early "every port refused" fast-fail here: _find_live_ports always
# rescues PREFERRED_PROBE_PORTS (issue #192), so candidates is never
# empty as long as that table is non-empty and within PROBE_PORT_RANGE --
@@ -285,24 +314,19 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
sess = None
try:
sess = DtlsCoapSession(
host, port,
host,
port,
cert_pem=fullchain_pem,
key_pem=leaf_key_pem,
)
sess.connect()
sess.start_reader()
code, payload = sess.get(['device', '0'], timeout=PROBE_GET_TIMEOUT_S)
code, payload = sess.get(["device", "0"], timeout=PROBE_GET_TIMEOUT_S)
if code != 0x45 or not payload:
raise CannotConnect(f"port {port}: unexpected code {code:#04x}")
body = cbor2.loads(payload)
resources = (
parse_device0_batch(body) if isinstance(body, list) else {}
)
serial = (
resources
.get('/information/vs/0', {})
.get('x.com.samsung.da.serialNum', '')
)
resources = parse_device0_batch(body) if isinstance(body, list) else {}
serial = resources.get("/information/vs/0", {}).get("x.com.samsung.da.serialNum", "")
if not serial or _is_placeholder_serial(serial):
serial = f"{host}:{port}"
# /oic/d's device type (read_identity) is the primary detection
@@ -312,8 +336,7 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
# empty device_types tuple here, falling through to the model-
# string/resource-signature detection resolve() already did.
identity = read_identity(sess, None)
recognized_registry = resolve_registry(
resources, device_types=identity.device_types)
recognized_registry = resolve_registry(resources, device_types=identity.device_types)
return {
"port": port,
"serial": serial,
@@ -328,10 +351,8 @@ def _probe_and_validate(host: str, ca_cert_pem: str, ca_key_pem: str) -> dict:
_LOGGER.debug("port %d failed: %s", port, exc)
finally:
if sess is not None:
try:
with contextlib.suppress(Exception):
sess.close()
except Exception:
pass
raise CannotConnect(f"no port responded on {host}: {last_exc}")
@@ -351,22 +372,20 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> LocalThingsOptionsFlow:
return LocalThingsOptionsFlow()
def _create_entry(self, info: dict) -> FlowResult:
def _create_entry(self, info: dict) -> ConfigFlowResult:
return self.async_create_entry(
title=f"Samsung Appliance ({self._host})",
data={
CONF_HOST: self._host,
CONF_PORT: info["port"],
CONF_CA_CERT_PEM: self._ca_cert_pem,
CONF_CA_KEY_PEM: self._ca_key_pem,
CONF_HOST: self._host,
CONF_PORT: info["port"],
CONF_CA_CERT_PEM: self._ca_cert_pem,
CONF_CA_KEY_PEM: self._ca_key_pem,
CONF_LEAF_CERT_PEM: info["leaf_cert_pem"],
CONF_LEAF_KEY_PEM: info["leaf_key_pem"],
CONF_LEAF_KEY_PEM: info["leaf_key_pem"],
},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
existing = self.hass.config_entries.async_entries(DOMAIN)
has_creds = bool(existing)
@@ -376,10 +395,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._host = user_input[CONF_HOST].strip()
if has_creds:
self._ca_cert_pem = existing[0].data[CONF_CA_CERT_PEM]
self._ca_key_pem = existing[0].data[CONF_CA_KEY_PEM]
self._ca_key_pem = existing[0].data[CONF_CA_KEY_PEM]
else:
self._ca_cert_pem = user_input[CONF_CA_CERT_PEM].strip()
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
self._ca_key_pem = user_input[CONF_CA_KEY_PEM].strip()
try:
info = await self.hass.async_add_executor_job(
@@ -407,11 +426,13 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
schema = vol.Schema({vol.Required(CONF_HOST): _TEXT})
step_id = "user_reuse"
else:
schema = vol.Schema({
vol.Required(CONF_HOST): _TEXT,
vol.Required(CONF_CA_CERT_PEM): _MULTILINE,
vol.Required(CONF_CA_KEY_PEM): _MULTILINE,
})
schema = vol.Schema(
{
vol.Required(CONF_HOST): _TEXT,
vol.Required(CONF_CA_CERT_PEM): _MULTILINE,
vol.Required(CONF_CA_KEY_PEM): _MULTILINE,
}
)
step_id = "user"
return self.async_show_form(
@@ -422,15 +443,16 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def async_step_user_reuse(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
"""Handle the localized host-only form for additional appliances."""
return await self.async_step_user(user_input)
async def async_step_confirm_unknown_type(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
"""Shown only when the probe already knows the device type is unrecognized."""
if user_input is not None:
assert self._pending_info is not None
return self._create_entry(self._pending_info)
return self.async_show_form(
step_id="confirm_unknown_type",
@@ -461,9 +483,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
def _coordinator(self):
return self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
return self.async_show_menu(
step_id="init",
menu_options=["settings", "debug_write"],
@@ -471,32 +491,32 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
async def async_step_settings(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
if user_input is not None:
return self.async_create_entry(data=user_input)
return self.async_show_form(
step_id="settings",
data_schema=vol.Schema({
vol.Required(
CONF_BYPASS_REMOTE_CONTROL,
default=self.config_entry.options.get(
CONF_BYPASS_REMOTE_CONTROL, False
),
): bool,
vol.Required(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
default=self.config_entry.options.get(
data_schema=vol.Schema(
{
vol.Required(
CONF_BYPASS_REMOTE_CONTROL,
default=self.config_entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False),
): bool,
vol.Required(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
),
): _HYSTERESIS_MINUTES,
}),
default=self.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
),
): _HYSTERESIS_MINUTES,
}
),
)
async def async_step_debug_write(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
@@ -508,38 +528,48 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
hrefs = sorted(coord.last_resources.keys())
return self.async_show_form(
step_id="debug_write",
data_schema=vol.Schema({
vol.Required("href"): SelectSelector(SelectSelectorConfig(
options=hrefs,
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
)),
}),
data_schema=vol.Schema(
{
vol.Required("href"): SelectSelector(
SelectSelectorConfig(
options=hrefs,
custom_value=True,
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
)
def _show_debug_edit_form(
self, href: str, current: dict, errors: dict[str, str], payload,
) -> FlowResult:
self,
href: str,
current: dict,
errors: dict[str, str],
payload,
) -> ConfigFlowResult:
return self.async_show_form(
step_id="debug_edit",
data_schema=vol.Schema({
vol.Required(
"payload", default=(payload if payload is not None else {}),
): ObjectSelector(),
}),
data_schema=vol.Schema(
{
vol.Required(
"payload",
default=(payload if payload is not None else {}),
): ObjectSelector(),
}
),
errors=errors,
description_placeholders={
"href": href,
"current_value": (
json.dumps(current, indent=2, ensure_ascii=False)
if current else "{}"
json.dumps(current, indent=2, ensure_ascii=False) if current else "{}"
),
},
)
async def async_step_debug_edit(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
coord = self._coordinator()
if coord is None:
return self.async_abort(reason="not_loaded")
@@ -555,11 +585,9 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
)
try:
code, new_rep = await coord.async_raw_write(href, payload)
except Exception: # noqa: BLE001 - surfaced to the user below
except Exception:
_LOGGER.exception("debug raw write failed for %s", href)
return self._show_debug_edit_form(
href, current, {"base": "write_failed"}, payload
)
return self._show_debug_edit_form(href, current, {"base": "write_failed"}, payload)
self._debug_result = (code, new_rep)
return await self.async_step_debug_result()
@@ -567,22 +595,19 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
async def async_step_debug_result(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
) -> ConfigFlowResult:
code, new_rep = self._debug_result or (0, {})
return self.async_show_menu(
step_id="debug_result",
menu_options=["debug_write", "finish"],
description_placeholders={
"code": f"{code >> 5}.{code & 0x1f:02d} ({code:#04x})",
"code": f"{code >> 5}.{code & 0x1F:02d} ({code:#04x})",
"new_value": (
json.dumps(new_rep, indent=2, ensure_ascii=False)
if new_rep else "{}"
json.dumps(new_rep, indent=2, ensure_ascii=False) if new_rep else "{}"
),
},
)
async def async_step_finish(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
async def async_step_finish(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
# Close the flow without altering saved options.
return self.async_create_entry(data=dict(self.config_entry.options))
+15 -7
View File
@@ -1,16 +1,24 @@
DOMAIN = "localthings"
PLATFORMS = [
"sensor", "binary_sensor", "switch", "number", "select", "button",
"time", "climate", "fan", "water_heater",
"sensor",
"binary_sensor",
"switch",
"number",
"select",
"button",
"time",
"climate",
"fan",
"water_heater",
]
CONF_HOST = "host"
CONF_PORT = "port"
CONF_CA_CERT_PEM = "ca_cert_pem"
CONF_CA_KEY_PEM = "ca_key_pem"
CONF_HOST = "host"
CONF_PORT = "port"
CONF_CA_CERT_PEM = "ca_cert_pem"
CONF_CA_KEY_PEM = "ca_key_pem"
CONF_LEAF_CERT_PEM = "leaf_cert_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
CONF_LEAF_KEY_PEM = "leaf_key_pem"
# Options-flow key (entry.options, not entry.data): lets a user override the
# device-wide remote-control-off write block for a specific device (issue
+139 -107
View File
@@ -1,7 +1,9 @@
"""Coordinator for Local Things integration."""
from __future__ import annotations
import asyncio
import contextlib
import ipaddress
import logging
import threading
@@ -11,17 +13,29 @@ from datetime import timedelta
from typing import Any
import cbor2
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.device_registry import DeviceInfo
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.protocol.dtls_session import DtlsCoapSession
from .const import (
CONF_BYPASS_REMOTE_CONTROL,
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM,
CONF_PORT,
DEVICE_SUPPORT_ISSUE_URL,
DOMAIN,
DTLS_LOCAL_PORT_BASE,
SUMMARY_INTERVAL_S,
)
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
from .registry import CAPABILITIES
from .registry.adapter import flatten
from .registry.batch import parse_device0_batch
from .registry.by_type import resolve as resolve_registry
from .registry.capabilities.common import (
@@ -31,29 +45,25 @@ from .registry.capabilities.common import (
remote_control_required_for_write,
)
from .registry.discovery import BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
from .registry.identity import read_identity, DeviceIdentity
from .registry.identity import DeviceIdentity, read_identity
from .registry.subdevices import (
MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices,
Subdevice,
canonical_view,
discover_partitioned,
enumerate_subdevices,
normalize_seed_batch,
)
from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
from .const import (
DOMAIN, CONF_HOST, CONF_PORT, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM,
CONF_BYPASS_REMOTE_CONTROL, DEVICE_SUPPORT_ISSUE_URL, SUMMARY_INTERVAL_S,
DTLS_LOCAL_PORT_BASE,
)
_LOGGER = logging.getLogger(__name__)
_SEED_PATH = ['device', '0']
_SEED_PATH = ["device", "0"]
class _NoOpDescriptor:
"""StateCache requires a descriptor with an on_observation hook. This
integration doesn't use per-capability observation hooks, so this is a
deliberate no-op, not a placeholder for missing functionality."""
def on_observation(self, state: dict, href: str, rep: dict) -> None:
return None
@@ -108,14 +118,10 @@ def _is_placeholder_serial(serial: str) -> bool:
check.
"""
s = serial.strip()
if s.lower().startswith('nothing'):
if s.lower().startswith("nothing"):
return True
upper = s.upper()
return (
len(upper) >= 8
and len(set(upper)) == 1
and upper[0] in '0123456789ABCDEF'
)
return len(upper) >= 8 and len(set(upper)) == 1 and upper[0] in "0123456789ABCDEF"
class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@@ -234,7 +240,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._hot_hrefs: list[str] = []
self._warm_hrefs: list[str] = []
self.device_type_name: str | None = None
self.one_ui_version: str = ''
self.one_ui_version: str = ""
self._consecutive_poll_timeouts = 0
self._unbound_hrefs: list[str] = []
self._reconnect_times: list[float] = []
@@ -291,15 +297,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
that resource when present anyway -- it's informational, not an
identifier.
"""
if subdevice.kind == 'main':
if subdevice.kind == "main":
return self.device_info
info = self.canonical_resources(subdevice).get('/information/vs/0', {})
model_num = info.get('x.com.samsung.da.modelNum', '')
model = model_num.split('|', 1)[0] if model_num else ''
serial = info.get('x.com.samsung.da.serialNum') or None
base_name = self.device_info.get('name') or 'Samsung Appliance'
info = self.canonical_resources(subdevice).get("/information/vs/0", {})
model_num = info.get("x.com.samsung.da.modelNum", "")
model = model_num.split("|", 1)[0] if model_num else ""
serial = info.get("x.com.samsung.da.serialNum") or None
base_name = self.device_info.get("name") or "Samsung Appliance"
if model:
label = model.replace('_', ' ').title()
label = model.replace("_", " ").title()
else:
# This poll never got (or never will get) the subdevice's own
# identity resource -- fall back to a generic per-subdevice label
@@ -308,14 +314,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# ordinal); UUID-prefixed subdevices are never more than one per
# connection today, so there's no ordinal to show.
label = (
f'Subdevice {subdevice.key}' if subdevice.kind == 'indexed'
else 'Secondary Subdevice'
f"Subdevice {subdevice.key}"
if subdevice.kind == "indexed"
else "Secondary Subdevice"
)
return DeviceInfo(
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
via_device=(DOMAIN, self.device_serial),
name=f"{base_name} {label}",
manufacturer=self.device_info.get('manufacturer') or 'Samsung',
manufacturer=self.device_info.get("manufacturer") or "Samsung",
model=model or None,
serial_number=serial,
)
@@ -325,14 +332,19 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
return self._observe.mode
def _connect_session(self) -> None:
host = self._entry.data[CONF_HOST]
port = self._entry.data[CONF_PORT]
host = self._entry.data[CONF_HOST]
port = self._entry.data[CONF_PORT]
cert_pem = self._entry.data[CONF_LEAF_CERT_PEM]
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
key_pem = self._entry.data[CONF_LEAF_KEY_PEM]
sess = DtlsCoapSession(host, port, cert_pem=cert_pem, key_pem=key_pem,
on_notification=self._observe.on_notification,
local_port=_local_source_port(host))
sess = DtlsCoapSession(
host,
port,
cert_pem=cert_pem,
key_pem=key_pem,
on_notification=self._observe.on_notification,
local_port=_local_source_port(host),
)
sess.connect()
sess.start_reader()
self._session = sess
@@ -347,10 +359,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
sess = self._session
self._session = None
if sess is not None:
try:
with contextlib.suppress(Exception):
sess.close()
except Exception:
pass
async def async_close(self) -> None:
if self._subpoll_task is not None:
@@ -397,6 +407,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self._session is None:
self._connect_session()
sess = self._session
assert sess is not None
try:
# A slow device can still be mid-transfer (block 8, block 11)
# when a tighter deadline cuts it off — that's a poll that
@@ -481,7 +492,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not first:
sess.pace()
first = False
path = [s for s in actual.strip('/').split('/')]
path = actual.strip("/").split("/")
code, payload = sess.get(path, timeout=10.0)
if code == 0x45 and payload:
rep = cbor2.loads(payload)
@@ -490,7 +501,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except Exception as e:
self._log.debug(
"subdevice %s flat href %s poll failed: %s",
subdevice.key, href, e,
subdevice.key,
href,
e,
)
return result
@@ -505,12 +518,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._session.pace()
first = False
try:
path = [s for s in href.strip('/').split('/')]
path = href.strip("/").split("/")
code, payload = self._session.get(path, timeout=10.0)
if code == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
self._observe.apply(href, rep, source='poll')
self._observe.apply(href, rep, source="poll")
results[href] = rep
except Exception as e:
self._log.debug("sub-poll %s: %s", href, e)
@@ -536,14 +549,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if not hot and not warm:
return
step = self._SUBPOLL_STEP_S
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
for i in range(1, 10): # slots 1..9 (T+3 s … T+27 s)
await asyncio.sleep(step)
hrefs = list(hot) + (list(warm) if i % 2 == 0 else [])
async with self._session_lock:
try:
await self.hass.async_add_executor_job(
self._poll_hrefs_blocking, hrefs
)
await self.hass.async_add_executor_job(self._poll_hrefs_blocking, hrefs)
except Exception as e:
self._log.debug("sub-poll batch failed: %s", e)
@@ -573,10 +584,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
sess = self._session
if sess is None:
return resources
oic_res = self._identity.raw.get('/oic/res', []) if self._identity else []
oic_res = self._identity.raw.get("/oic/res", []) if self._identity else []
probes: dict[str, bool] = {}
subdevices, extra = enumerate_subdevices(
sess, resources, oic_res,
sess,
resources,
oic_res,
probe_log=lambda href, found: probes.__setitem__(href, found),
)
self.subdevices = subdevices
@@ -592,7 +605,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# cache would freeze it there exactly like a rejected candidate's
# reps (see _live_subdevice_resources). Kept aside for diagnostics and
# for the numofsubdevice cross-check in _run_discovery instead.
self._multidevice = extra.pop('/multidevice/vs/0', {})
self._multidevice = extra.pop("/multidevice/vs/0", {})
return {**resources, **extra}
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
@@ -610,9 +623,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
kept: dict[str, dict] = {}
skipped: dict[str, dict] = {}
for href, rep in resources.items():
bucket = skipped if any(
skip.subdevice.owns(href) for skip in self._skipped_subdevices
) else kept
bucket = (
skipped
if any(skip.subdevice.owns(href) for skip in self._skipped_subdevices)
else kept
)
bucket[href] = rep
self._skipped_subdevice_resources = skipped
return kept
@@ -622,21 +637,23 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
# an issue. It does not route: only a minority of hardware reports it
# at all, and every device that does is already typed by its modelNum.
self.one_ui_version = (resources.get('/otninformation/vs/0', {})
.get('swVersionInfo', {})
.get('oneUiVersion', ''))
info = resources.get('/information/vs/0', {})
self.one_ui_version = (
resources.get("/otninformation/vs/0", {})
.get("swVersionInfo", {})
.get("oneUiVersion", "")
)
info = resources.get("/information/vs/0", {})
unbound: list[str] = []
hot, warm = set(), set()
def _tier_log(href: str, tier: str) -> None:
if tier == 'hot':
if tier == "hot":
hot.add(href)
elif tier == 'warm':
elif tier == "warm":
warm.add(href)
model_num = info.get('x.com.samsung.da.modelNum', '')
description = info.get('x.com.samsung.da.description', '')
model_num = info.get("x.com.samsung.da.modelNum", "")
description = info.get("x.com.samsung.da.description", "")
# Partitioned discovery (issue #177): the main pass binds every href
# owned by no subdevice; one further pass per *candidate* subdevice
@@ -651,8 +668,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# with no candidates (self.subdevices == []) this is exactly the
# single discover() call this method used to make.
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, self.subdevices, resolve_registry, CAPABILITIES,
log=unbound.append, tier_log=_tier_log,
resources,
self.subdevices,
resolve_registry,
CAPABILITIES,
log=unbound.append,
tier_log=_tier_log,
oic_device_types=self._identity.device_types if self._identity else (),
)
self.subdevices = materialized
@@ -661,7 +682,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._log.info(
"subdevice %s (%s) answered its seed but produced no live "
"primary state; not materialized (hrefs=%s)",
skip.subdevice.key, skip.subdevice.kind, list(skip.hrefs),
skip.subdevice.key,
skip.subdevice.kind,
list(skip.hrefs),
)
# Corroborating signal, not a gate (DESIGN-177.md section 4):
# /multidevice/vs/0's numofsubdevice is a plain count the issue
@@ -670,8 +693,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# family is known to expose the resource at all, so a mismatch is a
# "look into this" signal for triage, not proof either side is
# wrong.
numofsubdevice = self._multidevice.get(
'x.com.samsung.da.numofsubdevice')
numofsubdevice = self._multidevice.get("x.com.samsung.da.numofsubdevice")
if numofsubdevice is not None:
try:
reported = int(numofsubdevice)
@@ -682,7 +704,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._log.debug(
"/multidevice/vs/0 reports numofsubdevice=%r but %d "
"subdevice(s) materialized (including the master)",
numofsubdevice, subdevice_count,
numofsubdevice,
subdevice_count,
)
if device_type_name is not None:
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
@@ -693,25 +716,27 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# washer or dryer, and device_types is often empty even when
# populated hardware exists for a type we don't map yet.
self._log.warning(
"unknown device type modelNum=%r description=%r device_types=%r; "
"using common caps",
model_num, description,
"unknown device type modelNum=%r description=%r device_types=%r; using common caps",
model_num,
description,
self._identity.device_types if self._identity else (),
)
self.device_type_name = device_type_name
self.bound = bound
self._unbound_hrefs = unbound
serial = info.get('x.com.samsung.da.serialNum', '')
serial = info.get("x.com.samsung.da.serialNum", "")
if not serial or _is_placeholder_serial(serial):
serial = self._entry.data[CONF_HOST]
self.device_serial = serial
ident = self._identity
device_type = device_type_name.replace('_', ' ').title() if device_type_name else 'Appliance'
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
mfr = (ident.manufacturer if ident else '') or 'Samsung'
device_type = (
device_type_name.replace("_", " ").title() if device_type_name else "Appliance"
)
model = model_num.split("|", 1)[0] if model_num else (ident.model if ident else "")
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
mfr = (ident.manufacturer if ident else "") or "Samsung"
self.device_info = DeviceInfo(
identifiers={(DOMAIN, serial)},
@@ -727,12 +752,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._discovered = True
self._log.info(
"discovered %d entities (serial=%s) hot=%s warm=%s subdevices=%s",
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
len(bound),
serial,
self._hot_hrefs,
self._warm_hrefs,
[su.key for su in self.subdevices],
)
def _update_coverage_gap_issue(
self, unknown_type: bool, unbound_hrefs: list[str], device_name: str,
self,
unknown_type: bool,
unbound_hrefs: list[str],
device_name: str,
) -> None:
"""Raise or clear a Repairs issue when capability coverage is incomplete.
@@ -744,7 +775,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
issue_id = f"device_gap_{self._entry.entry_id}"
if unknown_type or unbound_hrefs:
ir.async_create_issue(
self.hass, DOMAIN, issue_id,
self.hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="device_gap",
@@ -768,7 +801,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if sess is None:
return
await self.hass.async_add_executor_job(
self._observe.try_enter_observe_mode, sess, hrefs,
self._observe.try_enter_observe_mode,
sess,
hrefs,
self._OBSERVE_GRACE_PERIOD_S,
)
@@ -812,8 +847,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
warn window -- see _RECONNECT_WARN_WINDOW_S/_RECONNECT_WARN_THRESHOLD."""
now = time.monotonic()
self._reconnect_times = [
t for t in self._reconnect_times
if now - t < self._RECONNECT_WARN_WINDOW_S
t for t in self._reconnect_times if now - t < self._RECONNECT_WARN_WINDOW_S
]
self._reconnect_times.append(now)
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
@@ -838,7 +872,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._log.debug(
"poll failed (%s), not yet treated as session "
"death; skipping this cycle: %s",
type(e).__name__, e,
type(e).__name__,
e,
)
return flatten(self.bound, self._cache.snapshot())
self._consecutive_poll_timeouts = 0
@@ -894,7 +929,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._enumerate_subdevices_blocking, resources
)
source = 'sweep' if self._discovered else 'poll'
source = "sweep" if self._discovered else "poll"
first_cycle = not self._discovered
if first_cycle:
# Discovery runs *before* the apply loop below, not after it, so
@@ -927,9 +962,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for href, rep in resources.items():
self._observe.apply(href, rep, source=source)
if first_cycle:
await self._attempt_observe_mode()
elif just_downgraded_from_observe:
if first_cycle or just_downgraded_from_observe:
await self._attempt_observe_mode()
elif self._observe.mode == MODE_POLL:
await self._maybe_retry_observe_mode()
@@ -954,8 +987,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Command dispatch (called by entity platforms in Task 5)
# ------------------------------------------------------------------
async def async_send_command(self, bound_entity: BoundEntity,
payload: Any) -> None:
async def async_send_command(self, bound_entity: BoundEntity, payload: Any) -> None:
"""Write a value to the device. Fire-and-forget style.
A description-level validate_fn (currently SwitchDesc only) runs
@@ -972,23 +1004,23 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
without Smart Control (cycle start/pause/stop on /operational/state
still require it)."""
desc = bound_entity.desc
write_fn = getattr(desc, 'write_fn', None)
write_fn = getattr(desc, "write_fn", None)
if write_fn is None:
return
href = bound_entity.href
rep = self._cache.get(href or '') or {}
rep = self._cache.get(href or "") or {}
resources = self._cache.snapshot()
bypass_remote_control = self._entry.options.get(CONF_BYPASS_REMOTE_CONTROL, False)
if (
not bypass_remote_control
and remote_control_required_for_write(resources, href or '')
and remote_control_required_for_write(resources, href or "")
and not remote_control_enabled(resources)
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="remote_control_disabled",
)
validate_fn = getattr(desc, 'validate_fn', None)
validate_fn = getattr(desc, "validate_fn", None)
if validate_fn is not None:
error = validate_fn(payload, rep, resources)
if error:
@@ -1028,8 +1060,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# guard and the POST below all target that subdevice's real resource --
# to_actual is the identity transform for MAIN, so a device with no
# subdevices writes exactly where it always did.
write_href = bound_entity.subdevice.to_actual('/' + '/'.join(path_segs))
path_segs = [s for s in write_href.strip('/').split('/') if s]
write_href = bound_entity.subdevice.to_actual("/" + "/".join(path_segs))
path_segs = [s for s in write_href.strip("/").split("/") if s]
# Apply the write optimistically before starting the settle guard,
# not after -- mark_write_pending gates every source (poll, sweep,
@@ -1079,24 +1111,24 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# cache entry stays complete; the minimal `body` below is still
# exactly what goes out over the wire.
optimistic_body = body
new_options = body.get('x.com.samsung.da.options')
new_options = body.get("x.com.samsung.da.options")
if isinstance(new_options, list):
cached_options = (self._cache.get(write_href) or {}).get('x.com.samsung.da.options')
cached_options = (self._cache.get(write_href) or {}).get("x.com.samsung.da.options")
optimistic_body = {
**optimistic_body,
'x.com.samsung.da.options': merge_options_field(cached_options, new_options),
"x.com.samsung.da.options": merge_options_field(cached_options, new_options),
}
# Same fact, items[] shape (e.g. airconditioner._climate_write's vendor
# temperature write, which now carries only {id, desired} -- see that
# module for the write-side half of this).
new_items = body.get('x.com.samsung.da.items')
new_items = body.get("x.com.samsung.da.items")
if isinstance(new_items, list):
cached_items = (self._cache.get(write_href) or {}).get('x.com.samsung.da.items')
cached_items = (self._cache.get(write_href) or {}).get("x.com.samsung.da.items")
optimistic_body = {
**optimistic_body,
'x.com.samsung.da.items': merge_items_field(cached_items, new_items),
"x.com.samsung.da.items": merge_items_field(cached_items, new_items),
}
self._observe.apply(write_href, optimistic_body, source='optimistic')
self._observe.apply(write_href, optimistic_body, source="optimistic")
self._observe.mark_write_pending(
write_href, settle_s=self._POST_TIMEOUT_S + self._POLL_TIMEOUT_S
)
@@ -1141,7 +1173,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if rcode == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
self._observe.apply(href, rep, source='poll')
self._observe.apply(href, rep, source="poll")
new_rep = rep
except Exception as e:
self._log.debug("raw write follow-up read failed: %s", e)
@@ -1160,13 +1192,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_domain=DOMAIN,
translation_key="debug_payload_empty",
)
path_segs = [s for s in href.strip('/').split('/') if s]
path_segs = [s for s in href.strip("/").split("/") if s]
if not path_segs:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="resource_href_required",
)
norm_href = '/' + '/'.join(path_segs)
norm_href = "/" + "/".join(path_segs)
async with self._session_lock:
code, new_rep = await self.hass.async_add_executor_job(
self._raw_write_blocking, path_segs, body, norm_href
+12 -7
View File
@@ -6,6 +6,7 @@ device > the menu > Download diagnostics. This is what the Repairs issue
users at: a redacted snapshot of the device's raw /device/0 state, plus
enough version/coverage metadata to reproduce and diagnose the gap.
"""
from __future__ import annotations
from importlib.metadata import version as pkg_version
@@ -67,7 +68,7 @@ async def async_get_config_entry_diagnostics(
**_seed_diag(su),
"bound_entity_count": len(matching),
"hrefs": sorted({b.href for b in matching}),
"model": res.get('/information/vs/0', {}).get('x.com.samsung.da.modelNum', ''),
"model": res.get("/information/vs/0", {}).get("x.com.samsung.da.modelNum", ""),
# Keyed by this subdevice's *canonical* hrefs, not the real ones
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
# '/<uuid>/mode/vs/0'. That's the form the registry and every
@@ -86,7 +87,9 @@ async def async_get_config_entry_diagnostics(
"model": identity.model,
"device_types": list(identity.device_types),
"resources": redact_resources(identity.raw),
} if identity is not None else None,
}
if identity is not None
else None,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
# This subdevice's own resources, and only this subdevice's -- what
# the module docstring and the adding-device-support skill have
@@ -131,11 +134,13 @@ async def async_get_config_entry_diagnostics(
# candidate is never polled again and never enters the state
# cache, so `resources` above cannot contain them by
# construction.
"resources": redact_resources({
canon: rep
for href, rep in coordinator._skipped_subdevice_resources.items()
if (canon := skip.subdevice.to_canonical(href)) is not None
}),
"resources": redact_resources(
{
canon: rep
for href, rep in coordinator._skipped_subdevice_resources.items()
if (canon := skip.subdevice.to_canonical(href)) is not None
}
),
}
for skip in coordinator._skipped_subdevices
],
+10 -12
View File
@@ -1,21 +1,21 @@
"""Base entity for Local Things."""
from __future__ import annotations
import re
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.adapter import _key
from .registry.batch import is_stub_rep
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> bool:
def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool:
"""Return False if the entity should not be registered for this device.
Explicit exists_fn takes priority. Otherwise, if the entity has a field,
@@ -63,7 +63,7 @@ def _derive_name(state_key: str) -> str:
builds the {instance_name} placeholder those translations interpolate,
for a device that named its own compartments/ice makers.
"""
name = re.sub(r'(\d+)$', lambda m: f' {m.group()}' if int(m.group()) > 0 else '', state_key)
name = re.sub(r"(\d+)$", lambda m: f" {m.group()}" if int(m.group()) > 0 else "", state_key)
return _snake_to_title(name).strip()
@@ -74,9 +74,9 @@ def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
source = bound.key_override or state_key
suffix = f"_{bound.desc.key}"
if source.endswith(suffix):
source = source[:-len(suffix)]
source = source[: -len(suffix)]
elif bound.instance and source.endswith(bound.instance):
source = source[:-len(bound.instance)] + bound.instance.replace("_", " ")
source = source[: -len(bound.instance)] + bound.instance.replace("_", " ")
return _derive_name(source)
@@ -91,9 +91,7 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
self._state_key = _key(bound)
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
if bound.desc.translation_placeholders is not None:
self._attr_translation_placeholders = dict(
bound.desc.translation_placeholders
)
self._attr_translation_placeholders = dict(bound.desc.translation_placeholders)
elif bound.desc.use_instance_name:
self._attr_translation_placeholders = {
"instance_name": _instance_display_name(bound, self._state_key)
+58 -52
View File
@@ -32,22 +32,24 @@ from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.air_purifier import HREF_AIRFLOW
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_HREF
from .registry.capabilities.air_purifier import HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF
from .registry.capabilities.air_purifier import (
HREF_WIND_STRENGTH as AIR_PURIFIER_WIND_STRENGTH_HREF,
)
from .registry.entities import FanDesc
_LOGGER = logging.getLogger(__name__)
POWER_HREF = '/power/0'
POWER_VS_HREF = '/power/vs/0'
_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.fanSpeed'
_SUPPORTED_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.supportedFanSpeed'
_MIN_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMinFanSpeed'
_MAX_FAN_SPEED_FIELD = 'x.com.samsung.da.hood.settableMaxFanSpeed'
_OFF_SPEED_CODE = '0'
POWER_HREF = "/power/0"
POWER_VS_HREF = "/power/vs/0"
_FAN_SPEED_FIELD = "x.com.samsung.da.hood.fanSpeed"
_SUPPORTED_FAN_SPEED_FIELD = "x.com.samsung.da.hood.supportedFanSpeed"
_MIN_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMinFanSpeed"
_MAX_FAN_SPEED_FIELD = "x.com.samsung.da.hood.settableMaxFanSpeed"
_OFF_SPEED_CODE = "0"
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
_MODES_NAME_FIELD = 'x.com.samsung.da.modesName'
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_MODES_FIELD = "x.com.samsung.da.supportedModes"
_MODES_NAME_FIELD = "x.com.samsung.da.modesName"
async def async_setup_entry(
@@ -116,7 +118,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
resource instead, so this is False there."""
rep = self._rep(self._bound.href)
return (
str(rep.get(_MIN_FAN_SPEED_FIELD, '')) == _OFF_SPEED_CODE
str(rep.get(_MIN_FAN_SPEED_FIELD, "")) == _OFF_SPEED_CODE
or _OFF_SPEED_CODE in self._all_speed_codes()
)
@@ -150,19 +152,17 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
"""Target whichever power resource this hood actually exposes."""
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
return "power", enabled, target
@property
def is_on(self) -> bool:
if self._speed_zero_is_off():
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, '0'))
return current not in ('', _OFF_SPEED_CODE)
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, "0"))
return current not in ("", _OFF_SPEED_CODE)
rep = self._rep(POWER_HREF)
if 'value' in rep:
return bool(rep.get('value'))
return str(
self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')
).lower() == 'on'
if "value" in rep:
return bool(rep.get("value"))
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
@property
def speed_count(self) -> int:
@@ -173,13 +173,15 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
if not self.is_on:
return 0
codes = self._active_speed_codes()
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ''))
current = str(self._rep(self._bound.href).get(_FAN_SPEED_FIELD, ""))
if not codes or current not in codes:
return None
return ordered_list_item_to_percentage(codes, current)
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
) -> None:
if self._speed_zero_is_off():
@@ -192,20 +194,22 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
return
codes = self._active_speed_codes()
if codes:
await self.coordinator.async_send_command(self._bound, ('speed', codes[0]))
await self.coordinator.async_send_command(self._bound, ("speed", codes[0]))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
self._bound,
self._power_payload(True),
)
if percentage is not None:
await self.async_set_percentage(percentage)
async def async_turn_off(self, **kwargs) -> None:
if self._speed_zero_is_off():
await self.coordinator.async_send_command(self._bound, ('speed', _OFF_SPEED_CODE))
await self.coordinator.async_send_command(self._bound, ("speed", _OFF_SPEED_CODE))
return
await self.coordinator.async_send_command(
self._bound, self._power_payload(False),
self._bound,
self._power_payload(False),
)
async def async_set_percentage(self, percentage: int) -> None:
@@ -217,10 +221,11 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
return
if not self._speed_zero_is_off() and not self.is_on:
await self.coordinator.async_send_command(
self._bound, self._power_payload(True),
self._bound,
self._power_payload(True),
)
code = percentage_to_ordered_list_item(codes, percentage)
await self.coordinator.async_send_command(self._bound, ('speed', code))
await self.coordinator.async_send_command(self._bound, ("speed", code))
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
@@ -230,9 +235,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.PRESET_MODE
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
FanEntityFeature.PRESET_MODE | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
@@ -253,14 +256,14 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
correctly."""
resources = self._resources
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
return 'power', enabled, target
return "power", enabled, target
@property
def is_on(self) -> bool:
power = self._rep(POWER_VS_HREF).get('x.com.samsung.da.power')
power = self._rep(POWER_VS_HREF).get("x.com.samsung.da.power")
if power is not None:
return str(power).lower() == 'on'
return bool(self._rep(POWER_HREF).get('value'))
return str(power).lower() == "on"
return bool(self._rep(POWER_HREF).get("value"))
def _label_for_code(self, code) -> str:
"""Lowercased HA preset label for a device mode code.
@@ -284,8 +287,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
@property
def preset_modes(self) -> list[str]:
return [
self._label_for_code(code)
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
self._label_for_code(code) for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
]
@property
@@ -295,7 +297,9 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
return self._label_for_code(code) if code is not None else None
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
@@ -311,22 +315,24 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
# modesName-labelled board), not the lowercased HA value.
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
if self._label_for_code(code) == preset_mode:
await self.coordinator.async_send_command(self._bound, ('mode', code))
await self.coordinator.async_send_command(self._bound, ("mode", code))
return
_LOGGER.warning(
"%s: %r is not a valid preset mode (supported: %s)",
self.entity_id, preset_mode, self.preset_modes,
self.entity_id,
preset_mode,
self.preset_modes,
)
_AIRFLOW_SPEED_FIELD = 'speed'
_AIRFLOW_SPEED_FIELD = "speed"
# Raw `speed` codes, low-to-high -- confirmed monotonic (Auto=0, Sleep=1,
# Low=2, Medium=3, High=4) via air_purifier.py's module docstring. Ordered
# as plain strings, same as _all_speed_codes above, so
# ordered_list_item_to_percentage/percentage_to_ordered_list_item can treat
# it exactly like the range hood's numeric levels -- no named-preset table
# needed since this board never reports mode names to hang one off of.
_AIRFLOW_SPEED_CODES = ('0', '1', '2', '3', '4')
_AIRFLOW_SPEED_CODES = ["0", "1", "2", "3", "4"]
class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
@@ -335,9 +341,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
_enable_turn_on_off_backwards_compatibility = False
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
FanEntityFeature.SET_SPEED | FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF
)
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
@@ -360,14 +364,14 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
warn about)."""
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
return "power", enabled, target
@property
def is_on(self) -> bool:
power = self._rep(POWER_HREF)
if 'value' in power:
return bool(power.get('value'))
return str(self._rep(POWER_VS_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
if "value" in power:
return bool(power.get("value"))
return str(self._rep(POWER_VS_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
@property
def speed_count(self) -> int:
@@ -377,13 +381,15 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
def percentage(self) -> int | None:
if not self.is_on:
return 0
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ''))
current = str(self._rep(self._bound.href).get(_AIRFLOW_SPEED_FIELD, ""))
if current not in _AIRFLOW_SPEED_CODES:
return None
return ordered_list_item_to_percentage(_AIRFLOW_SPEED_CODES, current)
async def async_turn_on(
self, percentage: int | None = None, preset_mode: str | None = None,
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs,
) -> None:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
@@ -400,4 +406,4 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
if not self.is_on:
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
code = percentage_to_ordered_list_item(_AIRFLOW_SPEED_CODES, percentage)
await self.coordinator.async_send_command(self._bound, ('speed', int(code)))
await self.coordinator.async_send_command(self._bound, ("speed", int(code)))
+18 -15
View File
@@ -1,16 +1,18 @@
"""Number platform for Local Things."""
from __future__ import annotations
from homeassistant.components.number import NumberEntity, NumberMode
from typing import cast
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import NumberDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import NumberDesc
async def async_setup_entry(
@@ -27,14 +29,15 @@ async def async_setup_entry(
class LocalThingsNumber(LocalThingsEntity, NumberEntity):
_attr_mode = NumberMode.SLIDER
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: NumberDesc = bound.desc
desc = cast(NumberDesc, bound.desc)
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = desc.device_class
self._attr_device_class = (
NumberDeviceClass(desc.device_class) if desc.device_class else None
)
if desc.native_min is not None:
self._attr_native_min_value = desc.native_min
if desc.native_max is not None:
@@ -44,13 +47,13 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_unit_of_measurement(self):
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if desc.unit_fn is not None:
return desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement
def _range_from_resource(self) -> list | None:
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if not desc.range_field:
return None
r = self.coordinator.resource(self._bound.href).get(desc.range_field)
@@ -58,34 +61,34 @@ class LocalThingsNumber(LocalThingsEntity, NumberEntity):
@property
def native_min_value(self) -> float:
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if desc.native_min_fn is not None:
return desc.native_min_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[0])
if hasattr(self, '_attr_native_min_value'):
if hasattr(self, "_attr_native_min_value"):
return self._attr_native_min_value
return super().native_min_value
@property
def native_max_value(self) -> float:
desc: NumberDesc = self._bound.desc
desc = cast(NumberDesc, self._bound.desc)
if desc.native_max_fn is not None:
return desc.native_max_fn(self.coordinator.resource(self._bound.href))
r = self._range_from_resource()
if r is not None:
return float(r[1])
if hasattr(self, '_attr_native_max_value'):
if hasattr(self, "_attr_native_max_value"):
return self._attr_native_max_value
return super().native_max_value
@property
def native_step(self) -> float:
desc: NumberDesc = self._bound.desc
def native_step(self) -> float | None:
desc = cast(NumberDesc, self._bound.desc)
if desc.step_fn is not None:
return desc.step_fn(self.coordinator.resource(self._bound.href))
if hasattr(self, '_attr_native_step'):
if hasattr(self, "_attr_native_step"):
return self._attr_native_step
return super().native_step
+23 -19
View File
@@ -8,6 +8,7 @@ are an external pip dependency we don't own, so behavior that would
naturally live inside StateCache.apply_rep lives here instead, gating
whether apply_rep is called at all.
"""
from __future__ import annotations
import logging
@@ -15,16 +16,15 @@ import threading
import time
import cbor2
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
from smartthings_local.ocf.state_cache import StateCache
_LOGGER = logging.getLogger(__name__)
REFRESH_INTERVAL_S = 6 * 3600.0
MODE_OBSERVE = 'observe'
MODE_POLL = 'poll'
MODE_OBSERVE = "observe"
MODE_POLL = "poll"
DEFAULT_SETTLE_S = 4.0
GRACE_PERIOD_S = 15.0
@@ -133,7 +133,7 @@ class ObserveManager:
happened to expire, i.e. the exact symptom this guard exists to
prevent, just relocated to whichever write loses the race.
"""
if source != 'optimistic' and self._is_settling(href):
if source != "optimistic" and self._is_settling(href):
self.log.debug("dropping %s update for %s (settling)", source, href)
return False
with self._cache_lock:
@@ -155,7 +155,7 @@ class ObserveManager:
self._last_notify_ts = time.monotonic()
self._notify_cond.notify_all()
self.log.debug("observe notify: %s", href)
self.apply(href, rep, source='observe')
self.apply(href, rep, source="observe")
def recently_notified(self, window_s: float = PUSH_HEALTH_WINDOW_S) -> bool:
"""True if any OBSERVE notify has arrived within `window_s`.
@@ -168,12 +168,13 @@ class ObserveManager:
channel has been perfectly healthy").
"""
return (
self._last_notify_ts is not None
and time.monotonic() - self._last_notify_ts < window_s
self._last_notify_ts is not None and time.monotonic() - self._last_notify_ts < window_s
)
def try_enter_observe_mode(
self, session, hrefs: list[str],
self,
session,
hrefs: list[str],
grace_period_s: float = GRACE_PERIOD_S,
success_fraction: float = SUCCESS_FRACTION,
) -> bool:
@@ -185,7 +186,7 @@ class ObserveManager:
self._notified.clear()
subscribed: set[str] = set()
for href in hrefs:
segs = [s for s in href.strip('/').split('/') if s]
segs = [s for s in href.strip("/").split("/") if s]
try:
session.subscribe(segs)
subscribed.add(href)
@@ -198,14 +199,12 @@ class ObserveManager:
return False
def _fraction_reached() -> bool:
return (
len(set(self._notified) & subscribed) / len(subscribed)
>= success_fraction
)
return len(set(self._notified) & subscribed) / len(subscribed) >= success_fraction
with self._notify_cond:
reached = self._notify_cond.wait_for(
_fraction_reached, timeout=grace_period_s,
_fraction_reached,
timeout=grace_period_s,
)
if reached:
@@ -271,7 +270,8 @@ class ObserveManager:
found = True
self.log.debug(
"observe missed a change on %s (sweep disagrees with cache): %s",
href, diff,
href,
diff,
)
return found
@@ -283,15 +283,19 @@ class ObserveManager:
def start_refresh_task(self, session) -> None:
self._stop_refresh_task()
paths = [tuple(h.strip('/').split('/')) for h in self.subscribed_hrefs]
paths = [tuple(h.strip("/").split("/")) for h in self.subscribed_hrefs]
self._refresh_task = ObserveRefreshTask(
session, paths, interval_s=REFRESH_INTERVAL_S, logger=self.log,
session,
paths,
interval_s=REFRESH_INTERVAL_S,
logger=self.log,
)
self._refresh_stop = threading.Event()
self._refresh_thread = threading.Thread(
target=self._refresh_task.run_forever,
args=(self._refresh_stop,),
daemon=True, name='localthings-observe-refresh',
daemon=True,
name="localthings-observe-refresh",
)
self._refresh_thread.start()
@@ -6,8 +6,9 @@ to the matching capabilities, and flattens the result into HA-ready entity
state. The DTLS/CoAP transport itself lives in the smartthings-local
package, not here.
"""
from .adapter import flatten, _key
from .adapter import _key, flatten
from .discovery import BoundEntity, discover
from .registry import CAPABILITIES
__all__ = ['CAPABILITIES', 'discover', 'BoundEntity', 'flatten', '_key']
__all__ = ["CAPABILITIES", "BoundEntity", "_key", "discover", "flatten"]
@@ -1,4 +1,5 @@
"""Adapter: BoundEntity list → flat state dict and command dispatch."""
from __future__ import annotations
from typing import Any
@@ -1,4 +1,5 @@
"""OCF /device/0 batch response parser."""
from __future__ import annotations
@@ -11,7 +12,7 @@ def is_stub_rep(rep: dict) -> bool:
never populate. Conflating the two used to make every field-gated entity
on a permanently-empty resource look like a not-yet-fetched stub forever,
creating phantom always-"unknown" entities (issue #127)."""
return isinstance(rep, dict) and set(rep.keys()) == {'href'}
return isinstance(rep, dict) and set(rep.keys()) == {"href"}
def parse_device0_batch(device0: list) -> dict[str, dict]:
@@ -22,11 +23,11 @@ def parse_device0_batch(device0: list) -> dict[str, dict]:
is_stub_rep to tell "not fetched yet" apart from a confirmed-empty {}.
"""
out = {}
for entry in device0[1:]: # skip [0] (device-level rep)
for entry in device0[1:]: # skip [0] (device-level rep)
if not isinstance(entry, dict):
continue
href = entry.get('href')
rep = entry.get('rep')
href = entry.get("href")
rep = entry.get("rep")
if not href:
continue
if isinstance(rep, dict):
@@ -1,42 +1,63 @@
"""Per-device-type registries."""
import re
from typing import Optional, Sequence
from ._base import DeviceRegistry
import re
from collections.abc import Sequence
from . import (
air_dresser, air_monitor, air_purifier, airconditioner, cooktop,
dehumidifier, dishwasher, dryer, ehs, induction_cooktop, microwave, oven,
range as _range, range_hood, refrigerator, vacuum_station, washer,
air_dresser,
air_monitor,
air_purifier,
airconditioner,
cooktop,
dehumidifier,
dishwasher,
dryer,
ehs,
induction_cooktop,
microwave,
oven,
range_hood,
refrigerator,
vacuum_station,
washer,
water_purifier,
)
from . import (
range as _range,
)
from ._base import DeviceRegistry
__all__ = [
'DeviceRegistry', 'resolve', 'for_device_by_oic_type', 'for_device_by_model',
'for_device_by_resources', '_board_tokens',
"DeviceRegistry",
"_board_tokens",
"for_device_by_model",
"for_device_by_oic_type",
"for_device_by_resources",
"resolve",
]
# One entry per registry, no aliases: every key here is reachable from
# `_BOARD_TOKEN_TO_KEY`, `_CONSUMER_PREFIX_TO_KEY`, or `for_device_by_resources`.
_REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
'air_dresser': air_dresser.REGISTRY,
'air_monitor': air_monitor.REGISTRY,
'air_purifier': air_purifier.REGISTRY,
'airconditioner': airconditioner.REGISTRY,
'cooktop': cooktop.REGISTRY,
'dehumidifier': dehumidifier.REGISTRY,
'dishwasher': dishwasher.REGISTRY,
'dryer': dryer.REGISTRY,
'ehs': ehs.REGISTRY,
'induction_cooktop': induction_cooktop.REGISTRY,
'microwave': microwave.REGISTRY,
'oven': oven.REGISTRY,
'range': _range.REGISTRY,
'range_hood': range_hood.REGISTRY,
'refrigerator': refrigerator.REGISTRY,
'vacuum_station': vacuum_station.REGISTRY,
'washer': washer.REGISTRY,
'water_purifier': water_purifier.REGISTRY,
"air_dresser": air_dresser.REGISTRY,
"air_monitor": air_monitor.REGISTRY,
"air_purifier": air_purifier.REGISTRY,
"airconditioner": airconditioner.REGISTRY,
"cooktop": cooktop.REGISTRY,
"dehumidifier": dehumidifier.REGISTRY,
"dishwasher": dishwasher.REGISTRY,
"dryer": dryer.REGISTRY,
"ehs": ehs.REGISTRY,
"induction_cooktop": induction_cooktop.REGISTRY,
"microwave": microwave.REGISTRY,
"oven": oven.REGISTRY,
"range": _range.REGISTRY,
"range_hood": range_hood.REGISTRY,
"refrigerator": refrigerator.REGISTRY,
"vacuum_station": vacuum_station.REGISTRY,
"washer": washer.REGISTRY,
"water_purifier": water_purifier.REGISTRY,
}
@@ -46,13 +67,13 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
# internal board-family prefix there, and dishwasher's modelNum contains
# the substring 'WW', so a modelNum-only rule misroutes both.
_CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
'WW': 'washer',
'WD': 'washer',
'WF': 'washer',
'WV': 'washer', # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
'WA': 'washer', # Top-load washers (e.g. WA8000T) -- issue #106
'DV': 'dryer',
'DW': 'dishwasher',
"WW": "washer",
"WD": "washer",
"WF": "washer",
"WV": "washer", # FlexWash twin units (e.g. WV55M9600AW) -- issue #19
"WA": "washer", # Top-load washers (e.g. WA8000T) -- issue #106
"DV": "dryer",
"DW": "dishwasher",
}
# Board-family token -> registry key, matched against whole tokens of
@@ -74,40 +95,40 @@ _CONSUMER_PREFIX_TO_KEY: dict[str, str] = {
# air-conditioner spellings below), which is a statement about the hardware,
# not a shortcut.
_BOARD_TOKEN_TO_KEY: dict[str, str] = {
'REF': 'refrigerator',
"REF": "refrigerator",
# Air conditioners. Every one of these is a distinct board family with
# the same resource surface: room (issues #37, #91), package, Korean
# (#136), window (#87), 2-in-1 floor+wall (#150, #153), system/commercial
# (#52), cassette (#191), and ARA-WW wall-mount (#115, #116, #117, #120).
'RAC': 'airconditioner',
'PRAC': 'airconditioner',
'KRAC': 'airconditioner',
'WAC': 'airconditioner',
'FAC': 'airconditioner',
'CAWW': 'airconditioner',
'CAC': 'airconditioner', # issue #191 -- TP1X_DA-AC-CAC-01001_0000
'ARA': 'airconditioner',
'DHM': 'dehumidifier', # issue #88 -- target humidity, no climate
'EHS': 'ehs', # Eco Heating System air-to-water heat pump --
# zone1 space heating/cooling + dhw domestic
# hot water, its own /mode/*/vs/0 and
# /temperatures/*/vs/0 resource shapes
'TVTL': 'air_purifier', # issue #56 (ARTIK051)
'VTWW': 'air_purifier', # issue #151 (BESPOKE Cube Air)
'AVT': 'air_purifier', # issue #190 -- AVT-WW-TP1-23-AXX500, a
# next-gen BESPOKE Cube Air board; same
# lineage as VTWW above but the '-WW-'
# delimiter now falls one letter to the
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
# into a token the existing entry can't see
'AIR': 'air_purifier', # issue #130 (TP1X_DA-AC-AIR)
'WATERPURIFIER': 'water_purifier', # issue #90
'ADW': 'dishwasher',
'AHD': 'range_hood',
'RANGE': 'range', # issue #44 -- cooktop+oven combo
'OVEN': 'oven', # issue #55 -- wall oven, no burners
'MICROWAVE': 'microwave', # issues #66, #121
'COOKTOP': 'induction_cooktop', # issue #86 -- standalone, no oven
"RAC": "airconditioner",
"PRAC": "airconditioner",
"KRAC": "airconditioner",
"WAC": "airconditioner",
"FAC": "airconditioner",
"CAWW": "airconditioner",
"CAC": "airconditioner", # issue #191 -- TP1X_DA-AC-CAC-01001_0000
"ARA": "airconditioner",
"DHM": "dehumidifier", # issue #88 -- target humidity, no climate
"EHS": "ehs", # Eco Heating System air-to-water heat pump --
# zone1 space heating/cooling + dhw domestic
# hot water, its own /mode/*/vs/0 and
# /temperatures/*/vs/0 resource shapes
"TVTL": "air_purifier", # issue #56 (ARTIK051)
"VTWW": "air_purifier", # issue #151 (BESPOKE Cube Air)
"AVT": "air_purifier", # issue #190 -- AVT-WW-TP1-23-AXX500, a
# next-gen BESPOKE Cube Air board; same
# lineage as VTWW above but the '-WW-'
# delimiter now falls one letter to the
# left ('A-VTWW-' -> 'AVT-WW-'), splitting
# into a token the existing entry can't see
"AIR": "air_purifier", # issue #130 (TP1X_DA-AC-AIR)
"WATERPURIFIER": "water_purifier", # issue #90
"ADW": "dishwasher",
"AHD": "range_hood",
"RANGE": "range", # issue #44 -- cooktop+oven combo
"OVEN": "oven", # issue #55 -- wall oven, no burners
"MICROWAVE": "microwave", # issues #66, #121
"COOKTOP": "induction_cooktop", # issue #86 -- standalone, no oven
# Legacy ARTIK051 gas cooktops ('ARTIK051_GB_CT_001'), whose burner state
# lives in /mode/vs/0's options array. Deliberately a bare two-letter
# token, and so the loosest entry in this table -- it is only ever
@@ -115,14 +136,14 @@ _BOARD_TOKEN_TO_KEY: dict[str, str] = {
# `description` ('ARTIK051_GLOBAL_COOKTOP') would otherwise read as an
# induction cooktop via the COOKTOP entry above. See `for_device_by_model`
# for the field ordering that makes that resolve correctly.
'CT': 'cooktop',
'VSKR': 'vacuum_station', # issue #131 -- stick-vacuum clean station
'DF': 'air_dresser', # issue #162
'VSWW': 'vacuum_station', # issue #219
'ASM': 'air_monitor', # issue #210 -- Air Monitor Plus
"CT": "cooktop",
"VSKR": "vacuum_station", # issue #131 -- stick-vacuum clean station
"DF": "air_dresser", # issue #162
"VSWW": "vacuum_station", # issue #219
"ASM": "air_monitor", # issue #210 -- Air Monitor Plus
}
_TOKEN_SPLIT_RE = re.compile(r'[^A-Z0-9]+')
_TOKEN_SPLIT_RE = re.compile(r"[^A-Z0-9]+")
def _board_tokens(value: str, cut_at: str) -> list[str]:
@@ -133,11 +154,11 @@ def _board_tokens(value: str, cut_at: str) -> list[str]:
contain anything) and after description's first '/' (a '/DC92-...' board
part number).
"""
head = (value or '').split(cut_at, 1)[0].upper()
head = (value or "").split(cut_at, 1)[0].upper()
return [t for t in _TOKEN_SPLIT_RE.split(head) if t]
def _board_family_key(value: str, cut_at: str) -> Optional[str]:
def _board_family_key(value: str, cut_at: str) -> str | None:
"""First `_BOARD_TOKEN_TO_KEY` hit among `value`'s tokens, or None.
No known modelNum or description yields two *conflicting* board keys, so
@@ -156,8 +177,8 @@ def _board_family_key(value: str, cut_at: str) -> Optional[str]:
matching carve-out for this exact pair.
"""
tokens = _board_tokens(value, cut_at)
if 'REF' in tokens and 'WATERPURIFIER' in tokens:
return 'water_purifier'
if "REF" in tokens and "WATERPURIFIER" in tokens:
return "water_purifier"
for token in tokens:
key = _BOARD_TOKEN_TO_KEY.get(token)
if key is not None:
@@ -165,7 +186,7 @@ def _board_family_key(value: str, cut_at: str) -> Optional[str]:
return None
def _consumer_model_key(description: str) -> Optional[str]:
def _consumer_model_key(description: str) -> str | None:
"""Registry key from the consumer-model token in `description`, or None.
Usually that token is the last '_'-delimited segment before any
@@ -190,7 +211,7 @@ def _consumer_model_key(description: str) -> Optional[str]:
that ambiguity resolves correctly without this function needing to know
about unrelated device families.
"""
segments = (description or '').split('/', 1)[0].split('_')
segments = (description or "").split("/", 1)[0].split("_")
for segment in reversed(segments):
key = _CONSUMER_PREFIX_TO_KEY.get(segment[:2].upper())
if key is not None:
@@ -230,21 +251,21 @@ def _consumer_model_key(description: str) -> Optional[str]:
# override a `COOKTOP`/`CT` board token that had it right. Same reasoning as
# `oic.d.robotcleaner` above: no unambiguous key to point at, so no row.
_OIC_TYPE_TO_KEY: dict[str, str] = {
'oic.d.airconditioner': 'airconditioner',
'oic.d.airpurifier': 'air_purifier',
'oic.d.dishwasher': 'dishwasher',
'oic.d.dryer': 'dryer',
'oic.d.oven': 'oven',
'oic.d.refrigerator': 'refrigerator',
'oic.d.washer': 'washer',
'x.com.st.d.airqualitysensor': 'air_monitor',
'x.com.st.d.hood': 'range_hood', # AHD-WW-TP1-22-COMMON
'x.com.st.d.stickcleaner': 'vacuum_station',
'x.com.st.d.steamcloset': 'air_dresser',
"oic.d.airconditioner": "airconditioner",
"oic.d.airpurifier": "air_purifier",
"oic.d.dishwasher": "dishwasher",
"oic.d.dryer": "dryer",
"oic.d.oven": "oven",
"oic.d.refrigerator": "refrigerator",
"oic.d.washer": "washer",
"x.com.st.d.airqualitysensor": "air_monitor",
"x.com.st.d.hood": "range_hood", # AHD-WW-TP1-22-COMMON
"x.com.st.d.stickcleaner": "vacuum_station",
"x.com.st.d.steamcloset": "air_dresser",
}
def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegistry]:
def for_device_by_oic_type(device_types: Sequence[str]) -> DeviceRegistry | None:
"""Device-type detection from /oic/d's `rt` -- OCF's own device-type
declaration.
@@ -261,7 +282,7 @@ def for_device_by_oic_type(device_types: Sequence[str]) -> Optional[DeviceRegist
return None
def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegistry]:
def for_device_by_model(model_num: str, description: str) -> DeviceRegistry | None:
"""Device-type detection from /information/vs/0's model strings.
The primary path: the board named in `modelNum` determines the resource
@@ -291,14 +312,14 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
known type, None otherwise.
"""
key = (
_board_family_key(model_num, '|')
or _board_family_key(description, '/')
_board_family_key(model_num, "|")
or _board_family_key(description, "/")
or _consumer_model_key(description)
)
return _REGISTRY_BY_KEY.get(key) if key else None
def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegistry]:
def for_device_by_resources(resources: dict[str, dict]) -> DeviceRegistry | None:
"""Detect a device family from a distinctive local-resource signature.
This runs first as an override path for non-standard devices, not because
@@ -312,23 +333,18 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
putting this ahead of OIC/model metadata cannot let a common resource
misclassify an unrelated family.
"""
mode = resources.get('/mode/vs/0', {})
options = mode.get('x.com.samsung.da.options') or ()
mode = resources.get("/mode/vs/0", {})
options = mode.get("x.com.samsung.da.options") or ()
has_device_type = any(
isinstance(option, str) and option.startswith('DeviceType_')
for option in options
isinstance(option, str) and option.startswith("DeviceType_") for option in options
)
operation_states = sum(
1 for option in options
if isinstance(option, str) and option.startswith('OperationState')
1 for option in options if isinstance(option, str) and option.startswith("OperationState")
)
if has_device_type and operation_states >= 2:
return _REGISTRY_BY_KEY['cooktop']
if (
'/hood/fanspeed/vs/0' in resources
and '/hood/lamp/vs/0' in resources
):
return _REGISTRY_BY_KEY['range_hood']
return _REGISTRY_BY_KEY["cooktop"]
if "/hood/fanspeed/vs/0" in resources and "/hood/lamp/vs/0" in resources:
return _REGISTRY_BY_KEY["range_hood"]
# Oven/range/microwave boards that report no /information/vs/0 at all
# (issue #74's NE63B8411SS, issue #172's ME8000T -- the resource is simply
# absent from the dump, not just empty) can't be matched via
@@ -336,26 +352,26 @@ def for_device_by_resources(resources: dict[str, dict]) -> Optional[DeviceRegist
# the oven cavity resource (/oven/vs/0) is a safe two-resource signature;
# it also corrects Qooker's generic oic.d.oven / OVEN metadata (issue
# PR #225) when resource detection runs before metadata.
supported_modes = mode.get('x.com.samsung.da.supportedModes') or ()
supported_modes = mode.get("x.com.samsung.da.supportedModes") or ()
if not isinstance(supported_modes, (list, tuple)):
supported_modes = ()
cavity = resources.get('/oven/vs/0')
cavity = resources.get("/oven/vs/0")
if isinstance(cavity, dict):
if any(
m in supported_modes
for m in ('MicroWave', 'MicroWaveGrill', 'MicroWaveConvection')
m in supported_modes for m in ("MicroWave", "MicroWaveGrill", "MicroWaveConvection")
):
return _REGISTRY_BY_KEY['microwave']
if 'Bake' in supported_modes:
if '/cooktopmonitoring/vs/0' in resources or '/cooktop/status/vs/0' in resources:
return _REGISTRY_BY_KEY['range']
return _REGISTRY_BY_KEY['oven']
return _REGISTRY_BY_KEY["microwave"]
if "Bake" in supported_modes:
if "/cooktopmonitoring/vs/0" in resources or "/cooktop/status/vs/0" in resources:
return _REGISTRY_BY_KEY["range"]
return _REGISTRY_BY_KEY["oven"]
return None
def resolve(
resources: dict[str, dict], device_types: Sequence[str] = (),
) -> Optional[DeviceRegistry]:
resources: dict[str, dict],
device_types: Sequence[str] = (),
) -> DeviceRegistry | None:
"""Device type for a parsed /device/0 dump, or None if unrecognized.
The single entry point for detection -- the coordinator, the config
@@ -377,12 +393,12 @@ def resolve(
device-support issue has ever been fixed by adding a mapping for it. It
is still reported in diagnostics as a firmware-generation marker.
"""
info = resources.get('/information/vs/0', {})
info = resources.get("/information/vs/0", {})
return (
for_device_by_resources(resources)
or for_device_by_oic_type(device_types)
or for_device_by_model(
info.get('x.com.samsung.da.modelNum', ''),
info.get('x.com.samsung.da.description', ''),
info.get("x.com.samsung.da.modelNum", ""),
info.get("x.com.samsung.da.description", ""),
)
)
@@ -1,4 +1,5 @@
"""Base DeviceRegistry dataclass and builder."""
from __future__ import annotations
from dataclasses import dataclass, field
@@ -9,6 +10,7 @@ from ..capability import Capability
@dataclass(frozen=True)
class DeviceRegistry:
"""Registry of capabilities for a specific device type."""
name: str
capabilities: dict[str, list[Capability]]
pattern_capabilities: list[Capability] = field(default_factory=list)
@@ -31,17 +33,19 @@ def _build(caps: list[Capability]) -> dict[str, list[Capability]]:
for cap in caps:
if cap.href is None:
raise ValueError(f"Use pattern_capabilities for href=None caps")
raise ValueError("Use pattern_capabilities for href=None caps")
if cap.href_prefix is not None:
raise ValueError(
f"href_prefix is only valid for pattern caps (href=None); "
f"cap with href={cap.href!r} must not set href_prefix")
f"cap with href={cap.href!r} must not set href_prefix"
)
out.setdefault(cap.href, []).append(cap)
# Validate that multi-cap hrefs have proper discrimination
for href, cs in out.items():
if len(cs) > 1 and any(c.rt_filter is None and c.match_fn is None for c in cs):
raise ValueError(
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn")
f"href {href!r} has multiple caps but at least one lacks rt_filter and match_fn"
)
return out
@@ -8,21 +8,24 @@ expects, no AirDresser-specific wiring needed. air_dresser.py holds the two
pieces specific to this device type: a minimal wrinkle-prevent-only
/washer/vs/0 capability, and the course select's own translation key.
"""
from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_dresser',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]),
name="air_dresser",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
air_dresser.AIR_DRESSER_SETTINGS,
air_dresser.AIR_DRESSER_COURSE,
air_dresser.AIR_DRESSER_SANITIZE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]
),
)
@@ -5,19 +5,22 @@ board) -- no controllable state at all beyond the do-not-disturb window,
so this registry is almost entirely sensors. No common.POWER: there's no
`/power/*` resource on this board, only `/energy/battery/vs/0`.
"""
from ..capabilities import air_monitor, common, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_monitor',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]),
name="air_monitor",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*air_monitor.COVERAGE,
air_monitor.SENSORS,
air_monitor.HUMIDITY,
air_monitor.BATTERY,
air_monitor.AIR_QUALITY_STANDARD,
air_monitor.DND,
]
),
)
@@ -25,33 +25,36 @@ match_fn discriminators that keep them from colliding):
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
contract).
"""
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='air_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dishwasher.DIAGNOSIS,
air_purifier.AIR_QUALITY,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
air_purifier.FAN,
air_purifier.WIND_STRENGTH_FAN,
air_purifier.DISPLAY,
air_purifier.HEPA_FILTER,
air_purifier.PANEL_STATUS,
air_purifier.PET_FILTER_ACTIVATION,
air_purifier.SOUND_MODE,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
*air_purifier.COVERAGE,
]),
name="air_purifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dishwasher.DIAGNOSIS,
air_purifier.AIR_QUALITY,
air_purifier.FILTER,
air_purifier.DEVICE_ACTIVE,
air_purifier.AIRFLOW_GENERIC,
air_purifier.AIRFLOW_VS_FALLBACK,
air_purifier.MODE,
air_purifier.FAN,
air_purifier.WIND_STRENGTH_FAN,
air_purifier.DISPLAY,
air_purifier.HEPA_FILTER,
air_purifier.PANEL_STATUS,
air_purifier.PET_FILTER_ACTIVATION,
air_purifier.SOUND_MODE,
air_purifier.SOUND_OUTPUT,
air_purifier.SOUND_VOLUME,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
*air_purifier.COVERAGE,
]
),
)
@@ -16,31 +16,34 @@ uses unconditionally.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0.
"""
from ..capabilities import airconditioner, common, dishwasher, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='airconditioner',
capabilities=_build([
*ignored.IGNORED,
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
airconditioner.ENERGY_METER_GENERIC,
airconditioner.ENERGY_METER_LEGACY,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.AIR_QUALITY,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.ABSENCE_POWER_SAVING,
airconditioner.MOTION_DETECT_WIND,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
*airconditioner.COVERAGE,
]),
name="airconditioner",
capabilities=_build(
[
*ignored.IGNORED,
*[c for c in common.UNIVERSAL if c is not common.ENERGY_METER],
airconditioner.ENERGY_METER_GENERIC,
airconditioner.ENERGY_METER_LEGACY,
dishwasher.DIAGNOSIS,
airconditioner.CLIMATE,
airconditioner.AIR_PURIFY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.AIR_QUALITY,
airconditioner.DISPLAY_LIGHT,
airconditioner.MUTE_ONCE,
airconditioner.CURRENT_LIMIT,
airconditioner.ANOMALY_LOAD,
airconditioner.ABSENCE_POWER_SAVING,
airconditioner.MOTION_DETECT_WIND,
airconditioner.CURRENT_TEMPERATURE,
airconditioner.CURRENT_TEMPERATURE_VS,
airconditioner.HUMIDITY,
*airconditioner.COVERAGE,
]
),
)
@@ -12,15 +12,16 @@ key is unchanged: it's relied on by the legacy ARTIK051 'CT' modelNum token
from ..capabilities import common, cooktop, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='gas_cooktop',
capabilities=_build([
*ignored.IGNORED,
cooktop.COOKTOP_POWER,
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
common.FIRMWARE_UPDATE,
]),
name="gas_cooktop",
capabilities=_build(
[
*ignored.IGNORED,
cooktop.COOKTOP_POWER,
cooktop.COOKTOP_MODE,
cooktop.COOKTOP_CONNECTED,
cooktop.PAIRED_HOOD_STATUS,
common.FIRMWARE_UPDATE,
]
),
)
@@ -4,20 +4,23 @@ Shares the DA_AC_ board family with airconditioner.py (power, air filter,
auto-clean, mute-once all use the identical resource shapes), so those three
Capability objects are reused directly rather than duplicated.
"""
from ..capabilities import airconditioner, common, dehumidifier, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dehumidifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dehumidifier.MODE,
dehumidifier.HUMIDITY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.MUTE_ONCE,
*dehumidifier.COVERAGE,
]),
name="dehumidifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
dehumidifier.MODE,
dehumidifier.HUMIDITY,
airconditioner.AUTO_CLEAN,
airconditioner.AIR_FILTER,
airconditioner.MUTE_ONCE,
*dehumidifier.COVERAGE,
]
),
)
@@ -1,23 +1,26 @@
"""Dishwasher device registry."""
from ..capabilities import common, dishwasher, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dishwasher',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
dishwasher.CYCLE_OPTIONS,
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
]),
name="dishwasher",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
common.WATER_METER,
common.WATER_FILTER,
operational.OPERATIONAL_STATE,
dishwasher.CYCLE_OPTIONS,
dishwasher.DISHWASHER_SETTINGS,
dishwasher.DIAGNOSIS,
dishwasher.OPERATION_ORIGIN,
laundry.JOB_BEGINNING_STATUS,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.SOUND_VOLUME,
]
),
)
@@ -9,22 +9,25 @@ objects the washer registry uses -- washer and dryer expose the same
DA_WM_-family surface, so they stay consistent instead of each carrying a
bespoke variant.
"""
from ..capabilities import common, dryer, ignored, laundry, operational
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='dryer',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
]),
name="dryer",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
operational.OPERATIONAL_STATE,
laundry.DOOR_LED,
laundry.SOUND_MODE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
dryer.DRYER_SETTINGS,
dryer.DRYER_COURSE,
dryer.DRYER_DIAGNOSIS,
]
),
)
@@ -9,21 +9,24 @@ pattern -- so nothing from that module is reused here except MUTE_ONCE,
whose /option/muteonce/vs/0 field shape (`muteonce`) is identical on this
family's dump.
"""
from ..capabilities import airconditioner, common, ehs, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='ehs',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
airconditioner.MUTE_ONCE,
ehs.ZONE_POWER,
ehs.ZONE_MODE,
ehs.ZONE_TEMPERATURE,
ehs.DHW,
*ehs.DHW_CONSUMED,
ehs.AWAY_MODE,
*ehs.COVERAGE,
]),
name="ehs",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
airconditioner.MUTE_ONCE,
ehs.ZONE_POWER,
ehs.ZONE_MODE,
ehs.ZONE_TEMPERATURE,
ehs.DHW,
*ehs.DHW_CONSUMED,
ehs.AWAY_MODE,
*ehs.COVERAGE,
]
),
)
@@ -11,20 +11,24 @@ units pair with. Distinct registry key from cooktop.REGISTRY ('cooktop')
embedded in /mode/vs/0's options array, a completely different OCF
surface that happens to share the English word "cooktop").
"""
from ..capabilities import common, cooktop as cooktop_caps, ignored
from ..capabilities import common, ignored
from ..capabilities import cooktop as cooktop_caps
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='induction_cooktop',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.PROBE_STATUS,
cooktop_caps.PAIRED_HOOD_STATUS,
]),
name="induction_cooktop",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.PROBE_STATUS,
cooktop_caps.PAIRED_HOOD_STATUS,
]
),
)
@@ -16,22 +16,25 @@ has no sibling `/power/0` or `/power/vs/0` resource; fan.py's
LocalThingsRangeHoodFan falls back to treating fan speed 0 as off in that
case (see its `_speed_zero_is_off` check).
"""
from ..capabilities import common, ignored, microwave, oven, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='microwave',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
microwave.MICROWAVE_CAVITY,
microwave.MICROWAVE_SETPOINT,
microwave.MICROWAVE_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_RECIPE_COOK,
range_hood.HOOD_FAN,
]),
name="microwave",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
microwave.MICROWAVE_CAVITY,
microwave.MICROWAVE_SETPOINT,
microwave.MICROWAVE_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_RECIPE_COOK,
range_hood.HOOD_FAN,
]
),
)
@@ -1,20 +1,23 @@
"""Oven device registry."""
from ..capabilities import common, ignored, oven
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='oven',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
oven.OVEN_RECIPE_COOK,
]),
name="oven",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
oven.OVEN_RECIPE_COOK,
]
),
)
@@ -5,26 +5,29 @@ connected capabilities wholesale (a range's oven half is the same OCF
surface as a standalone oven) and adds the cooktop-specific capabilities
for the burner half.
"""
from ..capabilities import common, ignored, oven
from ..capabilities import range as range_caps
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='range',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]),
name="range",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
oven.OVEN_CAVITY,
oven.OVEN_SETPOINT,
oven.OVEN_MODE,
oven.OVEN_OPERATIONAL_STATE,
oven.OVEN_DOOR,
oven.OVEN_CONNECTED,
oven.OVEN_SPEC,
range_caps.COOKTOP_STATUS,
range_caps.COOKTOP_SPEC,
range_caps.COOKTOP_SAFETY,
range_caps.COOKTOP_MONITORING,
]
),
)
@@ -3,21 +3,22 @@
from ..capabilities import common, ignored, range_hood
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='range_hood',
capabilities=_build([
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.AFTER_RUN,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
range_hood.AIR_QUALITY,
range_hood.AIR_LEVEL_CHECK,
range_hood.AUTO_VENTILATION,
*range_hood.COVERAGE,
]),
name="range_hood",
capabilities=_build(
[
*ignored.IGNORED,
range_hood.HOOD_ALARMS,
common.ENERGY_METER,
common.FIRMWARE_UPDATE,
range_hood.AFTER_RUN,
range_hood.HOOD_FAN,
range_hood.HOOD_LAMP,
range_hood.HOOD_FILTER,
range_hood.AIR_QUALITY,
range_hood.AIR_LEVEL_CHECK,
range_hood.AUTO_VENTILATION,
*range_hood.COVERAGE,
]
),
)
@@ -1,37 +1,40 @@
"""Refrigerator device registry."""
from ..capabilities import common, dishwasher, fridge, ignored
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='refrigerator',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
fridge.CABINET_LIGHT,
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DEFINITE_TEMPERATURE_COOLER,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]),
name="refrigerator",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
fridge.STATUS_LOCK,
fridge.DOOR_ALERT,
common.WATER_FILTER,
dishwasher.DIAGNOSIS,
fridge.ICEMAKER_NIGHTTIME,
fridge.FLEX_ZONE,
fridge.REFRIGERATION,
fridge.AUTOFILL,
fridge.WELCOME_LIGHTING,
fridge.CABINET_LIGHT,
fridge.CABINET_LIGHT_ENHANCED,
fridge.SABBATH,
fridge.BEVERAGE_ZONE,
fridge.PANTRY_ZONE,
fridge.DEFROST_DELAY,
fridge.DEFROST_DELAY_NATIVE_DUPLICATE,
fridge.DEFROST_BLOCK_STATUS,
fridge.DEFINITE_TEMPERATURE_COOLER,
fridge.DOORS_FALLBACK,
fridge.TEMPERATURES_FALLBACK,
fridge.ICEMAKER_STATUS_FALLBACK,
fridge.ICEMAKER_STATUS_NATIVE_DUPLICATE,
fridge.REFRIGERATION_FALLBACK,
]
),
pattern_capabilities=[
fridge.TEMP_CURRENT_GENERIC,
fridge.TEMP_SETPOINT,
@@ -5,18 +5,21 @@ covers the station's own dustbag/dustbin/UV-sanitize state and not any
vacuum-body control (suction, battery, cleaning mode) -- the diagnostics
dump this was built from reports none of that.
"""
from ..capabilities import common, ignored, vacuum_station
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='vacuum_station',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
vacuum_station.DUSTBAG,
vacuum_station.DUSTBAG_USAGE,
vacuum_station.DUSTBIN_SETTING,
vacuum_station.CLEANSTATION_STATUS,
]),
name="vacuum_station",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
vacuum_station.DUSTBAG,
vacuum_station.DUSTBAG_USAGE,
vacuum_station.DUSTBIN_SETTING,
vacuum_station.CLEANSTATION_STATUS,
]
),
)
@@ -1,19 +1,22 @@
"""Washer device registry."""
from ..capabilities import common, dishwasher, ignored, laundry, operational, washer
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='washer',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]),
name="washer",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
*common.POWER,
washer.WASHER_SETTINGS,
washer.WASHER_COURSE,
laundry.BUZZER_SOUND,
laundry.JOB_BEGINNING_STATUS,
common.WATER_METER,
operational.OPERATIONAL_STATE,
dishwasher.DIAGNOSIS,
]
),
)
@@ -1,25 +1,27 @@
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90).
"""
"""Water-purifier device registry (Samsung TP2X_WATERPURIFIER-class, issue #90)."""
from ..capabilities import common, ignored, water_purifier
from ._base import DeviceRegistry, _build
REGISTRY = DeviceRegistry(
name='water_purifier',
capabilities=_build([
*ignored.IGNORED,
*common.UNIVERSAL,
common.WATER_FILTER,
water_purifier.DISPENSE,
water_purifier.STATUS,
water_purifier.FAVORITE_CAPACITY,
water_purifier.FAVORITE_HOTWATER,
water_purifier.COFFEE,
water_purifier.LOCK,
water_purifier.CUP_STATE,
water_purifier.SOUND_MODE,
water_purifier.SOUND_OUTPUT,
water_purifier.SOUND_VOLUME,
water_purifier.STATISTIC_POUR,
*water_purifier.COVERAGE,
]),
name="water_purifier",
capabilities=_build(
[
*ignored.IGNORED,
*common.UNIVERSAL,
common.WATER_FILTER,
water_purifier.DISPENSE,
water_purifier.STATUS,
water_purifier.FAVORITE_CAPACITY,
water_purifier.FAVORITE_HOTWATER,
water_purifier.COFFEE,
water_purifier.LOCK,
water_purifier.CUP_STATE,
water_purifier.SOUND_MODE,
water_purifier.SOUND_OUTPUT,
water_purifier.SOUND_VOLUME,
water_purifier.STATISTIC_POUR,
*water_purifier.COVERAGE,
]
),
)
@@ -1,8 +1,12 @@
from . import (
common, cooktop, dishwasher, fridge, ignored, laundry, operational, oven,
range_hood,
)
from ..capability import Capability
from . import (
common,
fridge,
ignored,
laundry,
operational,
oven,
)
def _is_capability(v):
@@ -18,5 +22,13 @@ _OVEN_GLOBAL_CAPS = [
oven.OVEN_CAVITY,
]
ALL = [v for mod in (common, operational, laundry, fridge)
for v in vars(mod).values() if _is_capability(v)] + _OVEN_GLOBAL_CAPS + ignored.IGNORED
ALL = (
[
v
for mod in (common, operational, laundry, fridge)
for v in vars(mod).values()
if _is_capability(v)
]
+ _OVEN_GLOBAL_CAPS
+ ignored.IGNORED
)
@@ -32,49 +32,59 @@ machinery:
/airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only;
#162's board doesn't report this resource at all)
"""
from ..capability import Capability
from ..entities import SwitchDesc
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
def _sanitize_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p}
return ["airdresseroption", "sanitize", "vs", "0"], {"x.com.samsung.da.sanitize": p}
AIR_DRESSER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
href="/washer/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
SwitchDesc(
key="wrinkle_prevent",
field="x.com.samsung.da.wrinklePrevent",
icon="mdi:iron",
value_fn=lambda v: v == "On",
write_fn=_wrinkle_write,
),
),
)
AIR_DRESSER_COURSE = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew',
table_href='/st/airdressercourse/vs/0'),
cycle_select(
translation_key="air_dresser_cycle",
icon="mdi:tshirt-crew",
table_href="/st/airdressercourse/vs/0",
),
),
)
AIR_DRESSER_SANITIZE = Capability(
href='/airdresseroption/sanitize/vs/0',
poll_tier='warm',
href="/airdresseroption/sanitize/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
icon='mdi:weather-sunny',
value_fn=lambda v: v == 'On',
write_fn=_sanitize_write),
SwitchDesc(
key="sanitize",
field="x.com.samsung.da.sanitize",
icon="mdi:weather-sunny",
value_fn=lambda v: v == "On",
write_fn=_sanitize_write,
),
),
)
@@ -31,6 +31,7 @@ forever, and the write-side rejection safety net doesn't cover reads.
Exposed as plain `measurement` sensors named after the device's own
field instead (matching air_purifier.AIR_QUALITY's existing precedent).
"""
from datetime import time as dt_time
from ..capability import Capability
@@ -38,54 +39,78 @@ from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc, TimeDesc
from .air_purifier import _AIR_QUALITY_SENSORS
from .common import int_or_none, sensor_item_value
SENSORS = Capability(
href='/sensors/vs/0',
poll_tier='warm',
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
state_class='measurement',
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
) + (
SensorDesc(key='co2', field='x.com.samsung.da.items',
device_class='carbon_dioxide', state_class='measurement',
unit='ppm',
value_fn=lambda items: sensor_item_value(items, 'CO2')),
href="/sensors/vs/0",
poll_tier="warm",
entities=(
*(
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
state_class="measurement",
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
),
SensorDesc(
key="co2",
field="x.com.samsung.da.items",
device_class="carbon_dioxide",
state_class="measurement",
unit="ppm",
value_fn=lambda items: sensor_item_value(items, "CO2"),
),
),
)
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
href="/humidity/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', state_class='measurement',
unit='%', value_fn=int_or_none),
SensorDesc(
key="humidity",
field="x.com.samsung.da.humidity",
device_class="humidity",
state_class="measurement",
unit="%",
value_fn=int_or_none,
),
),
)
BATTERY = Capability(
href='/energy/battery/vs/0',
poll_tier='warm',
href="/energy/battery/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='battery', field='x.com.samsung.da.battery',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic',
value_fn=int_or_none),
BinarySensorDesc(key='battery_charging', field='x.com.samsung.da.charging',
device_class='battery_charging',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
SensorDesc(
key="battery",
field="x.com.samsung.da.battery",
device_class="battery",
state_class="measurement",
unit="%",
entity_category="diagnostic",
value_fn=int_or_none,
),
BinarySensorDesc(
key="battery_charging",
field="x.com.samsung.da.charging",
device_class="battery_charging",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
),
)
AIR_QUALITY_STANDARD = Capability(
href='/airqualitystandard/vs/0',
poll_tier='cold',
href="/airqualitystandard/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='air_quality_standard', field='x.com.samsung.da.standard',
entity_category='diagnostic'),
SensorDesc(
key="air_quality_standard",
field="x.com.samsung.da.standard",
entity_category="diagnostic",
),
),
)
@@ -96,7 +121,7 @@ def _parse_hms(v):
if not v:
return None
try:
parts = v.split(':')
parts = v.split(":")
return dt_time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
@@ -104,7 +129,8 @@ def _parse_hms(v):
def _dnd_time_write(field):
def _write(p, rep, href=None):
return ['dnd', 'vs', '0'], {field: f'{p.hour:02d}:{p.minute:02d}:00'}
return ["dnd", "vs", "0"], {field: f"{p.hour:02d}:{p.minute:02d}:00"}
return _write
@@ -116,26 +142,36 @@ def _dnd_time_write(field):
# needs a reporter to actually flip it on real hardware and confirm. See
# the adding-device-support skill's "Educated guesses are fine" section.
DND = Capability(
href='/dnd/vs/0',
poll_tier='cold',
href="/dnd/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='dnd', field='x.com.samsung.da.value',
icon='mdi:sleep',
entity_category='config',
value_fn=lambda v: v == 'true',
write_fn=lambda p, rep, href=None: (
['dnd', 'vs', '0'],
{'x.com.samsung.da.value': 'true' if p == 'On' else 'false'})),
TimeDesc(key='dnd_start', field='x.com.samsung.da.startTime',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.startTime')),
TimeDesc(key='dnd_end', field='x.com.samsung.da.endTime',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hms,
write_fn=_dnd_time_write('x.com.samsung.da.endTime')),
SwitchDesc(
key="dnd",
field="x.com.samsung.da.value",
icon="mdi:sleep",
entity_category="config",
value_fn=lambda v: v == "true",
write_fn=lambda p, rep, href=None: (
["dnd", "vs", "0"],
{"x.com.samsung.da.value": "true" if p == "On" else "false"},
),
),
TimeDesc(
key="dnd_start",
field="x.com.samsung.da.startTime",
icon="mdi:clock-start",
entity_category="config",
value_fn=_parse_hms,
write_fn=_dnd_time_write("x.com.samsung.da.startTime"),
),
TimeDesc(
key="dnd_end",
field="x.com.samsung.da.endTime",
icon="mdi:clock-end",
entity_category="config",
value_fn=_parse_hms,
write_fn=_dnd_time_write("x.com.samsung.da.endTime"),
),
),
)
@@ -146,10 +182,10 @@ DND = Capability(
_AM_IGNORED = [
# A single bare integer ('keepnormal': 0) with no description, no
# supported-values list, and no second dump to compare against -- opaque.
'/keepnormalstate/vs/0',
"/keepnormalstate/vs/0",
# {'remove': ''} -- looks like data-sink/cache-clearing plumbing, not a
# live user-facing field.
'/sensordatasinks/vs/0',
"/sensordatasinks/vs/0",
]
COVERAGE = [Capability(href=h) for h in _AM_IGNORED]
@@ -57,9 +57,15 @@ was unreliable on both units in that second round (Low/Medium collided on
one unit, stuck at 0 throughout on the other), so AIRFLOW_VS_FALLBACK below
stays a plain read-only diagnostic even after this change.
"""
from ..capability import Capability
from ..entities import (
BinarySensorDesc, FanDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
BinarySensorDesc,
FanDesc,
NumberDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from .common import filter_usage_percent, int_or_none, sensor_item_value
from .laundry import bool_option_exists, bool_option_value, option_value, option_write
@@ -72,28 +78,33 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option
# supportedModes at all (see the module docstring's Comode_Off finding).
# Both board generations share the /mode/vs/0 href, so FAN and MODE below
# are mutually exclusive via this presence check rather than colliding.
HREF_MODE = '/mode/vs/0'
HREF_AIRFLOW = '/airflow/0'
HREF_WIND_STRENGTH = '/wind/strength/vs/0'
HREF_MODE = "/mode/vs/0"
HREF_AIRFLOW = "/airflow/0"
HREF_WIND_STRENGTH = "/wind/strength/vs/0"
def _has_top_level_modes(rep, resources):
return isinstance(rep.get('x.com.samsung.da.supportedModes'), (list, tuple))
return isinstance(rep.get("x.com.samsung.da.supportedModes"), (list, tuple))
_AIR_QUALITY_SENSORS = (
('dust', 'mdi:blur', 'Dust'),
('fine_dust', 'mdi:blur', 'FineDust'),
('super_fine_dust', 'mdi:blur', 'SuperFineDust'),
('odor', 'mdi:scent', 'Odor'),
('clean_level', 'mdi:air-filter', 'CleanLevel'),
("dust", "mdi:blur", "Dust"),
("fine_dust", "mdi:blur", "FineDust"),
("super_fine_dust", "mdi:blur", "SuperFineDust"),
("odor", "mdi:scent", "Odor"),
("clean_level", "mdi:air-filter", "CleanLevel"),
)
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
href="/sensors/vs/0",
poll_tier="warm",
entities=tuple(
SensorDesc(key=key, field='x.com.samsung.da.items', icon=icon,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t))
SensorDesc(
key=key,
field="x.com.samsung.da.items",
icon=icon,
value_fn=lambda items, t=sensor_type: sensor_item_value(items, t),
)
for key, icon, sensor_type in _AIR_QUALITY_SENSORS
),
)
@@ -103,8 +114,8 @@ def _consumable_state(items, name):
"""Read a `/consumable/vs/0`-style items[] entry -- {name, state} pairs,
unlike AIR_QUALITY's {type, value} shape above."""
for item in items or ():
if isinstance(item, dict) and item.get('x.com.samsung.da.name') == name:
return item.get('x.com.samsung.da.state')
if isinstance(item, dict) and item.get("x.com.samsung.da.name") == name:
return item.get("x.com.samsung.da.state")
return None
@@ -115,46 +126,53 @@ def _consumable_state(items, name):
# which counts the same direction) rather than "filter life," which would
# imply the opposite direction.
FILTER = Capability(
href='/consumable/vs/0',
poll_tier='cold',
href="/consumable/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='filter_progress', field='x.com.samsung.da.items',
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda items: int_or_none(
_consumable_state(items, 'FilterProgress'))),
SensorDesc(
key="filter_progress",
field="x.com.samsung.da.items",
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=lambda items: int_or_none(_consumable_state(items, "FilterProgress")),
),
),
)
DEVICE_ACTIVE = Capability(
href='/devicespecificinfo/vs/0',
poll_tier='cold',
href="/devicespecificinfo/vs/0",
poll_tier="cold",
entities=(
BinarySensorDesc(key='device_active', field='x.com.samsung.da.deviceActive',
icon='mdi:check-network-outline',
entity_category='diagnostic',
value_fn=lambda v: bool(v)),
BinarySensorDesc(
key="device_active",
field="x.com.samsung.da.deviceActive",
icon="mdi:check-network-outline",
entity_category="diagnostic",
value_fn=lambda v: bool(v),
),
),
)
def _power_write(power_href, value):
"""Shared 'power' payload handling for this family's three FanDesc write
functions -- targets whichever power href fan.py's _power_payload picked
(the board may only report /power/0); a hardcoded vendor href here would
silently no-op on such a board even though the entity's own is_on
already falls back to reading it correctly."""
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
return (['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if value else 'Off'})
if power_href == "/power/0":
return ["power", "0"], {"value": bool(value)}
return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
def _airflow_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
return _power_write(args[0] if args else '/power/vs/0', value)
if kind == 'speed':
return ['airflow', '0'], {'speed': int(value)}
if kind == "power":
return _power_write(args[0] if args else "/power/vs/0", value)
if kind == "speed":
return ["airflow", "0"], {"speed": int(value)}
return None
@@ -178,12 +196,15 @@ def _airflow_fan_write(payload, rep, href=None):
# same key would collide if a future board ever reported both.
AIRFLOW_GENERIC = Capability(
href=HREF_AIRFLOW,
poll_tier='warm',
poll_tier="warm",
entities=(
FanDesc(key='airflow_fan', field='speed', write_fn=_airflow_fan_write),
SensorDesc(key='fan_direction', field='direction',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
FanDesc(key="airflow_fan", field="speed", write_fn=_airflow_fan_write),
SensorDesc(
key="fan_direction",
field="direction",
icon="mdi:rotate-3d-variant",
entity_category="diagnostic",
),
),
)
@@ -192,17 +213,24 @@ AIRFLOW_GENERIC = Capability(
# docstring): it collided Low/Medium on one unit and stuck at 0 throughout
# on the other in the same properly-spaced round.
AIRFLOW_VS_FALLBACK = Capability(
href='/airflow/vs/0',
match_fn=lambda rep, resources: '/airflow/0' not in resources,
poll_tier='warm',
href="/airflow/vs/0",
match_fn=lambda rep, resources: "/airflow/0" not in resources,
poll_tier="warm",
entities=(
SensorDesc(key='fan_speed_level', field='x.com.samsung.da.speedLevel',
icon='mdi:fan',
state_class='measurement', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='fan_direction', field='x.com.samsung.da.direction',
icon='mdi:rotate-3d-variant',
entity_category='diagnostic'),
SensorDesc(
key="fan_speed_level",
field="x.com.samsung.da.speedLevel",
icon="mdi:fan",
state_class="measurement",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key="fan_direction",
field="x.com.samsung.da.direction",
icon="mdi:rotate-3d-variant",
entity_category="diagnostic",
),
),
)
@@ -214,37 +242,43 @@ def _light_write(payload, rep, href=None):
# merges the same way everywhere. If some unit replaces the field
# outright instead, this would drop Comode/OptionCode alongside it on
# the next light toggle; revisit if a real device report surfaces that.
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Light', payload),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Light", payload),
}
MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
match_fn=lambda rep, resources: not _has_top_level_modes(rep, resources),
entities=(
SwitchDesc(key='display_light', icon='mdi:led-on',
entity_category='config',
rep_fn=bool_option_value('Light'),
exists_fn=bool_option_exists('Light'),
write_fn=_light_write),
SwitchDesc(
key="display_light",
icon="mdi:led-on",
entity_category="config",
rep_fn=bool_option_value("Light"),
exists_fn=bool_option_exists("Light"),
write_fn=_light_write,
),
# Read-only -- confirmed NOT the fan-speed selector (see module
# docstring), actual purpose still unconfirmed.
SensorDesc(key='operating_mode', icon='mdi:fan',
entity_category='diagnostic',
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Comode'),
exists_fn=bool_option_exists('Comode')),
SensorDesc(
key="operating_mode",
icon="mdi:fan",
entity_category="diagnostic",
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Comode"),
exists_fn=bool_option_exists("Comode"),
),
),
)
def _fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
return _power_write(args[0] if args else '/power/vs/0', value)
if kind == 'mode':
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}
if kind == "power":
return _power_write(args[0] if args else "/power/vs/0", value)
if kind == "mode":
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [value]}
return None
@@ -252,7 +286,7 @@ def _first_fan_mode(rep):
"""Representative scalar for the fan entity in the flattened state
(golden/regression), mirroring airconditioner.py's own _first_mode --
the real entity computes its state from live coordinator reads."""
modes = rep.get('x.com.samsung.da.modes')
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
@@ -265,21 +299,25 @@ def _first_fan_mode(rep):
# convenient modes are modeled as a preset rather than a speed number.
FAN = Capability(
href=HREF_MODE,
poll_tier='warm',
poll_tier="warm",
match_fn=_has_top_level_modes,
entities=(
FanDesc(key='fan', translation_key='air_purifier_fan',
rep_fn=_first_fan_mode, write_fn=_fan_write),
FanDesc(
key="fan",
translation_key="air_purifier_fan",
rep_fn=_first_fan_mode,
write_fn=_fan_write,
),
),
)
def _wind_strength_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
return _power_write(args[0] if args else '/power/vs/0', value)
if kind == 'mode':
return ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}
if kind == "power":
return _power_write(args[0] if args else "/power/vs/0", value)
if kind == "mode":
return ["wind", "strength", "vs", "0"], {"x.com.samsung.da.modes": value}
return None
@@ -298,10 +336,14 @@ def _wind_strength_fan_write(payload, rep, href=None):
# comment on this exact hazard -- missed here in the initial cut).
WIND_STRENGTH_FAN = Capability(
href=HREF_WIND_STRENGTH,
poll_tier='warm',
poll_tier="warm",
entities=(
FanDesc(key='wind_strength_fan', translation_key='air_purifier_fan',
field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write),
FanDesc(
key="wind_strength_fan",
translation_key="air_purifier_fan",
field="x.com.samsung.da.modes",
write_fn=_wind_strength_fan_write,
),
),
)
@@ -315,16 +357,20 @@ WIND_STRENGTH_FAN = Capability(
# separate hrefs on this dump, so they're two independent physical controls,
# not a duplicate encoding of one.
DISPLAY = Capability(
href='/display/vs/0',
poll_tier='cold',
href="/display/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='display', field='mode',
icon='mdi:monitor',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['display', 'vs', '0'],
{'mode': 'On' if p == 'On' else 'Off'})),
SwitchDesc(
key="display",
field="mode",
icon="mdi:monitor",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["display", "vs", "0"],
{"mode": "On" if p == "On" else "Off"},
),
),
),
)
@@ -333,18 +379,27 @@ DISPLAY = Capability(
# seen on this one dump, so the option list there is reused as-is rather
# than re-deriving it from a single sample.
HEPA_FILTER = Capability(
href='/filter/hepafilter/vs/0',
poll_tier='cold',
href="/filter/hepafilter/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='hepa_filter_usage', rep_fn=filter_usage_percent,
unit='%', state_class='measurement',
icon='mdi:air-filter', entity_category='diagnostic'),
SensorDesc(key='hepa_filter_status', field='x.com.samsung.da.filterStatus',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
icon='mdi:air-filter', entity_category='diagnostic',
value_fn=lambda v: v.lower() if isinstance(v, str) else v),
SensorDesc(
key="hepa_filter_usage",
rep_fn=filter_usage_percent,
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
),
SensorDesc(
key="hepa_filter_status",
field="x.com.samsung.da.filterStatus",
device_class="enum",
options=("normal", "wash", "replace"),
translation_key="filter_status",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
),
)
@@ -353,27 +408,35 @@ HEPA_FILTER = Capability(
# supportedStatus list is present to check against -- exposed as a plain
# diagnostic sensor rather than an asserted binary_sensor polarity.
PANEL_STATUS = Capability(
href='/panel/vs/0',
poll_tier='cold',
href="/panel/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='panel_status', field='status',
icon='mdi:archive-outline', entity_category='diagnostic'),
SensorDesc(
key="panel_status",
field="status",
icon="mdi:archive-outline",
entity_category="diagnostic",
),
),
)
# Pet-care filter mode -- a plain On/Off field with no vendor prefix, same
# convention as airconditioner.MUTE_ONCE.
PET_FILTER_ACTIVATION = Capability(
href='/petfilteractivation/vs/0',
poll_tier='cold',
href="/petfilteractivation/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='pet_filter_activation', field='status',
icon='mdi:paw',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['petfilteractivation', 'vs', '0'],
{'status': 'On' if p == 'On' else 'Off'})),
SwitchDesc(
key="pet_filter_activation",
field="status",
icon="mdi:paw",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["petfilteractivation", "vs", "0"],
{"status": "On" if p == "On" else "Off"},
),
),
),
)
@@ -385,49 +448,62 @@ PET_FILTER_ACTIVATION = Capability(
# separate descriptors reading the live supported values instead of a
# hardcoded table.
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
poll_tier='cold',
href="/settings/sound/mode/vs/0",
poll_tier="cold",
entities=(
# Distinct translation_key from laundry.SOUND_MODE's shared
# 'sound_mode' catalog entry -- that one's state table is
# {voice, tone, mute}, but this board's supportedModes is
# {mute, buzzer}. Sharing the key would leave 'buzzer' unlabelled
# (falls through to the raw code) since the catalogs don't overlap.
SelectDesc(key='sound_mode', translation_key='air_purifier_sound_mode',
field='mode',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedModes',
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
SelectDesc(
key="sound_mode",
translation_key="air_purifier_sound_mode",
field="mode",
icon="mdi:volume-high",
entity_category="config",
options_field="supportedModes",
write_fn=lambda p, rep, href=None: (
["settings", "sound", "mode", "vs", "0"],
{"mode": p},
),
),
),
)
# Read-only descriptor of which sound output the unit has -- only one value
# seen, no alternatives to select between.
SOUND_OUTPUT = Capability(
href='/settings/sound/output/vs/0',
poll_tier='cold',
href="/settings/sound/output/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='sound_output', field='deviceType',
icon='mdi:volume-high', entity_category='diagnostic'),
SensorDesc(
key="sound_output",
field="deviceType",
icon="mdi:volume-high",
entity_category="diagnostic",
),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
poll_tier='cold',
href="/settings/sound/volume/vs/0",
poll_tier="cold",
entities=(
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0,
native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0,
step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
NumberDesc(
key="sound_volume",
field="level",
icon="mdi:volume-medium",
entity_category="config",
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
["settings", "sound", "volume", "vs", "0"],
{"level": str(int(p))},
),
),
),
)
@@ -444,22 +520,22 @@ SOUND_VOLUME = Capability(
# colliding with a family-local bare Capability on the same href raises in
# _build()); left as a possible follow-up DRY cleanup.
COVERAGE = [
Capability(href='/humidity/0'),
Capability(href='/humidity/vs/0'),
Capability(href='/airlevelcheck/vs/0'), # periodic air-quality sensing scheduler plumbing
Capability(href='/availablecontrolsets/vs/0'), # opaque hex-encoded control-set bitmap
Capability(href='/da/softreset/vs/0'), # soft-reset trigger plumbing
Capability(href='/keepnormalstate/vs/0'), # internal keep-normal flag
Capability(href='/personality/presence/vs/0'), # presence-personalization plumbing (empty here)
Capability(href='/reserverulesets/vs/0'), # opaque hex-encoded schedule reservation blob
Capability(href="/humidity/0"),
Capability(href="/humidity/vs/0"),
Capability(href="/airlevelcheck/vs/0"), # periodic air-quality sensing scheduler plumbing
Capability(href="/availablecontrolsets/vs/0"), # opaque hex-encoded control-set bitmap
Capability(href="/da/softreset/vs/0"), # soft-reset trigger plumbing
Capability(href="/keepnormalstate/vs/0"), # internal keep-normal flag
Capability(href="/personality/presence/vs/0"), # presence-personalization plumbing (empty here)
Capability(href="/reserverulesets/vs/0"), # opaque hex-encoded schedule reservation blob
# Do-not-disturb/auto-sleep schedule (visible/startTime/endTime/
# useTimeSetting/functionState) -- every field reads its inert default
# on the only dump seen (times both '00:00:00', useTimeSetting/
# functionState both 'false'). Same "needs a multi-field schedule
# editor" treatment as fridge.py's /defrost/reservation/vs/0.
Capability(href='/dnd/autosleep/vs/0'),
Capability(href="/dnd/autosleep/vs/0"),
# Empty ({}) on the A-VTWW-TP2-21 dump (issue #151) -- this board's
# convenient-mode-equivalent behavior lives entirely in WIND_STRENGTH_FAN
# above instead.
Capability(href='/mode/convenient/vs/0'),
Capability(href="/mode/convenient/vs/0"),
]
File diff suppressed because it is too large Load Diff
@@ -10,12 +10,17 @@ against live device dumps:
/water/consumption/vs/0 -> x.com.samsung.da.cumulativeWater
/filter/waterfilter/vs/0 -> x.com.samsung.da.filterUsage / filterStatus
"""
from datetime import datetime, timezone
from datetime import UTC, datetime
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
BinarySensorDesc,
ButtonDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
@@ -56,7 +61,7 @@ def parse_iso_utc(raw):
dt = datetime.fromisoformat(raw)
except ValueError:
return None
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC)
def filter_usage_percent(rep):
@@ -65,23 +70,23 @@ def filter_usage_percent(rep):
`filterCapacityUnit` (Hours, e.g. 100 of a 500 capacity), so a plain
value with a '%' unit would be wrong -- normalize to used/capacity.
Returns None when capacity is missing/zero."""
used = _num(rep.get('x.com.samsung.da.filterUsage'))
cap = _num(rep.get('x.com.samsung.da.filterCapacity'))
used = _num(rep.get("x.com.samsung.da.filterUsage"))
cap = _num(rep.get("x.com.samsung.da.filterCapacity"))
if used is None or not cap:
return None
return round(used / cap * 100)
def normalize_temp_unit(raw, default='°F'):
def normalize_temp_unit(raw, default="°F"):
"""'C'/'Celsius' -> '°C', 'F'/'Fahrenheit' -> '°F'. Falls back to
`default` for any other/missing value. Shared by fridge.py and oven.py,
both of which read a per-device unit off a `/temperature*` resource
instead of assuming one (see fridge.py's module docstring, issue #7)."""
raw = (raw or '').strip().upper()
if raw.startswith('C'):
return '°C'
if raw.startswith('F'):
return '°F'
raw = (raw or "").strip().upper()
if raw.startswith("C"):
return "°C"
if raw.startswith("F"):
return "°F"
return default
@@ -112,15 +117,15 @@ def _active_alarm_codes(items):
the same rule to the whole '_OFF' suffix convention.
"""
if not items or not isinstance(items, list):
return 'none'
return "none"
codes = [
i.get('x.com.samsung.da.code')
i.get("x.com.samsung.da.code")
for i in items
if i.get('x.com.samsung.da.code')
and str(i.get('x.com.samsung.da.state', '')).lower() != 'deleted'
and not str(i.get('x.com.samsung.da.code', '')).lower().endswith('_off')
if i.get("x.com.samsung.da.code")
and str(i.get("x.com.samsung.da.state", "")).lower() != "deleted"
and not str(i.get("x.com.samsung.da.code", "")).lower().endswith("_off")
]
return ', '.join(codes) if codes else 'none'
return ", ".join(codes) if codes else "none"
def merge_options_field(cached, new_tokens):
@@ -137,12 +142,12 @@ def merge_options_field(cached, new_tokens):
body it applies straight to the cache no longer carries them."""
merged = list(cached or [])
for token in new_tokens or ():
if not isinstance(token, str) or '_' not in token:
if not isinstance(token, str) or "_" not in token:
continue
prefix = token.split('_', 1)[0]
prefix = token.split("_", 1)[0]
replaced = False
for i, o in enumerate(merged):
if isinstance(o, str) and o.startswith(prefix + '_'):
if isinstance(o, str) and o.startswith(prefix + "_"):
merged[i] = token
replaced = True
if not replaced:
@@ -168,9 +173,9 @@ def merge_items_field(cached, new_items):
for new_item in new_items or ():
if not isinstance(new_item, dict):
continue
item_id = new_item.get('x.com.samsung.da.id')
item_id = new_item.get("x.com.samsung.da.id")
for i, existing in enumerate(merged):
if isinstance(existing, dict) and existing.get('x.com.samsung.da.id') == item_id:
if isinstance(existing, dict) and existing.get("x.com.samsung.da.id") == item_id:
merged[i] = {**existing, **new_item}
break
else:
@@ -182,9 +187,9 @@ def merge_items_field(cached, new_items):
# washers, dryers, and dishwashers; absent on fridge/oven/AC. Static for the
# life of a given board, so reading them from the /device/0 seed (no dedicated
# poll_tier) is enough.
_SETINFO_HREF = '/wm/setinfo/vs/0'
_POWER_ON_OFF_FIELD = 'x.com.samsung.da.isModelSettingPowerOnOff'
_WITHOUT_SC_FIELD = 'x.com.samsung.da.isModelSettingWithoutSC'
_SETINFO_HREF = "/wm/setinfo/vs/0"
_POWER_ON_OFF_FIELD = "x.com.samsung.da.isModelSettingPowerOnOff"
_WITHOUT_SC_FIELD = "x.com.samsung.da.isModelSettingWithoutSC"
def model_allows_power_on_off(resources: dict) -> bool:
@@ -201,7 +206,7 @@ def model_allows_power_on_off(resources: dict) -> bool:
flag = setinfo.get(_POWER_ON_OFF_FIELD)
if flag is None:
return True
return str(flag).lower() != 'false'
return str(flag).lower() != "false"
def model_setting_without_sc(resources: dict) -> bool:
@@ -213,7 +218,7 @@ def model_setting_without_sc(resources: dict) -> bool:
the flag name is settings-specific, not a blanket remote-control bypass.
"""
setinfo = resources.get(_SETINFO_HREF) or {}
return str(setinfo.get(_WITHOUT_SC_FIELD, '')).lower() == 'true'
return str(setinfo.get(_WITHOUT_SC_FIELD, "")).lower() == "true"
def _power_switch_exists(rep, resources):
@@ -234,9 +239,9 @@ def sensor_item_value(items, sensor_type, index=0):
for item in items or ():
if not isinstance(item, dict):
continue
if item.get('x.com.samsung.da.type') != sensor_type:
if item.get("x.com.samsung.da.type") != sensor_type:
continue
values = item.get('x.com.samsung.da.value') or ()
values = item.get("x.com.samsung.da.value") or []
if index < len(values):
try:
return int(values[index])
@@ -259,48 +264,60 @@ def sensor_item_value(items, sensor_type, index=0):
# choice. Every device registry lists both caps of each pair.
POWER_GENERIC = Capability(
href='/power/0',
href="/power/0",
# Neither href of this pair carried a poll_tier before (issue #56's
# follow-up), so power state only ever refreshed on the once-per-30s
# summary poll instead of the subscribe/subpoll cadence 'warm' and 'hot'
# hrefs get -- the same "signal drives real-time state, but sat in the
# slow default tier" gap as REMOTE_CONTROL_GENERIC/VS_FALLBACK above.
poll_tier='warm',
poll_tier="warm",
entities=(
# Writable when firmware allows remote power; otherwise a read-only
# binary_sensor with the same key keeps HA state without a dead switch.
SwitchDesc(key='power_switch', field='value',
value_fn=lambda v: bool(v),
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
['power', '0'], {'value': p == 'On'})),
BinarySensorDesc(key='power_switch', field='value',
device_class='power',
value_fn=lambda v: bool(v),
exists_fn=_power_sensor_exists),
SwitchDesc(
key="power_switch",
field="value",
value_fn=lambda v: bool(v),
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (["power", "0"], {"value": p == "On"}),
),
BinarySensorDesc(
key="power_switch",
field="value",
device_class="power",
value_fn=lambda v: bool(v),
exists_fn=_power_sensor_exists,
),
),
)
POWER_VS_FALLBACK = Capability(
href='/power/vs/0',
match_fn=lambda rep, resources: '/power/0' not in resources,
poll_tier='warm',
href="/power/vs/0",
match_fn=lambda rep, resources: "/power/0" not in resources,
poll_tier="warm",
entities=(
SwitchDesc(key='power_switch', field='x.com.samsung.da.power',
value_fn=lambda v: v == 'On',
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
BinarySensorDesc(key='power_switch', field='x.com.samsung.da.power',
device_class='power',
value_fn=lambda v: v == 'On',
exists_fn=_power_sensor_exists),
SwitchDesc(
key="power_switch",
field="x.com.samsung.da.power",
value_fn=lambda v: v == "On",
exists_fn=_power_switch_exists,
write_fn=lambda p, rep, href=None: (
["power", "vs", "0"],
{"x.com.samsung.da.power": "On" if p == "On" else "Off"},
),
),
BinarySensorDesc(
key="power_switch",
field="x.com.samsung.da.power",
device_class="power",
value_fn=lambda v: v == "On",
exists_fn=_power_sensor_exists,
),
),
)
KIDS_LOCK_GENERIC = Capability(
href='/kidslock/0',
href="/kidslock/0",
entities=(
# Read-only like KIDS_LOCK_VS_FALLBACK (issues #181/#183) -- not a
# SwitchDesc. SwitchDesc's `device_class='lock'` was never honored
@@ -312,15 +329,15 @@ KIDS_LOCK_GENERIC = Capability(
# value_fn here (and in the fallback below) keeps the on-the-wire
# truth (value=False on /kidslock/0, kidsLock='Ready' on /kidslock/vs/0
# both mean kids lock NOT active) consistent with that polarity.
BinarySensorDesc(key='child_lock', field='value',
device_class='lock',
value_fn=lambda v: not bool(v)),
BinarySensorDesc(
key="child_lock", field="value", device_class="lock", value_fn=lambda v: not bool(v)
),
),
)
KIDS_LOCK_VS_FALLBACK = Capability(
href='/kidslock/vs/0',
match_fn=lambda rep, resources: '/kidslock/0' not in resources,
href="/kidslock/vs/0",
match_fn=lambda rep, resources: "/kidslock/0" not in resources,
entities=(
# Read-only, not a SwitchDesc (issues #181/#183): the write side of
# this capability wrote 'Enable', a value no dump in the fixture
@@ -332,9 +349,12 @@ KIDS_LOCK_VS_FALLBACK = Capability(
# hardware, not just wrong-valued. Polarity matches
# KIDS_LOCK_GENERIC above -- 'On' means open/unlocked, so
# kidsLock='Ready' (kids lock NOT active) renders as 'On'.
BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock',
device_class='lock',
value_fn=lambda v: v == 'Ready'),
BinarySensorDesc(
key="child_lock",
field="x.com.samsung.da.kidsLock",
device_class="lock",
value_fn=lambda v: v == "Ready",
),
),
)
@@ -350,12 +370,12 @@ def remote_control_enabled(resources: dict) -> bool:
once-per-30s cold summary poll. True (assume enabled) when neither
href is present -- most device types don't report this capability
at all."""
generic = resources.get('/remotectrl/0')
generic = resources.get("/remotectrl/0")
if generic is not None:
return bool(generic.get('value'))
fallback = resources.get('/remotectrl/vs/0')
return bool(generic.get("value"))
fallback = resources.get("/remotectrl/vs/0")
if fallback is not None:
return str(fallback.get('x.com.samsung.da.remoteControlEnabled')).lower() == 'true'
return str(fallback.get("x.com.samsung.da.remoteControlEnabled")).lower() == "true"
return True
@@ -369,39 +389,48 @@ def remote_control_required_for_write(resources: dict, bound_href: str) -> bool:
"""
if not model_setting_without_sc(resources):
return True
href = bound_href or ''
return href.startswith('/operational/state')
href = bound_href or ""
return href.startswith("/operational/state")
REMOTE_CONTROL_GENERIC = Capability(
href='/remotectrl/0',
poll_tier='warm',
href="/remotectrl/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='remote_control', field='value',
device_class='connectivity',
value_fn=lambda v: bool(v)),
BinarySensorDesc(
key="remote_control",
field="value",
device_class="connectivity",
value_fn=lambda v: bool(v),
),
),
)
REMOTE_CONTROL_VS_FALLBACK = Capability(
href='/remotectrl/vs/0',
match_fn=lambda rep, resources: '/remotectrl/0' not in resources,
poll_tier='warm',
href="/remotectrl/vs/0",
match_fn=lambda rep, resources: "/remotectrl/0" not in resources,
poll_tier="warm",
entities=(
BinarySensorDesc(key='remote_control',
field='x.com.samsung.da.remoteControlEnabled',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'true'),
BinarySensorDesc(
key="remote_control",
field="x.com.samsung.da.remoteControlEnabled",
device_class="connectivity",
value_fn=lambda v: str(v).lower() == "true",
),
),
)
ALARMS = Capability(
href='/alarms/vs/0',
poll_tier='hot',
href="/alarms/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='alarm_code', field='x.com.samsung.da.items',
icon='mdi:alert',
entity_category='diagnostic', value_fn=_active_alarm_codes),
SensorDesc(
key="alarm_code",
field="x.com.samsung.da.items",
icon="mdi:alert",
entity_category="diagnostic",
value_fn=_active_alarm_codes,
),
),
)
@@ -413,10 +442,10 @@ ALARMS = Capability(
# fridge's 93 W) still shows it (issue #6). cumulativePower is absent on at
# least one washer model; the exists_fn makes that explicit rather than relying
# on the generic field-presence gate.
_DEAD_INSTANTANEOUS_POWER = '-500'
_DEAD_INSTANTANEOUS_POWER = "-500"
ENERGY_METER = Capability(
href='/energy/consumption/vs/0',
href="/energy/consumption/vs/0",
entities=(
# `is_stub_rep(rep)` keeps the stub carve-out (see entity._is_included):
# an explicit exists_fn otherwise bypasses it, which would drop the
@@ -425,75 +454,123 @@ ENERGY_METER = Capability(
# answer, so it falls through to the normal field/sentinel checks like
# any populated rep. On a populated rep, hide power only for the dead
# sentinel or an absent field.
SensorDesc(key='power_watts', field='x.com.samsung.da.instantaneousPower',
device_class='power', state_class='measurement',
unit='W', value_fn=clamp_power,
exists_fn=lambda rep, resources: is_stub_rep(rep) or (
rep.get('x.com.samsung.da.instantaneousPower')
not in (None, _DEAD_INSTANTANEOUS_POWER))),
SensorDesc(key='energy_kwh', field='x.com.samsung.da.cumulativePower',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.cumulativePower' in rep)),
SensorDesc(
key="power_watts",
field="x.com.samsung.da.instantaneousPower",
device_class="power",
state_class="measurement",
unit="W",
value_fn=clamp_power,
exists_fn=lambda rep, resources: (
is_stub_rep(rep)
or (
rep.get("x.com.samsung.da.instantaneousPower")
not in (None, _DEAD_INSTANTANEOUS_POWER)
)
),
),
SensorDesc(
key="energy_kwh",
field="x.com.samsung.da.cumulativePower",
device_class="energy",
state_class="total_increasing",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.cumulativePower" in rep
),
),
# cumulativeConsumption is a second, independently-varying running
# total alongside cumulativePower -- some fridges (issue #26) report
# both. Self-gates off where only cumulativePower is present. The
# `is_stub_rep(rep) or` keeps the same stub carve-out as power_watts/
# energy_kwh above -- without it, an exists_fn permanently drops the
# entity if setup happens to land on a not-yet-fetched stub.
SensorDesc(key='power_energy_kwh', field='x.com.samsung.da.cumulativeConsumption',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeConsumption' in rep)),
SensorDesc(
key="power_energy_kwh",
field="x.com.samsung.da.cumulativeConsumption",
device_class="energy",
state_class="total_increasing",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.cumulativeConsumption" in rep
),
),
# AI Energy Mode's lifetime savings estimate vs. an unoptimized
# baseline -- present on some models (e.g. TP1X_REF_21K, issue #21/
# #27) and absent on others (issue #20/#26), unlike cumulativePower.
SensorDesc(key='energy_saved_kwh', field='x.com.samsung.da.cumulativeSavedPower',
device_class='energy',
state_class='total_increasing', unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.cumulativeSavedPower' in rep)),
SensorDesc(
key="energy_saved_kwh",
field="x.com.samsung.da.cumulativeSavedPower",
device_class="energy",
state_class="total_increasing",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.cumulativeSavedPower" in rep
),
),
# Monthly billing-cycle totals -- the completed prior month and the
# in-progress current month. Not ever-increasing (each resets at
# month boundary), so no state_class.
SensorDesc(key='energy_last_month_kwh', field='x.com.samsung.da.monthlyConsumption',
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.monthlyConsumption' in rep)),
SensorDesc(key='energy_this_month_kwh', field='x.com.samsung.da.thismonthlyConsumption',
device_class='energy',
unit='kWh', value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.thismonthlyConsumption' in rep)),
SensorDesc(
key="energy_last_month_kwh",
field="x.com.samsung.da.monthlyConsumption",
device_class="energy",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.monthlyConsumption" in rep
),
),
SensorDesc(
key="energy_this_month_kwh",
field="x.com.samsung.da.thismonthlyConsumption",
device_class="energy",
unit="kWh",
value_fn=wh_to_kwh,
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.thismonthlyConsumption" in rep
),
),
),
)
WATER_METER = Capability(
href='/water/consumption/vs/0',
href="/water/consumption/vs/0",
entities=(
SensorDesc(key='water_liters', field='x.com.samsung.da.cumulativeWater',
device_class='water',
state_class='total_increasing', unit='L', icon='mdi:water',
value_fn=_ml_to_l),
SensorDesc(
key="water_liters",
field="x.com.samsung.da.cumulativeWater",
device_class="water",
state_class="total_increasing",
unit="L",
icon="mdi:water",
value_fn=_ml_to_l,
),
),
)
WATER_FILTER = Capability(
href='/filter/waterfilter/vs/0',
match_fn=lambda rep, _: rep.get('x.com.samsung.da.filterStatus', '').lower() != 'notused',
href="/filter/waterfilter/vs/0",
match_fn=lambda rep, _: rep.get("x.com.samsung.da.filterStatus", "").lower() != "notused",
entities=(
SensorDesc(key='filter_usage', field='x.com.samsung.da.filterUsage',
unit='%', state_class='measurement',
icon='mdi:filter'),
SensorDesc(key='filter_status', field='x.com.samsung.da.filterStatus',
icon='mdi:filter-check',
device_class='enum', options=('normal', 'wash', 'replace'),
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
)),
SensorDesc(
key="filter_usage",
field="x.com.samsung.da.filterUsage",
unit="%",
state_class="measurement",
icon="mdi:filter",
),
SensorDesc(
key="filter_status",
field="x.com.samsung.da.filterStatus",
icon="mdi:filter-check",
device_class="enum",
options=("normal", "wash", "replace"),
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
),
),
)
@@ -517,28 +594,28 @@ WATER_FILTER = Capability(
def _ai_energy_supported_levels(rep):
"""supportedAiLevel as a list -- a stray scalar (e.g. a string) must not
be len()-checked as if it were a list."""
sl = rep.get('supportedAiLevel')
sl = rep.get("supportedAiLevel")
return list(sl) if isinstance(sl, (list, tuple)) else []
def _ai_energy_level_options(resources):
rep = resources.get('/energy/ailevel/vs/0') or {}
return ['0', *_ai_energy_supported_levels(rep)]
rep = resources.get("/energy/ailevel/vs/0") or {}
return ["0", *_ai_energy_supported_levels(rep)]
def _ai_energy_level_write(p, rep, href=None):
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': p}
return ["energy", "ailevel", "vs", "0"], {"aiLevel": p}
def _ai_energy_level_switch_write(p, rep, href=None):
levels = _ai_energy_supported_levels(rep)
on_level = levels[0] if levels else '1'
return ['energy', 'ailevel', 'vs', '0'], {'aiLevel': on_level if p == 'On' else '0'}
on_level = levels[0] if levels else "1"
return ["energy", "ailevel", "vs", "0"], {"aiLevel": on_level if p == "On" else "0"}
AI_ENERGY_LEVEL = Capability(
href='/energy/ailevel/vs/0',
poll_tier='cold',
href="/energy/ailevel/vs/0",
poll_tier="cold",
entities=(
# No is_stub_rep carve-out on either side, unlike most exists_fn
# gates in this file -- entity creation only ever runs once, against
@@ -556,60 +633,78 @@ AI_ENERGY_LEVEL = Capability(
# until a reload if the device's very first poll stubs this
# cold-tier href, the same reload already required to fix which
# platform got picked in that case.
SwitchDesc(key='ai_energy_level', field='aiLevel',
icon='mdi:leaf',
entity_category='config',
value_fn=lambda v: v != '0',
exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) == 1),
write_fn=_ai_energy_level_switch_write),
SelectDesc(key='ai_energy_level', field='aiLevel',
icon='mdi:leaf',
entity_category='config',
options=_ai_energy_level_options,
exists_fn=lambda rep, resources: (
len(_ai_energy_supported_levels(rep)) > 1),
write_fn=_ai_energy_level_write),
SwitchDesc(
key="ai_energy_level",
field="aiLevel",
icon="mdi:leaf",
entity_category="config",
value_fn=lambda v: v != "0",
exists_fn=lambda rep, resources: len(_ai_energy_supported_levels(rep)) == 1,
write_fn=_ai_energy_level_switch_write,
),
SelectDesc(
key="ai_energy_level",
field="aiLevel",
icon="mdi:leaf",
entity_category="config",
options=_ai_energy_level_options,
exists_fn=lambda rep, resources: len(_ai_energy_supported_levels(rep)) > 1,
write_fn=_ai_energy_level_write,
),
),
)
FIRMWARE_UPDATE = Capability(
href='/otninformation/vs/0',
poll_tier='cold',
href="/otninformation/vs/0",
poll_tier="cold",
entities=(
BinarySensorDesc(
key='firmware_update',
field='x.com.samsung.da.newVersionAvailable',
device_class='update',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true' if v is not None else None,
key="firmware_update",
field="x.com.samsung.da.newVersionAvailable",
device_class="update",
entity_category="diagnostic",
value_fn=lambda v: str(v).lower() == "true" if v is not None else None,
),
),
)
SELF_CHECK = Capability(
href='/selfcheck/vs/0',
poll_tier='cold',
href="/selfcheck/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='selfcheck_status', field='x.com.samsung.da.status',
icon='mdi:stethoscope',
entity_category='diagnostic'),
SensorDesc(key='selfcheck_result', field='x.com.samsung.da.result',
icon='mdi:clipboard-check-outline',
entity_category='diagnostic'),
SensorDesc(
key="selfcheck_status",
field="x.com.samsung.da.status",
icon="mdi:stethoscope",
entity_category="diagnostic",
),
SensorDesc(
key="selfcheck_result",
field="x.com.samsung.da.result",
icon="mdi:clipboard-check-outline",
entity_category="diagnostic",
),
# List of error codes from the last self-check; joined for display.
# Not every fridge reports the field, hence the exists_fn.
SensorDesc(key='selfcheck_error', field='x.com.samsung.da.error',
icon='mdi:alert-circle-outline',
entity_category='diagnostic',
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.error' in rep),
value_fn=lambda v: (', '.join(v) if v else None) if isinstance(v, list) else v),
ButtonDesc(key='selfcheck_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['selfcheck', 'vs', '0'], {'x.com.samsung.da.status': p})),
SensorDesc(
key="selfcheck_error",
field="x.com.samsung.da.error",
icon="mdi:alert-circle-outline",
entity_category="diagnostic",
exists_fn=lambda rep, resources: is_stub_rep(rep) or "x.com.samsung.da.error" in rep,
value_fn=lambda v: (", ".join(v) if v else None) if isinstance(v, list) else v,
),
ButtonDesc(
key="selfcheck_start",
field="",
payload="Start",
icon="mdi:play-circle-outline",
entity_category="diagnostic",
write_fn=lambda p, rep, href=None: (
["selfcheck", "vs", "0"],
{"x.com.samsung.da.status": p},
),
),
),
)
@@ -13,16 +13,15 @@ from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, SensorDesc
_INACTIVE_OPERATION_STATES = {'Off', 'Ready'}
_INACTIVE_OPERATION_STATES = {"Off", "Ready"}
def _option_value(options, prefix):
"""Return the value from the first ``<prefix>_<value>`` option."""
marker = prefix + '_'
marker = prefix + "_"
for option in options or ():
if isinstance(option, str) and option.startswith(marker):
return option[len(marker):]
return option[len(marker) :]
return None
@@ -32,7 +31,7 @@ def _operation_slots(options) -> tuple[int, ...]:
for option in options or ():
if not isinstance(option, str):
continue
match = re.match(r'^OperationState(\d+)_', option)
match = re.match(r"^OperationState(\d+)_", option)
if match:
slots.add(int(match.group(1)))
return tuple(sorted(slots))
@@ -40,10 +39,7 @@ def _operation_slots(options) -> tuple[int, ...]:
def _any_burner_active(options):
"""True when any advertised burner slot is not idle."""
states = [
_option_value(options, f'OperationState{slot}')
for slot in _operation_slots(options)
]
states = [_option_value(options, f"OperationState{slot}") for slot in _operation_slots(options)]
states = [state for state in states if state is not None]
return any(state not in _INACTIVE_OPERATION_STATES for state in states)
@@ -56,15 +52,15 @@ def _int_or_none(value):
COOKTOP_POWER = Capability(
href='/power/vs/0',
poll_tier='hot',
href="/power/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(
key='power_state',
field='x.com.samsung.da.power',
device_class='power',
icon='mdi:stove',
value_fn=lambda value: str(value).lower() == 'on',
key="power_state",
field="x.com.samsung.da.power",
device_class="power",
icon="mdi:stove",
value_fn=lambda value: str(value).lower() == "on",
),
),
)
@@ -77,109 +73,107 @@ COOKTOP_POWER = Capability(
_SUPPORTED_OPERATION_SLOTS = tuple(range(8))
COOKTOP_MODE = Capability(
href='/mode/vs/0',
poll_tier='hot',
href="/mode/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(
key='any_burner_active',
field='x.com.samsung.da.options',
device_class='running',
icon='mdi:fire',
key="any_burner_active",
field="x.com.samsung.da.options",
device_class="running",
icon="mdi:fire",
value_fn=_any_burner_active,
),
*(
SensorDesc(
key=f'burner_{slot}_state',
field='x.com.samsung.da.options',
translation_key='burner_state',
translation_placeholders={'number': str(slot)},
icon='mdi:gas-burner',
value_fn=lambda options, slot=slot: _option_value(
options, f'OperationState{slot}'
),
key=f"burner_{slot}_state",
field="x.com.samsung.da.options",
translation_key="burner_state",
translation_placeholders={"number": str(slot)},
icon="mdi:gas-burner",
value_fn=lambda options, slot=slot: _option_value(options, f"OperationState{slot}"),
exists_fn=lambda rep, resources, slot=slot: (
is_stub_rep(rep) or _option_value(
rep.get('x.com.samsung.da.options'),
f'OperationState{slot}',
) is not None
is_stub_rep(rep)
or _option_value(
rep.get("x.com.samsung.da.options"),
f"OperationState{slot}",
)
is not None
),
)
for slot in _SUPPORTED_OPERATION_SLOTS
),
SensorDesc(
key='main_timer_state',
field='x.com.samsung.da.options',
icon='mdi:timer-outline',
value_fn=lambda options: _option_value(options, 'MainTimerState'),
key="main_timer_state",
field="x.com.samsung.da.options",
icon="mdi:timer-outline",
value_fn=lambda options: _option_value(options, "MainTimerState"),
),
SensorDesc(
key='main_timer_current',
field='x.com.samsung.da.options',
icon='mdi:timer-sand',
key="main_timer_current",
field="x.com.samsung.da.options",
icon="mdi:timer-sand",
enabled_default=False,
value_fn=lambda options: _int_or_none(
_option_value(options, 'MainTimerCurrent')
),
value_fn=lambda options: _int_or_none(_option_value(options, "MainTimerCurrent")),
),
),
)
COOKTOP_CONNECTED = Capability(
href='/connected/vs/0',
poll_tier='warm',
href="/connected/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key='cloud_connected',
field='x.com.samsung.da.connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
key="cloud_connected",
field="x.com.samsung.da.connected",
device_class="connectivity",
entity_category="diagnostic",
value_fn=lambda value: str(value).lower() == "on",
),
),
)
PAIRED_HOOD_STATUS = Capability(
href='/bluetooth/hood/status/vs/0',
poll_tier='hot',
href="/bluetooth/hood/status/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(
key='paired_hood_connected',
field='connectionState',
device_class='connectivity',
value_fn=lambda value: str(value).lower() == 'connected',
key="paired_hood_connected",
field="connectionState",
device_class="connectivity",
value_fn=lambda value: str(value).lower() == "connected",
),
BinarySensorDesc(
key='paired_hood_power',
field='power',
device_class='running',
value_fn=lambda value: str(value).lower() == 'on',
key="paired_hood_power",
field="power",
device_class="running",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='paired_hood_fan_speed',
field='fanSpeed',
icon='mdi:fan',
key="paired_hood_fan_speed",
field="fanSpeed",
icon="mdi:fan",
value_fn=_int_or_none,
),
BinarySensorDesc(
key='paired_hood_light',
field='lampState',
device_class='light',
value_fn=lambda value: str(value).lower() == 'on',
key="paired_hood_light",
field="lampState",
device_class="light",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='paired_hood_model',
field='micomModelId',
icon='mdi:information-outline',
entity_category='diagnostic',
key="paired_hood_model",
field="micomModelId",
icon="mdi:information-outline",
entity_category="diagnostic",
enabled_default=False,
),
SensorDesc(
key='paired_hood_firmware',
field='firmwareVersion',
icon='mdi:chip',
entity_category='diagnostic',
key="paired_hood_firmware",
field="firmwareVersion",
icon="mdi:chip",
entity_category="diagnostic",
enabled_default=False,
),
),
@@ -7,6 +7,7 @@ humidity -- not temperature -- is this device's primary control, and there
is no climate composite: power, mode, and humidity are exposed as separate
entities rather than folded into one card.
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc
from .common import int_or_none
@@ -16,21 +17,26 @@ def _first_mode(rep):
"""Representative scalar for the operating-mode select. `modes` is a
single-element list on every dump seen so far, mirroring
airconditioner._first_mode's handling of the same field shape."""
modes = rep.get('x.com.samsung.da.modes')
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
SelectDesc(key='operating_mode', rep_fn=_first_mode,
icon='mdi:tune-variant',
options_field='x.com.samsung.da.supportedModes',
write_fn=lambda p, rep, href=None: (
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
SelectDesc(
key="operating_mode",
rep_fn=_first_mode,
icon="mdi:tune-variant",
options_field="x.com.samsung.da.supportedModes",
write_fn=lambda p, rep, href=None: (
["mode", "vs", "0"],
{"x.com.samsung.da.modes": [p]},
),
),
),
)
@@ -43,21 +49,31 @@ MODE = Capability(
# dump's values" section). Step comes live from the device's own `increment`
# field.
HUMIDITY = Capability(
href='/humidity/vs/0',
poll_tier='warm',
href="/humidity/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='humidity', field='x.com.samsung.da.humidity',
device_class='humidity', unit='%', state_class='measurement',
value_fn=int_or_none),
NumberDesc(key='target_humidity', field='x.com.samsung.da.desiredHumidity',
device_class='humidity', unit='%',
icon='mdi:water-percent',
entity_category='config',
value_fn=int_or_none,
step_fn=lambda rep: int_or_none(rep.get('increment')) or 1,
write_fn=lambda p, rep, href=None: (
['humidity', 'vs', '0'],
{'x.com.samsung.da.desiredHumidity': str(int(round(float(p))))})),
SensorDesc(
key="humidity",
field="x.com.samsung.da.humidity",
device_class="humidity",
unit="%",
state_class="measurement",
value_fn=int_or_none,
),
NumberDesc(
key="target_humidity",
field="x.com.samsung.da.desiredHumidity",
device_class="humidity",
unit="%",
icon="mdi:water-percent",
entity_category="config",
value_fn=int_or_none,
step_fn=lambda rep: int_or_none(rep.get("increment")) or 1,
write_fn=lambda p, rep, href=None: (
["humidity", "vs", "0"],
{"x.com.samsung.da.desiredHumidity": str(round(float(p)))},
),
),
),
)
@@ -69,17 +85,17 @@ HUMIDITY = Capability(
# families' schemas.
# ---------------------------------------------------------------------------
_DHM_IGNORED = [
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: DHM)
'/da/softreset/vs/0', # soft-reset trigger plumbing
'/keepnormalstate/vs/0', # internal keep-normal flag
'/personality/presence/vs/0', # presence-personalization plumbing (empty item value)
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/sensors/vs/0', # empty {} on this dump
'/welcome/humidity/vs/0', # welcome-mode plumbing (requestId/operatingStatus, inert)
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: DHM)
"/da/softreset/vs/0", # soft-reset trigger plumbing
"/keepnormalstate/vs/0", # internal keep-normal flag
"/personality/presence/vs/0", # presence-personalization plumbing (empty item value)
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
"/sensors/vs/0", # empty {} on this dump
"/welcome/humidity/vs/0", # welcome-mode plumbing (requestId/operatingStatus, inert)
# Only supportedModes ([Off, Sleep]) is present -- no live "current
# value" field on this dump to confirm the read/write contract, so per
# the 'don't guess' rule this is left unmodeled rather than assumed.
'/mode/convenient/vs/0',
"/mode/convenient/vs/0",
]
COVERAGE = [Capability(href=h) for h in _DHM_IGNORED]
@@ -6,6 +6,7 @@ The /course/vs/0 cycle select and its options-array machinery are shared with
washer and dryer in laundry.py; only the dishwasher-specific options (storm
wash, auto release dry) are read locally here.
"""
from ..capability import Capability
from ..entities import ButtonDesc, SelectDesc, SensorDesc, SwitchDesc
from .laundry import bool_option_switch, cycle_select
@@ -15,20 +16,28 @@ from .laundry import bool_option_switch, cycle_select
# ---------------------------------------------------------------------------
DISHWASHER_SETTINGS = Capability(
href='/dishwasher/vs/0',
href="/dishwasher/vs/0",
entities=(
SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize',
icon='mdi:bacteria',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.sanitize': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='heated_dry', field='x.com.samsung.da.heatedDry',
icon='mdi:heat-wave',
options_field='x.com.samsung.da.supportedHeatedDry',
write_fn=lambda p, rep, href=None: (
['dishwasher', 'vs', '0'],
{'x.com.samsung.da.heatedDry': p})),
SwitchDesc(
key="sanitize",
field="x.com.samsung.da.sanitize",
icon="mdi:bacteria",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["dishwasher", "vs", "0"],
{"x.com.samsung.da.sanitize": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="heated_dry",
field="x.com.samsung.da.heatedDry",
icon="mdi:heat-wave",
options_field="x.com.samsung.da.supportedHeatedDry",
write_fn=lambda p, rep, href=None: (
["dishwasher", "vs", "0"],
{"x.com.samsung.da.heatedDry": p},
),
),
),
)
@@ -42,13 +51,13 @@ DISHWASHER_SETTINGS = Capability(
# ---------------------------------------------------------------------------
CYCLE_OPTIONS = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='dishwasher_cycle', icon='mdi:dishwasher'),
bool_option_switch('storm_wash', 'mdi:weather-lightning-rainy',
'StormWashZone'),
bool_option_switch('auto_release_dry', 'mdi:door-open',
'AutoDoorRelease', gate_on_presence=True),
cycle_select(translation_key="dishwasher_cycle", icon="mdi:dishwasher"),
bool_option_switch("storm_wash", "mdi:weather-lightning-rainy", "StormWashZone"),
bool_option_switch(
"auto_release_dry", "mdi:door-open", "AutoDoorRelease", gate_on_presence=True
),
),
)
@@ -57,26 +66,35 @@ CYCLE_OPTIONS = Capability(
# ---------------------------------------------------------------------------
DIAGNOSIS = Capability(
href='/diagnosis/vs/0',
poll_tier='cold',
href="/diagnosis/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='diagnosis_status', field='x.com.samsung.da.diagnosisStart',
icon='mdi:stethoscope',
entity_category='diagnostic'),
ButtonDesc(key='diagnosis_start', field='', payload='Start',
icon='mdi:play-circle-outline',
entity_category='diagnostic',
write_fn=lambda p, rep, href=None: (
['diagnosis', 'vs', '0'], {'x.com.samsung.da.diagnosisStart': p})),
SensorDesc(
key="diagnosis_status",
field="x.com.samsung.da.diagnosisStart",
icon="mdi:stethoscope",
entity_category="diagnostic",
),
ButtonDesc(
key="diagnosis_start",
field="",
payload="Start",
icon="mdi:play-circle-outline",
entity_category="diagnostic",
write_fn=lambda p, rep, href=None: (
["diagnosis", "vs", "0"],
{"x.com.samsung.da.diagnosisStart": p},
),
),
),
)
OPERATION_ORIGIN = Capability(
href='/operation/origin/vs/0',
poll_tier='cold',
href="/operation/origin/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='operation_origin', field='origin',
icon='mdi:remote',
entity_category='diagnostic'),
SensorDesc(
key="operation_origin", field="origin", icon="mdi:remote", entity_category="diagnostic"
),
),
)
@@ -8,31 +8,32 @@ the /course/vs/0 cycle select -- lives in laundry.py.
/course/vs/0 -> DRYER_COURSE (shared cycle select; see below)
/diagnosis/vs/0 -> DRYER_DIAGNOSIS
"""
from ..capability import Capability
from ..entities import SensorDesc, SwitchDesc
from .laundry import cycle_select
def _wrinkle_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
return ["washer", "vs", "0"], {"x.com.samsung.da.wrinklePrevent": p}
DRYER_SETTINGS = Capability(
href='/washer/vs/0',
poll_tier='warm',
href="/washer/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
icon='mdi:water-percent'),
SensorDesc(key='dry_time', field='x.com.samsung.da.dryTime',
icon='mdi:timer'),
SensorDesc(key='dryer_type', field='x.com.samsung.da.dryerType',
icon='mdi:tumble-dryer'),
SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent',
icon='mdi:iron',
value_fn=lambda v: v == 'On',
write_fn=_wrinkle_write),
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
SensorDesc(key="dryer_type", field="x.com.samsung.da.dryerType", icon="mdi:tumble-dryer"),
SwitchDesc(
key="wrinkle_prevent",
field="x.com.samsung.da.wrinklePrevent",
icon="mdi:iron",
value_fn=lambda v: v == "On",
write_fn=_wrinkle_write,
),
),
)
@@ -49,18 +50,22 @@ DRYER_SETTINGS = Capability(
# course and is ignored (ignored.py) -- the mirror of how /st/washercourse/vs/0
# is ignored for washers.
DRYER_COURSE = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer',
table_href='/st/dryercourse/vs/0'),
cycle_select(
translation_key="dryer_cycle",
icon="mdi:tumble-dryer",
table_href="/st/dryercourse/vs/0",
),
),
)
DRYER_DIAGNOSIS = Capability(
href='/diagnosis/vs/0',
poll_tier='warm',
href="/diagnosis/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='diagnosis', field='x.com.samsung.da.diagnosisStart',
entity_category='diagnostic'),
SensorDesc(
key="diagnosis", field="x.com.samsung.da.diagnosisStart", entity_category="diagnostic"
),
),
)
@@ -20,6 +20,7 @@ airconditioner.py's CLIMATE/climate.py.
Verified against a real TP1X_DA_AC_EHS_01001_0000 diagnostics dump
(firmware AEH-WW-TP1-22-AE6000_17260402, TizenRT 3.1 / DAWIT 2.0).
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, WaterHeaterDesc
from .common import normalize_temp_unit
@@ -36,14 +37,14 @@ def _first_mode(rep):
"""Representative scalar for a mode select -- `modes` is a single-element
list on every dump seen so far, mirroring airconditioner._first_mode /
dehumidifier._first_mode's handling of the same field shape."""
modes = rep.get('x.com.samsung.da.modes')
modes = rep.get("x.com.samsung.da.modes")
if isinstance(modes, (list, tuple)):
return modes[0] if modes else None
return modes
def _temp_unit(rep):
return normalize_temp_unit(rep.get('x.com.samsung.da.unit'), '°C')
return normalize_temp_unit(rep.get("x.com.samsung.da.unit"), "°C")
def _bounds(rep, default_min, default_max):
@@ -55,39 +56,48 @@ def _bounds(rep, default_min, default_max):
climate._range()/water_heater._range(), and the same reason
oven._setpoint_bounds resolves its pair in one place.
"""
lo = _num(rep.get('x.com.samsung.da.minimum'))
hi = _num(rep.get('x.com.samsung.da.maximum'))
lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum"))
return (lo, hi) if (lo is not None and hi is not None) else (default_min, default_max)
def _step(rep, default):
"""`is None`, not `or` -- `or` collapses a genuine 0 (issue #160)."""
step = _num(rep.get('x.com.samsung.da.increment'))
step = _num(rep.get("x.com.samsung.da.increment"))
return default if step is None else step
ZONE_POWER = Capability(
href='/power/vs/0',
poll_tier='warm',
href="/power/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='zone_power', field='x.com.samsung.da.power',
icon='mdi:radiator',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['power', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if p == 'On' else 'Off'})),
SwitchDesc(
key="zone_power",
field="x.com.samsung.da.power",
icon="mdi:radiator",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["power", "vs", "0"],
{"x.com.samsung.da.power": "On" if p == "On" else "Off"},
),
),
),
)
ZONE_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
SelectDesc(key='zone_mode', rep_fn=_first_mode,
icon='mdi:sun-snowflake-variant',
options_field='x.com.samsung.da.supportedModes',
write_fn=lambda p, rep, href=None: (
['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]})),
SelectDesc(
key="zone_mode",
rep_fn=_first_mode,
icon="mdi:sun-snowflake-variant",
options_field="x.com.samsung.da.supportedModes",
write_fn=lambda p, rep, href=None: (
["mode", "vs", "0"],
{"x.com.samsung.da.modes": [p]},
),
),
),
)
@@ -95,21 +105,32 @@ ZONE_MODE = Capability(
# room setpoint, not a literal water temperature -- Samsung EHS zone control
# is leaving-water-temperature-based, same convention as the dhw loop below.
ZONE_TEMPERATURE = Capability(
href='/temperatures/indoor/vs/0',
poll_tier='warm',
href="/temperatures/indoor/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='zone_temperature', field='x.com.samsung.da.current',
device_class='temperature', unit_fn=_temp_unit,
state_class='measurement', value_fn=_num),
NumberDesc(key='zone_target_temperature', field='x.com.samsung.da.desired',
device_class='temperature', unit_fn=_temp_unit,
entity_category='config', value_fn=_num,
native_min_fn=lambda rep: _bounds(rep, 5.0, 30.0)[0],
native_max_fn=lambda rep: _bounds(rep, 5.0, 30.0)[1],
step_fn=lambda rep: _step(rep, 0.5),
write_fn=lambda p, rep, href=None: (
['temperatures', 'indoor', 'vs', '0'],
{'x.com.samsung.da.desired': str(float(p))})),
SensorDesc(
key="zone_temperature",
field="x.com.samsung.da.current",
device_class="temperature",
unit_fn=_temp_unit,
state_class="measurement",
value_fn=_num,
),
NumberDesc(
key="zone_target_temperature",
field="x.com.samsung.da.desired",
device_class="temperature",
unit_fn=_temp_unit,
entity_category="config",
value_fn=_num,
native_min_fn=lambda rep: _bounds(rep, 5.0, 30.0)[0],
native_max_fn=lambda rep: _bounds(rep, 5.0, 30.0)[1],
step_fn=lambda rep: _step(rep, 0.5),
write_fn=lambda p, rep, href=None: (
["temperatures", "indoor", "vs", "0"],
{"x.com.samsung.da.desired": str(float(p))},
),
),
),
)
@@ -119,9 +140,9 @@ ZONE_TEMPERATURE = Capability(
# airconditioner.py's HREF_MODE/CLIMATE_CONSUMED_HREFS. Declared once here
# and imported by water_heater.py, so a new sibling read can't drift out of
# sync with its DHW_CONSUMED_HREFS coverage entry below.
HREF_DHW_POWER = '/power/dhw/vs/0' # on/off
HREF_DHW_MODE = '/mode/dhw/vs/0' # primary (bound by DHW) -- current_operation
HREF_DHW_TEMPERATURE = '/temperatures/dhw/vs/0' # current/target temperature
HREF_DHW_POWER = "/power/dhw/vs/0" # on/off
HREF_DHW_MODE = "/mode/dhw/vs/0" # primary (bound by DHW) -- current_operation
HREF_DHW_TEMPERATURE = "/temperatures/dhw/vs/0" # current/target temperature
DHW_CONSUMED_HREFS = [HREF_DHW_POWER, HREF_DHW_TEMPERATURE]
@@ -132,30 +153,29 @@ def _dhw_write(payload, rep, href=None):
airconditioner._climate_write, just across the dhw loop's three
resources instead of the AC's power/mode/temperature/wind set."""
kind, value = payload
if kind == 'power':
return (['power', 'dhw', 'vs', '0'],
{'x.com.samsung.da.power': 'On' if value else 'Off'})
if kind == 'mode':
return (['mode', 'dhw', 'vs', '0'], {'x.com.samsung.da.modes': [value]})
if kind == 'temperature':
return (['temperatures', 'dhw', 'vs', '0'],
{'x.com.samsung.da.desired': str(float(value))})
if kind == "power":
return (["power", "dhw", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"})
if kind == "mode":
return (["mode", "dhw", "vs", "0"], {"x.com.samsung.da.modes": [value]})
if kind == "temperature":
return (["temperatures", "dhw", "vs", "0"], {"x.com.samsung.da.desired": str(float(value))})
return None
DHW = Capability(
href=HREF_DHW_MODE,
poll_tier='warm',
poll_tier="warm",
entities=(
WaterHeaterDesc(key='water_heater', translation_key='dhw',
rep_fn=_first_mode, write_fn=_dhw_write),
WaterHeaterDesc(
key="water_heater", translation_key="dhw", rep_fn=_first_mode, write_fn=_dhw_write
),
),
)
# Power and temperature are read by the composite DHW entity above, not
# given their own entities -- coverage-only caps so discover() reports no
# gap (see airconditioner.py's CLIMATE_CONSUMED_HREFS for the same pattern).
DHW_CONSUMED = [Capability(href=h, poll_tier='warm') for h in DHW_CONSUMED_HREFS]
DHW_CONSUMED = [Capability(href=h, poll_tier="warm") for h in DHW_CONSUMED_HREFS]
# Deliberately a plain config switch, not water_heater's AWAY_MODE feature.
# HA core's smartthings water_heater does wire this same Samsung capability
@@ -168,16 +188,20 @@ DHW_CONSUMED = [Capability(href=h, poll_tier='warm') for h in DHW_CONSUMED_HREFS
# hot water. It stays a switch until a board turns up with a per-loop away
# resource to bind instead.
AWAY_MODE = Capability(
href='/option/outgoing/vs/0',
poll_tier='cold',
href="/option/outgoing/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='away_mode', field='x.com.samsung.da.away',
icon='mdi:home-export-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['option', 'outgoing', 'vs', '0'],
{'x.com.samsung.da.away': 'On' if p == 'On' else 'Off'})),
SwitchDesc(
key="away_mode",
field="x.com.samsung.da.away",
icon="mdi:home-export-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["option", "outgoing", "vs", "0"],
{"x.com.samsung.da.away": "On" if p == "On" else "Off"},
),
),
),
)
@@ -189,16 +213,16 @@ AWAY_MODE = Capability(
# would need their own verification on other device families.
# ---------------------------------------------------------------------------
_EHS_IGNORED = [
'/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap (id: EHS)
'/da/softreset/vs/0', # soft-reset trigger plumbing
'/diagnosis/vs/0', # empty {} on this dump
'/ehscycle/vs/0', # opaque hex-encoded indoor/outdoor cycle log
'/ehsfsv/vs/0', # opaque hex-encoded factory setting values
'/option/dhwdisplay/vs/0', # front-panel DHW-display show/hide, cosmetic only
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
'/sac/installationinfo/vs/0', # static outdoor/indoor installation info, diagnostic only
'/actions/zone1/vs/0', # zone1 schedule/timer program -- unmodeled for now
'/actions/dhw/vs/0', # DHW schedule/timer program -- unmodeled for now
"/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap (id: EHS)
"/da/softreset/vs/0", # soft-reset trigger plumbing
"/diagnosis/vs/0", # empty {} on this dump
"/ehscycle/vs/0", # opaque hex-encoded indoor/outdoor cycle log
"/ehsfsv/vs/0", # opaque hex-encoded factory setting values
"/option/dhwdisplay/vs/0", # front-panel DHW-display show/hide, cosmetic only
"/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob
"/sac/installationinfo/vs/0", # static outdoor/indoor installation info, diagnostic only
"/actions/zone1/vs/0", # zone1 schedule/timer program -- unmodeled for now
"/actions/dhw/vs/0", # DHW schedule/timer program -- unmodeled for now
]
COVERAGE = [Capability(href=h) for h in _EHS_IGNORED]
File diff suppressed because it is too large Load Diff
@@ -23,114 +23,106 @@ override. Grow it as real /device/0 dumps surface more universal noise —
do not add a href here on a guess. If a href's relevance is unclear, leave
it unbound so it surfaces as a gap for a human to look at.
"""
from ..capability import Capability
IGNORED: list[Capability] = [
# Device serial/model is read directly by the coordinator for HA device
# identity, not modeled as an entity capability.
Capability(href='/information/vs/0'),
Capability(href="/information/vs/0"),
# Bixby voice assistant: feature negotiation, account provisioning
# (Samsung account email, access tokens), terms-of-service state, and
# enable/disable status.
Capability(href='/voice/feature/vs/0'),
Capability(href='/voice/provisioning/vs/0'),
Capability(href='/bixby/vs/0'),
Capability(href='/bixby/status/vs/0'),
Capability(href='/bixbyuservalidate/vs/0'),
Capability(href='/bixbyterms/vs/0'),
Capability(href="/voice/feature/vs/0"),
Capability(href="/voice/provisioning/vs/0"),
Capability(href="/bixby/vs/0"),
Capability(href="/bixby/status/vs/0"),
Capability(href="/bixbyuservalidate/vs/0"),
Capability(href="/bixbyterms/vs/0"),
# Network/WiFi housekeeping — MAC addresses, supported auth/crypto
# types, no controllable or observable appliance state.
Capability(href='/wirelessinfo/vs/0'),
Capability(href='/connectionconfig/vs/0'),
Capability(href="/wirelessinfo/vs/0"),
Capability(href="/connectionconfig/vs/0"),
# Static or internal-protocol metadata, not entity-worthy.
Capability(href='/quickcontrol/info/vs/0'),
Capability(href='/realtimenotiforclient/vs/0'),
Capability(href='/file/information/vs/0'),
Capability(href='/configuration/vs/0'), # region/countryCode
Capability(href='/setting/vs/0'), # supported/selected UI language
Capability(href='/timezone/vs/0'), # redundant with HA's own timezone
Capability(href='/wm/setinfo/vs/0'), # model/manufacturing metadata
Capability(href="/quickcontrol/info/vs/0"),
Capability(href="/realtimenotiforclient/vs/0"),
Capability(href="/file/information/vs/0"),
Capability(href="/configuration/vs/0"), # region/countryCode
Capability(href="/setting/vs/0"), # supported/selected UI language
Capability(href="/timezone/vs/0"), # redundant with HA's own timezone
Capability(href="/wm/setinfo/vs/0"), # model/manufacturing metadata
# Resource-monitoring poll-interval config (a bare minPeriod in
# milliseconds, issue #165's TP1X_REF_21K fridge) -- internal transport
# plumbing, not appliance state.
Capability(href='/rm/control/vs/0'),
Capability(href="/rm/control/vs/0"),
# Demand Response Load Control — utility-company grid signals; requires
# cloud registration with a utility program we don't support locally.
Capability(href='/drlc/vs/0'),
Capability(href="/drlc/vs/0"),
# Redundant with capabilities already declared elsewhere.
# /speakersound/vs/0 duplicates /settings/sound/volume/vs/0 (laundry.SOUND_VOLUME).
Capability(href='/speakersound/vs/0'),
Capability(href="/speakersound/vs/0"),
# /wm/editcourse/vs/0 has no entities of its own -- x.com.samsung.da.
# editCourseList is read directly out of the resource snapshot by
# dishwasher.CYCLE_OPTIONS's and washer.WASHER_COURSE's cycle select
# (options=_cycle_options) to build that device's actual supported
# course list, rather than exposing this href's raw byte string
# through its own entity.
Capability(href='/wm/editcourse/vs/0'),
Capability(href="/wm/editcourse/vs/0"),
# Bixby audio feedback (chime + volume played when Bixby starts/stops
# listening) — only meaningful with Bixby enabled, which this
# integration has no local path to configure or use.
Capability(href='/sec/networkaudio/audio/vs/0'),
Capability(href="/sec/networkaudio/audio/vs/0"),
# Static Bespoke-product-line flag, not appliance state.
Capability(href='/bespoke/vs/0'),
Capability(href="/bespoke/vs/0"),
# Empty resource on every dump seen so far — nothing to expose.
Capability(href='/defrost/prediction/vs/0'),
Capability(href="/defrost/prediction/vs/0"),
# Seasonal defrost schedule (start/period/end per season). Automating
# this cleanly would need a multi-field schedule editor; the practical
# on/off control is fridge.DEFROST_DELAY.
Capability(href='/defrost/reservation/vs/0'),
Capability(href="/defrost/reservation/vs/0"),
# Warranty/service-plan enrollment status — every field reads "Unknown"
# on hardware not enrolled in a Samsung Care+ style program.
Capability(href='/dginformation/vs/0'),
Capability(href="/dginformation/vs/0"),
# OCF-native vacation-mode flag (fridge). Only one value ('RVACATION_OFF')
# has ever been seen in `modes` (issue #7's dump) -- no real choice to
# expose yet. Revisit if a device surfaces it toggled on.
Capability(href='/mode/0'),
Capability(href="/mode/0"),
# Opaque integer with no supportedModes/options list to interpret it
# against — meaning unclear from the raw resource alone.
Capability(href='/runningmode/vs/0'),
Capability(href="/runningmode/vs/0"),
# Demand-response energy planner — same utility-program dependency as
# /drlc/vs/0 above; every dump seen so far is inert (plan: 'none').
Capability(href='/energy/planner/vs/0'),
Capability(href="/energy/planner/vs/0"),
# Temperature-unit display preference, redundant with HA's own units.
Capability(href='/wm/submode/vs/0'),
Capability(href="/wm/submode/vs/0"),
# Read-only re-encoding of the course already exposed by
# washer.WASHER_COURSE at /course/vs/0 (x.com.samsung.da.st.washerMode
# is literally "Table_02_Course_<same hex code>").
Capability(href='/st/washercourse/vs/0'),
Capability(href="/st/washercourse/vs/0"),
# Dryer counterpart of the above: re-encoding of the course already
# exposed by dryer.DRYER_COURSE at /course/vs/0
# (x.com.samsung.da.st.dryerMode is "Table_03_Course_<same hex code>").
Capability(href='/st/dryercourse/vs/0'),
Capability(href="/st/dryercourse/vs/0"),
# AirDresser counterpart of the above (issue #157): read only for its
# courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no
# entity of its own -- same "no entity, just the table id" role as
# /st/washercourse/vs/0 and /st/dryercourse/vs/0.
Capability(href='/st/airdressercourse/vs/0'),
Capability(href="/st/airdressercourse/vs/0"),
# Empty on every washer dump seen so far.
Capability(href='/wm/welcomemsg/vs/0'),
Capability(href="/wm/welcomemsg/vs/0"),
# User-saved custom course slots (F1-FA). No controllable/observable
# state without a multi-slot editor; revisit if that becomes valuable.
Capability(href='/wm/personalcourse/vs/0'),
Capability(href="/wm/personalcourse/vs/0"),
# OCF-native energy resource is empty ({}) on washer hardware seen so
# far, unlike /power/0, /kidslock/0, /remotectrl/0 which do carry real
# data -- common.ENERGY_METER on /energy/consumption/vs/0 is the only
# real source for this control.
Capability(href='/energy/consumption/0'),
Capability(href="/energy/consumption/0"),
# Empty ({}) on every washer dump seen so far -- nothing to expose.
Capability(href='/cycleinterface/vs/0'),
Capability(href="/cycleinterface/vs/0"),
# OCF-native duplicate of /drlc/vs/0 above -- same utility-program
# dependency this integration doesn't support locally.
Capability(href='/drlc/0'),
Capability(href="/drlc/0"),
# OCF-native duplicate of /operational/state/vs/0, which is already
# modeled by operational.OPERATIONAL_STATE (a richer, write-capable
# capability with start/pause/stop buttons and a delay-start control)
@@ -139,13 +131,12 @@ IGNORED: list[Capability] = [
# time, progress percentage) with no write path -- not worth building a
# parallel write-capable capability around an unverified generic OCF
# write contract.
Capability(href='/operational/state/0'),
Capability(href="/operational/state/0"),
# Cooktop guided-cooking/recipe status (issue #86, TP1X_DA-KS-COOKTOP
# family): sequenceNumber, operationBurnerNumber, a stageInfo block, and
# a textData.menu string -- every field empty/zero on the only dump
# seen so far (device idle, no guided-cooking program active). Same
# "don't guess" treatment as the microwave family's /recipe/cook/vs/0.
# Revisit if a dump with an active recipe surfaces.
Capability(href='/cooktop/recipe/status/vs/0'),
Capability(href="/cooktop/recipe/status/vs/0"),
]
@@ -19,33 +19,34 @@ Resource hrefs seen across laundry dumps:
Door-LED keys use NO `x.com.samsung.da.` prefix -- `setBrightness` /
`setNightLight` -- preserved exactly as they appear in the OCF resource rep.
"""
from datetime import time as dt_time
from ...catalog import has_entity_translation
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
_LED_LEVELS = ('Low', 'High')
_SOUND_MODES = ('voice', 'tone', 'mute')
_LED_LEVELS = ("Low", "High")
_SOUND_MODES = ("voice", "tone", "mute")
def _led_brightness_write(p, rep, href=None):
if p not in _LED_LEVELS:
return None
return ['doorled', 'light', 'vs', '0'], {'setBrightness': p}
return ["doorled", "light", "vs", "0"], {"setBrightness": p}
def _led_night_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['doorled', 'light', 'vs', '0'], {'setNightLight': p}
return ["doorled", "light", "vs", "0"], {"setNightLight": p}
def _parse_hm(v):
if not v:
return None
try:
h, m = v.split(':')
h, m = v.split(":")
return dt_time(int(h), int(m))
except Exception:
return None
@@ -54,66 +55,95 @@ def _parse_hm(v):
def _sound_mode_write(p, rep, href=None):
if p not in _SOUND_MODES:
return None
return ['settings', 'sound', 'mode', 'vs', '0'], {'mode': p}
return ["settings", "sound", "mode", "vs", "0"], {"mode": p}
DOOR_LED = Capability(
href='/doorled/light/vs/0',
href="/doorled/light/vs/0",
entities=(
SelectDesc(key='led_brightness', field='setBrightness',
icon='mdi:brightness-6',
entity_category='config',
options=_LED_LEVELS, write_fn=_led_brightness_write),
SwitchDesc(key='led_night_light', field='setNightLight',
icon='mdi:weather-night',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=_led_night_write),
SelectDesc(key='led_night_brightness', field='setNightLightBrightness',
icon='mdi:brightness-4',
entity_category='config',
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightBrightness': p})),
TimeDesc(key='led_night_start', field='setNightLightTimeStart',
icon='mdi:clock-start',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeStart': f'{p.hour:02d}:{p.minute:02d}'})),
TimeDesc(key='led_night_end', field='setNightLightTimeEnd',
icon='mdi:clock-end',
entity_category='config',
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
['doorled', 'light', 'vs', '0'],
{'setNightLightTimeEnd': f'{p.hour:02d}:{p.minute:02d}'})),
SelectDesc(
key="led_brightness",
field="setBrightness",
icon="mdi:brightness-6",
entity_category="config",
options=_LED_LEVELS,
write_fn=_led_brightness_write,
),
SwitchDesc(
key="led_night_light",
field="setNightLight",
icon="mdi:weather-night",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=_led_night_write,
),
SelectDesc(
key="led_night_brightness",
field="setNightLightBrightness",
icon="mdi:brightness-4",
entity_category="config",
options=_LED_LEVELS,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightBrightness": p},
),
),
TimeDesc(
key="led_night_start",
field="setNightLightTimeStart",
icon="mdi:clock-start",
entity_category="config",
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightTimeStart": f"{p.hour:02d}:{p.minute:02d}"},
),
),
TimeDesc(
key="led_night_end",
field="setNightLightTimeEnd",
icon="mdi:clock-end",
entity_category="config",
value_fn=_parse_hm,
write_fn=lambda p, rep, href=None: (
["doorled", "light", "vs", "0"],
{"setNightLightTimeEnd": f"{p.hour:02d}:{p.minute:02d}"},
),
),
),
)
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
href="/settings/sound/mode/vs/0",
entities=(
SelectDesc(key='sound_mode', field='mode',
icon='mdi:volume-high',
entity_category='config',
options=_SOUND_MODES, write_fn=_sound_mode_write),
SelectDesc(
key="sound_mode",
field="mode",
icon="mdi:volume-high",
entity_category="config",
options=_SOUND_MODES,
write_fn=_sound_mode_write,
),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
href="/settings/sound/volume/vs/0",
entities=(
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min=0, native_max=15, step=5,
value_fn=lambda v: int(v) if v is not None else None,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
NumberDesc(
key="sound_volume",
field="level",
icon="mdi:volume-medium",
entity_category="config",
native_min=0,
native_max=15,
step=5,
value_fn=lambda v: int(v) if v is not None else None,
write_fn=lambda p, rep, href=None: (
["settings", "sound", "volume", "vs", "0"],
{"level": str(int(p))},
),
),
),
)
@@ -125,21 +155,25 @@ SOUND_VOLUME = Capability(
# ---------------------------------------------------------------------------
BUZZER_SOUND = Capability(
href='/buzzersound/vs/0',
href="/buzzersound/vs/0",
entities=(
SelectDesc(key='buzzer_sound', field='setBuzzerSound',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedBuzzerSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setBuzzerSound': p})),
SelectDesc(key='finish_sound', field='setFinishSound',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=lambda rep, resources: 'supportedFinishSound' in rep,
options_field='supportedFinishSound',
write_fn=lambda p, rep, href=None: (
['buzzersound', 'vs', '0'], {'setFinishSound': p})),
SelectDesc(
key="buzzer_sound",
field="setBuzzerSound",
icon="mdi:volume-high",
entity_category="config",
options_field="supportedBuzzerSound",
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setBuzzerSound": p}),
),
SelectDesc(
key="finish_sound",
field="setFinishSound",
icon="mdi:bell-ring",
entity_category="config",
exists_fn=lambda rep, resources: "supportedFinishSound" in rep,
options_field="supportedFinishSound",
write_fn=lambda p, rep, href=None: (["buzzersound", "vs", "0"], {"setFinishSound": p}),
),
),
)
@@ -174,29 +208,29 @@ BUZZER_SOUND = Capability(
def hex_pairs(codes):
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
return [codes[i:i + 2] for i in range(0, len(codes) - 1, 2)]
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]
def parse_edit_course_list(raw):
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
if not isinstance(raw, str) or '_' not in raw:
if not isinstance(raw, str) or "_" not in raw:
return []
return hex_pairs(raw.split('_', 1)[1])
return hex_pairs(raw.split("_", 1)[1])
def cycle_options(resources):
rep = resources.get('/wm/editcourse/vs/0') or {}
codes = parse_edit_course_list(rep.get('x.com.samsung.da.editCourseList'))
rep = resources.get("/wm/editcourse/vs/0") or {}
codes = parse_edit_course_list(rep.get("x.com.samsung.da.editCourseList"))
if codes:
return codes
return _course_codes_from_supported_options(resources.get('/course/vs/0') or {})
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {})
def option_value(options, prefix):
"""Find `<prefix>_<value>` in the options array and return <value>."""
for o in (options or []):
if isinstance(o, str) and o.startswith(prefix + '_'):
return o.split('_', 1)[1]
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None
@@ -238,7 +272,7 @@ def _course_codes_from_supported_options(course_rep):
enough (double digits) that colliding by chance on both checks is
unlikely, and no device seen so far actually needs it.
"""
raw = course_rep.get('x.com.samsung.da.supportedOptions')
raw = course_rep.get("x.com.samsung.da.supportedOptions")
hexstr = raw[0] if isinstance(raw, list) and raw else raw
if not isinstance(hexstr, str) or len(hexstr) < 3:
return []
@@ -246,14 +280,14 @@ def _course_codes_from_supported_options(course_rep):
if len(body) % 2:
return []
total_bytes = len(body) // 2
current = option_value(course_rep.get('x.com.samsung.da.options'), 'Course')
current = option_value(course_rep.get("x.com.samsung.da.options"), "Course")
for k in range(1, total_bytes + 1):
if total_bytes % k:
continue
n = total_bytes // k
if n < 2:
continue
firsts = [body[i * k * 2:i * k * 2 + 2] for i in range(n)]
firsts = [body[i * k * 2 : i * k * 2 + 2] for i in range(n)]
if len(set(firsts)) != n:
continue
if current is not None and current not in firsts:
@@ -265,20 +299,20 @@ def _course_codes_from_supported_options(course_rep):
def option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write -- see the module comment
above cycle_options for why this doesn't read/rewrite the whole array."""
return [f'{prefix}_{new_value}']
return [f"{prefix}_{new_value}"]
def cycle_write(p, rep, href=None):
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Course', p),
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write("Course", p),
}
def _table_id(resources, table_href):
rep = resources.get(table_href) or {}
return rep.get('x.com.samsung.da.st.courseTable')
return rep.get("x.com.samsung.da.st.courseTable")
def cycle_select(*, translation_key, icon, table_href=None):
@@ -316,18 +350,21 @@ def cycle_select(*, translation_key, icon, table_href=None):
"""
key = translation_key
if table_href is not None:
def key(resources):
table = _table_id(resources, table_href)
if not isinstance(table, str) or not table:
return 'cycle'
candidate = f'{translation_key}_{table.lower()}'
return candidate if has_entity_translation('select', candidate) else 'cycle'
return "cycle"
candidate = f"{translation_key}_{table.lower()}"
return candidate if has_entity_translation("select", candidate) else "cycle"
return SelectDesc(
key='cycle', icon=icon, translation_key=key,
key="cycle",
icon=icon,
translation_key=key,
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get('x.com.samsung.da.options'), 'Course'),
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
write_fn=cycle_write,
)
@@ -344,27 +381,30 @@ def cycle_select(*, translation_key, icon, table_href=None):
def bool_option_write(prefix):
def write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, p),
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write(prefix, p),
}
return write
def bool_option_value(prefix):
return lambda rep: option_value(rep.get('x.com.samsung.da.options'), prefix) == 'On'
return lambda rep: option_value(rep.get("x.com.samsung.da.options"), prefix) == "On"
def bool_option_exists(prefix):
return lambda rep, resources: option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
return lambda rep, resources: (
option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
)
def bool_option_switch(key, icon, prefix, *, entity_category=None,
gate_on_presence=False, validate_fn=None):
def bool_option_switch(
key, icon, prefix, *, entity_category=None, gate_on_presence=False, validate_fn=None
):
"""A SwitchDesc over a '<prefix>_On'/'<prefix>_Off' options[] token.
gate_on_presence self-gates the entity off on models that never report
@@ -376,7 +416,9 @@ def bool_option_switch(key, icon, prefix, *, entity_category=None,
building one, if needed, is the caller's job.
"""
return SwitchDesc(
key=key, icon=icon, entity_category=entity_category,
key=key,
icon=icon,
entity_category=entity_category,
exists_fn=bool_option_exists(prefix) if gate_on_presence else None,
rep_fn=bool_option_value(prefix),
write_fn=bool_option_write(prefix),
@@ -394,11 +436,13 @@ def bool_option_switch(key, icon, prefix, *, entity_category=None,
# ---------------------------------------------------------------------------
JOB_BEGINNING_STATUS = Capability(
href='/wm/jobbeginingstatus/vs/0',
poll_tier='warm',
href="/wm/jobbeginingstatus/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='job_beginning_status',
field='x.com.samsung.da.currentStatus',
entity_category='diagnostic'),
SensorDesc(
key="job_beginning_status",
field="x.com.samsung.da.currentStatus",
entity_category="diagnostic",
),
),
)
@@ -36,6 +36,7 @@ Note: cooking-mode writes are unproven here, same caveat as oven.py's
OVEN_MODE -- exposed as a SelectDesc for fidelity, first real-world write
is also the test.
"""
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, normalize_temp_unit
@@ -53,17 +54,17 @@ from .laundry import option_value, option_write
# board's own /mode/vs/0 supportedModes reports; an entry here that a device
# never sends just never gets picked.
_MICROWAVE_MODES = (
'NoOperation',
'MicroWave',
'MicroWaveGrill',
'MicroWaveConvection',
'Convection',
'AirFryer',
'Grill',
'Autocook',
'AutocookCustom',
'Deodorization',
'KeepWarm',
"NoOperation",
"MicroWave",
"MicroWaveGrill",
"MicroWaveConvection",
"Convection",
"AirFryer",
"Grill",
"Autocook",
"AutocookCustom",
"Deodorization",
"KeepWarm",
)
# Convection/MicroWaveConvection modeSpec on issue #121's dump: tempMinC 40,
@@ -82,9 +83,9 @@ def _microwave_temp_unit(rep):
carries a per-item x.com.samsung.da.unit field. Both known dumps for
this family report 'Celsius'; kept live rather than hardcoded per the
fridge/oven convention (issue #7)."""
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')
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 _setpoint_write(p, rep, href=None):
@@ -98,12 +99,12 @@ def _setpoint_write(p, rep, href=None):
temp_i = int(round(temp / SETPOINT_STEP_C) * SETPOINT_STEP_C)
if not (SETPOINT_MIN_C <= temp_i <= SETPOINT_MAX_C):
return None
items = rep.get('x.com.samsung.da.items')
items = rep.get("x.com.samsung.da.items")
if not items:
return None
items = [dict(it) for it in items]
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
items[0]["x.com.samsung.da.desired"] = str(temp_i)
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
def _power_level_watts(v):
@@ -111,7 +112,7 @@ def _power_level_watts(v):
if v is None:
return None
s = str(v).strip()
if s.upper().endswith('W'):
if s.upper().endswith("W"):
s = s[:-1]
return int_or_none(s)
@@ -123,71 +124,71 @@ def _cooking_mode_options(resources):
pattern as oven._oven_mode_options -- a fixed list here would offer
users modes their own unit doesn't have (issue #152's ME7500D reports
only 4 of _MICROWAVE_MODES' 11)."""
rep = resources.get('/mode/vs/0') or {}
live = rep.get('x.com.samsung.da.supportedModes')
rep = resources.get("/mode/vs/0") or {}
live = rep.get("x.com.samsung.da.supportedModes")
return list(live) if live else list(_MICROWAVE_MODES)
def _mode_write(p, rep, href=None):
valid = rep.get('x.com.samsung.da.supportedModes') or _MICROWAVE_MODES
valid = rep.get("x.com.samsung.da.supportedModes") or _MICROWAVE_MODES
if p not in valid:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
def _sound_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Sound', p),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Sound", p),
}
def _lamp_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'Lamp') is not None
return option_value(rep.get("x.com.samsung.da.options"), "Lamp") is not None
def _filter_remind_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'FilterRemind') is not None
return option_value(rep.get("x.com.samsung.da.options"), "FilterRemind") is not None
def _remind_beep_exists(rep, resources):
return option_value(rep.get('x.com.samsung.da.options'), 'RemindBeep') is not None
return option_value(rep.get("x.com.samsung.da.options"), "RemindBeep") is not None
def _lamp_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
# 'High' and 'Off' are the two tokens actually confirmed on live dumps
# (issues #137/#152) -- 'On' has never been observed and the device
# likely doesn't recognize it (see module docstring).
token = 'High' if p == 'On' else 'Off'
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('Lamp', token),
token = "High" if p == "On" else "Off"
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("Lamp", token),
}
def _filter_remind_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('FilterRemind', p),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("FilterRemind", p),
}
def _remind_beep_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': option_write('RemindBeep', p),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": option_write("RemindBeep", p),
}
@@ -196,71 +197,102 @@ def _remind_beep_write(p, rep, href=None):
# ---------------------------------------------------------------------------
MICROWAVE_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
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',
unit='W', value_fn=_power_level_watts),
SensorDesc(key="cavity_state", field="x.com.samsung.da.state"),
SensorDesc(
key="power_level",
field="x.com.samsung.da.powerLevel",
unit="W",
value_fn=_power_level_watts,
),
),
)
MICROWAVE_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
href="/temperatures/vs/0",
poll_tier="hot",
entities=(
NumberDesc(key='setpoint', field='x.com.samsung.da.items',
device_class='temperature', unit_fn=_microwave_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == '°C',
value_fn=lambda items: int_or_none(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
device_class='temperature',
state_class='measurement', unit_fn=_microwave_temp_unit,
value_fn=lambda items: int_or_none(
(items[0].get('x.com.samsung.da.current') if items else None))),
NumberDesc(
key="setpoint",
field="x.com.samsung.da.items",
device_class="temperature",
unit_fn=_microwave_temp_unit,
native_min=float(SETPOINT_MIN_C),
native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C),
icon="mdi:thermometer-chevron-up",
exists_fn=lambda rep, resources: _microwave_temp_unit(rep) == "°C",
value_fn=lambda items: int_or_none(
items[0].get("x.com.samsung.da.desired") if items else None
),
write_fn=_setpoint_write,
),
SensorDesc(
key="current_temp_c",
field="x.com.samsung.da.items",
device_class="temperature",
state_class="measurement",
unit_fn=_microwave_temp_unit,
value_fn=lambda items: int_or_none(
items[0].get("x.com.samsung.da.current") if items else None
),
),
),
)
MICROWAVE_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
# SelectDesc first — test_microwave_mode_options_nonempty uses entities[0]
SelectDesc(key='cooking_mode', field='x.com.samsung.da.modes',
icon='mdi:tune',
options=_cooking_mode_options,
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),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
icon='mdi:track-light',
exists_fn=_lamp_exists,
value_fn=lambda opts: option_value(opts, 'Lamp') not in (None, 'Off'),
write_fn=_lamp_write),
SelectDesc(
key="cooking_mode",
field="x.com.samsung.da.modes",
icon="mdi:tune",
options=_cooking_mode_options,
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,
),
SwitchDesc(
key="lamp",
field="x.com.samsung.da.options",
icon="mdi:track-light",
exists_fn=_lamp_exists,
value_fn=lambda opts: option_value(opts, "Lamp") not in (None, "Off"),
write_fn=_lamp_write,
),
# issue #181: Filter Reminder / End Signal Reminder toggles,
# confirmed present (both On and Off observed across dumps -- see
# issue #152's ME7500D fixtures) but only on boards that carry the
# FilterRemind_*/RemindBeep_* tokens; gated off elsewhere (e.g. the
# MW7300B combi dump has neither) rather than assumed universal.
SwitchDesc(key='filter_remind', field='x.com.samsung.da.options',
icon='mdi:air-filter',
entity_category='config',
exists_fn=_filter_remind_exists,
value_fn=lambda opts: option_value(opts, 'FilterRemind') == 'On',
write_fn=_filter_remind_write),
SwitchDesc(key='remind_beep', field='x.com.samsung.da.options',
icon='mdi:bell-ring',
entity_category='config',
exists_fn=_remind_beep_exists,
value_fn=lambda opts: option_value(opts, 'RemindBeep') == 'On',
write_fn=_remind_beep_write),
SwitchDesc(
key="filter_remind",
field="x.com.samsung.da.options",
icon="mdi:air-filter",
entity_category="config",
exists_fn=_filter_remind_exists,
value_fn=lambda opts: option_value(opts, "FilterRemind") == "On",
write_fn=_filter_remind_write,
),
SwitchDesc(
key="remind_beep",
field="x.com.samsung.da.options",
icon="mdi:bell-ring",
entity_category="config",
exists_fn=_remind_beep_exists,
value_fn=lambda opts: option_value(opts, "RemindBeep") == "On",
write_fn=_remind_beep_write,
),
),
)
@@ -2,15 +2,21 @@
Shared by dryer/dishwasher/oven/washer families.
"""
import math
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta
from ..capability import Capability
from ..entities import BinarySensorDesc, ButtonDesc, NumberDesc, SensorDesc
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle', 'Run': 'active', 'Running': 'active',
'Pause': 'pause', 'Paused': 'pause', 'End': 'idle', 'Stop': 'idle',
"Ready": "idle",
"Run": "active",
"Running": "active",
"Pause": "pause",
"Paused": "pause",
"End": "idle",
"Stop": "idle",
}
@@ -19,7 +25,7 @@ def _to_ocf(v):
def _progress(v):
return 'Idle' if v in (None, 'None') else v
return "Idle" if v in (None, "None") else v
def _int(v):
@@ -32,8 +38,8 @@ def _int(v):
def _is_active(rep):
"""Check if appliance is actively running and cycle is not finished."""
return (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
and rep.get("x.com.samsung.da.progress") != "Finish"
)
@@ -42,7 +48,7 @@ def _remaining_seconds(raw):
if not isinstance(raw, str):
return None
try:
parts = [int(p) for p in raw.split(':')]
parts = [int(p) for p in raw.split(":")]
except (ValueError, TypeError):
return None
@@ -69,7 +75,7 @@ def _delay_hours(v):
def _format_delay(hours):
total_minutes = round(max(float(hours), 0) * 60)
h, m = divmod(total_minutes, 60)
return f'{h}:{m:02d}:00'
return f"{h}:{m:02d}:00"
def _delay_field(rep):
@@ -79,15 +85,16 @@ def _delay_field(rep):
device itself is using; default to delayStartTime for hardware that
reports neither yet (matches prior behavior)."""
return (
'x.com.samsung.da.delayEndTime' if 'x.com.samsung.da.delayEndTime' in rep
else 'x.com.samsung.da.delayStartTime'
"x.com.samsung.da.delayEndTime"
if "x.com.samsung.da.delayEndTime" in rep
else "x.com.samsung.da.delayStartTime"
)
def _finish_time(rep):
if not _is_active(rep):
return None
total_s = _remaining_seconds(rep.get('x.com.samsung.da.remainingTime'))
total_s = _remaining_seconds(rep.get("x.com.samsung.da.remainingTime"))
if not total_s:
return None
# Round to whole minutes -- remainingTime itself only has minute
@@ -96,42 +103,50 @@ def _finish_time(rep):
# even when the device-reported remaining time hasn't. That floods
# the recorder history/logbook with values that look identical once
# the UI rounds them down for display.
finish = datetime.now(timezone.utc) + timedelta(seconds=total_s)
finish = datetime.now(UTC) + timedelta(seconds=total_s)
return finish.replace(second=0, microsecond=0)
def _completion_minutes(rep):
"""Parse remaining time into minutes directly from device payload."""
raw = rep.get('x.com.samsung.da.remainingTime') or rep.get('remainingTime')
raw = rep.get("x.com.samsung.da.remainingTime") or rep.get("remainingTime")
total_s = _remaining_seconds(raw)
if total_s is None:
return None
# Avoid the firmware bug where it freezes at 1 minute post-cycle
if rep.get('x.com.samsung.da.progress') == 'Finish':
if rep.get("x.com.samsung.da.progress") == "Finish":
return 0
return math.ceil(total_s / 60)
# Shared by dryer/dishwasher/oven/washer -- oven.py imports this directly
# rather than keeping its own copy, since both wrote the identical
# state='Ready' RMW.
STOP_BUTTON = ButtonDesc(key='stop', field='', payload='Ready',
icon='mdi:stop',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p}))
STOP_BUTTON = ButtonDesc(
key="stop",
field="",
payload="Ready",
icon="mdi:stop",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
)
OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
href="/operational/state/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
device_class='enum',
options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
SensorDesc(
key="machine_state",
field="x.com.samsung.da.state",
device_class="enum",
options=("idle", "active", "pause"),
translation_key="machine_state",
value_fn=_to_ocf,
),
# cycle_active is a bool derived from machine_state; used by the
# adapter to gate oven writes (cycle_active_field='cycle_active').
# Harmless for non-oven appliances — just an extra bool in state.
@@ -141,56 +156,85 @@ OPERATIONAL_STATE = Capability(
# start/pause/stop buttons below) is shared across the dryer/
# dishwasher/oven/washer families, and 'cycle' is laundry-specific
# vocabulary that doesn't fit an oven's bake/roast/etc.
BinarySensorDesc(key='cycle_active', device_class='running',
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) == 'active'
and rep.get('x.com.samsung.da.progress') != 'Finish'
)),
SensorDesc(key='progress', icon='mdi:progress-wrench',
rep_fn=lambda rep: (
'Idle' if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _progress(rep.get('x.com.samsung.da.progress'))
)),
SensorDesc(key='progress_percentage',
unit='%', state_class='measurement',
rep_fn=lambda rep: (
0 if _SAMSUNG_STATE_TO_OCF.get(rep.get('x.com.samsung.da.state')) != 'active'
else _int(rep.get('x.com.samsung.da.progressPercentage'))
)),
BinarySensorDesc(
key="cycle_active",
device_class="running",
rep_fn=lambda rep: (
_SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) == "active"
and rep.get("x.com.samsung.da.progress") != "Finish"
),
),
SensorDesc(
key="progress",
icon="mdi:progress-wrench",
rep_fn=lambda rep: (
"Idle"
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
else _progress(rep.get("x.com.samsung.da.progress"))
),
),
SensorDesc(
key="progress_percentage",
unit="%",
state_class="measurement",
rep_fn=lambda rep: (
0
if _SAMSUNG_STATE_TO_OCF.get(rep.get("x.com.samsung.da.state")) != "active"
else _int(rep.get("x.com.samsung.da.progressPercentage"))
),
),
# Only show finish time when machine is actively running. Samsung
# firmware leaves a stale remainingTime after a cycle ends, and
# freezes it at '00:01:00' when progress reaches 'Finish'.
SensorDesc(key='finish_time', device_class='timestamp',
hysteresis=True, rep_fn=_finish_time),
SensorDesc(key='completion_minutes',
icon='mdi:clock-outline', unit='min',
device_class='duration', state_class='measurement',
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes),
NumberDesc(key='delay_start_hours', icon='mdi:timer-plus-outline',
device_class='duration', unit='h',
native_min=0, native_max=24, step=1,
rep_fn=lambda rep: _delay_hours(
rep.get('x.com.samsung.da.delayStartTime')
or rep.get('x.com.samsung.da.delayEndTime')),
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{_delay_field(rep): _format_delay(p)})),
ButtonDesc(key='start', field='', payload='Run',
icon='mdi:play',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
ButtonDesc(key='pause', field='', payload='Pause',
icon='mdi:pause',
write_fn=lambda p, rep, href=None: (
['operational', 'state', 'vs', '0'],
{'x.com.samsung.da.state': p})),
SensorDesc(
key="finish_time", device_class="timestamp", hysteresis=True, rep_fn=_finish_time
),
SensorDesc(
key="completion_minutes",
icon="mdi:clock-outline",
unit="min",
device_class="duration",
state_class="measurement",
exists_fn=lambda rep, resources: _completion_minutes(rep) is not None,
rep_fn=_completion_minutes,
),
NumberDesc(
key="delay_start_hours",
icon="mdi:timer-plus-outline",
device_class="duration",
unit="h",
native_min=0,
native_max=24,
step=1,
rep_fn=lambda rep: _delay_hours(
rep.get("x.com.samsung.da.delayStartTime")
or rep.get("x.com.samsung.da.delayEndTime")
),
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{_delay_field(rep): _format_delay(p)},
),
),
ButtonDesc(
key="start",
field="",
payload="Run",
icon="mdi:play",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
),
ButtonDesc(
key="pause",
field="",
payload="Pause",
icon="mdi:pause",
write_fn=lambda p, rep, href=None: (
["operational", "state", "vs", "0"],
{"x.com.samsung.da.state": p},
),
),
STOP_BUTTON,
),
)
@@ -22,12 +22,17 @@ _start_open.md). Mode writes are also unreliable — the oven rolls them back
once a cycle is active. OVEN_MODE is provided as a SelectDesc for fidelity
but is effectively read-only in practice.
"""
from datetime import datetime, timezone, timedelta
from datetime import UTC, datetime, timedelta
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import (
BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc,
BinarySensorDesc,
NumberDesc,
SelectDesc,
SensorDesc,
SwitchDesc,
)
from .common import normalize_temp_unit
from .operational import STOP_BUTTON
@@ -61,26 +66,26 @@ SETPOINT_STEP_F = 5
# the adding-device-support skill's preference for device-reported option
# lists over hardcoded ones.
_OVEN_MODES = (
'NoOperation',
'Bake',
'Broil',
'Convection',
'ConvectionBake',
'ConvectionBroil',
'FrozenPizzaPlus',
'SlowCook',
'PlateWarm',
'AirFry',
"NoOperation",
"Bake",
"Broil",
"Convection",
"ConvectionBake",
"ConvectionBroil",
"FrozenPizzaPlus",
"SlowCook",
"PlateWarm",
"AirFry",
)
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle',
'Run': 'active',
'Running': 'active',
'Pause': 'pause',
'Paused': 'pause',
'End': 'idle',
'Stop': 'idle',
"Ready": "idle",
"Run": "active",
"Running": "active",
"Pause": "pause",
"Paused": "pause",
"End": "idle",
"Stop": "idle",
}
@@ -99,11 +104,11 @@ def _finish_time(remaining_str):
if not remaining_str:
return None
try:
h, m, s = remaining_str.split(':')
h, m, s = remaining_str.split(":")
total_s = int(h) * 3600 + int(m) * 60 + int(s)
if total_s == 0:
return None
return datetime.now(timezone.utc) + timedelta(seconds=total_s)
return datetime.now(UTC) + timedelta(seconds=total_s)
except Exception:
return None
@@ -113,7 +118,7 @@ def _op_minutes(op_time):
if not op_time:
return None
try:
h, m, s = op_time.split(':')
h, m, s = op_time.split(":")
return int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
return None
@@ -123,11 +128,12 @@ def _op_minutes(op_time):
# Options-array helpers (shared by lamp, sound, fastpreheat, naturalsteam)
# ---------------------------------------------------------------------------
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]
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None
@@ -149,8 +155,9 @@ def _has_option(prefix):
a first chance to bind, since exists_fn runs before that first real
fetch lands.
"""
return lambda rep, resources: is_stub_rep(rep) or _option_value(
rep.get('x.com.samsung.da.options'), prefix) is not None
return lambda rep, resources: (
is_stub_rep(rep) or _option_value(rep.get("x.com.samsung.da.options"), prefix) is not None
)
def _option_write(prefix, new_value):
@@ -163,13 +170,14 @@ def _option_write(prefix, new_value):
replaces the field outright instead of merging would drop every other
option in it (Sound/fastpreheat/etc.) on the next write -- revisit if a
real device report surfaces that."""
return [f'{prefix}_{new_value}']
return [f"{prefix}_{new_value}"]
# ---------------------------------------------------------------------------
# Write functions
# ---------------------------------------------------------------------------
def _oven_setpoint_write(p, rep, href=None):
"""RMW write to /temperatures/vs/0 items array."""
try:
@@ -180,27 +188,27 @@ def _oven_setpoint_write(p, rep, href=None):
temp_i = int(round(temp / step_v) * step_v)
if not (min_v <= temp_i <= max_v):
return None
items = rep.get('x.com.samsung.da.items')
items = rep.get("x.com.samsung.da.items")
if not items:
return None
items = [dict(it) for it in items]
items[0]['x.com.samsung.da.desired'] = str(temp_i)
return ['temperatures', 'vs', '0'], {'x.com.samsung.da.items': items}
items[0]["x.com.samsung.da.desired"] = str(temp_i)
return ["temperatures", "vs", "0"], {"x.com.samsung.da.items": items}
def _cook_time_write(p, rep, href=None):
"""Write operationTime + remainingTime (H:MM:SS) from minutes."""
try:
minutes = int(round(float(p)))
minutes = round(float(p))
except (TypeError, ValueError):
return None
if not (0 <= minutes <= 1439):
return None
h, m = divmod(minutes, 60)
hms = f"{h:02d}:{m:02d}:00"
return ['operational', 'state', 'vs', '0'], {
'x.com.samsung.da.operationTime': hms,
'x.com.samsung.da.remainingTime': hms,
return ["operational", "state", "vs", "0"], {
"x.com.samsung.da.operationTime": hms,
"x.com.samsung.da.remainingTime": hms,
}
@@ -210,30 +218,32 @@ def _oven_mode_options(resources):
laundry.py's options_field pattern (buzzer_sound/finish_sound), but
needs the callable form rather than options_field because a static
fallback has to kick in when the device's own field is absent."""
rep = resources.get('/mode/vs/0') or {}
live = rep.get('x.com.samsung.da.supportedModes')
rep = resources.get("/mode/vs/0") or {}
live = rep.get("x.com.samsung.da.supportedModes")
return list(live) if live else list(_OVEN_MODES)
def _oven_mode_write(p, rep, href=None):
valid = rep.get('x.com.samsung.da.supportedModes') or _OVEN_MODES
valid = rep.get("x.com.samsung.da.supportedModes") or _OVEN_MODES
if p not in valid:
return None
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [p]}
return ["mode", "vs", "0"], {"x.com.samsung.da.modes": [p]}
def _option_switch_write(prefix):
"""Factory for a single-token on/off options-array write -- lamp, sound,
fast_preheat, natural_steam, energy_saving, and cooktop_on_alert were all
a byte-for-byte copy of this same shape, one per prefix."""
def write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
return None
return ['mode', 'vs', '0'], {
'x.com.samsung.da.options': _option_write(prefix, p),
return ["mode", "vs", "0"], {
"x.com.samsung.da.options": _option_write(prefix, p),
}
return write
@@ -242,44 +252,71 @@ def _option_switch_write(prefix):
# ---------------------------------------------------------------------------
OVEN_OPERATIONAL_STATE = Capability(
href='/operational/state/vs/0',
poll_tier='hot',
href="/operational/state/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='machine_state', field='x.com.samsung.da.state',
icon='mdi:stove',
device_class='enum', options=('idle', 'active', 'pause'),
translation_key='machine_state', value_fn=_to_ocf),
BinarySensorDesc(key='cycle_active', field='x.com.samsung.da.state',
device_class='running',
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == 'active'),
SensorDesc(key='progress_percentage',
field='x.com.samsung.da.progressPercentage',
unit='%', state_class='measurement',
value_fn=_int),
SensorDesc(key='operation_time_minutes',
field='x.com.samsung.da.operationTime',
unit='min',
state_class='measurement', value_fn=_op_minutes),
SensorDesc(key='finish_time', field='x.com.samsung.da.remainingTime',
device_class='timestamp',
value_fn=_finish_time),
NumberDesc(key='cook_time', field='x.com.samsung.da.operationTime',
unit='min', native_min=0, native_max=1439,
step=1.0, icon='mdi:timer', value_fn=_op_minutes,
write_fn=_cook_time_write),
SensorDesc(
key="machine_state",
field="x.com.samsung.da.state",
icon="mdi:stove",
device_class="enum",
options=("idle", "active", "pause"),
translation_key="machine_state",
value_fn=_to_ocf,
),
BinarySensorDesc(
key="cycle_active",
field="x.com.samsung.da.state",
device_class="running",
value_fn=lambda v: _SAMSUNG_STATE_TO_OCF.get(v) == "active",
),
SensorDesc(
key="progress_percentage",
field="x.com.samsung.da.progressPercentage",
unit="%",
state_class="measurement",
value_fn=_int,
),
SensorDesc(
key="operation_time_minutes",
field="x.com.samsung.da.operationTime",
unit="min",
state_class="measurement",
value_fn=_op_minutes,
),
SensorDesc(
key="finish_time",
field="x.com.samsung.da.remainingTime",
device_class="timestamp",
value_fn=_finish_time,
),
NumberDesc(
key="cook_time",
field="x.com.samsung.da.operationTime",
unit="min",
native_min=0,
native_max=1439,
step=1.0,
icon="mdi:timer",
value_fn=_op_minutes,
write_fn=_cook_time_write,
),
STOP_BUTTON,
),
)
OVEN_CAVITY = Capability(
href='/oven/vs/0',
poll_tier='hot',
href="/oven/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='oven_state', field='x.com.samsung.da.state',
),
SensorDesc(
key="oven_state",
field="x.com.samsung.da.state",
),
),
)
def _oven_temp_unit(rep):
"""Same shape/risk as fridge.py's TEMPERATURES_FALLBACK: this is the
same aggregate `/temperatures/vs/0` items[] resource type, which on
@@ -288,9 +325,9 @@ def _oven_temp_unit(rep):
Keeps the verified '°C' default when the field is absent (the original
NV7000BS-class dump this module was written against), but reads it live
-- issue #44's range dump is the first to report 'Fahrenheit' here."""
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')
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 _setpoint_bounds(rep):
@@ -298,54 +335,72 @@ def _setpoint_bounds(rep):
constants above for provenance. Bounds must track the unit shown by
unit_fn (both read the same live rep), or the HA slider's range would
silently mismatch its own displayed unit."""
if _oven_temp_unit(rep) == '°F':
if _oven_temp_unit(rep) == "°F":
return SETPOINT_MIN_F, SETPOINT_MAX_F, SETPOINT_STEP_F
return SETPOINT_MIN_C, SETPOINT_MAX_C, SETPOINT_STEP_C
OVEN_SETPOINT = Capability(
href='/temperatures/vs/0',
poll_tier='hot',
href="/temperatures/vs/0",
poll_tier="hot",
entities=(
# NumberDesc first — test_oven_setpoint_write_is_read_modify_write uses entities[0]
NumberDesc(key='oven_setpoint', field='x.com.samsung.da.items',
device_class='temperature', unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C), native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C), icon='mdi:thermometer-chevron-up',
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.desired') if items else None)),
write_fn=_oven_setpoint_write),
SensorDesc(key='current_temp_c', field='x.com.samsung.da.items',
device_class='temperature',
state_class='measurement', unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
(items[0].get('x.com.samsung.da.current') if items else None))),
NumberDesc(
key="oven_setpoint",
field="x.com.samsung.da.items",
device_class="temperature",
unit_fn=_oven_temp_unit,
native_min=float(SETPOINT_MIN_C),
native_max=float(SETPOINT_MAX_C),
step=float(SETPOINT_STEP_C),
icon="mdi:thermometer-chevron-up",
native_min_fn=lambda rep: float(_setpoint_bounds(rep)[0]),
native_max_fn=lambda rep: float(_setpoint_bounds(rep)[1]),
step_fn=lambda rep: float(_setpoint_bounds(rep)[2]),
value_fn=lambda items: _int(
items[0].get("x.com.samsung.da.desired") if items else None
),
write_fn=_oven_setpoint_write,
),
SensorDesc(
key="current_temp_c",
field="x.com.samsung.da.items",
device_class="temperature",
state_class="measurement",
unit_fn=_oven_temp_unit,
value_fn=lambda items: _int(
items[0].get("x.com.samsung.da.current") if items else None
),
),
),
)
OVEN_DOOR = Capability(
href='/doors/vs/0',
poll_tier='hot',
href="/doors/vs/0",
poll_tier="hot",
entities=(
BinarySensorDesc(key='door_open', field='x.com.samsung.da.items',
device_class='door',
value_fn=lambda items: (
items[0].get('x.com.samsung.da.openState') == 'Open'
if items else None)),
BinarySensorDesc(
key="door_open",
field="x.com.samsung.da.items",
device_class="door",
value_fn=lambda items: (
items[0].get("x.com.samsung.da.openState") == "Open" if items else None
),
),
),
)
OVEN_CONNECTED = Capability(
href='/connected/vs/0',
poll_tier='warm',
href="/connected/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='cloud_connected', field='x.com.samsung.da.connected',
device_class='connectivity',
entity_category='diagnostic',
value_fn=lambda v: v == 'On'),
BinarySensorDesc(
key="cloud_connected",
field="x.com.samsung.da.connected",
device_class="connectivity",
entity_category="diagnostic",
value_fn=lambda v: v == "On",
),
),
)
@@ -354,59 +409,82 @@ OVEN_CONNECTED = Capability(
# range: single cavity, no supportedFeatureList entries). Bound with no
# entities purely for coverage; revisit if a multi-cavity dump surfaces
# fields worth exposing.
OVEN_SPEC = Capability(href='/oven/spec/vs/0')
OVEN_SPEC = Capability(href="/oven/spec/vs/0")
# Quick-recipe display blob (combi microwave, issue #121) -- a JSON-encoded
# string (language/menu/servingSize/option) with every field blank on the
# only dump seen, and no documented write contract. No entity to bind per
# the 'don't guess' rule; a bare Capability still marks the href covered.
OVEN_RECIPE_COOK = Capability(href='/recipe/cook/vs/0')
OVEN_RECIPE_COOK = Capability(href="/recipe/cook/vs/0")
OVEN_MODE = Capability(
href='/mode/vs/0',
poll_tier='warm',
href="/mode/vs/0",
poll_tier="warm",
entities=(
# SelectDesc first — test_oven_mode_options_nonempty uses entities[0]
SelectDesc(key='oven_mode', field='x.com.samsung.da.modes',
icon='mdi:tune',
options=_oven_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write),
SwitchDesc(key='lamp', field='x.com.samsung.da.options',
icon='mdi:track-light',
value_fn=lambda opts: _option_value(opts, 'UpperLamp') == 'On',
write_fn=_option_switch_write('UpperLamp')),
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=_option_switch_write('Sound')),
SwitchDesc(key='fast_preheat', field='x.com.samsung.da.options',
icon='mdi:fire',
exists_fn=_has_option('fastpreheat'),
value_fn=lambda opts: _option_value(opts, 'fastpreheat') == 'On',
write_fn=_option_switch_write('fastpreheat')),
SwitchDesc(key='natural_steam', field='x.com.samsung.da.options',
icon='mdi:kettle-steam',
exists_fn=_has_option('NaturalSteam'),
value_fn=lambda opts: _option_value(opts, 'NaturalSteam') == 'On',
write_fn=_option_switch_write('NaturalSteam')),
SelectDesc(
key="oven_mode",
field="x.com.samsung.da.modes",
icon="mdi:tune",
options=_oven_mode_options,
value_fn=lambda v: v[0] if v else None,
write_fn=_oven_mode_write,
),
SwitchDesc(
key="lamp",
field="x.com.samsung.da.options",
icon="mdi:track-light",
value_fn=lambda opts: _option_value(opts, "UpperLamp") == "On",
write_fn=_option_switch_write("UpperLamp"),
),
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=_option_switch_write("Sound"),
),
SwitchDesc(
key="fast_preheat",
field="x.com.samsung.da.options",
icon="mdi:fire",
exists_fn=_has_option("fastpreheat"),
value_fn=lambda opts: _option_value(opts, "fastpreheat") == "On",
write_fn=_option_switch_write("fastpreheat"),
),
SwitchDesc(
key="natural_steam",
field="x.com.samsung.da.options",
icon="mdi:kettle-steam",
exists_fn=_has_option("NaturalSteam"),
value_fn=lambda opts: _option_value(opts, "NaturalSteam") == "On",
write_fn=_option_switch_write("NaturalSteam"),
),
# 120-hour energy-saving standby (issue #183): confirmed present in
# this unit's options[] (EnergySaving_On) and directly requested --
# unlike fast_preheat/natural_steam above, this token is real on this
# hardware, just previously unbound entirely.
SwitchDesc(key='energy_saving', field='x.com.samsung.da.options',
icon='mdi:leaf', entity_category='config',
exists_fn=_has_option('EnergySaving'),
value_fn=lambda opts: _option_value(opts, 'EnergySaving') == 'On',
write_fn=_option_switch_write('EnergySaving')),
SwitchDesc(
key="energy_saving",
field="x.com.samsung.da.options",
icon="mdi:leaf",
entity_category="config",
exists_fn=_has_option("EnergySaving"),
value_fn=lambda opts: _option_value(opts, "EnergySaving") == "On",
write_fn=_option_switch_write("EnergySaving"),
),
# Cooktop-on alert (issue #183): also confirmed present
# (BurnerOnAlert_Off) though the reporter noted it mainly matters for
# the SmartThings app's own alerting, not local automation.
SwitchDesc(key='cooktop_on_alert', field='x.com.samsung.da.options',
icon='mdi:alert-circle-outline', entity_category='config',
exists_fn=_has_option('BurnerOnAlert'),
value_fn=lambda opts: _option_value(opts, 'BurnerOnAlert') == 'On',
write_fn=_option_switch_write('BurnerOnAlert')),
SwitchDesc(
key="cooktop_on_alert",
field="x.com.samsung.da.options",
icon="mdi:alert-circle-outline",
entity_category="config",
exists_fn=_has_option("BurnerOnAlert"),
value_fn=lambda opts: _option_value(opts, "BurnerOnAlert") == "On",
write_fn=_option_switch_write("BurnerOnAlert"),
),
),
)
@@ -23,6 +23,7 @@ caveat as oven.py's RMW writes) -- power level uses the same read-modify-
write pattern already proven safe elsewhere in this codebase (oven setpoint,
icemaker toggles).
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import normalize_temp_unit
@@ -34,14 +35,14 @@ MAX_BURNERS = 6
def _burner(burner_list, i):
for b in (burner_list or []):
if b.get('burnerNumber') == i:
for b in burner_list or []:
if b.get("burnerNumber") == i:
return b
return None
def _burner_exists(i):
return lambda rep, resources: _burner(rep.get('burnerList'), i) is not None
return lambda rep, resources: _burner(rep.get("burnerList"), i) is not None
def _burner_field_fn(i, field):
@@ -49,35 +50,36 @@ def _burner_field_fn(i, field):
def _burner_hot_surface_fn(i):
get_state = _burner_field_fn(i, 'hotSurfaceState')
return lambda burner_list: get_state(burner_list) not in (None, 'normal')
get_state = _burner_field_fn(i, "hotSurfaceState")
return lambda burner_list: get_state(burner_list) not in (None, "normal")
def _burner_pan_detection_fn(i):
return lambda burner_list: bool(_burner_field_fn(i, 'panDetection')(burner_list))
return lambda burner_list: bool(_burner_field_fn(i, "panDetection")(burner_list))
def _power_level_options(resources):
spec = resources.get('/cooktop/spec/vs/0') or {}
return list(spec.get('supportedPowerLevelList') or [])
spec = resources.get("/cooktop/spec/vs/0") or {}
return list(spec.get("supportedPowerLevelList") or [])
def _burner_power_level_write(i):
def write(p, rep, href=None):
burner_list = rep.get('burnerList')
burner_list = rep.get("burnerList")
if not burner_list:
return None
new_list = []
found = False
for b in burner_list:
if b.get('burnerNumber') == i:
if b.get("burnerNumber") == i:
b = dict(b)
b['powerLevel'] = p
b["powerLevel"] = p
found = True
new_list.append(b)
if not found:
return None
return ['cooktop', 'status', 'vs', '0'], {'burnerList': new_list}
return ["cooktop", "status", "vs", "0"], {"burnerList": new_list}
return write
@@ -85,64 +87,83 @@ def _burner_entities(i):
exists = _burner_exists(i)
n = i + 1
return (
SelectDesc(key=f'burner_{i}_power_level', field='burnerList',
icon='mdi:knob',
translation_key='range_burner_power_level',
translation_placeholders={'number': str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, 'powerLevel'),
write_fn=_burner_power_level_write(i)),
SensorDesc(key=f'burner_{i}_state', field='burnerList',
icon='mdi:stove',
translation_key='burner_state',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, 'operationState')),
BinarySensorDesc(key=f'burner_{i}_hot_surface', field='burnerList',
device_class='heat',
translation_key='burner_hot_surface',
translation_placeholders={'number': str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i)),
BinarySensorDesc(key=f'burner_{i}_pan_detected', field='burnerList',
icon='mdi:pot',
translation_key='burner_pan_detected',
translation_placeholders={'number': str(n)},
entity_category='diagnostic',
exists_fn=exists,
value_fn=_burner_pan_detection_fn(i)),
SelectDesc(
key=f"burner_{i}_power_level",
field="burnerList",
icon="mdi:knob",
translation_key="range_burner_power_level",
translation_placeholders={"number": str(n)},
options=_power_level_options,
exists_fn=exists,
value_fn=_burner_field_fn(i, "powerLevel"),
write_fn=_burner_power_level_write(i),
),
SensorDesc(
key=f"burner_{i}_state",
field="burnerList",
icon="mdi:stove",
translation_key="burner_state",
translation_placeholders={"number": str(n)},
exists_fn=exists,
value_fn=_burner_field_fn(i, "operationState"),
),
BinarySensorDesc(
key=f"burner_{i}_hot_surface",
field="burnerList",
device_class="heat",
translation_key="burner_hot_surface",
translation_placeholders={"number": str(n)},
exists_fn=exists,
value_fn=_burner_hot_surface_fn(i),
),
BinarySensorDesc(
key=f"burner_{i}_pan_detected",
field="burnerList",
icon="mdi:pot",
translation_key="burner_pan_detected",
translation_placeholders={"number": str(n)},
entity_category="diagnostic",
exists_fn=exists,
value_fn=_burner_pan_detection_fn(i),
),
)
def _child_lock_write(p, rep, href=None):
if p not in ('On', 'Off'):
if p not in ("On", "Off"):
return None
return ['cooktop', 'status', 'vs', '0'], {'childLock': p.lower()}
return ["cooktop", "status", "vs", "0"], {"childLock": p.lower()}
COOKTOP_STATUS = Capability(
href='/cooktop/status/vs/0',
poll_tier='hot',
href="/cooktop/status/vs/0",
poll_tier="hot",
entities=(
SensorDesc(key='cooktop_state', field='operationState',
icon='mdi:pot-steam'),
SensorDesc(key="cooktop_state", field="operationState", icon="mdi:pot-steam"),
# The cooktop section's own on/off (issue #86) -- distinct from
# common.POWER's /power/0 or /power/vs/0, which some boards in this
# family (the range combo) additionally carry for the whole
# appliance. Read-only: no live device to confirm remotely turning
# a cooktop on wouldn't leave a burner active unattended.
BinarySensorDesc(key='cooktop_power', field='power',
device_class='power', icon='mdi:pot-steam',
value_fn=lambda v: str(v).lower() == 'on'),
BinarySensorDesc(
key="cooktop_power",
field="power",
device_class="power",
icon="mdi:pot-steam",
value_fn=lambda v: str(v).lower() == "on",
),
# Safe to write -- a lock toggle, not a heat control -- via a
# direct single-field PUT (no RMW needed; unlike burnerList this
# is a lone scalar, not an array of siblings to preserve).
SwitchDesc(key='cooktop_child_lock', field='childLock',
device_class='lock', entity_category='config',
icon='mdi:lock',
value_fn=lambda v: str(v).lower() == 'on',
write_fn=_child_lock_write),
SwitchDesc(
key="cooktop_child_lock",
field="childLock",
device_class="lock",
entity_category="config",
icon="mdi:lock",
value_fn=lambda v: str(v).lower() == "on",
write_fn=_child_lock_write,
),
*[e for i in range(MAX_BURNERS) for e in _burner_entities(i)],
),
)
@@ -151,7 +172,7 @@ COOKTOP_STATUS = Capability(
# COOKTOP_STATUS's power-level select (options=_power_level_options) rather
# than exposed through its own entity -- same "informs another capability,
# no entity of its own" pattern as /wm/editcourse/vs/0 (ignored.py).
COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0')
COOKTOP_SPEC = Capability(href="/cooktop/spec/vs/0")
# settingTime (seconds) is the hot-surface auto-shutoff timer's configured
# duration (1200s = 20 min in issue #44's dump); state on/off is whether the
@@ -159,12 +180,15 @@ COOKTOP_SPEC = Capability(href='/cooktop/spec/vs/0')
# (that's COOKTOP_STATUS's per-burner hot_surface). No write contract
# verified, so read-only for now.
COOKTOP_SAFETY = Capability(
href='/cooktop/settings/status/vs/0',
poll_tier='warm',
href="/cooktop/settings/status/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='cooktop_safety_shutoff_enabled', field='safetyAlert',
entity_category='config',
value_fn=lambda v: (v or {}).get('state') == 'on'),
BinarySensorDesc(
key="cooktop_safety_shutoff_enabled",
field="safetyAlert",
entity_category="config",
value_fn=lambda v: (v or {}).get("state") == "on",
),
),
)
@@ -174,21 +198,37 @@ COOKTOP_SAFETY = Capability(
# showing a disconnected accessory's fields plainly rather than hiding the
# whole capability.
PROBE_STATUS = Capability(
href='/bluetooth/probe/status/vs/0',
poll_tier='warm',
href="/bluetooth/probe/status/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='probe_connected', field='connectionState',
device_class='connectivity',
value_fn=lambda v: str(v).lower() == 'connected'),
SensorDesc(key='probe_battery', field='batteryPercentage',
device_class='battery', state_class='measurement',
unit='%', entity_category='diagnostic'),
SensorDesc(key='probe_temperature', field='currentTemperature',
device_class='temperature', state_class='measurement',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
SensorDesc(key='probe_target_temperature', field='targetTemperature',
device_class='temperature', entity_category='diagnostic',
unit_fn=lambda rep: normalize_temp_unit(rep.get('temperatureUnit'), '°C')),
BinarySensorDesc(
key="probe_connected",
field="connectionState",
device_class="connectivity",
value_fn=lambda v: str(v).lower() == "connected",
),
SensorDesc(
key="probe_battery",
field="batteryPercentage",
device_class="battery",
state_class="measurement",
unit="%",
entity_category="diagnostic",
),
SensorDesc(
key="probe_temperature",
field="currentTemperature",
device_class="temperature",
state_class="measurement",
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
),
SensorDesc(
key="probe_target_temperature",
field="targetTemperature",
device_class="temperature",
entity_category="diagnostic",
unit_fn=lambda rep: normalize_temp_unit(rep.get("temperatureUnit"), "°C"),
),
),
)
@@ -201,12 +241,19 @@ PROBE_STATUS = Capability(
# `supportedHoodLampStateList` has no corresponding live-state field on
# this resource, so nothing to bind it to yet.
COOKTOP_MONITORING = Capability(
href='/cooktopmonitoring/vs/0',
poll_tier='warm',
href="/cooktopmonitoring/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='cooktop_running_state', field='x.com.samsung.da.cooktopRunningState',
icon='mdi:pot-steam-outline'),
SensorDesc(key='warming_center_state', field='x.com.samsung.da.warmingCenterState',
icon='mdi:heat-wave', entity_category='diagnostic'),
SensorDesc(
key="cooktop_running_state",
field="x.com.samsung.da.cooktopRunningState",
icon="mdi:pot-steam-outline",
),
SensorDesc(
key="warming_center_state",
field="x.com.samsung.da.warmingCenterState",
icon="mdi:heat-wave",
entity_category="diagnostic",
),
),
)
@@ -7,7 +7,7 @@ brightness remain separate controls because the device advertises them as two
independent fields.
"""
from datetime import datetime, timezone
from datetime import UTC, datetime
from ..batch import is_stub_rep
from ..capability import Capability
@@ -24,7 +24,7 @@ from .common import int_or_none, sensor_item_value
def _timestamp(value):
try:
return datetime.fromtimestamp(float(value), tz=timezone.utc)
return datetime.fromtimestamp(float(value), tz=UTC)
except (TypeError, ValueError, OSError):
return None
@@ -39,23 +39,23 @@ def _active_alarm_codes(items):
for item in items or ():
if not isinstance(item, dict):
continue
if str(item.get('x.com.samsung.da.state', '')).lower() == 'deleted':
if str(item.get("x.com.samsung.da.state", "")).lower() == "deleted":
continue
code = item.get('x.com.samsung.da.code')
if code and str(code).lower() != 'errorcode_off':
code = item.get("x.com.samsung.da.code")
if code and str(code).lower() != "errorcode_off":
codes.append(code)
return ', '.join(codes) if codes else 'none'
return ", ".join(codes) if codes else "none"
HOOD_ALARMS = Capability(
href='/alarms/vs/0',
poll_tier='hot',
href="/alarms/vs/0",
poll_tier="hot",
entities=(
SensorDesc(
key='alarm_code',
field='x.com.samsung.da.items',
icon='mdi:alert',
entity_category='diagnostic',
key="alarm_code",
field="x.com.samsung.da.items",
icon="mdi:alert",
entity_category="diagnostic",
value_fn=_active_alarm_codes,
),
),
@@ -64,24 +64,21 @@ HOOD_ALARMS = Capability(
def _hood_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
power_href = args[0] if args else '/power/0'
if power_href == '/power/0':
return ['power', '0'], {'value': bool(value)}
if power_href == '/power/vs/0':
return ['power', 'vs', '0'], {
'x.com.samsung.da.power': 'On' if value else 'Off',
if kind == "power":
power_href = args[0] if args else "/power/0"
if power_href == "/power/0":
return ["power", "0"], {"value": bool(value)}
if power_href == "/power/vs/0":
return ["power", "vs", "0"], {
"x.com.samsung.da.power": "On" if value else "Off",
}
return None
if kind == 'speed':
if kind == "speed":
value = str(value)
supported = [
str(code)
for code in rep.get('x.com.samsung.da.hood.supportedFanSpeed', ())
]
supported = [str(code) for code in rep.get("x.com.samsung.da.hood.supportedFanSpeed", ())]
if not supported:
min_s = rep.get('x.com.samsung.da.hood.settableMinFanSpeed')
max_s = rep.get('x.com.samsung.da.hood.settableMaxFanSpeed')
min_s = rep.get("x.com.samsung.da.hood.settableMinFanSpeed")
max_s = rep.get("x.com.samsung.da.hood.settableMaxFanSpeed")
if min_s is not None and max_s is not None:
try:
mn, mx = int(min_s), int(max_s)
@@ -90,32 +87,33 @@ def _hood_fan_write(payload, rep, href=None):
pass
if value not in supported:
return None
return ['hood', 'fanspeed', 'vs', '0'], {
'x.com.samsung.da.hood.fanSpeed': value,
return ["hood", "fanspeed", "vs", "0"], {
"x.com.samsung.da.hood.fanSpeed": value,
}
return None
HOOD_FAN = Capability(
href='/hood/fanspeed/vs/0',
poll_tier='hot',
href="/hood/fanspeed/vs/0",
poll_tier="hot",
entities=(
FanDesc(
key='fan',
field='x.com.samsung.da.hood.fanSpeed',
key="fan",
field="x.com.samsung.da.hood.fanSpeed",
write_fn=_hood_fan_write,
),
BinarySensorDesc(
key='automatic_operation',
field='x.com.samsung.da.hood.autoOperation',
icon='mdi:fan-auto',
entity_category='diagnostic',
key="automatic_operation",
field="x.com.samsung.da.hood.autoOperation",
icon="mdi:fan-auto",
entity_category="diagnostic",
# Absent on the microwave family's built-in vent fan (issue
# #137) -- this board has no auto-ventilation mode, unlike the
# standalone range hood this capability was written for.
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or 'x.com.samsung.da.hood.autoOperation' in rep),
value_fn=lambda value: str(value).lower() == 'on',
is_stub_rep(rep) or "x.com.samsung.da.hood.autoOperation" in rep
),
value_fn=lambda value: str(value).lower() == "on",
),
),
)
@@ -123,34 +121,34 @@ HOOD_FAN = Capability(
def _lamp_level_write(value, rep, href=None):
code = str(value)
supported = [str(level) for level in rep.get('x.com.samsung.lamp.range', ())]
supported = [str(level) for level in rep.get("x.com.samsung.lamp.range", ())]
if code not in supported:
return None
return ['hood', 'lamp', 'vs', '0'], {
'x.com.samsung.lamp.current': code,
return ["hood", "lamp", "vs", "0"], {
"x.com.samsung.lamp.current": code,
}
HOOD_LAMP = Capability(
href='/hood/lamp/vs/0',
poll_tier='hot',
href="/hood/lamp/vs/0",
poll_tier="hot",
entities=(
SwitchDesc(
key='lamp',
field='x.com.samsung.lamp.power',
icon='mdi:range-hood',
value_fn=lambda value: str(value).lower() == 'on',
key="lamp",
field="x.com.samsung.lamp.power",
icon="mdi:range-hood",
value_fn=lambda value: str(value).lower() == "on",
write_fn=lambda payload, rep, href=None: (
['hood', 'lamp', 'vs', '0'],
{'x.com.samsung.lamp.power': 'On' if payload == 'On' else 'Off'},
["hood", "lamp", "vs", "0"],
{"x.com.samsung.lamp.power": "On" if payload == "On" else "Off"},
),
),
SelectDesc(
key='lamp_brightness',
field='x.com.samsung.lamp.current',
icon='mdi:brightness-6',
translation_key='range_hood_lamp_brightness',
options_field='x.com.samsung.lamp.range',
key="lamp_brightness",
field="x.com.samsung.lamp.current",
icon="mdi:brightness-6",
translation_key="range_hood_lamp_brightness",
options_field="x.com.samsung.lamp.range",
write_fn=_lamp_level_write,
),
),
@@ -158,36 +156,34 @@ HOOD_LAMP = Capability(
HOOD_FILTER = Capability(
href='/filter/hoodfilter/vs/0',
poll_tier='cold',
href="/filter/hoodfilter/vs/0",
poll_tier="cold",
entities=(
SensorDesc(
key='hood_filter_usage',
field='x.com.samsung.da.filterUsage',
unit='%',
state_class='measurement',
icon='mdi:air-filter',
entity_category='diagnostic',
key="hood_filter_usage",
field="x.com.samsung.da.filterUsage",
unit="%",
state_class="measurement",
icon="mdi:air-filter",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key='hood_filter_status',
field='x.com.samsung.da.filterStatus',
icon='mdi:air-filter',
entity_category='diagnostic',
device_class='enum',
options=('normal', 'wash', 'replace'),
translation_key='filter_status',
value_fn=lambda value: (
value.lower() if isinstance(value, str) else value
),
key="hood_filter_status",
field="x.com.samsung.da.filterStatus",
icon="mdi:air-filter",
entity_category="diagnostic",
device_class="enum",
options=("normal", "wash", "replace"),
translation_key="filter_status",
value_fn=lambda value: value.lower() if isinstance(value, str) else value,
),
SensorDesc(
key='hood_filter_capacity',
field='x.com.samsung.da.filterCapacity',
unit='h',
icon='mdi:timer-outline',
entity_category='diagnostic',
key="hood_filter_capacity",
field="x.com.samsung.da.filterCapacity",
unit="h",
icon="mdi:timer-outline",
entity_category="diagnostic",
enabled_default=False,
value_fn=int_or_none,
),
@@ -208,31 +204,31 @@ HOOD_FILTER = Capability(
# left an opaque passthrough -- unlike activationState/runningCancel, there's
# no ambiguous field name or missing-write-contract question here to hedge on.
AFTER_RUN = Capability(
href='/afterrun/vs/0',
poll_tier='warm',
href="/afterrun/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key='after_run_active',
field='x.com.samsung.da.activationState',
icon='mdi:fan-clock',
value_fn=lambda value: str(value).lower() == 'on',
key="after_run_active",
field="x.com.samsung.da.activationState",
icon="mdi:fan-clock",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='after_run_progress',
field='x.com.samsung.da.runningProgress',
unit='%',
state_class='measurement',
icon='mdi:fan-clock',
key="after_run_progress",
field="x.com.samsung.da.runningProgress",
unit="%",
state_class="measurement",
icon="mdi:fan-clock",
value_fn=int_or_none,
),
ButtonDesc(
key='after_run_cancel',
field='',
payload='Cancel',
icon='mdi:fan-off',
key="after_run_cancel",
field="",
payload="Cancel",
icon="mdi:fan-off",
write_fn=lambda p, rep, href=None: (
['afterrun', 'vs', '0'],
{'x.com.samsung.da.runningCancel': p},
["afterrun", "vs", "0"],
{"x.com.samsung.da.runningCancel": p},
),
),
),
@@ -240,82 +236,82 @@ AFTER_RUN = Capability(
AIR_QUALITY = Capability(
href='/sensors/vs/0',
poll_tier='warm',
href="/sensors/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key='clean_level',
field='x.com.samsung.da.items',
icon='mdi:air-filter',
value_fn=lambda items: sensor_item_value(items, 'CleanLevel'),
key="clean_level",
field="x.com.samsung.da.items",
icon="mdi:air-filter",
value_fn=lambda items: sensor_item_value(items, "CleanLevel"),
),
SensorDesc(
key='dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'Dust'),
key="dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "Dust"),
),
SensorDesc(
key='fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'FineDust'),
key="fine_dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "FineDust"),
),
SensorDesc(
key='super_fine_dust',
field='x.com.samsung.da.items',
value_fn=lambda items: sensor_item_value(items, 'SuperFineDust'),
key="super_fine_dust",
field="x.com.samsung.da.items",
value_fn=lambda items: sensor_item_value(items, "SuperFineDust"),
),
),
)
AIR_LEVEL_CHECK = Capability(
href='/airlevelcheck/vs/0',
poll_tier='warm',
href="/airlevelcheck/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(
key='periodic_air_sensing',
field='x.com.samsung.da.periodicSensingActivationState',
icon='mdi:radar',
entity_category='diagnostic',
value_fn=lambda value: str(value).lower() == 'on',
key="periodic_air_sensing",
field="x.com.samsung.da.periodicSensingActivationState",
icon="mdi:radar",
entity_category="diagnostic",
value_fn=lambda value: str(value).lower() == "on",
),
SensorDesc(
key='air_sensing_state',
field='x.com.samsung.da.sensingState',
icon='mdi:radar',
entity_category='diagnostic',
key="air_sensing_state",
field="x.com.samsung.da.sensingState",
icon="mdi:radar",
entity_category="diagnostic",
),
SensorDesc(
key='last_air_sensing_time',
field='x.com.samsung.da.lastSensingTime',
device_class='timestamp',
entity_category='diagnostic',
key="last_air_sensing_time",
field="x.com.samsung.da.lastSensingTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=_timestamp,
),
SensorDesc(
key='last_air_sensing_level',
field='x.com.samsung.da.lastSensingLevel',
icon='mdi:air-filter',
entity_category='diagnostic',
key="last_air_sensing_level",
field="x.com.samsung.da.lastSensingLevel",
icon="mdi:air-filter",
entity_category="diagnostic",
),
SensorDesc(
key='automatic_ventilation_state',
field='x.com.samsung.da.autoExeState',
icon='mdi:fan-auto',
entity_category='diagnostic',
key="automatic_ventilation_state",
field="x.com.samsung.da.autoExeState",
icon="mdi:fan-auto",
entity_category="diagnostic",
),
),
)
AUTO_VENTILATION = Capability(
href='/autoventilation/vs/0',
poll_tier='warm',
href="/autoventilation/vs/0",
poll_tier="warm",
entities=(
SensorDesc(
key='auto_ventilation_action',
field='action',
icon='mdi:fan-auto',
key="auto_ventilation_action",
field="action",
icon="mdi:fan-auto",
),
),
)
@@ -326,11 +322,11 @@ AUTO_VENTILATION = Capability(
COVERAGE = [
Capability(href=href)
for href in (
'/power/0',
'/power/vs/0',
'/mode/vs/0',
'/personality/presence/vs/0',
'/availablecontrolsets/vs/0',
'/da/softreset/vs/0',
"/power/0",
"/power/vs/0",
"/mode/vs/0",
"/personality/presence/vs/0",
"/availablecontrolsets/vs/0",
"/da/softreset/vs/0",
)
]
@@ -15,19 +15,23 @@ docstring for that rule).
Resources verified against the issue #131 diagnostics dump.
"""
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
from .common import int_or_none
from .common import parse_iso_utc as _parse_iso_utc
DUSTBAG = Capability(
href='/component/station/dustbag/vs/0',
poll_tier='warm',
href="/component/station/dustbag/vs/0",
poll_tier="warm",
entities=(
BinarySensorDesc(key='dustbag_full', field='x.com.samsung.da.status',
device_class='problem',
icon='mdi:bag-personal',
value_fn=lambda v: v == 'full'),
BinarySensorDesc(
key="dustbag_full",
field="x.com.samsung.da.status",
device_class="problem",
icon="mdi:bag-personal",
value_fn=lambda v: v == "full",
),
),
)
@@ -36,40 +40,57 @@ DUSTBAG = Capability(
# families' filterUsage, which always ships filterCapacity) -- exposed as a
# plain diagnostic count rather than guessing a unit.
DUSTBAG_USAGE = Capability(
href='/component/station/dustbagusage/vs/0',
poll_tier='cold',
href="/component/station/dustbagusage/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='dustbag_usage', field='x.com.samsung.da.dustbagUsage',
icon='mdi:counter', entity_category='diagnostic',
state_class='total_increasing', value_fn=int_or_none),
SensorDesc(
key="dustbag_usage",
field="x.com.samsung.da.dustbagUsage",
icon="mdi:counter",
entity_category="diagnostic",
state_class="total_increasing",
value_fn=int_or_none,
),
),
)
DUSTBIN_SETTING = Capability(
href='/setting/dustbin/vs/0',
poll_tier='warm',
href="/setting/dustbin/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='auto_empty', field='x.com.samsung.da.autoEmpty',
icon='mdi:delete-empty',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.autoEmpty': 'On' if p == 'On' else 'Off'})),
SwitchDesc(key='dustbin_auto_close', field='x.com.samsung.da.autoClose',
icon='mdi:door-sliding',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.autoClose': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='discharging_time', field='x.com.samsung.da.desiredDischargingTime',
icon='mdi:timer-outline',
entity_category='config',
options_field='x.com.samsung.da.supportedDischargingTime',
write_fn=lambda p, rep, href=None: (
['setting', 'dustbin', 'vs', '0'],
{'x.com.samsung.da.desiredDischargingTime': p})),
SwitchDesc(
key="auto_empty",
field="x.com.samsung.da.autoEmpty",
icon="mdi:delete-empty",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["setting", "dustbin", "vs", "0"],
{"x.com.samsung.da.autoEmpty": "On" if p == "On" else "Off"},
),
),
SwitchDesc(
key="dustbin_auto_close",
field="x.com.samsung.da.autoClose",
icon="mdi:door-sliding",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["setting", "dustbin", "vs", "0"],
{"x.com.samsung.da.autoClose": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="discharging_time",
field="x.com.samsung.da.desiredDischargingTime",
icon="mdi:timer-outline",
entity_category="config",
options_field="x.com.samsung.da.supportedDischargingTime",
write_fn=lambda p, rep, href=None: (
["setting", "dustbin", "vs", "0"],
{"x.com.samsung.da.desiredDischargingTime": p},
),
),
),
)
@@ -78,32 +99,60 @@ DUSTBIN_SETTING = Capability(
# rather than a binary_sensor, so no polarity/semantic is asserted that
# might be wrong.
CLEANSTATION_STATUS = Capability(
href='/status/cleanstation/vs/0',
poll_tier='warm',
href="/status/cleanstation/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='cleanstation_status', field='x.com.samsung.da.status',
icon='mdi:home-lightning-bolt', entity_category='diagnostic'),
SensorDesc(key='stick_status', field='x.com.samsung.da.stickStatus',
icon='mdi:broom', entity_category='diagnostic'),
SwitchDesc(key='uvc_intensive_mode', field='x.com.samsung.da.uvcIntensive',
icon='mdi:lightbulb-on-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['status', 'cleanstation', 'vs', '0'],
{'x.com.samsung.da.uvcIntensive': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='uvc_operation_time', field='x.com.samsung.da.uvcOperationTime',
icon='mdi:timer-sand', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(key='uvc_total_operation_time',
field='x.com.samsung.da.uvcTotalOperationTime',
icon='mdi:timer-sand', entity_category='diagnostic',
state_class='total_increasing', value_fn=int_or_none),
SensorDesc(key='uvc_finished_time', field='x.com.samsung.da.uvcFinishedTime',
device_class='timestamp', entity_category='diagnostic',
value_fn=_parse_iso_utc),
SensorDesc(key='uvc_emitted_time', field='x.com.samsung.da.emittedTime',
device_class='timestamp', entity_category='diagnostic',
value_fn=_parse_iso_utc),
SensorDesc(
key="cleanstation_status",
field="x.com.samsung.da.status",
icon="mdi:home-lightning-bolt",
entity_category="diagnostic",
),
SensorDesc(
key="stick_status",
field="x.com.samsung.da.stickStatus",
icon="mdi:broom",
entity_category="diagnostic",
),
SwitchDesc(
key="uvc_intensive_mode",
field="x.com.samsung.da.uvcIntensive",
icon="mdi:lightbulb-on-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["status", "cleanstation", "vs", "0"],
{"x.com.samsung.da.uvcIntensive": "On" if p == "On" else "Off"},
),
),
SensorDesc(
key="uvc_operation_time",
field="x.com.samsung.da.uvcOperationTime",
icon="mdi:timer-sand",
entity_category="diagnostic",
value_fn=int_or_none,
),
SensorDesc(
key="uvc_total_operation_time",
field="x.com.samsung.da.uvcTotalOperationTime",
icon="mdi:timer-sand",
entity_category="diagnostic",
state_class="total_increasing",
value_fn=int_or_none,
),
SensorDesc(
key="uvc_finished_time",
field="x.com.samsung.da.uvcFinishedTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=_parse_iso_utc,
),
SensorDesc(
key="uvc_emitted_time",
field="x.com.samsung.da.emittedTime",
device_class="timestamp",
entity_category="diagnostic",
value_fn=_parse_iso_utc,
),
),
)
@@ -14,12 +14,18 @@ specific controls (wash settings, drum-clean tracking, dispenser dosing) are
here; they read washer-only fields off the same shared /course/vs/0 options
array.
"""
from datetime import datetime, timezone
from datetime import UTC, datetime
from ..capability import Capability
from ..entities import BinarySensorDesc, SelectDesc, SensorDesc
from .laundry import (
bool_option_exists, bool_option_switch, cycle_options, cycle_select, hex_pairs, option_value,
bool_option_exists,
bool_option_switch,
cycle_options,
cycle_select,
hex_pairs,
option_value,
option_write,
)
@@ -81,39 +87,58 @@ from .laundry import (
# ---------------------------------------------------------------------------
WASHER_SETTINGS = Capability(
href='/washer/vs/0',
href="/washer/vs/0",
entities=(
SelectDesc(key='wash_temperature', field='x.com.samsung.da.waterTemperature',
icon='mdi:thermometer-water',
entity_category='config',
options_field='x.com.samsung.da.supportedWaterTemperature',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.waterTemperature': p})),
SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel',
icon='mdi:sync',
entity_category='config',
options_field='x.com.samsung.da.supportedSpinLevel',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': p})),
SelectDesc(key='rinse_cycles', field='x.com.samsung.da.rinseCycles',
icon='mdi:water-sync',
entity_category='config',
options_field='x.com.samsung.da.supportedRinseCycles',
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.rinseCycles': p})),
SelectDesc(
key="wash_temperature",
field="x.com.samsung.da.waterTemperature",
icon="mdi:thermometer-water",
entity_category="config",
options_field="x.com.samsung.da.supportedWaterTemperature",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.waterTemperature": p},
),
),
SelectDesc(
key="spin_speed",
field="x.com.samsung.da.spinLevel",
icon="mdi:sync",
entity_category="config",
options_field="x.com.samsung.da.supportedSpinLevel",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.spinLevel": p},
),
),
SelectDesc(
key="rinse_cycles",
field="x.com.samsung.da.rinseCycles",
icon="mdi:water-sync",
entity_category="config",
options_field="x.com.samsung.da.supportedRinseCycles",
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.rinseCycles": p},
),
),
# Washer/dryer combo units carry a dryLevel field on the wash
# resource itself (no separate dryer device/course) -- see issue
# #22. Self-gates off on plain washers, which never report
# supportedDryLevel.
SelectDesc(key='dry_level', field='x.com.samsung.da.dryLevel',
icon='mdi:tumble-dryer',
entity_category='config',
translation_key='washer_dry_level',
options_field='x.com.samsung.da.supportedDryLevel',
exists_fn=lambda rep, resources: bool(
rep.get('x.com.samsung.da.supportedDryLevel')),
write_fn=lambda p, rep, href=None: (
['washer', 'vs', '0'], {'x.com.samsung.da.dryLevel': p})),
SelectDesc(
key="dry_level",
field="x.com.samsung.da.dryLevel",
icon="mdi:tumble-dryer",
entity_category="config",
translation_key="washer_dry_level",
options_field="x.com.samsung.da.supportedDryLevel",
exists_fn=lambda rep, resources: bool(rep.get("x.com.samsung.da.supportedDryLevel")),
write_fn=lambda p, rep, href=None: (
["washer", "vs", "0"],
{"x.com.samsung.da.dryLevel": p},
),
),
),
)
@@ -135,9 +160,9 @@ WASHER_SETTINGS = Capability(
# so it's treated as UTC, matching this integration's convention for other
# bare ISO datetime fields (see fridge.py's night-light schedule comment).
def _drum_clean_cycles_remaining(rep):
opts = rep.get('x.com.samsung.da.options') or []
proposal = option_value(opts, 'DrumCleanProposal')
washed = option_value(opts, 'WashingTimes')
opts = rep.get("x.com.samsung.da.options") or []
proposal = option_value(opts, "DrumCleanProposal")
washed = option_value(opts, "WashingTimes")
if proposal is None or washed is None:
return None
try:
@@ -147,11 +172,11 @@ def _drum_clean_cycles_remaining(rep):
def _drum_clean_last_cleaned(rep):
raw = option_value(rep.get('x.com.samsung.da.options'), 'DrumCleanLog')
raw = option_value(rep.get("x.com.samsung.da.options"), "DrumCleanLog")
if not raw:
return None
try:
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
return datetime.fromisoformat(raw).replace(tzinfo=UTC)
except ValueError:
return None
@@ -183,8 +208,8 @@ def _drum_clean_last_cleaned(rep):
# otherwise identical in shape to the detergent side. Revisit if a second
# device's dump contradicts this.
def _supported_level_options(resources, prefix):
rep = resources.get('/course/vs/0') or {}
raw = option_value(rep.get('x.com.samsung.da.options'), f'Supported{prefix}')
rep = resources.get("/course/vs/0") or {}
raw = option_value(rep.get("x.com.samsung.da.options"), f"Supported{prefix}")
return hex_pairs(raw) if raw else []
@@ -202,12 +227,13 @@ def _dosing_level(prefix):
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')
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}')
supported_raw = option_value(opts, f"Supported{prefix}")
try:
target = int(raw, 16)
except (TypeError, ValueError):
@@ -219,29 +245,32 @@ def _dosing_level(prefix):
except (TypeError, ValueError):
continue
return raw
return fn
def _level_write(prefix):
def write(p, rep, href=None):
if not rep.get('x.com.samsung.da.options'):
if not rep.get("x.com.samsung.da.options"):
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')
native = format(int(p, 16), "X")
except (TypeError, ValueError):
native = p
return ['course', 'vs', '0'], {
'x.com.samsung.da.options': option_write(prefix, native),
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write(prefix, native),
}
return write
def _dosing_low(prefix):
return lambda rep: option_value(
rep.get('x.com.samsung.da.options'), prefix) not in (None, 'Off')
return lambda rep: (
option_value(rep.get("x.com.samsung.da.options"), prefix) not in (None, "Off")
)
# Bubble soak / pre-wash / intensive-wash toggles, from the same options[]
@@ -276,10 +305,10 @@ def _bool_option_switch(key, icon, prefix, availability_field):
resolved (unrecognized course, missing/mismatched-length bitmap)
rather than guessing -- a false rejection is worse than an
occasional no-op write."""
if p != 'On':
if p != "On":
return None
opts = rep.get('x.com.samsung.da.options') or []
current = option_value(opts, 'Course')
opts = rep.get("x.com.samsung.da.options") or []
current = option_value(opts, "Course")
courses = cycle_options(resources)
if not current or current not in courses:
return None
@@ -289,75 +318,99 @@ def _bool_option_switch(key, icon, prefix, availability_field):
pairs = hex_pairs(raw)
if len(pairs) != len(courses):
return None
if pairs[courses.index(current)] != 'F0':
if pairs[courses.index(current)] != "F0":
return f"{key}_unavailable_for_cycle"
return None
return bool_option_switch(
key, icon, prefix,
entity_category='config', gate_on_presence=True, validate_fn=validate)
key, icon, prefix, entity_category="config", gate_on_presence=True, validate_fn=validate
)
WASHER_COURSE = Capability(
href='/course/vs/0',
href="/course/vs/0",
entities=(
cycle_select(translation_key='washer_cycle', icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0'),
SensorDesc(key='drum_clean_cycles_remaining', unit='cycles',
icon='mdi:washing-machine-alert',
state_class='measurement',
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining),
SensorDesc(key='drum_clean_last_cleaned', device_class='timestamp',
icon='mdi:calendar-clock',
entity_category='diagnostic',
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned),
SelectDesc(key='detergent_quantity', icon='mdi:cup-water',
translation_key='detergent_quantity',
entity_category='config',
options=_level_options('DetergentLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevelCtrl')(resources)),
rep_fn=_dosing_level('DetergentLevelCtrl'),
write_fn=_level_write('DetergentLevelCtrl')),
SelectDesc(key='detergent_water_hardness', icon='mdi:water-opacity',
translation_key='detergent_water_hardness',
entity_category='config',
options=_level_options('DetergentLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('DetergentLevel2Ctrl')(resources)),
rep_fn=_dosing_level('DetergentLevel2Ctrl'),
write_fn=_level_write('DetergentLevel2Ctrl')),
SelectDesc(key='softener_quantity', icon='mdi:flask-outline',
translation_key='softener_quantity',
entity_category='config',
options=_level_options('SoftenerLevelCtrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevelCtrl')(resources)),
rep_fn=_dosing_level('SoftenerLevelCtrl'),
write_fn=_level_write('SoftenerLevelCtrl')),
SelectDesc(key='softener_concentration', icon='mdi:flask-plus-outline',
translation_key='softener_concentration',
entity_category='config',
options=_level_options('SoftenerLevel2Ctrl'),
exists_fn=lambda rep, resources: bool(
_level_options('SoftenerLevel2Ctrl')(resources)),
rep_fn=_dosing_level('SoftenerLevel2Ctrl'),
write_fn=_level_write('SoftenerLevel2Ctrl')),
BinarySensorDesc(key='detergent_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('DetergentAlarm'),
rep_fn=_dosing_low('DetergentAlarm')),
BinarySensorDesc(key='softener_low', device_class='problem',
icon='mdi:alert-circle-outline',
exists_fn=bool_option_exists('SoftenerAlarm'),
rep_fn=_dosing_low('SoftenerAlarm')),
_bool_option_switch('bubble_soak', 'mdi:chart-bubble',
'BubbleSoak', 'BubbleSoakSet'),
_bool_option_switch('pre_wash', 'mdi:washing-machine',
'PreWashSetting', 'PreWashAvailableSet'),
_bool_option_switch('intensive', 'mdi:washing-machine',
'IntensiveSetting', 'IntensiveAvailableSet'),
cycle_select(
translation_key="washer_cycle",
icon="mdi:washing-machine",
table_href="/st/washercourse/vs/0",
),
SensorDesc(
key="drum_clean_cycles_remaining",
unit="cycles",
icon="mdi:washing-machine-alert",
state_class="measurement",
exists_fn=lambda rep, resources: _drum_clean_cycles_remaining(rep) is not None,
rep_fn=_drum_clean_cycles_remaining,
),
SensorDesc(
key="drum_clean_last_cleaned",
device_class="timestamp",
icon="mdi:calendar-clock",
entity_category="diagnostic",
exists_fn=lambda rep, resources: _drum_clean_last_cleaned(rep) is not None,
rep_fn=_drum_clean_last_cleaned,
),
SelectDesc(
key="detergent_quantity",
icon="mdi:cup-water",
translation_key="detergent_quantity",
entity_category="config",
options=_level_options("DetergentLevelCtrl"),
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevelCtrl")(resources)),
rep_fn=_dosing_level("DetergentLevelCtrl"),
write_fn=_level_write("DetergentLevelCtrl"),
),
SelectDesc(
key="detergent_water_hardness",
icon="mdi:water-opacity",
translation_key="detergent_water_hardness",
entity_category="config",
options=_level_options("DetergentLevel2Ctrl"),
exists_fn=lambda rep, resources: bool(_level_options("DetergentLevel2Ctrl")(resources)),
rep_fn=_dosing_level("DetergentLevel2Ctrl"),
write_fn=_level_write("DetergentLevel2Ctrl"),
),
SelectDesc(
key="softener_quantity",
icon="mdi:flask-outline",
translation_key="softener_quantity",
entity_category="config",
options=_level_options("SoftenerLevelCtrl"),
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevelCtrl")(resources)),
rep_fn=_dosing_level("SoftenerLevelCtrl"),
write_fn=_level_write("SoftenerLevelCtrl"),
),
SelectDesc(
key="softener_concentration",
icon="mdi:flask-plus-outline",
translation_key="softener_concentration",
entity_category="config",
options=_level_options("SoftenerLevel2Ctrl"),
exists_fn=lambda rep, resources: bool(_level_options("SoftenerLevel2Ctrl")(resources)),
rep_fn=_dosing_level("SoftenerLevel2Ctrl"),
write_fn=_level_write("SoftenerLevel2Ctrl"),
),
BinarySensorDesc(
key="detergent_low",
device_class="problem",
icon="mdi:alert-circle-outline",
exists_fn=bool_option_exists("DetergentAlarm"),
rep_fn=_dosing_low("DetergentAlarm"),
),
BinarySensorDesc(
key="softener_low",
device_class="problem",
icon="mdi:alert-circle-outline",
exists_fn=bool_option_exists("SoftenerAlarm"),
rep_fn=_dosing_low("SoftenerAlarm"),
),
_bool_option_switch("bubble_soak", "mdi:chart-bubble", "BubbleSoak", "BubbleSoakSet"),
_bool_option_switch(
"pre_wash", "mdi:washing-machine", "PreWashSetting", "PreWashAvailableSet"
),
_bool_option_switch(
"intensive", "mdi:washing-machine", "IntensiveSetting", "IntensiveAvailableSet"
),
),
)
@@ -4,22 +4,27 @@ issue #196, model RWP70F15ANW/AILITE_WATERPURIFIER_25K).
Resources verified against the issue #90 and #196 diagnostics dumps.
"""
from ..batch import is_stub_rep
from ..capability import Capability
from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
from .common import int_or_none
from .common import parse_iso_utc as _parse_iso_utc
DISPENSE = Capability(
href='/setting/waterpurifier/vs/0',
poll_tier='warm',
href="/setting/waterpurifier/vs/0",
poll_tier="warm",
entities=(
SelectDesc(key='dispense_type', field='x.com.samsung.da.desiredType',
icon='mdi:cup-water',
options_field='x.com.samsung.da.supportedTypes',
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.desiredType': p})),
SelectDesc(
key="dispense_type",
field="x.com.samsung.da.desiredType",
icon="mdi:cup-water",
options_field="x.com.samsung.da.supportedTypes",
write_fn=lambda p, rep, href=None: (
["setting", "waterpurifier", "vs", "0"],
{"x.com.samsung.da.desiredType": p},
),
),
# Only a handful of discrete temperatures are selectable (not a
# continuous range) -- a select over the live-reported set, not a
# number with invented bounds.
@@ -33,16 +38,20 @@ DISPENSE = Capability(
# list and renders as "unknown" -- the exact symptom reported. Gate
# the entity off entirely when the board doesn't report a supported
# list, rather than guess at hotwaterRange/hotwaterLevel's meaning.
SelectDesc(key='hot_water_temperature', field='x.com.samsung.da.tempDesiredHotWater',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.supportedHotTemperatures',
exists_fn=lambda rep, resources: (
is_stub_rep(rep)
or 'x.com.samsung.da.supportedHotTemperatures' in rep),
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.tempDesiredHotWater': p})),
SelectDesc(
key="hot_water_temperature",
field="x.com.samsung.da.tempDesiredHotWater",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.supportedHotTemperatures",
exists_fn=lambda rep, resources: (
is_stub_rep(rep) or "x.com.samsung.da.supportedHotTemperatures" in rep
),
write_fn=lambda p, rep, href=None: (
["setting", "waterpurifier", "vs", "0"],
{"x.com.samsung.da.tempDesiredHotWater": p},
),
),
# Bounds and step come live from the device's own
# desiredCapacityRange/capacityResolution fields, not a hardcoded
# constant -- see the adding-device-support skill's "never hard-code
@@ -50,70 +59,103 @@ DISPENSE = Capability(
# "C" on this dump, which can't be right for a volume field, so per
# the 'don't guess' rule the unit is left unset rather than assumed
# to be mL.
NumberDesc(key='dispense_capacity', field='x.com.samsung.da.desiredCapacity',
icon='mdi:cup-water',
value_fn=int_or_none,
range_field='x.com.samsung.da.desiredCapacityRange',
step_fn=lambda rep: int_or_none(
rep.get('x.com.samsung.da.capacityResolution')) or 1,
write_fn=lambda p, rep, href=None: (
['setting', 'waterpurifier', 'vs', '0'],
{'x.com.samsung.da.desiredCapacity': str(int(round(float(p))))})),
BinarySensorDesc(key='pouring', field='x.com.samsung.da.pourStatus',
icon='mdi:cup-water',
value_fn=lambda v: v == 'On'),
NumberDesc(
key="dispense_capacity",
field="x.com.samsung.da.desiredCapacity",
icon="mdi:cup-water",
value_fn=int_or_none,
range_field="x.com.samsung.da.desiredCapacityRange",
step_fn=lambda rep: int_or_none(rep.get("x.com.samsung.da.capacityResolution")) or 1,
write_fn=lambda p, rep, href=None: (
["setting", "waterpurifier", "vs", "0"],
{"x.com.samsung.da.desiredCapacity": str(round(float(p)))},
),
),
BinarySensorDesc(
key="pouring",
field="x.com.samsung.da.pourStatus",
icon="mdi:cup-water",
value_fn=lambda v: v == "On",
),
),
)
STATUS = Capability(
href='/status/waterpurifier/vs/0',
poll_tier='warm',
href="/status/waterpurifier/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='waterpurifier_status', field='x.com.samsung.da.status',
icon='mdi:water-pump',
entity_category='diagnostic'),
BinarySensorDesc(key='filter_door_status', field='x.com.samsung.da.filterDoorStatus',
device_class='door',
entity_category='diagnostic',
value_fn=lambda v: v == 'Open'),
SensorDesc(key='sterilize_period', field='x.com.samsung.da.sterilizePeriod',
icon='mdi:calendar-sync',
entity_category='diagnostic'),
SensorDesc(key='sterilize_run_time', field='x.com.samsung.da.sterilizeRunTime',
icon='mdi:timer-outline',
entity_category='diagnostic'),
SensorDesc(key='sterilize_last_time', device_class='timestamp',
entity_category='diagnostic',
rep_fn=lambda rep: _parse_iso_utc(
rep.get('x.com.samsung.da.sterilizeLastTime'))),
SensorDesc(key='sterilize_plan_time', device_class='timestamp',
entity_category='diagnostic',
rep_fn=lambda rep: _parse_iso_utc(
rep.get('x.com.samsung.da.sterilizePlanTime'))),
SensorDesc(key='filter_clean_remain_time', field='x.com.samsung.da.filterCleanRemainTime',
icon='mdi:timer-sand',
entity_category='diagnostic'),
SensorDesc(
key="waterpurifier_status",
field="x.com.samsung.da.status",
icon="mdi:water-pump",
entity_category="diagnostic",
),
BinarySensorDesc(
key="filter_door_status",
field="x.com.samsung.da.filterDoorStatus",
device_class="door",
entity_category="diagnostic",
value_fn=lambda v: v == "Open",
),
SensorDesc(
key="sterilize_period",
field="x.com.samsung.da.sterilizePeriod",
icon="mdi:calendar-sync",
entity_category="diagnostic",
),
SensorDesc(
key="sterilize_run_time",
field="x.com.samsung.da.sterilizeRunTime",
icon="mdi:timer-outline",
entity_category="diagnostic",
),
SensorDesc(
key="sterilize_last_time",
device_class="timestamp",
entity_category="diagnostic",
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizeLastTime")),
),
SensorDesc(
key="sterilize_plan_time",
device_class="timestamp",
entity_category="diagnostic",
rep_fn=lambda rep: _parse_iso_utc(rep.get("x.com.samsung.da.sterilizePlanTime")),
),
SensorDesc(
key="filter_clean_remain_time",
field="x.com.samsung.da.filterCleanRemainTime",
icon="mdi:timer-sand",
entity_category="diagnostic",
),
),
)
FAVORITE_CAPACITY = Capability(
href='/favorite/capacity/vs/0',
poll_tier='cold',
href="/favorite/capacity/vs/0",
poll_tier="cold",
entities=(
SwitchDesc(key='favorite_capacity_enabled', field='x.com.samsung.da.switchCapacity',
icon='mdi:star-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['favorite', 'capacity', 'vs', '0'],
{'x.com.samsung.da.switchCapacity': 'On' if p == 'On' else 'Off'})),
SelectDesc(key='favorite_capacity', field='x.com.samsung.da.defaultCapacity',
icon='mdi:cup-water',
entity_category='config',
options_field='x.com.samsung.da.capacityList',
write_fn=lambda p, rep, href=None: (
['favorite', 'capacity', 'vs', '0'],
{'x.com.samsung.da.defaultCapacity': p})),
SwitchDesc(
key="favorite_capacity_enabled",
field="x.com.samsung.da.switchCapacity",
icon="mdi:star-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["favorite", "capacity", "vs", "0"],
{"x.com.samsung.da.switchCapacity": "On" if p == "On" else "Off"},
),
),
SelectDesc(
key="favorite_capacity",
field="x.com.samsung.da.defaultCapacity",
icon="mdi:cup-water",
entity_category="config",
options_field="x.com.samsung.da.capacityList",
write_fn=lambda p, rep, href=None: (
["favorite", "capacity", "vs", "0"],
{"x.com.samsung.da.defaultCapacity": p},
),
),
),
)
@@ -135,17 +177,17 @@ def _status_lock_definitely_lacks_hotwater_field(resources: dict) -> bool:
too would register both descriptors -- as SwitchDescs sharing one key,
with identical unique_ids -- until the next poll resolves it.
- href present and fetched -> the real answer."""
rep = resources.get('/status/lock/vs/0')
rep = resources.get("/status/lock/vs/0")
if rep is None:
return True
if not rep:
return False
return 'x.com.samsung.da.hotwaterLock' not in rep
return "x.com.samsung.da.hotwaterLock" not in rep
FAVORITE_HOTWATER = Capability(
href='/favorite/hotwater/vs/0',
poll_tier='cold',
href="/favorite/hotwater/vs/0",
poll_tier="cold",
entities=(
# Despite the resource/field naming, switchHotwater's value domain is
# Locked/Unlocked, not an enable flag (issue #144) -- it's the same
@@ -165,16 +207,21 @@ FAVORITE_HOTWATER = Capability(
# also re-asserts its own field's presence (switchHotwater), the
# gate a bare `field=` used to get for free before it had to share a
# key with LOCK's descriptor.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.switchHotwater',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
'x.com.samsung.da.switchHotwater' in rep
and _status_lock_definitely_lacks_hotwater_field(resources)),
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.switchHotwater': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(
key="hotwater_lock",
field="x.com.samsung.da.switchHotwater",
device_class="lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
exists_fn=lambda rep, resources: (
"x.com.samsung.da.switchHotwater" in rep
and _status_lock_definitely_lacks_hotwater_field(resources)
),
write_fn=lambda p, rep, href=None: (
["favorite", "hotwater", "vs", "0"],
{"x.com.samsung.da.switchHotwater": "Locked" if p == "On" else "Unlocked"},
),
),
# Issue #196: `supportedList` is only the four *fixed* presets
# (e.g. ['40', '75', '85', '90']) -- the SmartThings app also lets
# the user add one custom value to their own display list via its
@@ -187,33 +234,43 @@ FAVORITE_HOTWATER = Capability(
# "unknown" -- not a coverage gap, just the wrong field. `showList`
# is a superset of `supportedList` that always includes whatever
# the current default actually is, custom or not.
SelectDesc(key='favorite_hotwater_temperature',
field='x.com.samsung.da.favorite.defaultTemperature',
icon='mdi:thermometer',
entity_category='config',
options_field='x.com.samsung.da.favorite.showList',
write_fn=lambda p, rep, href=None: (
['favorite', 'hotwater', 'vs', '0'],
{'x.com.samsung.da.favorite.defaultTemperature': p})),
SelectDesc(
key="favorite_hotwater_temperature",
field="x.com.samsung.da.favorite.defaultTemperature",
icon="mdi:thermometer",
entity_category="config",
options_field="x.com.samsung.da.favorite.showList",
write_fn=lambda p, rep, href=None: (
["favorite", "hotwater", "vs", "0"],
{"x.com.samsung.da.favorite.defaultTemperature": p},
),
),
),
)
# Coffee-capable variant (issue #107). No 'x.com.samsung.da.' field prefix
# on this resource, unlike the rest of the water-purifier surface.
COFFEE = Capability(
href='/favorite/coffee/vs/0',
poll_tier='warm',
href="/favorite/coffee/vs/0",
poll_tier="warm",
entities=(
SwitchDesc(key='favorite_coffee_enabled', field='favorite.activate',
icon='mdi:coffee-outline',
entity_category='config',
value_fn=lambda v: v == 'On',
write_fn=lambda p, rep, href=None: (
['favorite', 'coffee', 'vs', '0'],
{'favorite.activate': 'On' if p == 'On' else 'Off'})),
SensorDesc(key='coffee_brew_status', field='brew.status',
icon='mdi:coffee-outline',
entity_category='diagnostic'),
SwitchDesc(
key="favorite_coffee_enabled",
field="favorite.activate",
icon="mdi:coffee-outline",
entity_category="config",
value_fn=lambda v: v == "On",
write_fn=lambda p, rep, href=None: (
["favorite", "coffee", "vs", "0"],
{"favorite.activate": "On" if p == "On" else "Off"},
),
),
SensorDesc(
key="coffee_brew_status",
field="brew.status",
icon="mdi:coffee-outline",
entity_category="diagnostic",
),
),
)
@@ -221,11 +278,15 @@ COFFEE = Capability(
# the full state domain isn't confirmed, so this stays a plain diagnostic
# sensor rather than an enum with an invented state table.
CUP_STATE = Capability(
href='/cup/state/vs/0',
poll_tier='warm',
href="/cup/state/vs/0",
poll_tier="warm",
entities=(
SensorDesc(key='cup_state', field='water.cup.state',
icon='mdi:cup-outline', entity_category='diagnostic'),
SensorDesc(
key="cup_state",
field="water.cup.state",
icon="mdi:cup-outline",
entity_category="diagnostic",
),
),
)
@@ -236,48 +297,64 @@ CUP_STATE = Capability(
# reject a live-supported value, so these read the device's own supported
# list/range like air_purifier's versions do.
SOUND_MODE = Capability(
href='/settings/sound/mode/vs/0',
poll_tier='cold',
href="/settings/sound/mode/vs/0",
poll_tier="cold",
entities=(
SelectDesc(key='sound_mode', translation_key='water_purifier_sound_mode',
field='mode',
icon='mdi:volume-high',
entity_category='config',
options_field='supportedModes',
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})),
SelectDesc(
key="sound_mode",
translation_key="water_purifier_sound_mode",
field="mode",
icon="mdi:volume-high",
entity_category="config",
options_field="supportedModes",
write_fn=lambda p, rep, href=None: (
["settings", "sound", "mode", "vs", "0"],
{"mode": p},
),
),
),
)
SOUND_OUTPUT = Capability(
href='/settings/sound/output/vs/0',
poll_tier='cold',
href="/settings/sound/output/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='sound_output', field='deviceType',
icon='mdi:volume-high', entity_category='diagnostic'),
SensorDesc(
key="sound_output",
field="deviceType",
icon="mdi:volume-high",
entity_category="diagnostic",
),
# No confirmed write contract (no sibling field advertising this as
# user-settable) -- surfaced read-only per the 'don't guess' rule.
BinarySensorDesc(key='alarm_in_mute', field='alarmInMute',
icon='mdi:volume-mute',
entity_category='diagnostic',
value_fn=lambda v: str(v).lower() == 'true'),
BinarySensorDesc(
key="alarm_in_mute",
field="alarmInMute",
icon="mdi:volume-mute",
entity_category="diagnostic",
value_fn=lambda v: str(v).lower() == "true",
),
),
)
SOUND_VOLUME = Capability(
href='/settings/sound/volume/vs/0',
poll_tier='cold',
href="/settings/sound/volume/vs/0",
poll_tier="cold",
entities=(
NumberDesc(key='sound_volume', field='level',
icon='mdi:volume-medium',
entity_category='config',
native_min_fn=lambda rep: int_or_none(rep.get('minLevel')) or 0,
native_max_fn=lambda rep: int_or_none(rep.get('maxLevel')) or 0,
step_fn=lambda rep: int_or_none(rep.get('resolution')) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
['settings', 'sound', 'volume', 'vs', '0'],
{'level': str(int(p))})),
NumberDesc(
key="sound_volume",
field="level",
icon="mdi:volume-medium",
entity_category="config",
native_min_fn=lambda rep: int_or_none(rep.get("minLevel")) or 0,
native_max_fn=lambda rep: int_or_none(rep.get("maxLevel")) or 0,
step_fn=lambda rep: int_or_none(rep.get("resolution")) or 1,
value_fn=int_or_none,
write_fn=lambda p, rep, href=None: (
["settings", "sound", "volume", "vs", "0"],
{"level": str(int(p))},
),
),
),
)
@@ -286,20 +363,28 @@ SOUND_VOLUME = Capability(
# which at least has an -- albeit suspect -- capacityUnit) so it's left
# unitless rather than assumed to be mL.
STATISTIC_POUR = Capability(
href='/statistic/pour/vs/0',
poll_tier='cold',
href="/statistic/pour/vs/0",
poll_tier="cold",
entities=(
SensorDesc(key='last_pour_type', field='last.type',
icon='mdi:cup-water', entity_category='diagnostic'),
SensorDesc(key='last_pour_capacity', field='last.capacity',
icon='mdi:cup-water', entity_category='diagnostic',
value_fn=int_or_none),
SensorDesc(
key="last_pour_type",
field="last.type",
icon="mdi:cup-water",
entity_category="diagnostic",
),
SensorDesc(
key="last_pour_capacity",
field="last.capacity",
icon="mdi:cup-water",
entity_category="diagnostic",
value_fn=int_or_none,
),
),
)
LOCK = Capability(
href='/status/lock/vs/0',
poll_tier='warm',
href="/status/lock/vs/0",
poll_tier="warm",
entities=(
# Shares its key with FAVORITE_HOTWATER's switchHotwater fallback
# above (issue #144); see the comment there for why this half also
@@ -308,29 +393,40 @@ LOCK = Capability(
# "present" here (matches entity.py's own default for a field-less
# gate) since the alternative -- treating an unfetched resource as
# confirmed-absent -- is what let both descriptors register at once.
SwitchDesc(key='hotwater_lock', field='x.com.samsung.da.hotwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
exists_fn=lambda rep, resources: (
not rep or 'x.com.samsung.da.hotwaterLock' in rep),
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.hotwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(key='coldwater_lock', field='x.com.samsung.da.coldwaterLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.coldwaterLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(key='buzz_lock', field='x.com.samsung.da.buzzLock',
device_class='lock',
entity_category='config',
value_fn=lambda v: v != 'Unlocked',
write_fn=lambda p, rep, href=None: (
['status', 'lock', 'vs', '0'],
{'x.com.samsung.da.buzzLock': 'Locked' if p == 'On' else 'Unlocked'})),
SwitchDesc(
key="hotwater_lock",
field="x.com.samsung.da.hotwaterLock",
device_class="lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
exists_fn=lambda rep, resources: not rep or "x.com.samsung.da.hotwaterLock" in rep,
write_fn=lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{"x.com.samsung.da.hotwaterLock": "Locked" if p == "On" else "Unlocked"},
),
),
SwitchDesc(
key="coldwater_lock",
field="x.com.samsung.da.coldwaterLock",
device_class="lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
write_fn=lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{"x.com.samsung.da.coldwaterLock": "Locked" if p == "On" else "Unlocked"},
),
),
SwitchDesc(
key="buzz_lock",
field="x.com.samsung.da.buzzLock",
device_class="lock",
entity_category="config",
value_fn=lambda v: v != "Unlocked",
write_fn=lambda p, rep, href=None: (
["status", "lock", "vs", "0"],
{"x.com.samsung.da.buzzLock": "Locked" if p == "On" else "Unlocked"},
),
),
),
)
@@ -346,18 +442,18 @@ _WP_IGNORED = [
# select. OCF-standard /mode/0 mirrors the same vendor resource but is
# already covered by the global ignored.IGNORED (fridge's OCF-native
# vacation-mode flag shares that href).
'/mode/vs/0',
"/mode/vs/0",
# Static support-flags blob (automation.supported.modes/options) -- no
# live "current automation setting" field to expose.
'/automation/waterpurifier/vs/0',
"/automation/waterpurifier/vs/0",
# Coffee-capable variant (issue #107). All four are static
# capability-advertisement blobs or empty -- no live "current recipe" /
# "current custom slot" field to expose, unlike /favorite/coffee/vs/0
# (COFFEE above), which does carry live brew status.
'/brand/recipe/info/vs/0', # revision + max-brand-count metadata
'/coffee/custom/recipe/vs/0', # publisher.support: allowed custom-recipe slot IDs
'/recipe/coffee/vs/0', # same publisher.support shape, no per-recipe content
'/recipe/coffee/deletion/vs/0', # empty {} on this dump
"/brand/recipe/info/vs/0", # revision + max-brand-count metadata
"/coffee/custom/recipe/vs/0", # publisher.support: allowed custom-recipe slot IDs
"/recipe/coffee/vs/0", # same publisher.support shape, no per-recipe content
"/recipe/coffee/deletion/vs/0", # empty {} on this dump
]
COVERAGE = [Capability(href=h) for h in _WP_IGNORED]
@@ -1,26 +1,27 @@
"""A Capability binds one OCF resource href to the entities it produces."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Callable, Optional
from .entities import SamsungEntityDescription
@dataclass(frozen=True, kw_only=True)
class Capability:
href: Optional[str] = None
href: str | None = None
entities: tuple[SamsungEntityDescription, ...] = ()
poll_tier: str = 'cold' # 'hot' | 'warm' | 'cold'
rt_filter: Optional[str] = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: Optional[str] = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
poll_tier: str = "cold" # 'hot' | 'warm' | 'cold'
rt_filter: str | None = None # bind only if rt_filter in rep.get('rt', ())
href_prefix: str | None = None # pattern caps only: bind only if href starts with this
strip_prefix_in_key: bool = False # strip href_prefix segs before building key_override
# Rep field holding this instance's device-given name (e.g. an ice
# maker's "CUBED_ICE"/"ICE_BITES"), normalized and used as the display
# name prefix in place of the href-derived instance label. Does not
# affect key_override/unique_id -- only what's shown in the UI.
name_field: Optional[str] = None
match_fn: Optional[Callable[[dict, dict], bool]] = None # match_fn(rep, resources) -> bool
name_field: str | None = None
match_fn: Callable[[dict, dict], bool] | None = None # match_fn(rep, resources) -> bool
# Rare optional hook — only operational-state-style resources use this.
on_observation: Optional[Callable[[dict, dict], None]] = None
project: Optional[Callable[[dict, dict], dict]] = None
on_observation: Callable[[dict, dict], None] | None = None
project: Callable[[dict, dict], dict] | None = None
@@ -11,10 +11,11 @@ doesn't have that filter) is *not* a gap — a maintainer already looked at
that href and decided how to handle it. Only hrefs absent from the registry
entirely are reported.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Callable, Iterable, Optional
from .capability import Capability
from .entities import SamsungEntityDescription
@@ -26,9 +27,9 @@ class BoundEntity:
href: str
capability: Capability
desc: SamsungEntityDescription
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
instance: str = ""
key_override: str | None = None
instance_name: str | None = None
# Which logical indoor subdevice (issue #177) this entity belongs to.
# `href` above is always the *actual*, on-the-wire href for that
# subdevice -- MAIN's
@@ -40,10 +41,10 @@ class BoundEntity:
def _snake_to_title(s: str) -> str:
"""'CUBED_ICE'/'cubed_ice' -> 'Cubed Ice'. Shared with entity.py's
_derive_name, which applies the same transform to an href-derived key."""
return s.replace('_', ' ').title()
return s.replace("_", " ").title()
def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
def _instance_name(cap: Capability, rep: dict) -> str | None:
"""Normalize `cap.name_field`'s raw value ("CUBED_ICE" -> "Cubed Ice")
for use as a display-name prefix, or None if the cap doesn't declare
one or the device didn't report it."""
@@ -57,15 +58,20 @@ def _instance_name(cap: Capability, rep: dict) -> Optional[str]:
def instance_suffix(href: str) -> str:
"""'' for the index-0 instance, else '_<n>' from the trailing segment."""
tail = href.rstrip('/').rsplit('/', 1)[-1]
if tail.isdigit() and tail != '0':
return f'_{tail}'
return ''
tail = href.rstrip("/").rsplit("/", 1)[-1]
if tail.isdigit() and tail != "0":
return f"_{tail}"
return ""
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None,
subdevice: Subdevice = MAIN) -> list[BoundEntity]:
def _bind(
cap: Capability,
href: str,
inst: str,
inst_name: str | None,
key_prefix: str | None = None,
subdevice: Subdevice = MAIN,
) -> list[BoundEntity]:
"""Build one BoundEntity per entity on `cap`, sharing the instance/
key-prefix/instance-name computed once by the caller.
@@ -74,10 +80,15 @@ def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
actually reads/writes (identity for MAIN, so single-subdevice devices are
unaffected -- see subdevices.py)."""
return [
BoundEntity(href=subdevice.to_actual(href), capability=cap, desc=desc,
instance=inst,
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
instance_name=inst_name, subdevice=subdevice)
BoundEntity(
href=subdevice.to_actual(href),
capability=cap,
desc=desc,
instance=inst,
key_override=f"{key_prefix}_{desc.key}" if key_prefix else None,
instance_name=inst_name,
subdevice=subdevice,
)
for desc in cap.entities
]
@@ -86,8 +97,8 @@ def discover(
resources: dict[str, dict],
registry: dict[str, list[Capability]],
pattern_caps: Iterable[Capability] = (),
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
log: Callable[[str], None] | None = None,
tier_log: Callable[[str, str], None] | None = None,
subdevice: Subdevice = MAIN,
) -> list[BoundEntity]:
"""`tier_log(href, poll_tier)` fires for every href a capability actually
@@ -114,7 +125,7 @@ def discover(
for href, rep in resources.items():
if not isinstance(rep, dict):
continue
rts = rep.get('rt') or ()
rts = rep.get("rt") or ()
caps = registry.get(href) or []
matched = False
@@ -142,10 +153,17 @@ def discover(
continue
inst = instance_suffix(href)
# Auto-derive key prefix from href segments (skip digits and 'vs')
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
subdevice=subdevice))
src = (
href[len(cap.href_prefix) :]
if (cap.strip_prefix_in_key and cap.href_prefix)
else href
)
segs = [s for s in src.strip("/").split("/") if s and not s.isdigit() and s != "vs"]
out.extend(
_bind(
cap, href, inst, _instance_name(cap, rep), "_".join(segs), subdevice=subdevice
)
)
matched = True
if tier_log is not None:
tier_log(subdevice.to_actual(href), cap.poll_tier)
@@ -6,17 +6,19 @@ presence gating in exists_fn; write logic in write_fn on command platforms;
pre-write rejection (surfaced to the user, not just logged) in validate_fn
where a description declares one.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Callable, Mapping, Optional
from typing import Any
WriteFn = Optional[Callable[[Any, dict], "tuple[list[str], dict] | None"]]
WriteFn = Callable[[Any, dict], "tuple[list[str], dict] | None"] | None
# (payload, rep, resources) -> a translation key, or None to allow the
# write. resources is the coordinator's full href->rep snapshot, for the same
# cross-resource lookups exists_fn needs (e.g. reading a sibling href's live
# option list).
ValidateFn = Optional[Callable[[Any, dict, dict], "str | None"]]
ValidateFn = Callable[[Any, dict, dict], "str | None"] | None
def _identity(v: Any) -> Any:
@@ -26,7 +28,7 @@ def _identity(v: Any) -> Any:
@dataclass(frozen=True, kw_only=True)
class SamsungEntityDescription:
key: str
field: str = ''
field: str = ""
# Defaults to `key`: entity names and states live in translations/, never
# here, so a descriptor only sets this to share one catalog entry across
# several descriptors, or to point at a differently-named one.
@@ -35,28 +37,28 @@ class SamsungEntityDescription:
# snapshot and returns the key to use -- for a descriptor shared across
# board generations whose state-code meaning isn't guaranteed consistent
# between them; see laundry.cycle_select's table-id-gated resolver.
translation_placeholders: Optional[Mapping[str, str]] = None
translation_placeholders: Mapping[str, str] | None = None
# Dynamic resources such as fridge compartments and ice makers use a
# device-provided or href-derived instance label inside a translated name.
use_instance_name: bool = False
icon: Optional[str] = None
entity_category: Optional[str] = None # 'diagnostic' | 'config' | None
icon: str | None = None
entity_category: str | None = None # 'diagnostic' | 'config' | None
enabled_default: bool = True
value_fn: Callable[[Any], Any] = _identity
rep_fn: Optional[Callable[[dict], Any]] = None # replaces field+value_fn; receives full rep
rep_fn: Callable[[dict], Any] | None = None # replaces field+value_fn; receives full rep
# (rep, resources): rep is this entity's own href's representation;
# resources is the coordinator's full href->rep snapshot, for gating
# presence on a sibling resource (e.g. laundry.cycle_options's source).
exists_fn: Optional[Callable[[dict, dict], bool]] = None
exists_fn: Callable[[dict, dict], bool] | None = None
@dataclass(frozen=True, kw_only=True)
class SensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None
state_class: Optional[str] = None
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
options: Optional[tuple] = None # required by HA when device_class == 'enum'
device_class: str | None = None
state_class: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
options: tuple | None = None # required by HA when device_class == 'enum'
# Opt-in: gate this sensor's reported value behind the user-configurable
# CONF_FINISH_TIME_HYSTERESIS_MINUTES threshold (see sensor.py). Only for
# values that are expected to jitter around their "true" value between
@@ -66,48 +68,48 @@ class SensorDesc(SamsungEntityDescription):
@dataclass(frozen=True, kw_only=True)
class BinarySensorDesc(SamsungEntityDescription):
device_class: Optional[str] = None # value_fn must return bool
device_class: str | None = None # value_fn must return bool
@dataclass(frozen=True, kw_only=True)
class SelectDesc(SamsungEntityDescription):
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
options: Any = () # tuple[str,...] | Callable[[dict[str, dict]], list[str]]
# callable form receives the coordinator's full href->rep resource
# snapshot (not just this entity's own href) and returns raw device
# option values; see select.py's LocalThingsSelect._raw_options().
options_field: Optional[str] = None # resource field that contains the live options list
options_field: str | None = None # resource field that contains the live options list
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class SwitchDesc(SamsungEntityDescription):
device_class: Optional[str] = None
device_class: str | None = None
write_fn: WriteFn = None
validate_fn: ValidateFn = None
@dataclass(frozen=True, kw_only=True)
class ButtonDesc(SamsungEntityDescription):
payload: str = ''
payload: str = ""
write_fn: WriteFn = None
@dataclass(frozen=True, kw_only=True)
class NumberDesc(SamsungEntityDescription):
device_class: Optional[str] = None
unit: Optional[str] = None
unit_fn: Optional[Callable[[dict], str]] = None # overrides `unit` from the live rep, when set
native_min: Optional[float] = None
native_max: Optional[float] = None
step: Optional[float] = None
device_class: str | None = None
unit: str | None = None
unit_fn: Callable[[dict], str] | None = None # overrides `unit` from the live rep, when set
native_min: float | None = None
native_max: float | None = None
step: float | None = None
# Override native_min/native_max/step from the live rep, when set --
# same "static default, live override" shape as unit_fn, for resources
# whose sane bounds depend on a per-device value (e.g. a temperature
# setpoint reported in Celsius on one device, Fahrenheit on another).
native_min_fn: Optional[Callable[[dict], float]] = None
native_max_fn: Optional[Callable[[dict], float]] = None
step_fn: Optional[Callable[[dict], float]] = None
range_field: Optional[str] = None # resource field containing [min, max] list
native_min_fn: Callable[[dict], float] | None = None
native_max_fn: Callable[[dict], float] | None = None
step_fn: Callable[[dict], float] | None = None
range_field: str | None = None # resource field containing [min, max] list
write_fn: WriteFn = None
@@ -144,14 +146,14 @@ class WaterHeaterDesc(SamsungEntityDescription):
PLATFORM_OF: dict[type, str] = {
SensorDesc: 'sensor',
BinarySensorDesc: 'binary_sensor',
SelectDesc: 'select',
SwitchDesc: 'switch',
ButtonDesc: 'button',
NumberDesc: 'number',
TimeDesc: 'time',
ClimateDesc: 'climate',
FanDesc: 'fan',
WaterHeaterDesc: 'water_heater',
SensorDesc: "sensor",
BinarySensorDesc: "binary_sensor",
SelectDesc: "select",
SwitchDesc: "switch",
ButtonDesc: "button",
NumberDesc: "number",
TimeDesc: "time",
ClimateDesc: "climate",
FanDesc: "fan",
WaterHeaterDesc: "water_heater",
}
@@ -1,8 +1,8 @@
"""Read device identity from standard OCF resources (/oic/p, /oic/d, /oic/res)."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
import cbor2
@@ -12,9 +12,9 @@ class DeviceIdentity:
manufacturer: str
model: str
name: str
serial: Optional[str]
serial: str | None
device_types: tuple[str, ...] = ()
raw: dict[str, dict] = field(default_factory=dict)
raw: dict[str, dict | list] = field(default_factory=dict)
def _get(sess, path) -> dict:
@@ -55,7 +55,7 @@ def _device_types(d: dict) -> tuple[str, ...]:
(see `raw` below) so incoming issue reports keep surfacing types that
table doesn't know about yet.
"""
rt = d.get('rt')
rt = d.get("rt")
if isinstance(rt, str):
rt = [rt]
if not isinstance(rt, (list, tuple)):
@@ -63,9 +63,9 @@ def _device_types(d: dict) -> tuple[str, ...]:
return tuple(t for t in rt if isinstance(t, str))
def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
p = _get(sess, ['oic', 'p'])
d = _get(sess, ['oic', 'd'])
def read_identity(sess, serial: str | None) -> DeviceIdentity:
p = _get(sess, ["oic", "p"])
d = _get(sess, ["oic", "d"])
# /oic/res is OCF's baseline resource-discovery endpoint: a unicast
# RETRIEVE on it returns every Resource/Collection href this endpoint
# hosts, not just the one /device/0 seed path the coordinator polls.
@@ -78,15 +78,15 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
# /device/2 speculative fallback it used to run right here on every
# _connect_session (including every reconnect), moved to that module so
# it only runs once, at first discovery, instead of on every reconnect.
res = _get_links(sess, ['oic', 'res'])
res = _get_links(sess, ["oic", "res"])
return DeviceIdentity(
manufacturer=p.get('mnmn') or 'Samsung',
model=p.get('mnmo') or '',
name=d.get('n') or '',
manufacturer=p.get("mnmn") or "Samsung",
model=p.get("mnmo") or "",
name=d.get("n") or "",
serial=serial,
device_types=_device_types(d),
# Kept whole rather than field-by-field: these resources are outside
# the /device/0 dump diagnostics already captures, and we don't yet
# know which of their fields will turn out to identify a device type.
raw={'/oic/p': p, '/oic/d': d, '/oic/res': res},
raw={"/oic/p": p, "/oic/d": d, "/oic/res": res},
)
@@ -10,13 +10,24 @@ under — new device types will have unknown-shaped data we can't fully
enumerate in advance, so this errs on catching the field by name rather
than only redacting inside hrefs we already recognize.
"""
from __future__ import annotations
REDACTED = "**REDACTED**"
_SENSITIVE_SUBSTRINGS = (
'mac', 'serial', 'token', 'login', 'account', 'email',
'userid', 'deviceid', 'uuid', 'duid', 'password', 'secret',
"mac",
"serial",
"token",
"login",
"account",
"email",
"userid",
"deviceid",
"uuid",
"duid",
"password",
"secret",
)
# Matched whole, not as substrings. OCF's /oic/d and /oic/p identify the unit
@@ -32,7 +43,7 @@ _SENSITIVE_SUBSTRINGS = (
# reachable when diagnostics started reporting /oic/d, and the
# device-type signal we actually want from that resource is `rt`,
# which is not redacted.
_SENSITIVE_EXACT = frozenset({'di', 'pi', 'n'})
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
def _is_sensitive_key(key: str) -> bool:
@@ -7,6 +7,7 @@ Raises ValueError at import if any href group contains an unfiltered cap
alongside other caps (i.e., a cap with neither rt_filter nor match_fn set
in a group with multiple caps).
"""
from .capabilities import ALL
from .capability import Capability
@@ -32,8 +33,7 @@ def _build() -> dict[str, list[Capability]]:
# Validate: every group with >1 cap must have all caps filtered
for href, caps in out.items():
if len(caps) > 1:
unfiltered = [c for c in caps
if c.rt_filter is None and c.match_fn is None]
unfiltered = [c for c in caps if c.rt_filter is None and c.match_fn is None]
if unfiltered:
raise ValueError(
f"duplicate capability href {href!r} has unfiltered cap(s); "
@@ -87,23 +87,24 @@ populated /energy/consumption/vs/1, a whole-appliance lifetime kWh counter
that materialized the slot as a phantom second air conditioner. See
`_has_live_primary_entity`.
"""
from __future__ import annotations
import re
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from typing import Callable, Optional, Sequence
import cbor2
from .batch import parse_device0_batch
from .by_type._base import DeviceRegistry
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
_INDEXED_HREF_RE = re.compile(r"^/device/(\d+)$")
# A UUID as the first path segment of an /oic/res link href -- Pattern C's
# discovery signal (issue #241): a subdevice tree whose UUID is advertised
# nowhere except as this prefix (no subdeviceIdList, no /device/<n>).
_UUID_PREFIX_RE = re.compile(
r'^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/')
_UUID_PREFIX_RE = re.compile(r"^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/")
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second logical subdevice's Collection on this board (moved here from
@@ -138,16 +139,17 @@ class Subdevice:
individually under its prefix instead of one Collection batch -- see
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
"""
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
seed_path: tuple[str, ...]
flat_hrefs: tuple[str, ...] = ()
def to_actual(self, canonical: str) -> str:
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
on-the-wire href for this subdevice."""
if self.kind == 'indexed':
head, sep, tail = canonical.rpartition('/')
if self.kind == "indexed":
head, sep, tail = canonical.rpartition("/")
# Only the index-0 trailing segment is ours to rewrite --
# deliberately not a "replace any trailing digit" rule, which
# would misread a genuine multi-instance resource (the fridge's
@@ -155,24 +157,24 @@ class Subdevice:
# registry declares a non-zero trailing index today and no
# fixture in the corpus contains one (verified across the whole
# corpus), so the strict rule costs nothing.
if tail == '0':
return f'{head}{sep}{self.key}'
if tail == "0":
return f"{head}{sep}{self.key}"
return canonical
if self.kind == 'prefixed':
return f'/{self.key}{canonical}'
if self.kind == "prefixed":
return f"/{self.key}{canonical}"
return canonical
def to_canonical(self, actual: str) -> Optional[str]:
def to_canonical(self, actual: str) -> str | None:
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
if self.kind == 'indexed':
head, sep, tail = actual.rpartition('/')
if self.kind == "indexed":
head, sep, tail = actual.rpartition("/")
if tail == self.key:
return f'{head}{sep}0'
return f"{head}{sep}0"
return None
if self.kind == 'prefixed':
prefix = f'/{self.key}'
if actual.startswith(prefix + '/'):
return actual[len(prefix):]
if self.kind == "prefixed":
prefix = f"/{self.key}"
if actual.startswith(prefix + "/"):
return actual[len(prefix) :]
return None
return actual
@@ -180,7 +182,7 @@ class Subdevice:
"""True if `actual` belongs to this subdevice's namespace. MAIN never
"owns" anything by this definition -- it gets whatever's left after
every other subdevice's hrefs are excluded (see canonical_view)."""
if self.kind == 'main':
if self.kind == "main":
return False
return self.to_canonical(actual) is not None
@@ -197,19 +199,21 @@ class Subdevice:
(see DESIGN-177.md section 6): HA derives the visible entity_id from
the device name + entity name, not from unique_id.
"""
if self.kind == 'indexed':
return f'subdevice{self.key}_'
if self.kind == 'prefixed':
slug = re.sub(r'[^a-zA-Z0-9]', '', self.key)
return f'subdevice_{slug}_'
return ''
if self.kind == "indexed":
return f"subdevice{self.key}_"
if self.kind == "prefixed":
slug = re.sub(r"[^a-zA-Z0-9]", "", self.key)
return f"subdevice_{slug}_"
return ""
MAIN = Subdevice(kind='main', key='', seed_path=('device', '0'))
MAIN = Subdevice(kind="main", key="", seed_path=("device", "0"))
def canonical_view(
subdevice: Subdevice, resources: dict[str, dict], subdevices: list['Subdevice'],
subdevice: Subdevice,
resources: dict[str, dict],
subdevices: list[Subdevice],
) -> dict[str, dict]:
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'s own
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
@@ -226,11 +230,8 @@ def canonical_view(
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
False, so including it is harmless.
"""
if subdevice.kind == 'main':
owned_elsewhere = {
href for href in resources
if any(su.owns(href) for su in subdevices)
}
if subdevice.kind == "main":
owned_elsewhere = {href for href in resources if any(su.owns(href) for su in subdevices)}
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
return {
canon: resources[actual]
@@ -250,11 +251,11 @@ def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[s
reporter's board was never probed live before the subdevice id was
known), so it's added when missing.
"""
if subdevice.kind != 'prefixed':
if subdevice.kind != "prefixed":
return batch
prefix = f'/{subdevice.key}'
prefix = f"/{subdevice.key}"
return {
(href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep
(href if href.startswith(prefix + "/") else f"{prefix}{href}"): rep
for href, rep in batch.items()
}
@@ -268,14 +269,14 @@ def _iter_oic_res_hrefs(oic_res):
rules it out, and _get_links' own posture already treats any list-shaped
body as possible) and of anything else by yielding nothing.
"""
for entry in (oic_res or []):
for entry in oic_res or []:
if not isinstance(entry, dict):
continue
if 'links' in entry:
for link in entry.get('links') or []:
if "links" in entry:
for link in entry.get("links") or []:
if isinstance(link, dict):
yield link
elif 'href' in entry:
elif "href" in entry:
yield entry
@@ -283,7 +284,7 @@ def _seed_href(path_segs: tuple[str, ...]) -> str:
"""('device', '1') -> '/device/1' -- the leading-slash href form
`probe_log` and diagnostics report, built from the path-segment form
`sess.get` takes."""
return '/' + '/'.join(path_segs)
return "/" + "/".join(path_segs)
def _get_raw(sess, path_segs: tuple[str, ...]):
@@ -322,8 +323,8 @@ def enumerate_subdevices(
sess,
resources: dict[str, dict],
oic_res_links,
probe_log: Optional[Callable[[str, bool], None]] = None,
) -> tuple[list['Subdevice'], dict[str, dict]]:
probe_log: Callable[[str, bool], None] | None = None,
) -> tuple[list[Subdevice], dict[str, dict]]:
"""Discover every sibling indoor subdevice reachable over `sess`'s
connection.
@@ -370,11 +371,11 @@ def enumerate_subdevices(
if sub_id.lower() in probed_ids:
return
probed_ids.add(sub_id.lower())
seed = (sub_id, 'device', '0')
seed = (sub_id, "device", "0")
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if batch:
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
return
@@ -407,22 +408,25 @@ def enumerate_subdevices(
if not first:
sess.pace()
first = False
actual = f'/{sub_id}{href}'
rep = _get_property(sess, tuple(actual.strip('/').split('/')))
_probed(actual, bool(rep))
actual = f"/{sub_id}{href}"
rep = _get_property(sess, tuple(actual.strip("/").split("/")))
_probed(actual, rep)
if rep:
flat_hrefs.append(href)
fetched[actual] = rep
if not flat_hrefs:
return
subdevices.append(Subdevice(
kind='prefixed', key=sub_id, seed_path=(),
flat_hrefs=tuple(flat_hrefs),
))
subdevices.append(
Subdevice(
kind="prefixed",
key=sub_id,
seed_path=(),
flat_hrefs=tuple(flat_hrefs),
)
)
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
'x.com.samsung.da.subdeviceIdList')
raw_ids = (resources.get("/subdevices/vs/0") or {}).get("x.com.samsung.da.subdeviceIdList")
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
@@ -452,22 +456,26 @@ def enumerate_subdevices(
# difference against `listed` here -- is what keeps an id already named
# by subdeviceIdList from being probed and materialized a second time,
# since the two sources can disagree on that UUID's case.
linked = sorted({
m.group(1)
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_UUID_PREFIX_RE.match(link.get('href', ''))]
if m
})
linked = sorted(
{
m.group(1)
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_UUID_PREFIX_RE.match(link.get("href", ""))]
if m
}
)
for sub_id in linked:
_probe_prefixed(sub_id)
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted({
int(m.group(1))
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_INDEXED_HREF_RE.match(link.get('href', ''))]
if m and int(m.group(1)) >= 1
})
indices = sorted(
{
int(m.group(1))
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_INDEXED_HREF_RE.match(link.get("href", ""))]
if m and int(m.group(1)) >= 1
}
)
if not indices:
# A board that hides its whole tree from /oic/res (Pattern B's
# reporter board does this too, but it has no /device/<n> to find
@@ -475,12 +483,12 @@ def enumerate_subdevices(
# bounded speculative probe this replaces from identity.py.
indices = list(_SPECULATIVE_DEVICE_INDICES)
for n in indices:
seed = ('device', str(n))
seed = ("device", str(n))
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if not batch:
continue
subdevice = Subdevice(kind='indexed', key=str(n), seed_path=seed)
subdevice = Subdevice(kind="indexed", key=str(n), seed_path=seed)
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
subdevices.append(subdevice)
@@ -497,11 +505,11 @@ def enumerate_subdevices(
# with the number of subdevices actually materialized is the
# coordinator's call to log (it owns the logger; this module doesn't),
# not this function's.
multidevice_seed = ('multidevice', 'vs', '0')
multidevice_seed = ("multidevice", "vs", "0")
multidevice = _get_property(sess, multidevice_seed)
_probed(_seed_href(multidevice_seed), multidevice)
if multidevice:
fetched['/multidevice/vs/0'] = multidevice
fetched["/multidevice/vs/0"] = multidevice
return subdevices, fetched
@@ -513,6 +521,7 @@ class SkippedSubdevice:
unused SmartThings slot (the Pattern A reporter's `/device/2`), not a
real second subdevice. Kept around (rather than silently dropped) so a
caller can log/report what was skipped and why."""
subdevice: Subdevice
hrefs: tuple[str, ...]
@@ -524,8 +533,8 @@ class SkippedSubdevice:
# descriptor may deliberately declare no state_class (common.ENERGY_METER's
# monthly totals reset at each billing boundary, so they aren't
# `total_increasing`).
_METER_STATE_CLASSES = frozenset({'total', 'total_increasing'})
_METER_DEVICE_CLASSES = frozenset({'energy', 'water', 'gas'})
_METER_STATE_CLASSES = frozenset({"total", "total_increasing"})
_METER_DEVICE_CLASSES = frozenset({"energy", "water", "gas"})
def _is_meter(desc) -> bool:
@@ -533,8 +542,8 @@ def _is_meter(desc) -> bool:
constants above. Only SensorDesc carries either attribute; everything
else answers False through the getattr defaults."""
return (
getattr(desc, 'state_class', None) in _METER_STATE_CLASSES
or getattr(desc, 'device_class', None) in _METER_DEVICE_CLASSES
getattr(desc, "state_class", None) in _METER_STATE_CLASSES
or getattr(desc, "device_class", None) in _METER_DEVICE_CLASSES
)
@@ -571,20 +580,20 @@ def _has_live_primary_entity(bound, state: dict) -> bool:
gate.
"""
from .adapter import _key # see discover_partitioned's deferred-import note
return any(
not b.desc.entity_category and not _is_meter(b.desc)
and state.get(_key(b)) is not None
not b.desc.entity_category and not _is_meter(b.desc) and state.get(_key(b)) is not None
for b in bound
)
def discover_partitioned(
resources: dict[str, dict],
subdevices: list['Subdevice'],
resolve_registry: Callable[..., object],
subdevices: list[Subdevice],
resolve_registry: Callable[..., DeviceRegistry | None],
fallback_capabilities: dict,
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
log: Callable[[str], None] | None = None,
tier_log: Callable[[str, str], None] | None = None,
oic_device_types: Sequence[str] = (),
):
"""Bind every href in `resources` (the merged, real-href snapshot -- main
@@ -649,7 +658,8 @@ def discover_partitioned(
reg = resolve_registry(main_view, device_types=oic_device_types)
caps, pats = (
(reg.capabilities, reg.pattern_capabilities) if reg is not None
(reg.capabilities, reg.pattern_capabilities)
if reg is not None
else (fallback_capabilities, [])
)
# MAIN is never gated -- the config entry's own physical connection
@@ -664,7 +674,8 @@ def discover_partitioned(
view = canonical_view(su, resources, subdevices)
su_reg = resolve_registry(view) or reg
su_caps, su_pats = (
(su_reg.capabilities, su_reg.pattern_capabilities) if su_reg is not None
(su_reg.capabilities, su_reg.pattern_capabilities)
if su_reg is not None
else (fallback_capabilities, [])
)
probe_bound = discover(view, su_caps, su_pats, subdevice=su)
@@ -672,12 +683,19 @@ def discover_partitioned(
if _has_live_primary_entity(probe_bound, probe_state):
materialized.append(su)
bound = bound + discover(
view, su_caps, su_pats, log=log, tier_log=tier_log, subdevice=su,
view,
su_caps,
su_pats,
log=log,
tier_log=tier_log,
subdevice=su,
)
else:
skipped.append(SkippedSubdevice(
subdevice=su,
hrefs=tuple(sorted({b.href for b in probe_bound})),
))
skipped.append(
SkippedSubdevice(
subdevice=su,
hrefs=tuple(sorted({b.href for b in probe_bound})),
)
)
return bound, device_type_name, materialized, skipped
+13 -14
View File
@@ -1,20 +1,20 @@
"""Select platform for Local Things."""
from __future__ import annotations
import re
from typing import Optional
from typing import cast
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SelectDesc
from .catalog import translated_states
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import SelectDesc
async def async_setup_entry(
@@ -30,7 +30,7 @@ async def async_setup_entry(
)
_CAMEL_BOUNDARY_RE = re.compile(r'(?<=[a-z0-9])(?=[A-Z])')
_CAMEL_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
def _translation_state(value: str, known: frozenset[str]) -> str | None:
@@ -42,14 +42,14 @@ def _translation_state(value: str, known: frozenset[str]) -> str | None:
knows are normalized -- an unrecognized (or future) vendor value keeps
its own readable form rather than becoming an untranslatable slug.
"""
direct = value.lower().replace(' ', '_')
direct = value.lower().replace(" ", "_")
if direct in known:
return direct
snake = _CAMEL_BOUNDARY_RE.sub('_', value).lower().replace(' ', '_')
snake = _CAMEL_BOUNDARY_RE.sub("_", value).lower().replace(" ", "_")
return snake if snake in known else None
def _display(value, translation_key: Optional[str]):
def _display(value, translation_key: str | None):
"""Turn a raw device option/state value into what's shown in the UI.
`translation_key` is the entity's already-resolved key (SelectDesc.
@@ -74,7 +74,7 @@ def _display(value, translation_key: Optional[str]):
if not isinstance(value, str):
return value
if translation_key:
known = translated_states('select', translation_key)
known = translated_states("select", translation_key)
if not known:
# No state table for this key: either the entity isn't translated
# at all, or its name is translated but its options deliberately
@@ -84,20 +84,19 @@ def _display(value, translation_key: Optional[str]):
if translated := _translation_state(value, known):
return translated
if value.islower():
return value.replace('_', ' ').title()
return _CAMEL_BOUNDARY_RE.sub(' ', value)
return value.replace("_", " ").title()
return _CAMEL_BOUNDARY_RE.sub(" ", value)
class LocalThingsSelect(LocalThingsEntity, SelectEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SelectDesc = bound.desc
desc = cast(SelectDesc, bound.desc)
if not desc.options_field and not callable(desc.options):
self._attr_options = [_display(o, self.translation_key) for o in desc.options]
def _raw_options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
desc = cast(SelectDesc, self._bound.desc)
if callable(desc.options):
# Per-device option list computed from the full resource
# snapshot (not just this entity's own href) -- e.g. a course
@@ -114,7 +113,7 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
@property
def options(self) -> list[str]:
desc: SelectDesc = self._bound.desc
desc = cast(SelectDesc, self._bound.desc)
if desc.options_field or callable(desc.options):
return [_display(o, self.translation_key) for o in self._raw_options()]
return self._attr_options
+21 -13
View File
@@ -1,9 +1,11 @@
"""Sensor platform for Local Things."""
from __future__ import annotations
from datetime import timedelta
from typing import cast
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass, SensorStateClass
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
@@ -11,12 +13,15 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
from .const import CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES, DOMAIN
from .const import (
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DOMAIN,
)
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .observe import MODE_OBSERVE, MODE_POLL
from .registry.entities import SensorDesc
async def async_setup_entry(
@@ -25,7 +30,7 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
entities = [
entities: list[SensorEntity] = [
LocalThingsSensor(coordinator, b)
for b in coordinator.bound
if isinstance(b.desc, SensorDesc) and _is_included(b, coordinator)
@@ -35,12 +40,13 @@ async def async_setup_entry(
class LocalThingsSensor(LocalThingsEntity, SensorEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SensorDesc = bound.desc
desc = cast(SensorDesc, bound.desc)
self._attr_native_unit_of_measurement = desc.unit
self._attr_device_class = desc.device_class
self._attr_device_class = (
SensorDeviceClass(desc.device_class) if desc.device_class else None
)
self._attr_state_class = desc.state_class
if desc.options:
self._attr_options = list(desc.options)
@@ -48,7 +54,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
@property
def native_unit_of_measurement(self):
desc: SensorDesc = self._bound.desc
desc = cast(SensorDesc, self._bound.desc)
if desc.unit_fn is not None:
return desc.unit_fn(self.coordinator.resource(self._bound.href))
return self._attr_native_unit_of_measurement
@@ -56,7 +62,8 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
@property
def native_value(self):
raw = (self.coordinator.data or {}).get(self._state_key)
if not self._bound.desc.hysteresis:
desc = cast(SensorDesc, self._bound.desc)
if not desc.hysteresis:
return raw
return self._apply_hysteresis(raw)
@@ -75,6 +82,7 @@ class LocalThingsSensor(LocalThingsEntity, SensorEntity):
immediately -- only in-between jitter while a value already exists
on both sides gets held back.
"""
assert self.coordinator.config_entry is not None
threshold_min = self.coordinator.config_entry.options.get(
CONF_FINISH_TIME_HYSTERESIS_MINUTES, DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES
)
@@ -95,11 +103,11 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
Disabled by default — it's for troubleshooting, not everyday use."""
_attr_has_entity_name = True
_attr_translation_key = 'connection_mode'
_attr_translation_key = "connection_mode"
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = False
_attr_device_class = SensorDeviceClass.ENUM
_attr_options = [MODE_OBSERVE, MODE_POLL]
_attr_options = [MODE_OBSERVE, MODE_POLL] # noqa: RUF012 -- HA `_attr_*` convention
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
super().__init__(coordinator)
+8 -7
View File
@@ -1,16 +1,16 @@
"""Switch platform for Local Things."""
from __future__ import annotations
from homeassistant.components.switch import SwitchEntity
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import SwitchDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import SwitchDesc
async def async_setup_entry(
@@ -27,18 +27,19 @@ async def async_setup_entry(
class LocalThingsSwitch(LocalThingsEntity, SwitchEntity):
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
super().__init__(coordinator, bound)
desc: SwitchDesc = bound.desc
self._attr_device_class = desc.device_class
self._attr_device_class = (
SwitchDeviceClass(desc.device_class) if desc.device_class else None
)
@property
def is_on(self):
return (self.coordinator.data or {}).get(self._state_key)
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, 'On')
await self.coordinator.async_send_command(self._bound, "On")
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, 'Off')
await self.coordinator.async_send_command(self._bound, "Off")
+2 -3
View File
@@ -1,4 +1,5 @@
"""Time platform for Local Things."""
from __future__ import annotations
import datetime
@@ -8,11 +9,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.entities import TimeDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.entities import TimeDesc
async def async_setup_entry(
@@ -29,7 +29,6 @@ async def async_setup_entry(
class LocalThingsTime(LocalThingsEntity, TimeEntity):
@property
def native_value(self) -> datetime.time | None:
return (self.coordinator.data or {}).get(self._state_key)
+40 -33
View File
@@ -35,6 +35,7 @@ it drives. This entity is named through the catalog like every other
descriptor here, via the DHW descriptor's `translation_key='dhw'`
(entity.water_heater.dhw.name -> "Hot water").
"""
from __future__ import annotations
import logging
@@ -52,30 +53,33 @@ from homeassistant.const import STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .registry.capabilities.ehs import (
HREF_DHW_MODE as MODE_HREF,
HREF_DHW_POWER as POWER_HREF,
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
)
from .registry.capabilities.common import normalize_temp_unit
from .registry.entities import WaterHeaterDesc
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .entity import LocalThingsEntity, _is_included
from .registry.capabilities.common import normalize_temp_unit
from .registry.capabilities.ehs import (
HREF_DHW_MODE as MODE_HREF,
)
from .registry.capabilities.ehs import (
HREF_DHW_POWER as POWER_HREF,
)
from .registry.capabilities.ehs import (
HREF_DHW_TEMPERATURE as TEMPERATURE_HREF,
)
from .registry.entities import WaterHeaterDesc
_LOGGER = logging.getLogger(__name__)
_MODES_FIELD = 'x.com.samsung.da.modes'
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
_MODES_FIELD = "x.com.samsung.da.modes"
_SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
# Device mode <-> HA water_heater operation state -- see the module
# docstring above for the SmartThings-cloud precedent this mirrors.
_DEVICE_TO_STATE: dict[str, str] = {
'Eco': STATE_ECO,
'Std': STATE_HEAT_PUMP,
'Force': STATE_HIGH_DEMAND,
'Power': STATE_PERFORMANCE,
"Eco": STATE_ECO,
"Std": STATE_HEAT_PUMP,
"Force": STATE_HIGH_DEMAND,
"Power": STATE_PERFORMANCE,
}
_STATE_TO_DEVICE = {v: k for k, v in _DEVICE_TO_STATE.items()}
@@ -148,7 +152,7 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
return self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
def _is_on(self) -> bool:
return str(self._rep(POWER_HREF).get('x.com.samsung.da.power', '')).lower() == 'on'
return str(self._rep(POWER_HREF).get("x.com.samsung.da.power", "")).lower() == "on"
def _supported(self) -> list[str]:
return list(self._rep(MODE_HREF).get(_SUPPORTED_FIELD) or [])
@@ -160,25 +164,28 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
_LOGGER.warning(
"%s: device DHW mode %r has no HA mapping and was dropped; "
"please file an issue with your diagnostics dump",
self.entity_id, code,
self.entity_id,
code,
)
# -- temperature --------------------------------------------------------
@property
def temperature_unit(self) -> str:
raw = self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.unit')
return (UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, '°C') == '°F'
else UnitOfTemperature.CELSIUS)
raw = self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.unit")
return (
UnitOfTemperature.FAHRENHEIT
if normalize_temp_unit(raw, "°C") == "°F"
else UnitOfTemperature.CELSIUS
)
@property
def current_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.current'))
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.current"))
@property
def target_temperature(self):
return _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.desired'))
return _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.desired"))
def _range(self) -> list | None:
"""The device's own (minimum, maximum) pair, or None.
@@ -190,8 +197,8 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
that really allows 62.
"""
rep = self._rep(TEMPERATURE_HREF)
lo = _num(rep.get('x.com.samsung.da.minimum'))
hi = _num(rep.get('x.com.samsung.da.maximum'))
lo = _num(rep.get("x.com.samsung.da.minimum"))
hi = _num(rep.get("x.com.samsung.da.maximum"))
return [lo, hi] if (lo is not None and hi is not None) else None
@property
@@ -208,7 +215,7 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
def target_temperature_step(self) -> float:
# `is None`, not `or` -- see issue #160: `or` collapses a genuine 0
# into the fallback.
step = _num(self._rep(TEMPERATURE_HREF).get('x.com.samsung.da.increment'))
step = _num(self._rep(TEMPERATURE_HREF).get("x.com.samsung.da.increment"))
return 0.5 if step is None else step
# -- operation mode -------------------------------------------------------
@@ -245,29 +252,29 @@ class LocalThingsWaterHeater(LocalThingsEntity, WaterHeaterEntity):
# dashboard "boost to 55" button that carries a mode actually changes
# mode, instead of only moving the setpoint. Same fix as the AC's
# (see climate.async_set_temperature).
operation_mode = kwargs.get('operation_mode')
operation_mode = kwargs.get("operation_mode")
if operation_mode is not None:
await self.async_set_operation_mode(operation_mode)
if operation_mode == STATE_OFF:
return
temp = kwargs.get('temperature')
temp = kwargs.get("temperature")
if temp is None:
return
await self.coordinator.async_send_command(self._bound, ('temperature', temp))
await self.coordinator.async_send_command(self._bound, ("temperature", temp))
async def async_set_operation_mode(self, operation_mode: str) -> None:
if operation_mode == STATE_OFF:
await self.coordinator.async_send_command(self._bound, ('power', False))
await self.coordinator.async_send_command(self._bound, ("power", False))
return
device = _STATE_TO_DEVICE.get(operation_mode)
if device is None:
return
if not self._is_on():
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ('mode', device))
await self.coordinator.async_send_command(self._bound, ("power", True))
await self.coordinator.async_send_command(self._bound, ("mode", device))
async def async_turn_on(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ('power', True))
await self.coordinator.async_send_command(self._bound, ("power", True))
async def async_turn_off(self, **kwargs) -> None:
await self.coordinator.async_send_command(self._bound, ('power', False))
await self.coordinator.async_send_command(self._bound, ("power", False))
+33
View File
@@ -3,3 +3,36 @@ requires-python = ">=3.13"
[tool.pytest.ini_options]
asyncio_mode = "auto"
[tool.ruff]
target-version = "py313"
line-length = 100
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"F", # pyflakes
"W", # pycodestyle warnings
"I", # isort
"UP", # pyupgrade
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
"RUF", # ruff-specific
"ASYNC", # flake8-async
"LOG", # flake8-logging
"G", # flake8-logging-format
"PIE", # flake8-pie
"RET", # flake8-return
"PERF", # perflint
"N", # pep8-naming
]
ignore = [
"N818", # exception name doesn't need an Error suffix in this codebase
]
[tool.ruff.lint.isort]
known-first-party = ["custom_components"]
[tool.ty.environment]
python-version = "3.13"
+4
View File
@@ -10,3 +10,7 @@ smartthings-local>=0.1.0
cbor2>=5.4.6
pyOpenSSL>=23.0
cryptography>=41.0
# Lint / format / type-check tooling, pinned to match CI.
ruff==0.16.1
ty==0.0.65
+23 -19
View File
@@ -3,16 +3,17 @@ from pathlib import Path
import pytest
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
FIXTURES = Path(__file__).resolve().parent / "fixtures"
def _resources_from_dump(dump: dict) -> dict[str, dict]:
from custom_components.localthings.registry.batch import parse_device0_batch
return parse_device0_batch(dump['device0'])
return parse_device0_batch(dump["device0"])
def _load_device(name: str) -> dict[str, dict]:
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
return _resources_from_dump(data)
@@ -33,10 +34,10 @@ def _load_device_full(name: str):
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
FakeCoapSession answers both shapes off the same href key.
"""
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
data = json.loads((FIXTURES / f"{name}_device.json").read_text())
resources = _resources_from_dump(data)
seeds = {**data.get('seeds', {}), **data.get('probes', {})}
return resources, data.get('oic_res', []), seeds
seeds = {**data.get("seeds", {}), **data.get("probes", {})}
return resources, data.get("oic_res", []), seeds
class FakeCoapSession:
@@ -57,11 +58,12 @@ class FakeCoapSession:
self.seeds = seeds or {}
def get(self, path, timeout=None):
href = '/' + '/'.join(path)
href = "/" + "/".join(path)
body = self.seeds.get(href)
if body is None:
return 0x84, b'' # 4.04 not found -- tolerated absence
return 0x84, b"" # 4.04 not found -- tolerated absence
import cbor2
return 0x45, cbor2.dumps(body)
def pace(self):
@@ -87,14 +89,18 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
from custom_components.localthings.registry.by_type import resolve
from custom_components.localthings.registry.registry import CAPABILITIES
from custom_components.localthings.registry.subdevices import (
discover_partitioned, enumerate_subdevices,
discover_partitioned,
enumerate_subdevices,
)
sess = FakeCoapSession(seeds)
candidates, extra = enumerate_subdevices(sess, resources, oic_res)
full_resources = {**resources, **extra}
bound, device_type_name, materialized, skipped = discover_partitioned(
full_resources, candidates, resolve, CAPABILITIES,
full_resources,
candidates,
resolve,
CAPABILITIES,
)
return bound, materialized, skipped, full_resources, device_type_name
@@ -102,8 +108,8 @@ def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
def _load_resources(ip: str) -> dict[str, dict]:
"""Legacy IP-based loader — maps known IPs to named fixtures."""
_ip_to_name = {
'10.0.0.129': 'dishwasher',
'10.0.0.254': 'refrigerator',
"10.0.0.129": "dishwasher",
"10.0.0.254": "refrigerator",
}
name = _ip_to_name.get(ip)
if name is None:
@@ -113,17 +119,17 @@ def _load_resources(ip: str) -> dict[str, dict]:
@pytest.fixture
def dishwasher_resources() -> dict[str, dict]:
return _load_device('dishwasher')
return _load_device("dishwasher")
@pytest.fixture
def fridge_resources() -> dict[str, dict]:
return _load_device('refrigerator')
return _load_device("refrigerator")
@pytest.fixture
def washer_resources() -> dict[str, dict]:
return _load_device('washer')
return _load_device("washer")
@pytest.fixture
@@ -134,8 +140,6 @@ def all_device_fixtures() -> dict[str, dict[str, dict]]:
device -- so a newly added dump exercises them automatically.
"""
return {
path.name[:-len('_device.json')]: _resources_from_dump(
json.loads(path.read_text())
)
for path in sorted(FIXTURES.glob('*_device.json'))
path.name[: -len("_device.json")]: _resources_from_dump(json.loads(path.read_text()))
for path in sorted(FIXTURES.glob("*_device.json"))
}
+52 -45
View File
@@ -1,4 +1,5 @@
"""Shared fixtures for localthings component tests."""
from __future__ import annotations
import json
@@ -10,6 +11,17 @@ import cbor2
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM,
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM,
CONF_PORT,
DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
# Point HA's config dir at the repo root so the loader mounts custom_components/
# from the project into sys.path — otherwise IntegrationNotFound is raised.
PROJECT_ROOT = str(Path(__file__).resolve().parents[2])
@@ -33,13 +45,6 @@ def auto_enable_custom_integrations(enable_custom_integrations):
return enable_custom_integrations
from custom_components.localthings.const import (
CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
@pytest.fixture(autouse=True)
def _fast_coordinator_timers():
"""Shrink the coordinator's real-time delays for every localthings test.
@@ -58,36 +63,38 @@ def _fast_coordinator_timers():
still pass `grace_period_s=` explicitly and are unaffected.
"""
with (
patch.object(LocalThingsCoordinator, '_SUBPOLL_STEP_S', 0.001),
patch.object(LocalThingsCoordinator, '_OBSERVE_GRACE_PERIOD_S', 0.02),
patch.object(LocalThingsCoordinator, '_RECONNECT_PAUSE_S', 0.01),
patch.object(LocalThingsCoordinator, "_SUBPOLL_STEP_S", 0.001),
patch.object(LocalThingsCoordinator, "_OBSERVE_GRACE_PERIOD_S", 0.02),
patch.object(LocalThingsCoordinator, "_RECONNECT_PAUSE_S", 0.01),
):
yield
FIXTURES = Path(__file__).resolve().parent.parent / 'fixtures'
MOCK_HOST = '10.0.0.254'
FIXTURES = Path(__file__).resolve().parent.parent / "fixtures"
MOCK_HOST = "10.0.0.254"
MOCK_PORT = 49154
MOCK_SERIAL = 'TEST-SERIAL-001'
MOCK_CA_CERT_PEM = '-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----'
MOCK_CA_KEY_PEM = '-----BEGIN PRIVATE KEY-----\nTEST-CA-KEY\n-----END PRIVATE KEY-----'
MOCK_LEAF_CERT_PEM = '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----'
MOCK_LEAF_KEY_PEM = '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----'
MOCK_SERIAL = "TEST-SERIAL-001"
MOCK_CA_CERT_PEM = "-----BEGIN CERTIFICATE-----\nTEST-CA\n-----END CERTIFICATE-----"
MOCK_CA_KEY_PEM = "-----BEGIN PRIVATE KEY-----\nTEST-CA-KEY\n-----END PRIVATE KEY-----"
MOCK_LEAF_CERT_PEM = "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----"
MOCK_LEAF_KEY_PEM = "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----"
ENTRY_DATA = {
CONF_HOST: MOCK_HOST,
CONF_PORT: MOCK_PORT,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
CONF_HOST: MOCK_HOST,
CONF_PORT: MOCK_PORT,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
CONF_LEAF_CERT_PEM: MOCK_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
CONF_LEAF_KEY_PEM: MOCK_LEAF_KEY_PEM,
}
def _load_fridge_resources() -> dict:
from custom_components.localthings.registry.batch import parse_device0_batch
data = json.loads((FIXTURES / 'refrigerator_device.json').read_text())
return parse_device0_batch(data['device0'])
data = json.loads((FIXTURES / "refrigerator_device.json").read_text())
return parse_device0_batch(data["device0"])
@pytest.fixture
@@ -99,13 +106,13 @@ def fridge_resources():
def mock_probe():
"""Patch _probe_and_validate to succeed (recognized type) without a real DTLS connection."""
with patch(
'custom_components.localthings.config_flow._probe_and_validate',
"custom_components.localthings.config_flow._probe_and_validate",
return_value={
'port': MOCK_PORT,
'serial': MOCK_SERIAL,
'leaf_cert_pem': MOCK_LEAF_CERT_PEM,
'leaf_key_pem': MOCK_LEAF_KEY_PEM,
'device_type_recognized': True,
"port": MOCK_PORT,
"serial": MOCK_SERIAL,
"leaf_cert_pem": MOCK_LEAF_CERT_PEM,
"leaf_key_pem": MOCK_LEAF_KEY_PEM,
"device_type_recognized": True,
},
) as m:
yield m
@@ -115,13 +122,13 @@ def mock_probe():
def mock_probe_unknown_type():
"""Patch _probe_and_validate to succeed, but with an unrecognized device type."""
with patch(
'custom_components.localthings.config_flow._probe_and_validate',
"custom_components.localthings.config_flow._probe_and_validate",
return_value={
'port': MOCK_PORT,
'serial': MOCK_SERIAL,
'leaf_cert_pem': MOCK_LEAF_CERT_PEM,
'leaf_key_pem': MOCK_LEAF_KEY_PEM,
'device_type_recognized': False,
"port": MOCK_PORT,
"serial": MOCK_SERIAL,
"leaf_cert_pem": MOCK_LEAF_CERT_PEM,
"leaf_key_pem": MOCK_LEAF_KEY_PEM,
"device_type_recognized": False,
},
) as m:
yield m
@@ -131,12 +138,12 @@ def mock_probe_unknown_type():
def mock_coordinator_session(fridge_resources):
"""Patch coordinator's blocking session methods so no real DTLS is needed."""
with (
patch('custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session'),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
return_value=fridge_resources,
),
patch('custom_components.localthings.coordinator.LocalThingsCoordinator._close_session'),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
):
yield
@@ -164,13 +171,13 @@ class FakeObserveSession:
self.notify_on_subscribe: dict[str, Any] | None = None
def subscribe(self, path_segs):
href = '/' + '/'.join(path_segs)
href = "/" + "/".join(path_segs)
if href in self.fail_hrefs:
raise ConnectionError("subscribe failed")
self.subscribed.append(href)
if self.notify_on_subscribe is not None and self.on_notification is not None:
self.on_notification(href, cbor2.dumps(self.notify_on_subscribe))
return b'\x01'
return b"\x01"
def refresh_observes(self, paths):
return None
@@ -192,14 +199,14 @@ def mock_coordinator_observe_session(fridge_resources):
with (
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session',
"custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session",
_connect,
),
patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once',
"custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once",
return_value=fridge_resources,
),
patch('custom_components.localthings.coordinator.LocalThingsCoordinator._close_session'),
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"),
):
yield fake
@@ -210,7 +217,7 @@ def mock_entry(hass):
entry = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id=f'localthings_{MOCK_SERIAL}',
unique_id=f"localthings_{MOCK_SERIAL}",
)
entry.add_to_hass(hass)
return entry
+219 -173
View File
@@ -1,32 +1,39 @@
"""Tests for the localthings config flow."""
from __future__ import annotations
from unittest.mock import patch
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_BYPASS_REMOTE_CONTROL, CONF_CA_CERT_PEM, CONF_CA_KEY_PEM,
CONF_HOST, CONF_PORT, DOMAIN,
CONF_BYPASS_REMOTE_CONTROL,
CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM,
CONF_HOST,
CONF_PORT,
DOMAIN,
)
from .conftest import (
ENTRY_DATA, MOCK_CA_CERT_PEM, MOCK_CA_KEY_PEM, MOCK_HOST, MOCK_PORT, MOCK_SERIAL,
ENTRY_DATA,
MOCK_CA_CERT_PEM,
MOCK_CA_KEY_PEM,
MOCK_HOST,
MOCK_PORT,
MOCK_SERIAL,
)
async def test_form_first_device(hass: HomeAssistant) -> None:
"""First device: form asks for host, CA cert, and CA key."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'user'
assert CONF_CA_CERT_PEM in result['data_schema'].schema
assert CONF_CA_KEY_PEM in result['data_schema'].schema
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user"
assert CONF_CA_CERT_PEM in result["data_schema"].schema
assert CONF_CA_KEY_PEM in result["data_schema"].schema
async def test_form_second_device_reuses_creds(hass: HomeAssistant) -> None:
@@ -34,28 +41,28 @@ async def test_form_second_device_reuses_creds(hass: HomeAssistant) -> None:
existing = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA)
existing.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'user_reuse'
assert CONF_CA_CERT_PEM not in result['data_schema'].schema
assert CONF_CA_KEY_PEM not in result['data_schema'].schema
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user_reuse"
assert CONF_CA_CERT_PEM not in result["data_schema"].schema
assert CONF_CA_KEY_PEM not in result["data_schema"].schema
async def test_successful_setup(hass: HomeAssistant, mock_probe) -> None:
"""Happy path: valid IP connects, entry created with discovered port."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM},
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert result['data'][CONF_HOST] == MOCK_HOST
assert result['data'][CONF_PORT] == MOCK_PORT
assert result['data'][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_HOST] == MOCK_HOST
assert result["data"][CONF_PORT] == MOCK_PORT
assert result["data"][CONF_CA_CERT_PEM] == MOCK_CA_CERT_PEM
def test_order_candidates_prefers_known_ports() -> None:
@@ -64,7 +71,10 @@ def test_order_candidates_prefers_known_ports() -> None:
from custom_components.localthings.config_flow import _order_candidates
assert _order_candidates([49160, 49153, 49155, 49154]) == [
49154, 49155, 49153, 49160,
49154,
49155,
49153,
49160,
]
assert _order_candidates([49153]) == [49153]
@@ -88,7 +98,7 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None:
reserve = [socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(3)]
for s in reserve:
s.bind(('127.0.0.1', 0))
s.bind(("127.0.0.1", 0))
ports = [s.getsockname()[1] for s in reserve]
live_sock, live_port = reserve[0], ports[0]
reserve[1].close()
@@ -97,7 +107,9 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None:
try:
result = _find_live_ports(
'127.0.0.1', [closed_ports[0], live_port, closed_ports[1]], 0.8,
"127.0.0.1",
[closed_ports[0], live_port, closed_ports[1]],
0.8,
)
finally:
live_sock.close()
@@ -106,7 +118,8 @@ def test_find_live_ports_detects_silent_port(socket_enabled) -> None:
def test_find_live_ports_rescues_preferred_ports_the_sweep_missed(
socket_enabled, monkeypatch,
socket_enabled,
monkeypatch,
) -> None:
"""Issue #192: a segregated VLAN made the ICMP-based sweep call three
closed ports live while missing the one port (a historically confirmed
@@ -129,17 +142,17 @@ def test_find_live_ports_rescues_preferred_ports_the_sweep_missed(
# once closed, loopback refuses datagrams to it, standing in for the
# sweep wrongly ruling out a port we have strong prior evidence for.
reserved = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
reserved.bind(('127.0.0.1', 0))
reserved.bind(("127.0.0.1", 0))
preferred_port = reserved.getsockname()[1]
reserved.close()
monkeypatch.setattr(config_flow, 'PREFERRED_PROBE_PORTS', [preferred_port])
monkeypatch.setattr(config_flow, "PREFERRED_PROBE_PORTS", [preferred_port])
live_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
live_sock.bind(('127.0.0.1', 0))
live_sock.bind(("127.0.0.1", 0))
live_port = live_sock.getsockname()[1]
try:
result = _find_live_ports('127.0.0.1', [preferred_port, live_port], 0.8)
result = _find_live_ports("127.0.0.1", [preferred_port, live_port], 0.8)
finally:
live_sock.close()
@@ -155,15 +168,16 @@ async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch)
from custom_components.localthings import config_flow
device0 = [
{'rt': ['x.com.samsung.devcol']},
{'href': '/information/vs/0', 'rep': {
'x.com.samsung.da.modelNum':
'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000',
'x.com.samsung.da.description':
'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048',
'x.com.samsung.da.serialNum': 'DISHWASHER-49153',
}},
{'href': '/otninformation/vs/0', 'rep': {'otnStatus': 'None'}},
{"rt": ["x.com.samsung.devcol"]},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000", # noqa: E501
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048",
"x.com.samsung.da.serialNum": "DISHWASHER-49153",
},
},
{"href": "/otninformation/vs/0", "rep": {"otnStatus": "None"}},
]
class _FakeSession:
@@ -182,28 +196,33 @@ async def test_probe_uses_discovered_low_port(hass: HomeAssistant, monkeypatch)
def close(self):
pass
monkeypatch.setattr(config_flow, '_fetch_samsung_uuid', lambda: 'test-uuid')
monkeypatch.setattr(config_flow, "_fetch_samsung_uuid", lambda: "test-uuid")
monkeypatch.setattr(
config_flow, '_mint_leaf_cert',
lambda ca_cert, ca_key, uuid: ('FULLCHAIN', 'LEAFKEY'),
config_flow,
"_mint_leaf_cert",
lambda ca_cert, ca_key, uuid: ("FULLCHAIN", "LEAFKEY"),
)
monkeypatch.setattr(
config_flow, '_find_live_ports',
config_flow,
"_find_live_ports",
lambda host, ports, timeout: [49153],
)
monkeypatch.setattr(
'smartthings_local.protocol.dtls_session.DtlsCoapSession', _FakeSession,
"smartthings_local.protocol.dtls_session.DtlsCoapSession",
_FakeSession,
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM},
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert result['data'][CONF_PORT] == 49153
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_PORT] == 49153
async def test_cannot_connect(hass: HomeAssistant) -> None:
@@ -211,53 +230,58 @@ async def test_cannot_connect(hass: HomeAssistant) -> None:
from custom_components.localthings.config_flow import CannotConnect
with patch(
'custom_components.localthings.config_flow._probe_and_validate',
side_effect=CannotConnect('no port'),
"custom_components.localthings.config_flow._probe_and_validate",
side_effect=CannotConnect("no port"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM},
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result['type'] == FlowResultType.FORM
assert result['errors']['base'] == 'cannot_connect'
assert result["type"] == FlowResultType.FORM
assert result["errors"]["base"] == "cannot_connect"
async def test_recognized_type_skips_confirmation_step(
hass: HomeAssistant, mock_probe
) -> None:
async def test_recognized_type_skips_confirmation_step(hass: HomeAssistant, mock_probe) -> None:
"""A recognized device type creates the entry with no extra step."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM},
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert result["type"] == FlowResultType.CREATE_ENTRY
async def test_unknown_type_shows_confirmation_step(
hass: HomeAssistant, mock_probe_unknown_type
) -> None:
"""An unrecognized device type shows a confirmation step before creating the entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
{CONF_HOST: MOCK_HOST, CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM, CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM},
result["flow_id"],
{
CONF_HOST: MOCK_HOST,
CONF_CA_CERT_PEM: MOCK_CA_CERT_PEM,
CONF_CA_KEY_PEM: MOCK_CA_KEY_PEM,
},
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'confirm_unknown_type'
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "confirm_unknown_type"
result = await hass.config_entries.flow.async_configure(
result['flow_id'], {},
result["flow_id"],
{},
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert result['data'][CONF_HOST] == MOCK_HOST
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["data"][CONF_HOST] == MOCK_HOST
async def test_unknown_type_step_description_makes_no_version_claim(
@@ -270,14 +294,19 @@ async def test_unknown_type_step_description_makes_no_version_claim(
from pathlib import Path
steps = json.loads(
(Path(__file__).parents[2] / 'custom_components' / 'localthings'
/ 'translations' / 'en.json').read_text()
)['config']['step']
(
Path(__file__).parents[2]
/ "custom_components"
/ "localthings"
/ "translations"
/ "en.json"
).read_text()
)["config"]["step"]
assert 'confirm_unknown_type_no_version' not in steps
description = steps['confirm_unknown_type']['description']
assert 'oneUiVersion' not in description
assert '{' not in description # no unfilled placeholder
assert "confirm_unknown_type_no_version" not in steps
description = steps["confirm_unknown_type"]["description"]
assert "oneUiVersion" not in description
assert "{" not in description # no unfilled placeholder
async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None:
@@ -289,42 +318,42 @@ async def test_duplicate_device_aborted(hass: HomeAssistant, mock_probe) -> None
existing = MockConfigEntry(
domain=DOMAIN,
data=ENTRY_DATA,
unique_id=f'localthings_{MOCK_SERIAL}',
unique_id=f"localthings_{MOCK_SERIAL}",
)
existing.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={'source': 'user'}
)
result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": "user"})
# Second-device form only has CONF_HOST; CA creds are reused from existing.
result = await hass.config_entries.flow.async_configure(
result['flow_id'],
result["flow_id"],
{CONF_HOST: MOCK_HOST},
)
assert result['type'] == FlowResultType.ABORT
assert result['reason'] == 'already_configured'
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
def test_probe_marks_washer_as_recognized(monkeypatch):
"""A washer reports no oneUiVersion at all -- its consumer-model code
must still resolve so setup doesn't warn about an unrecognized type."""
from custom_components.localthings import config_flow
device0 = [
{'rt': ['x.com.samsung.devcol']},
{'href': '/information/vs/0', 'rep': {
'x.com.samsung.da.modelNum':
'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000',
'x.com.samsung.da.description':
'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048',
'x.com.samsung.da.serialNum': 'TEST-SERIAL',
}},
{'href': '/otninformation/vs/0', 'rep': {'otnStatus': 'None'}},
{"rt": ["x.com.samsung.devcol"]},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000", # noqa: E501
"x.com.samsung.da.description": "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048",
"x.com.samsung.da.serialNum": "TEST-SERIAL",
},
},
{"href": "/otninformation/vs/0", "rep": {"otnStatus": "None"}},
]
from custom_components.localthings.registry.batch import parse_device0_batch
resources = parse_device0_batch(device0)
from custom_components.localthings.registry.by_type import resolve
assert resolve(resources) is not None
@@ -332,47 +361,47 @@ async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
"""The options flow's entry point is now a menu (issue #54's debug
panel lives alongside the remote-control settings), not the settings
form directly."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result['type'] == FlowResultType.MENU
assert result['step_id'] == 'init'
assert set(result['menu_options']) == {'settings', 'debug_write'}
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
assert set(result["menu_options"]) == {"settings", "debug_write"}
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
"""The bypass defaults to False, so devices that never touch this
option see no change in the remote-control write block."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'settings'}
result["flow_id"], user_input={"next_step_id": "settings"}
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'settings'
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is False
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "settings"
assert result["data_schema"]({})[CONF_BYPASS_REMOTE_CONTROL] is False
async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
"""Submitting the form with the toggle on stores it in entry.options,
where coordinator.async_send_command reads it (issue #54)."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'settings'}
result["flow_id"], user_input={"next_step_id": "settings"}
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={CONF_BYPASS_REMOTE_CONTROL: True}
result["flow_id"], user_input={CONF_BYPASS_REMOTE_CONTROL: True}
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert result["type"] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
@@ -380,36 +409,39 @@ async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant)
"""Reopening the form shows the currently-saved choice as the default,
not always False."""
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
domain=DOMAIN,
data=ENTRY_DATA,
unique_id=f"localthings_{MOCK_SERIAL}",
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'settings'}
result["flow_id"], user_input={"next_step_id": "settings"}
)
assert result['data_schema']({})[CONF_BYPASS_REMOTE_CONTROL] is True
assert result["data_schema"]({})[CONF_BYPASS_REMOTE_CONTROL] is True
async def test_options_flow_debug_write_shows_hrefs_from_coordinator(
hass: HomeAssistant, mock_coordinator_session,
hass: HomeAssistant,
mock_coordinator_session,
) -> None:
"""The debug panel's href dropdown is populated from the live
coordinator's cached resources, not a static list."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'debug_write'
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "debug_write"
async def test_options_flow_debug_write_aborts_when_device_not_loaded(
@@ -417,83 +449,92 @@ async def test_options_flow_debug_write_aborts_when_device_not_loaded(
) -> None:
"""No coordinator in hass.data (device never finished loading) means
the debug panel has nothing to write to or read from."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
assert result['type'] == FlowResultType.ABORT
assert result['reason'] == 'not_loaded'
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "not_loaded"
async def test_options_flow_debug_edit_writes_and_shows_result(
hass: HomeAssistant, mock_coordinator_session,
hass: HomeAssistant,
mock_coordinator_session,
) -> None:
"""Picking an href, then submitting a payload, drives
coordinator.async_raw_write and lands on the result menu with the
device's response."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'href': '/washer/vs/0'},
result["flow_id"],
user_input={"href": "/washer/vs/0"},
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'debug_edit'
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "debug_edit"
with patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write',
return_value=(0x44, {'a': 1}),
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
return_value=(0x44, {"a": 1}),
):
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'payload': {'a': 1}},
result["flow_id"],
user_input={"payload": {"a": 1}},
)
assert result['type'] == FlowResultType.MENU
assert result['step_id'] == 'debug_result'
assert result['description_placeholders']['code'] == '2.04 (0x44)'
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "debug_result"
assert result["description_placeholders"]["code"] == "2.04 (0x44)"
async def test_options_flow_debug_edit_rejects_empty_payload(
hass: HomeAssistant, mock_coordinator_session,
hass: HomeAssistant,
mock_coordinator_session,
) -> None:
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}')
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'href': '/washer/vs/0'},
result["flow_id"],
user_input={"href": "/washer/vs/0"},
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'payload': {}},
result["flow_id"],
user_input={"payload": {}},
)
assert result['type'] == FlowResultType.FORM
assert result['step_id'] == 'debug_edit'
assert result['errors'] == {'payload': 'empty_payload'}
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "debug_edit"
assert result["errors"] == {"payload": "empty_payload"}
async def test_options_flow_finish_preserves_existing_options(
hass: HomeAssistant, mock_coordinator_session,
hass: HomeAssistant,
mock_coordinator_session,
) -> None:
"""Finishing from the debug-result menu must not clobber a previously
saved remote-control bypass setting."""
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id=f'localthings_{MOCK_SERIAL}',
domain=DOMAIN,
data=ENTRY_DATA,
unique_id=f"localthings_{MOCK_SERIAL}",
options={CONF_BYPASS_REMOTE_CONTROL: True},
)
entry.add_to_hass(hass)
@@ -502,26 +543,28 @@ async def test_options_flow_finish_preserves_existing_options(
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'debug_write'}
result["flow_id"], user_input={"next_step_id": "debug_write"}
)
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'href': '/washer/vs/0'},
result["flow_id"],
user_input={"href": "/washer/vs/0"},
)
with patch(
'custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write',
return_value=(0x44, {'a': 1}),
"custom_components.localthings.coordinator.LocalThingsCoordinator.async_raw_write",
return_value=(0x44, {"a": 1}),
):
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'payload': {'a': 1}},
result["flow_id"],
user_input={"payload": {"a": 1}},
)
assert result['type'] == FlowResultType.MENU
assert result['step_id'] == 'debug_result'
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "debug_result"
result = await hass.config_entries.options.async_configure(
result['flow_id'], user_input={'next_step_id': 'finish'}
result["flow_id"], user_input={"next_step_id": "finish"}
)
assert result['type'] == FlowResultType.CREATE_ENTRY
assert result["type"] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
@@ -530,15 +573,17 @@ def test_is_placeholder_serial_catches_nothing_svc():
literal string 'Nothing(SVC)' as serialNum on every unit -- non-empty,
so it must be caught by name, not by the plain `if not serial` check."""
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('Nothing(SVC)') is True
assert _is_placeholder_serial('nothing(svc)') is True
assert _is_placeholder_serial(' Nothing(SVC) ') is True
assert _is_placeholder_serial("Nothing(SVC)") is True
assert _is_placeholder_serial("nothing(svc)") is True
assert _is_placeholder_serial(" Nothing(SVC) ") is True
def test_is_placeholder_serial_accepts_real_serials():
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('0A1B2C3D4E5F') is False
assert _is_placeholder_serial('') is False
assert _is_placeholder_serial("0A1B2C3D4E5F") is False
assert _is_placeholder_serial("") is False
def test_is_placeholder_serial_catches_all_same_hex_digit():
@@ -548,9 +593,10 @@ def test_is_placeholder_serial_catches_all_same_hex_digit():
different physical units, both reported the literal serialNum
'FFFFFFFFFFFFFFF', colliding on the config-entry unique_id."""
from custom_components.localthings.config_flow import _is_placeholder_serial
assert _is_placeholder_serial('FFFFFFFFFFFFFFF') is True
assert _is_placeholder_serial('ffffffffffffffff') is True
assert _is_placeholder_serial('00000000') is True
assert _is_placeholder_serial("FFFFFFFFFFFFFFF") is True
assert _is_placeholder_serial("ffffffffffffffff") is True
assert _is_placeholder_serial("00000000") is True
# Too short to be the flash-unset sentinel -- a real serial could
# plausibly repeat one hex digit seven times by chance.
assert _is_placeholder_serial('FFFFFFF') is False
assert _is_placeholder_serial("FFFFFFF") is False
File diff suppressed because it is too large Load Diff
+15 -9
View File
@@ -8,6 +8,7 @@ refrigerator whose diagnostics now report no subdevices at all) sticks
around forever. Defining this callback is what puts a working "Delete
device" button on it.
"""
from __future__ import annotations
from homeassistant.core import HomeAssistant
@@ -20,7 +21,8 @@ from custom_components.localthings.registry.subdevices import Subdevice
def _device(hass, entry, identifiers) -> dr.DeviceEntry:
return dr.async_get(hass).async_get_or_create(
config_entry_id=entry.entry_id, identifiers=identifiers,
config_entry_id=entry.entry_id,
identifiers=identifiers,
)
@@ -33,9 +35,11 @@ async def test_stale_device_can_be_removed(
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.subdevices == [] # this fixture is not a composite device
assert coordinator.subdevices == [] # this fixture is not a composite device
stale = _device(
hass, mock_entry, {(DOMAIN, f'{coordinator.device_serial}_1')},
hass,
mock_entry,
{(DOMAIN, f"{coordinator.device_serial}_1")},
)
assert await async_remove_config_entry_device(hass, mock_entry, stale) is True
@@ -50,7 +54,7 @@ async def test_master_device_cannot_be_removed(
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
master = _device(hass, mock_entry, set(coordinator.device_info['identifiers']))
master = _device(hass, mock_entry, set(coordinator.device_info["identifiers"]))
assert await async_remove_config_entry_device(hass, mock_entry, master) is False
@@ -65,22 +69,24 @@ async def test_live_subdevice_device_cannot_be_removed(
await hass.async_block_till_done()
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
subdevice = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
subdevice = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
coordinator.subdevices = [subdevice]
live = _device(
hass, mock_entry,
set(coordinator.device_info_for(subdevice)['identifiers']),
hass,
mock_entry,
set(coordinator.device_info_for(subdevice)["identifiers"]),
)
assert await async_remove_config_entry_device(hass, mock_entry, live) is False
async def test_removal_allowed_when_entry_is_not_loaded(
hass: HomeAssistant, mock_entry,
hass: HomeAssistant,
mock_entry,
) -> None:
"""No coordinator means nothing is claiming the device -- don't strand
it behind a callback that can't answer."""
mock_entry.add_to_hass(hass)
orphan = _device(hass, mock_entry, {(DOMAIN, 'whatever')})
orphan = _device(hass, mock_entry, {(DOMAIN, "whatever")})
assert await async_remove_config_entry_device(hass, mock_entry, orphan) is True
+74 -62
View File
@@ -1,4 +1,5 @@
"""Tests for the diagnostics platform."""
from __future__ import annotations
import json
@@ -13,8 +14,8 @@ from custom_components.localthings.diagnostics import async_get_config_entry_dia
from custom_components.localthings.registry.redact import REDACTED
_MANIFEST_VERSION = json.loads(
(Path(__file__).parents[2] / 'custom_components' / 'localthings' / 'manifest.json').read_text()
)['version']
(Path(__file__).parents[2] / "custom_components" / "localthings" / "manifest.json").read_text()
)["version"]
async def test_diagnostics_shape_and_redaction(
@@ -25,17 +26,17 @@ async def test_diagnostics_shape_and_redaction(
diagnostics = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diagnostics["device_type"] == 'refrigerator'
assert diagnostics["one_ui_version"] == '7.0 Refrigerator'
assert diagnostics["device_type"] == "refrigerator"
assert diagnostics["one_ui_version"] == "7.0 Refrigerator"
assert diagnostics["unbound_hrefs"] == []
assert diagnostics["integration_version"] == _MANIFEST_VERSION
assert diagnostics["smartthings_local_version"]
resources = diagnostics["resources"]
assert resources['/information/vs/0']['x.com.samsung.da.serialNum'] == REDACTED
assert resources['/wirelessinfo/vs/0']['macaddressWiFi'] == REDACTED
assert resources["/information/vs/0"]["x.com.samsung.da.serialNum"] == REDACTED
assert resources["/wirelessinfo/vs/0"]["macaddressWiFi"] == REDACTED
# Ordinary state survives.
assert resources['/status/lock/vs/0']['x.com.samsung.da.ado.devicecontrol'] == 'On'
assert resources["/status/lock/vs/0"]["x.com.samsung.da.ado.devicecontrol"] == "On"
async def test_diagnostics_include_ocf_identity(
@@ -51,33 +52,36 @@ async def test_diagnostics_include_ocf_identity(
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics',
model='RF9000B',
name='Family Hub',
manufacturer="Samsung Electronics",
model="RF9000B",
name="Family Hub",
serial=None,
device_types=('oic.wk.d', 'oic.d.refrigerator'),
device_types=("oic.wk.d", "oic.d.refrigerator"),
raw={
'/oic/p': {'mnmn': 'Samsung Electronics', 'pi': '12-34-56'},
'/oic/d': {'n': 'Family Hub', 'di': 'ab-cd-ef',
'rt': ['oic.wk.d', 'oic.d.refrigerator']},
"/oic/p": {"mnmn": "Samsung Electronics", "pi": "12-34-56"},
"/oic/d": {
"n": "Family Hub",
"di": "ab-cd-ef",
"rt": ["oic.wk.d", "oic.d.refrigerator"],
},
},
)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
identity = diag['identity']
assert identity['model'] == 'RF9000B'
assert identity['device_types'] == ['oic.wk.d', 'oic.d.refrigerator']
identity = diag["identity"]
assert identity["model"] == "RF9000B"
assert identity["device_types"] == ["oic.wk.d", "oic.d.refrigerator"]
# The raw payloads ride along whole -- we don't yet know which of their
# fields identify a device type, so nothing is dropped up front beyond
# what redaction takes out.
assert identity['resources']['/oic/p']['mnmn'] == 'Samsung Electronics'
assert identity['resources']['/oic/d']['di'] == REDACTED
assert identity['resources']['/oic/p']['pi'] == REDACTED
assert identity["resources"]["/oic/p"]["mnmn"] == "Samsung Electronics"
assert identity["resources"]["/oic/d"]["di"] == REDACTED
assert identity["resources"]["/oic/p"]["pi"] == REDACTED
# The owner-settable device name is redacted; `rt` -- the reason this
# block exists -- is not.
assert identity['resources']['/oic/d']['n'] == REDACTED
assert identity['resources']['/oic/d']['rt'] == ['oic.wk.d', 'oic.d.refrigerator']
assert identity["resources"]["/oic/d"]["n"] == REDACTED
assert identity["resources"]["/oic/d"]["rt"] == ["oic.wk.d", "oic.d.refrigerator"]
async def test_diagnostics_include_oic_res_links(
@@ -94,32 +98,38 @@ async def test_diagnostics_include_oic_res_links(
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics',
model='RF9000B',
name='Family Hub',
manufacturer="Samsung Electronics",
model="RF9000B",
name="Family Hub",
serial=None,
device_types=('oic.wk.d', 'oic.d.refrigerator'),
device_types=("oic.wk.d", "oic.d.refrigerator"),
raw={
'/oic/p': {'mnmn': 'Samsung Electronics'},
'/oic/d': {'rt': ['oic.wk.d', 'oic.d.refrigerator']},
'/oic/res': [
{'di': 'aaaa-1111', 'href': '/device/0',
'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
{'di': 'bbbb-2222', 'href': '/device/1',
'rt': ['x.com.samsung.devcol', 'oic.wk.col']},
"/oic/p": {"mnmn": "Samsung Electronics"},
"/oic/d": {"rt": ["oic.wk.d", "oic.d.refrigerator"]},
"/oic/res": [
{
"di": "aaaa-1111",
"href": "/device/0",
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
},
{
"di": "bbbb-2222",
"href": "/device/1",
"rt": ["x.com.samsung.devcol", "oic.wk.col"],
},
],
},
)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
links = diag['identity']['resources']['/oic/res']
links = diag["identity"]["resources"]["/oic/res"]
assert len(links) == 2
assert links[0]['di'] == REDACTED
assert links[0]['href'] == '/device/0'
assert links[1]['di'] == REDACTED
assert links[1]['href'] == '/device/1'
assert links[1]['rt'] == ['x.com.samsung.devcol', 'oic.wk.col']
assert links[0]["di"] == REDACTED
assert links[0]["href"] == "/device/0"
assert links[1]["di"] == REDACTED
assert links[1]["href"] == "/device/1"
assert links[1]["rt"] == ["x.com.samsung.devcol", "oic.wk.col"]
async def test_diagnostics_include_speculative_device_probes(
@@ -135,27 +145,29 @@ async def test_diagnostics_include_speculative_device_probes(
coordinator = hass.data[DOMAIN][mock_entry.entry_id]
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics',
model='RF9000B',
name='Family Hub',
manufacturer="Samsung Electronics",
model="RF9000B",
name="Family Hub",
serial=None,
device_types=('oic.wk.d', 'oic.d.refrigerator'),
device_types=("oic.wk.d", "oic.d.refrigerator"),
raw={
'/oic/p': {}, '/oic/d': {}, '/oic/res': [],
'/device/1': {
'/information/vs/0': {'x.com.samsung.da.serialNum': 'SECRET123'},
'/power/vs/0': {'x.com.samsung.da.power': 'On'},
"/oic/p": {},
"/oic/d": {},
"/oic/res": [],
"/device/1": {
"/information/vs/0": {"x.com.samsung.da.serialNum": "SECRET123"},
"/power/vs/0": {"x.com.samsung.da.power": "On"},
},
'/device/2': {},
"/device/2": {},
},
)
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
device1 = diag['identity']['resources']['/device/1']
assert device1['/information/vs/0']['x.com.samsung.da.serialNum'] == REDACTED
assert device1['/power/vs/0']['x.com.samsung.da.power'] == 'On'
assert diag['identity']['resources']['/device/2'] == {}
device1 = diag["identity"]["resources"]["/device/1"]
assert device1["/information/vs/0"]["x.com.samsung.da.serialNum"] == REDACTED
assert device1["/power/vs/0"]["x.com.samsung.da.power"] == "On"
assert diag["identity"]["resources"]["/device/2"] == {}
async def test_diagnostics_identity_none_when_unavailable(
@@ -168,7 +180,7 @@ async def test_diagnostics_identity_none_when_unavailable(
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['identity'] is None
assert diag["identity"] is None
async def test_diagnostics_include_observe_mode_fields(
@@ -179,11 +191,11 @@ async def test_diagnostics_include_observe_mode_fields(
diag = await async_get_config_entry_diagnostics(hass, mock_entry)
assert diag['observe_mode'] == 'poll'
assert diag['observe_subscribed_hrefs'] == []
assert diag['observe_fallback_hrefs'] == []
assert 'observe_last_mode_change' in diag
assert diag['observe_href_freshness_s'] == {}
assert diag["observe_mode"] == "poll"
assert diag["observe_subscribed_hrefs"] == []
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(
@@ -199,12 +211,12 @@ async def test_dependency_version_read_off_the_event_loop(
real_pkg_version = diagnostics_mod.pkg_version
def _spy(name: str) -> str:
seen['thread_id'] = threading.get_ident()
seen["thread_id"] = threading.get_ident()
return real_pkg_version(name)
monkeypatch.setattr(diagnostics_mod, 'pkg_version', _spy)
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
assert diag["smartthings_local_version"]
assert seen["thread_id"] != loop_thread_id
+96 -90
View File
@@ -1,16 +1,17 @@
"""Tests for ObserveManager: write-settle guard and mode defaults."""
from __future__ import annotations
import threading
import time
import cbor2
import pytest
from smartthings_local.ocf.state_cache import StateCache
from custom_components.localthings.observe import (
ObserveManager, MODE_POLL, _rep_diff,
MODE_POLL,
ObserveManager,
_rep_diff,
)
@@ -30,8 +31,8 @@ def test_starts_in_poll_mode():
def test_apply_writes_through_when_not_settling():
mgr = _manager()
assert mgr.apply('/oven/vs/0', {'a': 1}, source='poll') is True
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
assert mgr.apply("/oven/vs/0", {"a": 1}, source="poll") is True
assert mgr.cache.get("/oven/vs/0") == {"a": 1}
def test_apply_merges_partial_update_onto_prior_rep():
@@ -43,28 +44,28 @@ def test_apply_merges_partial_update_onto_prior_rep():
which is exactly what made the flex-zone select disappear."""
mgr = _manager()
full = {
'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT'],
'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'],
"x.com.samsung.da.modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_MEAT"],
"x.com.samsung.da.supportedOptions": ["CV_FDR_WINE", "CV_FDR_MEAT"],
}
mgr.apply('/mode/vs/0', full, source='poll')
mgr.apply("/mode/vs/0", full, source="poll")
partial = {'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']}
mgr.apply('/mode/vs/0', partial, source='observe')
partial = {"x.com.samsung.da.modes": ["CVN_CONVERTIBLE_ZONE", "WATERFILTER_ENABLE"]}
mgr.apply("/mode/vs/0", partial, source="observe")
cached = mgr.cache.get('/mode/vs/0')
assert cached['x.com.samsung.da.modes'] == ['CVN_CONVERTIBLE_ZONE', 'WATERFILTER_ENABLE']
assert cached['x.com.samsung.da.supportedOptions'] == ['CV_FDR_WINE', 'CV_FDR_MEAT']
cached = mgr.cache.get("/mode/vs/0")
assert cached["x.com.samsung.da.modes"] == ["CVN_CONVERTIBLE_ZONE", "WATERFILTER_ENABLE"]
assert cached["x.com.samsung.da.supportedOptions"] == ["CV_FDR_WINE", "CV_FDR_MEAT"]
def test_apply_drops_update_during_settle_window():
mgr = _manager()
mgr.cache.apply_rep('/oven/vs/0', {'a': 1}, source='seed')
mgr.mark_write_pending('/oven/vs/0', settle_s=1.0)
mgr.cache.apply_rep("/oven/vs/0", {"a": 1}, source="seed")
mgr.mark_write_pending("/oven/vs/0", settle_s=1.0)
result = mgr.apply('/oven/vs/0', {'a': 2}, source='poll')
result = mgr.apply("/oven/vs/0", {"a": 2}, source="poll")
assert result is False
assert mgr.cache.get('/oven/vs/0') == {'a': 1}
assert mgr.cache.get("/oven/vs/0") == {"a": 1}
def test_apply_optimistic_bypasses_an_in_progress_settle_window():
@@ -77,29 +78,29 @@ def test_apply_optimistic_bypasses_an_in_progress_settle_window():
dropped by a guard that exists to protect optimistic writes, not
suppress them."""
mgr = _manager()
mgr.cache.apply_rep('/course/vs/0', {'Course': '1C', 'Detergent': '1'}, source='seed')
mgr.mark_write_pending('/course/vs/0', settle_s=30.0)
mgr.cache.apply_rep("/course/vs/0", {"Course": "1C", "Detergent": "1"}, source="seed")
mgr.mark_write_pending("/course/vs/0", settle_s=30.0)
result = mgr.apply('/course/vs/0', {'Detergent': '2'}, source='optimistic')
result = mgr.apply("/course/vs/0", {"Detergent": "2"}, source="optimistic")
assert result is True
assert mgr.cache.get('/course/vs/0') == {'Course': '1C', 'Detergent': '2'}
assert mgr.cache.get("/course/vs/0") == {"Course": "1C", "Detergent": "2"}
# A poll/sweep/observe update racing in right behind it is still
# gated -- the second write's own guard (re-armed by mark_write_pending,
# not exercised directly here) is what protects it going forward.
assert mgr.apply('/course/vs/0', {'Detergent': '1'}, source='poll') is False
assert mgr.apply("/course/vs/0", {"Detergent": "1"}, source="poll") is False
def test_apply_accepts_update_after_settle_window_elapses():
mgr = _manager()
mgr.mark_write_pending('/oven/vs/0', settle_s=0.05)
mgr.mark_write_pending("/oven/vs/0", settle_s=0.05)
time.sleep(0.1)
result = mgr.apply('/oven/vs/0', {'a': 2}, source='poll')
result = mgr.apply("/oven/vs/0", {"a": 2}, source="poll")
assert result is True
assert mgr.cache.get('/oven/vs/0') == {'a': 2}
assert mgr.cache.get("/oven/vs/0") == {"a": 2}
class _FakeSession:
@@ -110,28 +111,29 @@ class _FakeSession:
`mgr.on_notification(href, payload)` itself to simulate delivery,
since real notify delivery is async/threaded in production.
"""
def __init__(self):
self.subscribed: list[str] = []
self.fail_hrefs: set[str] = set()
def subscribe(self, path_segs):
href = '/' + '/'.join(path_segs)
href = "/" + "/".join(path_segs)
if href in self.fail_hrefs:
raise ConnectionError("subscribe failed")
self.subscribed.append(href)
return b'\x01'
return b"\x01"
def test_try_enter_observe_mode_succeeds_when_all_hrefs_notify():
mgr = _manager()
session = _FakeSession()
hrefs = ['/oven/vs/0', '/power/vs/0']
hrefs = ["/oven/vs/0", "/power/vs/0"]
def _notify_during_grace_period():
# Simulate notifications arriving during the grace period
time.sleep(0.005) # Let the sleep start, then notify partway through
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'x': 1}))
mgr.on_notification(href, cbor2.dumps({"x": 1}))
# Start background thread to deliver notifications during grace period
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
@@ -142,7 +144,7 @@ def test_try_enter_observe_mode_succeeds_when_all_hrefs_notify():
notifier.join()
assert entered is True
assert mgr.mode == 'observe'
assert mgr.mode == "observe"
assert mgr.subscribed_hrefs == set(hrefs)
finally:
mgr.close()
@@ -151,57 +153,60 @@ def test_try_enter_observe_mode_succeeds_when_all_hrefs_notify():
def test_try_enter_observe_mode_falls_back_when_no_notifies_arrive():
mgr = _manager()
session = _FakeSession()
hrefs = ['/oven/vs/0', '/power/vs/0']
hrefs = ["/oven/vs/0", "/power/vs/0"]
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.01)
assert entered is False
assert mgr.mode == 'poll'
assert mgr.mode == "poll"
assert mgr.subscribed_hrefs == set()
def test_try_enter_observe_mode_falls_back_when_subscribe_fails_for_all():
mgr = _manager()
session = _FakeSession()
session.fail_hrefs = {'/oven/vs/0', '/power/vs/0'}
hrefs = ['/oven/vs/0', '/power/vs/0']
session.fail_hrefs = {"/oven/vs/0", "/power/vs/0"}
hrefs = ["/oven/vs/0", "/power/vs/0"]
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.01)
assert entered is False
assert mgr.mode == 'poll'
assert mgr.mode == "poll"
def test_try_enter_observe_mode_meets_success_fraction_with_partial_notifies():
mgr = _manager()
session = _FakeSession()
hrefs = ['/a/vs/0', '/b/vs/0', '/c/vs/0', '/d/vs/0']
hrefs = ["/a/vs/0", "/b/vs/0", "/c/vs/0", "/d/vs/0"]
def _notify_partial():
# Simulate partial notifications arriving during grace period
time.sleep(0.005)
for href in hrefs[:3]: # 3/4 = 0.75
mgr.on_notification(href, cbor2.dumps({'x': 1}))
mgr.on_notification(href, cbor2.dumps({"x": 1}))
notifier = threading.Thread(target=_notify_partial, daemon=True)
notifier.start()
try:
entered = mgr.try_enter_observe_mode(
session, hrefs, grace_period_s=0.02, success_fraction=0.7,
session,
hrefs,
grace_period_s=0.02,
success_fraction=0.7,
)
notifier.join()
assert entered is True
assert mgr.mode == 'observe'
assert mgr.mode == "observe"
finally:
mgr.close()
def test_on_notification_ignores_malformed_cbor():
mgr = _manager()
mgr.on_notification('/oven/vs/0', b'\xff\xff\xff not cbor')
assert mgr.cache.get('/oven/vs/0') is None
mgr.on_notification("/oven/vs/0", b"\xff\xff\xff not cbor")
assert mgr.cache.get("/oven/vs/0") is None
def test_recently_notified_false_before_any_notify():
@@ -211,13 +216,13 @@ def test_recently_notified_false_before_any_notify():
def test_recently_notified_true_just_after_a_notify():
mgr = _manager()
mgr.on_notification('/oven/vs/0', cbor2.dumps({'a': 1}))
mgr.on_notification("/oven/vs/0", cbor2.dumps({"a": 1}))
assert mgr.recently_notified() is True
def test_recently_notified_false_after_window_elapses():
mgr = _manager()
mgr.on_notification('/oven/vs/0', cbor2.dumps({'a': 1}))
mgr.on_notification("/oven/vs/0", cbor2.dumps({"a": 1}))
assert mgr.recently_notified(window_s=0.01) is True
time.sleep(0.02)
assert mgr.recently_notified(window_s=0.01) is False
@@ -229,21 +234,21 @@ def test_try_enter_observe_mode_clears_stale_notifications_on_retry():
usage when poll-only mode periodically retries entering observe mode."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/a/vs/0', '/b/vs/0']
hrefs = ["/a/vs/0", "/b/vs/0"]
try:
# First call: all hrefs notify -> succeeds and enters observe mode
def _notify_all_first():
time.sleep(0.005)
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'x': 1}))
mgr.on_notification(href, cbor2.dumps({"x": 1}))
notifier1 = threading.Thread(target=_notify_all_first, daemon=True)
notifier1.start()
entered = mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.02)
notifier1.join()
assert entered is True
assert mgr.mode == 'observe'
assert mgr.mode == "observe"
# Second call: retry with same hrefs, but only one notifies.
# Without the fix (missing self._notified.clear()), the old notifications
@@ -253,7 +258,7 @@ def test_try_enter_observe_mode_clears_stale_notifications_on_retry():
def _notify_partial_second():
time.sleep(0.005)
mgr.on_notification(hrefs[0], cbor2.dumps({'x': 2})) # only one notifies
mgr.on_notification(hrefs[0], cbor2.dumps({"x": 2})) # only one notifies
notifier2 = threading.Thread(target=_notify_partial_second, daemon=True)
notifier2.start()
@@ -261,7 +266,7 @@ def test_try_enter_observe_mode_clears_stale_notifications_on_retry():
notifier2.join()
assert entered is False
assert mgr.mode == 'poll'
assert mgr.mode == "poll"
assert mgr.subscribed_hrefs == set()
finally:
mgr.close()
@@ -269,9 +274,9 @@ def test_try_enter_observe_mode_clears_stale_notifications_on_retry():
def test_log_sweep_discrepancies_noop_when_not_in_observe_mode(caplog):
mgr = _manager()
with caplog.at_level('DEBUG'):
mgr.log_sweep_discrepancies({'/oven/vs/0': {'a': 2}})
assert 'observe missed a change' not in caplog.text
with caplog.at_level("DEBUG"):
mgr.log_sweep_discrepancies({"/oven/vs/0": {"a": 2}})
assert "observe missed a change" not in caplog.text
def test_log_sweep_discrepancies_never_changes_mode():
@@ -282,12 +287,12 @@ def test_log_sweep_discrepancies_never_changes_mode():
away working push coverage on every other subscribed href."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/oven/vs/0', '/power/vs/0']
hrefs = ["/oven/vs/0", "/power/vs/0"]
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'a': 1}))
mgr.on_notification(href, cbor2.dumps({"a": 1}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
@@ -295,12 +300,12 @@ def test_log_sweep_discrepancies_never_changes_mode():
try:
mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.02)
notifier.join()
assert mgr.mode == 'observe'
assert mgr.mode == "observe"
# Sweep sees values the cache never got via notify on BOTH hrefs.
mgr.log_sweep_discrepancies({hrefs[0]: {'a': 2}, hrefs[1]: {'a': 2}})
mgr.log_sweep_discrepancies({hrefs[0]: {"a": 2}, hrefs[1]: {"a": 2}})
assert mgr.mode == 'observe'
assert mgr.mode == "observe"
finally:
mgr.close()
@@ -310,9 +315,9 @@ def test_rep_diff_reports_only_shared_fields_that_differ():
sweep vs. individual GET/notify returning different fields for the
same href) must not be reported — only shared fields with different
values."""
diff = _rep_diff({'a': 1, 'b': 2, 'c': 3}, {'a': 1, 'b': 5, 'd': 9})
diff = _rep_diff({"a": 1, "b": 2, "c": 3}, {"a": 1, "b": 5, "d": 9})
assert diff == {'b': (2, 5)}
assert diff == {"b": (2, 5)}
def test_log_sweep_discrepancies_ignores_shape_only_differences(caplog):
@@ -321,11 +326,11 @@ def test_log_sweep_discrepancies_ignores_shape_only_differences(caplog):
must not be logged as a discrepancy."""
mgr = _manager()
session = _FakeSession()
href = '/oven/vs/0'
href = "/oven/vs/0"
def _notify_during_grace_period():
time.sleep(0.005)
mgr.on_notification(href, cbor2.dumps({'a': 1, 'rt': ['x'], 'if': ['y']}))
mgr.on_notification(href, cbor2.dumps({"a": 1, "rt": ["x"], "if": ["y"]}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
@@ -333,16 +338,16 @@ def test_log_sweep_discrepancies_ignores_shape_only_differences(caplog):
try:
mgr.try_enter_observe_mode(session, [href], grace_period_s=0.02)
notifier.join()
assert mgr.mode == 'observe'
assert mgr.mode == "observe"
# Sweep rep has the same 'a' value but lacks rt/if and adds 'href' —
# a shape difference (batch interface), not a missed content change.
with caplog.at_level('DEBUG'):
mgr.log_sweep_discrepancies({href: {'a': 1, 'href': href}})
with caplog.at_level("DEBUG"):
mgr.log_sweep_discrepancies({href: {"a": 1, "href": href}})
finally:
mgr.close()
assert 'observe missed a change' not in caplog.text
assert "observe missed a change" not in caplog.text
def test_log_sweep_discrepancies_includes_the_diff(caplog):
@@ -350,12 +355,12 @@ def test_log_sweep_discrepancies_includes_the_diff(caplog):
did."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/oven/vs/0', '/power/vs/0']
hrefs = ["/oven/vs/0", "/power/vs/0"]
def _notify_during_grace_period():
time.sleep(0.005)
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'a': 1, 'b': 2}))
mgr.on_notification(href, cbor2.dumps({"a": 1, "b": 2}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
@@ -363,15 +368,13 @@ def test_log_sweep_discrepancies_includes_the_diff(caplog):
try:
mgr.try_enter_observe_mode(session, hrefs, grace_period_s=0.02)
notifier.join()
assert mgr.mode == 'observe'
assert mgr.mode == "observe"
with caplog.at_level('DEBUG'):
mgr.log_sweep_discrepancies(
{hrefs[0]: {'a': 1, 'b': 5}, hrefs[1]: {'a': 1, 'b': 9}}
)
with caplog.at_level("DEBUG"):
mgr.log_sweep_discrepancies({hrefs[0]: {"a": 1, "b": 5}, hrefs[1]: {"a": 1, "b": 9}})
assert "{'b': (2, 5)}" in caplog.text
assert mgr.mode == 'observe'
assert mgr.mode == "observe"
finally:
mgr.close()
@@ -379,25 +382,25 @@ def test_log_sweep_discrepancies_includes_the_diff(caplog):
def test_log_sweep_discrepancies_ignores_href_during_settle_window(caplog):
mgr = _manager()
session = _FakeSession()
href = '/oven/vs/0'
mgr.on_notification(href, cbor2.dumps({'a': 1}))
href = "/oven/vs/0"
mgr.on_notification(href, cbor2.dumps({"a": 1}))
mgr.try_enter_observe_mode(session, [href], grace_period_s=0.01)
mgr.mark_write_pending(href, settle_s=5.0)
with caplog.at_level('DEBUG'):
mgr.log_sweep_discrepancies({href: {'a': 2}})
with caplog.at_level("DEBUG"):
mgr.log_sweep_discrepancies({href: {"a": 2}})
assert 'observe missed a change' not in caplog.text
assert "observe missed a change" not in caplog.text
def test_downgrade_to_poll_moves_subscribed_to_fallback():
mgr = _manager()
session = _FakeSession()
href = '/oven/vs/0'
href = "/oven/vs/0"
def _notify_during_grace_period():
time.sleep(0.005)
mgr.on_notification(href, cbor2.dumps({'a': 1}))
mgr.on_notification(href, cbor2.dumps({"a": 1}))
notifier = threading.Thread(target=_notify_during_grace_period, daemon=True)
notifier.start()
@@ -407,14 +410,14 @@ def test_downgrade_to_poll_moves_subscribed_to_fallback():
mgr.downgrade_to_poll()
assert mgr.mode == 'poll'
assert mgr.mode == "poll"
assert mgr.subscribed_hrefs == set()
assert mgr.fallback_hrefs == {href}
def test_start_refresh_task_spawns_a_daemon_thread():
mgr = _manager()
mgr.subscribed_hrefs = {'/oven/vs/0'}
mgr.subscribed_hrefs = {"/oven/vs/0"}
session = _FakeSession()
mgr.start_refresh_task(session)
@@ -428,7 +431,7 @@ def test_start_refresh_task_spawns_a_daemon_thread():
def test_close_stops_the_refresh_thread():
mgr = _manager()
mgr.subscribed_hrefs = {'/oven/vs/0'}
mgr.subscribed_hrefs = {"/oven/vs/0"}
session = _FakeSession()
mgr.start_refresh_task(session)
thread = mgr._refresh_thread
@@ -442,7 +445,7 @@ def test_close_stops_the_refresh_thread():
def test_downgrade_to_poll_stops_refresh_task():
mgr = _manager()
mgr.subscribed_hrefs = {'/oven/vs/0'}
mgr.subscribed_hrefs = {"/oven/vs/0"}
session = _FakeSession()
mgr.start_refresh_task(session)
thread = mgr._refresh_thread
@@ -462,14 +465,14 @@ def test_try_enter_observe_mode_exits_early_when_fraction_reached():
first-refresh / observe-retry fetch paid that dead time."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/a/vs/0', '/b/vs/0']
hrefs = ["/a/vs/0", "/b/vs/0"]
def _notify_after_subscribe():
# Let try_enter clear _notified + subscribe first (matches the existing
# test pattern's 0.005 lead), then notify well before the 2s ceiling.
time.sleep(0.01)
for href in hrefs:
mgr.on_notification(href, cbor2.dumps({'x': 1}))
mgr.on_notification(href, cbor2.dumps({"x": 1}))
notifier = threading.Thread(target=_notify_after_subscribe, daemon=True)
notifier.start()
@@ -481,7 +484,7 @@ def test_try_enter_observe_mode_exits_early_when_fraction_reached():
notifier.join()
assert entered is True
assert mgr.mode == 'observe'
assert mgr.mode == "observe"
# Early exit: returns near the notify (~0.01s), not the 2.0s ceiling.
# The old fixed sleep would make this >= 2.0.
assert elapsed < 0.5
@@ -496,11 +499,11 @@ def test_try_enter_observe_mode_waits_full_ceiling_when_fraction_not_reached():
against an over-eager predicate that enters observe on too few notifies."""
mgr = _manager()
session = _FakeSession()
hrefs = ['/a/vs/0', '/b/vs/0', '/c/vs/0', '/d/vs/0'] # 4 hrefs
hrefs = ["/a/vs/0", "/b/vs/0", "/c/vs/0", "/d/vs/0"] # 4 hrefs
def _notify_one():
time.sleep(0.01)
mgr.on_notification(hrefs[0], cbor2.dumps({'x': 1})) # 1/4 = 0.25
mgr.on_notification(hrefs[0], cbor2.dumps({"x": 1})) # 1/4 = 0.25
notifier = threading.Thread(target=_notify_one, daemon=True)
notifier.start()
@@ -508,13 +511,16 @@ def test_try_enter_observe_mode_waits_full_ceiling_when_fraction_not_reached():
try:
t0 = time.monotonic()
entered = mgr.try_enter_observe_mode(
session, hrefs, grace_period_s=0.2, success_fraction=0.8,
session,
hrefs,
grace_period_s=0.2,
success_fraction=0.8,
)
elapsed = time.monotonic() - t0
notifier.join()
assert entered is False
assert mgr.mode == 'poll'
assert mgr.mode == "poll"
assert mgr.subscribed_hrefs == set()
# Waited the full 0.2s ceiling (1/4 = 0.25 < 0.8); did not early-exit.
assert elapsed >= 0.18
+10 -17
View File
@@ -1,7 +1,7 @@
"""Tests for the sensor platform."""
from __future__ import annotations
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -18,7 +18,7 @@ async def test_sensors_registered(
ent_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(ent_reg, mock_entry.entry_id)
sensor_entries = [e for e in entries if e.domain == 'sensor']
sensor_entries = [e for e in entries if e.domain == "sensor"]
assert sensor_entries, "Expected at least one sensor entity"
@@ -34,18 +34,11 @@ async def test_sensor_state_from_coordinator(
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
states = hass.states.async_all('sensor')
temp_states = [
s for s in states
if s.attributes.get('icon') == 'mdi:thermometer'
]
assert temp_states, (
f"No thermometer sensor found. Sensors: {[s.entity_id for s in states]}"
)
states = hass.states.async_all("sensor")
temp_states = [s for s in states if s.attributes.get("icon") == "mdi:thermometer"]
assert temp_states, f"No thermometer sensor found. Sensors: {[s.entity_id for s in states]}"
state = temp_states[0]
assert state.state not in ('unknown', 'unavailable'), (
f"Sensor state is {state.state}"
)
assert state.state not in ("unknown", "unavailable"), f"Sensor state is {state.state}"
async def test_connection_mode_sensor_registered_disabled_by_default(
@@ -58,11 +51,11 @@ async def test_connection_mode_sensor_registered_disabled_by_default(
ent_reg = er.async_get(hass)
entries = er.async_entries_for_config_entry(ent_reg, mock_entry.entry_id)
mode_entries = [e for e in entries if e.unique_id.endswith('_connection_mode')]
mode_entries = [e for e in entries if e.unique_id.endswith("_connection_mode")]
assert len(mode_entries) == 1
entry = mode_entries[0]
assert entry.disabled_by is not None
assert entry.entity_category == 'diagnostic'
assert entry.entity_category == "diagnostic"
async def test_connection_mode_sensor_reflects_coordinator_mode(
@@ -78,5 +71,5 @@ async def test_connection_mode_sensor_reflects_coordinator_mode(
sensor = LocalThingsConnectionModeSensor(coordinator)
assert sensor.native_value == MODE_POLL
coordinator._observe._set_mode('observe')
assert sensor.native_value == 'observe'
coordinator._observe._set_mode("observe")
assert sensor.native_value == "observe"
+25 -13
View File
@@ -1,16 +1,17 @@
"""Tests for the AirDresser device type (DA_DF_A51_20_COMMON, issue #162)."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_dresser():
resources = _load_device('air_dresser')
info = resources['/information/vs/0']
resources = _load_device("air_dresser")
info = resources["/information/vs/0"]
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
return reg, resources
@@ -22,7 +23,7 @@ def _state():
def test_resolves_to_air_dresser_registry():
reg, _ = _air_dresser()
assert reg is not None and reg.name == 'air_dresser'
assert reg is not None and reg.name == "air_dresser"
def test_no_unbound_hrefs():
@@ -36,7 +37,7 @@ def test_course_select_present_and_reads_current_selection():
"""This was the actual reported gap: /course/vs/0 was previously
entirely unbound, so no cycle/mode select existed at all."""
state = _state()
assert state['cycle'] == '01'
assert state["cycle"] == "01"
def test_course_options_derived_from_supported_options_fallback():
@@ -44,9 +45,10 @@ def test_course_options_derived_from_supported_options_fallback():
come from laundry.cycle_options' supportedOptions decode rather than
editCourseList -- confirmed distinct codes, current selection included."""
from custom_components.localthings.registry.capabilities.laundry import cycle_options
_, resources = _air_dresser()
codes = cycle_options(resources)
assert codes == ['01', '02', '04', '03', '05', '1A', '1B', '1C', '07', '08']
assert codes == ["01", "02", "04", "03", "05", "1A", "1B", "1C", "07", "08"]
def test_wrinkle_prevent_present_dry_level_fields_absent():
@@ -54,17 +56,27 @@ def test_wrinkle_prevent_present_dry_level_fields_absent():
dryerType are dryer-only fields this device never reports, so they
should not appear as always-empty entities."""
state = _state()
assert state['wrinkle_prevent'] is False
for key in ('dry_level', 'dry_time', 'dryer_type'):
assert state["wrinkle_prevent"] is False
for key in ("dry_level", "dry_time", "dryer_type"):
assert key not in state, key
def test_expected_entities_present():
state = _state()
for key in (
'power_switch', 'child_lock', 'remote_control', 'cycle',
'machine_state', 'progress', 'progress_percentage', 'finish_time',
'completion_minutes', 'delay_start_hours', 'diagnosis_status',
'job_beginning_status', 'wrinkle_prevent', 'energy_kwh',
"power_switch",
"child_lock",
"remote_control",
"cycle",
"machine_state",
"progress",
"progress_percentage",
"finish_time",
"completion_minutes",
"delay_start_hours",
"diagnosis_status",
"job_beginning_status",
"wrinkle_prevent",
"energy_kwh",
):
assert key in state, key
+23 -20
View File
@@ -9,18 +9,19 @@ labelling problem, not a coverage one; this board's course table isn't
identified yet either (same as #162's), so 'cycle' still renders as the
raw code until a reporter can map codes to names in the SmartThings app.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_dresser():
resources = _load_device('air_dresser_tp1_21')
info = resources['/information/vs/0']
resources = _load_device("air_dresser_tp1_21")
info = resources["/information/vs/0"]
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
return reg, resources
@@ -32,7 +33,7 @@ def _state():
def test_resolves_to_air_dresser_registry():
reg, _ = _air_dresser()
assert reg is not None and reg.name == 'air_dresser'
assert reg is not None and reg.name == "air_dresser"
def test_no_unbound_hrefs():
@@ -46,34 +47,36 @@ def test_buzzer_sound_present_and_writable():
"""Issue #208's actual coverage gap: /buzzersound/vs/0 was unbound on
this board before laundry.BUZZER_SOUND was added to the registry."""
state = _state()
assert state['buzzer_sound'] == 'On'
assert 'finish_sound' not in state # supportedFinishSound absent -- self-gated off
assert state["buzzer_sound"] == "On"
assert "finish_sound" not in state # supportedFinishSound absent -- self-gated off
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/buzzersound/vs/0'][0].entities
if e.key == 'buzzer_sound'
e
for e in air_dresser.REGISTRY.capabilities["/buzzersound/vs/0"][0].entities
if e.key == "buzzer_sound"
)
path, body = desc.write_fn('Off', {})
assert path == ['buzzersound', 'vs', '0']
assert body == {'setBuzzerSound': 'Off'}
path, body = desc.write_fn("Off", {})
assert path == ["buzzersound", "vs", "0"]
assert body == {"setBuzzerSound": "Off"}
def test_course_translation_key_falls_back_to_cycle_for_unidentified_table():
"""Same as issue #162's board: Table_00's course codes aren't
identified yet, so the select renders raw codes rather than names."""
_, resources = _air_dresser()
desc = air_dresser.REGISTRY.capabilities['/course/vs/0'][0].entities[0]
assert desc.translation_key(resources) == 'cycle'
desc = air_dresser.REGISTRY.capabilities["/course/vs/0"][0].entities[0]
assert desc.translation_key(resources) == "cycle"
def test_sanitize_present_and_toggles():
state = _state()
assert state['sanitize'] is False
assert state["sanitize"] is False
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/airdresseroption/sanitize/vs/0'][0].entities
if e.key == 'sanitize'
e
for e in air_dresser.REGISTRY.capabilities["/airdresseroption/sanitize/vs/0"][0].entities
if e.key == "sanitize"
)
path, body = desc.write_fn('On', {})
assert path == ['airdresseroption', 'sanitize', 'vs', '0']
assert body == {'x.com.samsung.da.sanitize': 'On'}
path, body = desc.write_fn("On", {})
assert path == ["airdresseroption", "sanitize", "vs", "0"]
assert body == {"x.com.samsung.da.sanitize": "On"}
+20 -17
View File
@@ -7,18 +7,19 @@ Exercises the two things this board does differently: a populated
fallback #162 relies on) and the new /airdresseroption/sanitize/vs/0
capability #162's dump doesn't report at all.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_dresser():
resources = _load_device('air_dresser_tp2_20')
info = resources['/information/vs/0']
resources = _load_device("air_dresser_tp2_20")
info = resources["/information/vs/0"]
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
return reg, resources
@@ -30,7 +31,7 @@ def _state():
def test_resolves_to_air_dresser_registry():
reg, _ = _air_dresser()
assert reg is not None and reg.name == 'air_dresser'
assert reg is not None and reg.name == "air_dresser"
def test_no_unbound_hrefs():
@@ -48,15 +49,16 @@ def test_course_options_come_from_edit_course_list_not_the_fallback():
directly, so cycle_options() should never reach the
supportedOptions-decode fallback."""
from custom_components.localthings.registry.capabilities.laundry import cycle_options
_, resources = _air_dresser()
codes = cycle_options(resources)
assert codes[:3] == ['22', '23', '0C']
assert codes == cycle_options({'/wm/editcourse/vs/0': resources['/wm/editcourse/vs/0']})
assert codes[:3] == ["22", "23", "0C"]
assert codes == cycle_options({"/wm/editcourse/vs/0": resources["/wm/editcourse/vs/0"]})
def test_course_select_reads_current_selection():
state = _state()
assert state['cycle'] == '22'
assert state["cycle"] == "22"
def test_course_translation_key_falls_back_to_cycle_for_unidentified_table():
@@ -64,19 +66,20 @@ def test_course_translation_key_falls_back_to_cycle_for_unidentified_table():
translation_key resolves to the generic 'cycle' catalog entry rather
than a table-specific one that doesn't exist."""
_, resources = _air_dresser()
desc = air_dresser.REGISTRY.capabilities['/course/vs/0'][0].entities[0]
assert desc.translation_key(resources) == 'cycle'
desc = air_dresser.REGISTRY.capabilities["/course/vs/0"][0].entities[0]
assert desc.translation_key(resources) == "cycle"
def test_sanitize_present_and_toggles():
state = _state()
assert state['sanitize'] is False
assert state["sanitize"] is False
desc = next(
e for e in air_dresser.REGISTRY.capabilities['/airdresseroption/sanitize/vs/0'][0].entities
if e.key == 'sanitize'
e
for e in air_dresser.REGISTRY.capabilities["/airdresseroption/sanitize/vs/0"][0].entities
if e.key == "sanitize"
)
path, body = desc.write_fn('On', {})
assert path == ['airdresseroption', 'sanitize', 'vs', '0']
assert body == {'x.com.samsung.da.sanitize': 'On'}
assert desc.write_fn('Sparkle', {}) is None
path, body = desc.write_fn("On", {})
assert path == ["airdresseroption", "sanitize", "vs", "0"]
assert body == {"x.com.samsung.da.sanitize": "On"}
assert desc.write_fn("Sparkle", {}) is None
+29 -29
View File
@@ -2,17 +2,17 @@
issue #210) -- a standalone, battery-powered air-quality sensor puck. No
controllable appliance state at all beyond the do-not-disturb window.
"""
from datetime import time as dt_time
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_oic_type, resolve
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _air_monitor():
resources = _load_device('air_monitor')
resources = _load_device("air_monitor")
reg = resolve(resources)
return reg, resources
@@ -34,15 +34,15 @@ def _desc(key):
def test_resolves_to_air_monitor_registry():
reg, _ = _air_monitor()
assert reg is not None and reg.name == 'air_monitor'
assert reg is not None and reg.name == "air_monitor"
def test_resolves_via_oic_type_too():
"""This board's real dump carries /oic/d's device type
('x.com.st.d.airqualitysensor') as well as the 'ASM' modelNum token --
both paths must agree."""
reg = for_device_by_oic_type(('x.com.st.d.airqualitysensor',))
assert reg is not None and reg.name == 'air_monitor'
reg = for_device_by_oic_type(("x.com.st.d.airqualitysensor",))
assert reg is not None and reg.name == "air_monitor"
def test_no_unbound_hrefs():
@@ -57,12 +57,12 @@ def test_air_quality_sensors_read_from_shared_items_decode():
decode air_purifier.AIR_QUALITY already uses -- this board adds CO2,
which neither air_purifier nor range_hood report."""
state = _state()
assert state['dust'] == 31
assert state['fine_dust'] == 23
assert state['super_fine_dust'] == 18
assert state['odor'] == 1
assert state['clean_level'] == 2
assert state['co2'] == 498
assert state["dust"] == 31
assert state["fine_dust"] == 23
assert state["super_fine_dust"] == 18
assert state["odor"] == 1
assert state["clean_level"] == 2
assert state["co2"] == 498
def test_second_value_slot_is_not_exposed():
@@ -72,27 +72,27 @@ def test_second_value_slot_is_not_exposed():
nothing' rule, only the raw reading (value[0]) is bound, not a second
'dust_level' or similar entity for that code."""
state = _state()
assert 'dust_level' not in state
assert 'dust_grade' not in state
assert "dust_level" not in state
assert "dust_grade" not in state
def test_humidity_and_battery_present():
state = _state()
assert state['humidity'] == 57
assert state['battery'] == 80
assert state['battery_charging'] is False
assert state["humidity"] == 57
assert state["battery"] == 80
assert state["battery_charging"] is False
def test_air_quality_standard_present():
state = _state()
assert state['air_quality_standard'] == 'AirCleanAssociation'
assert state["air_quality_standard"] == "AirCleanAssociation"
def test_dnd_read_contract():
state = _state()
assert state['dnd'] is False
assert state['dnd_start'] == dt_time(14, 0)
assert state['dnd_end'] == dt_time(22, 0)
assert state["dnd"] is False
assert state["dnd_start"] == dt_time(14, 0)
assert state["dnd_end"] == dt_time(22, 0)
def test_dnd_write_contracts_are_flagged_educated_guesses():
@@ -102,14 +102,14 @@ def test_dnd_write_contracts_are_flagged_educated_guesses():
string/time-format shape, not invented from nothing, but still
unconfirmed on real hardware. Only the wiring is tested here; whether
the device actually accepts these writes needs a reporter to confirm."""
dnd = _desc('dnd')
path, body = dnd.write_fn('On', {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.value': 'true'}
path, body = dnd.write_fn('Off', {})
assert body == {'x.com.samsung.da.value': 'false'}
dnd = _desc("dnd")
path, body = dnd.write_fn("On", {})
assert path == ["dnd", "vs", "0"]
assert body == {"x.com.samsung.da.value": "true"}
path, body = dnd.write_fn("Off", {})
assert body == {"x.com.samsung.da.value": "false"}
start = _desc('dnd_start')
start = _desc("dnd_start")
path, body = start.write_fn(dt_time(9, 30), {})
assert path == ['dnd', 'vs', '0']
assert body == {'x.com.samsung.da.startTime': '09:30:00'}
assert path == ["dnd", "vs", "0"]
assert body == {"x.com.samsung.da.startTime": "09:30:00"}
+24 -21
View File
@@ -1,5 +1,8 @@
"""HA fan-entity mapping tests for the ARTIK051_TVTL air purifier (issue #56)."""
from typing import ClassVar, cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.fan import LocalThingsAirflowFan
from custom_components.localthings.registry.by_type import air_purifier
from custom_components.localthings.registry.capabilities.air_purifier import HREF_AIRFLOW
@@ -9,9 +12,9 @@ from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-AIRFLOW-SERIAL'
device_info = {}
data = {}
device_serial = "TEST-AIRFLOW-SERIAL"
device_info: ClassVar[dict] = {}
data: ClassVar[dict] = {}
def __init__(self, resources):
self.last_resources = resources
@@ -37,16 +40,16 @@ def _entity(resources, coordinator=None):
air_purifier.REGISTRY.pattern_capabilities,
)
fan_bound = next(
item for item in bound
if isinstance(item.desc, FanDesc) and item.href == HREF_AIRFLOW
item for item in bound if isinstance(item.desc, FanDesc) and item.href == HREF_AIRFLOW
)
return LocalThingsAirflowFan(
coordinator or _FakeCoordinator(resources), fan_bound,
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)),
fan_bound,
)
def test_power_off_maps_to_zero_percent_and_five_retained_speeds():
entity = _entity(_load_device('air_purifier'))
entity = _entity(_load_device("air_purifier"))
assert entity.is_on is False
assert entity.speed_count == 5
assert entity.percentage == 0
@@ -55,11 +58,11 @@ def test_power_off_maps_to_zero_percent_and_five_retained_speeds():
def test_active_codes_map_to_ordered_percentages():
"""Confirmed via issue #56's second, properly-spaced diagnostics round:
/airflow/0's speed is a clean 0-4 code across Auto/Sleep/Low/Medium/High."""
resources = _load_device('air_purifier')
resources['/power/0']['value'] = True
resources = _load_device("air_purifier")
resources["/power/0"]["value"] = True
for code, expected_percentage in ((0, 20), (1, 40), (2, 60), (3, 80), (4, 100)):
resources['/airflow/0']['speed'] = code
resources["/airflow/0"]["speed"] = code
assert _entity(resources).percentage == expected_percentage
@@ -69,43 +72,43 @@ async def test_power_write_prefers_standard_resource_when_both_exist():
power_switch entity is unconditionally bound to /power/0 whenever it's
present, so writing here to /power/vs/0 first would leave the two
entities disagreeing until the next poll)."""
resources = _load_device('air_purifier')
resources = _load_device("air_purifier")
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_on()
assert coordinator.commands[-1][1] == ('power', True, '/power/0')
assert coordinator.commands[-1][1] == ("power", True, "/power/0")
async def test_power_write_falls_back_to_vendor_resource():
resources = _load_device('air_purifier')
resources.pop('/power/0')
resources = _load_device("air_purifier")
resources.pop("/power/0")
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_turn_off()
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
assert coordinator.commands[-1][1] == ("power", False, "/power/vs/0")
async def test_set_percentage_writes_raw_speed_code():
resources = _load_device('air_purifier')
resources['/power/0']['value'] = True
resources = _load_device("air_purifier")
resources["/power/0"]["value"] = True
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_percentage(60)
assert coordinator.commands[-1][1] == ('speed', 2)
assert coordinator.commands[-1][1] == ("speed", 2)
async def test_set_percentage_zero_turns_off():
resources = _load_device('air_purifier')
resources['/power/0']['value'] = True
resources = _load_device("air_purifier")
resources["/power/0"]["value"] = True
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_percentage(0)
assert coordinator.commands[-1][1] == ('power', False, '/power/0')
assert coordinator.commands[-1][1] == ("power", False, "/power/0")
+14 -13
View File
@@ -8,25 +8,26 @@ diagnostics showed device_type 'unknown' with empty oneUiVersion and every
resource unbound -- once routed to the existing air_purifier registry, every
resource here binds with zero gaps.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
def _resources():
return _load_device('air_purifier_avt_ww')
return _load_device("air_purifier_avt_ww")
def _reg(resources):
info = resources['/information/vs/0']
info = resources["/information/vs/0"]
return for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
def test_resolves_to_air_purifier_registry():
assert _reg(_resources()).name == 'air_purifier'
assert _reg(_resources()).name == "air_purifier"
def test_no_unbound_hrefs():
@@ -45,9 +46,9 @@ def test_wind_strength_fan_preset_names():
reg = _reg(resources)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert state['wind_strength_fan'] == '87'
wind = resources['/wind/strength/vs/0']
assert wind['x.com.samsung.da.modesName'] == ['SMART', 'MAX', 'WINDFREE', 'Sleep']
assert state["wind_strength_fan"] == "87"
wind = resources["/wind/strength/vs/0"]
assert wind["x.com.samsung.da.modesName"] == ["SMART", "MAX", "WINDFREE", "Sleep"]
def test_air_quality_and_filter_sensors_present():
@@ -55,8 +56,8 @@ def test_air_quality_and_filter_sensors_present():
reg = _reg(resources)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert state['dust'] == 10
assert state['fine_dust'] == 9
assert state['odor'] == 1
assert state['hepa_filter_usage'] == 0
assert state['hepa_filter_status'] == 'normal'
assert state["dust"] == 10
assert state["fine_dust"] == 9
assert state["odor"] == 1
assert state["hepa_filter_usage"] == 0
assert state["hepa_filter_status"] == "normal"
+62 -38
View File
@@ -1,4 +1,5 @@
"""Tests for the ARTIK051_TVTL_18K air-purifier profile (issue #56)."""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities import air_purifier
@@ -7,10 +8,11 @@ from tests.conftest import _load_device
def _purifier():
resources = _load_device('air_purifier')
info = resources['/information/vs/0']
resources = _load_device("air_purifier")
info = resources["/information/vs/0"]
reg = for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
info["x.com.samsung.da.modelNum"],
info["x.com.samsung.da.description"],
)
return reg, resources
@@ -24,7 +26,7 @@ def _state():
def test_model_resolves_to_air_purifier_registry():
reg, _ = _purifier()
assert reg is not None
assert reg.name == 'air_purifier'
assert reg.name == "air_purifier"
def test_no_unbound_hrefs():
@@ -39,10 +41,20 @@ def test_no_unbound_hrefs():
def test_expected_entities_present():
state = _state()
for key in (
'power_switch', 'alarm_code', 'dust', 'fine_dust', 'super_fine_dust',
'odor', 'clean_level', 'filter_progress', 'device_active',
'diagnosis_status', 'airflow_fan', 'fan_direction',
'display_light', 'operating_mode',
"power_switch",
"alarm_code",
"dust",
"fine_dust",
"super_fine_dust",
"odor",
"clean_level",
"filter_progress",
"device_active",
"diagnosis_status",
"airflow_fan",
"fan_direction",
"display_light",
"operating_mode",
):
assert key in state, key
@@ -51,25 +63,26 @@ def test_air_quality_sensor_values():
"""Dust/FineDust/SuperFineDust/Odor/CleanLevel read index 0 of each
items[] entry's value list (the raw measurement, per common.sensor_item_value)."""
state = _state()
assert state['dust'] == 11
assert state['fine_dust'] == 9
assert state['super_fine_dust'] == 5
assert state['odor'] == 0
assert state['clean_level'] == 0
assert state["dust"] == 11
assert state["fine_dust"] == 9
assert state["super_fine_dust"] == 5
assert state["odor"] == 0
assert state["clean_level"] == 0
def test_filter_progress_reads_named_consumable_item():
"""FilterProgress is confirmed (issue #56) to count up as the filter
wears -- 100 means fully used and needs replacing, not "brand new"."""
assert _state()['filter_progress'] == 100
assert _state()["filter_progress"] == 100
def test_diagnosis_reuses_dishwasher_capability():
"""/diagnosis/vs/0 has the identical field/write contract as
dishwasher.DIAGNOSIS, so the by_type registry reuses it directly."""
from custom_components.localthings.registry.capabilities import dishwasher
reg, _ = _purifier()
assert dishwasher.DIAGNOSIS in reg.capabilities['/diagnosis/vs/0']
assert dishwasher.DIAGNOSIS in reg.capabilities["/diagnosis/vs/0"]
def test_light_switch_write_contract():
@@ -77,14 +90,19 @@ def test_light_switch_write_contract():
(via laundry.option_write) -- confirmed on real hardware (issue #54)
that the device merges by prefix itself, so no read-modify-write of the
whole packed /mode/vs/0 options list is needed."""
desc = next(e for e in air_purifier.MODE.entities if e.key == 'display_light')
rep = {'x.com.samsung.da.options': [
'Comode_Off', 'Blooming_0', 'Light_On', 'OptionCode_60282',
]}
desc = next(e for e in air_purifier.MODE.entities if e.key == "display_light")
rep = {
"x.com.samsung.da.options": [
"Comode_Off",
"Blooming_0",
"Light_On",
"OptionCode_60282",
]
}
assert desc.rep_fn(rep) is True
assert desc.write_fn('Off', rep) == (
['mode', 'vs', '0'],
{'x.com.samsung.da.options': ['Light_Off']},
assert desc.write_fn("Off", rep) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Light_Off"]},
)
@@ -94,25 +112,29 @@ def test_operating_mode_is_a_read_only_diagnostic():
regardless of the device's actual fan setting, ruling out the original
guess that it was the fan-speed selector; its real purpose is still
unconfirmed (see the air_purifier.py module docstring)."""
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == 'operating_mode')
rep = {'x.com.samsung.da.options': ['Comode_Off']}
assert operating_mode.rep_fn(rep) == 'Off'
assert not hasattr(operating_mode, 'write_fn')
operating_mode = next(e for e in air_purifier.MODE.entities if e.key == "operating_mode")
rep = {"x.com.samsung.da.options": ["Comode_Off"]}
assert operating_mode.rep_fn(rep) == "Off"
assert not hasattr(operating_mode, "write_fn")
def test_blooming_not_modeled():
"""Confirmed (issue #56) to have no corresponding SmartThings app
setting -- dropped entirely rather than kept as an unexplained
diagnostic."""
assert not any(e.key == 'blooming_level' for e in air_purifier.MODE.entities)
assert not any(e.key == "blooming_level" for e in air_purifier.MODE.entities)
def test_airflow_vs_fallback_only_binds_without_generic():
"""/airflow/vs/0 is a match_fn fallback -- it must not bind when the
OCF-standard /airflow/0 is also present (both are on every dump seen)."""
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn(
{}, {'/airflow/0': {'speed': 0, 'direction': 'Off'}},
) is False
assert (
air_purifier.AIRFLOW_VS_FALLBACK.match_fn(
{},
{"/airflow/0": {"speed": 0, "direction": "Off"}},
)
is False
)
assert air_purifier.AIRFLOW_VS_FALLBACK.match_fn({}, {}) is True
@@ -121,13 +143,15 @@ def test_airflow_fan_write_contract():
(two independent units, 60-90s apart per setting): /airflow/0's `speed`
is a clean, monotonic 0-4 code, so the write is a plain int passthrough
-- no named-preset table needed (see fan.py's LocalThingsAirflowFan)."""
fan_desc = next(e for e in air_purifier.AIRFLOW_GENERIC.entities if e.key == 'airflow_fan')
assert fan_desc.write_fn(('speed', 3), {}) == (['airflow', '0'], {'speed': 3})
assert fan_desc.write_fn(('power', True, '/power/vs/0'), {}) == (
['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'},
fan_desc = next(e for e in air_purifier.AIRFLOW_GENERIC.entities if e.key == "airflow_fan")
assert fan_desc.write_fn(("speed", 3), {}) == (["airflow", "0"], {"speed": 3})
assert fan_desc.write_fn(("power", True, "/power/vs/0"), {}) == (
["power", "vs", "0"],
{"x.com.samsung.da.power": "On"},
)
assert fan_desc.write_fn(('power', False, '/power/0'), {}) == (
['power', '0'], {'value': False},
assert fan_desc.write_fn(("power", False, "/power/0"), {}) == (
["power", "0"],
{"value": False},
)
@@ -136,5 +160,5 @@ def test_airflow_speed_reads_from_dump():
fan_direction stays a plain diagnostic (every dump seen reads 'Off'
regardless of fan setting)."""
state = _state()
assert state['airflow_fan'] == 0
assert state['fan_direction'] == 'Off'
assert state["airflow_fan"] == 0
assert state["fan_direction"] == "Off"
+5 -4
View File
@@ -8,6 +8,7 @@ capability (modes/supportedModes reported directly). Pure function, no
coordinator/entity dependency needed to test it directly -- same rationale
as test_climate_wind_oscillation_fallback.py's `_oscillation_swing`.
"""
from custom_components.localthings.registry.capabilities.air_purifier import (
_has_top_level_modes,
)
@@ -15,8 +16,8 @@ from custom_components.localthings.registry.capabilities.air_purifier import (
def test_new_family_with_supported_modes_list_matches():
rep = {
'x.com.samsung.da.modes': ['Smart'],
'x.com.samsung.da.supportedModes': ['Smart', 'Max', 'Mid', 'WindFree', 'Sleep'],
"x.com.samsung.da.modes": ["Smart"],
"x.com.samsung.da.supportedModes": ["Smart", "Max", "Mid", "WindFree", "Sleep"],
}
assert _has_top_level_modes(rep, {}) is True
@@ -26,13 +27,13 @@ def test_old_family_with_no_supported_modes_field_does_not_match():
options[] (Light_On, Comode_Off, ...) and has no top-level
supportedModes at all."""
rep = {
'x.com.samsung.da.options': ['Light_On', 'Comode_Off', 'OptionCode_60282'],
"x.com.samsung.da.options": ["Light_On", "Comode_Off", "OptionCode_60282"],
}
assert _has_top_level_modes(rep, {}) is False
def test_non_list_supported_modes_does_not_match():
assert _has_top_level_modes({'x.com.samsung.da.supportedModes': 'Smart'}, {}) is False
assert _has_top_level_modes({"x.com.samsung.da.supportedModes": "Smart"}, {}) is False
def test_empty_rep_does_not_match():
+26 -21
View File
@@ -8,19 +8,22 @@ family's /mode/vs/0 (issue #130) where supportedModes IS the name list
already. LocalThingsAirPurifierFan._label_for_code resolves both shapes
without a per-model map.
"""
from typing import ClassVar, cast
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.fan import LocalThingsAirPurifierFan
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.capabilities.air_purifier import HREF_WIND_STRENGTH
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import FanDesc
from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-VTWW-SERIAL'
device_info = {}
data = {}
device_serial = "TEST-VTWW-SERIAL"
device_info: ClassVar[dict] = {}
data: ClassVar[dict] = {}
def __init__(self, resources):
self.last_resources = resources
@@ -40,27 +43,29 @@ class _FakeCoordinator:
def _resources():
return _load_device('air_purifier_vtww')
return _load_device("air_purifier_vtww")
def _reg(resources):
info = resources['/information/vs/0']
info = resources["/information/vs/0"]
return for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
def _entity(resources, coordinator=None):
reg = _reg(resources)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
fan_bound = next(
item for item in bound
if isinstance(item.desc, FanDesc) and item.href == HREF_WIND_STRENGTH
item for item in bound if isinstance(item.desc, FanDesc) and item.href == HREF_WIND_STRENGTH
)
return LocalThingsAirPurifierFan(
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)), fan_bound
)
return LocalThingsAirPurifierFan(coordinator or _FakeCoordinator(resources), fan_bound)
def test_resolves_to_air_purifier_registry():
assert _reg(_resources()).name == 'air_purifier'
assert _reg(_resources()).name == "air_purifier"
def test_no_unbound_hrefs():
@@ -73,13 +78,13 @@ def test_no_unbound_hrefs():
def test_preset_modes_come_from_modes_name_not_the_raw_codes():
entity = _entity(_resources())
assert entity.preset_modes == ['smart', 'max', 'windfree', 'sleep']
assert entity.preset_modes == ["smart", "max", "windfree", "sleep"]
def test_preset_mode_reads_the_current_code_via_modes_name():
"""Fixture's current mode is '87' -> modesName[0] 'SMART'."""
entity = _entity(_resources())
assert entity.preset_mode == 'smart'
assert entity.preset_mode == "smart"
async def test_set_preset_mode_writes_back_the_raw_code():
@@ -87,9 +92,9 @@ async def test_set_preset_mode_writes_back_the_raw_code():
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_preset_mode('windfree')
await entity.async_set_preset_mode("windfree")
assert coordinator.commands[-1][1] == ('mode', '90')
assert coordinator.commands[-1][1] == ("mode", "90")
def test_is_on_reads_vendor_power():
@@ -111,7 +116,7 @@ def test_wind_strength_fan_key_does_not_collide_with_mode_fan():
for cap in (air_purifier.FAN, air_purifier.WIND_STRENGTH_FAN)
for entity in cap.entities
}
assert fan_keys == {'fan', 'wind_strength_fan'}
assert fan_keys == {"fan", "wind_strength_fan"}
def test_both_fan_hrefs_bound_simultaneously_produce_distinct_entities():
@@ -121,14 +126,14 @@ def test_both_fan_hrefs_bound_simultaneously_produce_distinct_entities():
from custom_components.localthings.registry.adapter import flatten
resources = _resources()
resources['/mode/vs/0'] = {
'x.com.samsung.da.modes': ['Smart'],
'x.com.samsung.da.supportedModes': ['Smart', 'Max', 'Mid', 'WindFree', 'Sleep'],
resources["/mode/vs/0"] = {
"x.com.samsung.da.modes": ["Smart"],
"x.com.samsung.da.supportedModes": ["Smart", "Max", "Mid", "WindFree", "Sleep"],
}
reg = _reg(resources)
unbound = []
bound = discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
state = flatten(bound, resources)
assert state['fan'] == 'Smart'
assert state['wind_strength_fan'] == '87'
assert state["fan"] == "Smart"
assert state["wind_strength_fan"] == "87"
+106 -82
View File
@@ -18,27 +18,34 @@ the same model with a slightly different token set (no ``Spi``,
``FilterTime_5460``, ``OutdoorTemp_81``), which the token entities' presence
gating handles the same way it handles newer boards.
"""
from typing import ClassVar
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import (
airconditioner, for_device_by_model,
airconditioner,
for_device_by_model,
)
from custom_components.localthings.registry.capabilities.airconditioner import (
HREF_AIRFLOW, HREF_WIND_STRENGTH, _option_number_write,
_option_switch_write, is_legacy_board,
HREF_AIRFLOW,
HREF_WIND_STRENGTH,
_option_number_write,
_option_switch_write,
is_legacy_board,
)
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from tests.conftest import _load_device
FIXTURE = 'airconditioner_artik051_krac_18k'
MODEL = 'ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000'
FIXTURE = "airconditioner_artik051_krac_18k"
MODEL = "ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000"
class _FakeCoordinator:
device_serial = 'TEST-KRAC-SERIAL'
device_info = {}
data = {}
device_serial = "TEST-KRAC-SERIAL"
device_info: ClassVar[dict] = {}
data: ClassVar[dict] = {}
def __init__(self, resources):
self.last_resources = resources
@@ -59,8 +66,9 @@ class _FakeCoordinator:
def _discover(resources, registry=airconditioner.REGISTRY):
unbound = []
bound = discover(resources, registry.capabilities,
registry.pattern_capabilities, log=unbound.append)
bound = discover(
resources, registry.capabilities, registry.pattern_capabilities, log=unbound.append
)
return bound, unbound
@@ -72,24 +80,24 @@ def _state(fixture=FIXTURE):
def _climate(resources, coordinator=None):
bound, _ = _discover(resources)
climate_bound = next(
item for item in bound if isinstance(item.desc, ClimateDesc)
)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
return LocalThingsClimate(
coordinator or _FakeCoordinator(resources), climate_bound,
coordinator or _FakeCoordinator(resources),
climate_bound,
)
# -- device type --------------------------------------------------------------
def test_krac_model_resolves_to_the_airconditioner_registry():
"""The '_RAC_' token check can't see '_KRAC_' -- the 'K' sits between the
underscore and 'RAC' -- and the consumer-prefix fallback only covers
washers/dryers/dishwashers, so this model resolved to 'unknown' and
exposed nothing but power."""
registry = for_device_by_model(MODEL, 'ARTIK051_KRAC_18K')
registry = for_device_by_model(MODEL, "ARTIK051_KRAC_18K")
assert registry is not None
assert registry.name == 'airconditioner'
assert registry.name == "airconditioner"
def test_no_unbound_hrefs():
@@ -99,20 +107,21 @@ def test_no_unbound_hrefs():
# -- option-token entities ----------------------------------------------------
def test_token_entities_present_with_calibrated_values():
state = _state()
# token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the
# official app on this unit, which pins the scale (the .5 here is a later
# reading). It counts up -- see the descriptor comment and
# test_filter_alarm_tracks_the_counter_against_its_threshold below.
assert state['filter_time'] == 171.5
assert state["filter_time"] == 171.5
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
assert state['outdoor_temperature'] == 19.0
assert state['beep'] is True
assert state['good_sleep'] == 0.0
assert state['spi'] is False
assert state['auto_clean_legacy'] is False
assert state['air_monitoring'] is False
assert state["outdoor_temperature"] == 19.0
assert state["beep"] is True
assert state["good_sleep"] == 0.0
assert state["spi"] is False
assert state["auto_clean_legacy"] is False
assert state["air_monitoring"] is False
def test_token_entities_stay_off_newer_boards():
@@ -120,9 +129,15 @@ def test_token_entities_stay_off_newer_boards():
while also exposing those settings as dedicated resources -- ungated, the
token entities would duplicate them (auto clean) or apply a scale
calibrated on another board generation (outdoor temperature)."""
state = _state('airconditioner_tp1x_rac')
for key in ('spi', 'auto_clean_legacy', 'air_monitoring',
'good_sleep', 'outdoor_temperature', 'filter_time'):
state = _state("airconditioner_tp1x_rac")
for key in (
"spi",
"auto_clean_legacy",
"air_monitoring",
"good_sleep",
"outdoor_temperature",
"filter_time",
):
assert key not in state, key
@@ -136,7 +151,7 @@ def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board():
assert is_legacy_board(legacy_resources) is True
assert _climate(legacy_resources)._legacy_airflow() == legacy_resources[HREF_AIRFLOW]
newer_resources = _load_device('airconditioner_tp1x_rac')
newer_resources = _load_device("airconditioner_tp1x_rac")
assert is_legacy_board(newer_resources) is False
assert _climate(newer_resources)._legacy_airflow() == {}
assert HREF_WIND_STRENGTH in newer_resources
@@ -145,12 +160,12 @@ def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board():
def test_absent_token_yields_no_entity():
"""The issue #136 unit of this same model reports no Spi token."""
resources = _load_device(FIXTURE)
options = resources['/mode/vs/0']['x.com.samsung.da.options']
resources['/mode/vs/0']['x.com.samsung.da.options'] = [
option for option in options if not option.startswith('Spi_')
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
option for option in options if not option.startswith("Spi_")
]
bound, _ = _discover(resources)
assert 'spi' not in flatten(bound, resources)
assert "spi" not in flatten(bound, resources)
def test_humidity_reads_the_vendor_field_and_treats_zero_as_unknown():
@@ -158,30 +173,33 @@ def test_humidity_reads_the_vendor_field_and_treats_zero_as_unknown():
only carries a reading while Air monitoring is on, and the unit switches
that back off by itself after about a minute."""
resources = _load_device(FIXTURE)
assert resources['/humidity/vs/0']['x.com.samsung.da.humidity'] == '0'
assert resources["/humidity/vs/0"]["x.com.samsung.da.humidity"] == "0"
bound, _ = _discover(resources)
assert flatten(bound, resources)['humidity'] is None
assert flatten(bound, resources)["humidity"] is None
resources['/humidity/vs/0']['x.com.samsung.da.humidity'] = '51'
resources["/humidity/vs/0"]["x.com.samsung.da.humidity"] = "51"
bound, _ = _discover(resources)
assert flatten(bound, resources)['humidity'] == 51.0
assert flatten(bound, resources)["humidity"] == 51.0
def test_option_writes_carry_one_token():
"""Both go through option_write's single-token merge, the same mechanism
the display light already uses on this href. Confirmed on hardware by
read-back: Spi_On/Spi_Off, and the volume token surviving a write."""
assert _option_switch_write('Spi')('On', {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Spi_On']},
assert _option_switch_write("Spi")("On", {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Spi_On"]},
)
# The number platform hands over a float; the device wants an integer token.
assert _option_number_write('Volume')(70.0, {}) == (
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_70']},
assert _option_number_write("Volume")(70.0, {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Volume_70"]},
)
# -- filter counter and its reset ---------------------------------------------
def _desc(resources, key):
"""The bound descriptor for `key`, or None when its exists_fn declines it.
@@ -206,13 +224,13 @@ def test_filter_alarm_time_reads_the_threshold_and_writes_one_token():
options tuple defensible here (the board advertises no supported-values
list for options[] tokens)."""
state = _state()
assert state['filter_alarm_time'] == '500' # the fixture's own value
assert state["filter_alarm_time"] == "500" # the fixture's own value
desc = _desc(_load_device(FIXTURE), 'filter_alarm_time')
assert desc.options == ('180', '300', '500', '700')
assert desc.write_fn('180', {}) == (
['mode', 'vs', '0'],
{'x.com.samsung.da.options': ['FilterAlarmTime_180']},
desc = _desc(_load_device(FIXTURE), "filter_alarm_time")
assert desc.options == ("180", "300", "500", "700")
assert desc.write_fn("180", {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["FilterAlarmTime_180"]},
)
@@ -225,15 +243,16 @@ def test_filter_alarm_time_stays_off_boards_with_a_real_threshold_resource():
would pass for the wrong reason -- absent token rather than the
board-generation gate. The token is injected to exercise the gate itself,
test_absent_token_yields_no_entity's technique in reverse."""
newer = _load_device('airconditioner_tp1x_rac')
assert _desc(newer, 'filter_alarm_time') is None
newer = _load_device("airconditioner_tp1x_rac")
assert _desc(newer, "filter_alarm_time") is None
mode = newer['/mode/vs/0']
mode['x.com.samsung.da.options'] = [
*(mode.get('x.com.samsung.da.options') or []), 'FilterAlarmTime_500',
mode = newer["/mode/vs/0"]
mode["x.com.samsung.da.options"] = [
*(mode.get("x.com.samsung.da.options") or []),
"FilterAlarmTime_500",
]
assert is_legacy_board(newer) is False
assert _desc(newer, 'filter_alarm_time') is None
assert _desc(newer, "filter_alarm_time") is None
def test_filter_alarm_tracks_the_counter_against_its_threshold():
@@ -243,36 +262,36 @@ def test_filter_alarm_tracks_the_counter_against_its_threshold():
same site, at FilterTime_5595 against the same FilterAlarmTime_500, instead
reported an unsuffixed 'FilterAlarm' in state 'Created'."""
resources = _load_device(FIXTURE)
options = resources['/mode/vs/0']['x.com.samsung.da.options']
assert 'FilterTime_1715' in options
assert 'FilterAlarmTime_500' in options
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
assert "FilterTime_1715" in options
assert "FilterAlarmTime_500" in options
alarms = resources['/alarms/vs/0']['x.com.samsung.da.items']
alarms = resources["/alarms/vs/0"]["x.com.samsung.da.items"]
filter_alarm = next(
item for item in alarms
if item['x.com.samsung.da.code'].startswith('FilterAlarm')
item for item in alarms if item["x.com.samsung.da.code"].startswith("FilterAlarm")
)
assert filter_alarm['x.com.samsung.da.code'] == 'FilterAlarm_OFF'
assert filter_alarm['x.com.samsung.da.state'] == 'Deleted'
assert filter_alarm["x.com.samsung.da.code"] == "FilterAlarm_OFF"
assert filter_alarm["x.com.samsung.da.state"] == "Deleted"
# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------
def test_fan_mode_reads_the_airflow_speed_level():
entity = _climate(_load_device(FIXTURE))
assert entity.fan_mode == 'high' # speedLevel 3 in the fixture
assert entity.fan_mode == "high" # speedLevel 3 in the fixture
# No supportedModes on this resource, so the full 0-4 scale is offered.
assert entity.fan_modes == ['auto', 'low', 'medium', 'high', 'turbo']
assert entity.fan_modes == ["auto", "low", "medium", "high", "turbo"]
def test_swing_mode_reads_the_airflow_direction():
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.swing_mode == 'off' # 'Fix' in the fixture
assert entity.swing_mode == "off" # 'Fix' in the fixture
resources[HREF_AIRFLOW]['x.com.samsung.da.direction'] = 'All'
assert _climate(resources).swing_mode == 'both'
assert 'both' in _climate(resources).swing_modes
resources[HREF_AIRFLOW]["x.com.samsung.da.direction"] = "All"
assert _climate(resources).swing_mode == "both"
assert "both" in _climate(resources).swing_modes
async def test_fan_and_swing_writes_target_the_airflow_resource():
@@ -280,32 +299,37 @@ async def test_fan_and_swing_writes_target_the_airflow_resource():
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode('turbo')
await entity.async_set_swing_mode('both')
await entity.async_set_fan_mode("turbo")
await entity.async_set_swing_mode("both")
assert [payload for _, payload in coordinator.commands] == [
('fan_legacy', '4'), ('swing_legacy', 'All'),
("fan_legacy", "4"),
("swing_legacy", "All"),
]
def test_preset_comes_from_the_comode_token():
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.preset_mode == 'none' # Comode_Off in the fixture
assert entity.preset_mode == "none" # Comode_Off in the fixture
# Codes learned by driving this unit through its cloud integration and
# reading the token back. They go through the same dynamic resolver as a
# real convenient resource's supportedModes, so 'Nano' resolves to the
# existing 'nano' preset -- already labelled WindFree in the catalog.
assert entity.preset_modes == [
'none', 'nano', 'quiet', 'comfort', '2step', 'speed',
"none",
"nano",
"quiet",
"comfort",
"2step",
"speed",
]
options = resources['/mode/vs/0']['x.com.samsung.da.options']
resources['/mode/vs/0']['x.com.samsung.da.options'] = [
'Comode_Nano' if option.startswith('Comode_') else option
for option in options
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
"Comode_Nano" if option.startswith("Comode_") else option for option in options
]
assert _climate(resources).preset_mode == 'nano'
assert _climate(resources).preset_mode == "nano"
async def test_preset_write_uses_the_token_path():
@@ -313,21 +337,21 @@ async def test_preset_write_uses_the_token_path():
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_preset_mode('nano')
await entity.async_set_preset_mode("nano")
assert coordinator.commands[-1][1] == ('preset_legacy', 'Nano')
assert coordinator.commands[-1][1] == ("preset_legacy", "Nano")
async def test_newer_boards_keep_the_resource_paths():
"""The legacy fallbacks are gated on this board's resource shape, so a
board with /wind/* and /mode/convenient/vs/0 must be untouched by them."""
resources = _load_device('airconditioner_tp1x_rac')
resources = _load_device("airconditioner_tp1x_rac")
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode('high')
await entity.async_set_swing_mode('off')
await entity.async_set_preset_mode('quiet') # from its own supportedModes
await entity.async_set_fan_mode("high")
await entity.async_set_swing_mode("off")
await entity.async_set_preset_mode("quiet") # from its own supportedModes
kinds = [payload[0] for _, payload in coordinator.commands]
assert kinds == ['fan', 'swing', 'preset']
assert kinds == ["fan", "swing", "preset"]
+21 -18
View File
@@ -13,38 +13,41 @@ new to this board generation. That's a real device-support gap, left
documented here rather than guessed at, per the 'don't guess' rule -- fixing
the routing regression was the scope of #191.
"""
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import for_device_by_model
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_device
_STILL_UNBOUND = frozenset({
'/edgelighting/vs/0',
'/filter/airdustPM1filter/vs/0',
'/light/stateful/vs/0',
'/mds/absenceclean/vs/0',
'/settings/sound/mode/vs/0',
'/settings/sound/optimization/vs/0',
'/settings/sound/output/vs/0',
'/settings/sound/volume/vs/0',
'/smartsensingcooling/vs/0',
'/uvled/vs/0',
})
_STILL_UNBOUND = frozenset(
{
"/edgelighting/vs/0",
"/filter/airdustPM1filter/vs/0",
"/light/stateful/vs/0",
"/mds/absenceclean/vs/0",
"/settings/sound/mode/vs/0",
"/settings/sound/optimization/vs/0",
"/settings/sound/output/vs/0",
"/settings/sound/volume/vs/0",
"/smartsensingcooling/vs/0",
"/uvled/vs/0",
}
)
def _resources():
return _load_device('airconditioner_cac')
return _load_device("airconditioner_cac")
def _reg(resources):
info = resources['/information/vs/0']
info = resources["/information/vs/0"]
return for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
def test_resolves_to_airconditioner_registry():
assert _reg(_resources()).name == 'airconditioner'
assert _reg(_resources()).name == "airconditioner"
def test_documented_coverage_gap_is_exactly_this_set():
@@ -67,4 +70,4 @@ def test_non_legacy_board_uses_the_generic_energy_scale():
reg = _reg(resources)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert state['energy_kwh'] == round(84044 / 1000.0, 2)
assert state["energy_kwh"] == round(84044 / 1000.0, 2)
File diff suppressed because it is too large Load Diff
+37 -32
View File
@@ -10,22 +10,26 @@ device's own modesName labels (parallel-indexed with supportedModes) for
any code _DEVICE_TO_FAN doesn't already cover, instead of hardcoding a
second numeric scale.
"""
from typing import ClassVar
from custom_components.localthings.climate import (
LocalThingsClimate, _DEVICE_TO_FAN, _wind_strength_label,
_DEVICE_TO_FAN,
LocalThingsClimate,
_wind_strength_label,
)
from custom_components.localthings.registry import by_type
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from tests.conftest import _load_device
FIXTURE = 'airconditioner_tp1x_rac_01001'
FIXTURE = "airconditioner_tp1x_rac_01001"
class _FakeCoordinator:
device_serial = 'TEST-RAC-01001-SERIAL'
device_info = {}
data = {}
device_serial = "TEST-RAC-01001-SERIAL"
device_info: ClassVar[dict] = {}
data: ClassVar[dict] = {}
def __init__(self, resources):
self.last_resources = resources
@@ -45,9 +49,10 @@ class _FakeCoordinator:
def _climate(resources, coordinator=None):
info = resources['/information/vs/0']
info = resources["/information/vs/0"]
reg = by_type.for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
return LocalThingsClimate(coordinator or _FakeCoordinator(resources), climate_bound)
@@ -55,15 +60,15 @@ def _climate(resources, coordinator=None):
def test_wind_strength_label_reads_the_devices_own_modes_name():
rep = {
'x.com.samsung.da.supportedModes': ['0', '31', '32', '33', '34', '35'],
'x.com.samsung.da.modesName': ['Auto', '1', '2', '3', '4', 'MAX'],
"x.com.samsung.da.supportedModes": ["0", "31", "32", "33", "34", "35"],
"x.com.samsung.da.modesName": ["Auto", "1", "2", "3", "4", "MAX"],
}
assert _wind_strength_label('32', rep) == '2'
assert _wind_strength_label('35', rep) == 'max'
assert _wind_strength_label("32", rep) == "2"
assert _wind_strength_label("35", rep) == "max"
def test_wind_strength_label_falls_back_to_raw_code_when_names_absent():
assert _wind_strength_label('32', {}) == '32'
assert _wind_strength_label("32", {}) == "32"
def test_fan_modes_include_every_supported_speed_not_just_auto():
@@ -71,14 +76,14 @@ def test_fan_modes_include_every_supported_speed_not_just_auto():
['auto'] -- exactly the reported symptom."""
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.fan_modes == ['auto', '1', '2', '3', '4', 'max']
assert entity.fan_modes == ["auto", "1", "2", "3", "4", "max"]
def test_fan_mode_reads_the_current_dynamic_code():
"""Fixture's /wind/strength/vs/0 modes is '32' -> modesName '2'."""
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.fan_mode == '2'
assert entity.fan_mode == "2"
def test_standard_scale_codes_still_use_device_to_fan():
@@ -86,9 +91,9 @@ def test_standard_scale_codes_still_use_device_to_fan():
label rather than falling through to the device's own (blunter) one --
no regression for boards using the standard "0"-"4" scale."""
resources = _load_device(FIXTURE)
resources['/wind/strength/vs/0']['x.com.samsung.da.modes'] = '0'
resources["/wind/strength/vs/0"]["x.com.samsung.da.modes"] = "0"
entity = _climate(resources)
assert entity.fan_mode == _DEVICE_TO_FAN['0'] == 'auto'
assert entity.fan_mode == _DEVICE_TO_FAN["0"] == "auto"
async def test_set_fan_mode_resolves_a_dynamic_label_back_to_its_code():
@@ -96,9 +101,9 @@ async def test_set_fan_mode_resolves_a_dynamic_label_back_to_its_code():
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode('max')
await entity.async_set_fan_mode("max")
assert coordinator.commands[-1][1] == ('fan', '35')
assert coordinator.commands[-1][1] == ("fan", "35")
async def test_set_fan_mode_still_resolves_standard_scale_labels():
@@ -106,9 +111,9 @@ async def test_set_fan_mode_still_resolves_standard_scale_labels():
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode('auto')
await entity.async_set_fan_mode("auto")
assert coordinator.commands[-1][1] == ('fan', '0')
assert coordinator.commands[-1][1] == ("fan", "0")
async def test_set_fan_mode_does_not_misroute_when_static_map_and_live_codes_collide():
@@ -120,21 +125,21 @@ async def test_set_fan_mode_does_not_misroute_when_static_map_and_live_codes_col
supportedModes would silently write a code the device doesn't have.
"""
resources = _load_device(FIXTURE)
resources['/wind/strength/vs/0'] = {
'x.com.samsung.da.modes': '0',
'x.com.samsung.da.supportedModes': ['0', '31', '32', '33'],
'x.com.samsung.da.modesName': ['Auto', 'Low', 'High', 'Turbo'],
resources["/wind/strength/vs/0"] = {
"x.com.samsung.da.modes": "0",
"x.com.samsung.da.supportedModes": ["0", "31", "32", "33"],
"x.com.samsung.da.modesName": ["Auto", "Low", "High", "Turbo"],
}
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
assert entity.fan_modes == ['auto', 'low', 'high', 'turbo']
assert entity.fan_modes == ["auto", "low", "high", "turbo"]
await entity.async_set_fan_mode('high')
assert coordinator.commands[-1][1] == ('fan', '32')
await entity.async_set_fan_mode("high")
assert coordinator.commands[-1][1] == ("fan", "32")
await entity.async_set_fan_mode('low')
assert coordinator.commands[-1][1] == ('fan', '31')
await entity.async_set_fan_mode("low")
assert coordinator.commands[-1][1] == ("fan", "31")
await entity.async_set_fan_mode('turbo')
assert coordinator.commands[-1][1] == ('fan', '33')
await entity.async_set_fan_mode("turbo")
assert coordinator.commands[-1][1] == ("fan", "33")
+13 -12
View File
@@ -8,49 +8,50 @@ empty" apart from "haven't polled it yet" and created phantom always-
"unknown" entities either way. is_stub_rep/parse_device0_batch now keep the
two shapes distinct.
"""
from custom_components.localthings.registry.batch import is_stub_rep, parse_device0_batch
class TestIsStubRep:
def test_true_for_bare_href_marker(self):
assert is_stub_rep({'href': '/energy/consumption/vs/0'}) is True
assert is_stub_rep({"href": "/energy/consumption/vs/0"}) is True
def test_false_for_genuinely_empty_rep(self):
assert is_stub_rep({}) is False
def test_false_for_populated_rep(self):
assert is_stub_rep({'x.com.samsung.da.cumulativePower': '58900'}) is False
assert is_stub_rep({"x.com.samsung.da.cumulativePower": "58900"}) is False
def test_false_for_href_plus_data(self):
"""A real, populated rep may legitimately echo 'href' alongside
actual fields -- only a rep with *no other keys* is the stub."""
assert is_stub_rep({'href': '/x/0', 'value': True}) is False
assert is_stub_rep({"href": "/x/0", "value": True}) is False
class TestParseDevice0Batch:
def test_stub_rep_kept_distinct_from_genuine_empty(self):
device0 = [
{},
{'href': '/energy/consumption/vs/0', 'rep': {'href': '/energy/consumption/vs/0'}},
{'href': '/sabbath/vs/0', 'rep': {}},
{"href": "/energy/consumption/vs/0", "rep": {"href": "/energy/consumption/vs/0"}},
{"href": "/sabbath/vs/0", "rep": {}},
]
resources = parse_device0_batch(device0)
assert is_stub_rep(resources['/energy/consumption/vs/0']) is True
assert is_stub_rep(resources['/sabbath/vs/0']) is False
assert resources['/sabbath/vs/0'] == {}
assert is_stub_rep(resources["/energy/consumption/vs/0"]) is True
assert is_stub_rep(resources["/sabbath/vs/0"]) is False
assert resources["/sabbath/vs/0"] == {}
def test_populated_rep_passes_through_unchanged(self):
device0 = [
{},
{'href': '/door/cooler/0', 'rep': {'openState': 'Close'}},
{"href": "/door/cooler/0", "rep": {"openState": "Close"}},
]
resources = parse_device0_batch(device0)
assert resources['/door/cooler/0'] == {'openState': 'Close'}
assert resources["/door/cooler/0"] == {"openState": "Close"}
def test_skips_entries_without_href(self):
device0 = [{}, {'rep': {'a': 1}}]
device0 = [{}, {"rep": {"a": 1}}]
assert parse_device0_batch(device0) == {}
def test_skips_non_dict_rep(self):
device0 = [{}, {'href': '/x/0', 'rep': 'not-a-dict'}]
device0 = [{}, {"href": "/x/0", "rep": "not-a-dict"}]
assert parse_device0_batch(device0) == {}
+340 -234
View File
File diff suppressed because it is too large Load Diff
+21 -23
View File
@@ -1,44 +1,42 @@
"""Tests for device-type registries and DOOR_GENERIC pattern capability."""
import json
from pathlib import Path
import pytest
from custom_components.localthings.registry.adapter import _key
from custom_components.localthings.registry.by_type import dishwasher, refrigerator
from custom_components.localthings.registry.capabilities import fridge
from custom_components.localthings.registry.discovery import discover
from tests.conftest import _load_resources
GOLDEN = Path(__file__).parent / 'fixtures' / 'golden'
GOLDEN = Path(__file__).parent / "fixtures" / "golden"
# ---------------------------------------------------------------------------
# Integration: dishwasher and refrigerator registries
# ---------------------------------------------------------------------------
def test_dishwasher_registry_discovers_expected_keys():
"""Discover over dishwasher dump and verify golden state_keys are a subset."""
resources = _load_resources('10.0.0.129')
resources = _load_resources("10.0.0.129")
reg = dishwasher.REGISTRY
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
actual_keys = {_key(b) for b in bound}
golden = json.loads((GOLDEN / 'dishwasher.json').read_text())
golden_keys = set(golden['state_keys'])
golden = json.loads((GOLDEN / "dishwasher.json").read_text())
golden_keys = set(golden["state_keys"])
missing = golden_keys - actual_keys
assert not missing, (
f"Golden keys not found in dishwasher discover output:\n {sorted(missing)}"
)
assert not missing, f"Golden keys not found in dishwasher discover output:\n {sorted(missing)}"
def test_refrigerator_registry_discovers_expected_keys():
"""Discover over refrigerator dump and verify golden state_keys are a subset."""
resources = _load_resources('10.0.0.254')
resources = _load_resources("10.0.0.254")
reg = refrigerator.REGISTRY
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
actual_keys = {_key(b) for b in bound}
golden = json.loads((GOLDEN / 'refrigerator.json').read_text())
golden_keys = set(golden['state_keys'])
golden = json.loads((GOLDEN / "refrigerator.json").read_text())
golden_keys = set(golden["state_keys"])
missing = golden_keys - actual_keys
assert not missing, (
f"Golden keys not found in refrigerator discover output:\n {sorted(missing)}"
@@ -49,23 +47,24 @@ def test_refrigerator_registry_discovers_expected_keys():
# DOOR_GENERIC pattern capability unit tests
# ---------------------------------------------------------------------------
def test_door_generic_binds_unknown_door_href():
"""/door/wine/0 binds via DOOR_GENERIC (href_prefix='/door/') with key door_wine_open."""
resources = {
'/door/wine/0': {'openState': 'Open'},
"/door/wine/0": {"openState": "Open"},
}
bound = discover(resources, {}, [fridge.DOOR_GENERIC])
assert len(bound) == 1
b = bound[0]
# entity key 'open' + href segs ['door','wine'] -> 'door_wine_open'
assert _key(b) == 'door_wine_open', f"Expected 'door_wine_open', got {_key(b)!r}"
assert _key(b) == "door_wine_open", f"Expected 'door_wine_open', got {_key(b)!r}"
def test_door_generic_does_not_bind_doors_aggregate():
"""/doors/vs/0 does not start with '/door/' so DOOR_GENERIC must not bind it."""
resources = {
'/doors/vs/0': {
'x.com.samsung.da.items': [],
"/doors/vs/0": {
"x.com.samsung.da.items": [],
},
}
bound = discover(resources, {}, [fridge.DOOR_GENERIC])
@@ -73,18 +72,17 @@ def test_door_generic_does_not_bind_doors_aggregate():
def test_door_generic_binds_cooler_door_with_auto_derived_key():
"""/door/cooler/0 is now handled by DOOR_GENERIC pattern cap with auto-derived key door_cooler_open."""
"""/door/cooler/0 is now handled by DOOR_GENERIC pattern cap with
auto-derived key door_cooler_open."""
resources = {
'/door/cooler/0': {'openState': 'Closed'},
"/door/cooler/0": {"openState": "Closed"},
}
reg = refrigerator.REGISTRY
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
cooler_bindings = [b for b in bound if b.href == '/door/cooler/0']
cooler_bindings = [b for b in bound if b.href == "/door/cooler/0"]
assert len(cooler_bindings) == 1, (
f"Expected exactly one binding for /door/cooler/0, got {len(cooler_bindings)}"
)
b = cooler_bindings[0]
assert b.capability is fridge.DOOR_GENERIC, (
f"Expected DOOR_GENERIC, got {b.capability!r}"
)
assert _key(b) == 'door_cooler_open', f"Expected 'door_cooler_open', got {_key(b)!r}"
assert b.capability is fridge.DOOR_GENERIC, f"Expected DOOR_GENERIC, got {b.capability!r}"
assert _key(b) == "door_cooler_open", f"Expected 'door_cooler_open', got {_key(b)!r}"
+7 -5
View File
@@ -3,16 +3,18 @@ from custom_components.localthings.registry.entities import BinarySensorDesc
def test_capability_defaults():
c = Capability(href='/kidslock/vs/0',
entities=(BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock'),))
assert c.poll_tier == 'cold'
c = Capability(
href="/kidslock/vs/0",
entities=(BinarySensorDesc(key="child_lock", field="x.com.samsung.da.kidsLock"),),
)
assert c.poll_tier == "cold"
assert len(c.entities) == 1
def test_capability_is_frozen():
c = Capability(href='/kidslock/vs/0', entities=())
c = Capability(href="/kidslock/vs/0", entities=())
try:
c.href = '/other/vs/0'
c.href = "/other/vs/0"
except Exception:
return
raise AssertionError("expected frozen dataclass")
+33 -21
View File
@@ -4,11 +4,15 @@ 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, _preset_to_ha,
_AI_COMFORT_MODE,
_DEVICE_TO_HVAC,
_HVAC_TO_DEVICE,
PRESET_AI_COMFORT,
_preset_to_ha,
)
@@ -18,7 +22,7 @@ def test_auto_maps_to_hvac_auto():
two-setpoint heat+cool range these single-setpoint units (including
cool-only models) don't have. AIComfort is handled separately, not
folded into this map."""
assert _DEVICE_TO_HVAC['Auto'] == HVACMode.AUTO
assert _DEVICE_TO_HVAC["Auto"] == HVACMode.AUTO
def test_aicomfort_not_in_flat_hvac_map():
@@ -34,19 +38,19 @@ def test_hvac_auto_writes_back_to_plain_auto_not_aicomfort():
ai_comfort preset, since it isn't a flat _DEVICE_TO_HVAC entry (see
test_aicomfort_not_in_flat_hvac_map) and so can never win the reverse
{v: k} dict even though both map to HVACMode.AUTO conceptually."""
assert _HVAC_TO_DEVICE[HVACMode.AUTO] == 'Auto'
assert _HVAC_TO_DEVICE[HVACMode.AUTO] == "Auto"
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
assert _DEVICE_TO_HVAC["Wind"] == HVACMode.FAN_ONLY
def test_fan_only_still_reachable_via_fan():
"""'Fan' (e.g. TP1X_DA-AC-RAC-01011) is a second FAN_ONLY spelling
alongside 'Wind' -- issue #91."""
assert _DEVICE_TO_HVAC['Fan'] == HVACMode.FAN_ONLY
assert _DEVICE_TO_HVAC["Fan"] == HVACMode.FAN_ONLY
def test_fan_only_reverse_fallback_prefers_wind():
@@ -56,24 +60,25 @@ def test_fan_only_reverse_fallback_prefers_wind():
original single spelling, predating 'Fan') rather than silently
flipping to whichever of the two duplicate-value entries happens to
come last in _DEVICE_TO_HVAC."""
assert _HVAC_TO_DEVICE[HVACMode.FAN_ONLY] == 'Wind'
assert _HVAC_TO_DEVICE[HVACMode.FAN_ONLY] == "Wind"
def test_preset_ai_comfort_constant():
assert PRESET_AI_COMFORT == 'ai_comfort'
assert PRESET_AI_COMFORT == "ai_comfort"
def test_preset_to_ha_off_maps_to_preset_none():
from homeassistant.components.climate import PRESET_NONE
assert _preset_to_ha('Off') == PRESET_NONE
assert _preset_to_ha("Off") == PRESET_NONE
def test_preset_to_ha_lowercases_other_codes():
"""Every other device code is exposed as its lowercased self -- resolved
dynamically, not via a per-model table (issue #91)."""
assert _preset_to_ha('Sleep') == 'sleep'
assert _preset_to_ha('NanoSleep') == 'nanosleep'
assert _preset_to_ha('MotionIndirect') == 'motionindirect'
assert _preset_to_ha("Sleep") == "sleep"
assert _preset_to_ha("NanoSleep") == "nanosleep"
assert _preset_to_ha("MotionIndirect") == "motionindirect"
def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
@@ -89,16 +94,19 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
even if fan_modes/fan_mode were completely broken, since it never
touches the entity at all.
"""
from typing import ClassVar, cast
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry import by_type
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from tests.conftest import _load_device
class _FakeCoordinator:
device_serial = 'TEST-FAC-BORA-SERIAL'
device_info = {}
data = {}
device_serial = "TEST-FAC-BORA-SERIAL"
device_info: ClassVar[dict] = {}
data: ClassVar[dict] = {}
def __init__(self, resources):
self.last_resources = resources
@@ -111,13 +119,17 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
# canonical view is just the raw snapshot (issue #177).
return self.last_resources
resources = _load_device('airconditioner_fac_bora')
info = resources['/information/vs/0']
resources = _load_device("airconditioner_fac_bora")
info = resources["/information/vs/0"]
reg = by_type.for_device_by_model(
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"]
)
assert reg is not None
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
entity = LocalThingsClimate(_FakeCoordinator(resources), climate_bound)
entity = LocalThingsClimate(
cast(LocalThingsCoordinator, _FakeCoordinator(resources)), climate_bound
)
assert entity.fan_modes == ['auto', 'medium', 'high', 'turbo']
assert entity.fan_mode == 'auto'
assert entity.fan_modes == ["auto", "medium", "high", "turbo"]
assert entity.fan_mode == "auto"
+10 -9
View File
@@ -9,6 +9,7 @@ master and its materialized sibling, which is exactly the shape climate.py's
own comments warn is easy to get wrong if the canonical view leaks between
subdevices.
"""
from __future__ import annotations
import pytest
@@ -17,13 +18,13 @@ from homeassistant.core import HomeAssistant
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.registry.entities import ClimateDesc
from tests.test_subdevice_discovery import _coordinator, _discover
def _climate_entities(coordinator):
"""{subdevice_key_or_None: LocalThingsClimate}, None standing for MAIN."""
from custom_components.localthings.registry.subdevices import MAIN
out = {}
for b in coordinator.bound:
if isinstance(b.desc, ClimateDesc):
@@ -35,7 +36,7 @@ def _climate_entities(coordinator):
@pytest.fixture
async def climates(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
await _discover(coordinator, "airconditioner_artik051_dongle_fac_18k")
return _climate_entities(coordinator)
@@ -44,7 +45,7 @@ async def test_subdevice_climate_reads_its_own_mode_and_power(climates):
confirmed distinct in the real captured fixture. If the subdevice
entity's _rep() weren't translating through its own subdevice, it would
read the master's /mode/vs/0 instead and report the master's mode."""
main, sub1 = climates[None], climates['1']
main, sub1 = climates[None], climates["1"]
assert main.hvac_mode == HVACMode.AUTO
assert sub1.hvac_mode == HVACMode.COOL
@@ -53,7 +54,7 @@ async def test_subdevice_climate_reads_its_own_temperature(climates):
"""Master: current 25.0 / desired 26.0. Subdevice 1: current 27.0 / desired
28.0 -- distinct values in the real fixture, so a href mix-up here
would show up as a wrong number, not just a wrong mode string."""
main, sub1 = climates[None], climates['1']
main, sub1 = climates[None], climates["1"]
assert main.current_temperature == 25.0
assert main.target_temperature == 26.0
assert sub1.current_temperature == 27.0
@@ -66,7 +67,7 @@ async def test_subdevice_climate_reads_its_own_power_state(climates):
hardcoded /power/vs/0, by checking the entity resolves without falling
back to OFF (which _is_on() would do if it silently read an absent
href instead of the subdevice's actual one)."""
main, sub1 = climates[None], climates['1']
main, sub1 = climates[None], climates["1"]
assert main.hvac_mode != HVACMode.OFF
assert sub1.hvac_mode != HVACMode.OFF
@@ -82,7 +83,7 @@ async def test_legacy_board_test_is_evaluated_per_subdevice(climates):
future board with one legacy + one modern subdevice sharing a
connection would silently read the wrong fan/swing channel on one
side."""
main, sub1 = climates[None], climates['1']
main, sub1 = climates[None], climates["1"]
assert main._legacy_airflow() != {}
assert sub1._legacy_airflow() != {}
# Both resolve to *some* fan mode via the legacy path rather than the
@@ -99,7 +100,7 @@ async def test_subdevice_climate_writes_are_scoped_to_its_own_bound_entity(clima
per-subdevice reads/writes possible at all -- see async_send_command's
translation test in test_coordinator_send_command.py for the write
side of this."""
main, sub1 = climates[None], climates['1']
assert main._bound.href == '/mode/vs/0'
assert sub1._bound.href == '/mode/vs/1'
main, sub1 = climates[None], climates["1"]
assert main._bound.href == "/mode/vs/0"
assert sub1._bound.href == "/mode/vs/1"
assert main._bound is not sub1._bound
+7 -5
View File
@@ -10,17 +10,19 @@ don't reach: TP1X-class firmware has no OCF-standard
/temperature/current+desired pair, so current_temperature/target_temperature
would silently be None without this fallback.
"""
from custom_components.localthings.climate import _temps_vs_item
def test_temps_vs_item_returns_first_item():
rep = {
'x.com.samsung.da.items': [
{'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0'},
"x.com.samsung.da.items": [
{"x.com.samsung.da.current": "17.0", "x.com.samsung.da.desired": "25.0"},
],
}
assert _temps_vs_item(rep) == {
'x.com.samsung.da.current': '17.0', 'x.com.samsung.da.desired': '25.0',
"x.com.samsung.da.current": "17.0",
"x.com.samsung.da.desired": "25.0",
}
@@ -31,8 +33,8 @@ def test_temps_vs_item_empty_when_items_missing():
def test_temps_vs_item_empty_when_items_is_empty_list():
assert _temps_vs_item({'x.com.samsung.da.items': []}) == {}
assert _temps_vs_item({"x.com.samsung.da.items": []}) == {}
def test_temps_vs_item_empty_when_items_entry_is_not_a_dict():
assert _temps_vs_item({'x.com.samsung.da.items': ['not-a-dict']}) == {}
assert _temps_vs_item({"x.com.samsung.da.items": ["not-a-dict"]}) == {}

Some files were not shown because too many files have changed in this diff Show More