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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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}"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user