diff --git a/custom_components/localthings/__init__.py b/custom_components/localthings/__init__.py index 3a21b24..741a19f 100644 --- a/custom_components/localthings/__init__.py +++ b/custom_components/localthings/__init__.py @@ -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) diff --git a/custom_components/localthings/binary_sensor.py b/custom_components/localthings/binary_sensor.py index 42674d9..0e14ffc 100644 --- a/custom_components/localthings/binary_sensor.py +++ b/custom_components/localthings/binary_sensor.py @@ -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): diff --git a/custom_components/localthings/button.py b/custom_components/localthings/button.py index 39118d5..535bb74 100644 --- a/custom_components/localthings/button.py +++ b/custom_components/localthings/button.py @@ -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 diff --git a/custom_components/localthings/catalog.py b/custom_components/localthings/catalog.py index 02d7616..68b6208 100644 --- a/custom_components/localthings/catalog.py +++ b/custom_components/localthings/catalog.py @@ -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() diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 63f8ae4..d81a8e3 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -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 diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index f28dda3..fa986cd 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -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)) diff --git a/custom_components/localthings/const.py b/custom_components/localthings/const.py index 3e2341b..534b20b 100644 --- a/custom_components/localthings/const.py +++ b/custom_components/localthings/const.py @@ -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 diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index b76dd33..fa38f30 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -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 diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index 8190856..eb13bdd 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -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 # '//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 ], diff --git a/custom_components/localthings/entity.py b/custom_components/localthings/entity.py index 27c8daa..ce6bab8 100644 --- a/custom_components/localthings/entity.py +++ b/custom_components/localthings/entity.py @@ -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) diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 2f3bf4d..834ab7f 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -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))) diff --git a/custom_components/localthings/number.py b/custom_components/localthings/number.py index ab04589..f4a954a 100644 --- a/custom_components/localthings/number.py +++ b/custom_components/localthings/number.py @@ -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 diff --git a/custom_components/localthings/observe.py b/custom_components/localthings/observe.py index ca13ce0..3d91d51 100644 --- a/custom_components/localthings/observe.py +++ b/custom_components/localthings/observe.py @@ -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() diff --git a/custom_components/localthings/registry/__init__.py b/custom_components/localthings/registry/__init__.py index fe9adb6..296a4ec 100644 --- a/custom_components/localthings/registry/__init__.py +++ b/custom_components/localthings/registry/__init__.py @@ -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"] diff --git a/custom_components/localthings/registry/adapter.py b/custom_components/localthings/registry/adapter.py index 5224e4e..116d1c1 100644 --- a/custom_components/localthings/registry/adapter.py +++ b/custom_components/localthings/registry/adapter.py @@ -1,4 +1,5 @@ """Adapter: BoundEntity list → flat state dict and command dispatch.""" + from __future__ import annotations from typing import Any diff --git a/custom_components/localthings/registry/batch.py b/custom_components/localthings/registry/batch.py index c7fb3ad..e5d5079 100644 --- a/custom_components/localthings/registry/batch.py +++ b/custom_components/localthings/registry/batch.py @@ -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): diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index a887d1b..917f7cd 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -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", ""), ) ) diff --git a/custom_components/localthings/registry/by_type/_base.py b/custom_components/localthings/registry/by_type/_base.py index 5c47962..d0cdc3b 100644 --- a/custom_components/localthings/registry/by_type/_base.py +++ b/custom_components/localthings/registry/by_type/_base.py @@ -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 diff --git a/custom_components/localthings/registry/by_type/air_dresser.py b/custom_components/localthings/registry/by_type/air_dresser.py index d482b03..7012c2d 100644 --- a/custom_components/localthings/registry/by_type/air_dresser.py +++ b/custom_components/localthings/registry/by_type/air_dresser.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/air_monitor.py b/custom_components/localthings/registry/by_type/air_monitor.py index b7165ac..d2315da 100644 --- a/custom_components/localthings/registry/by_type/air_monitor.py +++ b/custom_components/localthings/registry/by_type/air_monitor.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index 50ef840..ca67d32 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/airconditioner.py b/custom_components/localthings/registry/by_type/airconditioner.py index f6c08c7..5a84e16 100644 --- a/custom_components/localthings/registry/by_type/airconditioner.py +++ b/custom_components/localthings/registry/by_type/airconditioner.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/cooktop.py b/custom_components/localthings/registry/by_type/cooktop.py index aa4ee84..b2fcb83 100644 --- a/custom_components/localthings/registry/by_type/cooktop.py +++ b/custom_components/localthings/registry/by_type/cooktop.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/dehumidifier.py b/custom_components/localthings/registry/by_type/dehumidifier.py index 9c3207e..373fbdc 100644 --- a/custom_components/localthings/registry/by_type/dehumidifier.py +++ b/custom_components/localthings/registry/by_type/dehumidifier.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/dishwasher.py b/custom_components/localthings/registry/by_type/dishwasher.py index 1f099f3..c1a270f 100644 --- a/custom_components/localthings/registry/by_type/dishwasher.py +++ b/custom_components/localthings/registry/by_type/dishwasher.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/dryer.py b/custom_components/localthings/registry/by_type/dryer.py index 81d2891..12b48b6 100644 --- a/custom_components/localthings/registry/by_type/dryer.py +++ b/custom_components/localthings/registry/by_type/dryer.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/ehs.py b/custom_components/localthings/registry/by_type/ehs.py index 863cb8f..63b3844 100644 --- a/custom_components/localthings/registry/by_type/ehs.py +++ b/custom_components/localthings/registry/by_type/ehs.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/induction_cooktop.py b/custom_components/localthings/registry/by_type/induction_cooktop.py index 6c153db..dd9579a 100644 --- a/custom_components/localthings/registry/by_type/induction_cooktop.py +++ b/custom_components/localthings/registry/by_type/induction_cooktop.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/microwave.py b/custom_components/localthings/registry/by_type/microwave.py index 7e76a9a..03c019e 100644 --- a/custom_components/localthings/registry/by_type/microwave.py +++ b/custom_components/localthings/registry/by_type/microwave.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/oven.py b/custom_components/localthings/registry/by_type/oven.py index 252f1dc..107ca9c 100644 --- a/custom_components/localthings/registry/by_type/oven.py +++ b/custom_components/localthings/registry/by_type/oven.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/range.py b/custom_components/localthings/registry/by_type/range.py index c53bdca..ad54269 100644 --- a/custom_components/localthings/registry/by_type/range.py +++ b/custom_components/localthings/registry/by_type/range.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/range_hood.py b/custom_components/localthings/registry/by_type/range_hood.py index b647844..935751c 100644 --- a/custom_components/localthings/registry/by_type/range_hood.py +++ b/custom_components/localthings/registry/by_type/range_hood.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/refrigerator.py b/custom_components/localthings/registry/by_type/refrigerator.py index b834820..86228b7 100644 --- a/custom_components/localthings/registry/by_type/refrigerator.py +++ b/custom_components/localthings/registry/by_type/refrigerator.py @@ -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, diff --git a/custom_components/localthings/registry/by_type/vacuum_station.py b/custom_components/localthings/registry/by_type/vacuum_station.py index 80c1f3f..a4380fe 100644 --- a/custom_components/localthings/registry/by_type/vacuum_station.py +++ b/custom_components/localthings/registry/by_type/vacuum_station.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/washer.py b/custom_components/localthings/registry/by_type/washer.py index c7732d8..f1a5e71 100644 --- a/custom_components/localthings/registry/by_type/washer.py +++ b/custom_components/localthings/registry/by_type/washer.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/by_type/water_purifier.py b/custom_components/localthings/registry/by_type/water_purifier.py index 7e82042..fe9f6c9 100644 --- a/custom_components/localthings/registry/by_type/water_purifier.py +++ b/custom_components/localthings/registry/by_type/water_purifier.py @@ -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, + ] + ), ) diff --git a/custom_components/localthings/registry/capabilities/__init__.py b/custom_components/localthings/registry/capabilities/__init__.py index 4588c6a..ad1e82a 100644 --- a/custom_components/localthings/registry/capabilities/__init__.py +++ b/custom_components/localthings/registry/capabilities/__init__.py @@ -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 +) diff --git a/custom_components/localthings/registry/capabilities/air_dresser.py b/custom_components/localthings/registry/capabilities/air_dresser.py index f60ea30..d9066a7 100644 --- a/custom_components/localthings/registry/capabilities/air_dresser.py +++ b/custom_components/localthings/registry/capabilities/air_dresser.py @@ -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, + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/air_monitor.py b/custom_components/localthings/registry/capabilities/air_monitor.py index f5f1ee5..451ef43 100644 --- a/custom_components/localthings/registry/capabilities/air_monitor.py +++ b/custom_components/localthings/registry/capabilities/air_monitor.py @@ -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] diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index aa80c94..5b86e91 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -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"), ] diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 478720d..3da18bd 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -14,11 +14,17 @@ None of these caps may go into the global `ALL`/`CAPABILITIES`: `/mode/vs/0`, different schema (see capabilities/__init__.py). They live only in the AC by_type registry. """ + from dataclasses import replace from ..capability import Capability from ..entities import ( - BinarySensorDesc, ClimateDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc, + BinarySensorDesc, + ClimateDesc, + NumberDesc, + SelectDesc, + SensorDesc, + SwitchDesc, ) from . import common from .common import filter_usage_percent, normalize_temp_unit @@ -41,10 +47,10 @@ def _beep_on(rep): full 'Volume_100' token -- shared with _option_token_num/_option_token_on, which this module's other options[] readers already rely on. """ - tok = _option_token(rep, 'Volume') + tok = _option_token(rep, "Volume") if tok is None: return None - return tok != 'Mute' + return tok != "Mute" def _beep_write(payload, rep, href=None): @@ -53,15 +59,15 @@ def _beep_write(payload, rep, href=None): restores the last non-Mute level rather than forcing Volume_100, so a user's intermediate setting (e.g. Volume_50 set via the cloud) survives an off/on cycle; falls back to 100 when no prior level is known.""" - if payload not in ('On', 'Off'): + if payload not in ("On", "Off"): return None - if payload == 'Off': - token = 'Mute' + if payload == "Off": + token = "Mute" else: - prev = _option_token(rep, 'Volume') - token = prev if (prev and prev != 'Mute') else '100' - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': option_write('Volume', token), + prev = _option_token(rep, "Volume") + token = prev if (prev and prev != "Mute") else "100" + return ["mode", "vs", "0"], { + "x.com.samsung.da.options": option_write("Volume", token), } @@ -71,7 +77,7 @@ def _tropical_night_value(rep): _option_token returns the token's value half already (e.g. '16' for 'Sleep_16'), same convention as _beep_on above. """ - tok = _option_token(rep, 'Sleep') + tok = _option_token(rep, "Sleep") if tok is None: return None return _int(tok) @@ -81,13 +87,13 @@ def _tropical_night_write(value, rep, href=None): """Set tropical night level via a single-token `Sleep_` options write. Samsung cloud counterpart: custom.airConditionerTropicalNightMode (0-16).""" try: - level = int(round(float(value))) + level = round(float(value)) except (TypeError, ValueError): return None if not 0 <= level <= 16: return None - return ['mode', 'vs', '0'], { - 'x.com.samsung.da.options': option_write('Sleep', str(level)), + return ["mode", "vs", "0"], { + "x.com.samsung.da.options": option_write("Sleep", str(level)), } @@ -95,8 +101,8 @@ def _filter_unit(rep): """Unit of the filter-usage fields, normalised from filterCapacityUnit ('Hour' -> 'h'). Wired through unit_fn so a board advertising a different unit doesn't silently mislabel a duration statistic.""" - u = rep.get('x.com.samsung.da.filterCapacityUnit') - return {'Hour': 'h', 'Minute': 'min', 'Second': 's'}.get(u, u or 'h') + u = rep.get("x.com.samsung.da.filterCapacityUnit") + return {"Hour": "h", "Minute": "min", "Second": "s"}.get(u, u or "h") def _threshold_write(payload, rep, href=None): @@ -106,8 +112,8 @@ def _threshold_write(payload, rep, href=None): Select only surfaces where the device advertises supportedFilterDesiredUsage, so the valid options are known rather than guessed; boards without that enum leave this writable field unexposed.""" - return ['filter', 'airdustfilter', 'vs', '0'], { - 'x.com.samsung.da.filterDesiredUsage': payload, + return ["filter", "airdustfilter", "vs", "0"], { + "x.com.samsung.da.filterDesiredUsage": payload, } @@ -121,9 +127,9 @@ def _sensor_item_value(items, type_): unconfirmed, so v[0] is taken as the reading and v[1] is dropped; left as a string rather than coerced numeric because only CleanLevel has corroborating evidence (a top-level x.com.samsung.da.cleanLevel scalar).""" - for it in (items or []): - if isinstance(it, dict) and it.get('x.com.samsung.da.type') == type_: - v = it.get('x.com.samsung.da.value') + for it in items or []: + if isinstance(it, dict) and it.get("x.com.samsung.da.type") == type_: + v = it.get("x.com.samsung.da.value") if isinstance(v, list) and v: return str(v[0]) return None @@ -149,11 +155,16 @@ def _has_sensor_type(type_): entities are disabled by default instead (see AIR_QUALITY below) rather than existence-gated on a signal that would silently drop real readings on hardware this repo hasn't seen yet.""" + def fn(rep, resources): - return any(isinstance(i, dict) and i.get('x.com.samsung.da.type') == type_ - for i in (rep.get('x.com.samsung.da.items') or [])) + return any( + isinstance(i, dict) and i.get("x.com.samsung.da.type") == type_ + for i in (rep.get("x.com.samsung.da.items") or []) + ) + return fn + # --------------------------------------------------------------------------- # Canonical AC resource hrefs. The climate entity (climate.py) binds the # primary HREF_MODE via CLIMATE below and reads the CLIMATE_CONSUMED_HREFS @@ -162,34 +173,42 @@ def _has_sensor_type(type_): # imported by climate.py, so a new sibling read can't drift out of sync with # its coverage entry. # --------------------------------------------------------------------------- -HREF_MODE = '/mode/vs/0' # primary (bound by CLIMATE) -HREF_POWER = '/power/0' # on/off -> HVACMode.OFF / TURN_ON/OFF -HREF_POWER_VS = '/power/vs/0' # vendor fallback for on/off -HREF_TEMP_CURRENT = '/temperature/current/0' # current_temperature -HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write target) -HREF_TEMP_CONTROL = '/temperature/control/vs/0' # target_temperature_step -HREF_WIND_STRENGTH = '/wind/strength/vs/0' # fan_mode -HREF_WIND_DIRECTION = '/wind/direction/vs/0' # swing_mode +HREF_MODE = "/mode/vs/0" # primary (bound by CLIMATE) +HREF_POWER = "/power/0" # on/off -> HVACMode.OFF / TURN_ON/OFF +HREF_POWER_VS = "/power/vs/0" # vendor fallback for on/off +HREF_TEMP_CURRENT = "/temperature/current/0" # current_temperature +HREF_TEMP_DESIRED = "/temperature/desired/0" # target_temperature (write target) +HREF_TEMP_CONTROL = "/temperature/control/vs/0" # target_temperature_step +HREF_WIND_STRENGTH = "/wind/strength/vs/0" # fan_mode +HREF_WIND_DIRECTION = "/wind/direction/vs/0" # swing_mode # Newer boards (issue #126, TP1X_DA-AC-RAC-01011 WindFree variant) report no # HREF_WIND_DIRECTION at all and instead carry a 2-axis oscillation resource # (separate vertical/horizontal Swing|Fix toggles, each with its own angle). # climate.py falls back to this when HREF_WIND_DIRECTION is absent, the same # duality pattern as the OCF/vendor temperature channel below. -HREF_WIND_OSCILLATION = '/wind/oscillation/vs/0' # swing_mode fallback -HREF_CONVENIENT = '/mode/convenient/vs/0' # preset_mode -HREF_TEMPS_VS = '/temperatures/vs/0' # vendor temp fallback (items[] array) +HREF_WIND_OSCILLATION = "/wind/oscillation/vs/0" # swing_mode fallback +HREF_CONVENIENT = "/mode/convenient/vs/0" # preset_mode +HREF_TEMPS_VS = "/temperatures/vs/0" # vendor temp fallback (items[] array) # Legacy ARTIK051 boards (ARTIK051_KRAC_18K, issue #136) carry no /wind/* resources # at all: fan speed and vane direction sit together in this one resource, as # x.com.samsung.da.speedLevel (the same 0-4 scale as _DEVICE_TO_FAN) and # x.com.samsung.da.direction (the same codes as _DEVICE_TO_SWING). Their # convenient-mode preset is a Comode_* token in /mode/vs/0's options instead of a # resource of its own -- see climate.py's _legacy_airflow/_legacy_convenient. -HREF_AIRFLOW = '/airflow/vs/0' # legacy fan_mode + swing_mode +HREF_AIRFLOW = "/airflow/vs/0" # legacy fan_mode + swing_mode CLIMATE_CONSUMED_HREFS = [ - HREF_POWER, HREF_POWER_VS, HREF_TEMP_CURRENT, HREF_TEMP_DESIRED, - HREF_TEMP_CONTROL, HREF_TEMPS_VS, HREF_WIND_STRENGTH, HREF_WIND_DIRECTION, - HREF_WIND_OSCILLATION, HREF_CONVENIENT, HREF_AIRFLOW, + HREF_POWER, + HREF_POWER_VS, + HREF_TEMP_CURRENT, + HREF_TEMP_DESIRED, + HREF_TEMP_CONTROL, + HREF_TEMPS_VS, + HREF_WIND_STRENGTH, + HREF_WIND_DIRECTION, + HREF_WIND_OSCILLATION, + HREF_CONVENIENT, + HREF_AIRFLOW, ] @@ -205,31 +224,31 @@ def _temps_vs_item(rep): Tizen Lite board's only current-temperature source (see climate.py's identical helper; duplicated rather than imported to avoid a capabilities<->platform import cycle).""" - 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 {} def _temps_vs_current(rep): - return _num(_temps_vs_item(rep).get('x.com.samsung.da.current')) + return _num(_temps_vs_item(rep).get("x.com.samsung.da.current")) def _temps_vs_unit(rep): - return normalize_temp_unit(_temps_vs_item(rep).get('x.com.samsung.da.unit'), '°C') + return normalize_temp_unit(_temps_vs_item(rep).get("x.com.samsung.da.unit"), "°C") def _first_mode(rep): """Representative scalar for the climate entity in the flattened state (golden/regression). The real entity computes hvac_mode from power + mode.""" - 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 _mode_options(rep): - opts = rep.get('x.com.samsung.da.options') + opts = rep.get("x.com.samsung.da.options") return opts if isinstance(opts, (list, tuple)) else () @@ -240,8 +259,7 @@ def _has_display_light_option(rep, resources): models exposing the /light/vs/0 switch (bound by DISPLAY_LIGHT below) carry no Light_* option, so this entity only materialises on the boards that would otherwise have no display-light entity at all.""" - return any(isinstance(o, str) and o.startswith('Light_') - for o in _mode_options(rep)) + return any(isinstance(o, str) and o.startswith("Light_") for o in _mode_options(rep)) def _display_light_on(rep): @@ -252,8 +270,8 @@ def _display_light_on(rep): `Light_Off` -> light on, `Light_On` -> light off. Read-only from here; the write below uses the device's own single-token merge (see option_write).""" for o in _mode_options(rep): - if isinstance(o, str) and o.startswith('Light_'): - return o == 'Light_Off' + if isinstance(o, str) and o.startswith("Light_"): + return o == "Light_Off" return None @@ -264,9 +282,8 @@ def _display_light_write(payload, rep, href=None): air_purifier.py uses for its own Light switch). Polarity is inverted (see _display_light_on): switching the lamp ON writes 'Light_Off', OFF writes 'Light_On'.""" - token = 'Off' if payload == 'On' else 'On' - return (['mode', 'vs', '0'], - {'x.com.samsung.da.options': option_write('Light', token)}) + token = "Off" if payload == "On" else "On" + return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Light", token)}) # --------------------------------------------------------------------------- @@ -280,8 +297,8 @@ def _display_light_write(payload, rep, href=None): def _option_token(rep, prefix): """Value part of a `_` token in /mode/vs/0's options.""" for option in _mode_options(rep): - if isinstance(option, str) and option.startswith(prefix + '_'): - return option.split('_', 1)[1] + if isinstance(option, str) and option.startswith(prefix + "_"): + return option.split("_", 1)[1] return None @@ -328,8 +345,7 @@ ENERGY_METER_LEGACY = replace( common.ENERGY_METER, match_fn=lambda rep, resources: is_legacy_board(resources), entities=tuple( - replace(e, value_fn=_legacy_cumulative_power_kwh) - if e.key == 'energy_kwh' else e + replace(e, value_fn=_legacy_cumulative_power_kwh) if e.key == "energy_kwh" else e for e in common.ENERGY_METER.entities ), ) @@ -348,11 +364,12 @@ ENERGY_METER_GENERIC = replace( def _has_option_token(prefix): return lambda rep, resources: ( - is_legacy_board(resources) and _option_token(rep, prefix) is not None) + is_legacy_board(resources) and _option_token(rep, prefix) is not None + ) def _option_token_on(prefix): - return lambda rep: _option_token(rep, prefix) == 'On' + return lambda rep: _option_token(rep, prefix) == "On" def _option_token_num(prefix, offset=0, divisor=1): @@ -362,21 +379,24 @@ def _option_token_num(prefix, offset=0, divisor=1): return (float(raw) - offset) / divisor except (TypeError, ValueError): return None + return read def _option_switch_write(prefix): def write(payload, rep, href=None): - return (['mode', 'vs', '0'], - {'x.com.samsung.da.options': option_write(prefix, payload)}) + return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write(prefix, payload)}) + return write def _option_number_write(prefix): def write(payload, rep, href=None): - return (['mode', 'vs', '0'], - {'x.com.samsung.da.options': - option_write(prefix, str(int(round(float(payload)))))}) + return ( + ["mode", "vs", "0"], + {"x.com.samsung.da.options": option_write(prefix, str(round(float(payload))))}, + ) + return write @@ -390,17 +410,17 @@ def _odor_controller_active(rep): legacy-vs-newer split _has_option_token gates on. Read-only: no command capability is confirmed, so this stays a sensor rather than a guessed write (the 'don't guess' rule).""" - tok = _option_token(rep, 'SmartCoolClean') + tok = _option_token(rep, "SmartCoolClean") if tok is None: return None - return tok == 'On' + return tok == "On" def _odor_controller_progress(rep): """0-100 progress of the odor-controller self-clean cycle, from the `ProgressSmartClean_` option token -- mirrors the cloud airConditionerOdorControllerProgress field.""" - return _int(_option_token(rep, 'ProgressSmartClean')) + return _int(_option_token(rep, "ProgressSmartClean")) def _humidity(rep): @@ -421,10 +441,10 @@ def _humidity(rep): (issue #160). So fivepercentHumidity passes 0 through unchanged; only the humidity fallback applies the zero-collapse. """ - if 'x.com.samsung.da.fivepercentHumidity' in rep: - return _num(rep['x.com.samsung.da.fivepercentHumidity']) - if 'x.com.samsung.da.humidity' in rep: - value = _num(rep['x.com.samsung.da.humidity']) + if "x.com.samsung.da.fivepercentHumidity" in rep: + return _num(rep["x.com.samsung.da.fivepercentHumidity"]) + if "x.com.samsung.da.humidity" in rep: + value = _num(rep["x.com.samsung.da.humidity"]) return value if value else None return None @@ -453,56 +473,65 @@ def _climate_write(payload, rep, href=None): this write echoing those fields back). """ kind, value = payload - if kind == 'power': - return (['power', 'vs', '0'], - {'x.com.samsung.da.power': 'On' if value else 'Off'}) - if kind == 'mode': - return (['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}) - if kind == 'temperature_ocf': - return (['temperature', 'desired', '0'], - {'temperature': int(round(float(value)))}) - if kind == 'temperature': + if kind == "power": + return (["power", "vs", "0"], {"x.com.samsung.da.power": "On" if value else "Off"}) + if kind == "mode": + return (["mode", "vs", "0"], {"x.com.samsung.da.modes": [value]}) + if kind == "temperature_ocf": + return (["temperature", "desired", "0"], {"temperature": round(float(value))}) + if kind == "temperature": # Vendor items[] array; only one item observed on every AC dump, id # '0'. See the docstring above for why this doesn't echo current/ # minimum/maximum/unit back at the unit. - return (['temperatures', 'vs', '0'], - {'x.com.samsung.da.items': [ - {'x.com.samsung.da.id': '0', - 'x.com.samsung.da.desired': str(int(round(float(value))))}]}) - if kind == 'fan': - return (['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}) - if kind == 'swing': - return (['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': value}) - if kind == 'oscillation': + return ( + ["temperatures", "vs", "0"], + { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.desired": str(round(float(value))), + } + ] + }, + ) + if kind == "fan": + return (["wind", "strength", "vs", "0"], {"x.com.samsung.da.modes": value}) + if kind == "swing": + return (["wind", "direction", "vs", "0"], {"x.com.samsung.da.modes": value}) + if kind == "oscillation": # value is the HA swing_mode string ('off'/'vertical'/'horizontal'/ # 'both'); both axes are independent Swing|Fix toggles on this # resource, so one HA value maps to a pair of fields written # together (see climate.py's oscillation fallback). - return (['wind', 'oscillation', 'vs', '0'], { - 'vertical': 'Swing' if value in ('vertical', 'both') else 'Fix', - 'horizontal': 'Swing' if value in ('horizontal', 'both') else 'Fix', - }) - if kind == 'fan_legacy': - return (['airflow', 'vs', '0'], - {'x.com.samsung.da.speedLevel': str(value)}) - if kind == 'swing_legacy': - return (['airflow', 'vs', '0'], - {'x.com.samsung.da.direction': value}) - if kind == 'preset_legacy': + return ( + ["wind", "oscillation", "vs", "0"], + { + "vertical": "Swing" if value in ("vertical", "both") else "Fix", + "horizontal": "Swing" if value in ("horizontal", "both") else "Fix", + }, + ) + if kind == "fan_legacy": + return (["airflow", "vs", "0"], {"x.com.samsung.da.speedLevel": str(value)}) + if kind == "swing_legacy": + return (["airflow", "vs", "0"], {"x.com.samsung.da.direction": value}) + if kind == "preset_legacy": # Single-token options merge, same mechanism as _display_light_write. - return (['mode', 'vs', '0'], - {'x.com.samsung.da.options': option_write('Comode', value)}) - if kind == 'preset': - return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value}) + return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Comode", value)}) + if kind == "preset": + return (["mode", "convenient", "vs", "0"], {"x.com.samsung.da.modes": value}) return None CLIMATE = Capability( href=HREF_MODE, - poll_tier='warm', + poll_tier="warm", entities=( - ClimateDesc(key='climate', translation_key='airconditioner', - rep_fn=_first_mode, write_fn=_climate_write), + ClimateDesc( + key="climate", + translation_key="airconditioner", + rep_fn=_first_mode, + write_fn=_climate_write, + ), # Display (panel) light switch, only on boards that encode it in # /mode/vs/0's options instead of a /light/vs/0 switch (see # _has_display_light_option). Shares the /mode/vs/0 href with the @@ -512,10 +541,14 @@ CLIMATE = Capability( # _display_light_write. Shares the switch.display_light translation # with DISPLAY_LIGHT (the /light/vs/0 switch on other boards) -- # mutually exclusive per href, so only one ever binds for a given unit. - SwitchDesc(key='display_light', rep_fn=_display_light_on, - exists_fn=_has_display_light_option, - write_fn=_display_light_write, - icon='mdi:led-on', entity_category='config'), + SwitchDesc( + key="display_light", + rep_fn=_display_light_on, + exists_fn=_has_display_light_option, + write_fn=_display_light_write, + icon="mdi:led-on", + entity_category="config", + ), # Beep on/off from the `Volume_*` option token (Volume_Mute/Volume_100). # Single-token option_write; a full options RMW reverts on ARTIK051_PRAC. # @@ -529,10 +562,14 @@ CLIMATE = Capability( # the literal 'Mute' token needed to actually turn it off -- writing # '0' is simply not a token this firmware recognizes. A switch is # both the correct model and the actual fix. - SwitchDesc(key='beep', rep_fn=_beep_on, - exists_fn=lambda rep, resources: _option_token(rep, 'Volume') is not None, - write_fn=_beep_write, - icon='mdi:volume-high', entity_category='config'), + SwitchDesc( + key="beep", + rep_fn=_beep_on, + exists_fn=lambda rep, resources: _option_token(rep, "Volume") is not None, + write_fn=_beep_write, + icon="mdi:volume-high", + entity_category="config", + ), # Tropical night mode level (0-16) from the `Sleep_` option token. # Single-token option_write. Cloud: custom.airConditionerTropicalNightMode. # Gated off the legacy board for the same reason as beep above -- its @@ -550,50 +587,79 @@ CLIMATE = Capability( # Registered but disabled by default, same precedent as # fridge.rack_count / cooktop.paired_hood_* -- units that do have the # feature can enable it themselves. - NumberDesc(key='tropical_night_mode', rep_fn=_tropical_night_value, - exists_fn=lambda rep, resources: ( - not is_legacy_board(resources) - and _option_token(rep, 'Sleep') is not None), - write_fn=_tropical_night_write, - native_min=0, native_max=16, step=1, - enabled_default=False, - icon='mdi:weather-night', entity_category='config'), + NumberDesc( + key="tropical_night_mode", + rep_fn=_tropical_night_value, + exists_fn=lambda rep, resources: ( + not is_legacy_board(resources) and _option_token(rep, "Sleep") is not None + ), + write_fn=_tropical_night_write, + native_min=0, + native_max=16, + step=1, + enabled_default=False, + icon="mdi:weather-night", + entity_category="config", + ), # Settings that this board generation keeps as options[] tokens. - SwitchDesc(key='spi', rep_fn=_option_token_on('Spi'), - exists_fn=_has_option_token('Spi'), - write_fn=_option_switch_write('Spi'), - icon='mdi:air-purifier', entity_category='config'), + SwitchDesc( + key="spi", + rep_fn=_option_token_on("Spi"), + exists_fn=_has_option_token("Spi"), + write_fn=_option_switch_write("Spi"), + icon="mdi:air-purifier", + entity_category="config", + ), # Shares AUTO_CLEAN's catalog entry rather than duplicating it: same # feature, different board generation (that one is a /option/autoclean/ # vs/0 field, absent here). Distinct key, so nothing collides if some # future board ever reported both. - SwitchDesc(key='auto_clean_legacy', translation_key='auto_clean', - rep_fn=_option_token_on('Autoclean'), - exists_fn=_has_option_token('Autoclean'), - write_fn=_option_switch_write('Autoclean'), - icon='mdi:fan-auto', entity_category='config'), - SwitchDesc(key='air_monitoring', rep_fn=_option_token_on('AirMonitoring'), - exists_fn=_has_option_token('AirMonitoring'), - write_fn=_option_switch_write('AirMonitoring'), - icon='mdi:air-filter', entity_category='config'), + SwitchDesc( + key="auto_clean_legacy", + translation_key="auto_clean", + rep_fn=_option_token_on("Autoclean"), + exists_fn=_has_option_token("Autoclean"), + write_fn=_option_switch_write("Autoclean"), + icon="mdi:fan-auto", + entity_category="config", + ), + SwitchDesc( + key="air_monitoring", + rep_fn=_option_token_on("AirMonitoring"), + exists_fn=_has_option_token("AirMonitoring"), + write_fn=_option_switch_write("AirMonitoring"), + icon="mdi:air-filter", + entity_category="config", + ), # "Good Sleep" timer. 0 = off; the upper bound is a guess (the token # carries no range hint and only 0 has been observed on hardware), so a # write above 0 is unverified. - NumberDesc(key='good_sleep', rep_fn=_option_token_num('Sleep'), - exists_fn=_has_option_token('Sleep'), - write_fn=_option_number_write('Sleep'), - native_min=0, native_max=12, step=1, unit='h', - icon='mdi:sleep', entity_category='config'), + NumberDesc( + key="good_sleep", + rep_fn=_option_token_num("Sleep"), + exists_fn=_has_option_token("Sleep"), + write_fn=_option_number_write("Sleep"), + native_min=0, + native_max=12, + step=1, + unit="h", + icon="mdi:sleep", + entity_category="config", + ), # Outdoor temperature, offset by 55. Two calibration points on one unit: # token 75 while an independent outdoor thermometer in the same install # read 20.3 C, and token 74 against a 19.4 C forecast. Fahrenheit fits far # worse (74 F = 23.3 C); the issue #136 unit's 81 gives 26 C in a warmer # climate, which is also plausible. - SensorDesc(key='outdoor_temperature', - rep_fn=_option_token_num('OutdoorTemp', offset=55), - exists_fn=_has_option_token('OutdoorTemp'), - device_class='temperature', state_class='measurement', - unit='°C', icon='mdi:home-thermometer-outline'), + SensorDesc( + key="outdoor_temperature", + rep_fn=_option_token_num("OutdoorTemp", offset=55), + exists_fn=_has_option_token("OutdoorTemp"), + device_class="temperature", + state_class="measurement", + unit="°C", + icon="mdi:home-thermometer-outline", + ), # Filter time in tenths of an hour: the token read 1710 while the official # Samsung app displayed "171 hours 0 minutes" for the filter on the same # unit, and the .0 matching the app's "0 minutes" pins the scale. @@ -675,11 +741,15 @@ CLIMATE = Capability( # reset (Options -> Filter Reset -> SET), and IRremoteESP8266 decodes # this AC family, though issue #1277's dump doesn't include that button. # --------------------------------------------------------------------- - SensorDesc(key='filter_time', - rep_fn=_option_token_num('FilterTime', divisor=10), - exists_fn=_has_option_token('FilterTime'), - device_class='duration', unit='h', - state_class='measurement', icon='mdi:air-filter'), + SensorDesc( + key="filter_time", + rep_fn=_option_token_num("FilterTime", divisor=10), + exists_fn=_has_option_token("FilterTime"), + device_class="duration", + unit="h", + state_class="measurement", + icon="mdi:air-filter", + ), # The interval FilterTime_ is measured against -- the app offers it as a # four-way radio (180/300/500/700 hours, default 500) next to the filter # reminder. The token value is the hour count verbatim: captured by @@ -693,126 +763,167 @@ CLIMATE = Capability( # rather than a guess extrapolated from one observed value -- but a # board offering different steps would need this revisited, which is # why the tuple is documented rather than just written down. - SelectDesc(key='filter_alarm_time', - rep_fn=lambda rep: _option_token(rep, 'FilterAlarmTime'), - exists_fn=_has_option_token('FilterAlarmTime'), - options=('180', '300', '500', '700'), - write_fn=_option_switch_write('FilterAlarmTime'), - icon='mdi:alarm', entity_category='config'), + SelectDesc( + key="filter_alarm_time", + rep_fn=lambda rep: _option_token(rep, "FilterAlarmTime"), + exists_fn=_has_option_token("FilterAlarmTime"), + options=("180", "300", "500", "700"), + write_fn=_option_switch_write("FilterAlarmTime"), + icon="mdi:alarm", + entity_category="config", + ), # Odor-controller ("Smart Cool Clean") state + progress -- see # _odor_controller_active's docstring for the cloud-capability # correspondence. Present on TP1X_DA-AC-RAC-01001 (issue reporter's # dump); gating is token presence only, not board generation. - BinarySensorDesc(key='odor_controller_active', - rep_fn=_odor_controller_active, - exists_fn=lambda rep, resources: ( - _option_token(rep, 'SmartCoolClean') is not None), - icon='mdi:air-filter', entity_category='diagnostic'), - SensorDesc(key='odor_controller_progress', - rep_fn=_odor_controller_progress, - exists_fn=lambda rep, resources: ( - _option_token(rep, 'ProgressSmartClean') is not None), - unit='%', state_class='measurement', - icon='mdi:progress-check', entity_category='diagnostic'), + BinarySensorDesc( + key="odor_controller_active", + rep_fn=_odor_controller_active, + exists_fn=lambda rep, resources: _option_token(rep, "SmartCoolClean") is not None, + icon="mdi:air-filter", + entity_category="diagnostic", + ), + SensorDesc( + key="odor_controller_progress", + rep_fn=_odor_controller_progress, + exists_fn=lambda rep, resources: _option_token(rep, "ProgressSmartClean") is not None, + unit="%", + state_class="measurement", + icon="mdi:progress-check", + entity_category="diagnostic", + ), ), ) AIR_PURIFY = Capability( - href='/option/airpurify/vs/0', - poll_tier='warm', + href="/option/airpurify/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='air_purify', field='x.com.samsung.da.modes', - icon='mdi:air-purifier', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['option', 'airpurify', 'vs', '0'], - {'x.com.samsung.da.modes': 'On' if p == 'On' else 'Off'})), + SwitchDesc( + key="air_purify", + field="x.com.samsung.da.modes", + icon="mdi:air-purifier", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["option", "airpurify", "vs", "0"], + {"x.com.samsung.da.modes": "On" if p == "On" else "Off"}, + ), + ), ), ) AUTO_CLEAN = Capability( - href='/option/autoclean/vs/0', - poll_tier='cold', + href="/option/autoclean/vs/0", + poll_tier="cold", entities=( - SwitchDesc(key='auto_clean', field='x.com.samsung.da.settingStatus', - icon='mdi:spray-bottle', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['option', 'autoclean', 'vs', '0'], - {'x.com.samsung.da.settingStatus': 'On' if p == 'On' else 'Off'})), + SwitchDesc( + key="auto_clean", + field="x.com.samsung.da.settingStatus", + icon="mdi:spray-bottle", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["option", "autoclean", "vs", "0"], + {"x.com.samsung.da.settingStatus": "On" if p == "On" else "Off"}, + ), + ), # The cycle, as opposed to the switch above -- `settingStatus` says the # feature is enabled, which it is whether or not the unit is drying right # now. `status` is the run state, and the resource advertises exactly # Start/Stop in `supportedStatus`. Observed on a TP1X_DA-AC-CAC-01001: # 'Start' with progress 98 while a cycle ran, then 'Stop' with progress 0 # the moment it finished. - BinarySensorDesc(key='auto_clean_running', - field='x.com.samsung.da.status', - icon='mdi:spray-bottle', - entity_category='diagnostic', - value_fn=lambda v: v == 'Start'), + BinarySensorDesc( + key="auto_clean_running", + field="x.com.samsung.da.status", + icon="mdi:spray-bottle", + entity_category="diagnostic", + value_fn=lambda v: v == "Start", + ), # Percent through that cycle, matching the figure the appliance shows on # its own display (checked against 55% mid-run). - SensorDesc(key='auto_clean_progress', - field='x.com.samsung.da.progress', - icon='mdi:progress-clock', - unit='%', state_class='measurement', - entity_category='diagnostic', - value_fn=common.int_or_none), + SensorDesc( + key="auto_clean_progress", + field="x.com.samsung.da.progress", + icon="mdi:progress-clock", + unit="%", + state_class="measurement", + entity_category="diagnostic", + value_fn=common.int_or_none, + ), ), ) AIR_FILTER = Capability( - href='/filter/airdustfilter/vs/0', - poll_tier='cold', + href="/filter/airdustfilter/vs/0", + poll_tier="cold", entities=( - SensorDesc(key='air_filter_usage', rep_fn=filter_usage_percent, - unit='%', state_class='measurement', - icon='mdi:air-filter', entity_category='diagnostic'), + SensorDesc( + key="air_filter_usage", + rep_fn=filter_usage_percent, + unit="%", + state_class="measurement", + icon="mdi:air-filter", + entity_category="diagnostic", + ), # filterUsage is a lifetime hour counter that only resets on filter # replacement -- total_increasing so HA's long-term statistics handle # the reset rather than treating it as a bounded measurement. - SensorDesc(key='air_filter_usage_hours', - field='x.com.samsung.da.filterUsage', - device_class='duration', - state_class='total_increasing', - unit_fn=_filter_unit, - icon='mdi:air-filter', entity_category='diagnostic', - value_fn=_int), + SensorDesc( + key="air_filter_usage_hours", + field="x.com.samsung.da.filterUsage", + device_class="duration", + state_class="total_increasing", + unit_fn=_filter_unit, + icon="mdi:air-filter", + entity_category="diagnostic", + value_fn=_int, + ), # The alarm threshold (filterDesiredUsage) is a locally writable option: # see _threshold_write. Surfaces as a Select only where the device # advertises supportedFilterDesiredUsage; boards without that enum # leave it unexposed rather than guess the valid set. - SelectDesc(key='air_filter_threshold', - field='x.com.samsung.da.filterDesiredUsage', - options_field='x.com.samsung.da.supportedFilterDesiredUsage', - exists_fn=lambda rep, res: bool( - rep.get('x.com.samsung.da.supportedFilterDesiredUsage')), - icon='mdi:alarm', entity_category='config', - write_fn=_threshold_write, - value_fn=lambda v: str(v) if v is not None else None), - SensorDesc(key='air_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), + SelectDesc( + key="air_filter_threshold", + field="x.com.samsung.da.filterDesiredUsage", + options_field="x.com.samsung.da.supportedFilterDesiredUsage", + exists_fn=lambda rep, res: bool( + rep.get("x.com.samsung.da.supportedFilterDesiredUsage") + ), + icon="mdi:alarm", + entity_category="config", + write_fn=_threshold_write, + value_fn=lambda v: str(v) if v is not None else None, + ), + SensorDesc( + key="air_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, + ), ), ) DISPLAY_LIGHT = Capability( - href='/light/vs/0', - poll_tier='cold', + href="/light/vs/0", + poll_tier="cold", entities=( - SwitchDesc(key='display_light', field='mode', - icon='mdi:led-on', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['light', 'vs', '0'], - {'mode': 'On' if p == 'On' else 'Off'})), + SwitchDesc( + key="display_light", + field="mode", + icon="mdi:led-on", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["light", "vs", "0"], + {"mode": "On" if p == "On" else "Off"}, + ), + ), ), ) @@ -820,16 +931,20 @@ DISPLAY_LIGHT = Capability( # boolean field, no vendor prefix, mirroring the On/Off convention used # throughout the rest of this API. MUTE_ONCE = Capability( - href='/option/muteonce/vs/0', - poll_tier='warm', + href="/option/muteonce/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='mute_once', field='muteonce', - icon='mdi:volume-mute', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['option', 'muteonce', 'vs', '0'], - {'muteonce': 'On' if p == 'On' else 'Off'})), + SwitchDesc( + key="mute_once", + field="muteonce", + icon="mdi:volume-mute", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["option", "muteonce", "vs", "0"], + {"muteonce": "On" if p == "On" else "Off"}, + ), + ), ), ) @@ -840,16 +955,22 @@ MUTE_ONCE = Capability( # exposed read-only per the 'don't guess' rule rather than risking an # unverified write to live HVAC hardware. CURRENT_LIMIT = Capability( - href='/electriccurrent/vs/0', - poll_tier='cold', + href="/electriccurrent/vs/0", + poll_tier="cold", entities=( - BinarySensorDesc(key='current_limit_enabled', field='operation', - icon='mdi:current-ac', - entity_category='diagnostic', - value_fn=lambda v: v == 'On'), - SensorDesc(key='current_limit_level', field='modes', - icon='mdi:current-ac', - entity_category='diagnostic'), + BinarySensorDesc( + key="current_limit_enabled", + field="operation", + icon="mdi:current-ac", + entity_category="diagnostic", + value_fn=lambda v: v == "On", + ), + SensorDesc( + key="current_limit_level", + field="modes", + icon="mdi:current-ac", + entity_category="diagnostic", + ), ), ) @@ -862,19 +983,26 @@ CURRENT_LIMIT = Capability( # 'operation' is safe on live HVAC hardware -- exposed read-only per the # 'don't guess' rule, same precedent as CURRENT_LIMIT above. ANOMALY_LOAD = Capability( - href='/anomalyload/vs/0', - poll_tier='cold', + href="/anomalyload/vs/0", + poll_tier="cold", entities=( - BinarySensorDesc(key='overload_protection_active', field='operation', - icon='mdi:flash-alert', - entity_category='diagnostic', - value_fn=lambda v: v == 'On'), - SensorDesc(key='overload_protection_mode', field='mode', - device_class='enum', - options=('alarm', 'powersaving'), - translation_key='overload_protection_mode', - icon='mdi:flash-alert', entity_category='diagnostic', - value_fn=lambda v: v.lower() if isinstance(v, str) else v), + BinarySensorDesc( + key="overload_protection_active", + field="operation", + icon="mdi:flash-alert", + entity_category="diagnostic", + value_fn=lambda v: v == "On", + ), + SensorDesc( + key="overload_protection_mode", + field="mode", + device_class="enum", + options=("alarm", "powersaving"), + translation_key="overload_protection_mode", + icon="mdi:flash-alert", + entity_category="diagnostic", + value_fn=lambda v: v.lower() if isinstance(v, str) else v, + ), ), ) @@ -893,22 +1021,30 @@ ANOMALY_LOAD = Capability( # sensitivity setting) isn't distinguishable from the dump, so it's left # unmodeled entirely. ABSENCE_POWER_SAVING = Capability( - href='/mds/absencepowersaving/vs/0', - poll_tier='cold', + href="/mds/absencepowersaving/vs/0", + poll_tier="cold", entities=( - SwitchDesc(key='absence_power_saving_active', field='status', - icon='mdi:human-greeting-proximity', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['mds', 'absencepowersaving', 'vs', '0'], - {'status': 'On' if p == 'On' else 'Off'})), - SensorDesc(key='absence_power_saving_mode', field='switchPowerSaveMode', - device_class='enum', - options=('eco', 'normal', 'comfort'), - translation_key='absence_power_saving_mode', - icon='mdi:leaf', entity_category='diagnostic', - value_fn=lambda v: v.lower() if isinstance(v, str) else v), + SwitchDesc( + key="absence_power_saving_active", + field="status", + icon="mdi:human-greeting-proximity", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["mds", "absencepowersaving", "vs", "0"], + {"status": "On" if p == "On" else "Off"}, + ), + ), + SensorDesc( + key="absence_power_saving_mode", + field="switchPowerSaveMode", + device_class="enum", + options=("eco", "normal", "comfort"), + translation_key="absence_power_saving_mode", + icon="mdi:leaf", + entity_category="diagnostic", + value_fn=lambda v: v.lower() if isinstance(v, str) else v, + ), ), ) @@ -918,22 +1054,30 @@ ABSENCE_POWER_SAVING = Capability( # `supportedModes`) stays read-only -- same reasoning as # absence_power_saving_mode above. MOTION_DETECT_WIND = Capability( - href='/option/motiondetectwind/stateful/vs/0', - poll_tier='cold', + href="/option/motiondetectwind/stateful/vs/0", + poll_tier="cold", entities=( - SwitchDesc(key='motion_detect_wind_active', field='status', - icon='mdi:motion-sensor', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['option', 'motiondetectwind', 'stateful', 'vs', '0'], - {'status': 'On' if p == 'On' else 'Off'})), - SensorDesc(key='motion_detect_wind_mode', field='modes', - device_class='enum', - options=('direct', 'indirect'), - translation_key='motion_detect_wind_mode', - icon='mdi:weather-windy', entity_category='diagnostic', - value_fn=lambda v: v.lower() if isinstance(v, str) else v), + SwitchDesc( + key="motion_detect_wind_active", + field="status", + icon="mdi:motion-sensor", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["option", "motiondetectwind", "stateful", "vs", "0"], + {"status": "On" if p == "On" else "Off"}, + ), + ), + SensorDesc( + key="motion_detect_wind_mode", + field="modes", + device_class="enum", + options=("direct", "indirect"), + translation_key="motion_detect_wind_mode", + icon="mdi:weather-windy", + entity_category="diagnostic", + value_fn=lambda v: v.lower() if isinstance(v, str) else v, + ), ), ) @@ -945,22 +1089,30 @@ MOTION_DETECT_WIND = Capability( # (match_fn gates the vendor one off when the OCF resource is present). CURRENT_TEMPERATURE = Capability( href=HREF_TEMP_CURRENT, - poll_tier='warm', + poll_tier="warm", entities=( - SensorDesc(key='current_temperature_c', field='temperature', - device_class='temperature', state_class='measurement', - unit_fn=lambda rep: normalize_temp_unit(rep.get('units'), '°C')), + SensorDesc( + key="current_temperature_c", + field="temperature", + device_class="temperature", + state_class="measurement", + unit_fn=lambda rep: normalize_temp_unit(rep.get("units"), "°C"), + ), ), ) CURRENT_TEMPERATURE_VS = Capability( href=HREF_TEMPS_VS, - poll_tier='warm', + poll_tier="warm", match_fn=lambda rep, resources: HREF_TEMP_CURRENT not in resources, entities=( - SensorDesc(key='current_temperature_c', rep_fn=_temps_vs_current, - device_class='temperature', state_class='measurement', - unit_fn=_temps_vs_unit), + SensorDesc( + key="current_temperature_c", + rep_fn=_temps_vs_current, + device_class="temperature", + state_class="measurement", + unit_fn=_temps_vs_unit, + ), ), ) @@ -979,11 +1131,16 @@ CURRENT_TEMPERATURE_VS = Capability( # board generations a zero here means no measurement, never dry air. # /humidity/0 stayed 0 throughout that same observation too. HUMIDITY = Capability( - href='/humidity/vs/0', - poll_tier='warm', + href="/humidity/vs/0", + poll_tier="warm", entities=( - SensorDesc(key='humidity', rep_fn=_humidity, - device_class='humidity', state_class='measurement', unit='%'), + SensorDesc( + key="humidity", + rep_fn=_humidity, + device_class="humidity", + state_class="measurement", + unit="%", + ), ), ) @@ -1008,26 +1165,34 @@ HUMIDITY = Capability( # cooktop.paired_hood_model / this file's own tropical_night_mode): units # that do have the sensor can enable it themselves. AIR_QUALITY = Capability( - href='/sensors/vs/0', - poll_tier='cold', + href="/sensors/vs/0", + poll_tier="cold", entities=( - SensorDesc(key='clean_level', field='x.com.samsung.da.items', - icon='mdi:broom', entity_category='diagnostic', - state_class='measurement', - exists_fn=_has_sensor_type('CleanLevel'), - enabled_default=False, - value_fn=lambda items: _int(_sensor_item_value(items, 'CleanLevel'))), + SensorDesc( + key="clean_level", + field="x.com.samsung.da.items", + icon="mdi:broom", + entity_category="diagnostic", + state_class="measurement", + exists_fn=_has_sensor_type("CleanLevel"), + enabled_default=False, + value_fn=lambda items: _int(_sensor_item_value(items, "CleanLevel")), + ), *tuple( - SensorDesc(key=key, field='x.com.samsung.da.items', - icon=icon, entity_category='diagnostic', - exists_fn=_has_sensor_type(type_), - enabled_default=False, - value_fn=lambda items, t=type_: _sensor_item_value(items, t)) + SensorDesc( + key=key, + field="x.com.samsung.da.items", + icon=icon, + entity_category="diagnostic", + exists_fn=_has_sensor_type(type_), + enabled_default=False, + value_fn=lambda items, t=type_: _sensor_item_value(items, t), + ) for key, icon, type_ in ( - ('odor', 'mdi:weather-windy', 'Odor'), - ('dust', 'mdi:cloud', 'Dust'), - ('fine_dust', 'mdi:cloud-outline', 'FineDust'), - ('super_fine_dust', 'mdi:weather-fog', 'SuperFineDust'), + ("odor", "mdi:weather-windy", "Odor"), + ("dust", "mdi:cloud", "Dust"), + ("fine_dust", "mdi:cloud-outline", "FineDust"), + ("super_fine_dust", "mdi:weather-fog", "SuperFineDust"), ) ), ), @@ -1056,15 +1221,15 @@ _AC_IGNORED = [ # Stuck at "0" on every dump seen -- HUMIDITY above reads the vendor # resource's usable fivepercentHumidity field instead; this OCF-standard # one has no corresponding live value confirmed yet. - '/humidity/0', + "/humidity/0", # Presence-personalization plumbing (empty item list here). - '/personality/presence/vs/0', + "/personality/presence/vs/0", # OCF-standard mirror of /airflow/vs/0 ({speed, direction} vs # {speedLevel, direction}, identical values). The climate entity reads and # writes the vendor form, which is the one confirmed on hardware here -- # note that air_purifier.py found the opposite ordering on its family, so # neither form is reliable sight-unseen and this one stays unmodeled. - '/airflow/0', + "/airflow/0", # --- TP1X/TP2X-class housekeeping / opaque blobs. These carry no # user-actionable state or no documented write contract, so per the # 'don't guess' rule they are ignored rather than modeled. @@ -1072,29 +1237,29 @@ _AC_IGNORED = [ # see MUTE_ONCE above and common.SELF_CHECK (via common.UNIVERSAL) in # the by_type registry, both of which have a confirmed, cleanly # modelable contract. - '/airlevelcheck/vs/0', # periodic air-quality sensing scheduler plumbing - '/aisleep/vs/0', # AI-sleep feedback state (no actionable control) - '/availablecontrolsets/vs/0', # opaque hex-encoded control-set bitmap - '/da/softreset/vs/0', # soft-reset trigger plumbing - '/keepnormalstate/vs/0', # internal keep-normal flag - '/mds/absencemonitoring/vs/0', # motion-detection sensor plumbing (empty here) - '/mds/absencestate/vs/0', # motion-detection state (empty here) - '/remotedatacontrol/vs/0', # remote data-control session status - '/remotedeviceinfo/vs/0', # remote paired-device id list (empty didList here) - '/remotetemperature/vs/0', # external temp-sensor feed (unset on this unit) + "/airlevelcheck/vs/0", # periodic air-quality sensing scheduler plumbing + "/aisleep/vs/0", # AI-sleep feedback state (no actionable control) + "/availablecontrolsets/vs/0", # opaque hex-encoded control-set bitmap + "/da/softreset/vs/0", # soft-reset trigger plumbing + "/keepnormalstate/vs/0", # internal keep-normal flag + "/mds/absencemonitoring/vs/0", # motion-detection sensor plumbing (empty here) + "/mds/absencestate/vs/0", # motion-detection state (empty here) + "/remotedatacontrol/vs/0", # remote data-control session status + "/remotedeviceinfo/vs/0", # remote paired-device id list (empty didList here) + "/remotetemperature/vs/0", # external temp-sensor feed (unset on this unit) # Manual airflow-step position (supportedModes Off/80/60/40/Power). # Overlaps the /wind/direction swing control already on the climate card, # and the meaning of the numeric steps vs. 'Power' isn't documented in the # dump -- ignored per the 'don't guess' rule rather than modeled as a # select whose write could confuse live HVAC hardware. - '/stepcontrol/vs/0', - '/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob - '/welcome/temperature/vs/0', # welcome-cooling plumbing + "/stepcontrol/vs/0", + "/reserverulesets/vs/0", # opaque hex-encoded schedule reservation blob + "/welcome/temperature/vs/0", # welcome-cooling plumbing # System-AC-only (multi-indoor-subdevice commercial installs, e.g. # A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation # topology -- indoor/outdoor unit pairing, per-subdevice serials, MCU # info. Commissioning-time plumbing, not user-actionable appliance state. - '/sac/installationinfo/vs/0', + "/sac/installationinfo/vs/0", # Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing # *and* a wall-mounted indoor subdevice over one shared local IP, e.g. # TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id @@ -1105,10 +1270,10 @@ _AC_IGNORED = [ # registry/subdevices.py's enumerate_subdevices reads this same # subdeviceIdList to reach the second indoor subdevice over this same # connection instead -- see that module's Pattern B. - '/subdevices/vs/0', + "/subdevices/vs/0", # Undocumented single int (runningMode: 0 on every dump seen), no # supported-values list to interpret it against -- 'don't guess'. - '/runn/vs/0', + "/runn/vs/0", # 2-in-1/multi-indoor-subdevice systems (issue #177, the reporter's # ARTIK051_DONGLE_FAC_18K): x.com.samsung.da.numofsubdevice, a plain # corroborating count of indoor subdevices on this connection. Confirmed @@ -1117,15 +1282,14 @@ _AC_IGNORED = [ # fetches it with its own RETRIEVE and folds it into the resources dict # for diagnostics, which is why it needs an entry here rather than # surfacing as an unbound-href gap on every board that has it. - '/multidevice/vs/0', + "/multidevice/vs/0", ] # Built as bare no-entity caps; folded into the AC registry (not global). # HREF_TEMP_CURRENT and HREF_TEMPS_VS are excluded here -- CURRENT_TEMPERATURE # / CURRENT_TEMPERATURE_VS above already cover those two with real entities. COVERAGE = [ - Capability(href=h, poll_tier='warm') for h in CLIMATE_CONSUMED_HREFS + Capability(href=h, poll_tier="warm") + for h in CLIMATE_CONSUMED_HREFS if h not in (HREF_TEMP_CURRENT, HREF_TEMPS_VS) -] + [ - Capability(href=h) for h in _AC_IGNORED -] +] + [Capability(href=h) for h in _AC_IGNORED] diff --git a/custom_components/localthings/registry/capabilities/common.py b/custom_components/localthings/registry/capabilities/common.py index 984a6fe..b9e4855 100644 --- a/custom_components/localthings/registry/capabilities/common.py +++ b/custom_components/localthings/registry/capabilities/common.py @@ -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}, + ), + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/cooktop.py b/custom_components/localthings/registry/capabilities/cooktop.py index 96f1cfd..05060e9 100644 --- a/custom_components/localthings/registry/capabilities/cooktop.py +++ b/custom_components/localthings/registry/capabilities/cooktop.py @@ -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 ``_`` 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, ), ), diff --git a/custom_components/localthings/registry/capabilities/dehumidifier.py b/custom_components/localthings/registry/capabilities/dehumidifier.py index 4b58047..27c0e5b 100644 --- a/custom_components/localthings/registry/capabilities/dehumidifier.py +++ b/custom_components/localthings/registry/capabilities/dehumidifier.py @@ -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] diff --git a/custom_components/localthings/registry/capabilities/dishwasher.py b/custom_components/localthings/registry/capabilities/dishwasher.py index 0657c1f..f993f63 100644 --- a/custom_components/localthings/registry/capabilities/dishwasher.py +++ b/custom_components/localthings/registry/capabilities/dishwasher.py @@ -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" + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/dryer.py b/custom_components/localthings/registry/capabilities/dryer.py index 8d5224c..d903b6c 100644 --- a/custom_components/localthings/registry/capabilities/dryer.py +++ b/custom_components/localthings/registry/capabilities/dryer.py @@ -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" + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/ehs.py b/custom_components/localthings/registry/capabilities/ehs.py index bbde863..5a284e6 100644 --- a/custom_components/localthings/registry/capabilities/ehs.py +++ b/custom_components/localthings/registry/capabilities/ehs.py @@ -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] diff --git a/custom_components/localthings/registry/capabilities/fridge.py b/custom_components/localthings/registry/capabilities/fridge.py index e7e08a1..512f0fe 100644 --- a/custom_components/localthings/registry/capabilities/fridge.py +++ b/custom_components/localthings/registry/capabilities/fridge.py @@ -15,12 +15,17 @@ Multi-instance note: the two door resources (/door/cooler/0 and /icemaker/two/vs/0) use named path segments, so they are modelled via pattern capabilities that auto-derive distinct entity keys from href segments. """ + import datetime from ..capability import Capability from ..entities import ( - BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, - SwitchDesc, TimeDesc, + BinarySensorDesc, + NumberDesc, + SelectDesc, + SensorDesc, + SwitchDesc, + TimeDesc, ) from .common import normalize_temp_unit @@ -41,7 +46,7 @@ def _int(v): def _temp_unit(rep): """'units': 'C'/'F' (or 'Celsius'/'Fahrenheit') -> '°C'/'°F'. Defaults to °F (this module's original assumption) if the device omits the field.""" - return normalize_temp_unit(rep.get('units')) + return normalize_temp_unit(rep.get("units")) # --------------------------------------------------------------------------- @@ -50,18 +55,24 @@ def _temp_unit(rep): TEMP_CURRENT_GENERIC = Capability( href=None, - href_prefix='/temperature/current/', + href_prefix="/temperature/current/", strip_prefix_in_key=True, - poll_tier='warm', + poll_tier="warm", entities=( - SensorDesc(key='temperature', field='temperature', - translation_key='instance_temperature', - use_instance_name=True, icon='mdi:thermometer', - device_class='temperature', unit_fn=_temp_unit, - state_class='measurement'), + SensorDesc( + key="temperature", + field="temperature", + translation_key="instance_temperature", + use_instance_name=True, + icon="mdi:thermometer", + device_class="temperature", + unit_fn=_temp_unit, + state_class="measurement", + ), ), ) + def _temp_setpoint_write(p, rep, href=None, resources=None): """Write temperature — prefer vendor /temperatures/vs/0 when available, fall back to direct OCF /temperature/desired/ write otherwise. @@ -73,38 +84,46 @@ def _temp_setpoint_write(p, rep, href=None, resources=None): """ if not href: return None - if resources and '/temperatures/vs/0' in resources: - if '/cooler/' in href: - item_id = '1' - elif '/freezer/' in href: - item_id = '0' + if resources and "/temperatures/vs/0" in resources: + if "/cooler/" in href: + item_id = "1" + elif "/freezer/" in href: + item_id = "0" else: return None return ( - ['temperatures', 'vs', '0'], - {'x.com.samsung.da.items': [ - {'x.com.samsung.da.id': item_id, - 'x.com.samsung.da.desired': str(int(round(float(p))))} - ]} + ["temperatures", "vs", "0"], + { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": item_id, + "x.com.samsung.da.desired": str(round(float(p))), + } + ] + }, ) - return ( - [s for s in href.strip('/').split('/') if s], - {'temperature': int(round(float(p)))} - ) + return ([s for s in href.strip("/").split("/") if s], {"temperature": round(float(p))}) TEMP_SETPOINT = Capability( href=None, - href_prefix='/temperature/desired/', + href_prefix="/temperature/desired/", strip_prefix_in_key=True, - poll_tier='warm', + poll_tier="warm", entities=( - NumberDesc(key='setpoint', field='temperature', - translation_key='instance_setpoint', - use_instance_name=True, device_class='temperature', unit_fn=_temp_unit, - native_min=-20.0, native_max=50.0, - range_field='range', entity_category='config', - write_fn=_temp_setpoint_write), + NumberDesc( + key="setpoint", + field="temperature", + translation_key="instance_setpoint", + use_instance_name=True, + device_class="temperature", + unit_fn=_temp_unit, + native_min=-20.0, + native_max=50.0, + range_field="range", + entity_category="config", + write_fn=_temp_setpoint_write, + ), ), ) @@ -121,21 +140,26 @@ TEMP_SETPOINT = Capability( # options list instead, same shape as BEVERAGE_ZONE/PANTRY_ZONE above. # --------------------------------------------------------------------------- + def _definite_cooler_write(p, rep, href=None): - return (['temperature', 'definite', 'cooler', 'vs', '0'], - {'x.com.samsung.da.definite.desired': p}) + return ( + ["temperature", "definite", "cooler", "vs", "0"], + {"x.com.samsung.da.definite.desired": p}, + ) DEFINITE_TEMPERATURE_COOLER = Capability( - href='/temperature/definite/cooler/vs/0', - poll_tier='warm', + href="/temperature/definite/cooler/vs/0", + poll_tier="warm", entities=( - SelectDesc(key='cooler_temperature_setpoint', - field='x.com.samsung.da.definite.desired', - icon='mdi:thermometer', - entity_category='config', - options_field='x.com.samsung.da.definite.supportedList', - write_fn=_definite_cooler_write), + SelectDesc( + key="cooler_temperature_setpoint", + field="x.com.samsung.da.definite.desired", + icon="mdi:thermometer", + entity_category="config", + options_field="x.com.samsung.da.definite.supportedList", + write_fn=_definite_cooler_write, + ), ), ) @@ -144,16 +168,20 @@ DEFINITE_TEMPERATURE_COOLER = Capability( # --------------------------------------------------------------------------- ICEMAKER_NIGHTTIME = Capability( - href='/icemaker/nighttime/vs/0', - poll_tier='warm', + href="/icemaker/nighttime/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='ice_night_mode', field='ice.night.status', - icon='mdi:weather-night', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['icemaker', 'nighttime', 'vs', '0'], - {'ice.night.status': 'On' if p == 'On' else 'Off'})), + SwitchDesc( + key="ice_night_mode", + field="ice.night.status", + icon="mdi:weather-night", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["icemaker", "nighttime", "vs", "0"], + {"ice.night.status": "On" if p == "On" else "Off"}, + ), + ), ), ) @@ -170,38 +198,50 @@ ICEMAKER_NIGHTTIME = Capability( # select, but users still want them labeled with the device's own names. # --------------------------------------------------------------------------- + def _icemaker_write(field): return lambda p, rep, href=None: ( - [s for s in href.strip('/').split('/') if s], - {field: p} - ) if href else None + ([s for s in href.strip("/").split("/") if s], {field: p}) if href else None + ) + ICEMAKER_GENERIC = Capability( href=None, - href_prefix='/icemaker/', - match_fn=lambda rep, resources: 'x.com.samsung.da.iceMaker.state' in rep, - name_field='x.com.samsung.da.iceMaker.name', - poll_tier='warm', + href_prefix="/icemaker/", + match_fn=lambda rep, resources: "x.com.samsung.da.iceMaker.state" in rep, + name_field="x.com.samsung.da.iceMaker.name", + poll_tier="warm", entities=( - SensorDesc(key='making_status', - field='x.com.samsung.da.iceMaker.iceMakingStatus', - use_instance_name=True, icon='mdi:cube-outline', - device_class='enum', - options=('icestatus_stop', 'icestatus_run'), - translation_key='ice_making_status', - value_fn=lambda v: v.lower() if isinstance(v, str) else v), - SwitchDesc(key='enabled', field='x.com.samsung.da.iceMaker.state', - translation_key='instance_enabled', - use_instance_name=True, icon='mdi:cube-outline', - value_fn=lambda v: v == 'On', - write_fn=_icemaker_write('x.com.samsung.da.iceMaker.state')), - SelectDesc(key='type', field='x.com.samsung.da.iceType.desired', - use_instance_name=True, icon='mdi:cube-outline', - translation_key='ice_type', - entity_category='config', - options_field='x.com.samsung.da.iceType.supported', - exists_fn=lambda rep, resources: bool(rep.get('x.com.samsung.da.iceType.supported')), - write_fn=_icemaker_write('x.com.samsung.da.iceType.desired')), + SensorDesc( + key="making_status", + field="x.com.samsung.da.iceMaker.iceMakingStatus", + use_instance_name=True, + icon="mdi:cube-outline", + device_class="enum", + options=("icestatus_stop", "icestatus_run"), + translation_key="ice_making_status", + value_fn=lambda v: v.lower() if isinstance(v, str) else v, + ), + SwitchDesc( + key="enabled", + field="x.com.samsung.da.iceMaker.state", + translation_key="instance_enabled", + use_instance_name=True, + icon="mdi:cube-outline", + value_fn=lambda v: v == "On", + write_fn=_icemaker_write("x.com.samsung.da.iceMaker.state"), + ), + SelectDesc( + key="type", + field="x.com.samsung.da.iceType.desired", + use_instance_name=True, + icon="mdi:cube-outline", + translation_key="ice_type", + entity_category="config", + options_field="x.com.samsung.da.iceType.supported", + exists_fn=lambda rep, resources: bool(rep.get("x.com.samsung.da.iceType.supported")), + write_fn=_icemaker_write("x.com.samsung.da.iceType.desired"), + ), ), ) @@ -210,17 +250,21 @@ ICEMAKER_GENERIC = Capability( # --------------------------------------------------------------------------- DOOR_ALERT = Capability( - href='/settings/sound/alert/door/vs/0', - poll_tier='warm', + href="/settings/sound/alert/door/vs/0", + poll_tier="warm", entities=( - SelectDesc(key='door_alert', field='alert.door', - icon='mdi:bell-alert', - translation_key='door_alert', - entity_category='config', - options_field='supportedAlert.door', - write_fn=lambda p, rep, href=None: ( - ['settings', 'sound', 'alert', 'door', 'vs', '0'], - {'alert.door': p})), + SelectDesc( + key="door_alert", + field="alert.door", + icon="mdi:bell-alert", + translation_key="door_alert", + entity_category="config", + options_field="supportedAlert.door", + write_fn=lambda p, rep, href=None: ( + ["settings", "sound", "alert", "door", "vs", "0"], + {"alert.door": p}, + ), + ), ), ) @@ -228,27 +272,34 @@ DOOR_ALERT = Capability( # Status/lock — auto door opener and fridge sound # --------------------------------------------------------------------------- + def _status_lock_write(field): return lambda p, rep, href=None: ( - ['status', 'lock', 'vs', '0'], - {field: 'On' if p == 'On' else 'Off'} + ["status", "lock", "vs", "0"], + {field: "On" if p == "On" else "Off"}, ) STATUS_LOCK = Capability( - href='/status/lock/vs/0', - poll_tier='warm', + href="/status/lock/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='auto_door_opener', field='x.com.samsung.da.ado.devicecontrol', - icon='mdi:door-open', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=_status_lock_write('x.com.samsung.da.ado.devicecontrol')), - SwitchDesc(key='fridge_sound', field='x.com.samsung.da.device.sound', - icon='mdi:volume-high', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=_status_lock_write('x.com.samsung.da.device.sound')), + SwitchDesc( + key="auto_door_opener", + field="x.com.samsung.da.ado.devicecontrol", + icon="mdi:door-open", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=_status_lock_write("x.com.samsung.da.ado.devicecontrol"), + ), + SwitchDesc( + key="fridge_sound", + field="x.com.samsung.da.device.sound", + icon="mdi:volume-high", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=_status_lock_write("x.com.samsung.da.device.sound"), + ), ), ) @@ -266,16 +317,20 @@ STATUS_LOCK = Capability( # --------------------------------------------------------------------------- DEFROST_DELAY = Capability( - href='/defrost/delay/vs/0', - poll_tier='warm', + href="/defrost/delay/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='defrost_delay', field='x.com.samsung.da.delayDefrost', - icon='mdi:snowflake-off', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['defrost', 'delay', 'vs', '0'], - {'x.com.samsung.da.delayDefrost': 'On' if p == 'On' else 'Off'})), + SwitchDesc( + key="defrost_delay", + field="x.com.samsung.da.delayDefrost", + icon="mdi:snowflake-off", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["defrost", "delay", "vs", "0"], + {"x.com.samsung.da.delayDefrost": "On" if p == "On" else "Off"}, + ), + ), ), ) @@ -284,17 +339,20 @@ DEFROST_DELAY = Capability( # above has a confirmed write contract. Bind the native mirror without another # entity so discovery records it as an intentional duplicate. DEFROST_DELAY_NATIVE_DUPLICATE = Capability( - href='/defrost/delay/0', + href="/defrost/delay/0", ) DEFROST_BLOCK_STATUS = Capability( - href='/defrost/block/vs/0', - poll_tier='warm', + href="/defrost/block/vs/0", + poll_tier="warm", entities=( - BinarySensorDesc(key='defrost_active', field='x.com.samsung.da.modes', - icon='mdi:snowflake-melt', - entity_category='diagnostic', - value_fn=lambda modes: bool(modes) and modes[0] == 'DEFROST_BLOCK_ON'), + BinarySensorDesc( + key="defrost_active", + field="x.com.samsung.da.modes", + icon="mdi:snowflake-melt", + entity_category="diagnostic", + value_fn=lambda modes: bool(modes) and modes[0] == "DEFROST_BLOCK_ON", + ), ), ) @@ -302,28 +360,36 @@ DEFROST_BLOCK_STATUS = Capability( # Refrigeration modes (rapid cooling) # --------------------------------------------------------------------------- + def _refrigeration_write(field_name): def _write(p, rep, href=None): - if p not in ('On', 'Off'): + if p not in ("On", "Off"): return None - return ['refrigeration', 'vs', '0'], {field_name: p} + return ["refrigeration", "vs", "0"], {field_name: p} + return _write REFRIGERATION = Capability( - href='/refrigeration/vs/0', - poll_tier='warm', + href="/refrigeration/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='rapid_fridge', field='x.com.samsung.da.rapidFridge', - icon='mdi:fridge-industrial', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=_refrigeration_write('x.com.samsung.da.rapidFridge')), - SwitchDesc(key='rapid_freezing', field='x.com.samsung.da.rapidFreezing', - icon='mdi:snowflake', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=_refrigeration_write('x.com.samsung.da.rapidFreezing')), + SwitchDesc( + key="rapid_fridge", + field="x.com.samsung.da.rapidFridge", + icon="mdi:fridge-industrial", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=_refrigeration_write("x.com.samsung.da.rapidFridge"), + ), + SwitchDesc( + key="rapid_freezing", + field="x.com.samsung.da.rapidFreezing", + icon="mdi:snowflake", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=_refrigeration_write("x.com.samsung.da.rapidFreezing"), + ), ), ) @@ -331,21 +397,25 @@ REFRIGERATION = Capability( # Autofill # --------------------------------------------------------------------------- + def _autofill_write(p, rep, href=None): - if p not in ('On', 'Off'): + if p not in ("On", "Off"): return None - return ['autofill', 'vs', '0'], {'x.com.samsung.da.autofill': p} + return ["autofill", "vs", "0"], {"x.com.samsung.da.autofill": p} AUTOFILL = Capability( - href='/autofill/vs/0', - poll_tier='warm', + href="/autofill/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='autofill', field='x.com.samsung.da.autofill', - icon='mdi:cup-water', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=_autofill_write), + SwitchDesc( + key="autofill", + field="x.com.samsung.da.autofill", + icon="mdi:cup-water", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=_autofill_write, + ), ), ) @@ -354,16 +424,20 @@ AUTOFILL = Capability( # --------------------------------------------------------------------------- WELCOME_LIGHTING = Capability( - href='/proximity/vs/0', - poll_tier='warm', + href="/proximity/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='welcome_lighting', field='status', - icon='mdi:motion-sensor', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['proximity', 'vs', '0'], - {'status': 'On' if p == 'On' else 'Off'})), + SwitchDesc( + key="welcome_lighting", + field="status", + icon="mdi:motion-sensor", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["proximity", "vs", "0"], + {"status": "On" if p == "On" else "Off"}, + ), + ), ), ) @@ -376,20 +450,20 @@ WELCOME_LIGHTING = Capability( # other: writing start preserves duration; writing end recalculates duration. # --------------------------------------------------------------------------- -_NIGHT_BRIGHTNESS_OPTIONS = ('33', '66', '100') +_NIGHT_BRIGHTNESS_OPTIONS = ("33", "66", "100") def _tz_offset(rep) -> datetime.timedelta: - s = rep.get('timezone.offset', '+00:00') + s = rep.get("timezone.offset", "+00:00") try: - sign = 1 if s[0] == '+' else -1 - h, m = map(int, s[1:].split(':')) + sign = 1 if s[0] == "+" else -1 + h, m = map(int, s[1:].split(":")) return datetime.timedelta(hours=h, minutes=m) * sign except (ValueError, TypeError, IndexError): return datetime.timedelta(0) -def _parse_night_time(iso_str, offset=datetime.timedelta(0)) -> 'datetime.time | None': +def _parse_night_time(iso_str, offset=datetime.timedelta(0)) -> "datetime.time | None": """Convert a UTC ISO datetime string to local time using offset.""" if not iso_str: return None @@ -399,81 +473,102 @@ def _parse_night_time(iso_str, offset=datetime.timedelta(0)) -> 'datetime.time | return None -def _night_start_value(rep) -> 'datetime.time | None': - return _parse_night_time(rep.get('night.starttime'), _tz_offset(rep)) +def _night_start_value(rep) -> "datetime.time | None": + return _parse_night_time(rep.get("night.starttime"), _tz_offset(rep)) -def _night_end_value(rep) -> 'datetime.time | None': - start_t = _parse_night_time(rep.get('night.starttime'), _tz_offset(rep)) - duration_str = rep.get('night.duration.minute') +def _night_end_value(rep) -> "datetime.time | None": + start_t = _parse_night_time(rep.get("night.starttime"), _tz_offset(rep)) + duration_str = rep.get("night.duration.minute") if start_t is None or duration_str is None: return None try: duration = int(duration_str) except (ValueError, TypeError): return None - return (datetime.datetime.combine(datetime.date.today(), start_t) - + datetime.timedelta(minutes=duration)).time().replace(second=0) + return ( + ( + datetime.datetime.combine(datetime.date.today(), start_t) + + datetime.timedelta(minutes=duration) + ) + .time() + .replace(second=0) + ) def _night_start_write(p, rep, href=None): # p is local time; subtract offset to get UTC for storage offset = _tz_offset(rep) utc_dt = datetime.datetime.combine(datetime.date.today(), p) - offset - old_iso = rep.get('night.starttime') or '' + old_iso = rep.get("night.starttime") or "" try: new_dt = datetime.datetime.fromisoformat(old_iso).replace( - hour=utc_dt.hour, minute=utc_dt.minute, second=0) + hour=utc_dt.hour, minute=utc_dt.minute, second=0 + ) except (ValueError, TypeError): new_dt = utc_dt - return ['cabinet', 'light', 'enhanced', 'vs', '0'], { - 'night.starttime': new_dt.strftime('%Y-%m-%dT%H:%M:%S'), + return ["cabinet", "light", "enhanced", "vs", "0"], { + "night.starttime": new_dt.strftime("%Y-%m-%dT%H:%M:%S"), } def _night_end_write(p, rep, href=None): # duration = end_local - start_local; offset cancels so work in local time - start_t = _parse_night_time(rep.get('night.starttime'), _tz_offset(rep)) + start_t = _parse_night_time(rep.get("night.starttime"), _tz_offset(rep)) if start_t is None: return None start_min = start_t.hour * 60 + start_t.minute end_min = p.hour * 60 + p.minute duration = (end_min - start_min) % (24 * 60) - return ['cabinet', 'light', 'enhanced', 'vs', '0'], { - 'night.duration.minute': str(duration), + return ["cabinet", "light", "enhanced", "vs", "0"], { + "night.duration.minute": str(duration), } CABINET_LIGHT_ENHANCED = Capability( - href='/cabinet/light/enhanced/vs/0', - poll_tier='warm', + href="/cabinet/light/enhanced/vs/0", + poll_tier="warm", entities=( - SelectDesc(key='day_brightness', field='level.brightness.daytime', - icon='mdi:brightness-5', - translation_key='day_brightness', - entity_category='config', - options=_NIGHT_BRIGHTNESS_OPTIONS, - write_fn=lambda p, rep, href=None: ( - ['cabinet', 'light', 'enhanced', 'vs', '0'], - {'level.brightness.daytime': p})), - SelectDesc(key='brightness_level', field='level.brightness.nighttime', - icon='mdi:brightness-4', - translation_key='brightness_level', - entity_category='config', - options=_NIGHT_BRIGHTNESS_OPTIONS, - write_fn=lambda p, rep, href=None: ( - ['cabinet', 'light', 'enhanced', 'vs', '0'], - {'level.brightness.nighttime': p})), - TimeDesc(key='night_start', field='', - icon='mdi:clock-start', - entity_category='config', - rep_fn=_night_start_value, - write_fn=_night_start_write), - TimeDesc(key='night_end', field='', - icon='mdi:clock-end', - entity_category='config', - rep_fn=_night_end_value, - write_fn=_night_end_write), + SelectDesc( + key="day_brightness", + field="level.brightness.daytime", + icon="mdi:brightness-5", + translation_key="day_brightness", + entity_category="config", + options=_NIGHT_BRIGHTNESS_OPTIONS, + write_fn=lambda p, rep, href=None: ( + ["cabinet", "light", "enhanced", "vs", "0"], + {"level.brightness.daytime": p}, + ), + ), + SelectDesc( + key="brightness_level", + field="level.brightness.nighttime", + icon="mdi:brightness-4", + translation_key="brightness_level", + entity_category="config", + options=_NIGHT_BRIGHTNESS_OPTIONS, + write_fn=lambda p, rep, href=None: ( + ["cabinet", "light", "enhanced", "vs", "0"], + {"level.brightness.nighttime": p}, + ), + ), + TimeDesc( + key="night_start", + field="", + icon="mdi:clock-start", + entity_category="config", + rep_fn=_night_start_value, + write_fn=_night_start_write, + ), + TimeDesc( + key="night_end", + field="", + icon="mdi:clock-end", + entity_category="config", + rep_fn=_night_end_value, + write_fn=_night_end_write, + ), ), ) @@ -481,29 +576,37 @@ CABINET_LIGHT_ENHANCED = Capability( # Cabinet light # --------------------------------------------------------------------------- + def _cabinet_light_write(p, rep, href=None): - if p not in ('On', 'Off'): + if p not in ("On", "Off"): return None - return ['cabinet', 'light', 'total', 'vs', '0'], { - 'x.com.samsung.da.lightControl': p, + return ["cabinet", "light", "total", "vs", "0"], { + "x.com.samsung.da.lightControl": p, } CABINET_LIGHT = Capability( - href='/cabinet/light/total/vs/0', - poll_tier='warm', + href="/cabinet/light/total/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='cabinet_light_switch', field='x.com.samsung.da.lightControl', - icon='mdi:fridge-outline', - value_fn=lambda v: v == 'On', - write_fn=_cabinet_light_write), - SwitchDesc(key='cabinet_light_dim', field='light.dimming.status', - icon='mdi:brightness-auto', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['cabinet', 'light', 'total', 'vs', '0'], - {'light.dimming.status': 'On' if p == 'On' else 'Off'})), + SwitchDesc( + key="cabinet_light_switch", + field="x.com.samsung.da.lightControl", + icon="mdi:fridge-outline", + value_fn=lambda v: v == "On", + write_fn=_cabinet_light_write, + ), + SwitchDesc( + key="cabinet_light_dim", + field="light.dimming.status", + icon="mdi:brightness-auto", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["cabinet", "light", "total", "vs", "0"], + {"light.dimming.status": "On" if p == "On" else "Off"}, + ), + ), ), ) @@ -511,21 +614,25 @@ CABINET_LIGHT = Capability( # Sabbath mode # --------------------------------------------------------------------------- + def _sabbath_write(p, rep, href=None): - if p not in ('On', 'Off'): + if p not in ("On", "Off"): return None - return ['sabbath', 'vs', '0'], {'x.com.samsung.da.sabbathMode': p} + return ["sabbath", "vs", "0"], {"x.com.samsung.da.sabbathMode": p} SABBATH = Capability( - href='/sabbath/vs/0', - poll_tier='warm', + href="/sabbath/vs/0", + poll_tier="warm", entities=( - SwitchDesc(key='sabbath_mode', field='x.com.samsung.da.sabbathMode', - icon='mdi:hands-pray', - entity_category='config', - value_fn=lambda v: v == 'On', - write_fn=_sabbath_write), + SwitchDesc( + key="sabbath_mode", + field="x.com.samsung.da.sabbathMode", + icon="mdi:hands-pray", + entity_category="config", + value_fn=lambda v: v == "On", + write_fn=_sabbath_write, + ), ), ) @@ -533,20 +640,24 @@ SABBATH = Capability( # Beverage zone # --------------------------------------------------------------------------- + def _bzone_write(p, rep, href=None): - return ['specialzone', 'one', 'vs', '0'], {'roomDesiredMode': p} + return ["specialzone", "one", "vs", "0"], {"roomDesiredMode": p} BEVERAGE_ZONE = Capability( - href='/specialzone/one/vs/0', - poll_tier='warm', + href="/specialzone/one/vs/0", + poll_tier="warm", entities=( - SelectDesc(key='beverage_zone_mode', field='roomDesiredMode', - icon='mdi:glass-wine', - translation_key='beverage_zone_mode', - entity_category='config', - options_field='roomSupportedModes', - write_fn=_bzone_write), + SelectDesc( + key="beverage_zone_mode", + field="roomDesiredMode", + icon="mdi:glass-wine", + translation_key="beverage_zone_mode", + entity_category="config", + options_field="roomSupportedModes", + write_fn=_bzone_write, + ), ), ) @@ -561,20 +672,24 @@ BEVERAGE_ZONE = Capability( # instance turns up. # --------------------------------------------------------------------------- + def _pantry_write(p, rep, href=None): - return ['status', 'pantry', 'one', 'vs', '0'], {'x.com.samsung.da.mode': p} + return ["status", "pantry", "one", "vs", "0"], {"x.com.samsung.da.mode": p} PANTRY_ZONE = Capability( - href='/status/pantry/one/vs/0', - poll_tier='warm', + href="/status/pantry/one/vs/0", + poll_tier="warm", entities=( - SelectDesc(key='pantry_zone_mode', field='x.com.samsung.da.mode', - icon='mdi:glass-wine', - translation_key='pantry_zone_mode', - entity_category='config', - options_field='x.com.samsung.da.supportedOptions', - write_fn=_pantry_write), + SelectDesc( + key="pantry_zone_mode", + field="x.com.samsung.da.mode", + icon="mdi:glass-wine", + translation_key="pantry_zone_mode", + entity_category="config", + options_field="x.com.samsung.da.supportedOptions", + write_fn=_pantry_write, + ), ), ) @@ -591,8 +706,9 @@ PANTRY_ZONE = Capability( # Write replaces only that item; other flags are preserved. # --------------------------------------------------------------------------- + def _flex_zone_supported(rep): - return set(rep.get('x.com.samsung.da.supportedOptions') or ()) + return set(rep.get("x.com.samsung.da.supportedOptions") or ()) def _flex_zone_current(rep): @@ -600,40 +716,42 @@ def _flex_zone_current(rep): # "first match" and "strip all matches" (in the write below) agree. If a # future device ever reports two, this reads the first and the write # would drop both -- revisit if that turns up. - modes = rep.get('x.com.samsung.da.modes') or [] + modes = rep.get("x.com.samsung.da.modes") or [] supported = _flex_zone_supported(rep) return next((m for m in modes if m in supported), None) def _flex_zone_write(p, rep, href=None): supported = _flex_zone_supported(rep) - modes = [m for m in (rep.get('x.com.samsung.da.modes') or []) if m not in supported] + modes = [m for m in (rep.get("x.com.samsung.da.modes") or []) if m not in supported] modes.append(p) - return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': modes} + return ["mode", "vs", "0"], {"x.com.samsung.da.modes": modes} FLEX_ZONE = Capability( - href='/mode/vs/0', - poll_tier='warm', + href="/mode/vs/0", + poll_tier="warm", entities=( - SelectDesc(key='flex_zone_mode', - icon='mdi:thermostat', - translation_key='flex_zone_mode', - entity_category='config', - options_field='x.com.samsung.da.supportedOptions', - # A nonempty supportedOptions alone isn't sufficient: the - # kimchi-refrigerator family (issue #26) also populates - # /mode/vs/0's modes/supportedOptions with real data, but - # its tokens carry a "_[n]:[n]" parameter suffix on - # supportedOptions that modes never repeats, so no item - # ever overlaps -- the RF9000/Bespoke-class overlap this - # capability was built for never happens there. Require an - # actual resolvable value instead of just a populated - # list, so this stays absent on that family rather than - # showing a select permanently stuck on "unknown". - exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None, - rep_fn=_flex_zone_current, - write_fn=_flex_zone_write), + SelectDesc( + key="flex_zone_mode", + icon="mdi:thermostat", + translation_key="flex_zone_mode", + entity_category="config", + options_field="x.com.samsung.da.supportedOptions", + # A nonempty supportedOptions alone isn't sufficient: the + # kimchi-refrigerator family (issue #26) also populates + # /mode/vs/0's modes/supportedOptions with real data, but + # its tokens carry a "_[n]:[n]" parameter suffix on + # supportedOptions that modes never repeats, so no item + # ever overlaps -- the RF9000/Bespoke-class overlap this + # capability was built for never happens there. Require an + # actual resolvable value instead of just a populated + # list, so this stays absent on that family rather than + # showing a select permanently stuck on "unknown". + exists_fn=lambda rep, resources: _flex_zone_current(rep) is not None, + rep_fn=_flex_zone_current, + write_fn=_flex_zone_write, + ), ), ) @@ -641,6 +759,7 @@ FLEX_ZONE = Capability( # Generic door pattern capability (href=None — use as pattern_cap only) # --------------------------------------------------------------------------- + def _door_open_state(rep): """Most /door/* resources report bare `openState`, but the ARTIK051_DONGLE_REF family's /door/onedoorfreezer/vs/0 (issues #77, #83) @@ -648,20 +767,24 @@ def _door_open_state(rep): capability still binds either way (href_prefix match doesn't care about field names), but a plain `field=` lookup against the wrong key means the entity exists and is permanently unavailable -- check both.""" - v = rep.get('openState') + v = rep.get("openState") if v is None: - v = rep.get('x.com.samsung.da.openState') - return v == 'Open' + v = rep.get("x.com.samsung.da.openState") + return v == "Open" DOOR_GENERIC = Capability( href=None, - href_prefix='/door/', - poll_tier='hot', + href_prefix="/door/", + poll_tier="hot", entities=( - BinarySensorDesc(key='open', rep_fn=_door_open_state, - translation_key='instance_open', - use_instance_name=True, device_class='door'), + BinarySensorDesc( + key="open", + rep_fn=_door_open_state, + translation_key="instance_open", + use_instance_name=True, + device_class="door", + ), ), ) @@ -705,51 +828,69 @@ DOOR_GENERIC = Capability( # abbreviation guesses. # --------------------------------------------------------------------------- + def _kimchi_mode_write(p, rep, href=None): - if not href or p not in (rep.get('x.com.samsung.da.supportMode') or ()): + if not href or p not in (rep.get("x.com.samsung.da.supportMode") or ()): return None - return [s for s in href.strip('/').split('/') if s], { - 'x.com.samsung.da.currentMode': p, + return [s for s in href.strip("/").split("/") if s], { + "x.com.samsung.da.currentMode": p, } KIMCHI_ZONE = Capability( href=None, - href_prefix='/status/kimchi/', + href_prefix="/status/kimchi/", strip_prefix_in_key=True, - poll_tier='warm', + poll_tier="warm", entities=( - SelectDesc(key='mode', field='x.com.samsung.da.currentMode', - use_instance_name=True, icon='mdi:fridge-outline', - translation_key='kimchi_zone_mode', - entity_category='config', - options_field='x.com.samsung.da.supportMode', - write_fn=_kimchi_mode_write), - SensorDesc(key='ripening_status', field='x.com.samsung.da.ripeStatus', - use_instance_name=True, icon='mdi:progress-clock', - translation_key='kimchi_ripening_status', - entity_category='diagnostic'), - SensorDesc(key='ripening_remaining', field='x.com.samsung.da.ripeRemaintime', - use_instance_name=True, icon='mdi:timer-sand', - translation_key='kimchi_ripening_remaining', - entity_category='diagnostic', - # No dump has this nonzero (ripeStatus is always "Off" so - # far) -- device-reported unit unconfirmed, so this stays - # a bare number rather than asserting minutes or hours. - value_fn=_int), - SensorDesc(key='rack_count', field='x.com.samsung.da.rackCount', - use_instance_name=True, icon='mdi:tray-full', - translation_key='kimchi_rack_count', - entity_category='diagnostic', enabled_default=False, - value_fn=_int), + SelectDesc( + key="mode", + field="x.com.samsung.da.currentMode", + use_instance_name=True, + icon="mdi:fridge-outline", + translation_key="kimchi_zone_mode", + entity_category="config", + options_field="x.com.samsung.da.supportMode", + write_fn=_kimchi_mode_write, + ), + SensorDesc( + key="ripening_status", + field="x.com.samsung.da.ripeStatus", + use_instance_name=True, + icon="mdi:progress-clock", + translation_key="kimchi_ripening_status", + entity_category="diagnostic", + ), + SensorDesc( + key="ripening_remaining", + field="x.com.samsung.da.ripeRemaintime", + use_instance_name=True, + icon="mdi:timer-sand", + translation_key="kimchi_ripening_remaining", + entity_category="diagnostic", + # No dump has this nonzero (ripeStatus is always "Off" so + # far) -- device-reported unit unconfirmed, so this stays + # a bare number rather than asserting minutes or hours. + value_fn=_int, + ), + SensorDesc( + key="rack_count", + field="x.com.samsung.da.rackCount", + use_instance_name=True, + icon="mdi:tray-full", + translation_key="kimchi_rack_count", + entity_category="diagnostic", + enabled_default=False, + value_fn=_int, + ), ), ) KIMCHI_DOOR_GENERIC = Capability( href=None, - href_prefix='/kimchidoors/', + href_prefix="/kimchidoors/", strip_prefix_in_key=True, - poll_tier='hot', + poll_tier="hot", entities=( # Not deduped against DOORS_FALLBACK below: on the one reporter # (refrigerator_tp2x_ref_20k_kimchi) this binds alongside, the @@ -758,9 +899,13 @@ KIMCHI_DOOR_GENERIC = Capability( # that doesn't share this compartment's "top" instance numbering -- # a distinct main-cabinet door, not this kimchi drawer's own contact # switch reported twice. - BinarySensorDesc(key='open', rep_fn=_door_open_state, - translation_key='instance_open', - use_instance_name=True, device_class='door'), + BinarySensorDesc( + key="open", + rep_fn=_door_open_state, + translation_key="instance_open", + use_instance_name=True, + device_class="door", + ), ), ) @@ -778,82 +923,97 @@ KIMCHI_DOOR_GENERIC = Capability( # where they don't. # --------------------------------------------------------------------------- + def _any_door_generic(resources): - return any(h.startswith('/door/') for h in resources) + return any(h.startswith("/door/") for h in resources) DOORS_FALLBACK = Capability( - href='/doors/vs/0', + href="/doors/vs/0", match_fn=lambda rep, resources: not _any_door_generic(resources), - poll_tier='hot', + poll_tier="hot", entities=( - BinarySensorDesc(key='door_open', field='x.com.samsung.da.items', - device_class='door', - value_fn=lambda items: any( - i.get('x.com.samsung.da.openState') == 'Open' - for i in (items or []))), + BinarySensorDesc( + key="door_open", + field="x.com.samsung.da.items", + device_class="door", + value_fn=lambda items: any( + i.get("x.com.samsung.da.openState") == "Open" for i in (items or []) + ), + ), ), ) def _any_temperature_generic(resources): - return any(h.startswith('/temperature/') for h in resources) + return any(h.startswith("/temperature/") for h in resources) def _temp_item_value(items, keyword): - for item in (items or []): - if keyword.lower() in (item.get('x.com.samsung.da.description') or '').lower(): - return _int(item.get('x.com.samsung.da.current')) + for item in items or []: + if keyword.lower() in (item.get("x.com.samsung.da.description") or "").lower(): + return _int(item.get("x.com.samsung.da.current")) return None def _temp_item_unit(items, keyword): - for item in (items or []): - if keyword.lower() in (item.get('x.com.samsung.da.description') or '').lower(): - return normalize_temp_unit(item.get('x.com.samsung.da.unit')) - return '°F' + for item in items or []: + if keyword.lower() in (item.get("x.com.samsung.da.description") or "").lower(): + return normalize_temp_unit(item.get("x.com.samsung.da.unit")) + return "°F" TEMPERATURES_FALLBACK = Capability( - href='/temperatures/vs/0', + href="/temperatures/vs/0", match_fn=lambda rep, resources: not _any_temperature_generic(resources), - poll_tier='warm', + poll_tier="warm", entities=( - SensorDesc(key='freezer_temperature', field='x.com.samsung.da.items', - icon='mdi:thermometer', - device_class='temperature', state_class='measurement', - unit_fn=lambda rep: _temp_item_unit( - rep.get('x.com.samsung.da.items'), 'Freezer'), - value_fn=lambda items: _temp_item_value(items, 'Freezer')), - SensorDesc(key='fridge_temperature', field='x.com.samsung.da.items', - icon='mdi:thermometer', - device_class='temperature', state_class='measurement', - unit_fn=lambda rep: _temp_item_unit( - rep.get('x.com.samsung.da.items'), 'Fridge'), - value_fn=lambda items: _temp_item_value(items, 'Fridge')), + SensorDesc( + key="freezer_temperature", + field="x.com.samsung.da.items", + icon="mdi:thermometer", + device_class="temperature", + state_class="measurement", + unit_fn=lambda rep: _temp_item_unit(rep.get("x.com.samsung.da.items"), "Freezer"), + value_fn=lambda items: _temp_item_value(items, "Freezer"), + ), + SensorDesc( + key="fridge_temperature", + field="x.com.samsung.da.items", + icon="mdi:thermometer", + device_class="temperature", + state_class="measurement", + unit_fn=lambda rep: _temp_item_unit(rep.get("x.com.samsung.da.items"), "Fridge"), + value_fn=lambda items: _temp_item_value(items, "Fridge"), + ), ), ) def _any_icemaker_unit_generic(resources): return any( - h.startswith('/icemaker/') and isinstance(r, dict) - and 'x.com.samsung.da.iceMaker.state' in r + h.startswith("/icemaker/") + and isinstance(r, dict) + and "x.com.samsung.da.iceMaker.state" in r for h, r in resources.items() ) ICEMAKER_STATUS_FALLBACK = Capability( - href='/icemaker/status/vs/0', + href="/icemaker/status/vs/0", match_fn=lambda rep, resources: not _any_icemaker_unit_generic(resources), - poll_tier='warm', + poll_tier="warm", entities=( - SwitchDesc(key='ice_maker_enabled', field='x.com.samsung.da.iceMaker', - icon='mdi:cube-outline', - value_fn=lambda v: v == 'On', - write_fn=lambda p, rep, href=None: ( - ['icemaker', 'status', 'vs', '0'], - {'x.com.samsung.da.iceMaker': 'On' if p == 'On' else 'Off'})), + SwitchDesc( + key="ice_maker_enabled", + field="x.com.samsung.da.iceMaker", + icon="mdi:cube-outline", + value_fn=lambda v: v == "On", + write_fn=lambda p, rep, href=None: ( + ["icemaker", "status", "vs", "0"], + {"x.com.samsung.da.iceMaker": "On" if p == "On" else "Off"}, + ), + ), ), ) @@ -862,7 +1022,7 @@ ICEMAKER_STATUS_FALLBACK = Capability( # /icemaker/one|two/vs/0 resources. Its write contract is not advertised, so # keep the proven per-unit/vendor controls and bind this as a duplicate only. ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability( - href='/icemaker/status/0', + href="/icemaker/status/0", ) # OCF-native /refrigeration/0 (issue #7's unbound_hrefs) -- the odd one out @@ -875,23 +1035,32 @@ ICEMAKER_STATUS_NATIVE_DUPLICATE = Capability( # DEFROST_BLOCK_STATUS's is. No write path confirmed for this href, so # these are read-only, unlike REFRIGERATION's switches. REFRIGERATION_FALLBACK = Capability( - href='/refrigeration/0', - poll_tier='warm', + href="/refrigeration/0", + poll_tier="warm", entities=( - BinarySensorDesc(key='defrost_active', field='defrost', - icon='mdi:snowflake-melt', - entity_category='diagnostic', - value_fn=lambda v: bool(v), - exists_fn=lambda rep, resources: '/defrost/block/vs/0' not in resources), - BinarySensorDesc(key='rapid_fridge', field='rapidCool', - icon='mdi:fridge-industrial', - entity_category='config', - value_fn=lambda v: bool(v), - exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources), - BinarySensorDesc(key='rapid_freezing', field='rapidFreeze', - icon='mdi:snowflake', - entity_category='config', - value_fn=lambda v: bool(v), - exists_fn=lambda rep, resources: '/refrigeration/vs/0' not in resources), + BinarySensorDesc( + key="defrost_active", + field="defrost", + icon="mdi:snowflake-melt", + entity_category="diagnostic", + value_fn=lambda v: bool(v), + exists_fn=lambda rep, resources: "/defrost/block/vs/0" not in resources, + ), + BinarySensorDesc( + key="rapid_fridge", + field="rapidCool", + icon="mdi:fridge-industrial", + entity_category="config", + value_fn=lambda v: bool(v), + exists_fn=lambda rep, resources: "/refrigeration/vs/0" not in resources, + ), + BinarySensorDesc( + key="rapid_freezing", + field="rapidFreeze", + icon="mdi:snowflake", + entity_category="config", + value_fn=lambda v: bool(v), + exists_fn=lambda rep, resources: "/refrigeration/vs/0" not in resources, + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index f931798..8683cfb 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -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_"). - 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_"). - 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"), ] diff --git a/custom_components/localthings/registry/capabilities/laundry.py b/custom_components/localthings/registry/capabilities/laundry.py index 9bf4ab4..802e371 100644 --- a/custom_components/localthings/registry/capabilities/laundry.py +++ b/custom_components/localthings/registry/capabilities/laundry.py @@ -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 `_` in the options array and return .""" - 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 '_On'/'_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", + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/microwave.py b/custom_components/localthings/registry/capabilities/microwave.py index 6910d9f..ebac9db 100644 --- a/custom_components/localthings/registry/capabilities/microwave.py +++ b/custom_components/localthings/registry/capabilities/microwave.py @@ -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, + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/operational.py b/custom_components/localthings/registry/capabilities/operational.py index dcd2039..7859887 100644 --- a/custom_components/localthings/registry/capabilities/operational.py +++ b/custom_components/localthings/registry/capabilities/operational.py @@ -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, ), ) diff --git a/custom_components/localthings/registry/capabilities/oven.py b/custom_components/localthings/registry/capabilities/oven.py index 252416f..77da512 100644 --- a/custom_components/localthings/registry/capabilities/oven.py +++ b/custom_components/localthings/registry/capabilities/oven.py @@ -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 `_` in an options array and return .""" - 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"), + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/range.py b/custom_components/localthings/registry/capabilities/range.py index ea77f06..e8f11bd 100644 --- a/custom_components/localthings/registry/capabilities/range.py +++ b/custom_components/localthings/registry/capabilities/range.py @@ -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", + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/range_hood.py b/custom_components/localthings/registry/capabilities/range_hood.py index be0414a..6277859 100644 --- a/custom_components/localthings/registry/capabilities/range_hood.py +++ b/custom_components/localthings/registry/capabilities/range_hood.py @@ -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", ) ] diff --git a/custom_components/localthings/registry/capabilities/vacuum_station.py b/custom_components/localthings/registry/capabilities/vacuum_station.py index 090ed88..a75e56e 100644 --- a/custom_components/localthings/registry/capabilities/vacuum_station.py +++ b/custom_components/localthings/registry/capabilities/vacuum_station.py @@ -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, + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/washer.py b/custom_components/localthings/registry/capabilities/washer.py index b1dded1..ef41fe4 100644 --- a/custom_components/localthings/registry/capabilities/washer.py +++ b/custom_components/localthings/registry/capabilities/washer.py @@ -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" + ), ), ) diff --git a/custom_components/localthings/registry/capabilities/water_purifier.py b/custom_components/localthings/registry/capabilities/water_purifier.py index b04e2e9..9b42581 100644 --- a/custom_components/localthings/registry/capabilities/water_purifier.py +++ b/custom_components/localthings/registry/capabilities/water_purifier.py @@ -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] diff --git a/custom_components/localthings/registry/capability.py b/custom_components/localthings/registry/capability.py index 2c6b2d3..ac8128b 100644 --- a/custom_components/localthings/registry/capability.py +++ b/custom_components/localthings/registry/capability.py @@ -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 diff --git a/custom_components/localthings/registry/discovery.py b/custom_components/localthings/registry/discovery.py index 3ba4555..90e96d0 100644 --- a/custom_components/localthings/registry/discovery.py +++ b/custom_components/localthings/registry/discovery.py @@ -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 '_' 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) diff --git a/custom_components/localthings/registry/entities.py b/custom_components/localthings/registry/entities.py index b086fe1..cc6e983 100644 --- a/custom_components/localthings/registry/entities.py +++ b/custom_components/localthings/registry/entities.py @@ -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", } diff --git a/custom_components/localthings/registry/identity.py b/custom_components/localthings/registry/identity.py index 32b0ba6..0dcf47f 100644 --- a/custom_components/localthings/registry/identity.py +++ b/custom_components/localthings/registry/identity.py @@ -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}, ) diff --git a/custom_components/localthings/registry/redact.py b/custom_components/localthings/registry/redact.py index 74ed17d..9b09a8a 100644 --- a/custom_components/localthings/registry/redact.py +++ b/custom_components/localthings/registry/redact.py @@ -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: diff --git a/custom_components/localthings/registry/registry.py b/custom_components/localthings/registry/registry.py index 74488b0..cd0a8a0 100644 --- a/custom_components/localthings/registry/registry.py +++ b/custom_components/localthings/registry/registry.py @@ -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); " diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index 28ed369..02fec48 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -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/). -_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/ 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/ 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/ 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 diff --git a/custom_components/localthings/select.py b/custom_components/localthings/select.py index da56992..37338e2 100644 --- a/custom_components/localthings/select.py +++ b/custom_components/localthings/select.py @@ -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 diff --git a/custom_components/localthings/sensor.py b/custom_components/localthings/sensor.py index 00bad2c..4d98c33 100644 --- a/custom_components/localthings/sensor.py +++ b/custom_components/localthings/sensor.py @@ -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) diff --git a/custom_components/localthings/switch.py b/custom_components/localthings/switch.py index 1961fbb..ce342e6 100644 --- a/custom_components/localthings/switch.py +++ b/custom_components/localthings/switch.py @@ -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") diff --git a/custom_components/localthings/time.py b/custom_components/localthings/time.py index dc5be85..e32c356 100644 --- a/custom_components/localthings/time.py +++ b/custom_components/localthings/time.py @@ -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) diff --git a/custom_components/localthings/water_heater.py b/custom_components/localthings/water_heater.py index 4896ede..4880d73 100644 --- a/custom_components/localthings/water_heater.py +++ b/custom_components/localthings/water_heater.py @@ -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)) diff --git a/pyproject.toml b/pyproject.toml index 3d40eb1..309ed09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements-dev.txt b/requirements-dev.txt index f47c26c..0d19172 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 70a937e..b0054f4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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")) } diff --git a/tests/localthings/conftest.py b/tests/localthings/conftest.py index cb72715..885462b 100644 --- a/tests/localthings/conftest.py +++ b/tests/localthings/conftest.py @@ -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 diff --git a/tests/localthings/test_config_flow.py b/tests/localthings/test_config_flow.py index 492614d..f880b6b 100644 --- a/tests/localthings/test_config_flow.py +++ b/tests/localthings/test_config_flow.py @@ -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 diff --git a/tests/localthings/test_coordinator.py b/tests/localthings/test_coordinator.py index 3b89d5e..5eb6b8e 100644 --- a/tests/localthings/test_coordinator.py +++ b/tests/localthings/test_coordinator.py @@ -1,4 +1,5 @@ """Tests for the LocalThingsCoordinator.""" + from __future__ import annotations from datetime import timedelta @@ -11,17 +12,22 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import issue_registry as ir from custom_components.localthings.const import ( - CONF_BYPASS_REMOTE_CONTROL, CONF_HOST, DOMAIN, DTLS_LOCAL_PORT_BASE, + CONF_BYPASS_REMOTE_CONTROL, + CONF_HOST, + DOMAIN, + DTLS_LOCAL_PORT_BASE, SUMMARY_INTERVAL_S, ) from custom_components.localthings.coordinator import ( - LocalThingsCoordinator, _is_placeholder_serial, _local_source_port, + LocalThingsCoordinator, + _is_placeholder_serial, + _local_source_port, ) +from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S from custom_components.localthings.registry.capabilities.common import ( remote_control_enabled, remote_control_required_for_write, ) -from custom_components.localthings.observe import MODE_OBSERVE, MODE_POLL, PUSH_HEALTH_WINDOW_S from .conftest import ENTRY_DATA, MOCK_SERIAL @@ -47,15 +53,10 @@ async def test_device_info_populated( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] assert coordinator.device_info is not None - assert any( - DOMAIN in str(i) - for i in coordinator.device_info.get('identifiers', set()) - ) + assert any(DOMAIN in str(i) for i in coordinator.device_info.get("identifiers", set())) -async def test_summary_interval( - hass: HomeAssistant, mock_entry, mock_coordinator_session -) -> None: +async def test_summary_interval(hass: HomeAssistant, mock_entry, mock_coordinator_session) -> None: """Summary poll interval is always SUMMARY_INTERVAL_S.""" await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() @@ -64,19 +65,16 @@ async def test_summary_interval( assert coordinator.update_interval == timedelta(seconds=SUMMARY_INTERVAL_S) -async def test_update_failed_on_persistent_poll_error( - hass: HomeAssistant, mock_entry -) -> None: +async def test_update_failed_on_persistent_poll_error(hass: HomeAssistant, mock_entry) -> None: """ConfigEntryNotReady raised when poll fails even after reconnect.""" - from homeassistant.exceptions import ConfigEntryNotReady 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', - side_effect=RuntimeError('connection lost'), + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", + side_effect=RuntimeError("connection lost"), ), - patch('custom_components.localthings.coordinator.LocalThingsCoordinator._close_session'), + patch("custom_components.localthings.coordinator.LocalThingsCoordinator._close_session"), ): result = await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() @@ -89,6 +87,7 @@ async def test_update_failed_on_persistent_poll_error( # Coverage-gap detection and the Repairs issue it raises # --------------------------------------------------------------------------- + async def test_discovery_populates_device_type_with_no_unbound_hrefs( hass: HomeAssistant, mock_entry, mock_coordinator_session ) -> None: @@ -103,8 +102,8 @@ async def test_discovery_populates_device_type_with_no_unbound_hrefs( await hass.async_block_till_done() coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] - assert coordinator.device_type_name == 'refrigerator' - assert coordinator.one_ui_version == '7.0 Refrigerator' + assert coordinator.device_type_name == "refrigerator" + assert coordinator.one_ui_version == "7.0 Refrigerator" assert coordinator._unbound_hrefs == [] @@ -119,25 +118,21 @@ async def test_coverage_gap_issue_absent_for_fully_covered_real_fixture( assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None -def test_run_discovery_detects_washer_via_model_fallback( - hass: HomeAssistant, mock_entry -) -> None: +def test_run_discovery_detects_washer_via_model_fallback(hass: HomeAssistant, mock_entry) -> None: """Washer hardware reports no oneUiVersion; device type must resolve via for_device_by_model instead of falling back to generic CAPABILITIES.""" resources = { - '/information/vs/0': { - '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', + "/information/vs/0": { + "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", }, - '/otninformation/vs/0': {'otnStatus': 'None'}, - '/power/vs/0': {'x.com.samsung.da.power': 'On'}, + "/otninformation/vs/0": {"otnStatus": "None"}, + "/power/vs/0": {"x.com.samsung.da.power": "On"}, } coordinator = LocalThingsCoordinator(hass, mock_entry) coordinator._run_discovery(resources) - assert coordinator.device_type_name == 'washer' + assert coordinator.device_type_name == "washer" def test_run_discovery_falls_back_to_host_for_placeholder_serial( @@ -150,13 +145,12 @@ def test_run_discovery_falls_back_to_host_for_placeholder_serial( second one's entities silently collide and get dropped. It must be treated the same as an empty serial and fall back to the host.""" resources = { - '/information/vs/0': { - 'x.com.samsung.da.modelNum': - 'ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000', - 'x.com.samsung.da.description': 'ARTIK_REF_17K', - 'x.com.samsung.da.serialNum': 'Nothing(SVC)', + "/information/vs/0": { + "x.com.samsung.da.modelNum": "ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000", # noqa: E501 + "x.com.samsung.da.description": "ARTIK_REF_17K", + "x.com.samsung.da.serialNum": "Nothing(SVC)", }, - '/otninformation/vs/0': {}, + "/otninformation/vs/0": {}, } coordinator = LocalThingsCoordinator(hass, mock_entry) coordinator._run_discovery(resources) @@ -173,14 +167,12 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial( 'FFFFFFFFFFFFFFF', so without this fallback they'd collide on device_serial exactly like the #83 case above.""" resources = { - '/information/vs/0': { - 'x.com.samsung.da.modelNum': - 'DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000', - 'x.com.samsung.da.description': - 'DA_WM_A51_20_COMMON_DVE50A8800/DC92-02835A_0080', - 'x.com.samsung.da.serialNum': 'FFFFFFFFFFFFFFF', + "/information/vs/0": { + "x.com.samsung.da.modelNum": "DA_WM_A51_20_COMMON|20221341|30010102001211000103000000000000", # noqa: E501 + "x.com.samsung.da.description": "DA_WM_A51_20_COMMON_DVE50A8800/DC92-02835A_0080", + "x.com.samsung.da.serialNum": "FFFFFFFFFFFFFFF", }, - '/otninformation/vs/0': {}, + "/otninformation/vs/0": {}, } coordinator = LocalThingsCoordinator(hass, mock_entry) coordinator._run_discovery(resources) @@ -188,17 +180,17 @@ def test_run_discovery_falls_back_to_host_for_all_f_placeholder_serial( def test_is_placeholder_serial_catches_all_same_hex_digit(): - 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 def test_is_placeholder_serial_accepts_real_serials_and_short_runs(): - 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 # 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 def test_run_discovery_detects_cooktop_via_resource_signature( @@ -208,21 +200,19 @@ def test_run_discovery_detects_cooktop_via_resource_signature( from tests.conftest import _load_device coordinator = LocalThingsCoordinator(hass, mock_entry) - coordinator._run_discovery(_load_device('cooktop')) + coordinator._run_discovery(_load_device("cooktop")) - assert coordinator.device_type_name == 'gas_cooktop' + assert coordinator.device_type_name == "gas_cooktop" assert coordinator._unbound_hrefs == [] -def test_run_discovery_detects_range_hood_via_model( - hass: HomeAssistant, mock_entry -) -> None: +def test_run_discovery_detects_range_hood_via_model(hass: HomeAssistant, mock_entry) -> None: from tests.conftest import _load_device coordinator = LocalThingsCoordinator(hass, mock_entry) - coordinator._run_discovery(_load_device('range_hood')) + coordinator._run_discovery(_load_device("range_hood")) - assert coordinator.device_type_name == 'range_hood' + assert coordinator.device_type_name == "range_hood" assert coordinator._unbound_hrefs == [] @@ -231,13 +221,13 @@ def test_update_coverage_gap_issue_creates_issue_for_unknown_type( ) -> None: coordinator = LocalThingsCoordinator(hass, mock_entry) - coordinator._update_coverage_gap_issue(True, [], 'Test Appliance') + coordinator._update_coverage_gap_issue(True, [], "Test Appliance") issue_id = f"device_gap_{mock_entry.entry_id}" issue = ir.async_get(hass).async_get_issue(DOMAIN, issue_id) assert issue is not None - assert issue.translation_key == 'device_gap' - assert issue.translation_placeholders == {'device_name': 'Test Appliance'} + assert issue.translation_key == "device_gap" + assert issue.translation_placeholders == {"device_name": "Test Appliance"} assert issue.is_fixable is False @@ -246,7 +236,7 @@ def test_update_coverage_gap_issue_creates_issue_for_unbound_hrefs( ) -> None: coordinator = LocalThingsCoordinator(hass, mock_entry) - coordinator._update_coverage_gap_issue(False, ['/mystery/vs/0'], 'Test Appliance') + coordinator._update_coverage_gap_issue(False, ["/mystery/vs/0"], "Test Appliance") issue_id = f"device_gap_{mock_entry.entry_id}" assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None @@ -257,23 +247,21 @@ def test_update_coverage_gap_issue_absent_when_fully_covered( ) -> None: coordinator = LocalThingsCoordinator(hass, mock_entry) - coordinator._update_coverage_gap_issue(False, [], 'Test Appliance') + coordinator._update_coverage_gap_issue(False, [], "Test Appliance") issue_id = f"device_gap_{mock_entry.entry_id}" assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None -def test_update_coverage_gap_issue_clears_previous_issue( - hass: HomeAssistant, mock_entry -) -> None: +def test_update_coverage_gap_issue_clears_previous_issue(hass: HomeAssistant, mock_entry) -> None: """A later discovery run with no gap deletes an issue raised earlier.""" coordinator = LocalThingsCoordinator(hass, mock_entry) issue_id = f"device_gap_{mock_entry.entry_id}" - coordinator._update_coverage_gap_issue(True, [], 'Test Appliance') + coordinator._update_coverage_gap_issue(True, [], "Test Appliance") assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None - coordinator._update_coverage_gap_issue(False, [], 'Test Appliance') + coordinator._update_coverage_gap_issue(False, [], "Test Appliance") assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None @@ -281,6 +269,7 @@ def test_update_coverage_gap_issue_clears_previous_issue( # StateCache / ObserveManager wiring # --------------------------------------------------------------------------- + async def test_last_resources_backed_by_state_cache( hass: HomeAssistant, mock_entry, mock_coordinator_session ) -> None: @@ -295,9 +284,7 @@ async def test_last_resources_backed_by_state_cache( assert resources # fridge fixture is non-empty -def test_logger_is_scoped_to_device_host( - hass: HomeAssistant, mock_entry -) -> None: +def test_logger_is_scoped_to_device_host(hass: HomeAssistant, mock_entry) -> None: """Every log line (coordinator's own, DataUpdateCoordinator's internal messages, and ObserveManager's) must identify which device it's about — a bare module-level logger is shared across every configured device and @@ -309,23 +296,21 @@ def test_logger_is_scoped_to_device_host( assert coordinator._observe.log is coordinator._log -def test_cache_changes_coalesce_into_a_single_push( - hass: HomeAssistant, mock_entry -) -> None: +def test_cache_changes_coalesce_into_a_single_push(hass: HomeAssistant, mock_entry) -> None: """A poll/sweep cycle applying many hrefs in a tight loop must not schedule one hass.add_job push per href — only one push per burst.""" coordinator = LocalThingsCoordinator(hass, mock_entry) - with patch.object(hass, 'add_job') as mock_add_job: - coordinator._on_cache_changed(True, 'poll') - coordinator._on_cache_changed(True, 'poll') - coordinator._on_cache_changed(True, 'poll') + with patch.object(hass, "add_job") as mock_add_job: + coordinator._on_cache_changed(True, "poll") + coordinator._on_cache_changed(True, "poll") + coordinator._on_cache_changed(True, "poll") assert mock_add_job.call_count == 1 coordinator._push_cache_snapshot() - coordinator._on_cache_changed(True, 'poll') + coordinator._on_cache_changed(True, "poll") assert mock_add_job.call_count == 2 @@ -359,10 +344,13 @@ async def test_enters_observe_mode_when_hot_warm_hrefs_notify( # synchronously from subscribe() for every href it subscribes to (see # FakeObserveSession.notify_on_subscribe in conftest.py), which lands # it in that window by construction rather than by timing. - fake.notify_on_subscribe = {'notified': True} + fake.notify_on_subscribe = {"notified": True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, - fake, hrefs, 0.02, 0.8, + fake, + hrefs, + 0.02, + 0.8, ) assert entered is True @@ -389,10 +377,13 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll( # Get the coordinator into observe mode the same way # test_enters_observe_mode_when_hot_warm_hrefs_notify does. - fake.notify_on_subscribe = {'notified': True} + fake.notify_on_subscribe = {"notified": True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, - fake, hrefs, 0.02, 0.8, + fake, + hrefs, + 0.02, + 0.8, ) assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -414,11 +405,11 @@ async def test_reconnect_while_observe_mode_downgrades_to_poll( fake.notify_on_subscribe = None with ( patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', - side_effect=[RuntimeError('connection lost'), fridge_resources], + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", + side_effect=[RuntimeError("connection lost"), fridge_resources], ), patch( - 'custom_components.localthings.coordinator.asyncio.sleep', + "custom_components.localthings.coordinator.asyncio.sleep", new=AsyncMock(), ), ): @@ -444,21 +435,24 @@ async def test_poll_timeout_skips_reconnect_when_push_is_healthy( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - fake.notify_on_subscribe = {'notified': True} + fake.notify_on_subscribe = {"notified": True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, - fake, hrefs, 0.02, 0.8, + fake, + hrefs, + 0.02, + 0.8, ) assert entered is True assert coordinator.observe_mode == MODE_OBSERVE with ( patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', - side_effect=TimeoutError('GET /device/0 block 11 timeout'), + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", + side_effect=TimeoutError("GET /device/0 block 11 timeout"), ), patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._close_session', + "custom_components.localthings.coordinator.LocalThingsCoordinator._close_session", ) as mock_close, ): await coordinator.async_request_refresh() @@ -483,10 +477,13 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - fake.notify_on_subscribe = {'notified': True} + fake.notify_on_subscribe = {"notified": True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, - fake, hrefs, 0.02, 0.8, + fake, + hrefs, + 0.02, + 0.8, ) assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -501,8 +498,8 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push( # otherwise coalesce these rapid-fire calls into far fewer than the # consecutive-timeout count this test needs to exercise. with patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', - side_effect=TimeoutError('GET /device/0 block 11 timeout'), + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", + side_effect=TimeoutError("GET /device/0 block 11 timeout"), ): for _ in range(coordinator._POLL_TIMEOUT_LIMIT - 1): await coordinator._async_update_data() @@ -515,11 +512,11 @@ async def test_poll_timeout_reconnects_after_consecutive_limit_even_with_push( fake.notify_on_subscribe = None with ( patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', - side_effect=[TimeoutError('GET /device/0 block 11 timeout'), fridge_resources], + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", + side_effect=[TimeoutError("GET /device/0 block 11 timeout"), fridge_resources], ), patch( - 'custom_components.localthings.coordinator.asyncio.sleep', + "custom_components.localthings.coordinator.asyncio.sleep", new=AsyncMock(), ), ): @@ -544,10 +541,13 @@ async def test_poll_timeout_counter_resets_when_push_is_healthy_again( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - fake.notify_on_subscribe = {'notified': True} + fake.notify_on_subscribe = {"notified": True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, - fake, hrefs, 0.02, 0.8, + fake, + hrefs, + 0.02, + 0.8, ) assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -555,8 +555,8 @@ async def test_poll_timeout_counter_resets_when_push_is_healthy_again( # Age the notify out so timeouts accrue toward the limit. coordinator._observe._last_notify_ts -= PUSH_HEALTH_WINDOW_S + 1 with patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', - side_effect=TimeoutError('GET /device/0 block 11 timeout'), + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", + side_effect=TimeoutError("GET /device/0 block 11 timeout"), ): for _ in range(coordinator._POLL_TIMEOUT_LIMIT - 1): await coordinator._async_update_data() @@ -564,10 +564,10 @@ async def test_poll_timeout_counter_resets_when_push_is_healthy_again( # A fresh notify proves push is healthy again — the next timeout must # not add to the pre-existing count, and must not reconnect. - fake.on_notification(hrefs[0], cbor2.dumps({'notified': True})) + fake.on_notification(hrefs[0], cbor2.dumps({"notified": True})) with patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', - side_effect=TimeoutError('GET /device/0 block 11 timeout'), + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", + side_effect=TimeoutError("GET /device/0 block 11 timeout"), ): await coordinator._async_update_data() @@ -590,10 +590,13 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs - fake.notify_on_subscribe = {'notified': True} + fake.notify_on_subscribe = {"notified": True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, - fake, hrefs, 0.02, 0.8, + fake, + hrefs, + 0.02, + 0.8, ) assert entered is True assert coordinator.observe_mode == MODE_OBSERVE @@ -609,11 +612,11 @@ async def test_reconnect_from_observe_mode_resubscribes_immediately( # refresh call — the 600s gate wouldn't have elapsed yet. with ( patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', - side_effect=[RuntimeError('connection lost'), fridge_resources], + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", + side_effect=[RuntimeError("connection lost"), fridge_resources], ), patch( - 'custom_components.localthings.coordinator.asyncio.sleep', + "custom_components.localthings.coordinator.asyncio.sleep", new=AsyncMock(), ), ): @@ -639,20 +642,23 @@ async def test_sweep_mismatch_never_downgrades_a_live_observe_session( hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs assert len(hrefs) >= 2 - fake.notify_on_subscribe = {'notified': True} + fake.notify_on_subscribe = {"notified": True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, - fake, hrefs, 0.02, 0.8, + fake, + hrefs, + 0.02, + 0.8, ) assert entered is True assert coordinator.observe_mode == MODE_OBSERVE # A sweep result disagreeing with the cache on every subscribed href — # previously treated as a device-wide miss and downgraded. - stale_sweep = {href: {'notified': False} for href in hrefs} + stale_sweep = {href: {"notified": False} for href in hrefs} with patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", return_value=stale_sweep, ): await coordinator.async_request_refresh() @@ -661,7 +667,7 @@ async def test_sweep_mismatch_never_downgrades_a_live_observe_session( assert coordinator.observe_mode == MODE_OBSERVE # The mismatch is diagnostic-only, but the sweep result still won — # the cache reflects the latest authoritative state either way. - assert coordinator.last_resources[hrefs[0]]['notified'] is False + assert coordinator.last_resources[hrefs[0]]["notified"] is False async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session( @@ -679,23 +685,28 @@ async def test_sweep_mismatch_forces_subpolls_on_a_live_observe_session( hrefs = coordinator._hot_hrefs + coordinator._warm_hrefs assert len(hrefs) >= 2 - fake.notify_on_subscribe = {'notified': True} + fake.notify_on_subscribe = {"notified": True} entered = await hass.async_add_executor_job( coordinator._observe.try_enter_observe_mode, - fake, hrefs, 0.02, 0.8, + fake, + hrefs, + 0.02, + 0.8, ) assert entered is True assert coordinator.observe_mode == MODE_OBSERVE - stale_sweep = {href: {'notified': False} for href in hrefs} + stale_sweep = {href: {"notified": False} for href in hrefs} with ( patch( - 'custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once', + "custom_components.localthings.coordinator.LocalThingsCoordinator._poll_once", return_value=stale_sweep, ), patch.object( - LocalThingsCoordinator, '_run_subpolls', new_callable=AsyncMock, + LocalThingsCoordinator, + "_run_subpolls", + new_callable=AsyncMock, ) as mock_subpolls, ): await coordinator.async_request_refresh() @@ -724,21 +735,23 @@ async def test_write_marks_href_pending_before_post( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] def _write_fn(payload, rep, href): - return (['some', 'path'], {'value': payload}) + return (["some", "path"], {"value": payload}) - desc = NumberDesc(key='test', field='value', write_fn=_write_fn) - bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + desc = NumberDesc(key="test", field="value", write_fn=_write_fn) + bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc) - with patch.object(fake, 'subscribe'): - fake.post = lambda *a, **k: (0x44, b'') + with patch.object(fake, "subscribe"): + fake.post = lambda *a, **k: (0x44, b"") await coordinator.async_send_command(bound, 5) - assert coordinator._observe._settle_until.get('/some/path') is not None - assert coordinator._observe._settle_until.get('/test/vs/0') is None + assert coordinator._observe._settle_until.get("/some/path") is not None + assert coordinator._observe._settle_until.get("/test/vs/0") is None async def test_send_command_applies_write_optimistically_before_settling( - hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, + hass: HomeAssistant, + mock_entry, + mock_coordinator_observe_session, ) -> None: """The cache must reflect a write immediately at the write's actual target href (write_fn's path_segs) -- not bound_entity.href, which @@ -759,26 +772,26 @@ async def test_send_command_applies_write_optimistically_before_settling( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] def _write_fn(payload, rep, href): - return (['some', 'path'], {'value': payload}) + return (["some", "path"], {"value": payload}) - desc = NumberDesc(key='test', field='value', write_fn=_write_fn) - bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + desc = NumberDesc(key="test", field="value", write_fn=_write_fn) + bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc) - with patch.object(fake, 'subscribe'): - fake.post = lambda *a, **k: (0x44, b'') + with patch.object(fake, "subscribe"): + fake.post = lambda *a, **k: (0x44, b"") await coordinator.async_send_command(bound, 5) # The optimistic value is visible right away at the write's real # target, not the bound href. - assert coordinator._cache.get('/some/path') == {'value': 5} - assert coordinator._cache.get('/test/vs/0') != {'value': 5} + assert coordinator._cache.get("/some/path") == {"value": 5} + assert coordinator._cache.get("/test/vs/0") != {"value": 5} # A stale update racing in behind the write (e.g. a poll/notify that # was already in flight before the PUT) must not clobber it while the # settle window is open. - applied = coordinator._observe.apply('/some/path', {'value': 0}, source='observe') + applied = coordinator._observe.apply("/some/path", {"value": 0}, source="observe") assert applied is False - assert coordinator._cache.get('/some/path') == {'value': 5} + assert coordinator._cache.get("/some/path") == {"value": 5} async def test_climate_power_write_applies_to_its_own_href_not_bound_href( @@ -805,19 +818,22 @@ async def test_climate_power_write_applies_to_its_own_href_not_bound_href( desc=airconditioner.CLIMATE.entities[0], ) - with patch.object(fake, 'subscribe'): - fake.post = lambda *a, **k: (0x44, b'') - await coordinator.async_send_command(bound, ('power', True)) + with patch.object(fake, "subscribe"): + fake.post = lambda *a, **k: (0x44, b"") + await coordinator.async_send_command(bound, ("power", True)) - assert (coordinator._cache.get('/power/vs/0') or {}).get( - 'x.com.samsung.da.power') == 'On' - assert 'x.com.samsung.da.power' not in ( - coordinator._cache.get(airconditioner.CLIMATE.href) or {}) - assert coordinator._observe._settle_until.get('/power/vs/0') is not None + assert (coordinator._cache.get("/power/vs/0") or {}).get("x.com.samsung.da.power") == "On" + assert "x.com.samsung.da.power" not in ( + coordinator._cache.get(airconditioner.CLIMATE.href) or {} + ) + assert coordinator._observe._settle_until.get("/power/vs/0") is not None async def test_send_command_survives_stale_confirm_poll( - hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, fridge_resources, + hass: HomeAssistant, + mock_entry, + mock_coordinator_observe_session, + fridge_resources, ) -> None: """The settle window must outlast the confirm poll async_send_command triggers via async_request_refresh(), not just DEFAULT_SETTLE_S's fixed @@ -843,40 +859,42 @@ async def test_send_command_survives_stale_confirm_poll( coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] def _write_fn(payload, rep, href): - return (['test', 'vs', '0'], {'value': payload}) + return (["test", "vs", "0"], {"value": payload}) - desc = NumberDesc(key='test', field='value', write_fn=_write_fn) - bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + desc = NumberDesc(key="test", field="value", write_fn=_write_fn) + bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc) def _stale_confirm_poll(): # Stand-in for the write's own confirming /device/0 poll racing # against a stale read for the same href -- the device's internal # state hasn't caught up to the write yet, even though it will a # little later. - coordinator._observe.apply('/test/vs/0', {'value': 0}, source='poll') + coordinator._observe.apply("/test/vs/0", {"value": 0}, source="poll") return fridge_resources with ( - patch.object(fake, 'subscribe'), - patch.object(LocalThingsCoordinator, '_poll_once', side_effect=_stale_confirm_poll), + patch.object(fake, "subscribe"), + patch.object(LocalThingsCoordinator, "_poll_once", side_effect=_stale_confirm_poll), ): - fake.post = lambda *a, **k: (0x44, b'') + fake.post = lambda *a, **k: (0x44, b"") await coordinator.async_send_command(bound, 5) # The optimistic value is visible right away and survived the stale # confirm-poll race triggered by the refresh above. - assert coordinator._cache.get('/test/vs/0') == {'value': 5} + assert coordinator._cache.get("/test/vs/0") == {"value": 5} # The settle window is still open afterward -- sized to outlast the # whole PUT + confirm-poll round trip, not released the moment that # round trip happens to finish. - applied = coordinator._observe.apply('/test/vs/0', {'value': 0}, source='observe') + applied = coordinator._observe.apply("/test/vs/0", {"value": 0}, source="observe") assert applied is False - assert coordinator._cache.get('/test/vs/0') == {'value': 5} + assert coordinator._cache.get("/test/vs/0") == {"value": 5} async def test_second_write_to_same_href_lands_during_first_writes_settle_window( - hass: HomeAssistant, mock_entry, mock_coordinator_observe_session, + hass: HomeAssistant, + mock_entry, + mock_coordinator_observe_session, ) -> None: """Regression for issue #9: /course/vs/0 backs several independent washer selects (cycle, detergent quantity, softener quantity, ...) @@ -896,22 +914,32 @@ async def test_second_write_to_same_href_lands_during_first_writes_settle_window await hass.async_block_till_done() coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] - desc_a = NumberDesc(key='cycle', field='cycle', - write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'cycle': p})) - desc_b = NumberDesc(key='detergent', field='detergent', - write_fn=lambda p, rep, href: (['test', 'vs', '0'], {'detergent': p})) - bound_a = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_a) - bound_b = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc_b) + desc_a = NumberDesc( + key="cycle", + field="cycle", + write_fn=lambda p, rep, href: (["test", "vs", "0"], {"cycle": p}), + ) + desc_b = NumberDesc( + key="detergent", + field="detergent", + write_fn=lambda p, rep, href: (["test", "vs", "0"], {"detergent": p}), + ) + bound_a = BoundEntity( + href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc_a + ) + bound_b = BoundEntity( + href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc_b + ) - with patch.object(fake, 'subscribe'): - fake.post = lambda *a, **k: (0x44, b'') - await coordinator.async_send_command(bound_a, 'Eco') - assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco'} + with patch.object(fake, "subscribe"): + fake.post = lambda *a, **k: (0x44, b"") + await coordinator.async_send_command(bound_a, "Eco") + assert coordinator._cache.get("/test/vs/0") == {"cycle": "Eco"} # Still well within the first write's settle window. - await coordinator.async_send_command(bound_b, 'High') + await coordinator.async_send_command(bound_b, "High") - assert coordinator._cache.get('/test/vs/0') == {'cycle': 'Eco', 'detergent': 'High'} + assert coordinator._cache.get("/test/vs/0") == {"cycle": "Eco", "detergent": "High"} class TestRemoteControlEnabled: @@ -921,25 +949,25 @@ class TestRemoteControlEnabled: write guard alike.""" def test_vs_fallback_href_true_is_enabled(self): - resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'true'}} + resources = {"/remotectrl/vs/0": {"x.com.samsung.da.remoteControlEnabled": "true"}} assert remote_control_enabled(resources) is True def test_vs_fallback_href_false_is_disabled(self): - resources = {'/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}} + resources = {"/remotectrl/vs/0": {"x.com.samsung.da.remoteControlEnabled": "false"}} assert remote_control_enabled(resources) is False def test_generic_href_true_is_enabled(self): - resources = {'/remotectrl/0': {'value': True}} + resources = {"/remotectrl/0": {"value": True}} assert remote_control_enabled(resources) is True def test_generic_href_false_is_disabled(self): - resources = {'/remotectrl/0': {'value': False}} + resources = {"/remotectrl/0": {"value": False}} assert remote_control_enabled(resources) is False def test_generic_href_wins_when_both_present(self): resources = { - '/remotectrl/0': {'value': True}, - '/remotectrl/vs/0': {'x.com.samsung.da.remoteControlEnabled': 'false'}, + "/remotectrl/0": {"value": True}, + "/remotectrl/vs/0": {"x.com.samsung.da.remoteControlEnabled": "false"}, } assert remote_control_enabled(resources) is True @@ -952,31 +980,28 @@ class TestRemoteControlRequiredForWrite: def test_without_flag_always_requires(self): resources = {} - assert remote_control_required_for_write(resources, '/washer/vs/0') is True - assert remote_control_required_for_write( - resources, '/operational/state/vs/0') is True + assert remote_control_required_for_write(resources, "/washer/vs/0") is True + assert remote_control_required_for_write(resources, "/operational/state/vs/0") is True def test_without_sc_exempts_settings_keeps_operational(self): resources = { - '/wm/setinfo/vs/0': { - 'x.com.samsung.da.isModelSettingWithoutSC': 'true', + "/wm/setinfo/vs/0": { + "x.com.samsung.da.isModelSettingWithoutSC": "true", }, } - assert remote_control_required_for_write(resources, '/washer/vs/0') is False - assert remote_control_required_for_write(resources, '/course/vs/0') is False - assert remote_control_required_for_write(resources, '/buzzersound/vs/0') is False - assert remote_control_required_for_write( - resources, '/operational/state/vs/0') is True - assert remote_control_required_for_write( - resources, '/operational/state/0') is True + assert remote_control_required_for_write(resources, "/washer/vs/0") is False + assert remote_control_required_for_write(resources, "/course/vs/0") is False + assert remote_control_required_for_write(resources, "/buzzersound/vs/0") is False + assert remote_control_required_for_write(resources, "/operational/state/vs/0") is True + assert remote_control_required_for_write(resources, "/operational/state/0") is True def test_without_sc_false_still_requires(self): resources = { - '/wm/setinfo/vs/0': { - 'x.com.samsung.da.isModelSettingWithoutSC': 'false', + "/wm/setinfo/vs/0": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", }, } - assert remote_control_required_for_write(resources, '/washer/vs/0') is True + assert remote_control_required_for_write(resources, "/washer/vs/0") is True async def test_send_command_blocked_when_remote_control_disabled( @@ -993,31 +1018,31 @@ async def test_send_command_blocked_when_remote_control_disabled( await hass.async_block_till_done() coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] coordinator._cache.apply_rep( - '/remotectrl/vs/0', - {'x.com.samsung.da.remoteControlEnabled': 'false'}, - source='test', + "/remotectrl/vs/0", + {"x.com.samsung.da.remoteControlEnabled": "false"}, + source="test", ) def _write_fn(payload, rep, href): - return (['some', 'path'], {'value': payload}) + return (["some", "path"], {"value": payload}) - desc = NumberDesc(key='test', field='value', write_fn=_write_fn) - bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + desc = NumberDesc(key="test", field="value", write_fn=_write_fn) + bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc) posted = False def _post(*a, **k): nonlocal posted posted = True - return (0x44, b'') + return (0x44, b"") - with patch.object(fake, 'subscribe'): + with patch.object(fake, "subscribe"): fake.post = _post with pytest.raises(ServiceValidationError) as exc_info: await coordinator.async_send_command(bound, 5) assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == 'remote_control_disabled' + assert exc_info.value.translation_key == "remote_control_disabled" assert posted is False @@ -1034,22 +1059,22 @@ async def test_send_command_allowed_when_remote_control_enabled( await hass.async_block_till_done() coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] coordinator._cache.apply_rep( - '/remotectrl/vs/0', - {'x.com.samsung.da.remoteControlEnabled': 'true'}, - source='test', + "/remotectrl/vs/0", + {"x.com.samsung.da.remoteControlEnabled": "true"}, + source="test", ) def _write_fn(payload, rep, href): - return (['some', 'path'], {'value': payload}) + return (["some", "path"], {"value": payload}) - desc = NumberDesc(key='test', field='value', write_fn=_write_fn) - bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + desc = NumberDesc(key="test", field="value", write_fn=_write_fn) + bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc) - with patch.object(fake, 'subscribe'): - fake.post = lambda *a, **k: (0x44, b'') + with patch.object(fake, "subscribe"): + fake.post = lambda *a, **k: (0x44, b"") await coordinator.async_send_command(bound, 5) - assert coordinator._cache.get('/some/path') == {'value': 5} + assert coordinator._cache.get("/some/path") == {"value": 5} async def test_send_command_remote_control_check_precedes_validate_fn( @@ -1065,23 +1090,23 @@ async def test_send_command_remote_control_check_precedes_validate_fn( await hass.async_block_till_done() coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] coordinator._cache.apply_rep( - '/remotectrl/vs/0', - {'x.com.samsung.da.remoteControlEnabled': 'false'}, - source='test', + "/remotectrl/vs/0", + {"x.com.samsung.da.remoteControlEnabled": "false"}, + source="test", ) def _write_fn(payload, rep, href=None): - return (['some', 'path'], {'value': payload}) + return (["some", "path"], {"value": payload}) def _validate_fn(payload, rep, resources): return "always rejected" - desc = SwitchDesc(key='test', field='value', write_fn=_write_fn, validate_fn=_validate_fn) - bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + desc = SwitchDesc(key="test", field="value", write_fn=_write_fn, validate_fn=_validate_fn) + bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc) with pytest.raises(ServiceValidationError) as exc_info: - await coordinator.async_send_command(bound, 'On') - assert exc_info.value.translation_key == 'remote_control_disabled' + await coordinator.async_send_command(bound, "On") + assert exc_info.value.translation_key == "remote_control_disabled" async def test_send_command_bypasses_remote_control_when_option_enabled( @@ -1097,7 +1122,9 @@ async def test_send_command_bypasses_remote_control_when_option_enabled( from custom_components.localthings.registry.entities import NumberDesc 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) @@ -1107,22 +1134,22 @@ async def test_send_command_bypasses_remote_control_when_option_enabled( await hass.async_block_till_done() coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator._cache.apply_rep( - '/remotectrl/vs/0', - {'x.com.samsung.da.remoteControlEnabled': 'false'}, - source='test', + "/remotectrl/vs/0", + {"x.com.samsung.da.remoteControlEnabled": "false"}, + source="test", ) def _write_fn(payload, rep, href): - return (['some', 'path'], {'value': payload}) + return (["some", "path"], {"value": payload}) - desc = NumberDesc(key='test', field='value', write_fn=_write_fn) - bound = BoundEntity(href='/test/vs/0', capability=coordinator.bound[0].capability, desc=desc) + desc = NumberDesc(key="test", field="value", write_fn=_write_fn) + bound = BoundEntity(href="/test/vs/0", capability=coordinator.bound[0].capability, desc=desc) - with patch.object(fake, 'subscribe'): - fake.post = lambda *a, **k: (0x44, b'') + with patch.object(fake, "subscribe"): + fake.post = lambda *a, **k: (0x44, b"") await coordinator.async_send_command(bound, 5) - assert coordinator._cache.get('/some/path') == {'value': 5} + assert coordinator._cache.get("/some/path") == {"value": 5} async def test_send_command_settings_allowed_when_without_sc( @@ -1138,31 +1165,32 @@ async def test_send_command_settings_allowed_when_without_sc( await hass.async_block_till_done() coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] coordinator._cache.apply_rep( - '/remotectrl/vs/0', - {'x.com.samsung.da.remoteControlEnabled': 'false'}, - source='test', + "/remotectrl/vs/0", + {"x.com.samsung.da.remoteControlEnabled": "false"}, + source="test", ) coordinator._cache.apply_rep( - '/wm/setinfo/vs/0', - {'x.com.samsung.da.isModelSettingWithoutSC': 'true'}, - source='test', + "/wm/setinfo/vs/0", + {"x.com.samsung.da.isModelSettingWithoutSC": "true"}, + source="test", ) def _write_fn(payload, rep, href=None): - return (['washer', 'vs', '0'], {'x.com.samsung.da.spinLevel': payload}) + return (["washer", "vs", "0"], {"x.com.samsung.da.spinLevel": payload}) - desc = SelectDesc(key='spin_speed', field='x.com.samsung.da.spinLevel', - write_fn=_write_fn) + desc = SelectDesc(key="spin_speed", field="x.com.samsung.da.spinLevel", write_fn=_write_fn) bound = BoundEntity( - href='/washer/vs/0', capability=coordinator.bound[0].capability, desc=desc, + href="/washer/vs/0", + capability=coordinator.bound[0].capability, + desc=desc, ) - with patch.object(fake, 'subscribe'): - fake.post = lambda *a, **k: (0x44, b'') - await coordinator.async_send_command(bound, '1000') + with patch.object(fake, "subscribe"): + fake.post = lambda *a, **k: (0x44, b"") + await coordinator.async_send_command(bound, "1000") - assert coordinator._cache.get('/washer/vs/0') == { - 'x.com.samsung.da.spinLevel': '1000', + assert coordinator._cache.get("/washer/vs/0") == { + "x.com.samsung.da.spinLevel": "1000", } @@ -1179,23 +1207,22 @@ async def test_send_command_operational_still_blocked_when_without_sc( await hass.async_block_till_done() coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id] coordinator._cache.apply_rep( - '/remotectrl/vs/0', - {'x.com.samsung.da.remoteControlEnabled': 'false'}, - source='test', + "/remotectrl/vs/0", + {"x.com.samsung.da.remoteControlEnabled": "false"}, + source="test", ) coordinator._cache.apply_rep( - '/wm/setinfo/vs/0', - {'x.com.samsung.da.isModelSettingWithoutSC': 'true'}, - source='test', + "/wm/setinfo/vs/0", + {"x.com.samsung.da.isModelSettingWithoutSC": "true"}, + source="test", ) def _write_fn(payload, rep, href=None): - return (['operational', 'state', 'vs', '0'], - {'x.com.samsung.da.state': payload}) + return (["operational", "state", "vs", "0"], {"x.com.samsung.da.state": payload}) - desc = ButtonDesc(key='start', payload='Run', write_fn=_write_fn) + desc = ButtonDesc(key="start", payload="Run", write_fn=_write_fn) bound = BoundEntity( - href='/operational/state/vs/0', + href="/operational/state/vs/0", capability=coordinator.bound[0].capability, desc=desc, ) @@ -1205,22 +1232,22 @@ async def test_send_command_operational_still_blocked_when_without_sc( def _post(*a, **k): nonlocal posted posted = True - return (0x44, b'') + return (0x44, b"") - with patch.object(fake, 'subscribe'): + with patch.object(fake, "subscribe"): fake.post = _post with pytest.raises(ServiceValidationError) as exc_info: - await coordinator.async_send_command(bound, 'Run') + await coordinator.async_send_command(bound, "Run") assert exc_info.value.translation_domain == DOMAIN - assert exc_info.value.translation_key == 'remote_control_disabled' + assert exc_info.value.translation_key == "remote_control_disabled" assert posted is False def test_local_source_port_stable_and_in_range() -> None: """Same host always maps to the same port (stability is the whole point: a reconnect must reuse the 5-tuple), and it stays in the documented window.""" - for host in ('192.168.1.217', '10.0.0.42', 'fridge.local'): + for host in ("192.168.1.217", "10.0.0.42", "fridge.local"): port = _local_source_port(host) assert port == _local_source_port(host) assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF @@ -1230,17 +1257,17 @@ def test_local_source_port_unique_per_host_on_a_24() -> None: """Distinct last octets -> distinct ports, so two devices on the same HA host never share a source port (the lib's socket is unconnected, so a shared port would cross-deliver datagrams).""" - ports = {_local_source_port(f'192.168.1.{n}') for n in range(1, 255)} + ports = {_local_source_port(f"192.168.1.{n}") for n in range(1, 255)} assert len(ports) == 254 def test_local_source_port_ipv4_uses_last_octet() -> None: """The IPv4 fast path is the last octet, not a hash.""" - assert _local_source_port('192.168.1.217') == DTLS_LOCAL_PORT_BASE + 217 + assert _local_source_port("192.168.1.217") == DTLS_LOCAL_PORT_BASE + 217 def test_local_source_port_non_ipv4_falls_back_to_hash() -> None: """A non-IPv4 host still yields a deterministic in-range port.""" - port = _local_source_port('some-hostname') - assert port == _local_source_port('some-hostname') + port = _local_source_port("some-hostname") + assert port == _local_source_port("some-hostname") assert DTLS_LOCAL_PORT_BASE <= port <= DTLS_LOCAL_PORT_BASE + 0xFF diff --git a/tests/localthings/test_device_removal.py b/tests/localthings/test_device_removal.py index 402f7d8..8affe32 100644 --- a/tests/localthings/test_device_removal.py +++ b/tests/localthings/test_device_removal.py @@ -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 diff --git a/tests/localthings/test_diagnostics.py b/tests/localthings/test_diagnostics.py index 0a522f6..677813b 100644 --- a/tests/localthings/test_diagnostics.py +++ b/tests/localthings/test_diagnostics.py @@ -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 diff --git a/tests/localthings/test_observe.py b/tests/localthings/test_observe.py index 1a87724..33bc7bf 100644 --- a/tests/localthings/test_observe.py +++ b/tests/localthings/test_observe.py @@ -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 diff --git a/tests/localthings/test_sensor.py b/tests/localthings/test_sensor.py index 10a3ba4..db83cbe 100644 --- a/tests/localthings/test_sensor.py +++ b/tests/localthings/test_sensor.py @@ -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" diff --git a/tests/test_air_dresser_capabilities.py b/tests/test_air_dresser_capabilities.py index 5a4e838..071ebf0 100644 --- a/tests/test_air_dresser_capabilities.py +++ b/tests/test_air_dresser_capabilities.py @@ -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 diff --git a/tests/test_air_dresser_tp1_21_capabilities.py b/tests/test_air_dresser_tp1_21_capabilities.py index 2fb26c8..d6fe379 100644 --- a/tests/test_air_dresser_tp1_21_capabilities.py +++ b/tests/test_air_dresser_tp1_21_capabilities.py @@ -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"} diff --git a/tests/test_air_dresser_tp2_20_capabilities.py b/tests/test_air_dresser_tp2_20_capabilities.py index 2d3ef00..3fc9406 100644 --- a/tests/test_air_dresser_tp2_20_capabilities.py +++ b/tests/test_air_dresser_tp2_20_capabilities.py @@ -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 diff --git a/tests/test_air_monitor_capabilities.py b/tests/test_air_monitor_capabilities.py index ec3ea00..cff6a59 100644 --- a/tests/test_air_monitor_capabilities.py +++ b/tests/test_air_monitor_capabilities.py @@ -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"} diff --git a/tests/test_air_purifier_airflow_fan.py b/tests/test_air_purifier_airflow_fan.py index f67626b..b884261 100644 --- a/tests/test_air_purifier_airflow_fan.py +++ b/tests/test_air_purifier_airflow_fan.py @@ -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") diff --git a/tests/test_air_purifier_avt_ww.py b/tests/test_air_purifier_avt_ww.py index 83e692b..9a8fc00 100644 --- a/tests/test_air_purifier_avt_ww.py +++ b/tests/test_air_purifier_avt_ww.py @@ -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" diff --git a/tests/test_air_purifier_capabilities.py b/tests/test_air_purifier_capabilities.py index 8e85228..aed9871 100644 --- a/tests/test_air_purifier_capabilities.py +++ b/tests/test_air_purifier_capabilities.py @@ -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" diff --git a/tests/test_air_purifier_fan_discriminator.py b/tests/test_air_purifier_fan_discriminator.py index 0f61a12..7e5697a 100644 --- a/tests/test_air_purifier_fan_discriminator.py +++ b/tests/test_air_purifier_fan_discriminator.py @@ -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(): diff --git a/tests/test_air_purifier_vtww_fan.py b/tests/test_air_purifier_vtww_fan.py index e3e7742..ac17ef3 100644 --- a/tests/test_air_purifier_vtww_fan.py +++ b/tests/test_air_purifier_vtww_fan.py @@ -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" diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index ffe60c5..5862b68 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -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"] diff --git a/tests/test_airconditioner_cac.py b/tests/test_airconditioner_cac.py index 586c365..e1e5bc7 100644 --- a/tests/test_airconditioner_cac.py +++ b/tests/test_airconditioner_cac.py @@ -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) diff --git a/tests/test_airconditioner_capabilities.py b/tests/test_airconditioner_capabilities.py index 57d7161..64dfef9 100644 --- a/tests/test_airconditioner_capabilities.py +++ b/tests/test_airconditioner_capabilities.py @@ -5,20 +5,21 @@ discovery/flatten, and the CLIMATE capability's write contract. The composite climate entity itself lives in climate.py (imports homeassistant) and is not importable here -- consistent with how the other HA platform files are untested. """ + 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 airconditioner from custom_components.localthings.registry.discovery import discover from custom_components.localthings.registry.entities import ClimateDesc, SelectDesc - from tests.conftest import _load_device def _ac(): - resources = _load_device('airconditioner') - info = resources['/information/vs/0'] + resources = _load_device("airconditioner") + 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 @@ -26,9 +27,10 @@ def _ac(): def _resolve(name): """Mirror the coordinator's detection: board tokens in modelNum.""" resources = _load_device(name) - info = resources['/information/vs/0'] + 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 @@ -45,7 +47,7 @@ def _state(): def test_ac_model_resolves_to_airconditioner_registry(): reg, _ = _ac() - assert reg is not None and reg.name == 'airconditioner' + assert reg is not None and reg.name == "airconditioner" def test_no_unbound_hrefs(): @@ -62,13 +64,21 @@ def test_climate_entity_is_bound(): bound, _ = _bound() climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] assert len(climate) == 1 - assert climate[0].href == '/mode/vs/0' + assert climate[0].href == "/mode/vs/0" def test_expected_state_keys_present(): state = _state() - for key in ('climate', 'air_purify', 'auto_clean', 'air_filter_status', - 'air_filter_usage', 'diagnosis_status', 'alarm_code', 'energy_kwh'): + for key in ( + "climate", + "air_purify", + "auto_clean", + "air_filter_status", + "air_filter_usage", + "diagnosis_status", + "alarm_code", + "energy_kwh", + ): assert key in state, key @@ -76,14 +86,14 @@ def test_power_and_convenient_folded_into_climate(): """On/off is the climate entity's HVACMode.OFF and convenient mode is its preset_mode -- neither surfaces as a standalone switch/select.""" state = _state() - assert 'power_switch' not in state - assert 'convenient_mode' not in state + assert "power_switch" not in state + assert "convenient_mode" not in state def test_air_filter_usage_is_percentage_of_capacity(): """filterUsage is a raw count in the capacity unit (100 of 500), surfaced as a percentage rather than the misleading raw value.""" - assert _state()['air_filter_usage'] == 20 + assert _state()["air_filter_usage"] == 20 def test_climate_write_targets(): @@ -94,30 +104,42 @@ def test_climate_write_targets(): present; /temperature/desired/0 is only written via the temperature_ocf kind, on boards that have the OCF pair).""" write = airconditioner.CLIMATE.entities[0].write_fn - assert write(('power', True), {}) == ( - ['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'}) - assert write(('power', False), {}) == ( - ['power', 'vs', '0'], {'x.com.samsung.da.power': 'Off'}) - assert write(('mode', 'Heat'), {}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.modes': ['Heat']}) + assert write(("power", True), {}) == (["power", "vs", "0"], {"x.com.samsung.da.power": "On"}) + assert write(("power", False), {}) == (["power", "vs", "0"], {"x.com.samsung.da.power": "Off"}) + assert write(("mode", "Heat"), {}) == ( + ["mode", "vs", "0"], + {"x.com.samsung.da.modes": ["Heat"]}, + ) # OCF-pair boards: temperature_ocf -> /temperature/desired/0. - assert write(('temperature_ocf', 23.6), {}) == ( - ['temperature', 'desired', '0'], {'temperature': 24}) + assert write(("temperature_ocf", 23.6), {}) == ( + ["temperature", "desired", "0"], + {"temperature": 24}, + ) # Vendor boards: temperature -> /temperatures/vs/0, carrying only the id # and the changed field -- the device merges current/min/max/unit itself # (see common.merge_items_field, wired into async_send_command, for the # read-side half that keeps the optimistic cache complete). - assert write(('temperature', 22), {}) == ( - ['temperatures', 'vs', '0'], - {'x.com.samsung.da.items': [ - {'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}]}) - assert write(('fan', '2'), {}) == ( - ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': '2'}) - assert write(('swing', 'All'), {}) == ( - ['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': 'All'}) - assert write(('preset', 'Sleep'), {}) == ( - ['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': 'Sleep'}) - assert write(('bogus', 1), {}) is None + assert write(("temperature", 22), {}) == ( + ["temperatures", "vs", "0"], + { + "x.com.samsung.da.items": [ + {"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "22"} + ] + }, + ) + assert write(("fan", "2"), {}) == ( + ["wind", "strength", "vs", "0"], + {"x.com.samsung.da.modes": "2"}, + ) + assert write(("swing", "All"), {}) == ( + ["wind", "direction", "vs", "0"], + {"x.com.samsung.da.modes": "All"}, + ) + assert write(("preset", "Sleep"), {}) == ( + ["mode", "convenient", "vs", "0"], + {"x.com.samsung.da.modes": "Sleep"}, + ) + assert write(("bogus", 1), {}) is None def test_climate_consumed_hrefs_declared_as_coverage(): @@ -128,9 +150,14 @@ def test_climate_consumed_hrefs_declared_as_coverage(): those two real sensor entities (issue #75). /sensors/vs/0 is also NOT here -- AIR_QUALITY gives it real entity sensors.""" reg, _ = _ac() - for href in ('/power/0', '/power/vs/0', '/temperature/desired/0', - '/wind/strength/vs/0', '/mode/convenient/vs/0', - '/humidity/0'): + for href in ( + "/power/0", + "/power/vs/0", + "/temperature/desired/0", + "/wind/strength/vs/0", + "/mode/convenient/vs/0", + "/humidity/0", + ): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href @@ -144,13 +171,14 @@ def test_climate_consumed_hrefs_declared_as_coverage(): # carries extra vendor housekeeping hrefs. Issue #17 for this class (PR #36). # --------------------------------------------------------------------------- + def _ac_tp1x(): - return _resolve('airconditioner_tp1x_da_ac_rac_01011') + return _resolve("airconditioner_tp1x_da_ac_rac_01011") def test_tp1x_resolves_to_airconditioner_registry(): reg, _ = _ac_tp1x() - assert reg is not None and reg.name == 'airconditioner' + assert reg is not None and reg.name == "airconditioner" def test_tp1x_no_unbound_hrefs(): @@ -166,17 +194,16 @@ def test_tp1x_no_unbound_hrefs(): def test_tp1x_display_light_switch_present(): """/light/vs/0 (mode On/Off) surfaces as the display-light switch.""" reg, resources = _ac_tp1x() - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state.get('display_light') is True # device reports mode == 'On' + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state.get("display_light") is True # device reports mode == 'On' def test_tp1x_vendor_temperature_and_light_covered(): """The vendor temperature resource (read by the climate entity) and the display-light resource both resolve in the registry -- no gap.""" reg, _ = _ac_tp1x() - assert reg.capabilities.get('/temperatures/vs/0'), '/temperatures/vs/0' - assert reg.capabilities.get('/light/vs/0'), '/light/vs/0' + assert reg.capabilities.get("/temperatures/vs/0"), "/temperatures/vs/0" + assert reg.capabilities.get("/light/vs/0"), "/light/vs/0" def test_tp1x_climate_entity_is_bound(): @@ -184,18 +211,18 @@ def test_tp1x_climate_entity_is_bound(): reg, resources = _ac_tp1x() bound = discover(resources, reg.capabilities, reg.pattern_capabilities) climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] - assert len(climate) == 1 and climate[0].href == '/mode/vs/0' + assert len(climate) == 1 and climate[0].href == "/mode/vs/0" def test_tp2x_rac_20k_model_resolves_via_model_fallback(): """TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token -- resolved via the '_RAC_' modelNum fallback added for this device.""" - reg, _ = _resolve('airconditioner_tp2x_rac_20k') - assert reg is not None and reg.name == 'airconditioner' + reg, _ = _resolve("airconditioner_tp2x_rac_20k") + assert reg is not None and reg.name == "airconditioner" def test_tp2x_rac_20k_no_unbound_hrefs(): - reg, resources = _resolve('airconditioner_tp2x_rac_20k') + reg, resources = _resolve("airconditioner_tp2x_rac_20k") unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] @@ -206,23 +233,30 @@ def test_tp1x_rac_model_resolves_via_rac_token(): '7.0 Air conditioner', which used to be what resolved it; the hyphenated '-RAC-' board token types it now, so firmware that omits oneUiVersion (the cool-only variant, issue #91) lands on the same registry.""" - reg, _ = _resolve('airconditioner_tp1x_rac') - assert reg is not None and reg.name == 'airconditioner' + reg, _ = _resolve("airconditioner_tp1x_rac") + assert reg is not None and reg.name == "airconditioner" def test_tp1x_rac_no_unbound_hrefs(): - reg, resources = _resolve('airconditioner_tp1x_rac') + reg, resources = _resolve("airconditioner_tp1x_rac") unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] def test_tp1x_rac_expected_state_keys_present(): - reg, resources = _resolve('airconditioner_tp1x_rac') + reg, resources = _resolve("airconditioner_tp1x_rac") bound = discover(resources, reg.capabilities, reg.pattern_capabilities) state = flatten(bound, resources) - for key in ('climate', 'display_light', 'mute_once', 'selfcheck_status', - 'selfcheck_result', 'current_limit_enabled', 'current_limit_level'): + for key in ( + "climate", + "display_light", + "mute_once", + "selfcheck_status", + "selfcheck_result", + "current_limit_enabled", + "current_limit_level", + ): assert key in state, key @@ -230,8 +264,8 @@ def test_caww_tp2_model_resolves_via_model_fallback(): """A-CAWW-TP2-20-COMMON (issue #52, System AC) reports no oneUiVersion and no '_RAC_'/'_PRAC_' token -- resolved via the '-CAWW-' modelNum fallback added for this device.""" - reg, _ = _resolve('airconditioner_caww_tp2') - assert reg is not None and reg.name == 'airconditioner' + reg, _ = _resolve("airconditioner_caww_tp2") + assert reg is not None and reg.name == "airconditioner" def test_caww_tp2_no_unbound_hrefs(): @@ -239,7 +273,7 @@ def test_caww_tp2_no_unbound_hrefs(): the coverage-gap repair. Only new href beyond the existing RAC/PRAC surface is /sac/installationinfo/vs/0 (opaque SAC installation topology, ignored).""" - reg, resources = _resolve('airconditioner_caww_tp2') + reg, resources = _resolve("airconditioner_caww_tp2") unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] @@ -247,19 +281,19 @@ def test_caww_tp2_no_unbound_hrefs(): def test_caww_tp2_sac_installationinfo_is_ignored(): ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE} - assert '/sac/installationinfo/vs/0' in ignored_hrefs + assert "/sac/installationinfo/vs/0" in ignored_hrefs def test_mute_once_write_target(): write = airconditioner.MUTE_ONCE.entities[0].write_fn - assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'}) - assert write('Off', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'Off'}) + assert write("On", {}) == (["option", "muteonce", "vs", "0"], {"muteonce": "On"}) + assert write("Off", {}) == (["option", "muteonce", "vs", "0"], {"muteonce": "Off"}) def test_display_light_write_target(): write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn - assert write('On', {}) == (['light', 'vs', '0'], {'mode': 'On'}) - assert write('Off', {}) == (['light', 'vs', '0'], {'mode': 'Off'}) + assert write("On", {}) == (["light", "vs", "0"], {"mode": "On"}) + assert write("Off", {}) == (["light", "vs", "0"], {"mode": "Off"}) def test_current_limit_is_read_only(): @@ -267,7 +301,7 @@ def test_current_limit_is_read_only(): from the dump alone -- exposed as read-only diagnostic sensors rather than a guessed writable control.""" for desc in airconditioner.CURRENT_LIMIT.entities: - assert getattr(desc, 'write_fn', None) is None + assert getattr(desc, "write_fn", None) is None # --------------------------------------------------------------------------- @@ -281,22 +315,23 @@ def test_current_limit_is_read_only(): # /mode/vs/0's options blob instead of a dedicated /light/vs/0 switch. # --------------------------------------------------------------------------- + def test_tp1x_rac_coolonly_resolves_via_hyphenated_model_token(): """This unit reports no oneUiVersion at all -- the 'RAC' board token is the only thing that types it. Guards the regression where it loaded as 'unknown'.""" - resources = _load_device('airconditioner_tp1x_rac_coolonly') - otn = resources.get('/otninformation/vs/0', {}) - assert otn.get('swVersionInfo', {}).get('oneUiVersion', '') == '' - reg, _ = _resolve('airconditioner_tp1x_rac_coolonly') - assert reg is not None and reg.name == 'airconditioner' + resources = _load_device("airconditioner_tp1x_rac_coolonly") + otn = resources.get("/otninformation/vs/0", {}) + assert otn.get("swVersionInfo", {}).get("oneUiVersion", "") == "" + reg, _ = _resolve("airconditioner_tp1x_rac_coolonly") + assert reg is not None and reg.name == "airconditioner" def test_tp1x_rac_coolonly_no_unbound_hrefs(): """Every resource binds or is ignored -- including the two hrefs unique to this dump (/stepcontrol/vs/0, /remotedeviceinfo/vs/0). Clears the gap repair.""" - reg, resources = _resolve('airconditioner_tp1x_rac_coolonly') + reg, resources = _resolve("airconditioner_tp1x_rac_coolonly") unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) assert unbound == [] @@ -304,15 +339,15 @@ def test_tp1x_rac_coolonly_no_unbound_hrefs(): def test_tp1x_rac_coolonly_stray_hrefs_ignored(): ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE} - assert '/stepcontrol/vs/0' in ignored_hrefs - assert '/remotedeviceinfo/vs/0' in ignored_hrefs + assert "/stepcontrol/vs/0" in ignored_hrefs + assert "/remotedeviceinfo/vs/0" in ignored_hrefs def test_tp1x_rac_coolonly_climate_bound(): - reg, resources = _resolve('airconditioner_tp1x_rac_coolonly') + reg, resources = _resolve("airconditioner_tp1x_rac_coolonly") bound = discover(resources, reg.capabilities, reg.pattern_capabilities) climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] - assert len(climate) == 1 and climate[0].href == '/mode/vs/0' + assert len(climate) == 1 and climate[0].href == "/mode/vs/0" def test_tp1x_rac_coolonly_display_light_from_mode_options(): @@ -320,16 +355,16 @@ def test_tp1x_rac_coolonly_display_light_from_mode_options(): /mode/vs/0's options and surfaces as a display_light switch. The token is inverted vs its name (confirmed by a live toggle test): with the panel lit the option reads `Light_Off`, and with it dark it reads `Light_On`.""" - reg, resources = _resolve('airconditioner_tp1x_rac_coolonly') + reg, resources = _resolve("airconditioner_tp1x_rac_coolonly") state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state.get('display_light') is True + assert state.get("display_light") is True def test_display_light_option_parsing_and_gating(): # Inverted token: Light_Off -> panel lit (on), Light_On -> panel dark (off). - lit = {'x.com.samsung.da.options': ['CoolCapa_35', 'Light_Off', 'Volume_Mute']} - dark = {'x.com.samsung.da.options': ['Light_On']} - absent = {'x.com.samsung.da.options': ['Volume_Mute']} + lit = {"x.com.samsung.da.options": ["CoolCapa_35", "Light_Off", "Volume_Mute"]} + dark = {"x.com.samsung.da.options": ["Light_On"]} + absent = {"x.com.samsung.da.options": ["Volume_Mute"]} assert airconditioner._display_light_on(lit) is True assert airconditioner._display_light_on(dark) is False assert airconditioner._display_light_on(absent) is None @@ -340,19 +375,23 @@ def test_display_light_option_parsing_and_gating(): def test_mode_options_display_light_write_is_inverted_single_token(): """Turning the lamp ON writes the inverted 'Light_Off' token as a single-element options list (single-token merge); OFF writes 'Light_On'.""" - sw = next(e for e in airconditioner.CLIMATE.entities if e.key == 'display_light') - assert sw.write_fn('On', {}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Light_Off']}) - assert sw.write_fn('Off', {}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Light_On']}) + sw = next(e for e in airconditioner.CLIMATE.entities if e.key == "display_light") + assert sw.write_fn("On", {}) == ( + ["mode", "vs", "0"], + {"x.com.samsung.da.options": ["Light_Off"]}, + ) + assert sw.write_fn("Off", {}) == ( + ["mode", "vs", "0"], + {"x.com.samsung.da.options": ["Light_On"]}, + ) def test_light_switch_board_gates_out_mode_options_light(): """Boards with a real /light/vs/0 switch carry no Light_* option, so the mode-options display-light entity doesn't double up (mutually exclusive encodings).""" - reg, resources = _resolve('airconditioner_tp1x_rac') - assert airconditioner._has_display_light_option(resources['/mode/vs/0'], resources) is False + _reg, resources = _resolve("airconditioner_tp1x_rac") + assert airconditioner._has_display_light_option(resources["/mode/vs/0"], resources) is False # --------------------------------------------------------------------------- @@ -363,11 +402,13 @@ def test_light_switch_board_gates_out_mode_options_light(): # fivepercentHumidity is populated (unlike the all-zero original dump). # --------------------------------------------------------------------------- + def _ac_windfree(): - resources = _load_device('airconditioner_windfree') - info = resources['/information/vs/0'] + resources = _load_device("airconditioner_windfree") + 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 @@ -383,8 +424,8 @@ def test_windfree_humidity_and_temperature_sensors_present(): reg, resources = _ac_windfree() bound = discover(resources, reg.capabilities, reg.pattern_capabilities) state = flatten(bound, resources) - assert state['humidity'] == 42.0 # fivepercentHumidity, not the stuck humidity=0 field - assert state['current_temperature_c'] == 27.0 + assert state["humidity"] == 42.0 # fivepercentHumidity, not the stuck humidity=0 field + assert state["current_temperature_c"] == 27.0 def test_current_temperature_vs_only_binds_when_ocf_href_absent(): @@ -392,13 +433,13 @@ def test_current_temperature_vs_only_binds_when_ocf_href_absent(): CURRENT_TEMPERATURE when a device (like this one) reports both /temperature/current/0 and /temperatures/vs/0.""" match = airconditioner.CURRENT_TEMPERATURE_VS.match_fn - assert match({}, {'/temperature/current/0': {}}) is False + assert match({}, {"/temperature/current/0": {}}) is False assert match({}, {}) is True def test_humidity_reads_five_percent_field_not_stuck_humidity_field(): desc = airconditioner.HUMIDITY.entities[0] - rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'} + rep = {"x.com.samsung.da.humidity": "0", "x.com.samsung.da.fivepercentHumidity": "42"} assert desc.rep_fn(rep) == 42.0 @@ -407,8 +448,8 @@ def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent(): Their plain field is not stuck -- it carries a reading while Air monitoring is on -- so 0 means "not measuring" on both generations, not 0% humidity.""" desc = airconditioner.HUMIDITY.entities[0] - assert desc.rep_fn({'x.com.samsung.da.humidity': '51'}) == 51.0 - assert desc.rep_fn({'x.com.samsung.da.humidity': '0'}) is None + assert desc.rep_fn({"x.com.samsung.da.humidity": "51"}) == 51.0 + assert desc.rep_fn({"x.com.samsung.da.humidity": "0"}) is None assert desc.rep_fn({}) is None @@ -420,7 +461,7 @@ def test_humidity_five_percent_field_passes_a_genuine_zero_through(): fallback field collapses 0 -- fivepercentHumidity's 0 is a real reading.""" desc = airconditioner.HUMIDITY.entities[0] - assert desc.rep_fn({'x.com.samsung.da.fivepercentHumidity': '0'}) == 0.0 + assert desc.rep_fn({"x.com.samsung.da.fivepercentHumidity": "0"}) == 0.0 # --------------------------------------------------------------------------- @@ -433,18 +474,20 @@ def test_humidity_five_percent_field_passes_a_genuine_zero_through(): # the two hrefs (/subdevices/vs/0, /runn/vs/0) unique to this board. # --------------------------------------------------------------------------- + def _ac_fac_bora(): - resources = _load_device('airconditioner_fac_bora') - info = resources['/information/vs/0'] + resources = _load_device("airconditioner_fac_bora") + 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 def test_fac_bora_resolves_to_airconditioner_registry(): reg, _ = _ac_fac_bora() - assert reg is not None and reg.name == 'airconditioner' + assert reg is not None and reg.name == "airconditioner" def test_fac_bora_no_unbound_hrefs(): @@ -463,8 +506,8 @@ def test_fac_bora_climate_entity_present(): def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed(): - assert '/subdevices/vs/0' in airconditioner._AC_IGNORED - assert '/runn/vs/0' in airconditioner._AC_IGNORED + assert "/subdevices/vs/0" in airconditioner._AC_IGNORED + assert "/runn/vs/0" in airconditioner._AC_IGNORED # --------------------------------------------------------------------------- @@ -477,18 +520,20 @@ def test_fac_bora_subdevices_and_runningmode_are_ignored_not_guessed(): # CURRENT_LIMIT/ANOMALY_LOAD. # --------------------------------------------------------------------------- + def _ac_lnx_rac_heatpump(): - resources = _load_device('airconditioner_lnx_rac_heatpump') - info = resources['/information/vs/0'] + resources = _load_device("airconditioner_lnx_rac_heatpump") + 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 def test_lnx_rac_heatpump_resolves_to_airconditioner_registry(): reg, _ = _ac_lnx_rac_heatpump() - assert reg is not None and reg.name == 'airconditioner' + assert reg is not None and reg.name == "airconditioner" def test_lnx_rac_heatpump_no_unbound_hrefs(): @@ -502,16 +547,16 @@ def test_lnx_rac_heatpump_absence_power_saving_state(): reg, resources = _ac_lnx_rac_heatpump() bound = discover(resources, reg.capabilities, reg.pattern_capabilities) state = flatten(bound, resources) - assert state['absence_power_saving_active'] is False - assert state['absence_power_saving_mode'] == 'normal' + assert state["absence_power_saving_active"] is False + assert state["absence_power_saving_mode"] == "normal" def test_lnx_rac_heatpump_motion_detect_wind_state(): reg, resources = _ac_lnx_rac_heatpump() bound = discover(resources, reg.capabilities, reg.pattern_capabilities) state = flatten(bound, resources) - assert state['motion_detect_wind_active'] is False - assert state['motion_detect_wind_mode'] == 'indirect' + assert state["motion_detect_wind_active"] is False + assert state["motion_detect_wind_mode"] == "indirect" def test_lnx_rac_heatpump_enable_switches_are_writable(): @@ -520,32 +565,44 @@ def test_lnx_rac_heatpump_enable_switches_are_writable(): token no-ops. The paired mode selects stay read-only sensors.""" absence_keys = {e.key for e in airconditioner.ABSENCE_POWER_SAVING.entities} motion_keys = {e.key for e in airconditioner.MOTION_DETECT_WIND.entities} - assert absence_keys == {'absence_power_saving_active', 'absence_power_saving_mode'} - assert motion_keys == {'motion_detect_wind_active', 'motion_detect_wind_mode'} - absence_mode = next(e for e in airconditioner.ABSENCE_POWER_SAVING.entities - if e.key == 'absence_power_saving_mode') - motion_mode = next(e for e in airconditioner.MOTION_DETECT_WIND.entities - if e.key == 'motion_detect_wind_mode') - assert not hasattr(absence_mode, 'write_fn') or absence_mode.write_fn is None - assert not hasattr(motion_mode, 'write_fn') or motion_mode.write_fn is None + assert absence_keys == {"absence_power_saving_active", "absence_power_saving_mode"} + assert motion_keys == {"motion_detect_wind_active", "motion_detect_wind_mode"} + absence_mode = next( + e + for e in airconditioner.ABSENCE_POWER_SAVING.entities + if e.key == "absence_power_saving_mode" + ) + motion_mode = next( + e for e in airconditioner.MOTION_DETECT_WIND.entities if e.key == "motion_detect_wind_mode" + ) + assert not hasattr(absence_mode, "write_fn") or absence_mode.write_fn is None + assert not hasattr(motion_mode, "write_fn") or motion_mode.write_fn is None def test_lnx_rac_heatpump_absence_power_saving_write_target(): - write = next(e for e in airconditioner.ABSENCE_POWER_SAVING.entities - if e.key == 'absence_power_saving_active').write_fn - assert write('On', {}) == ( - ['mds', 'absencepowersaving', 'vs', '0'], {'status': 'On'}) - assert write('Off', {}) == ( - ['mds', 'absencepowersaving', 'vs', '0'], {'status': 'Off'}) + write = next( + e + for e in airconditioner.ABSENCE_POWER_SAVING.entities + if e.key == "absence_power_saving_active" + ).write_fn + assert write("On", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "On"}) + assert write("Off", {}) == (["mds", "absencepowersaving", "vs", "0"], {"status": "Off"}) def test_lnx_rac_heatpump_motion_detect_wind_write_target(): - write = next(e for e in airconditioner.MOTION_DETECT_WIND.entities - if e.key == 'motion_detect_wind_active').write_fn - assert write('On', {}) == ( - ['option', 'motiondetectwind', 'stateful', 'vs', '0'], {'status': 'On'}) - assert write('Off', {}) == ( - ['option', 'motiondetectwind', 'stateful', 'vs', '0'], {'status': 'Off'}) + write = next( + e + for e in airconditioner.MOTION_DETECT_WIND.entities + if e.key == "motion_detect_wind_active" + ).write_fn + assert write("On", {}) == ( + ["option", "motiondetectwind", "stateful", "vs", "0"], + {"status": "On"}, + ) + assert write("Off", {}) == ( + ["option", "motiondetectwind", "stateful", "vs", "0"], + {"status": "Off"}, + ) # --------------------------------------------------------------------------- @@ -558,24 +615,21 @@ def test_lnx_rac_heatpump_motion_detect_wind_write_target(): # [[samsung-ac-local-vs-cloud-control]] memory). # --------------------------------------------------------------------------- + def _beep_desc(): - return next(e for e in airconditioner.CLIMATE.entities if e.key == 'beep') + return next(e for e in airconditioner.CLIMATE.entities if e.key == "beep") def _tropical_desc(): - return next(e for e in airconditioner.CLIMATE.entities - if e.key == 'tropical_night_mode') + return next(e for e in airconditioner.CLIMATE.entities if e.key == "tropical_night_mode") def test_beep_read_from_volume_token(): """Volume_100 (and any non-Mute) -> on; Volume_Mute -> off; no Volume_ slot -> None (entity won't bind via exists_fn).""" - assert airconditioner._beep_on( - {'x.com.samsung.da.options': ['Volume_100']}) is True - assert airconditioner._beep_on( - {'x.com.samsung.da.options': ['Volume_Mute']}) is False - assert airconditioner._beep_on( - {'x.com.samsung.da.options': ['Light_Off']}) is None + assert airconditioner._beep_on({"x.com.samsung.da.options": ["Volume_100"]}) is True + assert airconditioner._beep_on({"x.com.samsung.da.options": ["Volume_Mute"]}) is False + assert airconditioner._beep_on({"x.com.samsung.da.options": ["Light_Off"]}) is None assert airconditioner._beep_on({}) is None @@ -585,41 +639,40 @@ def test_beep_write_is_single_token_options_merge(): setting (e.g. Volume_50) survives an off/on cycle; falls back to 100 when no prior level is known or the prior token is itself Mute.""" write = _beep_desc().write_fn - assert write('On', {}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_100']}) - assert write('On', {'x.com.samsung.da.options': ['Volume_50']}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_50']}) - assert write('On', {'x.com.samsung.da.options': ['Volume_Mute']}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_100']}) - assert write('Off', {}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Volume_Mute']}) - assert write('Bogus', {}) is None + assert write("On", {}) == (["mode", "vs", "0"], {"x.com.samsung.da.options": ["Volume_100"]}) + assert write("On", {"x.com.samsung.da.options": ["Volume_50"]}) == ( + ["mode", "vs", "0"], + {"x.com.samsung.da.options": ["Volume_50"]}, + ) + assert write("On", {"x.com.samsung.da.options": ["Volume_Mute"]}) == ( + ["mode", "vs", "0"], + {"x.com.samsung.da.options": ["Volume_100"]}, + ) + assert write("Off", {}) == (["mode", "vs", "0"], {"x.com.samsung.da.options": ["Volume_Mute"]}) + assert write("Bogus", {}) is None def test_beep_absent_when_no_volume_token(): """TP1X_DA-AC-RAC-01011 carries no Volume_ option -- beep must not bind.""" reg, resources = _ac_tp1x() - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert 'beep' not in state + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert "beep" not in state def test_beep_state_on_windfree(): """The WindFree fixture reports Volume_100 -> beep reads True.""" reg, resources = _ac_windfree() - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state['beep'] is True + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state["beep"] is True def test_tropical_night_read_from_sleep_token(): """Sleep_ -> N; absent -> None.""" - assert airconditioner._tropical_night_value( - {'x.com.samsung.da.options': ['Sleep_0']}) == 0 - assert airconditioner._tropical_night_value( - {'x.com.samsung.da.options': ['Sleep_16']}) == 16 - assert airconditioner._tropical_night_value( - {'x.com.samsung.da.options': ['Volume_100']}) is None + assert airconditioner._tropical_night_value({"x.com.samsung.da.options": ["Sleep_0"]}) == 0 + assert airconditioner._tropical_night_value({"x.com.samsung.da.options": ["Sleep_16"]}) == 16 + assert ( + airconditioner._tropical_night_value({"x.com.samsung.da.options": ["Volume_100"]}) is None + ) assert airconditioner._tropical_night_value({}) is None @@ -627,16 +680,13 @@ def test_tropical_night_write_is_single_token_options_merge(): """Valid 0-16 -> `['Sleep_']`; out of range / non-numeric -> None (no write). Cloud counterpart: custom.airConditionerTropicalNightMode (0-16).""" write = _tropical_desc().write_fn - assert write(0, {}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_0']}) - assert write(16, {}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_16']}) + assert write(0, {}) == (["mode", "vs", "0"], {"x.com.samsung.da.options": ["Sleep_0"]}) + assert write(16, {}) == (["mode", "vs", "0"], {"x.com.samsung.da.options": ["Sleep_16"]}) assert write(17, {}) is None assert write(-1, {}) is None - assert write('not-a-number', {}) is None + assert write("not-a-number", {}) is None # Float rounds to nearest int within range. - assert write(5.6, {}) == ( - ['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Sleep_6']}) + assert write(5.6, {}) == (["mode", "vs", "0"], {"x.com.samsung.da.options": ["Sleep_6"]}) def test_tropical_night_disabled_by_default(): @@ -655,44 +705,47 @@ def test_tropical_night_disabled_by_default(): def test_tropical_night_absent_when_no_sleep_token(): """TP1X_DA-AC-WAC (window AC) carries no Sleep_ option -- tropical night mode must not bind.""" - resources = _load_device('airconditioner_window_ac') - info = resources['/information/vs/0'] + resources = _load_device("airconditioner_window_ac") + info = resources["/information/vs/0"] reg = for_device_by_model( - info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert 'tropical_night_mode' not in state + info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"] + ) + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert "tropical_night_mode" not in state def test_tropical_night_state_levels_across_fixtures(): """Sleep_0 / Sleep_6 / Sleep_16 surface as 0 / 6 / 16 respectively.""" + def level(name): res = _load_device(name) - info = res['/information/vs/0'] + info = res["/information/vs/0"] r = 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 flatten(discover(res, r.capabilities, r.pattern_capabilities), res).get( - 'tropical_night_mode') - assert level('airconditioner_windfree') == 0 - assert level('airconditioner_tp1x_da_ac_rac_01011') == 6 - assert level('airconditioner_tp2x_rac_20k') == 16 + "tropical_night_mode" + ) + + assert level("airconditioner_windfree") == 0 + assert level("airconditioner_tp1x_da_ac_rac_01011") == 6 + assert level("airconditioner_tp2x_rac_20k") == 16 def test_air_filter_usage_hours_reads_raw_count(): """filterUsage is a lifetime hour counter (41 of 500) that resets on filter replacement -- total_increasing, not measurement. Unit comes from filterCapacityUnit via unit_fn, not a hardcoded 'h'.""" - desc = next(e for e in airconditioner.AIR_FILTER.entities - if e.key == 'air_filter_usage_hours') - assert desc.value_fn('41') == 41 + desc = next(e for e in airconditioner.AIR_FILTER.entities if e.key == "air_filter_usage_hours") + assert desc.value_fn("41") == 41 assert desc.value_fn(41) == 41 assert desc.value_fn(None) is None - assert desc.value_fn('not-a-number') is None - assert desc.device_class == 'duration' - assert desc.state_class == 'total_increasing' - assert desc.unit_fn({'x.com.samsung.da.filterCapacityUnit': 'Hour'}) == 'h' - assert desc.unit_fn({'x.com.samsung.da.filterCapacityUnit': 'Minute'}) == 'min' - assert desc.unit_fn({}) == 'h' # static fallback when the field is absent + assert desc.value_fn("not-a-number") is None + assert desc.device_class == "duration" + assert desc.state_class == "total_increasing" + assert desc.unit_fn({"x.com.samsung.da.filterCapacityUnit": "Hour"}) == "h" + assert desc.unit_fn({"x.com.samsung.da.filterCapacityUnit": "Minute"}) == "min" + assert desc.unit_fn({}) == "h" # static fallback when the field is absent def test_air_filter_threshold_is_writable_select(): @@ -702,22 +755,25 @@ def test_air_filter_threshold_is_writable_select(): field back to /filter/airdustfilter/vs/0. Only binds where the enum is advertised -- boards without it leave this writable field unexposed rather than guess the valid set.""" - desc = next(e for e in airconditioner.AIR_FILTER.entities - if e.key == 'air_filter_threshold') + desc = next(e for e in airconditioner.AIR_FILTER.entities if e.key == "air_filter_threshold") assert isinstance(desc, SelectDesc) - assert desc.options_field == 'x.com.samsung.da.supportedFilterDesiredUsage' - assert desc.exists_fn( - {'x.com.samsung.da.supportedFilterDesiredUsage': ['180', '300', '500', '700']}, - {}) is True + assert desc.options_field == "x.com.samsung.da.supportedFilterDesiredUsage" + assert ( + desc.exists_fn( + {"x.com.samsung.da.supportedFilterDesiredUsage": ["180", "300", "500", "700"]}, {} + ) + is True + ) assert desc.exists_fn({}, {}) is False # Current value is stringified for option matching. - assert desc.value_fn('500') == '500' - assert desc.value_fn(500) == '500' + assert desc.value_fn("500") == "500" + assert desc.value_fn(500) == "500" assert desc.value_fn(None) is None # Write POSTs the selected option as the scalar field. - assert desc.write_fn('700', {}) == ( - ['filter', 'airdustfilter', 'vs', '0'], - {'x.com.samsung.da.filterDesiredUsage': '700'}) + assert desc.write_fn("700", {}) == ( + ["filter", "airdustfilter", "vs", "0"], + {"x.com.samsung.da.filterDesiredUsage": "700"}, + ) def test_air_filter_threshold_absent_without_supported_enum(): @@ -726,20 +782,18 @@ def test_air_filter_threshold_absent_without_supported_enum(): scalar field is present and writable. Don't expose a control whose valid options aren't known.""" reg, resources = _ac_windfree() - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert 'air_filter_threshold' not in state - assert state['air_filter_usage_hours'] == 41 - assert state['air_filter_usage'] == 8 # 41/500 -> 8% + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert "air_filter_threshold" not in state + assert state["air_filter_usage_hours"] == 41 + assert state["air_filter_usage"] == 8 # 41/500 -> 8% def test_air_filter_threshold_binds_on_enum_board(): """tp1x_rac advertises supportedFilterDesiredUsage -> threshold Select binds, current value read from filterDesiredUsage.""" - reg, resources = _resolve('airconditioner_tp1x_rac') - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state['air_filter_threshold'] == '500' + reg, resources = _resolve("airconditioner_tp1x_rac") + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state["air_filter_threshold"] == "500" def test_air_quality_sensors_from_sensors_vs_items(): @@ -751,17 +805,15 @@ def test_air_quality_sensors_from_sensors_vs_items(): array whose second element is unconfirmed -- v[0] is taken as the reading (see _sensor_item_value).""" reg, resources = _ac_windfree() - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state['clean_level'] == 0 # numeric (int), corroborated - for key in ('odor', 'dust', 'fine_dust', 'super_fine_dust'): - assert state[key] == '0' # string diagnostic + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state["clean_level"] == 0 # numeric (int), corroborated + for key in ("odor", "dust", "fine_dust", "super_fine_dust"): + assert state[key] == "0" # string diagnostic # tp1x_da_ac_rac_01011 is the only fixture with a non-zero air-quality # reading -- the one that catches a value_fn regression. reg2, resources2 = _ac_tp1x() - state2 = flatten( - discover(resources2, reg2.capabilities, reg2.pattern_capabilities), resources2) - assert state2['clean_level'] == 1 + state2 = flatten(discover(resources2, reg2.capabilities, reg2.pattern_capabilities), resources2) + assert state2["clean_level"] == 1 def test_air_quality_disabled_by_default(): @@ -782,7 +834,7 @@ def test_air_quality_disabled_by_default(): _has_sensor_type) and disabled by default instead, same precedent as fridge.rack_count / cooktop.paired_hood_model / tropical_night_mode -- units that do have the sensor can enable it themselves.""" - for key in ('clean_level', 'odor', 'dust', 'fine_dust', 'super_fine_dust'): + for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust"): desc = next(e for e in airconditioner.AIR_QUALITY.entities if e.key == key) assert desc.enabled_default is False, key @@ -791,11 +843,10 @@ def test_air_quality_absent_when_no_sensor_items(): """A board whose /sensors/vs/0 carries an empty items[] (the cool-only RAC variant) binds no air-quality entities -- exists_fn gates each on its item type, not merely on the href being present.""" - reg, resources = _resolve('airconditioner_tp1x_rac_coolonly') - assert '/sensors/vs/0' in resources # the href is there, just empty - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - for key in ('clean_level', 'odor', 'dust', 'fine_dust', 'super_fine_dust'): + reg, resources = _resolve("airconditioner_tp1x_rac_coolonly") + assert "/sensors/vs/0" in resources # the href is there, just empty + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + for key in ("clean_level", "odor", "dust", "fine_dust", "super_fine_dust"): assert key not in state, key @@ -803,13 +854,13 @@ def test_sensor_item_value_picks_first_value(): """_sensor_item_value returns the first element of the value list, as a string; None when the item is absent or its value is empty.""" items = [ - {'x.com.samsung.da.type': 'Dust', 'x.com.samsung.da.value': ['0', '0']}, - {'x.com.samsung.da.type': 'Odor', 'x.com.samsung.da.value': []}, + {"x.com.samsung.da.type": "Dust", "x.com.samsung.da.value": ["0", "0"]}, + {"x.com.samsung.da.type": "Odor", "x.com.samsung.da.value": []}, ] - assert airconditioner._sensor_item_value(items, 'Dust') == '0' - assert airconditioner._sensor_item_value(items, 'Odor') is None - assert airconditioner._sensor_item_value(items, 'Missing') is None - assert airconditioner._sensor_item_value(None, 'Dust') is None + assert airconditioner._sensor_item_value(items, "Dust") == "0" + assert airconditioner._sensor_item_value(items, "Odor") is None + assert airconditioner._sensor_item_value(items, "Missing") is None + assert airconditioner._sensor_item_value(None, "Dust") is None def test_beep_and_tropical_night_stay_off_legacy_krac_board(): @@ -819,13 +870,12 @@ def test_beep_and_tropical_night_stay_off_legacy_krac_board(): test_legacy_krac_board_beep_is_a_switch_not_a_volume_number below); Sleep_ is still good_sleep, not tropical_night_mode, on this board (see airconditioner.CLIMATE).""" - reg, resources = _resolve('airconditioner_artik051_krac_18k') - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert 'buzzer_volume' not in state - assert 'tropical_night_mode' not in state - assert state['beep'] is True - assert state['good_sleep'] == 0.0 + reg, resources = _resolve("airconditioner_artik051_krac_18k") + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert "buzzer_volume" not in state + assert "tropical_night_mode" not in state + assert state["beep"] is True + assert state["good_sleep"] == 0.0 def test_legacy_krac_board_beep_is_a_switch_not_a_volume_number(): @@ -835,22 +885,20 @@ def test_legacy_krac_board_beep_is_a_switch_not_a_volume_number(): produce the literal 'Mute' token needed to turn it off. 'beep' now applies uniformly across board generations instead of being gated off the legacy board.""" - reg, resources = _resolve('airconditioner_artik051_krac_18k') - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state['beep'] is True + reg, resources = _resolve("airconditioner_artik051_krac_18k") + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state["beep"] is True - resources['/mode/vs/0']['x.com.samsung.da.options'] = [ - o.replace('Volume_100', 'Volume_Mute') - for o in resources['/mode/vs/0']['x.com.samsung.da.options'] + resources["/mode/vs/0"]["x.com.samsung.da.options"] = [ + o.replace("Volume_100", "Volume_Mute") + for o in resources["/mode/vs/0"]["x.com.samsung.da.options"] ] - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state['beep'] is False + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state["beep"] is False - path, body = airconditioner._beep_write('Off', resources['/mode/vs/0']) - assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['Volume_Mute']} + path, body = airconditioner._beep_write("Off", resources["/mode/vs/0"]) + assert path == ["mode", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["Volume_Mute"]} def test_legacy_krac_board_energy_kwh_uses_centiwatt_hour_scale(): @@ -859,19 +907,17 @@ def test_legacy_krac_board_energy_kwh_uses_centiwatt_hour_scale(): assumes -- confirmed against the reporter's own SmartThings-app reading of 1,174.30 kWh. ENERGY_METER_LEGACY's /100000 scale must produce that exact value, not the /1000-only 117430.0 the generic capability would.""" - reg, resources = _resolve('airconditioner_artik051_krac_energy') - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state['energy_kwh'] == 1174.3 + reg, resources = _resolve("airconditioner_artik051_krac_energy") + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state["energy_kwh"] == 1174.3 def test_non_legacy_board_energy_kwh_still_uses_plain_wh_scale(): """The ENERGY_METER_GENERIC/LEGACY split must not change behavior for every other AC board generation -- same value as plain wh_to_kwh.""" reg, resources = _ac() - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state['energy_kwh'] == round(1686632 / 1000.0, 2) + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state["energy_kwh"] == round(1686632 / 1000.0, 2) # --------------------------------------------------------------------------- @@ -887,8 +933,9 @@ def test_non_legacy_board_energy_kwh_still_uses_plain_wh_scale(): # _odor_controller_active's docstring. # --------------------------------------------------------------------------- + def _ac_odor_controller(): - return _resolve('airconditioner_tp1x_rac_odor_controller') + return _resolve("airconditioner_tp1x_rac_odor_controller") def test_odor_controller_no_unbound_hrefs(): @@ -906,30 +953,49 @@ def test_odor_controller_fan_and_windfree_already_bind(): reg, resources = _ac_odor_controller() bound = discover(resources, reg.capabilities, reg.pattern_capabilities) climate = [b for b in bound if isinstance(b.desc, ClimateDesc)] - assert len(climate) == 1 and climate[0].href == '/mode/vs/0' - assert resources['/wind/strength/vs/0']['x.com.samsung.da.supportedModes'] == [ - '0', '1', '2', '3', '4'] - assert resources['/mode/convenient/vs/0']['x.com.samsung.da.supportedModes'] == [ - 'Off', 'Sleep', 'Quiet', 'Speed', 'Nano', 'NanoSleep'] + assert len(climate) == 1 and climate[0].href == "/mode/vs/0" + assert resources["/wind/strength/vs/0"]["x.com.samsung.da.supportedModes"] == [ + "0", + "1", + "2", + "3", + "4", + ] + assert resources["/mode/convenient/vs/0"]["x.com.samsung.da.supportedModes"] == [ + "Off", + "Sleep", + "Quiet", + "Speed", + "Nano", + "NanoSleep", + ] def test_odor_controller_state_and_progress_present(): reg, resources = _ac_odor_controller() - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert state['odor_controller_active'] is False # SmartCoolClean_Off - assert state['odor_controller_progress'] == 0 # ProgressSmartClean_0 + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert state["odor_controller_active"] is False # SmartCoolClean_Off + assert state["odor_controller_progress"] == 0 # ProgressSmartClean_0 def test_odor_controller_read_from_option_tokens(): - assert airconditioner._odor_controller_active( - {'x.com.samsung.da.options': ['SmartCoolClean_On']}) is True - assert airconditioner._odor_controller_active( - {'x.com.samsung.da.options': ['SmartCoolClean_Off']}) is False - assert airconditioner._odor_controller_active( - {'x.com.samsung.da.options': ['Volume_100']}) is None - assert airconditioner._odor_controller_progress( - {'x.com.samsung.da.options': ['ProgressSmartClean_42']}) == 42 + assert ( + airconditioner._odor_controller_active({"x.com.samsung.da.options": ["SmartCoolClean_On"]}) + is True + ) + assert ( + airconditioner._odor_controller_active({"x.com.samsung.da.options": ["SmartCoolClean_Off"]}) + is False + ) + assert ( + airconditioner._odor_controller_active({"x.com.samsung.da.options": ["Volume_100"]}) is None + ) + assert ( + airconditioner._odor_controller_progress( + {"x.com.samsung.da.options": ["ProgressSmartClean_42"]} + ) + == 42 + ) assert airconditioner._odor_controller_progress({}) is None @@ -937,18 +1003,17 @@ def test_odor_controller_absent_when_no_smartcoolclean_token(): """The original issue #17 dump's /mode/vs/0 options carry no SmartCoolClean_/ProgressSmartClean_ tokens -- neither entity binds.""" reg, resources = _ac() - state = flatten( - discover(resources, reg.capabilities, reg.pattern_capabilities), resources) - assert 'odor_controller_active' not in state - assert 'odor_controller_progress' not in state + state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources) + assert "odor_controller_active" not in state + assert "odor_controller_progress" not in state def test_odor_controller_is_read_only(): """No command capability is confirmed for SmartCoolClean -- exposed read-only, same 'don't guess' precedent as CURRENT_LIMIT/ANOMALY_LOAD.""" - active = next(e for e in airconditioner.CLIMATE.entities - if e.key == 'odor_controller_active') - progress = next(e for e in airconditioner.CLIMATE.entities - if e.key == 'odor_controller_progress') - assert not hasattr(active, 'write_fn') or active.write_fn is None - assert not hasattr(progress, 'write_fn') or progress.write_fn is None + active = next(e for e in airconditioner.CLIMATE.entities if e.key == "odor_controller_active") + progress = next( + e for e in airconditioner.CLIMATE.entities if e.key == "odor_controller_progress" + ) + assert not hasattr(active, "write_fn") or active.write_fn is None + assert not hasattr(progress, "write_fn") or progress.write_fn is None diff --git a/tests/test_airconditioner_tp1x_rac_01001_fan.py b/tests/test_airconditioner_tp1x_rac_01001_fan.py index 1085728..414ccfd 100644 --- a/tests/test_airconditioner_tp1x_rac_01001_fan.py +++ b/tests/test_airconditioner_tp1x_rac_01001_fan.py @@ -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") diff --git a/tests/test_batch.py b/tests/test_batch.py index d707662..c71ac84 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -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) == {} diff --git a/tests/test_by_type.py b/tests/test_by_type.py index ce29aa4..2bf6931 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -1,7 +1,10 @@ """Tests for samsung_appliance/registry/by_type.""" + import pytest + from custom_components.localthings.registry.by_type import ( - DeviceRegistry, _REGISTRY_BY_KEY, + _REGISTRY_BY_KEY, + DeviceRegistry, ) @@ -28,28 +31,42 @@ class TestDeviceRegistries: class TestWasherRegistry: def test_washer_registry_registered(self): from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY - assert 'washer' in _REGISTRY_BY_KEY - assert _REGISTRY_BY_KEY['washer'].name == 'washer' + + assert "washer" in _REGISTRY_BY_KEY + assert _REGISTRY_BY_KEY["washer"].name == "washer" def test_washer_registry_has_no_dup_hrefs(self): from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY - registry = _REGISTRY_BY_KEY['washer'] + + registry = _REGISTRY_BY_KEY["washer"] for href, caps in registry.capabilities.items(): if len(caps) > 1: for cap in caps: - assert cap.rt_filter is not None or cap.match_fn is not None, \ + assert cap.rt_filter is not None or cap.match_fn is not None, ( f"href {href!r} has multiple caps but {cap!r} lacks rt_filter and match_fn" + ) def test_washer_registry_covers_known_hrefs(self): from custom_components.localthings.registry.by_type import _REGISTRY_BY_KEY - registry = _REGISTRY_BY_KEY['washer'] + + registry = _REGISTRY_BY_KEY["washer"] for href in ( - '/power/0', '/power/vs/0', '/kidslock/0', '/kidslock/vs/0', - '/remotectrl/0', '/remotectrl/vs/0', '/alarms/vs/0', - '/energy/consumption/vs/0', '/water/consumption/vs/0', - '/operational/state/vs/0', '/washer/vs/0', '/course/vs/0', - '/buzzersound/vs/0', '/wm/jobbeginingstatus/vs/0', - '/diagnosis/vs/0', '/otninformation/vs/0', + "/power/0", + "/power/vs/0", + "/kidslock/0", + "/kidslock/vs/0", + "/remotectrl/0", + "/remotectrl/vs/0", + "/alarms/vs/0", + "/energy/consumption/vs/0", + "/water/consumption/vs/0", + "/operational/state/vs/0", + "/washer/vs/0", + "/course/vs/0", + "/buzzersound/vs/0", + "/wm/jobbeginingstatus/vs/0", + "/diagnosis/vs/0", + "/otninformation/vs/0", ): assert href in registry.capabilities, f"{href} missing from washer registry" @@ -57,32 +74,43 @@ class TestWasherRegistry: class TestBoardTokens: def test_splits_pipe_prefix_into_whole_tokens(self): from custom_components.localthings.registry.by_type import _board_tokens - assert _board_tokens('ARTIK051_DONGLE_REF|00127641|000800200014', '|') == [ - 'ARTIK051', 'DONGLE', 'REF', + + assert _board_tokens("ARTIK051_DONGLE_REF|00127641|000800200014", "|") == [ + "ARTIK051", + "DONGLE", + "REF", ] def test_ignores_everything_after_the_cut(self): from custom_components.localthings.registry.by_type import _board_tokens - assert _board_tokens('TP2X_RAC_20K|abc|REF_should_not_appear', '|') == [ - 'TP2X', 'RAC', '20K', + + assert _board_tokens("TP2X_RAC_20K|abc|REF_should_not_appear", "|") == [ + "TP2X", + "RAC", + "20K", ] def test_both_delimiters_produce_the_same_tokens(self): """The whole point of tokenizing: Samsung spells one board family with either delimiter, and both must reduce to the same tokens.""" from custom_components.localthings.registry.by_type import _board_tokens - assert (_board_tokens('TP1X_DA-AC-RAC-01001_0000', '|') - == _board_tokens('TP1X_DA_AC_RAC_01001_0000', '|') - == ['TP1X', 'DA', 'AC', 'RAC', '01001', '0000']) + + assert ( + _board_tokens("TP1X_DA-AC-RAC-01001_0000", "|") + == _board_tokens("TP1X_DA_AC_RAC_01001_0000", "|") + == ["TP1X", "DA", "AC", "RAC", "01001", "0000"] + ) def test_upper_cases_and_drops_empty_runs(self): from custom_components.localthings.registry.by_type import _board_tokens - assert _board_tokens('a--b__c', '|') == ['A', 'B', 'C'] + + assert _board_tokens("a--b__c", "|") == ["A", "B", "C"] def test_empty_for_none_or_empty_input(self): from custom_components.localthings.registry.by_type import _board_tokens - assert _board_tokens('', '|') == [] - assert _board_tokens(None, '|') == [] + + assert _board_tokens("", "|") == [] + assert _board_tokens(None, "|") == [] class TestBoardTokenTable: @@ -90,13 +118,17 @@ class TestBoardTokenTable: """'DA-AC-' prefixes RAC/WAC/DHM/AIR alike -- a bare 'AC' entry would type the dehumidifier and the air purifier as air conditioners.""" from custom_components.localthings.registry.by_type import _BOARD_TOKEN_TO_KEY - for family_token in ('AC', 'DA', 'KS', 'WM', 'TP1X', 'TP2X', 'ARTIK051'): + + for family_token in ("AC", "DA", "KS", "WM", "TP1X", "TP2X", "ARTIK051"): assert family_token not in _BOARD_TOKEN_TO_KEY def test_every_token_resolves_to_a_real_registry(self): from custom_components.localthings.registry.by_type import ( - _BOARD_TOKEN_TO_KEY, _CONSUMER_PREFIX_TO_KEY, _REGISTRY_BY_KEY, + _BOARD_TOKEN_TO_KEY, + _CONSUMER_PREFIX_TO_KEY, + _REGISTRY_BY_KEY, ) + for token, key in _BOARD_TOKEN_TO_KEY.items(): assert key in _REGISTRY_BY_KEY, f"{token!r} -> unknown registry {key!r}" for prefix, key in _CONSUMER_PREFIX_TO_KEY.items(): @@ -106,6 +138,7 @@ class TestBoardTokenTable: """`_board_tokens` upper-cases before lookup, so a lower-case entry would be dead.""" from custom_components.localthings.registry.by_type import _BOARD_TOKEN_TO_KEY + for token in _BOARD_TOKEN_TO_KEY: assert token == token.upper() @@ -113,22 +146,27 @@ class TestBoardTokenTable: class TestConsumerModelKey: def test_finds_key_in_last_segment(self): from custom_components.localthings.registry.by_type import _consumer_model_key - assert _consumer_model_key('DA_WM_TP1_21_COMMON_WW5000C') == 'washer' + + assert _consumer_model_key("DA_WM_TP1_21_COMMON_WW5000C") == "washer" def test_finds_key_before_a_trailing_unrecognized_segment(self): """Issue #79: 'DVE50A8800_8600' pairs two model numbers -- the real consumer token is the second-to-last segment, not the last.""" from custom_components.localthings.registry.by_type import _consumer_model_key - assert _consumer_model_key( - 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080') == 'dryer' + + assert ( + _consumer_model_key("DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080") == "dryer" + ) def test_ignores_everything_after_first_slash(self): from custom_components.localthings.registry.by_type import _consumer_model_key - assert _consumer_model_key('DA_WM_TP1_21_COMMON_WW5000C/DW9000_board') == 'washer' + + assert _consumer_model_key("DA_WM_TP1_21_COMMON_WW5000C/DW9000_board") == "washer" def test_none_when_no_segment_matches(self): from custom_components.localthings.registry.by_type import _consumer_model_key - assert _consumer_model_key('ARTIK051_DONGLE_REF') is None + + assert _consumer_model_key("ARTIK051_DONGLE_REF") is None class TestForDeviceByOicType: @@ -136,26 +174,32 @@ class TestForDeviceByOicType: def test_every_oic_type_resolves_to_a_real_registry(self): from custom_components.localthings.registry.by_type import ( - _OIC_TYPE_TO_KEY, _REGISTRY_BY_KEY, + _OIC_TYPE_TO_KEY, + _REGISTRY_BY_KEY, ) + for oic_type, key in _OIC_TYPE_TO_KEY.items(): assert key in _REGISTRY_BY_KEY, f"{oic_type!r} -> unknown registry {key!r}" - @pytest.mark.parametrize('oic_type, expected', [ - ('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.hood', 'range_hood'), - ('x.com.st.d.stickcleaner', 'vacuum_station'), - ('x.com.st.d.steamcloset', 'air_dresser'), - ('x.com.st.d.airqualitysensor', 'air_monitor'), - ]) + @pytest.mark.parametrize( + "oic_type, expected", + [ + ("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.hood", "range_hood"), + ("x.com.st.d.stickcleaner", "vacuum_station"), + ("x.com.st.d.steamcloset", "air_dresser"), + ("x.com.st.d.airqualitysensor", "air_monitor"), + ], + ) def test_known_oic_types_resolve(self, oic_type, expected): from custom_components.localthings.registry.by_type import for_device_by_oic_type + reg = for_device_by_oic_type((oic_type,)) assert reg is not None assert reg.name == expected @@ -164,11 +208,13 @@ class TestForDeviceByOicType: """The generic 'oic.wk.d' base type every OCF device carries alongside its concrete type isn't itself a device type.""" from custom_components.localthings.registry.by_type import for_device_by_oic_type - assert for_device_by_oic_type(('oic.wk.d',)) is None + + assert for_device_by_oic_type(("oic.wk.d",)) is None def test_unrecognized_type_returns_none(self): from custom_components.localthings.registry.by_type import for_device_by_oic_type - assert for_device_by_oic_type(('oic.d.somethingnew',)) is None + + assert for_device_by_oic_type(("oic.d.somethingnew",)) is None def test_robotcleaner_is_not_mapped_to_the_vacuum_station_registry(self): """'oic.d.robotcleaner' names an actual robot vacuum, a different @@ -176,7 +222,8 @@ class TestForDeviceByOicType: vacuum-body capabilities at all) -- mapping it there would misroute a genuine robot-vacuum dump.""" from custom_components.localthings.registry.by_type import for_device_by_oic_type - assert for_device_by_oic_type(('oic.d.robotcleaner',)) is None + + assert for_device_by_oic_type(("oic.d.robotcleaner",)) is None def test_cooktop_is_not_mapped_to_either_cooktop_registry(self): """'oic.d.cooktop' cannot tell the two cooktop families apart. @@ -188,19 +235,22 @@ class TestForDeviceByOicType: it would override a board token that had it right. """ from custom_components.localthings.registry.by_type import for_device_by_oic_type - assert for_device_by_oic_type(('oic.d.cooktop',)) is None + + assert for_device_by_oic_type(("oic.d.cooktop",)) is None def test_empty_returns_none(self): from custom_components.localthings.registry.by_type import for_device_by_oic_type + assert for_device_by_oic_type(()) is None def test_finds_the_concrete_type_alongside_the_generic_one(self): """A real /oic/d `rt` carries both the generic base type and the concrete one, order unspecified -- either position must resolve.""" from custom_components.localthings.registry.by_type import for_device_by_oic_type - reg = for_device_by_oic_type(('oic.wk.d', 'oic.d.washer')) + + reg = for_device_by_oic_type(("oic.wk.d", "oic.d.washer")) assert reg is not None - assert reg.name == 'washer' + assert reg.name == "washer" class TestForDeviceByModel: @@ -208,31 +258,35 @@ class TestForDeviceByModel: def test_washer_ww_prefix(self): from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000', - 'DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048', + "DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000", + "DA_WM_TP1_21_COMMON_WW5000C/DC92-03495A_B048", ) assert reg is not None - assert reg.name == 'washer' + assert reg.name == "washer" def test_washer_wd_prefix(self): from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000', - 'DA_WM_TP1_21_COMMON_WD7000B/DC92-03724A_004D', + "DA_WM_TP1_21_COMMON|20375141|20010002001811424AA30217008A0000", + "DA_WM_TP1_21_COMMON_WD7000B/DC92-03724A_004D", ) assert reg is not None - assert reg.name == 'washer' + assert reg.name == "washer" def test_dryer_not_misdetected_as_washer(self): """Dryer shares the DA_WM_ board prefix with washer -- must not be misrouted despite the shared prefix.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'DA_WM_TP2_20_COMMON_DV5000T', 'DA_WM_TP2_20_COMMON_DV5000T', + "DA_WM_TP2_20_COMMON_DV5000T", + "DA_WM_TP2_20_COMMON_DV5000T", ) assert reg is not None - assert reg.name == 'dryer' + assert reg.name == "dryer" def test_dryer_dve50a8600_paired_model_numbers_in_description(self): """Issue #79: description pairs two model numbers @@ -241,34 +295,37 @@ class TestForDeviceByModel: recognizable prefix on its own). The old last-segment-only check fell through to 'unknown' here.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000', - 'DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080', + "DA_WM_TP1_21_COMMON|20286441|300000010015110002A3031700000000", + "DA_WM_TP1_21_COMMON_DVE50A8800_8600/DC92-02835A_0080", ) assert reg is not None - assert reg.name == 'dryer' + assert reg.name == "dryer" def test_dishwasher_not_misdetected_as_washer(self): """Dishwasher's modelNum contains the substring 'WW' -- must not be misrouted by a naive substring match.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'ADW-WW-RTL-24-AILITE|90000541|400002010019130059C1000500E10000', - 'ADW-WW-RTL-24-AILITE_DW9000F/DD91-00002A_0002', + "ADW-WW-RTL-24-AILITE|90000541|400002010019130059C1000500E10000", + "ADW-WW-RTL-24-AILITE_DW9000F/DD91-00002A_0002", ) assert reg is not None - assert reg.name == 'dishwasher' + assert reg.name == "dishwasher" def test_refrigerator_via_modelnum_ref_token(self): """Refrigerator's description has no consumer-model suffix; falls back to the '_REF_' token in modelNum.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_REF_21K|00176141|00000850031813294103010041030000', - 'TP1X_REF_21K', + "TP1X_REF_21K|00176141|00000850031813294103010041030000", + "TP1X_REF_21K", ) assert reg is not None - assert reg.name == 'refrigerator' + assert reg.name == "refrigerator" def test_refrigerator_rl_series_via_ref_token(self): """Issue #7: RL38C6B0CWW/EG (a bottom-freezer RL-series fridge, not @@ -277,12 +334,13 @@ class TestForDeviceByModel: internal platform code as any other TP1X-based fridge, so the existing '_REF_' fallback already resolves it correctly.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_REF_21K|00156941|00050126001611304100000031010000', - 'TP1X_REF_21K', + "TP1X_REF_21K|00156941|00050126001611304100000031010000", + "TP1X_REF_21K", ) assert reg is not None - assert reg.name == 'refrigerator' + assert reg.name == "refrigerator" def test_refrigerator_dongle_ref_pipe_delimited_modelnum(self): """Issues #77/#83: the ARTIK051_DONGLE_REF family's modelNum is @@ -291,24 +349,26 @@ class TestForDeviceByModel: substring check used to miss it entirely and the device fell back to 'unknown' with only common capabilities.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000', - 'ARTIK_REF_17K', + "ARTIK051_DONGLE_REF|00127641|00080020001430300100000000000000", + "ARTIK_REF_17K", ) assert reg is not None - assert reg.name == 'refrigerator' + assert reg.name == "refrigerator" def test_airconditioner_via_prac_token(self): """Issue #17: a room AC (ARTIK051_PRAC_20K) reports no oneUiVersion and an unrecognized consumer token ('20K'); it falls back to the '_PRAC_' (Package Room Air Conditioner) token in modelNum.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000', - 'ARTIK051_PRAC_20K', + "ARTIK051_PRAC_20K|10217841|60010532001411004200003000000000", + "ARTIK051_PRAC_20K", ) assert reg is not None - assert reg.name == 'airconditioner' + assert reg.name == "airconditioner" def test_airconditioner_via_cac_token(self): """Issue #191: a cassette AC (TP1X_DA-AC-CAC-01001_0000) regressed to @@ -316,12 +376,13 @@ class TestForDeviceByModel: dropped -- 'CAC' had never been added to the modelNum board-token table, only reachable before via oneUiVersion's '7.0 Air conditioner'.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00', - 'TP1X_DA-AC-CAC-01001_0000', + "TP1X_DA-AC-CAC-01001_0000|10255541|60030748171811DF42005F2A00F2ED00", + "TP1X_DA-AC-CAC-01001_0000", ) assert reg is not None - assert reg.name == 'airconditioner' + assert reg.name == "airconditioner" def test_dehumidifier_via_dhm_token(self): """Issue #88: a dehumidifier (AY18CG7500GED) shares the DA_AC_ board @@ -330,12 +391,13 @@ class TestForDeviceByModel: '_RAC_'/'_PRAC_'/'_WAC_'; falls back to the '_DHM_' token in modelNum.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_DA_AC_DHM_01001_0000|10253841|77000000001700000A00000000000000', - 'TP1X_DA_AC_DHM_01001_0000', + "TP1X_DA_AC_DHM_01001_0000|10253841|77000000001700000A00000000000000", + "TP1X_DA_AC_DHM_01001_0000", ) assert reg is not None - assert reg.name == 'dehumidifier' + assert reg.name == "dehumidifier" def test_ehs_via_ehs_token(self): """A Samsung EHS air-to-water heat pump (TP1X_DA_AC_EHS_01001_0000) @@ -344,24 +406,26 @@ class TestForDeviceByModel: instead of '_RAC_'/'_PRAC_'/'_WAC_'/'_DHM_'; falls back to the '_EHS_' token in modelNum.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_DA_AC_EHS_01001_0000|10250141|60070105001711034A00010000002000', - 'TP1X_DA_AC_EHS_01001_0000', + "TP1X_DA_AC_EHS_01001_0000|10250141|60070105001711034A00010000002000", + "TP1X_DA_AC_EHS_01001_0000", ) assert reg is not None - assert reg.name == 'ehs' + assert reg.name == "ehs" def test_water_purifier_via_waterpurifier_token(self): """Issue #90: a water purifier (TP2X_WATERPURIFIER_20K) reports no oneUiVersion and no consumer-prefix match; falls back to the 'WATERPURIFIER' token shared by modelNum and description.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000', - 'TP2X_WATERPURIFIER_20K', + "TP2X_WATERPURIFIER_20K|00132341|900000000215130001060F0000020000", + "TP2X_WATERPURIFIER_20K", ) assert reg is not None - assert reg.name == 'water_purifier' + assert reg.name == "water_purifier" def test_water_purifier_wins_over_ref_when_both_tokens_present(self): """Issue #196: an AILITE water purifier (RWP70F15ANW) spells its @@ -370,25 +434,26 @@ class TestForDeviceByModel: 'WATERPURIFIER' -- misrouting it to the refrigerator registry, whose resource surface shares almost nothing with a water purifier.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'AILITE_DA-REF-WATERPURIFIER-01011|70674641|' - '900100000219130081088700001E0000', - 'AILITE_WATERPURIFIER_25K', + "AILITE_DA-REF-WATERPURIFIER-01011|70674641|900100000219130081088700001E0000", + "AILITE_WATERPURIFIER_25K", ) assert reg is not None - assert reg.name == 'water_purifier' + assert reg.name == "water_purifier" def test_bare_ref_token_still_resolves_refrigerator(self): """Regression guard for the #196 carve-out above: a genuine refrigerator modelNum with no 'WATERPURIFIER' token must still resolve to 'refrigerator'.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_REF_21K|00175941|00050126001811344100000020090000', - 'TP1X_REF_21K', + "TP1X_REF_21K|00175941|00050126001811344100000020090000", + "TP1X_REF_21K", ) assert reg is not None - assert reg.name == 'refrigerator' + assert reg.name == "refrigerator" def test_airconditioner_via_wac_token(self): """Issue #87: a Bespoke Window AC (AW06C7155EWAZ) reports no @@ -396,12 +461,13 @@ class TestForDeviceByModel: Conditioner) token instead of '_RAC_'/'_PRAC_'; falls back to the '_WAC_' token in modelNum.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_DA_AC_WAC_01001_0000|40460041|50030018001611020A00000000000000', - 'AW06C7155EWAZ', + "TP1X_DA_AC_WAC_01001_0000|40460041|50030018001611020A00000000000000", + "AW06C7155EWAZ", ) assert reg is not None - assert reg.name == 'airconditioner' + assert reg.name == "airconditioner" def test_wac_token_not_shadowed_by_wa_washer_prefix(self): """Regression: adding the 'WA' consumer-model prefix (issue #106) @@ -410,12 +476,13 @@ class TestForDeviceByModel: 'WAC' segment -- 'WAC'[:2] == 'WA' would otherwise match the washer prefix before the more specific '_WAC_' modelNum check ever runs.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_DA_AC_WAC_01001_0000|40460041|50030018001611020A00000000000000', - 'TP1X_DA_AC_WAC_01001_0000', + "TP1X_DA_AC_WAC_01001_0000|40460041|50030018001611020A00000000000000", + "TP1X_DA_AC_WAC_01001_0000", ) assert reg is not None - assert reg.name == 'airconditioner' + assert reg.name == "airconditioner" def test_airconditioner_via_ara_ww_token(self): """Issues #115/#116/#117/#120: ARA-WW-TP1-22-COMMON wall-mount RACs @@ -425,12 +492,13 @@ class TestForDeviceByModel: registry as every other TP1X-class room AC rather than a new device type.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'ARA-WW-TP1-22-COMMON|10229641|6001051A001511014600083200800000', - 'ARA-WW-TP1-22-COMMON', + "ARA-WW-TP1-22-COMMON|10229641|6001051A001511014600083200800000", + "ARA-WW-TP1-22-COMMON", ) assert reg is not None - assert reg.name == 'airconditioner' + assert reg.name == "airconditioner" def test_microwave_via_microwave_token(self): """Issue #121/#66: a combi microwave (MW7300B-/EU1) reports no @@ -439,12 +507,13 @@ class TestForDeviceByModel: (initially folded into 'oven' for issue #121, split out into a distinct device type per user feedback).""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_DA-KS-MICROWAVE-01041|40475341|50040100021811000A00000000000000', - 'MW7300B-/EU1', + "TP1X_DA-KS-MICROWAVE-01041|40475341|50040100021811000A00000000000000", + "MW7300B-/EU1", ) assert reg is not None - assert reg.name == 'microwave' + assert reg.name == "microwave" def test_vacuum_station_via_vskr_token(self): """Issue #131: a stick-vacuum clean/auto-empty station @@ -455,12 +524,13 @@ class TestForDeviceByModel: dustbag/dustbin/UV-sanitize resources -- see capabilities/vacuum_station.py's module docstring).""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'A-VSKR-TP1-22-VS9500AL|50023541|80030100001611000800000000000000', - 'A-VSKR-TP1-22-VS9500AL', + "A-VSKR-TP1-22-VS9500AL|50023541|80030100001611000800000000000000", + "A-VSKR-TP1-22-VS9500AL", ) assert reg is not None - assert reg.name == 'vacuum_station' + assert reg.name == "vacuum_station" def test_air_monitor_via_asm_token(self): """Issue #210: a standalone air-quality sensor puck @@ -468,74 +538,81 @@ class TestForDeviceByModel: washer/dryer/dishwasher consumer prefix; falls back to the 'ASM' token in modelNum onto the air_monitor registry.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000', - 'ASM-KR-TP1-22-ACMB1M', + "ASM-KR-TP1-22-ACMB1M|10243041|75000000001611C40800020000080000", + "ASM-KR-TP1-22-ACMB1M", ) assert reg is not None - assert reg.name == 'air_monitor' + assert reg.name == "air_monitor" def test_cooktop_via_legacy_model_description(self): """Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'ARTIK051_GB_CT_001|40424141|50000204001211000200000000000000', - 'ARTIK051_GLOBAL_COOKTOP', + "ARTIK051_GB_CT_001|40424141|50000204001211000200000000000000", + "ARTIK051_GLOBAL_COOKTOP", ) assert reg is not None - assert reg.name == 'gas_cooktop' + assert reg.name == "gas_cooktop" def test_range_hood_via_ahd_model(self): from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000', - 'AHD-WW-TP1-22-COMMON', + "AHD-WW-TP1-22-COMMON|20136141|7800006B001713C44D00090001030000", + "AHD-WW-TP1-22-COMMON", ) assert reg is not None - assert reg.name == 'range_hood' + assert reg.name == "range_hood" def test_washer_wf_prefix(self): """US front-load washers use the WF consumer-model prefix.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'DA_WM_TP1_21_COMMON|20313741|20010001001611244AA3021700000000', - 'DA_WM_TP1_21_COMMON_WF8900B/DC92-03129A_A0AE', + "DA_WM_TP1_21_COMMON|20313741|20010001001611244AA3021700000000", + "DA_WM_TP1_21_COMMON_WF8900B/DC92-03129A_A0AE", ) assert reg is not None - assert reg.name == 'washer' + assert reg.name == "washer" def test_washer_wv_prefix(self): """FlexWash twin washers use the WV consumer-model prefix -- issue #19.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000', - 'DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014', + "DA_WM_A51_20_COMMON|20198042|20020001001111400203000000000000", + "DA_WM_A51_20_COMMON_WV9600M/DC92-01980B_0014", ) assert reg is not None - assert reg.name == 'washer' + assert reg.name == "washer" def test_washer_wa_prefix(self): """Top-load washers (e.g. WA8000T) use the WA consumer-model prefix -- issue #106.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'DA_WM_TP2_20_COMMON|20233741|200000010014110002A3020200000000', - 'DA_WM_TP2_20_COMMON_WA8000T/DC92-02810A_0002', + "DA_WM_TP2_20_COMMON|20233741|200000010014110002A3020200000000", + "DA_WM_TP2_20_COMMON_WA8000T/DC92-02810A_0002", ) assert reg is not None - assert reg.name == 'washer' + assert reg.name == "washer" def test_oven_via_oven_token(self): """Issue #55: a wall oven (NV7000BS/ET5) reports no oneUiVersion and an unrecognized consumer token ('NV'); it falls back to the '-OVEN-' token in modelNum, mirroring the '-RANGE-' fallback for issue #44.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000', - 'NV7000BS/ET5', + "TP1X_DA-KS-OVEN-0107X|40460041|50030018001611020A00000000000000", + "NV7000BS/ET5", ) assert reg is not None - assert reg.name == 'oven' + assert reg.name == "oven" def test_induction_cooktop_via_hyphenated_cooktop_token(self): """Issue #86: a standalone induction cooktop (NV8500T-/KO4) reports @@ -544,42 +621,50 @@ class TestForDeviceByModel: from the underscore-delimited '_COOKTOP'/'_GB_CT_' check, which is the unrelated older NA9300K gas-cooktop family.""" from custom_components.localthings.registry.by_type import for_device_by_model + reg = for_device_by_model( - 'TP1X_DA-KS-COOKTOP-01011|40459741|50000203001711000A00000000000000', - 'NV8500T-/KO4', + "TP1X_DA-KS-COOKTOP-01011|40459741|50000203001711000A00000000000000", + "NV8500T-/KO4", ) assert reg is not None - assert reg.name == 'induction_cooktop' + assert reg.name == "induction_cooktop" def test_hyphenated_cooktop_token_not_confused_with_range(self): """'-COOKTOP-' and '-RANGE-' must route to distinct registries even though both are TP1X_DA-KS- board-family siblings.""" from custom_components.localthings.registry.by_type import for_device_by_model - reg = for_device_by_model('TP1X_DA-KS-COOKTOP-01011', 'NV8500T-/KO4') + + reg = for_device_by_model("TP1X_DA-KS-COOKTOP-01011", "NV8500T-/KO4") assert reg is not None - assert reg.name != 'range' + assert reg.name != "range" def test_unknown_model_returns_none(self): from custom_components.localthings.registry.by_type import for_device_by_model - reg = for_device_by_model('SOME-UNKNOWN-BOARD', 'SOME-UNKNOWN-BOARD') + + reg = for_device_by_model("SOME-UNKNOWN-BOARD", "SOME-UNKNOWN-BOARD") assert reg is None def test_empty_inputs_return_none(self): from custom_components.localthings.registry.by_type import for_device_by_model - assert for_device_by_model('', '') is None - @pytest.mark.parametrize('model_num', [ - 'TP1X_DA-AC-RAC-01001_0000', # hyphenated (issue #91) - 'TP1X_DA_AC_RAC_01001_0000', # underscored - 'TP2X_RAC_20K', # bare, no board-family prefix (issue #37) - 'TP2X-RAC-20K', - ]) + assert for_device_by_model("", "") is None + + @pytest.mark.parametrize( + "model_num", + [ + "TP1X_DA-AC-RAC-01001_0000", # hyphenated (issue #91) + "TP1X_DA_AC_RAC_01001_0000", # underscored + "TP2X_RAC_20K", # bare, no board-family prefix (issue #37) + "TP2X-RAC-20K", + ], + ) def test_delimiter_spelling_does_not_change_the_answer(self, model_num): """One board family, four spellings, one table entry.""" from custom_components.localthings.registry.by_type import for_device_by_model - reg = for_device_by_model(model_num, '') + + reg = for_device_by_model(model_num, "") assert reg is not None - assert reg.name == 'airconditioner' + assert reg.name == "airconditioner" def test_model_num_wins_when_the_two_fields_disagree(self): """The legacy gas cooktop is the one known device whose fields @@ -587,25 +672,28 @@ class TestForDeviceByModel: otherwise means induction). The board is right, so modelNum is consulted first.""" from custom_components.localthings.registry.by_type import for_device_by_model - reg = for_device_by_model('ARTIK051_GB_CT_001', 'ARTIK051_GLOBAL_COOKTOP') + + reg = for_device_by_model("ARTIK051_GB_CT_001", "ARTIK051_GLOBAL_COOKTOP") assert reg is not None - assert reg.name == 'gas_cooktop' + assert reg.name == "gas_cooktop" def test_board_token_in_description_used_when_model_num_has_none(self): """Some units report a placeholder modelNum and carry the board token only in `description`.""" from custom_components.localthings.registry.by_type import for_device_by_model - reg = for_device_by_model('TEST-MODEL', 'TP1X_REF_21K') + + reg = for_device_by_model("TEST-MODEL", "TP1X_REF_21K") assert reg is not None - assert reg.name == 'refrigerator' + assert reg.name == "refrigerator" def test_board_token_beats_consumer_prefix(self): """'WAC' (window AC, issue #87) starts with 'WA' (top-load washer, issue #106). The board table runs first, so the AC wins.""" from custom_components.localthings.registry.by_type import for_device_by_model - reg = for_device_by_model('TP1X_DA_AC_WAC_01001_0000', 'TP1X_DA_AC_WAC_01001_0000') + + reg = for_device_by_model("TP1X_DA_AC_WAC_01001_0000", "TP1X_DA_AC_WAC_01001_0000") assert reg is not None - assert reg.name == 'airconditioner' + assert reg.name == "airconditioner" class TestBoardTokenAmbiguity: @@ -619,17 +707,19 @@ class TestBoardTokenAmbiguity: # carve-out resolving this one pair to 'water_purifier'; this is the one # documented exception to the "no two board tokens ever co-occur" # invariant the rest of this test enforces. - _ALLOWED_CONFLICTS = {frozenset({'refrigerator', 'water_purifier'})} + _ALLOWED_CONFLICTS = frozenset({frozenset({"refrigerator", "water_purifier"})}) def test_no_fixture_model_string_yields_two_conflicting_keys(self, all_device_fixtures): from custom_components.localthings.registry.by_type import ( - _BOARD_TOKEN_TO_KEY, _board_tokens, + _BOARD_TOKEN_TO_KEY, + _board_tokens, ) + for name, resources in all_device_fixtures.items(): - info = resources.get('/information/vs/0', {}) + info = resources.get("/information/vs/0", {}) for field, cut in ( - (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", ""), "/"), ): keys = { _BOARD_TOKEN_TO_KEY[t] @@ -650,25 +740,28 @@ class TestOneUiVersionIsNotConsulted: def test_resolve_ignores_a_recognizable_one_ui_version(self): from custom_components.localthings.registry.by_type import resolve + resources = { - '/otninformation/vs/0': {'swVersionInfo': {'oneUiVersion': '7.0 Dishwasher'}}, - '/information/vs/0': { - 'x.com.samsung.da.modelNum': 'SOME-UNKNOWN-BOARD', - 'x.com.samsung.da.description': 'SOME-UNKNOWN-BOARD', + "/otninformation/vs/0": {"swVersionInfo": {"oneUiVersion": "7.0 Dishwasher"}}, + "/information/vs/0": { + "x.com.samsung.da.modelNum": "SOME-UNKNOWN-BOARD", + "x.com.samsung.da.description": "SOME-UNKNOWN-BOARD", }, } assert resolve(resources) is None - def test_resolve_types_every_one_ui_reporting_fixture_without_it( - self, all_device_fixtures - ): + def test_resolve_types_every_one_ui_reporting_fixture_without_it(self, all_device_fixtures): """The claim above, checked: for every dump that reports a oneUiVersion, the model strings alone reach a registry.""" from custom_components.localthings.registry.by_type import resolve + seen = 0 for name, resources in all_device_fixtures.items(): - one_ui = (resources.get('/otninformation/vs/0', {}) - .get('swVersionInfo', {}).get('oneUiVersion', '')) + one_ui = ( + resources.get("/otninformation/vs/0", {}) + .get("swVersionInfo", {}) + .get("oneUiVersion", "") + ) if not one_ui: continue seen += 1 @@ -684,51 +777,54 @@ class TestResolve: parsing -- an unrecognizable modelNum with a known oic.d type must still resolve, and a *conflicting* modelNum must lose to it.""" from custom_components.localthings.registry.by_type import resolve + resources = { - '/information/vs/0': { - 'x.com.samsung.da.modelNum': 'SOME-UNKNOWN-BOARD', - 'x.com.samsung.da.description': 'SOME-UNKNOWN-BOARD', + "/information/vs/0": { + "x.com.samsung.da.modelNum": "SOME-UNKNOWN-BOARD", + "x.com.samsung.da.description": "SOME-UNKNOWN-BOARD", }, } - reg = resolve(resources, device_types=('oic.d.washer',)) + reg = resolve(resources, device_types=("oic.d.washer",)) assert reg is not None - assert reg.name == 'washer' + assert reg.name == "washer" conflicting = resolve( resources={ - '/information/vs/0': { - 'x.com.samsung.da.modelNum': 'TP1X_REF_21K', - 'x.com.samsung.da.description': 'TP1X_REF_21K', + "/information/vs/0": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K", + "x.com.samsung.da.description": "TP1X_REF_21K", }, }, - device_types=('oic.d.washer',), + device_types=("oic.d.washer",), ) assert conflicting is not None - assert conflicting.name == 'washer' + assert conflicting.name == "washer" def test_empty_device_types_falls_back_to_model_strings(self): from custom_components.localthings.registry.by_type import resolve + resources = { - '/information/vs/0': { - 'x.com.samsung.da.modelNum': 'TP1X_REF_21K', - 'x.com.samsung.da.description': 'TP1X_REF_21K', + "/information/vs/0": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K", + "x.com.samsung.da.description": "TP1X_REF_21K", }, } reg = resolve(resources, device_types=()) assert reg is not None - assert reg.name == 'refrigerator' + assert reg.name == "refrigerator" def test_unmapped_device_types_falls_back_to_model_strings(self): from custom_components.localthings.registry.by_type import resolve + resources = { - '/information/vs/0': { - 'x.com.samsung.da.modelNum': 'TP1X_REF_21K', - 'x.com.samsung.da.description': 'TP1X_REF_21K', + "/information/vs/0": { + "x.com.samsung.da.modelNum": "TP1X_REF_21K", + "x.com.samsung.da.description": "TP1X_REF_21K", }, } - reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.somethingnew')) + reg = resolve(resources, device_types=("oic.wk.d", "oic.d.somethingnew")) assert reg is not None - assert reg.name == 'refrigerator' + assert reg.name == "refrigerator" def test_prefers_resource_signatures_over_model_strings(self, all_device_fixtures): """A strong live-resource signature wins over model metadata. @@ -739,15 +835,18 @@ class TestResolve: from silently becoming greedy as new signatures or fixtures land. """ from custom_components.localthings.registry.by_type import ( - resolve, for_device_by_model, for_device_by_resources, + for_device_by_model, + for_device_by_resources, + resolve, ) + disagreements = {} for name, resources in all_device_fixtures.items(): - info = resources.get('/information/vs/0', {}) + info = resources.get("/information/vs/0", {}) by_resources = for_device_by_resources(resources) by_model = 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", ""), ) if by_resources is None or by_model is None: continue @@ -756,7 +855,7 @@ class TestResolve: disagreements[name] = (by_resources.name, by_model.name) assert disagreements == { - 'qooker_mw7500a': ('microwave', 'oven'), + "qooker_mw7500a": ("microwave", "oven"), } def test_qooker_microwave_surface_overrides_generic_oven_oic_type(self): @@ -771,32 +870,34 @@ class TestResolve: ) from tests.conftest import _load_device - resources = _load_device('qooker_mw7500a') - info = resources['/information/vs/0'] + resources = _load_device("qooker_mw7500a") + info = resources["/information/vs/0"] by_resources = for_device_by_resources(resources) - by_oic = for_device_by_oic_type(('oic.wk.d', 'oic.d.oven')) + by_oic = for_device_by_oic_type(("oic.wk.d", "oic.d.oven")) by_model = 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"], ) - reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.oven')) + reg = resolve(resources, device_types=("oic.wk.d", "oic.d.oven")) - assert by_resources is not None and by_resources.name == 'microwave' - assert by_oic is not None and by_oic.name == 'oven' - assert by_model is not None and by_model.name == 'oven' + assert by_resources is not None and by_resources.name == "microwave" + assert by_oic is not None and by_oic.name == "oven" + assert by_model is not None and by_model.name == "oven" assert reg is by_resources def test_resource_signature_types_dumps_without_information(self, all_device_fixtures): """The three dumps with no /information/vs/0 still type.""" from custom_components.localthings.registry.by_type import resolve - for name in ('cooktop', 'range_ne63a6511', 'range_no_info'): + + for name in ("cooktop", "range_ne63a6511", "range_no_info"): resources = all_device_fixtures[name] - assert '/information/vs/0' not in resources, name + assert "/information/vs/0" not in resources, name assert resolve(resources) is not None, name def test_returns_none_for_an_unrecognizable_dump(self): from custom_components.localthings.registry.by_type import resolve - assert resolve({'/some/unknown/vs/0': {}}) is None + + assert resolve({"/some/unknown/vs/0": {}}) is None class TestForDeviceByResources: @@ -804,19 +905,19 @@ class TestForDeviceByResources: from custom_components.localthings.registry.by_type import for_device_by_resources from tests.conftest import _load_device - reg = for_device_by_resources(_load_device('cooktop')) + reg = for_device_by_resources(_load_device("cooktop")) assert reg is not None - assert reg.name == 'gas_cooktop' + assert reg.name == "gas_cooktop" def test_unrelated_mode_options_are_not_cooktop(self): from custom_components.localthings.registry.by_type import for_device_by_resources resources = { - '/mode/vs/0': { - 'x.com.samsung.da.options': [ - 'DeviceType_SOME_OVEN', - 'UpperLamp_Off', + "/mode/vs/0": { + "x.com.samsung.da.options": [ + "DeviceType_SOME_OVEN", + "UpperLamp_Off", ], }, } @@ -825,13 +926,14 @@ class TestForDeviceByResources: def test_hood_resource_signature(self): from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { - '/hood/fanspeed/vs/0': {}, - '/hood/lamp/vs/0': {}, + "/hood/fanspeed/vs/0": {}, + "/hood/lamp/vs/0": {}, } reg = for_device_by_resources(resources) assert reg is not None - assert reg.name == 'range_hood' + assert reg.name == "range_hood" def test_ne63b8411ss_without_information_or_burner_status_is_range(self): """Issue #74: no oneUiVersion, no /information/vs/0 at all, and no @@ -839,39 +941,42 @@ class TestForDeviceByResources: 'Bake' in supportedModes plus that monitoring resource must still route this to the range registry, not plain oven or unknown.""" from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { - '/mode/vs/0': { - 'x.com.samsung.da.supportedModes': ['Bake', 'Broil', 'SelfClean'], - 'x.com.samsung.da.options': ['DeviceType_NE8411B-/AC0'], + "/mode/vs/0": { + "x.com.samsung.da.supportedModes": ["Bake", "Broil", "SelfClean"], + "x.com.samsung.da.options": ["DeviceType_NE8411B-/AC0"], }, - '/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}, - '/cooktopmonitoring/vs/0': {'x.com.samsung.da.cooktopRunningState': 'Ready'}, + "/oven/vs/0": {"x.com.samsung.da.state": "Ready"}, + "/cooktopmonitoring/vs/0": {"x.com.samsung.da.cooktopRunningState": "Ready"}, } reg = for_device_by_resources(resources) assert reg is not None - assert reg.name == 'range' + assert reg.name == "range" def test_bake_without_cooktop_resource_is_plain_oven(self): from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { - '/mode/vs/0': { - 'x.com.samsung.da.supportedModes': ['Bake', 'Broil'], - 'x.com.samsung.da.options': ['DeviceType_SOME_OVEN'], + "/mode/vs/0": { + "x.com.samsung.da.supportedModes": ["Bake", "Broil"], + "x.com.samsung.da.options": ["DeviceType_SOME_OVEN"], }, - '/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}, + "/oven/vs/0": {"x.com.samsung.da.state": "Ready"}, } reg = for_device_by_resources(resources) assert reg is not None - assert reg.name == 'oven' + assert reg.name == "oven" def test_bake_without_oven_cavity_resource_is_not_matched(self): """'Bake' alone isn't enough -- the oven cavity resource must also be present, or this falls through to None like any other unknown shape.""" from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { - '/mode/vs/0': { - 'x.com.samsung.da.supportedModes': ['Bake', 'Broil'], + "/mode/vs/0": { + "x.com.samsung.da.supportedModes": ["Bake", "Broil"], }, } assert for_device_by_resources(resources) is None @@ -881,25 +986,26 @@ class TestForDeviceByResources: resource and empty oneUiVersion; 'MicroWave' in supportedModes alongside /oven/vs/0 must route to the microwave registry.""" from custom_components.localthings.registry.by_type import for_device_by_resources + resources = { - '/mode/vs/0': { - 'x.com.samsung.da.supportedModes': ['MicroWave', 'Autocook', 'Convection'], - 'x.com.samsung.da.options': ['DeviceType_ME8000T-/AA0', 'Lamp_Off'], + "/mode/vs/0": { + "x.com.samsung.da.supportedModes": ["MicroWave", "Autocook", "Convection"], + "x.com.samsung.da.options": ["DeviceType_ME8000T-/AA0", "Lamp_Off"], }, - '/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}, - '/hood/fanspeed/vs/0': {'x.com.samsung.da.hood.fanSpeed': '0'}, + "/oven/vs/0": {"x.com.samsung.da.state": "Ready"}, + "/hood/fanspeed/vs/0": {"x.com.samsung.da.hood.fanSpeed": "0"}, } reg = for_device_by_resources(resources) assert reg is not None - assert reg.name == 'microwave' + assert reg.name == "microwave" def test_microwave_mode_without_oven_cavity_is_not_matched(self): """The mode vocabulary alone is too common to preempt metadata.""" from custom_components.localthings.registry.by_type import for_device_by_resources resources = { - '/mode/vs/0': { - 'x.com.samsung.da.supportedModes': ['MicroWave'], + "/mode/vs/0": { + "x.com.samsung.da.supportedModes": ["MicroWave"], }, } @@ -910,10 +1016,10 @@ class TestForDeviceByResources: from custom_components.localthings.registry.by_type import for_device_by_resources resources = { - '/mode/vs/0': { - 'x.com.samsung.da.supportedModes': 'MicroWave', + "/mode/vs/0": { + "x.com.samsung.da.supportedModes": "MicroWave", }, - '/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}, + "/oven/vs/0": {"x.com.samsung.da.state": "Ready"}, } assert for_device_by_resources(resources) is None diff --git a/tests/test_by_type_capabilities.py b/tests/test_by_type_capabilities.py index 9c9e096..51366f1 100644 --- a/tests/test_by_type_capabilities.py +++ b/tests/test_by_type_capabilities.py @@ -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}" diff --git a/tests/test_capability.py b/tests/test_capability.py index 25e738a..1cd9ac3 100644 --- a/tests/test_capability.py +++ b/tests/test_capability.py @@ -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") diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index c92abd4..6a37cd2 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -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" diff --git a/tests/test_climate_subdevice.py b/tests/test_climate_subdevice.py index 9fedc1e..8c5964a 100644 --- a/tests/test_climate_subdevice.py +++ b/tests/test_climate_subdevice.py @@ -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 diff --git a/tests/test_climate_temperature_fallback.py b/tests/test_climate_temperature_fallback.py index 41fb3ca..124e32b 100644 --- a/tests/test_climate_temperature_fallback.py +++ b/tests/test_climate_temperature_fallback.py @@ -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"]}) == {} diff --git a/tests/test_climate_wind_oscillation_fallback.py b/tests/test_climate_wind_oscillation_fallback.py index d9f5292..31dfe2c 100644 --- a/tests/test_climate_wind_oscillation_fallback.py +++ b/tests/test_climate_wind_oscillation_fallback.py @@ -8,23 +8,24 @@ registry-level "no unbound hrefs" test in test_golden_regression.py doesn't reach: newer WindFree firmware reports no /wind/direction/vs/0 at all, so swing_mode/swing_modes would silently be None/empty without this fallback. """ + from custom_components.localthings.climate import _oscillation_swing def test_oscillation_swing_off_when_both_fixed(): - assert _oscillation_swing({'vertical': 'Fix', 'horizontal': 'Fix'}) == 'off' + assert _oscillation_swing({"vertical": "Fix", "horizontal": "Fix"}) == "off" def test_oscillation_swing_vertical_only(): - assert _oscillation_swing({'vertical': 'Swing', 'horizontal': 'Fix'}) == 'vertical' + assert _oscillation_swing({"vertical": "Swing", "horizontal": "Fix"}) == "vertical" def test_oscillation_swing_horizontal_only(): - assert _oscillation_swing({'vertical': 'Fix', 'horizontal': 'Swing'}) == 'horizontal' + assert _oscillation_swing({"vertical": "Fix", "horizontal": "Swing"}) == "horizontal" def test_oscillation_swing_both(): - assert _oscillation_swing({'vertical': 'Swing', 'horizontal': 'Swing'}) == 'both' + assert _oscillation_swing({"vertical": "Swing", "horizontal": "Swing"}) == "both" def test_oscillation_swing_none_when_resource_absent(): diff --git a/tests/test_climate_windfree.py b/tests/test_climate_windfree.py index f5d065b..c4faf51 100644 --- a/tests/test_climate_windfree.py +++ b/tests/test_climate_windfree.py @@ -10,15 +10,16 @@ table with a generic resolver that covers WindFree/motion generically instead of a per-model dict -- duplicating that here would just conflict with it. """ + from custom_components.localthings.climate import _DEVICE_TO_SWING, _SWING_TO_DEVICE def test_horizontal_swing_maps_both_directions(): - assert _DEVICE_TO_SWING['Left_And_Right'] == 'horizontal' - assert _SWING_TO_DEVICE['horizontal'] == 'Left_And_Right' + assert _DEVICE_TO_SWING["Left_And_Right"] == "horizontal" + assert _SWING_TO_DEVICE["horizontal"] == "Left_And_Right" def test_existing_swing_modes_unchanged(): - assert _DEVICE_TO_SWING['Fix'] == 'off' - assert _DEVICE_TO_SWING['All'] == 'both' - assert _DEVICE_TO_SWING['Up_And_Low'] == 'vertical' + assert _DEVICE_TO_SWING["Fix"] == "off" + assert _DEVICE_TO_SWING["All"] == "both" + assert _DEVICE_TO_SWING["Up_And_Low"] == "vertical" diff --git a/tests/test_common_capabilities.py b/tests/test_common_capabilities.py index 2fdfd0d..2a2a2c7 100644 --- a/tests/test_common_capabilities.py +++ b/tests/test_common_capabilities.py @@ -5,13 +5,21 @@ from tests.conftest import _load_device def _reg(): - return {c.href: [c] for c in ( - common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK, - common.POWER_GENERIC, common.POWER_VS_FALLBACK, - common.ALARMS, common.ENERGY_METER, common.WATER_METER, - common.WATER_FILTER, - )} + return { + c.href: [c] + for c in ( + common.KIDS_LOCK_GENERIC, + common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, + common.REMOTE_CONTROL_VS_FALLBACK, + common.POWER_GENERIC, + common.POWER_VS_FALLBACK, + common.ALARMS, + common.ENERGY_METER, + common.WATER_METER, + common.WATER_FILTER, + ) + } def test_kids_lock_vs_value_fn(): @@ -20,16 +28,16 @@ def test_kids_lock_vs_value_fn(): TestKidsLockFallback.test_vs_fallback_is_read_only for why this is a binary_sensor at all).""" desc = common.KIDS_LOCK_VS_FALLBACK.entities[0] - assert desc.value_fn('Lock') is False - assert desc.value_fn('Ready') is True + assert desc.value_fn("Lock") is False + assert desc.value_fn("Ready") is True def test_common_caps_discover_on_dishwasher(dishwasher_resources): bound = discover(dishwasher_resources, _reg()) keys = {b.desc.key for b in bound} - assert 'child_lock' in keys - assert 'remote_control' in keys - assert 'power_switch' in keys + assert "child_lock" in keys + assert "remote_control" in keys + assert "power_switch" in keys class TestMergeOptionsField: @@ -39,28 +47,31 @@ class TestMergeOptionsField: still present) without waiting on a real poll.""" def test_replaces_matching_prefix(self): - cached = ['DeviceType_0167', 'Course_16', 'GMT_04'] - assert common.merge_options_field(cached, ['Course_1D']) == [ - 'DeviceType_0167', 'Course_1D', 'GMT_04', + cached = ["DeviceType_0167", "Course_16", "GMT_04"] + assert common.merge_options_field(cached, ["Course_1D"]) == [ + "DeviceType_0167", + "Course_1D", + "GMT_04", ] def test_appends_when_prefix_absent(self): - cached = ['DeviceType_0167'] - assert common.merge_options_field(cached, ['NaturalSteam_On']) == [ - 'DeviceType_0167', 'NaturalSteam_On', + cached = ["DeviceType_0167"] + assert common.merge_options_field(cached, ["NaturalSteam_On"]) == [ + "DeviceType_0167", + "NaturalSteam_On", ] def test_merges_multiple_tokens_independently(self): - cached = ['DetergentLevelCtrl_1', 'SoftenerLevelCtrl_0', 'GMT_04'] - merged = common.merge_options_field(cached, ['SoftenerLevelCtrl_2']) - assert merged == ['DetergentLevelCtrl_1', 'SoftenerLevelCtrl_2', 'GMT_04'] + cached = ["DetergentLevelCtrl_1", "SoftenerLevelCtrl_0", "GMT_04"] + merged = common.merge_options_field(cached, ["SoftenerLevelCtrl_2"]) + assert merged == ["DetergentLevelCtrl_1", "SoftenerLevelCtrl_2", "GMT_04"] def test_handles_missing_cache(self): - assert common.merge_options_field(None, ['Course_1D']) == ['Course_1D'] + assert common.merge_options_field(None, ["Course_1D"]) == ["Course_1D"] def test_ignores_malformed_tokens(self): - cached = ['Course_16'] - assert common.merge_options_field(cached, ['nounderscore']) == ['Course_16'] + cached = ["Course_16"] + assert common.merge_options_field(cached, ["nounderscore"]) == ["Course_16"] class TestMergeItemsField: @@ -72,31 +83,45 @@ class TestMergeItemsField: present) without waiting on a real poll.""" def test_merges_fields_into_matching_id(self): - cached = [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0', - 'x.com.samsung.da.maximum': '30', 'x.com.samsung.da.minimum': '16'}] + cached = [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.current": "20.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + } + ] merged = common.merge_items_field( - cached, [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}]) - assert merged == [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0', - 'x.com.samsung.da.maximum': '30', 'x.com.samsung.da.minimum': '16', - 'x.com.samsung.da.desired': '22'}] + cached, [{"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "22"}] + ) + assert merged == [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.current": "20.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "16", + "x.com.samsung.da.desired": "22", + } + ] def test_appends_when_id_absent(self): - cached = [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0'}] + cached = [{"x.com.samsung.da.id": "0", "x.com.samsung.da.current": "20.0"}] merged = common.merge_items_field( - cached, [{'x.com.samsung.da.id': '1', 'x.com.samsung.da.desired': '22'}]) + cached, [{"x.com.samsung.da.id": "1", "x.com.samsung.da.desired": "22"}] + ) assert merged == [ - {'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0'}, - {'x.com.samsung.da.id': '1', 'x.com.samsung.da.desired': '22'}, + {"x.com.samsung.da.id": "0", "x.com.samsung.da.current": "20.0"}, + {"x.com.samsung.da.id": "1", "x.com.samsung.da.desired": "22"}, ] def test_handles_missing_cache(self): assert common.merge_items_field( - None, [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}] - ) == [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}] + None, [{"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "22"}] + ) == [{"x.com.samsung.da.id": "0", "x.com.samsung.da.desired": "22"}] def test_ignores_malformed_new_items(self): - cached = [{'x.com.samsung.da.id': '0', 'x.com.samsung.da.current': '20.0'}] - assert common.merge_items_field(cached, ['not-a-dict']) == cached + cached = [{"x.com.samsung.da.id": "0", "x.com.samsung.da.current": "20.0"}] + assert common.merge_items_field(cached, ["not-a-dict"]) == cached # --------------------------------------------------------------------------- @@ -106,95 +131,104 @@ class TestMergeItemsField: class TestPowerFallback: def test_generic_href_read_write(self): - assert common.POWER_GENERIC.href == '/power/0' + assert common.POWER_GENERIC.href == "/power/0" desc = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) assert desc.value_fn(True) is True assert desc.value_fn(False) is False - path, body = desc.write_fn('On', {}) - assert path == ['power', '0'] - assert body == {'value': True} - assert desc.write_fn('Off', {})[1] == {'value': False} + path, body = desc.write_fn("On", {}) + assert path == ["power", "0"] + assert body == {"value": True} + assert desc.write_fn("Off", {})[1] == {"value": False} def test_vs_fallback_binds_only_when_generic_absent(self): - assert common.POWER_VS_FALLBACK.href == '/power/vs/0' - assert common.POWER_VS_FALLBACK.match_fn({}, {'/power/vs/0': {}}) is True - assert common.POWER_VS_FALLBACK.match_fn( - {}, {'/power/0': {}, '/power/vs/0': {}}) is False + assert common.POWER_VS_FALLBACK.href == "/power/vs/0" + assert common.POWER_VS_FALLBACK.match_fn({}, {"/power/vs/0": {}}) is True + assert common.POWER_VS_FALLBACK.match_fn({}, {"/power/0": {}, "/power/vs/0": {}}) is False def test_vs_fallback_read_write(self): desc = next(e for e in common.POWER_VS_FALLBACK.entities if isinstance(e, SwitchDesc)) - assert desc.value_fn('On') is True - assert desc.value_fn('Off') is False - path, body = desc.write_fn('On', {}) - assert path == ['power', 'vs', '0'] - assert body == {'x.com.samsung.da.power': 'On'} + assert desc.value_fn("On") is True + assert desc.value_fn("Off") is False + path, body = desc.write_fn("On", {}) + assert path == ["power", "vs", "0"] + assert body == {"x.com.samsung.da.power": "On"} def test_power_switch_hidden_when_firmware_disallows(self): switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc)) resources = { - '/power/0': {'value': True}, - '/wm/setinfo/vs/0': {'x.com.samsung.da.isModelSettingPowerOnOff': 'false'}, + "/power/0": {"value": True}, + "/wm/setinfo/vs/0": {"x.com.samsung.da.isModelSettingPowerOnOff": "false"}, } - assert switch.exists_fn(resources['/power/0'], resources) is False - assert sensor.exists_fn(resources['/power/0'], resources) is True + assert switch.exists_fn(resources["/power/0"], resources) is False + assert sensor.exists_fn(resources["/power/0"], resources) is True def test_power_switch_writable_when_firmware_allows(self): switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc)) resources = { - '/power/0': {'value': True}, - '/wm/setinfo/vs/0': {'x.com.samsung.da.isModelSettingPowerOnOff': 'true'}, + "/power/0": {"value": True}, + "/wm/setinfo/vs/0": {"x.com.samsung.da.isModelSettingPowerOnOff": "true"}, } - assert switch.exists_fn(resources['/power/0'], resources) is True - assert sensor.exists_fn(resources['/power/0'], resources) is False + assert switch.exists_fn(resources["/power/0"], resources) is True + assert sensor.exists_fn(resources["/power/0"], resources) is False def test_power_switch_default_writable_without_setinfo(self): switch = next(e for e in common.POWER_GENERIC.entities if isinstance(e, SwitchDesc)) sensor = next(e for e in common.POWER_GENERIC.entities if isinstance(e, BinarySensorDesc)) - resources = {'/power/0': {'value': True}} - assert switch.exists_fn(resources['/power/0'], resources) is True - assert sensor.exists_fn(resources['/power/0'], resources) is False + resources = {"/power/0": {"value": True}} + assert switch.exists_fn(resources["/power/0"], resources) is True + assert sensor.exists_fn(resources["/power/0"], resources) is False def test_polled_warm_so_power_state_reflects_quickly(self): """issue #56 follow-up: neither href had a poll_tier, so power state only ever refreshed on the once-per-30s summary poll instead of the subscribe/subpoll cadence 'warm'/'hot' hrefs get -- same reasoning as REMOTE_CONTROL_GENERIC/VS_FALLBACK's own 'warm' tier.""" - assert common.POWER_GENERIC.poll_tier == 'warm' - assert common.POWER_VS_FALLBACK.poll_tier == 'warm' + assert common.POWER_GENERIC.poll_tier == "warm" + assert common.POWER_VS_FALLBACK.poll_tier == "warm" class TestWmSetinfoFlags: def test_washer_fixture_carries_setinfo_from_device0(self): """/wm/setinfo/vs/0 lands in the seed snapshot -- no dedicated capability.""" - resources = _load_device('washer') - assert '/wm/setinfo/vs/0' in resources + resources = _load_device("washer") + assert "/wm/setinfo/vs/0" in resources assert common.model_allows_power_on_off(resources) is False assert common.model_setting_without_sc(resources) is True def test_dishwasher_allows_power_on_off(self): - resources = _load_device('dishwasher') + resources = _load_device("dishwasher") assert common.model_allows_power_on_off(resources) is True assert common.model_setting_without_sc(resources) is False def test_deleted_alarms_filtered(self): - assert common._active_alarm_codes([ - { - 'x.com.samsung.da.code': 'ErrorCode', - 'x.com.samsung.da.state': 'Deleted', - }, - ]) == 'none' - assert common._active_alarm_codes([ - { - 'x.com.samsung.da.code': 'LE', - 'x.com.samsung.da.state': 'Triggered', - }, - { - 'x.com.samsung.da.code': 'ErrorCode', - 'x.com.samsung.da.state': 'Deleted', - }, - ]) == 'LE' + assert ( + common._active_alarm_codes( + [ + { + "x.com.samsung.da.code": "ErrorCode", + "x.com.samsung.da.state": "Deleted", + }, + ] + ) + == "none" + ) + assert ( + common._active_alarm_codes( + [ + { + "x.com.samsung.da.code": "LE", + "x.com.samsung.da.state": "Triggered", + }, + { + "x.com.samsung.da.code": "ErrorCode", + "x.com.samsung.da.state": "Deleted", + }, + ] + ) + == "LE" + ) def test_off_suffixed_placeholder_codes_filtered(self): """issue #166: Samsung pre-populates /alarms/vs/0 with one row per @@ -203,32 +237,42 @@ class TestWmSetinfoFlags: confirmed not just for 'ErrorCode_OFF' but also 'FilterAlarm_OFF' on the same dump. These were previously shown as if they were active alarms.""" - assert common._active_alarm_codes([ - { - 'x.com.samsung.da.code': 'ErrorCode_OFF', - 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', - }, - { - 'x.com.samsung.da.code': 'FilterAlarm_OFF', - 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', - }, - ]) == 'none' + assert ( + common._active_alarm_codes( + [ + { + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-28T12:59:22", + }, + { + "x.com.samsung.da.code": "FilterAlarm_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-28T12:59:22", + }, + ] + ) + == "none" + ) def test_real_filter_alarm_not_off_suffixed_still_shown(self): """issue #166's actual live filter alert: code 'FilterAlarm' (no '_OFF' suffix), state 'Created' -- distinct from the 'FilterAlarm_OFF' placeholder and must still surface.""" - assert common._active_alarm_codes([ - { - 'x.com.samsung.da.code': 'ErrorCode_OFF', - 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', - }, - { - 'x.com.samsung.da.code': 'FilterAlarm', - 'x.com.samsung.da.state': 'Created', - 'x.com.samsung.da.triggeredTime': '2026-07-28T12:59:22', - }, - ]) == 'FilterAlarm' + assert ( + common._active_alarm_codes( + [ + { + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-28T12:59:22", + }, + { + "x.com.samsung.da.code": "FilterAlarm", + "x.com.samsung.da.state": "Created", + "x.com.samsung.da.triggeredTime": "2026-07-28T12:59:22", + }, + ] + ) + == "FilterAlarm" + ) class TestKidsLockFallback: @@ -240,16 +284,18 @@ class TestKidsLockFallback: KIDS_LOCK_VS_FALLBACK now share the same shape (BinarySensorDesc, device_class='lock', same key) so both surfaces render with the same polarity: 'On' means open/unlocked.""" - assert common.KIDS_LOCK_GENERIC.href == '/kidslock/0' + assert common.KIDS_LOCK_GENERIC.href == "/kidslock/0" desc = common.KIDS_LOCK_GENERIC.entities[0] assert isinstance(desc, BinarySensorDesc) - assert desc.value_fn(False) is True # value=False -> On=Unlocked - assert desc.value_fn(True) is False # value=True -> Off=Locked + assert desc.value_fn(False) is True # value=False -> On=Unlocked + assert desc.value_fn(True) is False # value=True -> Off=Locked def test_vs_fallback_gated(self): - assert common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {'/kidslock/vs/0': {}}) is True - assert common.KIDS_LOCK_VS_FALLBACK.match_fn( - {}, {'/kidslock/0': {}, '/kidslock/vs/0': {}}) is False + assert common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {"/kidslock/vs/0": {}}) is True + assert ( + common.KIDS_LOCK_VS_FALLBACK.match_fn({}, {"/kidslock/0": {}, "/kidslock/vs/0": {}}) + is False + ) def test_vs_fallback_is_read_only(self): """Issues #181/#183: writing this resource 4.05s on real hardware @@ -264,23 +310,27 @@ class TestKidsLockFallback: class TestRemoteControlFallback: def test_generic_read(self): - assert common.REMOTE_CONTROL_GENERIC.href == '/remotectrl/0' + assert common.REMOTE_CONTROL_GENERIC.href == "/remotectrl/0" desc = common.REMOTE_CONTROL_GENERIC.entities[0] assert desc.value_fn(True) is True assert desc.value_fn(False) is False def test_vs_fallback_gated(self): - assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {'/remotectrl/vs/0': {}}) is True - assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn( - {}, {'/remotectrl/0': {}, '/remotectrl/vs/0': {}}) is False + assert common.REMOTE_CONTROL_VS_FALLBACK.match_fn({}, {"/remotectrl/vs/0": {}}) is True + assert ( + common.REMOTE_CONTROL_VS_FALLBACK.match_fn( + {}, {"/remotectrl/0": {}, "/remotectrl/vs/0": {}} + ) + is False + ) def test_polled_warm_so_write_gating_stays_fresh(self): """coordinator.async_send_command blocks writes on this signal, so it can't sit in the default 'cold' tier (refreshed only once per 30s summary poll) -- it needs the subscribe/subpoll cadence 'warm' and 'hot' hrefs get instead.""" - assert common.REMOTE_CONTROL_GENERIC.poll_tier == 'warm' - assert common.REMOTE_CONTROL_VS_FALLBACK.poll_tier == 'warm' + assert common.REMOTE_CONTROL_GENERIC.poll_tier == "warm" + assert common.REMOTE_CONTROL_VS_FALLBACK.poll_tier == "warm" # --------------------------------------------------------------------------- @@ -292,36 +342,36 @@ class TestRemoteControlFallback: class TestEnergyMeter: def test_href(self): - assert common.ENERGY_METER.href == '/energy/consumption/vs/0' + assert common.ENERGY_METER.href == "/energy/consumption/vs/0" def test_power_clamps_negative(self): - pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') + pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts") assert pw.value_fn(-500.0) == 0.0 assert pw.value_fn(93.0) == 93.0 def test_power_watts_hidden_for_dead_sentinel(self): - pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') - assert pw.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False + pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts") + assert pw.exists_fn({"x.com.samsung.da.instantaneousPower": "-500"}, {}) is False def test_power_watts_shown_for_real_value(self): - pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') - assert pw.exists_fn({'x.com.samsung.da.instantaneousPower': '150'}, {}) is True + pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts") + assert pw.exists_fn({"x.com.samsung.da.instantaneousPower": "150"}, {}) is True def test_energy_kwh_hidden_when_cumulative_power_absent(self): - kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh') - assert kwh.exists_fn({'x.com.samsung.da.instantaneousPower': '-500'}, {}) is False + kwh = next(e for e in common.ENERGY_METER.entities if e.key == "energy_kwh") + assert kwh.exists_fn({"x.com.samsung.da.instantaneousPower": "-500"}, {}) is False def test_energy_kwh_shown_when_present(self): - kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh') - assert kwh.exists_fn({'x.com.samsung.da.cumulativePower': '58900'}, {}) is True + kwh = next(e for e in common.ENERGY_METER.entities if e.key == "energy_kwh") + assert kwh.exists_fn({"x.com.samsung.da.cumulativePower": "58900"}, {}) is True def test_both_entities_included_on_true_stub(self): """A true stub -- /device/0's {"href": "..."} "not fetched yet" marker (see registry.batch.is_stub_rep) -- means the resource exists but data isn't fetched yet; include both so sub-polls populate them.""" - pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') - kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh') - stub = {'href': '/energy/consumption/vs/0'} + pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts") + kwh = next(e for e in common.ENERGY_METER.entities if e.key == "energy_kwh") + stub = {"href": "/energy/consumption/vs/0"} assert pw.exists_fn(stub, {}) is True assert kwh.exists_fn(stub, {}) is True @@ -329,16 +379,16 @@ class TestEnergyMeter: """A real {} rep (no 'href' key) is the device's confirmed -- if empty -- answer, not a stub, so a model that never populates this resource doesn't get a phantom always-"unknown" entity (issue #127).""" - pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') - kwh = next(e for e in common.ENERGY_METER.entities if e.key == 'energy_kwh') + pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts") + kwh = next(e for e in common.ENERGY_METER.entities if e.key == "energy_kwh") assert pw.exists_fn({}, {}) is False assert kwh.exists_fn({}, {}) is False def test_power_watts_hidden_when_field_absent_in_populated_rep(self): """A populated rep that lacks instantaneousPower must not spawn a phantom power sensor (the exists_fn replaces the field-presence gate).""" - pw = next(e for e in common.ENERGY_METER.entities if e.key == 'power_watts') - assert pw.exists_fn({'x.com.samsung.da.cumulativePower': '5'}, {}) is False + pw = next(e for e in common.ENERGY_METER.entities if e.key == "power_watts") + assert pw.exists_fn({"x.com.samsung.da.cumulativePower": "5"}, {}) is False # --------------------------------------------------------------------------- @@ -352,36 +402,35 @@ class TestEnergyMeter: def _ai_energy_level_desc(cls_name): - return next(e for e in common.AI_ENERGY_LEVEL.entities - if e.__class__.__name__ == cls_name) + return next(e for e in common.AI_ENERGY_LEVEL.entities if e.__class__.__name__ == cls_name) class TestAiEnergyLevelSwitch: def _desc(self): - return _ai_energy_level_desc('SwitchDesc') + return _ai_energy_level_desc("SwitchDesc") def test_href(self): - assert common.AI_ENERGY_LEVEL.href == '/energy/ailevel/vs/0' + assert common.AI_ENERGY_LEVEL.href == "/energy/ailevel/vs/0" def test_shown_only_with_single_supported_level(self): desc = self._desc() - assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is True - assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is False + assert desc.exists_fn({"aiLevel": "1", "supportedAiLevel": ["1"]}, {}) is True + assert desc.exists_fn({"aiLevel": "1", "supportedAiLevel": ["1", "2"]}, {}) is False def test_hidden_when_supported_level_is_non_list_scalar(self): """A stray scalar (e.g. a string) must not be len()-checked as if it were a list -- a 2-char string would otherwise wrongly pass `== 1` style checks.""" desc = self._desc() - assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '1'}, {}) is False + assert desc.exists_fn({"aiLevel": "1", "supportedAiLevel": "1"}, {}) is False def test_hidden_when_supported_level_missing(self): desc = self._desc() - assert desc.exists_fn({'aiLevel': '1'}, {}) is False + assert desc.exists_fn({"aiLevel": "1"}, {}) is False def test_hidden_when_supported_level_empty_list(self): desc = self._desc() - assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False + assert desc.exists_fn({"aiLevel": "0", "supportedAiLevel": []}, {}) is False def test_hidden_on_empty_stub_rep(self): desc = self._desc() @@ -389,43 +438,43 @@ class TestAiEnergyLevelSwitch: def test_value_fn(self): desc = self._desc() - assert desc.value_fn('0') is False - assert desc.value_fn('1') is True + assert desc.value_fn("0") is False + assert desc.value_fn("1") is True def test_write_on_uses_the_single_supported_level(self): """The on-value is whatever the device calls its one level, not a hardcoded '1'.""" desc = self._desc() - path, body = desc.write_fn('On', {'supportedAiLevel': ['2']}) - assert path == ['energy', 'ailevel', 'vs', '0'] - assert body == {'aiLevel': '2'} + path, body = desc.write_fn("On", {"supportedAiLevel": ["2"]}) + assert path == ["energy", "ailevel", "vs", "0"] + assert body == {"aiLevel": "2"} def test_write_off(self): desc = self._desc() - path, body = desc.write_fn('Off', {'supportedAiLevel': ['1']}) - assert body == {'aiLevel': '0'} + _path, body = desc.write_fn("Off", {"supportedAiLevel": ["1"]}) + assert body == {"aiLevel": "0"} class TestAiEnergyLevelSelect: def _desc(self): - return _ai_energy_level_desc('SelectDesc') + return _ai_energy_level_desc("SelectDesc") def test_shown_only_with_multiple_supported_levels(self): desc = self._desc() - assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1', '2']}, {}) is True - assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': ['1']}, {}) is False + assert desc.exists_fn({"aiLevel": "1", "supportedAiLevel": ["1", "2"]}, {}) is True + assert desc.exists_fn({"aiLevel": "1", "supportedAiLevel": ["1"]}, {}) is False def test_hidden_when_supported_level_is_non_list_scalar(self): desc = self._desc() - assert desc.exists_fn({'aiLevel': '1', 'supportedAiLevel': '12'}, {}) is False + assert desc.exists_fn({"aiLevel": "1", "supportedAiLevel": "12"}, {}) is False def test_hidden_when_supported_level_missing(self): desc = self._desc() - assert desc.exists_fn({'aiLevel': '1'}, {}) is False + assert desc.exists_fn({"aiLevel": "1"}, {}) is False def test_hidden_when_supported_level_empty_list(self): desc = self._desc() - assert desc.exists_fn({'aiLevel': '0', 'supportedAiLevel': []}, {}) is False + assert desc.exists_fn({"aiLevel": "0", "supportedAiLevel": []}, {}) is False def test_no_translation_key(self): """aiLevel's values are plain digits that render fine untranslated @@ -438,18 +487,18 @@ class TestAiEnergyLevelSelect: """'0' is never in supportedAiLevel but is a real, observed aiLevel value -- synthesized back in as the explicit off option.""" desc = self._desc() - resources = {'/energy/ailevel/vs/0': {'supportedAiLevel': ['1', '2']}} - assert desc.options(resources) == ['0', '1', '2'] + resources = {"/energy/ailevel/vs/0": {"supportedAiLevel": ["1", "2"]}} + assert desc.options(resources) == ["0", "1", "2"] def test_options_empty_when_resource_missing(self): desc = self._desc() - assert desc.options({}) == ['0'] + assert desc.options({}) == ["0"] def test_write(self): desc = self._desc() - path, body = desc.write_fn('2', {}) - assert path == ['energy', 'ailevel', 'vs', '0'] - assert body == {'aiLevel': '2'} + path, body = desc.write_fn("2", {}) + assert path == ["energy", "ailevel", "vs", "0"] + assert body == {"aiLevel": "2"} class TestAiEnergyLevelStubDoesNotDecideThePlatform: @@ -468,15 +517,15 @@ class TestAiEnergyLevelStubDoesNotDecideThePlatform: on first-poll timing, instead of appearing as the wrong widget type.""" def test_neither_widget_exists_on_true_stub_rep(self): - switch = _ai_energy_level_desc('SwitchDesc') - select = _ai_energy_level_desc('SelectDesc') - stub = {'href': '/energy/ailevel/vs/0'} + switch = _ai_energy_level_desc("SwitchDesc") + select = _ai_energy_level_desc("SelectDesc") + stub = {"href": "/energy/ailevel/vs/0"} assert switch.exists_fn(stub, {}) is False assert select.exists_fn(stub, {}) is False def test_neither_widget_exists_on_genuinely_empty_rep(self): - switch = _ai_energy_level_desc('SwitchDesc') - select = _ai_energy_level_desc('SelectDesc') + switch = _ai_energy_level_desc("SwitchDesc") + select = _ai_energy_level_desc("SelectDesc") assert switch.exists_fn({}, {}) is False assert select.exists_fn({}, {}) is False @@ -486,21 +535,21 @@ class TestSelfCheckError: x.com.samsung.da.error, joined into a single display string.""" def _desc(self): - return next(e for e in common.SELF_CHECK.entities if e.key == 'selfcheck_error') + return next(e for e in common.SELF_CHECK.entities if e.key == "selfcheck_error") def test_exists_when_field_present(self): desc = self._desc() - assert desc.exists_fn({'x.com.samsung.da.error': ['DA_ERROR_NONE']}, {}) is True + assert desc.exists_fn({"x.com.samsung.da.error": ["DA_ERROR_NONE"]}, {}) is True def test_does_not_exist_when_field_absent(self): desc = self._desc() - assert desc.exists_fn({'x.com.samsung.da.status': 'Ready'}, {}) is False + assert desc.exists_fn({"x.com.samsung.da.status": "Ready"}, {}) is False def test_exists_for_true_stub_rep(self): """A true stub ({"href": "..."}) is /device/0's not-yet-fetched marker -- must be included-for-now, same as ENERGY_METER's fields.""" desc = self._desc() - assert desc.exists_fn({'href': '/selfcheck/vs/0'}, {}) is True + assert desc.exists_fn({"href": "/selfcheck/vs/0"}, {}) is True def test_hidden_for_genuinely_empty_rep(self): """A real {} rep is the device's confirmed empty answer, not a stub -- @@ -510,11 +559,11 @@ class TestSelfCheckError: def test_value_joins_list(self): desc = self._desc() - assert desc.value_fn(['E1', 'E2']) == 'E1, E2' + assert desc.value_fn(["E1", "E2"]) == "E1, E2" def test_value_passes_through_scalar(self): desc = self._desc() - assert desc.value_fn('DA_ERROR_NONE') == 'DA_ERROR_NONE' + assert desc.value_fn("DA_ERROR_NONE") == "DA_ERROR_NONE" def test_value_none_for_empty_list(self): """An empty error list means no value to show -- None (unknown), @@ -533,10 +582,15 @@ class TestSelfCheckError: class TestUniversalAndPowerBundles: def test_universal_contains_the_no_conflict_capabilities(self): assert set(common.UNIVERSAL) == { - common.ALARMS, common.ENERGY_METER, common.FIRMWARE_UPDATE, - common.SELF_CHECK, common.AI_ENERGY_LEVEL, - common.KIDS_LOCK_GENERIC, common.KIDS_LOCK_VS_FALLBACK, - common.REMOTE_CONTROL_GENERIC, common.REMOTE_CONTROL_VS_FALLBACK, + common.ALARMS, + common.ENERGY_METER, + common.FIRMWARE_UPDATE, + common.SELF_CHECK, + common.AI_ENERGY_LEVEL, + common.KIDS_LOCK_GENERIC, + common.KIDS_LOCK_VS_FALLBACK, + common.REMOTE_CONTROL_GENERIC, + common.REMOTE_CONTROL_VS_FALLBACK, } def test_power_kept_separate_for_airconditioners_sake(self): @@ -548,6 +602,7 @@ class TestUniversalAndPowerBundles: def test_airconditioner_registry_does_not_include_power(self): from custom_components.localthings.registry.by_type import airconditioner + bound_caps = {c for caps in airconditioner.REGISTRY.capabilities.values() for c in caps} assert common.POWER_GENERIC not in bound_caps assert common.POWER_VS_FALLBACK not in bound_caps diff --git a/tests/test_cooktop_capabilities.py b/tests/test_cooktop_capabilities.py index 35b3d99..2cf0f87 100644 --- a/tests/test_cooktop_capabilities.py +++ b/tests/test_cooktop_capabilities.py @@ -19,30 +19,30 @@ def _state(resources): def test_real_cooktop_fixture_has_expected_idle_state(): - state = _state(_load_device('cooktop')) + state = _state(_load_device("cooktop")) - assert state['power_state'] is True - assert state['any_burner_active'] is False - assert state['burner_0_state'] == 'Ready' - assert state['burner_5_state'] == 'Ready' - assert state['main_timer_state'] == 'Ready' - assert state['main_timer_current'] == 0 - assert state['cloud_connected'] is True - assert state['paired_hood_connected'] is False - assert state['paired_hood_power'] is False - assert state['paired_hood_fan_speed'] == 0 - assert state['paired_hood_light'] is False + assert state["power_state"] is True + assert state["any_burner_active"] is False + assert state["burner_0_state"] == "Ready" + assert state["burner_5_state"] == "Ready" + assert state["main_timer_state"] == "Ready" + assert state["main_timer_current"] == 0 + assert state["cloud_connected"] is True + assert state["paired_hood_connected"] is False + assert state["paired_hood_power"] is False + assert state["paired_hood_fan_speed"] == 0 + assert state["paired_hood_light"] is False def test_run_operation_marks_any_burner_active(): - resources = _load_device('cooktop') - options = resources['/mode/vs/0']['x.com.samsung.da.options'] - options[options.index('OperationState3_Ready')] = 'OperationState3_Run' + resources = _load_device("cooktop") + options = resources["/mode/vs/0"]["x.com.samsung.da.options"] + options[options.index("OperationState3_Ready")] = "OperationState3_Run" state = _state(resources) - assert state['burner_3_state'] == 'Run' - assert state['any_burner_active'] is True + assert state["burner_3_state"] == "Run" + assert state["any_burner_active"] is True def test_cooktop_profile_has_no_write_functions(): @@ -55,23 +55,22 @@ def test_cooktop_profile_has_no_write_functions(): ] assert descriptions - assert all(not hasattr(desc, 'write_fn') or desc.write_fn is None - for desc in descriptions) + assert all(not hasattr(desc, "write_fn") or desc.write_fn is None for desc in descriptions) def test_static_slot_superset_has_headroom_for_other_layouts(): keys = {desc.key for desc in COOKTOP_MODE.entities} - assert {f'burner_{slot}_state' for slot in range(8)} <= keys + assert {f"burner_{slot}_state" for slot in range(8)} <= keys def test_variant_slot_outside_na9300k_layout_is_discovered(): - resources = _load_device('cooktop') - resources['/mode/vs/0']['x.com.samsung.da.options'].append( - 'OperationState7_Ready', + resources = _load_device("cooktop") + resources["/mode/vs/0"]["x.com.samsung.da.options"].append( + "OperationState7_Ready", ) state = _state(resources) - assert state['burner_7_state'] == 'Ready' - assert 'burner_2_state' not in state + assert state["burner_7_state"] == "Ready" + assert "burner_2_state" not in state diff --git a/tests/test_coordinator_raw_write.py b/tests/test_coordinator_raw_write.py index 516e05e..ec4441f 100644 --- a/tests/test_coordinator_raw_write.py +++ b/tests/test_coordinator_raw_write.py @@ -5,6 +5,7 @@ write_fn/validate_fn, so this only exercises the primitive itself: it POSTs exactly the caller's body, reads the href back, and validates its inputs. """ + from __future__ import annotations from unittest.mock import AsyncMock @@ -16,15 +17,19 @@ from homeassistant.exceptions import ServiceValidationError from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.localthings.const import ( - CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN, + CONF_HOST, + CONF_LEAF_CERT_PEM, + CONF_LEAF_KEY_PEM, + CONF_PORT, + DOMAIN, ) from custom_components.localthings.coordinator import LocalThingsCoordinator ENTRY_DATA = { - CONF_HOST: '10.0.0.199', + CONF_HOST: "10.0.0.199", CONF_PORT: 49154, - CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----', - CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----', + CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----", + CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----", } @@ -41,7 +46,7 @@ class _FakeRawWriteSession: def post(self, path_segs, payload, timeout=None): self.post_calls.append((list(path_segs), payload)) - return self._post_code, b'' + return self._post_code, b"" def get(self, path_segs, timeout=None): self.get_calls.append(list(path_segs)) @@ -54,7 +59,9 @@ class _FakeRawWriteSession: @pytest.fixture def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator: entry = MockConfigEntry( - domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_RAWWRITE-TEST', + domain=DOMAIN, + data=ENTRY_DATA, + unique_id="localthings_RAWWRITE-TEST", ) entry.add_to_hass(hass) coord = LocalThingsCoordinator(hass, entry) @@ -66,63 +73,63 @@ def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator: async def test_raw_write_splits_href_and_posts_exact_body(coordinator) -> None: - fake = _FakeRawWriteSession(post_code=0x44, get_rep={'x.field': 'after'}) + fake = _FakeRawWriteSession(post_code=0x44, get_rep={"x.field": "after"}) coordinator._session = fake - body = {'x.com.samsung.da.field': 'value', 'n': 1} - code, new_rep = await coordinator.async_raw_write('/course/vs/0', body) + body = {"x.com.samsung.da.field": "value", "n": 1} + code, new_rep = await coordinator.async_raw_write("/course/vs/0", body) assert len(fake.post_calls) == 1 posted_path, posted_bytes = fake.post_calls[0] - assert posted_path == ['course', 'vs', '0'] + assert posted_path == ["course", "vs", "0"] assert cbor2.loads(posted_bytes) == body assert code == 0x44 - assert new_rep == {'x.field': 'after'} + assert new_rep == {"x.field": "after"} coordinator.async_request_refresh.assert_awaited_once() async def test_raw_write_reads_href_back_for_ground_truth(coordinator) -> None: - fake = _FakeRawWriteSession(post_code=0x45, get_rep={'value': 'confirmed'}) + fake = _FakeRawWriteSession(post_code=0x45, get_rep={"value": "confirmed"}) coordinator._session = fake - code, new_rep = await coordinator.async_raw_write('/washer/vs/0', {'a': 1}) + code, new_rep = await coordinator.async_raw_write("/washer/vs/0", {"a": 1}) - assert fake.get_calls == [['washer', 'vs', '0']] + assert fake.get_calls == [["washer", "vs", "0"]] assert code == 0x45 - assert new_rep == {'value': 'confirmed'} + assert new_rep == {"value": "confirmed"} async def test_raw_write_returns_coap_code_from_post_not_get(coordinator) -> None: """The returned code reflects the write's own response, even though a follow-up GET (which could carry a different code in principle) runs right after it.""" - fake = _FakeRawWriteSession(post_code=0x80, get_rep={'value': 1}) + fake = _FakeRawWriteSession(post_code=0x80, get_rep={"value": 1}) coordinator._session = fake - code, _ = await coordinator.async_raw_write('/test/vs/0', {'a': 1}) + code, _ = await coordinator.async_raw_write("/test/vs/0", {"a": 1}) assert code == 0x80 async def test_raw_write_rejects_empty_dict(coordinator) -> None: with pytest.raises(ServiceValidationError): - await coordinator.async_raw_write('/washer/vs/0', {}) + await coordinator.async_raw_write("/washer/vs/0", {}) async def test_raw_write_rejects_non_dict_payload(coordinator) -> None: with pytest.raises(ServiceValidationError): - await coordinator.async_raw_write('/washer/vs/0', ['not', 'a', 'dict']) # type: ignore[arg-type] + await coordinator.async_raw_write("/washer/vs/0", ["not", "a", "dict"]) # type: ignore[arg-type] async def test_raw_write_rejects_empty_href(coordinator) -> None: with pytest.raises(ServiceValidationError): - await coordinator.async_raw_write('', {'a': 1}) + await coordinator.async_raw_write("", {"a": 1}) async def test_raw_write_rejects_root_href(coordinator) -> None: with pytest.raises(ServiceValidationError): - await coordinator.async_raw_write('/', {'a': 1}) + await coordinator.async_raw_write("/", {"a": 1}) async def test_raw_write_validation_errors_do_not_touch_the_session(coordinator) -> None: @@ -131,7 +138,7 @@ async def test_raw_write_validation_errors_do_not_touch_the_session(coordinator) assert coordinator._session is None with pytest.raises(ServiceValidationError): - await coordinator.async_raw_write('/washer/vs/0', {}) + await coordinator.async_raw_write("/washer/vs/0", {}) assert coordinator._session is None coordinator.async_request_refresh.assert_not_awaited() diff --git a/tests/test_coordinator_send_command.py b/tests/test_coordinator_send_command.py index dec826e..dc26cca 100644 --- a/tests/test_coordinator_send_command.py +++ b/tests/test_coordinator_send_command.py @@ -11,6 +11,7 @@ real poll lands. async_send_command must pre-merge the token into the cached array itself before applying it optimistically, while still POSTing only the minimal body over the wire. """ + from __future__ import annotations from unittest.mock import AsyncMock @@ -21,7 +22,11 @@ from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.localthings.const import ( - CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN, + CONF_HOST, + CONF_LEAF_CERT_PEM, + CONF_LEAF_KEY_PEM, + CONF_PORT, + DOMAIN, ) from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.registry.capabilities import laundry @@ -31,10 +36,10 @@ from custom_components.localthings.registry.entities import ClimateDesc from custom_components.localthings.registry.subdevices import Subdevice ENTRY_DATA = { - CONF_HOST: '10.0.0.199', + CONF_HOST: "10.0.0.199", CONF_PORT: 49154, - CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----', - CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----', + CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----", + CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----", } @@ -44,7 +49,7 @@ class _FakeSendSession: def post(self, path_segs, payload, timeout=None): self.post_calls.append((list(path_segs), payload)) - return 0x44, b'' + return 0x44, b"" def pace(self): pass @@ -53,7 +58,9 @@ class _FakeSendSession: @pytest.fixture def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator: entry = MockConfigEntry( - domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SENDCMD-TEST', + domain=DOMAIN, + data=ENTRY_DATA, + unique_id="localthings_SENDCMD-TEST", ) entry.add_to_hass(hass) coord = LocalThingsCoordinator(hass, entry) @@ -63,37 +70,47 @@ def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator: async def test_options_write_posts_only_the_changed_token(coordinator) -> None: - href = '/course/vs/0' - coordinator._observe.apply(href, { - 'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'], - }, source='poll') + href = "/course/vs/0" + coordinator._observe.apply( + href, + { + "x.com.samsung.da.options": ["DeviceType_0167", "Course_16", "GMT_04"], + }, + source="poll", + ) - desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') + desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x") bound = BoundEntity(href=href, capability=None, desc=desc) - await coordinator.async_send_command(bound, '1D') + await coordinator.async_send_command(bound, "1D") posted_path, posted_bytes = coordinator._session.post_calls[0] - assert posted_path == ['course', 'vs', '0'] - assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.options': ['Course_1D']} + assert posted_path == ["course", "vs", "0"] + assert cbor2.loads(posted_bytes) == {"x.com.samsung.da.options": ["Course_1D"]} async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator) -> None: """The regression this guards: applying the minimal wire body straight to the cache would wipe DeviceType_0167/GMT_04 until the next poll.""" - href = '/course/vs/0' - coordinator._observe.apply(href, { - 'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'], - }, source='poll') + href = "/course/vs/0" + coordinator._observe.apply( + href, + { + "x.com.samsung.da.options": ["DeviceType_0167", "Course_16", "GMT_04"], + }, + source="poll", + ) - desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') + desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x") bound = BoundEntity(href=href, capability=None, desc=desc) - await coordinator.async_send_command(bound, '1D') + await coordinator.async_send_command(bound, "1D") cached = coordinator._cache.get(href) - assert cached['x.com.samsung.da.options'] == [ - 'DeviceType_0167', 'Course_1D', 'GMT_04', + assert cached["x.com.samsung.da.options"] == [ + "DeviceType_0167", + "Course_1D", + "GMT_04", ] @@ -107,63 +124,65 @@ async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator) # its own. # --------------------------------------------------------------------------- + def _climate_bound(href: str, subdevice: Subdevice) -> BoundEntity: - desc = ClimateDesc(key='climate', translation_key='airconditioner', - write_fn=_climate_write) + desc = ClimateDesc(key="climate", translation_key="airconditioner", write_fn=_climate_write) return BoundEntity(href=href, capability=None, desc=desc, subdevice=subdevice) async def test_indexed_subdevice_write_posts_to_translated_path(coordinator) -> None: """A power write from the bedroom subdevice's (indexed '1') climate entity must POST to /power/vs/1, not the master's /power/vs/0.""" - sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1')) - bound = _climate_bound('/mode/vs/1', sub1) + sub1 = Subdevice(kind="indexed", key="1", seed_path=("device", "1")) + bound = _climate_bound("/mode/vs/1", sub1) - await coordinator.async_send_command(bound, ('power', True)) + await coordinator.async_send_command(bound, ("power", True)) posted_path, posted_bytes = coordinator._session.post_calls[0] - assert posted_path == ['power', 'vs', '1'] - assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'} + assert posted_path == ["power", "vs", "1"] + assert cbor2.loads(posted_bytes) == {"x.com.samsung.da.power": "On"} async def test_indexed_subdevice_write_applies_optimistic_value_to_translated_href( coordinator, ) -> None: - sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1')) - bound = _climate_bound('/mode/vs/1', sub1) + sub1 = Subdevice(kind="indexed", key="1", seed_path=("device", "1")) + bound = _climate_bound("/mode/vs/1", sub1) - await coordinator.async_send_command(bound, ('power', True)) + await coordinator.async_send_command(bound, ("power", True)) # The optimistic apply + settle guard must land on /power/vs/1 -- the # translated href a real device confirms this write against -- not on # /mode/vs/1 (bound_entity.href) or /power/vs/0 (the master's resource). - assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'} - assert coordinator._cache.get('/power/vs/0') is None + assert coordinator._cache.get("/power/vs/1") == {"x.com.samsung.da.power": "On"} + assert coordinator._cache.get("/power/vs/0") is None # The settle guard is armed on that same translated href: a stale poll # reporting the pre-write value must be dropped, not allowed to revert # the optimistic 'On' (issue #27's regression, translated to a # subdevice's own resource). applied = coordinator._observe.apply( - '/power/vs/1', {'x.com.samsung.da.power': 'Off'}, source='poll', + "/power/vs/1", + {"x.com.samsung.da.power": "Off"}, + source="poll", ) assert applied is False - assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'} + assert coordinator._cache.get("/power/vs/1") == {"x.com.samsung.da.power": "On"} async def test_prefixed_subdevice_write_posts_to_translated_path(coordinator) -> None: """A power write from a UUID-prefixed subdevice's climate entity must POST to //power/vs/0, not the bare canonical href.""" - sub_id = '6c2dff6d-ee5c-dad1-6a5e-000000000001' - subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0')) - bound = _climate_bound(f'/{sub_id}/mode/vs/0', subdevice) + sub_id = "6c2dff6d-ee5c-dad1-6a5e-000000000001" + subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=(sub_id, "device", "0")) + bound = _climate_bound(f"/{sub_id}/mode/vs/0", subdevice) - await coordinator.async_send_command(bound, ('power', True)) + await coordinator.async_send_command(bound, ("power", True)) posted_path, posted_bytes = coordinator._session.post_calls[0] - assert posted_path == [sub_id, 'power', 'vs', '0'] - assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'} - assert coordinator._cache.get(f'/{sub_id}/power/vs/0') == { - 'x.com.samsung.da.power': 'On', + assert posted_path == [sub_id, "power", "vs", "0"] + assert cbor2.loads(posted_bytes) == {"x.com.samsung.da.power": "On"} + assert coordinator._cache.get(f"/{sub_id}/power/vs/0") == { + "x.com.samsung.da.power": "On", } @@ -172,9 +191,10 @@ async def test_main_climate_write_unaffected_by_subdevice_translation(coordinato subdevices must keep posting to the exact same path as before this translation step existed.""" from custom_components.localthings.registry.subdevices import MAIN - bound = _climate_bound('/mode/vs/0', MAIN) - await coordinator.async_send_command(bound, ('power', True)) + bound = _climate_bound("/mode/vs/0", MAIN) + + await coordinator.async_send_command(bound, ("power", True)) posted_path, _ = coordinator._session.post_calls[0] - assert posted_path == ['power', 'vs', '0'] + assert posted_path == ["power", "vs", "0"] diff --git a/tests/test_dehumidifier_capabilities.py b/tests/test_dehumidifier_capabilities.py index 3d8e67f..2aa1320 100644 --- a/tests/test_dehumidifier_capabilities.py +++ b/tests/test_dehumidifier_capabilities.py @@ -3,19 +3,20 @@ HA-free like the rest of the suite: exercises the registry, discovery/ flatten, and the target-humidity/operating-mode write contracts. """ + 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 custom_components.localthings.registry.entities import NumberDesc, SelectDesc - from tests.conftest import _load_device def _dehumidifier(): - resources = _load_device('dehumidifier') - info = resources['/information/vs/0'] + resources = _load_device("dehumidifier") + 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 @@ -37,7 +38,7 @@ def _desc(key): def test_model_resolves_to_dehumidifier_registry(): reg, _ = _dehumidifier() - assert reg is not None and reg.name == 'dehumidifier' + assert reg is not None and reg.name == "dehumidifier" def test_no_unbound_hrefs(): @@ -51,36 +52,43 @@ def test_no_unbound_hrefs(): def test_expected_state_keys_present(): state = _state() - for key in ('humidity', 'target_humidity', 'operating_mode', 'power_switch', - 'auto_clean', 'air_filter_status', 'mute_once'): + for key in ( + "humidity", + "target_humidity", + "operating_mode", + "power_switch", + "auto_clean", + "air_filter_status", + "mute_once", + ): assert key in state, key def test_humidity_sensor_reads_current_value(): state = _state() - assert state['humidity'] == 47 + assert state["humidity"] == 47 def test_target_humidity_number_reads_desired_value(): state = _state() - assert state['target_humidity'] == 50 + assert state["target_humidity"] == 50 def test_target_humidity_write_contract(): - desc = _desc('target_humidity') + desc = _desc("target_humidity") assert isinstance(desc, NumberDesc) - path, body = desc.write_fn('55', {}) - assert path == ['humidity', 'vs', '0'] - assert body == {'x.com.samsung.da.desiredHumidity': '55'} + path, body = desc.write_fn("55", {}) + assert path == ["humidity", "vs", "0"] + assert body == {"x.com.samsung.da.desiredHumidity": "55"} def test_target_humidity_step_reads_live_increment(): """Step comes from the device's own `increment` field rather than a hardcoded constant -- see the adding-device-support skill's 'never hard-code the one dump's values' section.""" - desc = _desc('target_humidity') - assert desc.step_fn({'increment': '5'}) == 5 - assert desc.step_fn({'increment': '10'}) == 10 + desc = _desc("target_humidity") + assert desc.step_fn({"increment": "5"}) == 5 + assert desc.step_fn({"increment": "10"}) == 10 # No live field: falls back to a sane default rather than raising. assert desc.step_fn({}) == 1 @@ -89,7 +97,7 @@ def test_target_humidity_has_no_hardcoded_bounds(): """No range field is present in any dump seen so far -- native_min/max are deliberately left unset (falls back to HA's own 0-100 default for a percentage field) rather than a bound guessed from a spec sheet.""" - desc = _desc('target_humidity') + desc = _desc("target_humidity") assert desc.native_min is None assert desc.native_max is None assert desc.native_min_fn is None @@ -100,19 +108,19 @@ def test_operating_mode_select_options_come_from_live_supported_modes(): """Options are read live from x.com.samsung.da.supportedModes, not a hardcoded tuple -- so a future device with a different mode set is handled automatically.""" - desc = _desc('operating_mode') + desc = _desc("operating_mode") assert isinstance(desc, SelectDesc) - assert desc.options_field == 'x.com.samsung.da.supportedModes' + assert desc.options_field == "x.com.samsung.da.supportedModes" assert desc.options == () def test_operating_mode_write_contract(): - desc = _desc('operating_mode') - path, body = desc.write_fn('Quiet', {}) - assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.modes': ['Quiet']} + desc = _desc("operating_mode") + path, body = desc.write_fn("Quiet", {}) + assert path == ["mode", "vs", "0"] + assert body == {"x.com.samsung.da.modes": ["Quiet"]} def test_operating_mode_reads_first_mode(): state = _state() - assert state['operating_mode'] == 'Smart' + assert state["operating_mode"] == "Smart" diff --git a/tests/test_diagnostics_subdevices.py b/tests/test_diagnostics_subdevices.py index 1406272..bc5265e 100644 --- a/tests/test_diagnostics_subdevices.py +++ b/tests/test_diagnostics_subdevices.py @@ -3,6 +3,7 @@ subdevices_skipped, subdevice_probes) against a real composite-device discovery run -- makes sure the new blocks are actually reachable/shaped right, not just that the coordinator's own attributes look correct in isolation (test_subdevice_discovery.py covers that).""" + from __future__ import annotations from homeassistant.core import HomeAssistant @@ -11,36 +12,37 @@ from custom_components.localthings.const import DOMAIN from custom_components.localthings.diagnostics import ( async_get_config_entry_diagnostics, ) - from tests.test_subdevice_discovery import _coordinator, _discover async def test_diagnostics_reports_materialized_and_skipped_subdevices( - hass: HomeAssistant, enable_custom_integrations, + hass: HomeAssistant, + enable_custom_integrations, ) -> None: coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + await _discover(coordinator, "airconditioner_artik051_dongle_fac_18k") hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) - subdevice_keys = {su['key'] for su in diag['subdevices']} - skipped_keys = {su['key'] for su in diag['subdevices_skipped']} - assert subdevice_keys == {'1'} - assert skipped_keys == {'2'} - assert diag['subdevices'][0]['bound_entity_count'] > 0 - assert diag['subdevices_skipped'][0]['hrefs'] - assert '/multidevice/vs/0' in diag['subdevice_probes'] + subdevice_keys = {su["key"] for su in diag["subdevices"]} + skipped_keys = {su["key"] for su in diag["subdevices_skipped"]} + assert subdevice_keys == {"1"} + assert skipped_keys == {"2"} + assert diag["subdevices"][0]["bound_entity_count"] > 0 + assert diag["subdevices_skipped"][0]["hrefs"] + assert "/multidevice/vs/0" in diag["subdevice_probes"] # The reporter's hand-read value, carried in the fixture's `probes` map # (it belongs to no batch -- see that fixture's seeds_note). Two real # subdevices, master + bedroom, independently corroborating the gate's # decision to skip /device/2. Reported on its own, never as a resource. - assert diag['multidevice'] == {'x.com.samsung.da.numofsubdevice': '2'} - assert '/multidevice/vs/0' not in diag['resources'] + assert diag["multidevice"] == {"x.com.samsung.da.numofsubdevice": "2"} + assert "/multidevice/vs/0" not in diag["resources"] async def test_top_level_resources_is_the_master_subdevice_only( - hass: HomeAssistant, enable_custom_integrations, + hass: HomeAssistant, + enable_custom_integrations, ) -> None: """`resources` reports this subdevice's own hrefs and nothing else. @@ -51,23 +53,24 @@ async def test_top_level_resources_is_the_master_subdevice_only( snapshot"). Each sibling carries its own canonicalized block instead. """ coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + await _discover(coordinator, "airconditioner_artik051_dongle_fac_18k") hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) - assert not [h for h in diag['resources'] if h.endswith('/1')] - assert '/mode/vs/0' in diag['resources'] + assert not [h for h in diag["resources"] if h.endswith("/1")] + assert "/mode/vs/0" in diag["resources"] # The sibling's own block carries its state, canonicalized -- '/mode/vs/0', # not the '/mode/vs/1' it actually answers on. sub1 = diag["subdevices"][0] - assert '/mode/vs/0' in sub1["resources"] - assert not [h for h in sub1['resources'] if h.endswith('/1')] - assert sub1["resources"]["/power/vs/0"]['x.com.samsung.da.power'] == 'On' + assert "/mode/vs/0" in sub1["resources"] + assert not [h for h in sub1["resources"] if h.endswith("/1")] + assert sub1["resources"]["/power/vs/0"]["x.com.samsung.da.power"] == "On" async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache( - hass: HomeAssistant, enable_custom_integrations, + hass: HomeAssistant, + enable_custom_integrations, ) -> None: """A gate-rejected slot is never polled again, so anything applied to the state cache for it would sit frozen at its first-discovery value while @@ -75,40 +78,42 @@ async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache( and reported only under its own subdevices_skipped entry -- which is also the only place a reader can go to second-guess the gate.""" coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + await _discover(coordinator, "airconditioner_artik051_dongle_fac_18k") hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator - assert not [h for h in coordinator.last_resources if h.endswith('/2')] + assert not [h for h in coordinator.last_resources if h.endswith("/2")] diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) - skipped = diag['subdevices_skipped'][0] + skipped = diag["subdevices_skipped"][0] # Present, canonicalized, and visibly the empty state the gate rejected. - assert skipped['resources']['/power/vs/0'] == {} - assert skipped['resources']['/mode/vs/0'] == {} - assert skipped['resources']['/information/vs/0'] + assert skipped["resources"]["/power/vs/0"] == {} + assert skipped["resources"]["/mode/vs/0"] == {} + assert skipped["resources"]["/information/vs/0"] async def test_diagnostics_reports_prefixed_subdevice( - hass: HomeAssistant, enable_custom_integrations, + hass: HomeAssistant, + enable_custom_integrations, ) -> None: coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_fac_bora_2in1') + await _discover(coordinator, "airconditioner_fac_bora_2in1") hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) - assert len(diag['subdevices']) == 1 - assert diag['subdevices'][0]['kind'] == 'prefixed' + assert len(diag["subdevices"]) == 1 + assert diag["subdevices"][0]["kind"] == "prefixed" # diagnostics reports the raw modelNum field verbatim (board revision/ # capability-bitmap suffix included), unlike device_info_for's model # (split at '|') -- confirms it's still the wall subdevice's own identity, # not the master's TP2X_FAC_BORA_21K. - assert diag['subdevices'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K') - assert diag['subdevices_skipped'] == [] + assert diag["subdevices"][0]["model"].startswith("TP2X_FAC_BORA_RAC_21K") + assert diag["subdevices_skipped"] == [] async def test_diagnostics_reports_flat_hrefs_for_skipped_prefixed_candidate( - hass: HomeAssistant, enable_custom_integrations, + hass: HomeAssistant, + enable_custom_integrations, ) -> None: """issue #205: a prefixed candidate found through the per-href flat fallback (no working //device/0 Collection) has no meaningful @@ -116,14 +121,14 @@ async def test_diagnostics_reports_flat_hrefs_for_skipped_prefixed_candidate( bare "/" an empty tuple would otherwise join to, and lists the actual hrefs the fallback confirmed instead.""" coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_fac_bora_205_flat') + await _discover(coordinator, "airconditioner_fac_bora_205_flat") hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) - assert diag['subdevices'] == [] - assert len(diag['subdevices_skipped']) == 1 - skipped = diag['subdevices_skipped'][0] - assert skipped['kind'] == 'prefixed' - assert skipped['seed_path'] is None - assert skipped['flat_hrefs'] == ['/information/vs/0'] + assert diag["subdevices"] == [] + assert len(diag["subdevices_skipped"]) == 1 + skipped = diag["subdevices_skipped"][0] + assert skipped["kind"] == "prefixed" + assert skipped["seed_path"] is None + assert skipped["flat_hrefs"] == ["/information/vs/0"] diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 89591e8..582623e 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -1,111 +1,118 @@ from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.discovery import ( - BoundEntity, discover, instance_suffix, + discover, + instance_suffix, ) from custom_components.localthings.registry.entities import BinarySensorDesc LOCK = Capability( - href='/kidslock/vs/0', - entities=(BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock'),), + href="/kidslock/vs/0", + entities=(BinarySensorDesc(key="child_lock", field="x.com.samsung.da.kidsLock"),), ) +assert LOCK.href is not None REG = {LOCK.href: [LOCK]} def test_instance_suffix(): - assert instance_suffix('/kidslock/vs/0') == '' - assert instance_suffix('/door/vs/1') == '_1' - assert instance_suffix('/icemaker/vs/2') == '_2' + assert instance_suffix("/kidslock/vs/0") == "" + assert instance_suffix("/door/vs/1") == "_1" + assert instance_suffix("/icemaker/vs/2") == "_2" def test_discover_binds_present_capability(): - resources = {'/kidslock/vs/0': {'x.com.samsung.da.kidsLock': 'On'}} + resources = {"/kidslock/vs/0": {"x.com.samsung.da.kidsLock": "On"}} bound = discover(resources, REG) assert len(bound) == 1 - assert bound[0].href == '/kidslock/vs/0' - assert bound[0].desc.key == 'child_lock' - assert bound[0].instance == '' + assert bound[0].href == "/kidslock/vs/0" + assert bound[0].desc.key == "child_lock" + assert bound[0].instance == "" def test_discover_skips_unknown_href(): seen = [] - resources = {'/mystery/vs/0': {'x.com.samsung.da.mystery': 'x'}} + resources = {"/mystery/vs/0": {"x.com.samsung.da.mystery": "x"}} bound = discover(resources, REG, log=seen.append) assert bound == [] - assert any('mystery' in m for m in seen) + assert any("mystery" in m for m in seen) def test_discover_multi_instance_suffixes(): - cap = Capability(href='/door/vs/0', - entities=(BinarySensorDesc(key='door', field='x.com.samsung.da.doorState'),)) + cap = Capability( + href="/door/vs/0", + entities=(BinarySensorDesc(key="door", field="x.com.samsung.da.doorState"),), + ) resources = { - '/door/vs/0': {'x.com.samsung.da.doorState': 'Open'}, - '/door/vs/1': {'x.com.samsung.da.doorState': 'Closed'}, + "/door/vs/0": {"x.com.samsung.da.doorState": "Open"}, + "/door/vs/1": {"x.com.samsung.da.doorState": "Closed"}, } - reg = {'/door/vs/0': [cap], '/door/vs/1': [cap]} + reg = {"/door/vs/0": [cap], "/door/vs/1": [cap]} bound = discover(resources, reg) insts = sorted(b.instance for b in bound) - assert insts == ['', '_1'] + assert insts == ["", "_1"] def test_discover_logs_unregistered_href(): seen = [] - cap = Capability(href='/kidslock/vs/0', - entities=(BinarySensorDesc(key='child_lock', field='x.com.samsung.da.kidsLock'),)) + cap = Capability( + href="/kidslock/vs/0", + entities=(BinarySensorDesc(key="child_lock", field="x.com.samsung.da.kidsLock"),), + ) resources = { - '/kidslock/vs/0': {'x.com.samsung.da.kidsLock': 'On'}, - '/mystery/vs/0': {'x.com.samsung.da.mystery': 'x'}, + "/kidslock/vs/0": {"x.com.samsung.da.kidsLock": "On"}, + "/mystery/vs/0": {"x.com.samsung.da.mystery": "x"}, } - bound = discover(resources, {'/kidslock/vs/0': [cap]}, log=seen.append) + bound = discover(resources, {"/kidslock/vs/0": [cap]}, log=seen.append) assert len(bound) == 1 - assert any('mystery' in m for m in seen) + assert any("mystery" in m for m in seen) # --------------------------------------------------------------------------- # New tests for Task 2: pattern caps, rt_filter, match_fn # --------------------------------------------------------------------------- + def test_discover_pattern_cap_binds_unmatched_href(): """Pattern cap with rt_filter binds unmatched hrefs; exact-href cap does not steal unmatched hrefs.""" cooler_cap = Capability( - href='/door/cooler/0', - entities=(BinarySensorDesc(key='cooler_door', field='x.com.samsung.da.doorState'),), + href="/door/cooler/0", + entities=(BinarySensorDesc(key="cooler_door", field="x.com.samsung.da.doorState"),), ) door_pattern = Capability( href=None, - rt_filter='oic.r.door', - entities=(BinarySensorDesc(key='door', field='x.com.samsung.da.doorState'),), + rt_filter="oic.r.door", + entities=(BinarySensorDesc(key="door", field="x.com.samsung.da.doorState"),), ) resources = { - '/door/cooler/0': {'x.com.samsung.da.doorState': 'Closed', 'rt': ['oic.r.door']}, - '/door/wine/0': {'x.com.samsung.da.doorState': 'Open', 'rt': ['oic.r.door']}, + "/door/cooler/0": {"x.com.samsung.da.doorState": "Closed", "rt": ["oic.r.door"]}, + "/door/wine/0": {"x.com.samsung.da.doorState": "Open", "rt": ["oic.r.door"]}, } - reg = {'/door/cooler/0': [cooler_cap]} + reg = {"/door/cooler/0": [cooler_cap]} bound = discover(resources, reg, pattern_caps=[door_pattern]) hrefs = [b.href for b in bound] # exact cap claims /door/cooler/0 - assert '/door/cooler/0' in hrefs + assert "/door/cooler/0" in hrefs # pattern cap claims /door/wine/0 (unmatched by registry) - assert '/door/wine/0' in hrefs + assert "/door/wine/0" in hrefs # /door/cooler/0 is not also claimed by the pattern cap - 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 all(b.capability is cooler_cap for b in cooler_bindings) def test_discover_pattern_cap_skips_already_bound_href(): """Pattern cap must not bind an href already claimed by an exact-href cap.""" exact_cap = Capability( - href='/door/cooler/0', - entities=(BinarySensorDesc(key='cooler_door', field='x.com.samsung.da.doorState'),), + href="/door/cooler/0", + entities=(BinarySensorDesc(key="cooler_door", field="x.com.samsung.da.doorState"),), ) pattern = Capability( href=None, - rt_filter='oic.r.door', - entities=(BinarySensorDesc(key='generic_door', field='x.com.samsung.da.doorState'),), + rt_filter="oic.r.door", + entities=(BinarySensorDesc(key="generic_door", field="x.com.samsung.da.doorState"),), ) - resources = {'/door/cooler/0': {'x.com.samsung.da.doorState': 'Closed', 'rt': ['oic.r.door']}} - reg = {'/door/cooler/0': [exact_cap]} + resources = {"/door/cooler/0": {"x.com.samsung.da.doorState": "Closed", "rt": ["oic.r.door"]}} + reg = {"/door/cooler/0": [exact_cap]} bound = discover(resources, reg, pattern_caps=[pattern]) # exactly one binding for /door/cooler/0 — the exact cap, not the pattern @@ -115,25 +122,15 @@ def test_discover_pattern_cap_skips_already_bound_href(): def test_discover_match_fn_filters_wrong_device(): """A cap with match_fn must not bind when its condition is not met.""" - oven_cap = Capability( - href='/oven/vs/0', - match_fn=lambda r, rs: '/oven/vs/0' in rs, - entities=(BinarySensorDesc(key='oven_status', field='x.com.samsung.da.state'),), - ) - # resources does NOT include /oven/vs/0 — match_fn should fail - resources = {'/oven/vs/0': {'x.com.samsung.da.state': 'Ready'}} - reg = {'/oven/vs/0': [oven_cap]} - # Without /oven/vs/0 present as a key in resources, match_fn should be False - resources_no_oven = {'/other/vs/0': {'x.com.samsung.da.state': 'Ready'}} - reg_no_oven = {} + resources_no_oven = {"/other/vs/0": {"x.com.samsung.da.state": "Ready"}} # Put oven cap on a different href to test match_fn rejection oven_cap2 = Capability( - href='/other/vs/0', - match_fn=lambda r, rs: '/oven/vs/0' in rs, - entities=(BinarySensorDesc(key='oven_status', field='x.com.samsung.da.state'),), + href="/other/vs/0", + match_fn=lambda r, rs: "/oven/vs/0" in rs, + entities=(BinarySensorDesc(key="oven_status", field="x.com.samsung.da.state"),), ) - bound = discover(resources_no_oven, {'/other/vs/0': [oven_cap2]}) + bound = discover(resources_no_oven, {"/other/vs/0": [oven_cap2]}) # match_fn returns False → no bindings assert bound == [] @@ -148,12 +145,12 @@ def test_discover_match_fn_decline_is_not_logged_as_gap(): """ seen = [] cap = Capability( - href='/filter/vs/0', + href="/filter/vs/0", match_fn=lambda rep, resources: False, - entities=(BinarySensorDesc(key='filter', field='x.com.samsung.da.filterStatus'),), + entities=(BinarySensorDesc(key="filter", field="x.com.samsung.da.filterStatus"),), ) - resources = {'/filter/vs/0': {'x.com.samsung.da.filterStatus': 'notused'}} - bound = discover(resources, {'/filter/vs/0': [cap]}, log=seen.append) + resources = {"/filter/vs/0": {"x.com.samsung.da.filterStatus": "notused"}} + bound = discover(resources, {"/filter/vs/0": [cap]}, log=seen.append) assert bound == [] assert seen == [] @@ -164,36 +161,36 @@ def test_discover_pattern_cap_reads_name_field(): independent of the href-derived key (issue #27).""" cap = Capability( href=None, - href_prefix='/icemaker/', - name_field='x.com.samsung.da.iceMaker.name', - entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),), + href_prefix="/icemaker/", + name_field="x.com.samsung.da.iceMaker.name", + entities=(BinarySensorDesc(key="enabled", field="x.com.samsung.da.iceMaker.state"),), ) resources = { - '/icemaker/one/vs/0': { - 'x.com.samsung.da.iceMaker.state': 'On', - 'x.com.samsung.da.iceMaker.name': 'CUBED_ICE', + "/icemaker/one/vs/0": { + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.name": "CUBED_ICE", }, - '/icemaker/two/vs/0': { - 'x.com.samsung.da.iceMaker.state': 'On', - 'x.com.samsung.da.iceMaker.name': 'ICE_BITES', + "/icemaker/two/vs/0": { + "x.com.samsung.da.iceMaker.state": "On", + "x.com.samsung.da.iceMaker.name": "ICE_BITES", }, } bound = discover(resources, {}, pattern_caps=[cap]) names = {b.href: b.instance_name for b in bound} assert names == { - '/icemaker/one/vs/0': 'Cubed Ice', - '/icemaker/two/vs/0': 'Ice Bites', + "/icemaker/one/vs/0": "Cubed Ice", + "/icemaker/two/vs/0": "Ice Bites", } def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none(): cap = Capability( href=None, - href_prefix='/icemaker/', - name_field='x.com.samsung.da.iceMaker.name', - entities=(BinarySensorDesc(key='enabled', field='x.com.samsung.da.iceMaker.state'),), + href_prefix="/icemaker/", + name_field="x.com.samsung.da.iceMaker.name", + entities=(BinarySensorDesc(key="enabled", field="x.com.samsung.da.iceMaker.state"),), ) - resources = {'/icemaker/one/vs/0': {'x.com.samsung.da.iceMaker.state': 'On'}} + resources = {"/icemaker/one/vs/0": {"x.com.samsung.da.iceMaker.state": "On"}} bound = discover(resources, {}, pattern_caps=[cap]) assert bound[0].instance_name is None @@ -201,13 +198,13 @@ def test_discover_pattern_cap_name_field_absent_leaves_instance_name_none(): def test_discover_rt_filter_gates_binding(): """Cap with rt_filter must not bind a rep whose rt list does not match.""" oven_mode_cap = Capability( - href='/mode/vs/0', - rt_filter='x.com.samsung.da.ovenMode', - entities=(BinarySensorDesc(key='oven_mode', field='x.com.samsung.da.mode'),), + href="/mode/vs/0", + rt_filter="x.com.samsung.da.ovenMode", + entities=(BinarySensorDesc(key="oven_mode", field="x.com.samsung.da.mode"),), ) # rep has a different rt → should not bind - resources = {'/mode/vs/0': {'rt': ['x.com.samsung.da.mode'], 'x.com.samsung.da.mode': 'Bake'}} - bound = discover(resources, {'/mode/vs/0': [oven_mode_cap]}) + resources = {"/mode/vs/0": {"rt": ["x.com.samsung.da.mode"], "x.com.samsung.da.mode": "Bake"}} + bound = discover(resources, {"/mode/vs/0": [oven_mode_cap]}) assert bound == [] @@ -218,10 +215,14 @@ def test_discover_tier_log_fires_for_no_entity_coverage_cap(): caller its poll_tier. tier_log must still report it, or a consumer (the coordinator's hot/warm href lists) silently treats it as 'cold' regardless of what poll_tier the capability actually declares.""" - coverage_cap = Capability(href='/wind/strength/vs/0', poll_tier='warm') - resources = {'/wind/strength/vs/0': {'x.com.samsung.da.modes': '0'}} + coverage_cap = Capability(href="/wind/strength/vs/0", poll_tier="warm") + resources = {"/wind/strength/vs/0": {"x.com.samsung.da.modes": "0"}} seen = [] - bound = discover(resources, {coverage_cap.href: [coverage_cap]}, - tier_log=lambda href, tier: seen.append((href, tier))) + assert coverage_cap.href is not None + bound = discover( + resources, + {coverage_cap.href: [coverage_cap]}, + tier_log=lambda href, tier: seen.append((href, tier)), + ) assert bound == [] - assert seen == [('/wind/strength/vs/0', 'warm')] + assert seen == [("/wind/strength/vs/0", "warm")] diff --git a/tests/test_dishwasher_capabilities.py b/tests/test_dishwasher_capabilities.py index 4497966..5cfb7b6 100644 --- a/tests/test_dishwasher_capabilities.py +++ b/tests/test_dishwasher_capabilities.py @@ -4,44 +4,44 @@ The shared /course/vs/0 cycle-select machinery (parse_edit_course_list, cycle_options, cycle_write) is tested in test_laundry_capabilities.py; here we check the dishwasher wiring and its device-specific options. """ + from custom_components.localthings.registry.capabilities import dishwasher, laundry class TestCycleOptions: def _cycle(self): - return next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'cycle') + return next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "cycle") def test_cycle_desc_uses_shared_cycle_options(self): desc = self._cycle() assert desc.options is laundry.cycle_options - assert desc.translation_key == 'dishwasher_cycle' + assert desc.translation_key == "dishwasher_cycle" def test_exists_only_when_edit_course_list_is_live(self): desc = self._cycle() assert desc.exists_fn({}, {}) is False - assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False - live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_0E'}} + assert desc.exists_fn({}, {"/wm/editcourse/vs/0": {}}) is False + live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_0E"}} assert desc.exists_fn({}, live) is True def test_cycle_write_uses_raw_code_directly(self): desc = self._cycle() - rep = {'x.com.samsung.da.options': ['DeviceType_0001', 'Course_0E', 'GMT_04']} - path, body = desc.write_fn('90', rep) - assert path == ['course', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['Course_90']} + rep = {"x.com.samsung.da.options": ["DeviceType_0001", "Course_0E", "GMT_04"]} + path, body = desc.write_fn("90", rep) + assert path == ["course", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["Course_90"]} class TestDishwasherOptions: def test_storm_wash_read_and_write(self): - desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'storm_wash') - assert desc.rep_fn({'x.com.samsung.da.options': ['StormWashZone_On']}) is True - assert desc.rep_fn({'x.com.samsung.da.options': ['StormWashZone_Off']}) is False - path, body = desc.write_fn('Off', {'x.com.samsung.da.options': ['StormWashZone_On']}) - assert path == ['course', 'vs', '0'] - assert 'StormWashZone_Off' in body['x.com.samsung.da.options'] + desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "storm_wash") + assert desc.rep_fn({"x.com.samsung.da.options": ["StormWashZone_On"]}) is True + assert desc.rep_fn({"x.com.samsung.da.options": ["StormWashZone_Off"]}) is False + path, body = desc.write_fn("Off", {"x.com.samsung.da.options": ["StormWashZone_On"]}) + assert path == ["course", "vs", "0"] + assert "StormWashZone_Off" in body["x.com.samsung.da.options"] def test_auto_release_exists_only_when_field_present(self): - desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == 'auto_release_dry') - assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False - assert desc.exists_fn( - {'x.com.samsung.da.options': ['AutoDoorRelease_On']}, {}) is True + desc = next(e for e in dishwasher.CYCLE_OPTIONS.entities if e.key == "auto_release_dry") + assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False + assert desc.exists_fn({"x.com.samsung.da.options": ["AutoDoorRelease_On"]}, {}) is True diff --git a/tests/test_dryer_capabilities.py b/tests/test_dryer_capabilities.py index f2e25a9..229ff87 100644 --- a/tests/test_dryer_capabilities.py +++ b/tests/test_dryer_capabilities.py @@ -1,17 +1,18 @@ """Tests for dryer support and washer/dryer consistency (issue #14).""" + 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 dryer, ignored, laundry from custom_components.localthings.registry.discovery import discover - from tests.conftest import _load_device def _dryer(): - resources = _load_device('dryer') - info = resources['/information/vs/0'] + resources = _load_device("dryer") + 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 +25,7 @@ def _state(): def test_dryer_model_resolves_to_dryer_registry(): reg, _ = _dryer() - assert reg is not None and reg.name == 'dryer' + assert reg is not None and reg.name == "dryer" def test_no_unbound_hrefs(): @@ -38,8 +39,16 @@ def test_no_unbound_hrefs(): def test_expected_entities_present(): state = _state() - for key in ('buzzer_sound', 'cycle', 'power_switch', 'child_lock', - 'remote_control', 'dry_level', 'wrinkle_prevent', 'energy_kwh'): + for key in ( + "buzzer_sound", + "cycle", + "power_switch", + "child_lock", + "remote_control", + "dry_level", + "wrinkle_prevent", + "energy_kwh", + ): assert key in state, key @@ -47,15 +56,15 @@ def test_job_beginning_status_reads_current_status(): """The dump carries x.com.samsung.da.currentStatus (not the old jobBeginingStatus field the dryer descriptor used to read), so the sensor is populated instead of blank.""" - assert _state()['job_beginning_status'] == 'None' # the dump's currentStatus value + assert _state()["job_beginning_status"] == "None" # the dump's currentStatus value def test_power_watts_gated_for_dead_sentinel(): """instantaneousPower is the dead '-500' sentinel on this dryer, so the misleading 0 W power sensor is suppressed; cumulative energy still shows.""" state = _state() - assert 'power_watts' not in state - assert 'energy_kwh' in state + assert "power_watts" not in state + assert "energy_kwh" in state def test_course_bound_to_shared_course_vs_0(): @@ -64,20 +73,20 @@ def test_course_bound_to_shared_course_vs_0(): laundry.cycle_select) -- confirmed dryers report Table_03, matching the shipped dryer_cycle_table_03 translations, consistent with washer/dishwasher.""" - assert dryer.DRYER_COURSE.href == '/course/vs/0' - desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == 'cycle') + assert dryer.DRYER_COURSE.href == "/course/vs/0" + desc = next(e for e in dryer.DRYER_COURSE.entities if e.key == "cycle") assert callable(desc.translation_key) - table_03 = {'/st/dryercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_03'}} - assert desc.translation_key(table_03) == 'dryer_cycle_table_03' - assert desc.translation_key({}) == 'cycle' + table_03 = {"/st/dryercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_03"}} + assert desc.translation_key(table_03) == "dryer_cycle_table_03" + assert desc.translation_key({}) == "cycle" assert desc.options is laundry.cycle_options - rep = {'x.com.samsung.da.options': ['Course_16', 'GMT_02']} - assert desc.rep_fn(rep) == '16' + rep = {"x.com.samsung.da.options": ["Course_16", "GMT_02"]} + assert desc.rep_fn(rep) == "16" def test_st_dryercourse_is_ignored(): """/st/dryercourse/vs/0 re-encodes the course exposed via /course/vs/0 and is globally ignored -- the mirror of /st/washercourse/vs/0.""" ignored_hrefs = {c.href for c in ignored.IGNORED} - assert '/st/dryercourse/vs/0' in ignored_hrefs - assert '/st/washercourse/vs/0' in ignored_hrefs + assert "/st/dryercourse/vs/0" in ignored_hrefs + assert "/st/washercourse/vs/0" in ignored_hrefs diff --git a/tests/test_ehs_capabilities.py b/tests/test_ehs_capabilities.py index 7afd883..63f8b79 100644 --- a/tests/test_ehs_capabilities.py +++ b/tests/test_ehs_capabilities.py @@ -4,19 +4,20 @@ HA-free like the rest of the suite: exercises the registry, discovery/ flatten, and the zone/dhw mode and temperature write contracts. """ + 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 custom_components.localthings.registry.entities import NumberDesc, SelectDesc, WaterHeaterDesc - from tests.conftest import _load_device def _ehs(): - resources = _load_device('ehs') - info = resources['/information/vs/0'] + resources = _load_device("ehs") + 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 @@ -38,7 +39,7 @@ def _desc(key): def test_model_resolves_to_ehs_registry(): reg, _ = _ehs() - assert reg is not None and reg.name == 'ehs' + assert reg is not None and reg.name == "ehs" def test_no_unbound_hrefs(): @@ -53,72 +54,81 @@ def test_no_unbound_hrefs(): def test_expected_state_keys_present(): state = _state() - for key in ('zone_power', 'zone_mode', 'zone_temperature', 'zone_target_temperature', - 'water_heater', 'away_mode', 'mute_once', 'alarm_code', 'energy_kwh'): + for key in ( + "zone_power", + "zone_mode", + "zone_temperature", + "zone_target_temperature", + "water_heater", + "away_mode", + "mute_once", + "alarm_code", + "energy_kwh", + ): assert key in state, key def test_zone_temperature_reads_current_value(): state = _state() - assert state['zone_temperature'] == 30.0 + assert state["zone_temperature"] == 30.0 def test_zone_target_temperature_reads_desired_value(): state = _state() - assert state['zone_target_temperature'] == 5.0 + assert state["zone_target_temperature"] == 5.0 def test_zone_mode_reads_first_mode(): state = _state() - assert state['zone_mode'] == 'Cool' + assert state["zone_mode"] == "Cool" def test_zone_mode_select_options_come_from_live_supported_modes(): """Options are read live from x.com.samsung.da.supportedModes, not a hardcoded tuple -- so a future firmware with a different mode set is handled automatically.""" - desc = _desc('zone_mode') + desc = _desc("zone_mode") assert isinstance(desc, SelectDesc) - assert desc.options_field == 'x.com.samsung.da.supportedModes' + assert desc.options_field == "x.com.samsung.da.supportedModes" assert desc.options == () def test_zone_mode_write_contract(): - desc = _desc('zone_mode') - path, body = desc.write_fn('Heat', {}) - assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.modes': ['Heat']} + desc = _desc("zone_mode") + path, body = desc.write_fn("Heat", {}) + assert path == ["mode", "vs", "0"] + assert body == {"x.com.samsung.da.modes": ["Heat"]} def test_zone_power_reads_off(): state = _state() - assert state['zone_power'] is False + assert state["zone_power"] is False def test_zone_power_write_contract(): - desc = _desc('zone_power') - path, body = desc.write_fn('On', {}) - assert path == ['power', 'vs', '0'] - assert body == {'x.com.samsung.da.power': 'On'} + desc = _desc("zone_power") + path, body = desc.write_fn("On", {}) + assert path == ["power", "vs", "0"] + assert body == {"x.com.samsung.da.power": "On"} def test_zone_target_temperature_write_contract(): - desc = _desc('zone_target_temperature') + desc = _desc("zone_target_temperature") assert isinstance(desc, NumberDesc) - path, body = desc.write_fn('21.5', {}) - assert path == ['temperatures', 'indoor', 'vs', '0'] - assert body == {'x.com.samsung.da.desired': '21.5'} + path, body = desc.write_fn("21.5", {}) + assert path == ["temperatures", "indoor", "vs", "0"] + assert body == {"x.com.samsung.da.desired": "21.5"} def test_zone_target_temperature_bounds_read_live(): """min/max/step come from the device's own resource fields rather than a hardcoded constant -- see the adding-device-support skill's 'never hard-code the one dump's values' section.""" - desc = _desc('zone_target_temperature') - rep = {'x.com.samsung.da.minimum': '5.0', 'x.com.samsung.da.maximum': '25.0'} + desc = _desc("zone_target_temperature") + rep = {"x.com.samsung.da.minimum": "5.0", "x.com.samsung.da.maximum": "25.0"} assert desc.native_min_fn(rep) == 5.0 assert desc.native_max_fn(rep) == 25.0 - assert desc.step_fn({'x.com.samsung.da.increment': '0.5'}) == 0.5 + assert desc.step_fn({"x.com.samsung.da.increment": "0.5"}) == 0.5 # No live field: falls back to a sane default rather than raising. assert desc.native_min_fn({}) == 5.0 assert desc.native_max_fn({}) == 30.0 @@ -129,16 +139,16 @@ def test_zone_target_temperature_bounds_fall_back_together(): """One end without the other is not a usable range: pairing a real device minimum with an invented default maximum looks plausible and is silently wrong, so a half-reported range falls back whole.""" - desc = _desc('zone_target_temperature') - half = {'x.com.samsung.da.minimum': '10.0'} + desc = _desc("zone_target_temperature") + half = {"x.com.samsung.da.minimum": "10.0"} assert desc.native_min_fn(half) == 5.0 assert desc.native_max_fn(half) == 30.0 def test_zone_target_temperature_zero_increment_is_not_collapsed(): """`or` would turn a genuine 0 into the 0.5 default (issue #160).""" - desc = _desc('zone_target_temperature') - assert desc.step_fn({'x.com.samsung.da.increment': '0'}) == 0.0 + desc = _desc("zone_target_temperature") + assert desc.step_fn({"x.com.samsung.da.increment": "0"}) == 0.0 def test_water_heater_entity_is_bound(): @@ -148,7 +158,7 @@ def test_water_heater_entity_is_bound(): bound, _ = _bound() water_heaters = [b for b in bound if isinstance(b.desc, WaterHeaterDesc)] assert len(water_heaters) == 1 - assert water_heaters[0].href == '/mode/dhw/vs/0' + assert water_heaters[0].href == "/mode/dhw/vs/0" def test_water_heater_reads_first_mode(): @@ -156,23 +166,31 @@ def test_water_heater_reads_first_mode(): the entity's current_operation is derived from -- see climate.py's _first_mode for the identical pattern on the AC side.""" state = _state() - assert state['water_heater'] == 'Eco' + assert state["water_heater"] == "Eco" def test_water_heater_write_targets(): """DHW.entities[0].write_fn maps each (kind, value) command to the right vendor POST target and body -- power, mode and temperature only, no fan/ swing/preset (the AC's climate.py has those; the DHW loop doesn't).""" - write = _desc('water_heater').write_fn - assert write(('power', True), {}) == ( - ['power', 'dhw', 'vs', '0'], {'x.com.samsung.da.power': 'On'}) - assert write(('power', False), {}) == ( - ['power', 'dhw', 'vs', '0'], {'x.com.samsung.da.power': 'Off'}) - assert write(('mode', 'Force'), {}) == ( - ['mode', 'dhw', 'vs', '0'], {'x.com.samsung.da.modes': ['Force']}) - assert write(('temperature', 45.0), {}) == ( - ['temperatures', 'dhw', 'vs', '0'], {'x.com.samsung.da.desired': '45.0'}) - assert write(('bogus', 1), {}) is None + write = _desc("water_heater").write_fn + assert write(("power", True), {}) == ( + ["power", "dhw", "vs", "0"], + {"x.com.samsung.da.power": "On"}, + ) + assert write(("power", False), {}) == ( + ["power", "dhw", "vs", "0"], + {"x.com.samsung.da.power": "Off"}, + ) + assert write(("mode", "Force"), {}) == ( + ["mode", "dhw", "vs", "0"], + {"x.com.samsung.da.modes": ["Force"]}, + ) + assert write(("temperature", 45.0), {}) == ( + ["temperatures", "dhw", "vs", "0"], + {"x.com.samsung.da.desired": "45.0"}, + ) + assert write(("bogus", 1), {}) is None def test_dhw_power_and_temperature_declared_as_coverage(): @@ -181,7 +199,7 @@ def test_dhw_power_and_temperature_declared_as_coverage(): their own entities -- declared as no-entity coverage caps so discover() reports no gap, same pattern as the AC's CLIMATE_CONSUMED_HREFS.""" reg, _ = _ehs() - for href in ('/power/dhw/vs/0', '/temperatures/dhw/vs/0'): + for href in ("/power/dhw/vs/0", "/temperatures/dhw/vs/0"): caps = reg.capabilities.get(href) assert caps, href assert all(c.entities == () for c in caps), href @@ -189,11 +207,11 @@ def test_dhw_power_and_temperature_declared_as_coverage(): def test_away_mode_reads_off(): state = _state() - assert state['away_mode'] is False + assert state["away_mode"] is False def test_away_mode_write_contract(): - desc = _desc('away_mode') - path, body = desc.write_fn('On', {}) - assert path == ['option', 'outgoing', 'vs', '0'] - assert body == {'x.com.samsung.da.away': 'On'} + desc = _desc("away_mode") + path, body = desc.write_fn("On", {}) + assert path == ["option", "outgoing", "vs", "0"] + assert body == {"x.com.samsung.da.away": "On"} diff --git a/tests/test_end_to_end_v2.py b/tests/test_end_to_end_v2.py index 119ad01..71e4fd6 100644 --- a/tests/test_end_to_end_v2.py +++ b/tests/test_end_to_end_v2.py @@ -1,20 +1,25 @@ import pytest -from tests.conftest import _load_device + +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 custom_components.localthings.registry.adapter import flatten +from tests.conftest import _load_device -@pytest.mark.parametrize('name,expected_type', [ - ('dishwasher', 'dishwasher'), - ('refrigerator', 'refrigerator'), -]) +@pytest.mark.parametrize( + "name,expected_type", + [ + ("dishwasher", "dishwasher"), + ("refrigerator", "refrigerator"), + ], +) def test_full_pipeline_v2(name, expected_type): resources = _load_device(name) - info = resources['/information/vs/0'] + 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"], ) assert reg is not None assert reg.name == expected_type diff --git a/tests/test_entities.py b/tests/test_entities.py index 8f24bf4..c897015 100644 --- a/tests/test_entities.py +++ b/tests/test_entities.py @@ -1,37 +1,49 @@ from custom_components.localthings.registry.entities import ( - SensorDesc, BinarySensorDesc, SelectDesc, SwitchDesc, ButtonDesc, - NumberDesc, TimeDesc, ClimateDesc, FanDesc, PLATFORM_OF, + PLATFORM_OF, + BinarySensorDesc, + ButtonDesc, + ClimateDesc, + FanDesc, + NumberDesc, + SelectDesc, + SensorDesc, + SwitchDesc, + TimeDesc, ) def test_value_fn_defaults_to_identity(): - d = SensorDesc(key='power', field='x.com.samsung.da.instantaneousPower') + d = SensorDesc(key="power", field="x.com.samsung.da.instantaneousPower") assert d.value_fn(42) == 42 def test_descriptions_are_frozen(): - d = SensorDesc(key='power', field='f') + d = SensorDesc(key="power", field="f") try: - d.key = 'other' + d.key = "other" except Exception as e: - assert 'frozen' in str(type(e)).lower() or 'cannot' in str(e).lower() + assert "frozen" in str(type(e)).lower() or "cannot" in str(e).lower() else: raise AssertionError("expected frozen dataclass") def test_platform_mapping_covers_all_subclasses(): - assert PLATFORM_OF[SensorDesc] == 'sensor' - assert PLATFORM_OF[BinarySensorDesc] == 'binary_sensor' - assert PLATFORM_OF[SelectDesc] == 'select' - assert PLATFORM_OF[SwitchDesc] == 'switch' - assert PLATFORM_OF[ButtonDesc] == 'button' - assert PLATFORM_OF[NumberDesc] == 'number' - assert PLATFORM_OF[TimeDesc] == 'time' - assert PLATFORM_OF[ClimateDesc] == 'climate' - assert PLATFORM_OF[FanDesc] == 'fan' + assert PLATFORM_OF[SensorDesc] == "sensor" + assert PLATFORM_OF[BinarySensorDesc] == "binary_sensor" + assert PLATFORM_OF[SelectDesc] == "select" + assert PLATFORM_OF[SwitchDesc] == "switch" + assert PLATFORM_OF[ButtonDesc] == "button" + assert PLATFORM_OF[NumberDesc] == "number" + assert PLATFORM_OF[TimeDesc] == "time" + assert PLATFORM_OF[ClimateDesc] == "climate" + assert PLATFORM_OF[FanDesc] == "fan" def test_select_carries_options_and_write_fn(): - d = SelectDesc(key='sound', field='mode', options=('voice', 'tone', 'mute'), - write_fn=lambda p, rep: (['settings', 'sound', 'mode', 'vs', '0'], {'mode': p})) - assert d.write_fn('tone', {}) == (['settings', 'sound', 'mode', 'vs', '0'], {'mode': 'tone'}) + d = SelectDesc( + key="sound", + field="mode", + options=("voice", "tone", "mute"), + write_fn=lambda p, rep: (["settings", "sound", "mode", "vs", "0"], {"mode": p}), + ) + assert d.write_fn("tone", {}) == (["settings", "sound", "mode", "vs", "0"], {"mode": "tone"}) diff --git a/tests/test_entity.py b/tests/test_entity.py index 24ee791..b6c22f1 100644 --- a/tests/test_entity.py +++ b/tests/test_entity.py @@ -7,6 +7,7 @@ normal, no-alarm state (fridge._active_alarm_codes), so excluding the entity there would silently drop working sensors on first-poll timing, not just fix phantom ones. """ + from custom_components.localthings.entity import _is_included from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.discovery import BoundEntity @@ -35,21 +36,21 @@ class TestDefaultFieldGate: """No explicit exists_fn -- the field-presence default in entity.py.""" def _bound(self): - return _bound(SensorDesc(key='x', field='x.com.samsung.da.value'), '/x/vs/0') + return _bound(SensorDesc(key="x", field="x.com.samsung.da.value"), "/x/vs/0") def test_included_when_field_present(self): bound = self._bound() - coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.value': '1'}}) + coord = _FakeCoordinator({"/x/vs/0": {"x.com.samsung.da.value": "1"}}) assert _is_included(bound, coord) is True def test_excluded_when_field_absent_from_populated_rep(self): bound = self._bound() - coord = _FakeCoordinator({'/x/vs/0': {'x.com.samsung.da.other': '1'}}) + coord = _FakeCoordinator({"/x/vs/0": {"x.com.samsung.da.other": "1"}}) assert _is_included(bound, coord) is False def test_included_on_true_stub(self): bound = self._bound() - coord = _FakeCoordinator({'/x/vs/0': {'href': '/x/vs/0'}}) + coord = _FakeCoordinator({"/x/vs/0": {"href": "/x/vs/0"}}) assert _is_included(bound, coord) is True def test_included_on_genuinely_empty_rep(self): @@ -59,7 +60,7 @@ class TestDefaultFieldGate: exists_fn (e.g. common.ENERGY_METER) opts into excluding on confirmed-empty, after verifying that's actually safe for its field.""" bound = self._bound() - coord = _FakeCoordinator({'/x/vs/0': {}}) + coord = _FakeCoordinator({"/x/vs/0": {}}) assert _is_included(bound, coord) is True def test_excluded_when_href_missing_from_resources(self): @@ -70,18 +71,21 @@ class TestDefaultFieldGate: class TestExplicitExistsFnGate: def test_explicit_exists_fn_overrides_default_field_gate(self): - desc = SensorDesc(key='x', field='x.com.samsung.da.value', - exists_fn=lambda rep, resources: rep.get('flag') is True) - bound = _bound(desc, '/x/vs/0') - coord = _FakeCoordinator({'/x/vs/0': {'flag': True}}) + desc = SensorDesc( + key="x", + field="x.com.samsung.da.value", + exists_fn=lambda rep, resources: rep.get("flag") is True, + ) + bound = _bound(desc, "/x/vs/0") + coord = _FakeCoordinator({"/x/vs/0": {"flag": True}}) assert _is_included(bound, coord) is True - coord = _FakeCoordinator({'/x/vs/0': {'flag': False, 'x.com.samsung.da.value': '1'}}) + coord = _FakeCoordinator({"/x/vs/0": {"flag": False, "x.com.samsung.da.value": "1"}}) assert _is_included(bound, coord) is False class TestNoFieldEntities: def test_rep_fn_entity_always_included(self): - desc = SensorDesc(key='x', rep_fn=lambda rep: rep.get('x.com.samsung.da.value')) - bound = _bound(desc, '/x/vs/0') - coord = _FakeCoordinator({'/x/vs/0': {}}) + desc = SensorDesc(key="x", rep_fn=lambda rep: rep.get("x.com.samsung.da.value")) + bound = _bound(desc, "/x/vs/0") + coord = _FakeCoordinator({"/x/vs/0": {}}) assert _is_included(bound, coord) is True diff --git a/tests/test_entity_naming.py b/tests/test_entity_naming.py index d204ff4..1153091 100644 --- a/tests/test_entity_naming.py +++ b/tests/test_entity_naming.py @@ -1,4 +1,5 @@ """Tests for translated entity naming and dynamic instance placeholders.""" + from custom_components.localthings.entity import LocalThingsEntity from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.discovery import BoundEntity @@ -6,17 +7,22 @@ from custom_components.localthings.registry.entities import BinarySensorDesc class _FakeCoordinator: - device_serial = 'TEST-SERIAL' + device_serial = "TEST-SERIAL" def __init__(self, last_resources=None): self.last_resources = last_resources or {} -def _make_entity(desc, href='/x/vs/0', key_override=None, instance='', instance_name=None): +def _make_entity(desc, href="/x/vs/0", key_override=None, instance="", instance_name=None): capability = Capability(href=href, entities=(desc,)) - bound = BoundEntity(href=href, capability=capability, desc=desc, - instance=instance, key_override=key_override, - instance_name=instance_name) + bound = BoundEntity( + href=href, + capability=capability, + desc=desc, + instance=instance, + key_override=key_override, + instance_name=instance_name, + ) return LocalThingsEntity(_FakeCoordinator(), bound) @@ -26,26 +32,25 @@ def test_descriptor_key_is_the_default_translation_key(): Nothing sets _attr_name: that would take precedence over HA's translation catalog and make the entity untranslatable. """ - desc = BinarySensorDesc(key='enabled') - entity = _make_entity(desc, instance_name='Cubed Ice') - assert entity.translation_key == 'enabled' - assert not hasattr(entity, '_attr_name') + desc = BinarySensorDesc(key="enabled") + entity = _make_entity(desc, instance_name="Cubed Ice") + assert entity.translation_key == "enabled" + assert not hasattr(entity, "_attr_name") def test_device_instance_name_becomes_translation_placeholder(): desc = BinarySensorDesc( - key='enabled', translation_key='instance_enabled', use_instance_name=True + key="enabled", translation_key="instance_enabled", use_instance_name=True ) - entity = _make_entity(desc, key_override='icemaker_one_enabled', - instance_name='Cubed Ice') - assert entity.translation_key == 'instance_enabled' - assert entity.translation_placeholders == {'instance_name': 'Cubed Ice'} - assert not hasattr(entity, '_attr_name') + entity = _make_entity(desc, key_override="icemaker_one_enabled", instance_name="Cubed Ice") + assert entity.translation_key == "instance_enabled" + assert entity.translation_placeholders == {"instance_name": "Cubed Ice"} + assert not hasattr(entity, "_attr_name") def test_href_instance_name_becomes_translation_placeholder(): desc = BinarySensorDesc( - key='enabled', translation_key='instance_enabled', use_instance_name=True + key="enabled", translation_key="instance_enabled", use_instance_name=True ) - entity = _make_entity(desc, key_override='icemaker_one_enabled') - assert entity.translation_placeholders == {'instance_name': 'Icemaker One'} + entity = _make_entity(desc, key_override="icemaker_one_enabled") + assert entity.translation_placeholders == {"instance_name": "Icemaker One"} diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index e035693..508a128 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -4,12 +4,11 @@ def test_dishwasher_resources_loaded(dishwasher_resources): # batch responses (OCF carries rt at the collection-entry level only). # Verify the fixture contains dict values (including empty reps which are # valid batch entries for resources that temporarily have no state). - non_dicts = [h for h, rep in dishwasher_resources.items() - if not isinstance(rep, dict)] + non_dicts = [h for h, rep in dishwasher_resources.items() if not isinstance(rep, dict)] assert not non_dicts, f"resources with non-dict rep: {non_dicts}" def test_fridge_has_autofill(fridge_resources): - assert '/autofill/vs/0' in fridge_resources + assert "/autofill/vs/0" in fridge_resources # Batch reps do not carry 'rt'; check for the actual autofill field. - assert 'x.com.samsung.da.autofill' in fridge_resources['/autofill/vs/0'] + assert "x.com.samsung.da.autofill" in fridge_resources["/autofill/vs/0"] diff --git a/tests/test_fridge_capabilities.py b/tests/test_fridge_capabilities.py index d8d6d80..830fbaa 100644 --- a/tests/test_fridge_capabilities.py +++ b/tests/test_fridge_capabilities.py @@ -1,4 +1,7 @@ """Tests for fridge-specific capabilities.""" + +from typing import ClassVar + from custom_components.localthings.registry.capabilities import fridge @@ -9,15 +12,15 @@ class TestTempCurrentGeneric: def test_unit_reads_celsius(self): desc = fridge.TEMP_CURRENT_GENERIC.entities[0] - assert desc.unit_fn({'temperature': 3.0, 'units': 'C'}) == '°C' + assert desc.unit_fn({"temperature": 3.0, "units": "C"}) == "°C" def test_unit_reads_fahrenheit(self): desc = fridge.TEMP_CURRENT_GENERIC.entities[0] - assert desc.unit_fn({'temperature': 5.0, 'units': 'F'}) == '°F' + assert desc.unit_fn({"temperature": 5.0, "units": "F"}) == "°F" def test_unit_defaults_to_fahrenheit_when_missing(self): desc = fridge.TEMP_CURRENT_GENERIC.entities[0] - assert desc.unit_fn({'temperature': 5.0}) == '°F' + assert desc.unit_fn({"temperature": 5.0}) == "°F" class TestDoorGeneric: @@ -29,24 +32,24 @@ class TestDoorGeneric: def test_reads_bare_open_state(self): desc = fridge.DOOR_GENERIC.entities[0] - assert desc.rep_fn({'openState': 'Open'}) is True - assert desc.rep_fn({'openState': 'Close'}) is False + assert desc.rep_fn({"openState": "Open"}) is True + assert desc.rep_fn({"openState": "Close"}) is False def test_reads_vendor_prefixed_open_state(self): desc = fridge.DOOR_GENERIC.entities[0] - assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True - assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False + assert desc.rep_fn({"x.com.samsung.da.openState": "Open"}) is True + assert desc.rep_fn({"x.com.samsung.da.openState": "Close"}) is False def test_prefers_bare_field_when_both_present(self): desc = fridge.DOOR_GENERIC.entities[0] - rep = {'openState': 'Open', 'x.com.samsung.da.openState': 'Close'} + rep = {"openState": "Open", "x.com.samsung.da.openState": "Close"} assert desc.rep_fn(rep) is True class TestTempSetpointGeneric: def test_unit_reads_celsius(self): desc = fridge.TEMP_SETPOINT.entities[0] - assert desc.unit_fn({'temperature': -19.0, 'units': 'C'}) == '°C' + assert desc.unit_fn({"temperature": -19.0, "units": "C"}) == "°C" class TestTemperaturesFallback: @@ -54,30 +57,46 @@ class TestTemperaturesFallback: x.com.samsung.da.unit ('Celsius'/'Fahrenheit').""" def test_freezer_unit_celsius(self): - desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities - if e.key == 'freezer_temperature') - rep = {'x.com.samsung.da.items': [ - {'x.com.samsung.da.description': 'Freezer', 'x.com.samsung.da.current': '-19', - 'x.com.samsung.da.unit': 'Celsius'}, - {'x.com.samsung.da.description': 'Fridge', 'x.com.samsung.da.current': '3', - 'x.com.samsung.da.unit': 'Celsius'}, - ]} - assert desc.unit_fn(rep) == '°C' - assert desc.value_fn(rep['x.com.samsung.da.items']) == -19 + desc = next( + e for e in fridge.TEMPERATURES_FALLBACK.entities if e.key == "freezer_temperature" + ) + rep = { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.description": "Freezer", + "x.com.samsung.da.current": "-19", + "x.com.samsung.da.unit": "Celsius", + }, + { + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.current": "3", + "x.com.samsung.da.unit": "Celsius", + }, + ] + } + assert desc.unit_fn(rep) == "°C" + assert desc.value_fn(rep["x.com.samsung.da.items"]) == -19 def test_fridge_unit_fahrenheit(self): - desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities - if e.key == 'fridge_temperature') - rep = {'x.com.samsung.da.items': [ - {'x.com.samsung.da.description': 'Fridge', 'x.com.samsung.da.current': '37', - 'x.com.samsung.da.unit': 'Fahrenheit'}, - ]} - assert desc.unit_fn(rep) == '°F' + desc = next( + e for e in fridge.TEMPERATURES_FALLBACK.entities if e.key == "fridge_temperature" + ) + rep = { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.description": "Fridge", + "x.com.samsung.da.current": "37", + "x.com.samsung.da.unit": "Fahrenheit", + }, + ] + } + assert desc.unit_fn(rep) == "°F" def test_unit_defaults_to_fahrenheit_when_item_missing(self): - desc = next(e for e in fridge.TEMPERATURES_FALLBACK.entities - if e.key == 'freezer_temperature') - assert desc.unit_fn({'x.com.samsung.da.items': []}) == '°F' + desc = next( + e for e in fridge.TEMPERATURES_FALLBACK.entities if e.key == "freezer_temperature" + ) + assert desc.unit_fn({"x.com.samsung.da.items": []}) == "°F" class TestDefrostBlockStatus: @@ -87,12 +106,12 @@ class TestDefrostBlockStatus: def test_key_and_name_reflect_active_defrosting(self): desc = fridge.DEFROST_BLOCK_STATUS.entities[0] - assert desc.key == 'defrost_active' + assert desc.key == "defrost_active" def test_value_fn(self): desc = fridge.DEFROST_BLOCK_STATUS.entities[0] - assert desc.value_fn(['DEFROST_BLOCK_ON']) is True - assert desc.value_fn(['DEFROST_BLOCK_OFF']) is False + assert desc.value_fn(["DEFROST_BLOCK_ON"]) is True + assert desc.value_fn(["DEFROST_BLOCK_OFF"]) is False class TestRefrigerationFallback: @@ -102,29 +121,27 @@ class TestRefrigerationFallback: (defrost) that has no vs-href equivalent.""" def test_href(self): - assert fridge.REFRIGERATION_FALLBACK.href == '/refrigeration/0' + assert fridge.REFRIGERATION_FALLBACK.href == "/refrigeration/0" def test_defrost_active_is_fallback_of_defrost_block_status(self): # Duplicates DEFROST_BLOCK_STATUS's defrost_active # (/defrost/block/vs/0) -- only a true fallback when that richer # href is absent (issue #7's device: /refrigeration/0 only). - desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities - if e.key == 'defrost_active') + desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == "defrost_active") assert desc.value_fn(True) is True assert desc.value_fn(False) is False - assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True - assert desc.exists_fn( - {}, {'/refrigeration/0': {}, '/defrost/block/vs/0': {}}) is False + assert desc.exists_fn({}, {"/refrigeration/0": {}}) is True + assert desc.exists_fn({}, {"/refrigeration/0": {}, "/defrost/block/vs/0": {}}) is False def test_rapid_switches_hidden_when_vs_href_present(self): - for key in ('rapid_fridge', 'rapid_freezing'): + for key in ("rapid_fridge", "rapid_freezing"): desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key) - assert desc.exists_fn({}, {'/refrigeration/vs/0': {}, '/refrigeration/0': {}}) is False + assert desc.exists_fn({}, {"/refrigeration/vs/0": {}, "/refrigeration/0": {}}) is False def test_rapid_switches_shown_when_vs_href_absent(self): - for key in ('rapid_fridge', 'rapid_freezing'): + for key in ("rapid_fridge", "rapid_freezing"): desc = next(e for e in fridge.REFRIGERATION_FALLBACK.entities if e.key == key) - assert desc.exists_fn({}, {'/refrigeration/0': {}}) is True + assert desc.exists_fn({}, {"/refrigeration/0": {}}) is True class TestFlexZone: @@ -136,32 +153,42 @@ class TestFlexZone: def test_reads_rf9000_prefix(self): rep = { - 'x.com.samsung.da.modes': ['CV_TTYPE_RF9000A_BEVERAGE', 'WATERFILTER_DISABLE'], - 'x.com.samsung.da.supportedOptions': [ - 'CV_TTYPE_RF9000A_FREEZE', 'CV_TTYPE_RF9000A_BEVERAGE'], + "x.com.samsung.da.modes": ["CV_TTYPE_RF9000A_BEVERAGE", "WATERFILTER_DISABLE"], + "x.com.samsung.da.supportedOptions": [ + "CV_TTYPE_RF9000A_FREEZE", + "CV_TTYPE_RF9000A_BEVERAGE", + ], } - assert fridge._flex_zone_current(rep) == 'CV_TTYPE_RF9000A_BEVERAGE' + assert fridge._flex_zone_current(rep) == "CV_TTYPE_RF9000A_BEVERAGE" def test_reads_cv_fdr_prefix(self): rep = { - 'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'], - 'x.com.samsung.da.supportedOptions': [ - 'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'], + "x.com.samsung.da.modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_MEAT", "WATERFILTER_ENABLE"], + "x.com.samsung.da.supportedOptions": [ + "CV_FDR_WINE", + "CV_FDR_DELI", + "CV_FDR_BEVERAGE", + "CV_FDR_MEAT", + ], } - assert fridge._flex_zone_current(rep) == 'CV_FDR_MEAT' + assert fridge._flex_zone_current(rep) == "CV_FDR_MEAT" def test_write_replaces_rather_than_duplicates(self): rep = { - 'x.com.samsung.da.modes': ['CVN_CONVERTIBLE_ZONE', 'CV_FDR_MEAT', 'WATERFILTER_ENABLE'], - 'x.com.samsung.da.supportedOptions': [ - 'CV_FDR_WINE', 'CV_FDR_DELI', 'CV_FDR_BEVERAGE', 'CV_FDR_MEAT'], + "x.com.samsung.da.modes": ["CVN_CONVERTIBLE_ZONE", "CV_FDR_MEAT", "WATERFILTER_ENABLE"], + "x.com.samsung.da.supportedOptions": [ + "CV_FDR_WINE", + "CV_FDR_DELI", + "CV_FDR_BEVERAGE", + "CV_FDR_MEAT", + ], } - path, payload = fridge._flex_zone_write('CV_FDR_WINE', rep) - assert path == ['mode', 'vs', '0'] - modes = payload['x.com.samsung.da.modes'] - assert modes.count('CV_FDR_WINE') == 1 - assert 'CV_FDR_MEAT' not in modes - assert 'CVN_CONVERTIBLE_ZONE' in modes and 'WATERFILTER_ENABLE' in modes + path, payload = fridge._flex_zone_write("CV_FDR_WINE", rep) + assert path == ["mode", "vs", "0"] + modes = payload["x.com.samsung.da.modes"] + assert modes.count("CV_FDR_WINE") == 1 + assert "CV_FDR_MEAT" not in modes + assert "CVN_CONVERTIBLE_ZONE" in modes and "WATERFILTER_ENABLE" in modes def test_exists_only_when_a_current_value_resolves(self): """Issue #26's kimchi-refrigerator family also populates /mode/vs/0's @@ -170,16 +197,15 @@ class TestFlexZone: the entity used to bind anyway (supportedOptions is nonempty) and get stuck on "unknown" forever.""" no_overlap_rep = { - 'x.com.samsung.da.modes': ['KIMCHIT_STORAGE_FREEZER_NORMAL'], - 'x.com.samsung.da.supportedOptions': [ - 'KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]'], + "x.com.samsung.da.modes": ["KIMCHIT_STORAGE_FREEZER_NORMAL"], + "x.com.samsung.da.supportedOptions": ["KIMCHIT_STORAGE_FREEZER_NORMAL_[0]:[0]"], } desc = fridge.FLEX_ZONE.entities[0] assert desc.exists_fn(no_overlap_rep, {}) is False overlap_rep = { - 'x.com.samsung.da.modes': ['CV_FDR_MEAT'], - 'x.com.samsung.da.supportedOptions': ['CV_FDR_WINE', 'CV_FDR_MEAT'], + "x.com.samsung.da.modes": ["CV_FDR_MEAT"], + "x.com.samsung.da.supportedOptions": ["CV_FDR_WINE", "CV_FDR_MEAT"], } assert desc.exists_fn(overlap_rep, {}) is True @@ -199,7 +225,7 @@ class TestTp1xNativeDuplicateResources: from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('refrigerator_tp1x_ref_21k_us') + resources = _load_device("refrigerator_tp1x_ref_21k_us") unbound = [] bound = discover( resources, @@ -210,24 +236,24 @@ class TestTp1xNativeDuplicateResources: assert unbound == [] state = flatten(bound, resources) - assert state['defrost_delay'] is False - assert 'ice_maker_enabled' not in state - assert state['icemaker_one_enabled'] is True - assert state['icemaker_two_enabled'] is True - assert state['selfcheck_error'] == 'DA_ERROR_NONE' + assert state["defrost_delay"] is False + assert "ice_maker_enabled" not in state + assert state["icemaker_one_enabled"] is True + assert state["icemaker_two_enabled"] is True + assert state["selfcheck_error"] == "DA_ERROR_NONE" class TestPantryZone: """Cool Select Zone pantry compartment on ARTIK051_REF_17K -- issue #20.""" def test_href(self): - assert fridge.PANTRY_ZONE.href == '/status/pantry/one/vs/0' + assert fridge.PANTRY_ZONE.href == "/status/pantry/one/vs/0" def test_write(self): desc = fridge.PANTRY_ZONE.entities[0] - path, body = desc.write_fn('FDR_WINE', {}) - assert path == ['status', 'pantry', 'one', 'vs', '0'] - assert body == {'x.com.samsung.da.mode': 'FDR_WINE'} + path, body = desc.write_fn("FDR_WINE", {}) + assert path == ["status", "pantry", "one", "vs", "0"] + assert body == {"x.com.samsung.da.mode": "FDR_WINE"} class TestDefiniteTemperatureCooler: @@ -237,13 +263,13 @@ class TestDefiniteTemperatureCooler: control was entirely unbound before this capability was added.""" def test_href(self): - assert fridge.DEFINITE_TEMPERATURE_COOLER.href == '/temperature/definite/cooler/vs/0' + assert fridge.DEFINITE_TEMPERATURE_COOLER.href == "/temperature/definite/cooler/vs/0" def test_write(self): desc = fridge.DEFINITE_TEMPERATURE_COOLER.entities[0] - path, body = desc.write_fn('3', {}) - assert path == ['temperature', 'definite', 'cooler', 'vs', '0'] - assert body == {'x.com.samsung.da.definite.desired': '3'} + path, body = desc.write_fn("3", {}) + assert path == ["temperature", "definite", "cooler", "vs", "0"] + assert body == {"x.com.samsung.da.definite.desired": "3"} def test_no_unbound_hrefs_and_discrete_options(self): from custom_components.localthings.registry.adapter import flatten @@ -251,7 +277,7 @@ class TestDefiniteTemperatureCooler: from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('refrigerator_definite_cooler') + resources = _load_device("refrigerator_definite_cooler") unbound = [] bound = discover( resources, @@ -262,10 +288,10 @@ class TestDefiniteTemperatureCooler: assert unbound == [] state = flatten(bound, resources) - assert state['cooler_temperature_setpoint'] == '2' + assert state["cooler_temperature_setpoint"] == "2" - rep = resources['/temperature/definite/cooler/vs/0'] - assert rep['x.com.samsung.da.definite.supportedList'] == ['1', '2', '3', '4', '7'] + rep = resources["/temperature/definite/cooler/vs/0"] + assert rep["x.com.samsung.da.definite.supportedList"] == ["1", "2", "3", "4", "7"] class TestKimchiZone: @@ -274,21 +300,20 @@ class TestKimchiZone: supportMode resource under /status/kimchi//vs/0.""" def test_href_prefix(self): - assert fridge.KIMCHI_ZONE.href_prefix == '/status/kimchi/' - assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == '/kimchidoors/' + assert fridge.KIMCHI_ZONE.href_prefix == "/status/kimchi/" + assert fridge.KIMCHI_DOOR_GENERIC.href_prefix == "/kimchidoors/" def test_write_derives_path_from_href(self): desc = fridge.KIMCHI_ZONE.entities[0] - rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} - path, body = desc.write_fn( - 'KIMCHI_STORAGE_COLD', rep, href='/status/kimchi/middle/vs/0') - assert path == ['status', 'kimchi', 'middle', 'vs', '0'] - assert body == {'x.com.samsung.da.currentMode': 'KIMCHI_STORAGE_COLD'} + rep = {"x.com.samsung.da.supportMode": ["KIMCHI_STORAGE_COLD"]} + path, body = desc.write_fn("KIMCHI_STORAGE_COLD", rep, href="/status/kimchi/middle/vs/0") + assert path == ["status", "kimchi", "middle", "vs", "0"] + assert body == {"x.com.samsung.da.currentMode": "KIMCHI_STORAGE_COLD"} def test_write_without_href_is_rejected(self): desc = fridge.KIMCHI_ZONE.entities[0] - rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} - assert desc.write_fn('KIMCHI_STORAGE_COLD', rep) is None + rep = {"x.com.samsung.da.supportMode": ["KIMCHI_STORAGE_COLD"]} + assert desc.write_fn("KIMCHI_STORAGE_COLD", rep) is None def test_write_rejects_value_outside_supportmode(self): """A value the compartment never advertised is rejected rather than @@ -296,22 +321,27 @@ class TestKimchiZone: hardware (module docstring above KIMCHI_ZONE), so a bad value here is a food-safety-adjacent outcome, not just a cosmetic one.""" desc = fridge.KIMCHI_ZONE.entities[0] - rep = {'x.com.samsung.da.supportMode': ['KIMCHI_STORAGE_COLD']} - assert desc.write_fn( - 'KIMCHI_STORAGE_WARM', rep, href='/status/kimchi/middle/vs/0', - ) is None + rep = {"x.com.samsung.da.supportMode": ["KIMCHI_STORAGE_COLD"]} + assert ( + desc.write_fn( + "KIMCHI_STORAGE_WARM", + rep, + href="/status/kimchi/middle/vs/0", + ) + is None + ) def test_ripening_status_passes_through_device_value(self): """No device_class='enum' catalog entry exists for this sensor, so lowercasing it would only make the raw device token un-translatable by HA -- pass the device's own casing straight through instead.""" - desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == 'ripening_status') - assert desc.value_fn('Off') == 'Off' + desc = next(e for e in fridge.KIMCHI_ZONE.entities if e.key == "ripening_status") + assert desc.value_fn("Off") == "Off" def test_door_reuses_open_state_helper(self): desc = fridge.KIMCHI_DOOR_GENERIC.entities[0] - assert desc.rep_fn({'x.com.samsung.da.openState': 'Open'}) is True - assert desc.rep_fn({'x.com.samsung.da.openState': 'Close'}) is False + assert desc.rep_fn({"x.com.samsung.da.openState": "Open"}) is True + assert desc.rep_fn({"x.com.samsung.da.openState": "Close"}) is False async def test_zone_mode_select_round_trips_through_display_casing(self): """kimchi_zone_mode's displayed value (lowercase, catalog-translated) @@ -327,18 +357,20 @@ class TestKimchiZone: from custom_components.localthings.select import LocalThingsSelect from tests.conftest import _load_device - resources = _load_device('refrigerator_tp2x_ref_20k_kimchi') + resources = _load_device("refrigerator_tp2x_ref_20k_kimchi") bound = discover( - resources, refrigerator.REGISTRY.capabilities, + resources, + refrigerator.REGISTRY.capabilities, refrigerator.REGISTRY.pattern_capabilities, ) mode_bound = next( - b for b in bound - if isinstance(b.desc, SelectDesc) and b.href == '/status/kimchi/middle/vs/0' + b + for b in bound + if isinstance(b.desc, SelectDesc) and b.href == "/status/kimchi/middle/vs/0" ) class _FakeCoordinator: - device_serial = 'TEST-SERIAL' + device_serial = "TEST-SERIAL" def __init__(self, resources, data): self.last_resources = resources @@ -351,12 +383,12 @@ class TestKimchiZone: coordinator = _FakeCoordinator(resources, flatten(bound, resources)) entity = LocalThingsSelect(coordinator, mode_bound) - assert entity.current_option == 'kimchi_storage_normal' - assert 'kimchi_storage_cold' in entity.options + assert entity.current_option == "kimchi_storage_normal" + assert "kimchi_storage_cold" in entity.options - await entity.async_select_option('kimchi_storage_cold') + await entity.async_select_option("kimchi_storage_cold") - assert coordinator.commands == ['KIMCHI_STORAGE_COLD'] + assert coordinator.commands == ["KIMCHI_STORAGE_COLD"] class TestArtik051AndTp2xFixturesHaveCompleteCoverage: @@ -372,7 +404,7 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('refrigerator_artik051_ref_17k') + resources = _load_device("refrigerator_artik051_ref_17k") unbound = [] bound = discover( resources, @@ -382,7 +414,7 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: ) assert unbound == [] state = flatten(bound, resources) - assert state['pantry_zone_mode'] == 'FDR_DRINKS' + assert state["pantry_zone_mode"] == "FDR_DRINKS" def test_tp2x_ref_20k(self): from custom_components.localthings.registry.adapter import flatten @@ -390,7 +422,7 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('refrigerator_tp2x_ref_20k') + resources = _load_device("refrigerator_tp2x_ref_20k") unbound = [] bound = discover( resources, @@ -400,7 +432,7 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: ) assert unbound == [] state = flatten(bound, resources) - assert state['flex_zone_mode'] == 'CV_FDR_BEVERAGE' + assert state["flex_zone_mode"] == "CV_FDR_BEVERAGE" def test_tp2x_ref_20k_kimchi(self): """A different physical unit sharing the same modelNum string (issue @@ -412,7 +444,7 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('refrigerator_tp2x_ref_20k_kimchi') + resources = _load_device("refrigerator_tp2x_ref_20k_kimchi") unbound = [] bound = discover( resources, @@ -422,12 +454,12 @@ class TestArtik051AndTp2xFixturesHaveCompleteCoverage: ) assert unbound == [] state = flatten(bound, resources) - assert state['top_mode'] == 'STORAGE_FREEZER_NORMAL' - assert state['middle_mode'] == 'KIMCHI_STORAGE_NORMAL' - assert state['bottom_mode'] == 'KIMCHI_STORAGE_NORMAL' - assert state['top_open'] is False - assert 'middle_open' not in state # no /kimchidoors/middle/vs/0 reported - assert 'flex_zone_mode' not in state # no resolvable overlap on this family + assert state["top_mode"] == "STORAGE_FREEZER_NORMAL" + assert state["middle_mode"] == "KIMCHI_STORAGE_NORMAL" + assert state["bottom_mode"] == "KIMCHI_STORAGE_NORMAL" + assert state["top_open"] is False + assert "middle_open" not in state # no /kimchidoors/middle/vs/0 reported + assert "flex_zone_mode" not in state # no resolvable overlap on this family class TestArtik051DongleRefFixtureCoverage: @@ -440,27 +472,31 @@ class TestArtik051DongleRefFixtureCoverage: def test_no_unbound_hrefs_and_expected_entities(self): from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import ( - for_device_by_model, refrigerator, + for_device_by_model, + refrigerator, ) from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('refrigerator_artik051_dongle_ref') - info = resources['/information/vs/0'] + resources = _load_device("refrigerator_artik051_dongle_ref") + info = resources["/information/vs/0"] reg = for_device_by_model( - info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) - assert reg is not None and reg.name == 'refrigerator' + info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"] + ) + assert reg is not None and reg.name == "refrigerator" unbound = [] bound = discover( - resources, refrigerator.REGISTRY.capabilities, - refrigerator.REGISTRY.pattern_capabilities, log=unbound.append, + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, ) assert unbound == [] state = flatten(bound, resources) - assert state['door_onedoorfreezer_open'] is False # rep reports 'Close' - assert state['freezer_temperature'] == -20.0 - assert state['freezer_setpoint'] == -20.0 + assert state["door_onedoorfreezer_open"] is False # rep reports 'Close' + assert state["freezer_temperature"] == -20.0 + assert state["freezer_setpoint"] == -20.0 class TestArtik051DongleRefCoolerFixtureCoverage: @@ -475,28 +511,32 @@ class TestArtik051DongleRefCoolerFixtureCoverage: def test_no_unbound_hrefs_and_expected_entities(self): from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import ( - for_device_by_model, refrigerator, + for_device_by_model, + refrigerator, ) from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('refrigerator_artik051_dongle_ref_cooler') - info = resources['/information/vs/0'] + resources = _load_device("refrigerator_artik051_dongle_ref_cooler") + info = resources["/information/vs/0"] reg = for_device_by_model( - info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) - assert reg is not None and reg.name == 'refrigerator' + info["x.com.samsung.da.modelNum"], info["x.com.samsung.da.description"] + ) + assert reg is not None and reg.name == "refrigerator" unbound = [] bound = discover( - resources, refrigerator.REGISTRY.capabilities, - refrigerator.REGISTRY.pattern_capabilities, log=unbound.append, + resources, + refrigerator.REGISTRY.capabilities, + refrigerator.REGISTRY.pattern_capabilities, + log=unbound.append, ) assert unbound == [] state = flatten(bound, resources) - assert state['door_cooler_open'] is False - assert state['door_onedoorfreezer_open'] is False - assert state['cooler_temperature'] == 3.0 - assert state['cooler_setpoint'] == 3.0 + assert state["door_cooler_open"] is False + assert state["door_onedoorfreezer_open"] is False + assert state["cooler_temperature"] == 3.0 + assert state["cooler_setpoint"] == 3.0 class TestRefrigeratorAiEnergyLevelFixtureCoverage: @@ -515,7 +555,7 @@ class TestRefrigeratorAiEnergyLevelFixtureCoverage: from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('refrigerator') + resources = _load_device("refrigerator") unbound = [] bound = discover( resources, @@ -525,7 +565,7 @@ class TestRefrigeratorAiEnergyLevelFixtureCoverage: ) assert unbound == [] state = flatten(bound, resources) - assert state['ai_energy_level'] == '1' + assert state["ai_energy_level"] == "1" class TestSwitchOffIsNotInverted: @@ -539,26 +579,24 @@ class TestSwitchOffIsNotInverted: """ # (switch descriptor, payload key it writes) - CASES = [ - (fridge.ICEMAKER_NIGHTTIME.entities[0], 'ice.night.status'), - (fridge.STATUS_LOCK.entities[0], 'x.com.samsung.da.ado.devicecontrol'), - (fridge.STATUS_LOCK.entities[1], 'x.com.samsung.da.device.sound'), - (fridge.DEFROST_DELAY.entities[0], 'x.com.samsung.da.delayDefrost'), - (fridge.WELCOME_LIGHTING.entities[0], 'status'), - (fridge.CABINET_LIGHT.entities[1], 'light.dimming.status'), - (fridge.ICEMAKER_STATUS_FALLBACK.entities[0], 'x.com.samsung.da.iceMaker'), + CASES: ClassVar[list] = [ + (fridge.ICEMAKER_NIGHTTIME.entities[0], "ice.night.status"), + (fridge.STATUS_LOCK.entities[0], "x.com.samsung.da.ado.devicecontrol"), + (fridge.STATUS_LOCK.entities[1], "x.com.samsung.da.device.sound"), + (fridge.DEFROST_DELAY.entities[0], "x.com.samsung.da.delayDefrost"), + (fridge.WELCOME_LIGHTING.entities[0], "status"), + (fridge.CABINET_LIGHT.entities[1], "light.dimming.status"), + (fridge.ICEMAKER_STATUS_FALLBACK.entities[0], "x.com.samsung.da.iceMaker"), ] def test_turning_off_sends_off(self): for desc, key in self.CASES: - _segs, payload = desc.write_fn('Off', {}) - assert payload[key] == 'Off', ( + _segs, payload = desc.write_fn("Off", {}) + assert payload[key] == "Off", ( f"{desc.key}: OFF must send 'Off', got {payload[key]!r} (inverted)" ) def test_turning_on_sends_on(self): for desc, key in self.CASES: - _segs, payload = desc.write_fn('On', {}) - assert payload[key] == 'On', ( - f"{desc.key}: ON must send 'On', got {payload[key]!r}" - ) + _segs, payload = desc.write_fn("On", {}) + assert payload[key] == "On", f"{desc.key}: ON must send 'On', got {payload[key]!r}" diff --git a/tests/test_fridge_multi_instance.py b/tests/test_fridge_multi_instance.py index 51fe70e..9b2c960 100644 --- a/tests/test_fridge_multi_instance.py +++ b/tests/test_fridge_multi_instance.py @@ -5,31 +5,32 @@ ice-maker resources (/icemaker/one/vs/0, /icemaker/two/vs/0) use named path segments rather than numeric indices, so they are modelled via DOOR_GENERIC pattern capability that auto-derives distinct keys from the href segments. """ -from tests.conftest import _load_resources from custom_components.localthings.registry.adapter import _key from custom_components.localthings.registry.by_type import refrigerator from custom_components.localthings.registry.discovery import discover +from tests.conftest import _load_resources def test_two_doors_get_distinct_keys(): - 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) - door_keys = sorted(_key(b) for b in bound if _key(b).startswith('door')) + door_keys = sorted(_key(b) for b in bound if _key(b).startswith("door")) # at least the two physical doors (cooler + freezer) produce distinct keys assert len(set(door_keys)) >= 2, f"Expected >= 2 distinct door keys, got: {door_keys}" -def test_two_iceMakers_get_distinct_keys(): - resources = _load_resources('10.0.0.254') +def test_two_ice_makers_get_distinct_keys(): + resources = _load_resources("10.0.0.254") reg = refrigerator.REGISTRY bound = discover(resources, reg.capabilities, reg.pattern_capabilities) - ice_keys = sorted(_key(b) for b in bound if _key(b).startswith('icemaker_')) + ice_keys = sorted(_key(b) for b in bound if _key(b).startswith("icemaker_")) # icemaker_one_* and icemaker_two_* are distinct - ice_one_keys = [k for k in ice_keys if k.startswith('icemaker_one_')] - ice_two_keys = [k for k in ice_keys if k.startswith('icemaker_two_')] + ice_one_keys = [k for k in ice_keys if k.startswith("icemaker_one_")] + ice_two_keys = [k for k in ice_keys if k.startswith("icemaker_two_")] assert ice_one_keys, "No icemaker_one_* keys found" assert ice_two_keys, "No icemaker_two_* keys found" - assert set(ice_one_keys).isdisjoint(set(ice_two_keys)), \ + assert set(ice_one_keys).isdisjoint(set(ice_two_keys)), ( f"icemaker_one and icemaker_two keys overlap: {ice_one_keys} vs {ice_two_keys}" + ) diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index a05a74b..b88f5a0 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -3,16 +3,18 @@ from pathlib import Path import pytest -GOLDEN = Path(__file__).parent / 'fixtures' / 'golden' +GOLDEN = Path(__file__).parent / "fixtures" / "golden" def _new_state_keys(name, resources): + from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import resolve from custom_components.localthings.registry.discovery import discover - from custom_components.localthings.registry.adapter import flatten + reg = resolve(resources) if reg is None: from custom_components.localthings.registry.registry import CAPABILITIES + caps, pats = CAPABILITIES, [] else: caps, pats = reg.capabilities, reg.pattern_capabilities @@ -21,16 +23,20 @@ def _new_state_keys(name, resources): return sorted(state.keys()) -@pytest.mark.parametrize('name,ip', [ - ('dishwasher', '10.0.0.129'), - ('refrigerator', '10.0.0.254'), -]) +@pytest.mark.parametrize( + "name,ip", + [ + ("dishwasher", "10.0.0.129"), + ("refrigerator", "10.0.0.254"), + ], +) def test_registry_reproduces_golden_state_keys(name, ip, request): from tests.conftest import _load_resources + resources = _load_resources(ip) - golden = json.loads((GOLDEN / f'{name}.json').read_text()) + golden = json.loads((GOLDEN / f"{name}.json").read_text()) state_keys = _new_state_keys(name, resources) - assert set(state_keys) == set(golden['state_keys']), ( + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -39,10 +45,11 @@ def test_registry_reproduces_golden_state_keys(name, ip, request): def test_registry_reproduces_golden_state_keys_for_washer(): from tests.conftest import _load_device - resources = _load_device('washer') - golden = json.loads((GOLDEN / 'washer.json').read_text()) - state_keys = _new_state_keys('washer', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("washer") + golden = json.loads((GOLDEN / "washer.json").read_text()) + state_keys = _new_state_keys("washer", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -51,10 +58,11 @@ def test_registry_reproduces_golden_state_keys_for_washer(): def test_registry_reproduces_golden_state_keys_for_ehs(): from tests.conftest import _load_device - resources = _load_device('ehs') - golden = json.loads((GOLDEN / 'ehs.json').read_text()) - state_keys = _new_state_keys('ehs', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("ehs") + golden = json.loads((GOLDEN / "ehs.json").read_text()) + state_keys = _new_state_keys("ehs", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -66,10 +74,11 @@ def test_registry_reproduces_golden_state_keys_for_washer_wa8000t(): used the 'WA' consumer-model prefix, previously unmapped in _CONSUMER_PREFIX_TO_KEY -- fell back to 'unknown'.""" from tests.conftest import _load_device - resources = _load_device('washer_wa8000t') - golden = json.loads((GOLDEN / 'washer_wa8000t.json').read_text()) - state_keys = _new_state_keys('washer_wa8000t', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("washer_wa8000t") + golden = json.loads((GOLDEN / "washer_wa8000t.json").read_text()) + state_keys = _new_state_keys("washer_wa8000t", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -78,10 +87,11 @@ def test_registry_reproduces_golden_state_keys_for_washer_wa8000t(): def test_registry_reproduces_golden_state_keys_for_dryer(): from tests.conftest import _load_device - resources = _load_device('dryer') - golden = json.loads((GOLDEN / 'dryer.json').read_text()) - state_keys = _new_state_keys('dryer', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("dryer") + golden = json.loads((GOLDEN / "dryer.json").read_text()) + state_keys = _new_state_keys("dryer", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -95,10 +105,11 @@ def test_registry_reproduces_golden_state_keys_for_dryer_dve50a8600(): last-segment-only check missed it and fell back to 'unknown'; resolved via _consumer_model_key scanning segments from the end.""" from tests.conftest import _load_device - resources = _load_device('dryer_dve50a8600') - golden = json.loads((GOLDEN / 'dryer_dve50a8600.json').read_text()) - state_keys = _new_state_keys('dryer_dve50a8600', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("dryer_dve50a8600") + golden = json.loads((GOLDEN / "dryer_dve50a8600.json").read_text()) + state_keys = _new_state_keys("dryer_dve50a8600", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -107,10 +118,11 @@ def test_registry_reproduces_golden_state_keys_for_dryer_dve50a8600(): def test_registry_reproduces_golden_state_keys_for_airconditioner(): from tests.conftest import _load_device - resources = _load_device('airconditioner') - golden = json.loads((GOLDEN / 'airconditioner.json').read_text()) - state_keys = _new_state_keys('airconditioner', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner") + golden = json.loads((GOLDEN / "airconditioner.json").read_text()) + state_keys = _new_state_keys("airconditioner", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -124,10 +136,11 @@ def test_registry_reproduces_golden_state_keys_for_dehumidifier(): dedicated dehumidifier registry (target humidity, operating mode, reused AC filter/auto-clean/mute-once capabilities).""" from tests.conftest import _load_device - resources = _load_device('dehumidifier') - golden = json.loads((GOLDEN / 'dehumidifier.json').read_text()) - state_keys = _new_state_keys('dehumidifier', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("dehumidifier") + golden = json.loads((GOLDEN / "dehumidifier.json").read_text()) + state_keys = _new_state_keys("dehumidifier", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -140,10 +153,11 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier(): water_purifier registry (dispense settings, sterilize/filter status, favorite capacity, and the three lock switches).""" from tests.conftest import _load_device - resources = _load_device('water_purifier') - golden = json.loads((GOLDEN / 'water_purifier.json').read_text()) - state_keys = _new_state_keys('water_purifier', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("water_purifier") + golden = json.loads((GOLDEN / "water_purifier.json").read_text()) + state_keys = _new_state_keys("water_purifier", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -159,10 +173,11 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier_ailite_25k(): this device type, and a hot_water_temperature select that must gate off (no supportedHotTemperatures reported) rather than surface as 'unknown'.""" from tests.conftest import _load_device - resources = _load_device('water_purifier_ailite_25k') - golden = json.loads((GOLDEN / 'water_purifier_ailite_25k.json').read_text()) - state_keys = _new_state_keys('water_purifier_ailite_25k', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("water_purifier_ailite_25k") + golden = json.loads((GOLDEN / "water_purifier_ailite_25k.json").read_text()) + state_keys = _new_state_keys("water_purifier_ailite_25k", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -182,10 +197,11 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier_coffee(): bug all along; the registry-level golden just had no way to show it since flatten()'s state dict doesn't model select option membership.""" from tests.conftest import _load_device - resources = _load_device('water_purifier_coffee') - golden = json.loads((GOLDEN / 'water_purifier_coffee.json').read_text()) - state_keys = _new_state_keys('water_purifier_coffee', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("water_purifier_coffee") + golden = json.loads((GOLDEN / "water_purifier_coffee.json").read_text()) + state_keys = _new_state_keys("water_purifier_coffee", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -194,10 +210,11 @@ def test_registry_reproduces_golden_state_keys_for_water_purifier_coffee(): def test_registry_reproduces_golden_state_keys_for_cooktop(): from tests.conftest import _load_device - resources = _load_device('cooktop') - golden = json.loads((GOLDEN / 'cooktop.json').read_text()) - state_keys = _new_state_keys('cooktop', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("cooktop") + golden = json.loads((GOLDEN / "cooktop.json").read_text()) + state_keys = _new_state_keys("cooktop", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -206,12 +223,11 @@ def test_registry_reproduces_golden_state_keys_for_cooktop(): def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_us(): from tests.conftest import _load_device - resources = _load_device('refrigerator_tp1x_ref_21k_us') - golden = json.loads( - (GOLDEN / 'refrigerator_tp1x_ref_21k_us.json').read_text() - ) - state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_us', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("refrigerator_tp1x_ref_21k_us") + golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_us.json").read_text()) + state_keys = _new_state_keys("refrigerator_tp1x_ref_21k_us", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -227,12 +243,11 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_eu(): covered by fridge.DOOR_GENERIC/DOORS_FALLBACK -- this href was the only gap keeping the coverage repair open.""" from tests.conftest import _load_device - resources = _load_device('refrigerator_tp1x_ref_21k_eu') - golden = json.loads( - (GOLDEN / 'refrigerator_tp1x_ref_21k_eu.json').read_text() - ) - state_keys = _new_state_keys('refrigerator_tp1x_ref_21k_eu', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("refrigerator_tp1x_ref_21k_eu") + golden = json.loads((GOLDEN / "refrigerator_tp1x_ref_21k_eu.json").read_text()) + state_keys = _new_state_keys("refrigerator_tp1x_ref_21k_eu", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -241,10 +256,11 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_ref_21k_eu(): def test_registry_reproduces_golden_state_keys_for_range_hood(): from tests.conftest import _load_device - resources = _load_device('range_hood') - golden = json.loads((GOLDEN / 'range_hood.json').read_text()) - state_keys = _new_state_keys('range_hood', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("range_hood") + golden = json.loads((GOLDEN / "range_hood.json").read_text()) + state_keys = _new_state_keys("range_hood", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -256,10 +272,11 @@ def test_registry_reproduces_golden_state_keys_for_washer_flexwash(): report no oneUiVersion and previously fell through for_device_by_model's consumer-prefix map entirely -- issue #19.""" from tests.conftest import _load_device - resources = _load_device('washer_flexwash') - golden = json.loads((GOLDEN / 'washer_flexwash.json').read_text()) - state_keys = _new_state_keys('washer_flexwash', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("washer_flexwash") + golden = json.loads((GOLDEN / "washer_flexwash.json").read_text()) + state_keys = _new_state_keys("washer_flexwash", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -270,10 +287,11 @@ def test_registry_reproduces_golden_state_keys_for_washer_dryer_combo(): """Washer/dryer combo units carry a writable dryLevel field on /washer/vs/0 itself, with no separate dryer resource -- issue #22.""" from tests.conftest import _load_device - resources = _load_device('washer_dryer_combo') - golden = json.loads((GOLDEN / 'washer_dryer_combo.json').read_text()) - state_keys = _new_state_keys('washer_dryer_combo', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("washer_dryer_combo") + golden = json.loads((GOLDEN / "washer_dryer_combo.json").read_text()) + state_keys = _new_state_keys("washer_dryer_combo", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -284,10 +302,11 @@ def test_registry_reproduces_golden_state_keys_for_artik051_ref_17k(): """ARTIK051_REF_17K's Cool Select Zone pantry compartment (/status/pantry/one/vs/0) -- issue #20.""" from tests.conftest import _load_device - resources = _load_device('refrigerator_artik051_ref_17k') - golden = json.loads((GOLDEN / 'refrigerator_artik051_ref_17k.json').read_text()) - state_keys = _new_state_keys('refrigerator_artik051_ref_17k', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("refrigerator_artik051_ref_17k") + golden = json.loads((GOLDEN / "refrigerator_artik051_ref_17k.json").read_text()) + state_keys = _new_state_keys("refrigerator_artik051_ref_17k", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -302,10 +321,11 @@ def test_registry_reproduces_golden_state_keys_for_artik051_dongle_ref_cooler(): /temperature/{current,desired}/cooler/0. Same pipe-delimited modelNum detection gap and DOOR_GENERIC field-name gap as #77, same fix.""" from tests.conftest import _load_device - resources = _load_device('refrigerator_artik051_dongle_ref_cooler') - golden = json.loads((GOLDEN / 'refrigerator_artik051_dongle_ref_cooler.json').read_text()) - state_keys = _new_state_keys('refrigerator_artik051_dongle_ref_cooler', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("refrigerator_artik051_dongle_ref_cooler") + golden = json.loads((GOLDEN / "refrigerator_artik051_dongle_ref_cooler.json").read_text()) + state_keys = _new_state_keys("refrigerator_artik051_dongle_ref_cooler", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -323,10 +343,11 @@ def test_registry_reproduces_golden_state_keys_for_artik051_dongle_ref(): tries -- so this also regression-tests that those resources bind at all once routed to the right registry.""" from tests.conftest import _load_device - resources = _load_device('refrigerator_artik051_dongle_ref') - golden = json.loads((GOLDEN / 'refrigerator_artik051_dongle_ref.json').read_text()) - state_keys = _new_state_keys('refrigerator_artik051_dongle_ref', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("refrigerator_artik051_dongle_ref") + golden = json.loads((GOLDEN / "refrigerator_artik051_dongle_ref.json").read_text()) + state_keys = _new_state_keys("refrigerator_artik051_dongle_ref", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -338,10 +359,11 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k(): energy fields (cumulativeConsumption/monthlyConsumption/ thismonthlyConsumption) surfaced by issue #26.""" from tests.conftest import _load_device - resources = _load_device('refrigerator_tp2x_ref_20k') - golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k.json').read_text()) - state_keys = _new_state_keys('refrigerator_tp2x_ref_20k', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("refrigerator_tp2x_ref_20k") + golden = json.loads((GOLDEN / "refrigerator_tp2x_ref_20k.json").read_text()) + state_keys = _new_state_keys("refrigerator_tp2x_ref_20k", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -355,10 +377,11 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_ref_20k_kimchi(): freezer/cooler split, but its own /status/kimchi//vs/0 and /kimchidoors/top/vs/0 resources (fridge.KIMCHI_ZONE/KIMCHI_DOOR_GENERIC).""" from tests.conftest import _load_device - resources = _load_device('refrigerator_tp2x_ref_20k_kimchi') - golden = json.loads((GOLDEN / 'refrigerator_tp2x_ref_20k_kimchi.json').read_text()) - state_keys = _new_state_keys('refrigerator_tp2x_ref_20k_kimchi', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("refrigerator_tp2x_ref_20k_kimchi") + golden = json.loads((GOLDEN / "refrigerator_tp2x_ref_20k_kimchi.json").read_text()) + state_keys = _new_state_keys("refrigerator_tp2x_ref_20k_kimchi", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -371,12 +394,11 @@ def test_registry_reproduces_golden_state_keys_for_ac_tp1x_da_ac_rac_01011(): items[] resource and adds a /light/vs/0 display light, with extra vendor housekeeping hrefs -- issue #17 for this model class (PR #36).""" from tests.conftest import _load_device - resources = _load_device('airconditioner_tp1x_da_ac_rac_01011') - golden = json.loads( - (GOLDEN / 'airconditioner_tp1x_da_ac_rac_01011.json').read_text() - ) - state_keys = _new_state_keys('airconditioner_tp1x_da_ac_rac_01011', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_tp1x_da_ac_rac_01011") + golden = json.loads((GOLDEN / "airconditioner_tp1x_da_ac_rac_01011.json").read_text()) + state_keys = _new_state_keys("airconditioner_tp1x_da_ac_rac_01011", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -387,10 +409,11 @@ def test_registry_reproduces_golden_state_keys_for_tp2x_rac_20k(): """TP2X_RAC_20K (issue #37) -- reports no oneUiVersion; resolved via the '_RAC_' modelNum token fallback in for_device_by_model.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_tp2x_rac_20k') - golden = json.loads((GOLDEN / 'airconditioner_tp2x_rac_20k.json').read_text()) - state_keys = _new_state_keys('airconditioner_tp2x_rac_20k', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_tp2x_rac_20k") + golden = json.loads((GOLDEN / "airconditioner_tp2x_rac_20k.json").read_text()) + state_keys = _new_state_keys("airconditioner_tp2x_rac_20k", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -405,10 +428,11 @@ def test_registry_reproduces_golden_state_keys_for_caww_tp2(): airconditioner registry -- same TP1X/TP2X-class resource surface, plus one SAC-only installation-topology resource (ignored).""" from tests.conftest import _load_device - resources = _load_device('airconditioner_caww_tp2') - golden = json.loads((GOLDEN / 'airconditioner_caww_tp2.json').read_text()) - state_keys = _new_state_keys('airconditioner_caww_tp2', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_caww_tp2") + golden = json.loads((GOLDEN / "airconditioner_caww_tp2.json").read_text()) + state_keys = _new_state_keys("airconditioner_caww_tp2", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -422,10 +446,11 @@ def test_registry_reproduces_golden_state_keys_for_window_ac(): fallback in for_device_by_model. Otherwise binds cleanly against the existing airconditioner registry with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_window_ac') - golden = json.loads((GOLDEN / 'airconditioner_window_ac.json').read_text()) - state_keys = _new_state_keys('airconditioner_window_ac', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_window_ac") + golden = json.loads((GOLDEN / "airconditioner_window_ac.json").read_text()) + state_keys = _new_state_keys("airconditioner_window_ac", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -436,10 +461,11 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac(): """TP1X_DA-AC-RAC-01001_0000 (issue #38) -- fuller RAC board with display light, self-check, mute-once, and a current-limit setting.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_tp1x_rac') - golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac.json').read_text()) - state_keys = _new_state_keys('airconditioner_tp1x_rac', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_tp1x_rac") + golden = json.loads((GOLDEN / "airconditioner_tp1x_rac.json").read_text()) + state_keys = _new_state_keys("airconditioner_tp1x_rac", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -451,10 +477,11 @@ def test_registry_reproduces_golden_state_keys_for_tp1x_rac_coolonly(): /otninformation/vs/0 ships no swVersionInfo block -- resolves via the 'RAC' board token in its modelNum.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_tp1x_rac_coolonly') - golden = json.loads((GOLDEN / 'airconditioner_tp1x_rac_coolonly.json').read_text()) - state_keys = _new_state_keys('airconditioner_tp1x_rac_coolonly', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_tp1x_rac_coolonly") + golden = json.loads((GOLDEN / "airconditioner_tp1x_rac_coolonly.json").read_text()) + state_keys = _new_state_keys("airconditioner_tp1x_rac_coolonly", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -468,10 +495,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree(): /wind/direction/vs/0 reports Left_And_Right, and /humidity/vs/0's fivepercentHumidity is actually populated.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_windfree') - golden = json.loads((GOLDEN / 'airconditioner_windfree.json').read_text()) - state_keys = _new_state_keys('airconditioner_windfree', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_windfree") + golden = json.loads((GOLDEN / "airconditioner_windfree.json").read_text()) + state_keys = _new_state_keys("airconditioner_windfree", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -487,12 +515,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_lnx_rac_heatpu (avoid-direct-wind-on-motion), both exposed read-only per the 'don't guess' rule.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_lnx_rac_heatpump') - golden = json.loads( - (GOLDEN / 'airconditioner_lnx_rac_heatpump.json').read_text() - ) - state_keys = _new_state_keys('airconditioner_lnx_rac_heatpump', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_lnx_rac_heatpump") + golden = json.loads((GOLDEN / "airconditioner_lnx_rac_heatpump.json").read_text()) + state_keys = _new_state_keys("airconditioner_lnx_rac_heatpump", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -506,10 +533,11 @@ def test_registry_reproduces_golden_state_keys_for_range(): setpoint/mode/operational-state capabilities and adds range.py's per-burner capabilities for the 4 burners this dump reports.""" from tests.conftest import _load_device - resources = _load_device('range') - golden = json.loads((GOLDEN / 'range.json').read_text()) - state_keys = _new_state_keys('range', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("range") + golden = json.loads((GOLDEN / "range.json").read_text()) + state_keys = _new_state_keys("range", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -523,10 +551,11 @@ def test_registry_reproduces_golden_state_keys_for_induction_cooktop(): for_device_by_model into its own 'induction_cooktop' registry (not cooktop.REGISTRY, which is the unrelated NA9300K gas-cooktop family).""" from tests.conftest import _load_device - resources = _load_device('induction_cooktop') - golden = json.loads((GOLDEN / 'induction_cooktop.json').read_text()) - state_keys = _new_state_keys('induction_cooktop', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("induction_cooktop") + golden = json.loads((GOLDEN / "induction_cooktop.json").read_text()) + state_keys = _new_state_keys("induction_cooktop", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -543,10 +572,11 @@ def test_registry_reproduces_golden_state_keys_for_range_no_info(): /cooktopmonitoring/vs/0 monitoring resource covered by range.py's COOKTOP_MONITORING.""" from tests.conftest import _load_device - resources = _load_device('range_no_info') - golden = json.loads((GOLDEN / 'range_no_info.json').read_text()) - state_keys = _new_state_keys('range_no_info', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("range_no_info") + golden = json.loads((GOLDEN / "range_no_info.json").read_text()) + state_keys = _new_state_keys("range_no_info", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -557,10 +587,11 @@ def test_registry_reproduces_golden_state_keys_for_air_purifier(): """ARTIK051_TVTL_18K (issue #56) -- reports no oneUiVersion; resolved via the '_TVTL_' modelNum token fallback in for_device_by_model.""" from tests.conftest import _load_device - resources = _load_device('air_purifier') - golden = json.loads((GOLDEN / 'air_purifier.json').read_text()) - state_keys = _new_state_keys('air_purifier', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("air_purifier") + golden = json.loads((GOLDEN / "air_purifier.json").read_text()) + state_keys = _new_state_keys("air_purifier", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -575,10 +606,11 @@ def test_registry_reproduces_golden_state_keys_for_oven(): 'unknown' and every href fell through to the global CAPABILITIES registry instead of the oven family's own.""" from tests.conftest import _load_device - resources = _load_device('oven') - golden = json.loads((GOLDEN / 'oven.json').read_text()) - state_keys = _new_state_keys('oven', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("oven") + golden = json.loads((GOLDEN / "oven.json").read_text()) + state_keys = _new_state_keys("oven", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -592,10 +624,11 @@ def test_registry_reproduces_golden_state_keys_for_washer_wa55a7700av(): #106). Binds cleanly against the existing washer registry with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('washer_wa55a7700av') - golden = json.loads((GOLDEN / 'washer_wa55a7700av.json').read_text()) - state_keys = _new_state_keys('washer_wa55a7700av', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("washer_wa55a7700av") + golden = json.loads((GOLDEN / "washer_wa55a7700av.json").read_text()) + state_keys = _new_state_keys("washer_wa55a7700av", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -609,10 +642,11 @@ def test_registry_reproduces_golden_state_keys_for_range_ne8300d(): /cooktopmonitoring/vs/0 via range.COOKTOP_MONITORING, with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('range_ne8300d') - golden = json.loads((GOLDEN / 'range_ne8300d.json').read_text()) - state_keys = _new_state_keys('range_ne8300d', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("range_ne8300d") + golden = json.loads((GOLDEN / "range_ne8300d.json").read_text()) + state_keys = _new_state_keys("range_ne8300d", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -626,10 +660,11 @@ def test_registry_reproduces_golden_state_keys_for_range_ne63a6511(): for_device_by_resources. Binds cleanly against the existing range registry with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('range_ne63a6511') - golden = json.loads((GOLDEN / 'range_ne63a6511.json').read_text()) - state_keys = _new_state_keys('range_ne63a6511', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("range_ne63a6511") + golden = json.loads((GOLDEN / "range_ne63a6511.json").read_text()) + state_keys = _new_state_keys("range_ne63a6511", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -644,10 +679,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_ara_ww_tp1_22( against the existing airconditioner registry with zero unbound hrefs, so no new device type was needed.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_ara_ww_tp1_22') - golden = json.loads((GOLDEN / 'airconditioner_ara_ww_tp1_22.json').read_text()) - state_keys = _new_state_keys('airconditioner_ara_ww_tp1_22', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_ara_ww_tp1_22") + golden = json.loads((GOLDEN / "airconditioner_ara_ww_tp1_22.json").read_text()) + state_keys = _new_state_keys("airconditioner_ara_ww_tp1_22", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -665,12 +701,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_windfree_oscil ANOMALY_LOAD, read-only per the 'don't guess' rule). Binds cleanly with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_windfree_oscillation') - golden = json.loads( - (GOLDEN / 'airconditioner_windfree_oscillation.json').read_text() - ) - state_keys = _new_state_keys('airconditioner_windfree_oscillation', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_windfree_oscillation") + golden = json.loads((GOLDEN / "airconditioner_windfree_oscillation.json").read_text()) + state_keys = _new_state_keys("airconditioner_windfree_oscillation", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -688,10 +723,11 @@ def test_registry_reproduces_golden_state_keys_for_microwave_mw7300b(): into the oven registry (issue #121); split into its own device type per user feedback that microwaves shouldn't show up as ovens.""" from tests.conftest import _load_device - resources = _load_device('microwave_mw7300b') - golden = json.loads((GOLDEN / 'microwave_mw7300b.json').read_text()) - state_keys = _new_state_keys('microwave_mw7300b', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("microwave_mw7300b") + golden = json.loads((GOLDEN / "microwave_mw7300b.json").read_text()) + state_keys = _new_state_keys("microwave_mw7300b", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -704,10 +740,11 @@ def test_registry_reproduces_golden_state_keys_for_qooker_mw7500a(): powerLevel. The routing correction must expose microwave state keys without the misleading oven_mode/oven_state entities.""" from tests.conftest import _load_device - resources = _load_device('qooker_mw7500a') - golden = json.loads((GOLDEN / 'qooker_mw7500a.json').read_text()) - state_keys = _new_state_keys('qooker_mw7500a', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("qooker_mw7500a") + golden = json.loads((GOLDEN / "qooker_mw7500a.json").read_text()) + state_keys = _new_state_keys("qooker_mw7500a", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -728,10 +765,11 @@ def test_registry_reproduces_golden_state_keys_for_microwave_me7500d(): field, unlike MW7300B, so `setpoint`/`current_temp_c` and `automatic_operation` are correctly absent here.""" from tests.conftest import _load_device - resources = _load_device('microwave_me7500d') - golden = json.loads((GOLDEN / 'microwave_me7500d.json').read_text()) - state_keys = _new_state_keys('microwave_me7500d', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("microwave_me7500d") + golden = json.loads((GOLDEN / "microwave_me7500d.json").read_text()) + state_keys = _new_state_keys("microwave_me7500d", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -744,10 +782,11 @@ def test_registry_reproduces_golden_state_keys_for_microwave_me7500d_lamp_high() non-Off Lamp token ('Lamp_High'). Locks in that the lamp switch reads it as on rather than the previously-hardcoded 'On'-only comparison.""" from tests.conftest import _load_device - resources = _load_device('microwave_me7500d_lamp_high') - golden = json.loads((GOLDEN / 'microwave_me7500d_lamp_high.json').read_text()) - state_keys = _new_state_keys('microwave_me7500d_lamp_high', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("microwave_me7500d_lamp_high") + golden = json.loads((GOLDEN / "microwave_me7500d_lamp_high.json").read_text()) + state_keys = _new_state_keys("microwave_me7500d_lamp_high", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -766,10 +805,11 @@ def test_registry_reproduces_golden_state_keys_for_air_purifier_tp1x_da_ac_air() entity (PRESET_MODE, not an ordered speed -- see fan.py). Binds with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('air_purifier_tp1x_da_ac_air') - golden = json.loads((GOLDEN / 'air_purifier_tp1x_da_ac_air.json').read_text()) - state_keys = _new_state_keys('air_purifier_tp1x_da_ac_air', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("air_purifier_tp1x_da_ac_air") + golden = json.loads((GOLDEN / "air_purifier_tp1x_da_ac_air.json").read_text()) + state_keys = _new_state_keys("air_purifier_tp1x_da_ac_air", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -786,10 +826,11 @@ def test_registry_reproduces_golden_state_keys_for_vacuum_station(): UV-sanitize resources that share no hrefs with anything else already modeled). Binds with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('vacuum_station') - golden = json.loads((GOLDEN / 'vacuum_station.json').read_text()) - state_keys = _new_state_keys('vacuum_station', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("vacuum_station") + golden = json.loads((GOLDEN / "vacuum_station.json").read_text()) + state_keys = _new_state_keys("vacuum_station", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -805,12 +846,11 @@ def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k(): /airflow/vs/0), no /mode/convenient/vs/0 (the preset is a Comode_* token), and several settings carried as /mode/vs/0 options[] tokens.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_artik051_krac_18k') - golden = json.loads( - (GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text() - ) - state_keys = _new_state_keys('airconditioner_artik051_krac_18k', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_artik051_krac_18k") + golden = json.loads((GOLDEN / "airconditioner_artik051_krac_18k.json").read_text()) + state_keys = _new_state_keys("airconditioner_artik051_krac_18k", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -826,12 +866,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001 dropped every fan speed but Auto -- see test_airconditioner_tp1x_rac_01001_fan.py for the climate-level fix.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_tp1x_rac_01001') - golden = json.loads( - (GOLDEN / 'airconditioner_tp1x_rac_01001.json').read_text() - ) - state_keys = _new_state_keys('airconditioner_tp1x_rac_01001', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_tp1x_rac_01001") + golden = json.loads((GOLDEN / "airconditioner_tp1x_rac_01001.json").read_text()) + state_keys = _new_state_keys("airconditioner_tp1x_rac_01001", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -847,12 +886,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_odor_ previously unbound to any entity -- now odor_controller_active/ odor_controller_progress.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_tp1x_rac_odor_controller') - golden = json.loads( - (GOLDEN / 'airconditioner_tp1x_rac_odor_controller.json').read_text() - ) - state_keys = _new_state_keys('airconditioner_tp1x_rac_odor_controller', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_tp1x_rac_odor_controller") + golden = json.loads((GOLDEN / "airconditioner_tp1x_rac_odor_controller.json").read_text()) + state_keys = _new_state_keys("airconditioner_tp1x_rac_odor_controller", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -866,10 +904,11 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser(): entirely from laundry.cycle_options' supportedOptions fallback. Binds cleanly against the new air_dresser registry with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('air_dresser') - golden = json.loads((GOLDEN / 'air_dresser.json').read_text()) - state_keys = _new_state_keys('air_dresser', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("air_dresser") + golden = json.loads((GOLDEN / "air_dresser.json").read_text()) + state_keys = _new_state_keys("air_dresser", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -887,10 +926,11 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser_tp2_20(): /airdresseroption/sanitize/vs/0 (air_dresser.AIR_DRESSER_SANITIZE). Binds cleanly with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('air_dresser_tp2_20') - golden = json.loads((GOLDEN / 'air_dresser_tp2_20.json').read_text()) - state_keys = _new_state_keys('air_dresser_tp2_20', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("air_dresser_tp2_20") + golden = json.loads((GOLDEN / "air_dresser_tp2_20.json").read_text()) + state_keys = _new_state_keys("air_dresser_tp2_20", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -910,10 +950,11 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser_tp1_21(): board's table still aren't identified (same as #162's), so 'cycle' remains the raw code rather than a table-specific translation.""" from tests.conftest import _load_device - resources = _load_device('air_dresser_tp1_21') - golden = json.loads((GOLDEN / 'air_dresser_tp1_21.json').read_text()) - state_keys = _new_state_keys('air_dresser_tp1_21', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("air_dresser_tp1_21") + golden = json.loads((GOLDEN / "air_dresser_tp1_21.json").read_text()) + state_keys = _new_state_keys("air_dresser_tp1_21", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -931,10 +972,11 @@ def test_registry_reproduces_golden_state_keys_for_air_monitor(): clean_level, adding a CO2 reading those families don't report. Binds cleanly with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('air_monitor') - golden = json.loads((GOLDEN / 'air_monitor.json').read_text()) - state_keys = _new_state_keys('air_monitor', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("air_monitor") + golden = json.loads((GOLDEN / "air_monitor.json").read_text()) + state_keys = _new_state_keys("air_monitor", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -949,10 +991,11 @@ def test_registry_reproduces_golden_state_keys_for_air_purifier_vtww(): /mode/vs/0 FAN the other two board generations in this registry use. Binds cleanly with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('air_purifier_vtww') - golden = json.loads((GOLDEN / 'air_purifier_vtww.json').read_text()) - state_keys = _new_state_keys('air_purifier_vtww', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("air_purifier_vtww") + golden = json.loads((GOLDEN / "air_purifier_vtww.json").read_text()) + state_keys = _new_state_keys("air_purifier_vtww", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -969,10 +1012,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora(): hrefs this board reports that no other AC family does, both added to _AC_IGNORED as undocumented/not-locally-actionable.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_fac_bora') - golden = json.loads((GOLDEN / 'airconditioner_fac_bora.json').read_text()) - state_keys = _new_state_keys('airconditioner_fac_bora', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_fac_bora") + golden = json.loads((GOLDEN / "airconditioner_fac_bora.json").read_text()) + state_keys = _new_state_keys("airconditioner_fac_bora", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -987,9 +1031,12 @@ def _new_subdevice_aware_state_keys(name): (subdevice1_-/subdevice__-prefixed), not just the master's.""" from custom_components.localthings.registry.adapter import flatten from tests.conftest import _discover_full, _load_device_full + resources, oic_res, seeds = _load_device_full(name) bound, _materialized, _skipped, full_resources, _device_type_name = _discover_full( - resources, oic_res, seeds, + resources, + oic_res, + seeds, ) state = flatten(bound, full_resources) return sorted(state.keys()) @@ -1004,10 +1051,10 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_dongl test_subdevice_discovery.py's explicit "/device/2 produces no entities" assertion) -- so this golden has no `subdevice2_`-prefixed keys at all, which is the point.""" - name = 'airconditioner_artik051_dongle_fac_18k' - golden = json.loads((GOLDEN / f'{name}.json').read_text()) + name = "airconditioner_artik051_dongle_fac_18k" + golden = json.loads((GOLDEN / f"{name}.json").read_text()) state_keys = _new_subdevice_aware_state_keys(name) - assert set(state_keys) == set(golden['state_keys']), ( + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -1024,10 +1071,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac(): LED), all genuinely new to this board generation and out of scope for the routing fix; see test_airconditioner_cac.py for the documented gap.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_cac') - golden = json.loads((GOLDEN / 'airconditioner_cac.json').read_text()) - state_keys = _new_state_keys('airconditioner_cac', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_cac") + golden = json.loads((GOLDEN / "airconditioner_cac.json").read_text()) + state_keys = _new_state_keys("airconditioner_cac", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -1044,10 +1092,10 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_2in1( tests/fixtures/airconditioner_fac_bora_device.json, which is deliberately left unchanged as the redacted-subdeviceIdList regression case (zero subdevices).""" - name = 'airconditioner_fac_bora_2in1' - golden = json.loads((GOLDEN / f'{name}.json').read_text()) + name = "airconditioner_fac_bora_2in1" + golden = json.loads((GOLDEN / f"{name}.json").read_text()) state_keys = _new_subdevice_aware_state_keys(name) - assert set(state_keys) == set(golden['state_keys']), ( + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -1068,10 +1116,10 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_205_f tests/fixtures/golden/airconditioner_fac_bora.json, which is the point: a device whose sibling can't yet be confirmed live must regress to exactly the master-only state, never a phantom or partial subdevice.""" - name = 'airconditioner_fac_bora_205_flat' - golden = json.loads((GOLDEN / f'{name}.json').read_text()) + name = "airconditioner_fac_bora_205_flat" + golden = json.loads((GOLDEN / f"{name}.json").read_text()) state_keys = _new_subdevice_aware_state_keys(name) - assert set(state_keys) == set(golden['state_keys']), ( + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -1089,18 +1137,16 @@ def test_registry_reproduces_golden_state_keys_for_artik051_krac_18k_slot(): byte-identical to airconditioner_artik051_krac_18k.json -- master keys only, no `subdevice1_` prefix anywhere -- which is what test_krac_18k_energy_only_slot_is_not_materialized asserts structurally.""" - name = 'airconditioner_artik051_krac_18k_slot' - golden = json.loads((GOLDEN / f'{name}.json').read_text()) + name = "airconditioner_artik051_krac_18k_slot" + golden = json.loads((GOLDEN / f"{name}.json").read_text()) state_keys = _new_subdevice_aware_state_keys(name) - assert set(state_keys) == set(golden['state_keys']), ( + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" ) - master_golden = json.loads( - (GOLDEN / 'airconditioner_artik051_krac_18k.json').read_text() - ) - assert set(state_keys) == set(master_golden['state_keys']) + master_golden = json.loads((GOLDEN / "airconditioner_artik051_krac_18k.json").read_text()) + assert set(state_keys) == set(master_golden["state_keys"]) def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww(): @@ -1110,10 +1156,11 @@ def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww(): new 'AVT' modelNum board-token fallback into the existing air_purifier registry. Binds cleanly with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('air_purifier_avt_ww') - golden = json.loads((GOLDEN / 'air_purifier_avt_ww.json').read_text()) - state_keys = _new_state_keys('air_purifier_avt_ww', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("air_purifier_avt_ww") + golden = json.loads((GOLDEN / "air_purifier_avt_ww.json").read_text()) + state_keys = _new_state_keys("air_purifier_avt_ww", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -1127,12 +1174,11 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_krac_ asserted separately in test_airconditioner_capabilities.py since golden only compares key sets, not values.""" from tests.conftest import _load_device - resources = _load_device('airconditioner_artik051_krac_energy') - golden = json.loads( - (GOLDEN / 'airconditioner_artik051_krac_energy.json').read_text() - ) - state_keys = _new_state_keys('airconditioner_artik051_krac_energy', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("airconditioner_artik051_krac_energy") + golden = json.loads((GOLDEN / "airconditioner_artik051_krac_energy.json").read_text()) + state_keys = _new_state_keys("airconditioner_artik051_krac_energy", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -1149,12 +1195,11 @@ def test_registry_reproduces_golden_state_keys_for_refrigerator_definite_cooler( continuous NumberDesc range -- 5 and 6 aren't valid setpoints on this model). Binds cleanly with zero unbound hrefs.""" from tests.conftest import _load_device - resources = _load_device('refrigerator_definite_cooler') - golden = json.loads( - (GOLDEN / 'refrigerator_definite_cooler.json').read_text() - ) - state_keys = _new_state_keys('refrigerator_definite_cooler', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("refrigerator_definite_cooler") + golden = json.loads((GOLDEN / "refrigerator_definite_cooler.json").read_text()) + state_keys = _new_state_keys("refrigerator_definite_cooler", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -1170,10 +1215,11 @@ def test_registry_reproduces_golden_state_keys_for_range_ne6516a(): switches now correctly stay unbound instead of binding as always-off, does-nothing phantom controls.""" from tests.conftest import _load_device - resources = _load_device('range_ne6516a') - golden = json.loads((GOLDEN / 'range_ne6516a.json').read_text()) - state_keys = _new_state_keys('range_ne6516a', resources) - assert set(state_keys) == set(golden['state_keys']), ( + + resources = _load_device("range_ne6516a") + golden = json.loads((GOLDEN / "range_ne6516a.json").read_text()) + state_keys = _new_state_keys("range_ne6516a", resources) + assert set(state_keys) == set(golden["state_keys"]), ( f"state_keys mismatch:\n" f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" @@ -1182,12 +1228,13 @@ def test_registry_reproduces_golden_state_keys_for_range_ne6516a(): def test_resources_from_batch_preferred_over_flat(): from tests.conftest import _resources_from_dump + dump = { - 'device0': [ - {'di': 'device'}, # [0] device-level rep, skipped - {'href': '/foo', 'rep': {'x': 1}}, + "device0": [ + {"di": "device"}, # [0] device-level rep, skipped + {"href": "/foo", "rep": {"x": 1}}, ], - 'resources': {'/foo': {'x': 99}}, + "resources": {"/foo": {"x": 99}}, } result = _resources_from_dump(dump) - assert result == {'/foo': {'x': 1}} + assert result == {"/foo": {"x": 1}} diff --git a/tests/test_identity.py b/tests/test_identity.py index 2a02953..4eed27e 100644 --- a/tests/test_identity.py +++ b/tests/test_identity.py @@ -1,74 +1,78 @@ import cbor2 -from custom_components.localthings.registry.identity import DeviceIdentity, read_identity +from custom_components.localthings.registry.identity import read_identity class FakeSession: def __init__(self, table): - self.table = table # tuple(path) -> rep dict + self.table = table # tuple(path) -> rep dict def get(self, path, timeout=10.0): rep = self.table.get(tuple(path)) if rep is None: - return 0x84, b'' # 4.04 not found + return 0x84, b"" # 4.04 not found return 0x45, cbor2.dumps(rep) def test_read_identity_from_oic_p_and_d(): - sess = FakeSession({ - ('oic', 'p'): {'mnmn': 'Samsung Electronics', 'mnmo': 'RF9000B'}, - ('oic', 'd'): {'n': 'Family Hub'}, - }) - ident = read_identity(sess, serial='ABC123') - assert ident.manufacturer == 'Samsung Electronics' - assert ident.model == 'RF9000B' - assert ident.name == 'Family Hub' - assert ident.serial == 'ABC123' + sess = FakeSession( + { + ("oic", "p"): {"mnmn": "Samsung Electronics", "mnmo": "RF9000B"}, + ("oic", "d"): {"n": "Family Hub"}, + } + ) + ident = read_identity(sess, serial="ABC123") + assert ident.manufacturer == "Samsung Electronics" + assert ident.model == "RF9000B" + assert ident.name == "Family Hub" + assert ident.serial == "ABC123" def test_read_identity_tolerates_missing_resources(): ident = read_identity(FakeSession({}), serial=None) - assert ident.manufacturer == 'Samsung' - assert ident.model == '' + assert ident.manufacturer == "Samsung" + assert ident.model == "" assert ident.serial is None assert ident.device_types == () - assert ident.raw == {'/oic/p': {}, '/oic/d': {}, '/oic/res': []} + assert ident.raw == {"/oic/p": {}, "/oic/d": {}, "/oic/res": []} def test_read_identity_captures_oic_d_device_types(): """/oic/d's `rt` is OCF's own device-type declaration -- captured so diagnostics can show whether real hardware populates it usefully.""" - sess = FakeSession({ - ('oic', 'd'): { - 'n': 'Living Room AC', - 'rt': ['oic.wk.d', 'oic.d.airconditioner'], - }, - }) + sess = FakeSession( + { + ("oic", "d"): { + "n": "Living Room AC", + "rt": ["oic.wk.d", "oic.d.airconditioner"], + }, + } + ) ident = read_identity(sess, serial=None) - assert ident.device_types == ('oic.wk.d', 'oic.d.airconditioner') + assert ident.device_types == ("oic.wk.d", "oic.d.airconditioner") def test_read_identity_normalizes_scalar_and_malformed_rt(): """Firmware that reports a bare string, or a non-list, must not explode.""" assert read_identity( - FakeSession({('oic', 'd'): {'rt': 'oic.d.refrigerator'}}), None - ).device_types == ('oic.d.refrigerator',) + FakeSession({("oic", "d"): {"rt": "oic.d.refrigerator"}}), None + ).device_types == ("oic.d.refrigerator",) + assert read_identity(FakeSession({("oic", "d"): {"rt": 42}}), None).device_types == () assert read_identity( - FakeSession({('oic', 'd'): {'rt': 42}}), None - ).device_types == () - assert read_identity( - FakeSession({('oic', 'd'): {'rt': ['oic.wk.d', 7, None]}}), None - ).device_types == ('oic.wk.d',) + FakeSession({("oic", "d"): {"rt": ["oic.wk.d", 7, None]}}), None + ).device_types == ("oic.wk.d",) def test_read_identity_keeps_raw_payloads_for_diagnostics(): - sess = FakeSession({ - ('oic', 'p'): {'mnmn': 'Samsung Electronics', 'mnmo': 'RF9000B'}, - ('oic', 'd'): {'n': 'Family Hub', 'di': 'abc-123'}, - }) + sess = FakeSession( + { + ("oic", "p"): {"mnmn": "Samsung Electronics", "mnmo": "RF9000B"}, + ("oic", "d"): {"n": "Family Hub", "di": "abc-123"}, + } + ) ident = read_identity(sess, serial=None) - assert ident.raw['/oic/p']['mnmo'] == 'RF9000B' - assert ident.raw['/oic/d']['di'] == 'abc-123' + assert ident.raw["/oic/p"]["mnmo"] == "RF9000B" + assert ident.raw["/oic/d"]["di"] == "abc-123" def test_read_identity_captures_oic_res_links(): @@ -81,22 +85,32 @@ def test_read_identity_captures_oic_res_links(): all, meaning it's registered non-discoverable and simply invisible to this endpoint -- see _SPECULATIVE_DEVICE_INDICES' docstring for why that motivated probing /device/1 and /device/2 directly instead.""" - sess = FakeSession({ - ('oic', 'res'): [ - {'di': 'aaaa', 'links': [ - {'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'], - 'p': {'bm': 1}}, - {'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}}, - ]}, - ], - }) + sess = FakeSession( + { + ("oic", "res"): [ + { + "di": "aaaa", + "links": [ + { + "href": "/oic/d", + "rt": ["oic.wk.d", "oic.d.refrigerator"], + "p": {"bm": 1}, + }, + {"href": "/oic/sec/doxm", "rt": ["oic.r.doxm"], "p": {"bm": 1}}, + ], + }, + ], + } + ) ident = read_identity(sess, serial=None) - assert ident.raw['/oic/res'] == [ - {'di': 'aaaa', 'links': [ - {'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'], - 'p': {'bm': 1}}, - {'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}}, - ]}, + assert ident.raw["/oic/res"] == [ + { + "di": "aaaa", + "links": [ + {"href": "/oic/d", "rt": ["oic.wk.d", "oic.d.refrigerator"], "p": {"bm": 1}}, + {"href": "/oic/sec/doxm", "rt": ["oic.r.doxm"], "p": {"bm": 1}}, + ], + }, ] @@ -104,7 +118,5 @@ def test_read_identity_tolerates_malformed_oic_res(): """A single Property map instead of an array (or anything else non-list-shaped) must not explode -- same defensive posture as _device_types' handling of a malformed /oic/d rt.""" - ident = read_identity( - FakeSession({('oic', 'res'): {'not': 'a list'}}), serial=None - ) - assert ident.raw['/oic/res'] == [] + ident = read_identity(FakeSession({("oic", "res"): {"not": "a list"}}), serial=None) + assert ident.raw["/oic/res"] == [] diff --git a/tests/test_induction_cooktop_capabilities.py b/tests/test_induction_cooktop_capabilities.py index 1c3dfea..84db7a7 100644 --- a/tests/test_induction_cooktop_capabilities.py +++ b/tests/test_induction_cooktop_capabilities.py @@ -1,16 +1,17 @@ """Tests for the standalone induction-cooktop registry (issue #86).""" + from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import for_device_by_model, induction_cooktop from custom_components.localthings.registry.discovery import discover - from tests.conftest import _load_device def _cooktop(): - resources = _load_device('induction_cooktop') - info = resources['/information/vs/0'] + resources = _load_device("induction_cooktop") + 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 @@ -23,7 +24,7 @@ def _state(): def test_model_resolves_to_induction_cooktop_registry(): reg, _ = _cooktop() - assert reg is not None and reg.name == 'induction_cooktop' + assert reg is not None and reg.name == "induction_cooktop" assert reg is induction_cooktop.REGISTRY @@ -39,11 +40,19 @@ def test_no_unbound_hrefs(): def test_expected_entities_present(): state = _state() for key in ( - 'cooktop_state', 'cooktop_power', 'cooktop_child_lock', - 'burner_0_power_level', 'burner_0_state', 'burner_0_hot_surface', - 'burner_0_pan_detected', 'burner_2_pan_detected', - 'paired_hood_connected', 'probe_connected', 'probe_temperature', - 'energy_kwh', 'alarm_code', + "cooktop_state", + "cooktop_power", + "cooktop_child_lock", + "burner_0_power_level", + "burner_0_state", + "burner_0_hot_surface", + "burner_0_pan_detected", + "burner_2_pan_detected", + "paired_hood_connected", + "probe_connected", + "probe_temperature", + "energy_kwh", + "alarm_code", ): assert key in state, key @@ -52,7 +61,7 @@ def test_unreported_burners_gated_out(): """Dump reports numberOfBurners=3 (indices 0-2) -- burner slot 3+ of range.MAX_BURNERS must not appear as entities.""" state = _state() - assert 'burner_3_power_level' not in state + assert "burner_3_power_level" not in state def test_recipe_status_href_is_ignored_not_unbound(): @@ -60,6 +69,7 @@ def test_recipe_status_href_is_ignored_not_unbound(): ignored.py, not modeled as an entity (same treatment as the microwave family's /recipe/cook/vs/0).""" reg, _ = _cooktop() - ignored_hrefs = {cap.href for caps in reg.capabilities.values() for cap in caps - if cap.entities == ()} - assert '/cooktop/recipe/status/vs/0' in ignored_hrefs + ignored_hrefs = { + cap.href for caps in reg.capabilities.values() for cap in caps if cap.entities == () + } + assert "/cooktop/recipe/status/vs/0" in ignored_hrefs diff --git a/tests/test_laundry_capabilities.py b/tests/test_laundry_capabilities.py index f0fc6c0..0bf5a3c 100644 --- a/tests/test_laundry_capabilities.py +++ b/tests/test_laundry_capabilities.py @@ -1,39 +1,62 @@ """Tests for the shared laundry capabilities (washer/dryer/dishwasher).""" + from custom_components.localthings.registry.capabilities import laundry class TestCourseHelpers: def test_parses_edit_course_list(self): - raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A' + raw = "EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F962B2A" assert laundry.parse_edit_course_list(raw) == [ - '1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27', - '22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96', - '2B', '2A' + "1C", + "1D", + "21", + "1B", + "1E", + "29", + "24", + "33", + "28", + "26", + "27", + "22", + "20", + "23", + "25", + "32", + "2F", + "2E", + "30", + "66", + "2D", + "8F", + "96", + "2B", + "2A", ] def test_parse_edit_course_list_handles_missing_or_malformed(self): assert laundry.parse_edit_course_list(None) == [] - assert laundry.parse_edit_course_list('') == [] - assert laundry.parse_edit_course_list('no underscore') == [] + assert laundry.parse_edit_course_list("") == [] + assert laundry.parse_edit_course_list("no underscore") == [] def test_cycle_options_reads_live_edit_course_list(self): """The device's own course list -- including a code ('65') never seen on the primary dump -- is used as-is; there is no hardcoded table.""" resources = { - '/wm/editcourse/vs/0': { - 'x.com.samsung.da.editCourseList': 'EditCourseList_651C', + "/wm/editcourse/vs/0": { + "x.com.samsung.da.editCourseList": "EditCourseList_651C", }, } - assert laundry.cycle_options(resources) == ['65', '1C'] + assert laundry.cycle_options(resources) == ["65", "1C"] def test_cycle_options_empty_when_resource_absent_or_empty(self): assert laundry.cycle_options({}) == [] - assert laundry.cycle_options({'/wm/editcourse/vs/0': {}}) == [] + assert laundry.cycle_options({"/wm/editcourse/vs/0": {}}) == [] def test_option_value(self): - opts = ['DeviceType_0167', 'Course_1C', 'GMT_04'] - assert laundry.option_value(opts, 'Course') == '1C' - assert laundry.option_value(opts, 'Missing') is None + opts = ["DeviceType_0167", "Course_1C", "GMT_04"] + assert laundry.option_value(opts, "Course") == "1C" + assert laundry.option_value(opts, "Missing") is None class TestCourseCodesFromSupportedOptions: @@ -49,45 +72,57 @@ class TestCourseCodesFromSupportedOptions: # checked item, and by six other independent devices' already-shipped # translations agreeing on the same code -> name mapping. _REAL_SUPPORTED_OPTIONS = ( - '31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F' - '088000913FA67F7485209204A5208780009000A00006841E930FA30F' - '7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F' - '34841E923FA67FA0811E933FA33F' + "31C8410923FA67F1B847E923FA67F25843E933FA57F20857E943FA67F" + "088000913FA67F7485209204A5208780009000A00006841E930FA30F" + "7F841E920FA30F65841E943FA57F8F8102923FA57F96841E920FA37F" + "34841E923FA67FA0811E933FA33F" ) def test_derives_codes_when_edit_course_list_is_empty(self): resources = { - '/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': ''}, - '/course/vs/0': { - 'x.com.samsung.da.options': ['Course_1C'], - 'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS], + "/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": ""}, + "/course/vs/0": { + "x.com.samsung.da.options": ["Course_1C"], + "x.com.samsung.da.supportedOptions": [self._REAL_SUPPORTED_OPTIONS], }, } assert laundry.cycle_options(resources) == [ - '1C', '1B', '25', '20', '08', '74', '87', '06', - '7F', '65', '8F', '96', '34', 'A0', + "1C", + "1B", + "25", + "20", + "08", + "74", + "87", + "06", + "7F", + "65", + "8F", + "96", + "34", + "A0", ] def test_edit_course_list_still_takes_priority(self): """A live editCourseList wins even with supportedOptions present -- no reason to prefer a derived list over the authoritative one.""" resources = { - '/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_651C'}, - '/course/vs/0': { - 'x.com.samsung.da.options': ['Course_1C'], - 'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS], + "/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_651C"}, + "/course/vs/0": { + "x.com.samsung.da.options": ["Course_1C"], + "x.com.samsung.da.supportedOptions": [self._REAL_SUPPORTED_OPTIONS], }, } - assert laundry.cycle_options(resources) == ['65', '1C'] + assert laundry.cycle_options(resources) == ["65", "1C"] def test_rejects_a_table_missing_the_current_course(self): """The device's own currently-selected course must be a member of its derived list -- a mismatch means the guess is wrong, not that the device selected something outside its own supported set.""" resources = { - '/course/vs/0': { - 'x.com.samsung.da.options': ['Course_FF'], - 'x.com.samsung.da.supportedOptions': [self._REAL_SUPPORTED_OPTIONS], + "/course/vs/0": { + "x.com.samsung.da.options": ["Course_FF"], + "x.com.samsung.da.supportedOptions": [self._REAL_SUPPORTED_OPTIONS], }, } assert laundry.cycle_options(resources) == [] @@ -99,16 +134,16 @@ class TestCourseCodesFromSupportedOptions: current course" carry over for free) -- but K=1 is the real, most specific table and must be the one returned.""" resources = { - '/course/vs/0': { - 'x.com.samsung.da.options': ['Course_AA'], - 'x.com.samsung.da.supportedOptions': ['0AABBCCDD'], + "/course/vs/0": { + "x.com.samsung.da.options": ["Course_AA"], + "x.com.samsung.da.supportedOptions": ["0AABBCCDD"], }, } - assert laundry.cycle_options(resources) == ['AA', 'BB', 'CC', 'DD'] + assert laundry.cycle_options(resources) == ["AA", "BB", "CC", "DD"] def test_empty_without_supported_options_or_course_href(self): assert laundry.cycle_options({}) == [] - assert laundry.cycle_options({'/course/vs/0': {}}) == [] + assert laundry.cycle_options({"/course/vs/0": {}}) == [] def test_smallest_wins_even_when_a_larger_pass_is_not_a_multiple(self): """Real dishwasher dump (K=7, 10 courses): K=10, 14, and 35 also @@ -119,54 +154,63 @@ class TestCourseCodesFromSupportedOptions: real dump checked so far, not a proven guarantee -- see _course_codes_from_supported_options's docstring.""" resources = { - '/course/vs/0': { - 'x.com.samsung.da.options': ['Course_0E'], - 'x.com.samsung.da.supportedOptions': [ - '30E5434B102D102835034B002D002845034B002D002805034B000D000' - '865034B002D002075000B000D000905000B000D0008D5034B002D0028' - 'E5034B000D0008F5034B000D000' + "/course/vs/0": { + "x.com.samsung.da.options": ["Course_0E"], + "x.com.samsung.da.supportedOptions": [ + "30E5434B102D102835034B002D002845034B002D002805034B000D000" + "865034B002D002075000B000D000905000B000D0008D5034B002D0028" + "E5034B000D0008F5034B000D000" ], }, } assert laundry.cycle_options(resources) == [ - '0E', '83', '84', '80', '86', '07', '90', '8D', '8E', '8F', + "0E", + "83", + "84", + "80", + "86", + "07", + "90", + "8D", + "8E", + "8F", ] class TestCycleSelect: def test_builds_labelled_cycle_select(self): - desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer') - assert desc.key == 'cycle' - assert desc.translation_key == 'dryer_cycle' - assert desc.icon == 'mdi:tumble-dryer' + desc = laundry.cycle_select(translation_key="dryer_cycle", icon="mdi:tumble-dryer") + assert desc.key == "cycle" + assert desc.translation_key == "dryer_cycle" + assert desc.icon == "mdi:tumble-dryer" assert desc.options is laundry.cycle_options def test_reads_raw_course_code_from_options(self): - desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') - rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']} - assert desc.rep_fn(rep) == '16' - assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None + desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x") + rep = {"x.com.samsung.da.options": ["DeviceType_0167", "Course_16", "GMT_04"]} + assert desc.rep_fn(rep) == "16" + assert desc.rep_fn({"x.com.samsung.da.options": ["GMT_04"]}) is None def test_exists_only_when_edit_course_list_is_live(self): - desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') + desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x") assert desc.exists_fn({}, {}) is False - assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False - live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_16'}} + assert desc.exists_fn({}, {"/wm/editcourse/vs/0": {}}) is False + live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_16"}} assert desc.exists_fn({}, live) is True def test_cycle_write_is_single_token(self): """Confirmed on real hardware (issue #54): the device merges a single-token options[] write by prefix itself, so the write only needs to carry the changed token, not the whole rewritten array.""" - desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') - rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']} - path, body = desc.write_fn('1D', rep) - assert path == ['course', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['Course_1D']} + desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x") + rep = {"x.com.samsung.da.options": ["DeviceType_0167", "Course_16", "GMT_04"]} + path, body = desc.write_fn("1D", rep) + assert path == ["course", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["Course_1D"]} def test_cycle_write_noop_without_options(self): - desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x') - assert desc.write_fn('1D', {}) is None + desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x") + assert desc.write_fn("1D", {}) is None class TestCycleSelectTableGating: @@ -181,56 +225,57 @@ class TestCycleSelectTableGating: def _desc(self): return laundry.cycle_select( - translation_key='washer_cycle', icon='x', - table_href='/st/washercourse/vs/0', + translation_key="washer_cycle", + icon="x", + table_href="/st/washercourse/vs/0", ) def test_static_string_when_no_table_href_given(self): """dishwasher's call site -- no equivalent table-id resource in any dump seen, no evidence of the same cross-board risk -- keeps the plain static key unconditionally.""" - desc = laundry.cycle_select(translation_key='dishwasher_cycle', icon='x') - assert desc.translation_key == 'dishwasher_cycle' + desc = laundry.cycle_select(translation_key="dishwasher_cycle", icon="x") + assert desc.translation_key == "dishwasher_cycle" def test_resolved_key_is_built_from_the_reported_table(self): desc = self._desc() - resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}} + resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_02"}} assert callable(desc.translation_key) - assert desc.translation_key(resources) == 'washer_cycle_table_02' + assert desc.translation_key(resources) == "washer_cycle_table_02" def test_untranslated_table_uses_generic_cycle_key(self): """An unknown table does not claim another board's state labels.""" desc = self._desc() - resources = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} - assert desc.translation_key(resources) == 'cycle' + resources = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}} + assert desc.translation_key(resources) == "cycle" def test_resolves_to_generic_cycle_when_table_id_is_unknown(self): """An absent table id still gets a translated generic entity name.""" desc = self._desc() - assert desc.translation_key({}) == 'cycle' - assert desc.translation_key({'/st/washercourse/vs/0': {}}) == 'cycle' + assert desc.translation_key({}) == "cycle" + assert desc.translation_key({"/st/washercourse/vs/0": {}}) == "cycle" class TestBuzzerSound: def test_href(self): - assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0' + assert laundry.BUZZER_SOUND.href == "/buzzersound/vs/0" def test_buzzer_sound_write(self): - desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'buzzer_sound') - assert desc.options_field == 'supportedBuzzerSound' - path, body = desc.write_fn('On', {}) - assert path == ['buzzersound', 'vs', '0'] - assert body == {'setBuzzerSound': 'On'} + desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == "buzzer_sound") + assert desc.options_field == "supportedBuzzerSound" + path, body = desc.write_fn("On", {}) + assert path == ["buzzersound", "vs", "0"] + assert body == {"setBuzzerSound": "On"} def test_finish_sound_exists_only_when_supported(self): - desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'finish_sound') - assert desc.exists_fn({'setBuzzerSound': 'On'}, {}) is False - assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}, {}) is True + desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == "finish_sound") + assert desc.exists_fn({"setBuzzerSound": "On"}, {}) is False + assert desc.exists_fn({"supportedFinishSound": ["FinishSound_1"]}, {}) is True class TestJobBeginningStatus: def test_href_and_field(self): - assert laundry.JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0' + assert laundry.JOB_BEGINNING_STATUS.href == "/wm/jobbeginingstatus/vs/0" desc = laundry.JOB_BEGINNING_STATUS.entities[0] - assert desc.field == 'x.com.samsung.da.currentStatus' - assert desc.entity_category == 'diagnostic' + assert desc.field == "x.com.samsung.da.currentStatus" + assert desc.entity_category == "diagnostic" diff --git a/tests/test_laundry_writes.py b/tests/test_laundry_writes.py index 93a5d12..4c9d891 100644 --- a/tests/test_laundry_writes.py +++ b/tests/test_laundry_writes.py @@ -1,58 +1,59 @@ """Write-handler unit tests for laundry-family capabilities.""" + from custom_components.localthings.registry.capabilities import laundry from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE def test_start_button_writes_run(): - btn = next(e for e in OPERATIONAL_STATE.entities if e.key == 'start') - assert btn.write_fn('Run', {}) == ( - ['operational', 'state', 'vs', '0'], - {'x.com.samsung.da.state': 'Run'}, + btn = next(e for e in OPERATIONAL_STATE.entities if e.key == "start") + assert btn.write_fn("Run", {}) == ( + ["operational", "state", "vs", "0"], + {"x.com.samsung.da.state": "Run"}, ) def test_pause_button_writes_pause(): - btn = next(e for e in OPERATIONAL_STATE.entities if e.key == 'pause') - assert btn.write_fn('Pause', {}) == ( - ['operational', 'state', 'vs', '0'], - {'x.com.samsung.da.state': 'Pause'}, + btn = next(e for e in OPERATIONAL_STATE.entities if e.key == "pause") + assert btn.write_fn("Pause", {}) == ( + ["operational", "state", "vs", "0"], + {"x.com.samsung.da.state": "Pause"}, ) def test_stop_button_writes_ready(): - btn = next(e for e in OPERATIONAL_STATE.entities if e.key == 'stop') - assert btn.write_fn('Ready', {}) == ( - ['operational', 'state', 'vs', '0'], - {'x.com.samsung.da.state': 'Ready'}, + btn = next(e for e in OPERATIONAL_STATE.entities if e.key == "stop") + assert btn.write_fn("Ready", {}) == ( + ["operational", "state", "vs", "0"], + {"x.com.samsung.da.state": "Ready"}, ) def test_sound_mode_write_valid(): - desc = next(e for e in laundry.SOUND_MODE.entities if e.key == 'sound_mode') - assert desc.write_fn('mute', {}) == ( - ['settings', 'sound', 'mode', 'vs', '0'], - {'mode': 'mute'}, + desc = next(e for e in laundry.SOUND_MODE.entities if e.key == "sound_mode") + assert desc.write_fn("mute", {}) == ( + ["settings", "sound", "mode", "vs", "0"], + {"mode": "mute"}, ) - assert desc.write_fn('invalid', {}) is None + assert desc.write_fn("invalid", {}) is None def test_led_brightness_write_valid(): - desc = next(e for e in laundry.DOOR_LED.entities if e.key == 'led_brightness') - assert desc.write_fn('Low', {}) == ( - ['doorled', 'light', 'vs', '0'], - {'setBrightness': 'Low'}, + desc = next(e for e in laundry.DOOR_LED.entities if e.key == "led_brightness") + assert desc.write_fn("Low", {}) == ( + ["doorled", "light", "vs", "0"], + {"setBrightness": "Low"}, ) - assert desc.write_fn('Medium', {}) is None + assert desc.write_fn("Medium", {}) is None def test_led_night_light_write_valid(): - desc = next(e for e in laundry.DOOR_LED.entities if e.key == 'led_night_light') - assert desc.write_fn('On', {}) == ( - ['doorled', 'light', 'vs', '0'], - {'setNightLight': 'On'}, + desc = next(e for e in laundry.DOOR_LED.entities if e.key == "led_night_light") + assert desc.write_fn("On", {}) == ( + ["doorled", "light", "vs", "0"], + {"setNightLight": "On"}, ) - assert desc.write_fn('Off', {}) == ( - ['doorled', 'light', 'vs', '0'], - {'setNightLight': 'Off'}, + assert desc.write_fn("Off", {}) == ( + ["doorled", "light", "vs", "0"], + {"setNightLight": "Off"}, ) - assert desc.write_fn('invalid', {}) is None + assert desc.write_fn("invalid", {}) is None diff --git a/tests/test_microwave_capabilities.py b/tests/test_microwave_capabilities.py index 20d4afa..91b2c9c 100644 --- a/tests/test_microwave_capabilities.py +++ b/tests/test_microwave_capabilities.py @@ -1,5 +1,6 @@ """Unit tests for the microwave-family capabilities (issue #121/#66 split into their own device type instead of being folded into oven.py).""" + from custom_components.localthings.registry.by_type import ( for_device_by_model, resolve, @@ -7,19 +8,21 @@ from custom_components.localthings.registry.by_type import ( from custom_components.localthings.registry.capabilities import microwave from custom_components.localthings.registry.discovery import discover - # --------------------------------------------------------------------------- # Device-type detection + full-dump coverage # --------------------------------------------------------------------------- + def test_microwave_fixture_resolves_and_has_no_unbound_hrefs(): from tests.conftest import _load_device - resources = _load_device('microwave_mw7300b') - info = resources['/information/vs/0'] + + resources = _load_device("microwave_mw7300b") + 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"] + ) assert reg is not None - assert reg.name == 'microwave' + assert reg.name == "microwave" unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) @@ -30,12 +33,14 @@ def test_microwave_hood_fan_fixture_resolves_and_has_no_unbound_hrefs(): """Issues #137/#142: `/hood/fanspeed/vs/0` (the combi unit's built-in vent fan) was previously unbound on this family.""" from tests.conftest import _load_device - resources = _load_device('microwave_me7500d') - info = resources['/information/vs/0'] + + resources = _load_device("microwave_me7500d") + 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"] + ) assert reg is not None - assert reg.name == 'microwave' + assert reg.name == "microwave" unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) @@ -46,59 +51,67 @@ def test_qooker_fixture_resolves_as_microwave_and_has_no_unbound_hrefs(): """Bespoke Qooker MW7500A is microwave-shaped despite both its OCF type and internal board token saying oven.""" from tests.conftest import _load_device - resources = _load_device('qooker_mw7500a') - reg = resolve(resources, device_types=('oic.wk.d', 'oic.d.oven')) + resources = _load_device("qooker_mw7500a") + + reg = resolve(resources, device_types=("oic.wk.d", "oic.d.oven")) assert reg is not None - assert reg.name == 'microwave' + assert reg.name == "microwave" unbound = [] bound = discover( - resources, reg.capabilities, reg.pattern_capabilities, + resources, + reg.capabilities, + reg.pattern_capabilities, log=unbound.append, ) assert unbound == [] keys = {entity.desc.key for entity in bound} - assert {'cooking_mode', 'power_level', 'setpoint'} <= keys - assert 'oven_mode' not in keys + assert {"cooking_mode", "power_level", "setpoint"} <= keys + assert "oven_mode" not in keys # --------------------------------------------------------------------------- # MICROWAVE_SETPOINT — NumberDesc with RMW write semantics # --------------------------------------------------------------------------- + def test_microwave_setpoint_write_is_read_modify_write(): desc = microwave.MICROWAVE_SETPOINT.entities[0] - rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]} + rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "0"}]} path, body = desc.write_fn(180, rep) - assert path == ['temperatures', 'vs', '0'] - assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180' + assert path == ["temperatures", "vs", "0"] + assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "180" def test_microwave_setpoint_rmw_preserves_other_item_fields(): desc = microwave.MICROWAVE_SETPOINT.entities[0] - rep = {'x.com.samsung.da.items': [{ - 'x.com.samsung.da.current': '150', - 'x.com.samsung.da.desired': '150', - }]} - path, body = desc.write_fn(180, rep) - item = body['x.com.samsung.da.items'][0] - assert item['x.com.samsung.da.desired'] == '180' - assert item['x.com.samsung.da.current'] == '150' + rep = { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.current": "150", + "x.com.samsung.da.desired": "150", + } + ] + } + _path, body = desc.write_fn(180, rep) + item = body["x.com.samsung.da.items"][0] + assert item["x.com.samsung.da.desired"] == "180" + assert item["x.com.samsung.da.current"] == "150" def test_microwave_setpoint_clamps_to_step(): desc = microwave.MICROWAVE_SETPOINT.entities[0] - rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]} - _, body = desc.write_fn(182, rep) # nearest 5 = 180 - assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '180' + rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "0"}]} + _, body = desc.write_fn(182, rep) # nearest 5 = 180 + assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "180" def test_microwave_setpoint_rejects_out_of_range(): desc = microwave.MICROWAVE_SETPOINT.entities[0] - rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '100'}]} - assert desc.write_fn(20, rep) is None # below min (40) - assert desc.write_fn(210, rep) is None # above max (200) + rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "100"}]} + assert desc.write_fn(20, rep) is None # below min (40) + assert desc.write_fn(210, rep) is None # above max (200) def test_microwave_setpoint_rejects_missing_items(): @@ -111,8 +124,8 @@ def test_microwave_setpoint_exists_only_for_celsius(): against issue #44) -- the writable setpoint stays hidden rather than showing unverified bounds under the wrong unit.""" desc = microwave.MICROWAVE_SETPOINT.entities[0] - celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]} - fahrenheit_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Fahrenheit'}]} + celsius_rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.unit": "Celsius"}]} + fahrenheit_rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.unit": "Fahrenheit"}]} assert desc.exists_fn(celsius_rep, {}) is True assert desc.exists_fn(fahrenheit_rep, {}) is False @@ -121,20 +134,21 @@ def test_microwave_setpoint_exists_only_for_celsius(): # MICROWAVE_CAVITY — power_level sensor # --------------------------------------------------------------------------- + def test_power_level_parses_watt_suffix(): """Issue #121's combi dump reports e.g. '0W'.""" - desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') - assert desc.value_fn('900W') == 900 + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == "power_level") + assert desc.value_fn("900W") == 900 def test_power_level_parses_bare_number(): """Issue #137's plain microwave reports the bare number, no 'W' suffix.""" - desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') - assert desc.value_fn('0') == 0 + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == "power_level") + assert desc.value_fn("0") == 0 def test_power_level_handles_missing_value(): - desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == 'power_level') + desc = next(e for e in microwave.MICROWAVE_CAVITY.entities if e.key == "power_level") assert desc.value_fn(None) is None @@ -142,13 +156,14 @@ def test_power_level_handles_missing_value(): # MICROWAVE_MODE — SelectDesc with non-empty, family-specific options # --------------------------------------------------------------------------- + def test_microwave_mode_options_nonempty(): desc = microwave.MICROWAVE_MODE.entities[0] assert callable(desc.options) options = desc.options({}) assert len(options) > 0 - assert 'MicroWave' in options - assert 'AirFryer' in options # distinct spelling from oven.py's 'AirFry' + assert "MicroWave" in options + assert "AirFryer" in options # distinct spelling from oven.py's 'AirFry' def test_microwave_mode_options_reads_live_supported_modes(): @@ -157,22 +172,24 @@ def test_microwave_mode_options_reads_live_supported_modes(): present, same live-first pattern as oven._oven_mode_options, instead of offering users modes their own unit doesn't have.""" desc = microwave.MICROWAVE_MODE.entities[0] - resources = {'/mode/vs/0': { - 'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm'], - }} - assert desc.options(resources) == ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm'] + resources = { + "/mode/vs/0": { + "x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"], + } + } + assert desc.options(resources) == ["NoOperation", "MicroWave", "Autocook", "KeepWarm"] def test_microwave_mode_write_round_trips(): desc = microwave.MICROWAVE_MODE.entities[0] - path, body = desc.write_fn('MicroWave', {}) - assert path == ['mode', 'vs', '0'] - assert body['x.com.samsung.da.modes'] == ['MicroWave'] + path, body = desc.write_fn("MicroWave", {}) + assert path == ["mode", "vs", "0"] + assert body["x.com.samsung.da.modes"] == ["MicroWave"] def test_microwave_mode_rejects_unknown(): desc = microwave.MICROWAVE_MODE.entities[0] - assert desc.write_fn('SpaghettiMode', {}) is None + assert desc.write_fn("SpaghettiMode", {}) is None def test_microwave_mode_write_validates_against_live_supported_modes(): @@ -181,129 +198,131 @@ def test_microwave_mode_write_validates_against_live_supported_modes(): valid _MICROWAVE_MODES entry but must still be rejected for a unit whose own supportedModes doesn't include it.""" desc = microwave.MICROWAVE_MODE.entities[0] - rep = {'x.com.samsung.da.supportedModes': ['NoOperation', 'MicroWave', 'Autocook', 'KeepWarm']} - path, body = desc.write_fn('MicroWave', rep) - assert path == ['mode', 'vs', '0'] - assert body['x.com.samsung.da.modes'] == ['MicroWave'] - assert desc.write_fn('AirFryer', rep) is None + rep = {"x.com.samsung.da.supportedModes": ["NoOperation", "MicroWave", "Autocook", "KeepWarm"]} + path, body = desc.write_fn("MicroWave", rep) + assert path == ["mode", "vs", "0"] + assert body["x.com.samsung.da.modes"] == ["MicroWave"] + assert desc.write_fn("AirFryer", rep) is None # --------------------------------------------------------------------------- # MICROWAVE_MODE — lamp/sound options-array writes # --------------------------------------------------------------------------- + def test_sound_write_is_single_token(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'sound') - rep = {'x.com.samsung.da.options': ['Sound_On']} - path, body = desc.write_fn('Off', rep) - assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['Sound_Off']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "sound") + rep = {"x.com.samsung.da.options": ["Sound_On"]} + path, body = desc.write_fn("Off", rep) + assert path == ["mode", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["Sound_Off"]} def test_lamp_gated_absent_when_no_lamp_option(): """Issue #121's combi dump has no 'Lamp_*' token at all -- unlike oven.py's lamp switch (assumed universal), this one self-gates off.""" - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') - rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp") + rep = {"x.com.samsung.da.options": ["DeviceType_MW7300B-/EU1", "Sound_Off"]} assert desc.exists_fn(rep, {}) is False def test_lamp_gated_present_when_lamp_option_reported(): """Issue #137's plain microwave reports 'Lamp_Off'.""" - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') - rep = {'x.com.samsung.da.options': ['Lamp_Off', 'Sound_On']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp") + rep = {"x.com.samsung.da.options": ["Lamp_Off", "Sound_On"]} assert desc.exists_fn(rep, {}) is True def test_lamp_write_is_single_token(): """issue #152: the device has never been observed accepting 'On' -- only 'High'/'Off' -- so the switch's "on" write uses 'High'.""" - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') - rep = {'x.com.samsung.da.options': ['Lamp_Off']} - path, body = desc.write_fn('On', rep) - assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['Lamp_High']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp") + rep = {"x.com.samsung.da.options": ["Lamp_Off"]} + path, body = desc.write_fn("On", rep) + assert path == ["mode", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["Lamp_High"]} def test_lamp_write_requires_existing_options(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') - assert desc.write_fn('On', {}) is None + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp") + assert desc.write_fn("On", {}) is None def test_lamp_reads_off_as_false(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') - assert desc.value_fn(['Lamp_Off']) is False + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp") + assert desc.value_fn(["Lamp_Off"]) is False def test_lamp_reads_any_non_off_level_as_true(): """issue #152's ME7500D reports 'Lamp_High', not the binary 'Lamp_On' #137's dump implied -- any non-Off/non-absent value must read as on, not just a literal 'On'.""" - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'lamp') - assert desc.value_fn(['Lamp_High']) is True + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "lamp") + assert desc.value_fn(["Lamp_High"]) is True # --------------------------------------------------------------------------- # MICROWAVE_MODE — filter_remind/remind_beep options-array writes (issue #181) # --------------------------------------------------------------------------- + def test_filter_remind_gated_absent_when_no_option(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') - rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "filter_remind") + rep = {"x.com.samsung.da.options": ["DeviceType_MW7300B-/EU1", "Sound_Off"]} assert desc.exists_fn(rep, {}) is False def test_filter_remind_gated_present_when_option_reported(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') - rep = {'x.com.samsung.da.options': ['FilterRemind_Off']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "filter_remind") + rep = {"x.com.samsung.da.options": ["FilterRemind_Off"]} assert desc.exists_fn(rep, {}) is True def test_filter_remind_reads_on_off(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') - assert desc.value_fn(['FilterRemind_On']) is True - assert desc.value_fn(['FilterRemind_Off']) is False + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "filter_remind") + assert desc.value_fn(["FilterRemind_On"]) is True + assert desc.value_fn(["FilterRemind_Off"]) is False def test_filter_remind_write_is_single_token(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') - rep = {'x.com.samsung.da.options': ['FilterRemind_Off']} - path, body = desc.write_fn('On', rep) - assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['FilterRemind_On']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "filter_remind") + rep = {"x.com.samsung.da.options": ["FilterRemind_Off"]} + path, body = desc.write_fn("On", rep) + assert path == ["mode", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["FilterRemind_On"]} def test_filter_remind_write_requires_existing_options(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'filter_remind') - assert desc.write_fn('On', {}) is None + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "filter_remind") + assert desc.write_fn("On", {}) is None def test_remind_beep_gated_absent_when_no_option(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') - rep = {'x.com.samsung.da.options': ['DeviceType_MW7300B-/EU1', 'Sound_Off']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "remind_beep") + rep = {"x.com.samsung.da.options": ["DeviceType_MW7300B-/EU1", "Sound_Off"]} assert desc.exists_fn(rep, {}) is False def test_remind_beep_gated_present_when_option_reported(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') - rep = {'x.com.samsung.da.options': ['RemindBeep_On']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "remind_beep") + rep = {"x.com.samsung.da.options": ["RemindBeep_On"]} assert desc.exists_fn(rep, {}) is True def test_remind_beep_reads_on_off(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') - assert desc.value_fn(['RemindBeep_On']) is True - assert desc.value_fn(['RemindBeep_Off']) is False + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "remind_beep") + assert desc.value_fn(["RemindBeep_On"]) is True + assert desc.value_fn(["RemindBeep_Off"]) is False def test_remind_beep_write_is_single_token(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') - rep = {'x.com.samsung.da.options': ['RemindBeep_On']} - path, body = desc.write_fn('Off', rep) - assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['RemindBeep_Off']} + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "remind_beep") + rep = {"x.com.samsung.da.options": ["RemindBeep_On"]} + path, body = desc.write_fn("Off", rep) + assert path == ["mode", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["RemindBeep_Off"]} def test_remind_beep_write_requires_existing_options(): - desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == 'remind_beep') - assert desc.write_fn('On', {}) is None + desc = next(e for e in microwave.MICROWAVE_MODE.entities if e.key == "remind_beep") + assert desc.write_fn("On", {}) is None diff --git a/tests/test_onebody_washer_subdevice.py b/tests/test_onebody_washer_subdevice.py index b4e643b..4305975 100644 --- a/tests/test_onebody_washer_subdevice.py +++ b/tests/test_onebody_washer_subdevice.py @@ -8,10 +8,11 @@ The fixture is a live capture from the reporting board (see its `x.com.samsung.da.multidevice` link in `/oic/res` -- no `subdeviceIdList` (Pattern B's signal), no `/device/` sibling (Pattern A's). """ + from tests.conftest import FakeCoapSession, _discover_full, _load_device_full -FIXTURE = 'washer_dryer_onebody_awm' -WASHER_UUID = '58b7d338-15c5-97d3-b562-000000000001' +FIXTURE = "washer_dryer_onebody_awm" +WASHER_UUID = "58b7d338-15c5-97d3-b562-000000000001" def _discover(): @@ -20,12 +21,12 @@ def _discover(): def test_washer_subdevice_materializes_from_oic_res_uuid_link(): - bound, materialized, skipped, full_resources, device_type_name = _discover() + _bound, materialized, _skipped, _full_resources, _device_type_name = _discover() assert [s.key for s in materialized] == [WASHER_UUID] sub = materialized[0] - assert sub.kind == 'prefixed' - assert sub.seed_path == (WASHER_UUID, 'device', '0') + assert sub.kind == "prefixed" + assert sub.seed_path == (WASHER_UUID, "device", "0") # Collection mode, not the issue-#205 flat fallback. assert sub.flat_hrefs == () @@ -42,16 +43,19 @@ def test_washer_subdevice_probe_only_fires_for_the_advertised_uuid(): probes: dict[str, bool] = {} sess = FakeCoapSession(seeds) candidates, _extra = enumerate_subdevices( - sess, resources, oic_res, probe_log=probes.__setitem__, + sess, + resources, + oic_res, + probe_log=probes.__setitem__, ) - assert probes[f'/{WASHER_UUID}/device/0'] is True - assert probes['/device/1'] is False - assert probes['/device/2'] is False + assert probes[f"/{WASHER_UUID}/device/0"] is True + assert probes["/device/1"] is False + assert probes["/device/2"] is False # No flat-fallback flood: the seed Collection answered, so no per-href # probes under the prefix beyond the seed itself. - prefixed_probes = [h for h in probes if h.startswith(f'/{WASHER_UUID}/')] - assert prefixed_probes == [f'/{WASHER_UUID}/device/0'] + prefixed_probes = [h for h in probes if h.startswith(f"/{WASHER_UUID}/")] + assert prefixed_probes == [f"/{WASHER_UUID}/device/0"] assert [c.key for c in candidates] == [WASHER_UUID] @@ -63,11 +67,11 @@ def test_washer_subdevice_binds_its_own_primary_entities(): sub = materialized[0] sub_keys = {b.desc.key for b in bound if b.subdevice.key == sub.key} - assert sub_keys, 'washer subdevice bound no entities at all' + assert sub_keys, "washer subdevice bound no entities at all" # Its own operational state and its own power switch/sensor -- the two # things SmartThings cloud splits this unit over. - assert any('machine_state' in k for k in sub_keys), sub_keys - assert any('power' in k for k in sub_keys), sub_keys + assert any("machine_state" in k for k in sub_keys), sub_keys + assert any("power" in k for k in sub_keys), sub_keys def test_master_dryer_entities_unchanged_by_washer_discovery(): @@ -78,15 +82,15 @@ def test_master_dryer_entities_unchanged_by_washer_discovery(): # Without any subdevices (oic_res withheld, seeds empty -> washer never # discovered). bound_solo, materialized_solo, _s, _f, _n = _discover_full( - resources, [], {}, + resources, + [], + {}, ) assert materialized_solo == [] solo_keys = {(b.href, b.desc.key) for b in bound_solo} bound, materialized, _s2, _f2, _n2 = _discover() sub = materialized[0] - main_keys = { - (b.href, b.desc.key) for b in bound if b.subdevice.key != sub.key - } + main_keys = {(b.href, b.desc.key) for b in bound if b.subdevice.key != sub.key} assert main_keys == solo_keys diff --git a/tests/test_operational_capability.py b/tests/test_operational_capability.py index 5f50535..9520d67 100644 --- a/tests/test_operational_capability.py +++ b/tests/test_operational_capability.py @@ -4,10 +4,10 @@ from custom_components.localthings.registry.capabilities.operational import OPER def test_machine_state_maps_samsung_to_ocf(): - ms = next(e for e in OPERATIONAL_STATE.entities if e.key == 'machine_state') - assert ms.value_fn('Run') == 'active' - assert ms.value_fn('Pause') == 'pause' - assert ms.value_fn('Ready') == 'idle' + ms = next(e for e in OPERATIONAL_STATE.entities if e.key == "machine_state") + assert ms.value_fn("Run") == "active" + assert ms.value_fn("Pause") == "pause" + assert ms.value_fn("Ready") == "idle" class TestProgressPercentage: @@ -16,13 +16,13 @@ class TestProgressPercentage: active state the same way `progress`/`cycle_active`/`finish_time` are.""" def test_zeroed_when_not_active(self): - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'progress_percentage') - rep = {'x.com.samsung.da.state': 'Ready', 'x.com.samsung.da.progressPercentage': '1'} + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage") + rep = {"x.com.samsung.da.state": "Ready", "x.com.samsung.da.progressPercentage": "1"} assert desc.rep_fn(rep) == 0 def test_passes_through_when_active(self): - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'progress_percentage') - rep = {'x.com.samsung.da.state': 'Run', 'x.com.samsung.da.progressPercentage': '42'} + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "progress_percentage") + rep = {"x.com.samsung.da.state": "Run", "x.com.samsung.da.progressPercentage": "42"} assert desc.rep_fn(rep) == 42 @@ -30,34 +30,34 @@ class TestCompletionMinutes: """Unit tests for completion_minutes entity.""" def test_completion_minutes_parsing(self): - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes') - + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "completion_minutes") + # 1 hour 25 mins 30 secs -> 85 mins + 1 sec ceiling = 86 mins - rep = {'x.com.samsung.da.remainingTime': '01:25:30'} + rep = {"x.com.samsung.da.remainingTime": "01:25:30"} assert desc.rep_fn(rep) == 86 # Exact minutes: 1 hour 30 mins 00 secs -> 90 mins - rep_exact = {'x.com.samsung.da.remainingTime': '01:30:00'} + rep_exact = {"x.com.samsung.da.remainingTime": "01:30:00"} assert desc.rep_fn(rep_exact) == 90 def test_completion_minutes_fallback_key(self): - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes') - rep = {'remainingTime': '00:45:00'} + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "completion_minutes") + rep = {"remainingTime": "00:45:00"} assert desc.rep_fn(rep) == 45 def test_completion_minutes_stale_finish_gated(self): """Firmware freezes remainingTime at '00:01:00' when progress reaches 'Finish'. Should return 0 to prevent stuck values.""" - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes') + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "completion_minutes") rep = { - 'x.com.samsung.da.progress': 'Finish', - 'x.com.samsung.da.remainingTime': '00:01:00', + "x.com.samsung.da.progress": "Finish", + "x.com.samsung.da.remainingTime": "00:01:00", } assert desc.rep_fn(rep) == 0 def test_completion_minutes_missing_or_invalid(self): - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'completion_minutes') - + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "completion_minutes") + rep = {} assert desc.rep_fn(rep) is None @@ -70,20 +70,20 @@ class TestFinishTime: the UI rounded them down to the minute for display.""" def test_seconds_and_microseconds_are_zeroed(self): - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time') + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "finish_time") rep = { - 'x.com.samsung.da.state': 'Run', - 'x.com.samsung.da.remainingTime': '00:29:00', + "x.com.samsung.da.state": "Run", + "x.com.samsung.da.remainingTime": "00:29:00", } result = desc.rep_fn(rep) assert result.second == 0 assert result.microsecond == 0 def test_stable_across_polls_within_same_minute(self): - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time') + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "finish_time") rep = { - 'x.com.samsung.da.state': 'Run', - 'x.com.samsung.da.remainingTime': '00:29:00', + "x.com.samsung.da.state": "Run", + "x.com.samsung.da.remainingTime": "00:29:00", } first = desc.rep_fn(rep) second = desc.rep_fn(rep) @@ -92,31 +92,43 @@ class TestFinishTime: class TestDelayFieldFallback: def test_reads_delay_end_time_when_delay_start_time_absent(self): - from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours') - rep = {'x.com.samsung.da.delayEndTime': '02:30:00'} + from custom_components.localthings.registry.capabilities.operational import ( + OPERATIONAL_STATE, + ) + + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "delay_start_hours") + rep = {"x.com.samsung.da.delayEndTime": "02:30:00"} assert desc.rep_fn(rep) == 2.5 def test_prefers_delay_start_time_when_both_present(self): - from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours') + from custom_components.localthings.registry.capabilities.operational import ( + OPERATIONAL_STATE, + ) + + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "delay_start_hours") rep = { - 'x.com.samsung.da.delayStartTime': '01:00:00', - 'x.com.samsung.da.delayEndTime': '02:00:00', + "x.com.samsung.da.delayStartTime": "01:00:00", + "x.com.samsung.da.delayEndTime": "02:00:00", } assert desc.rep_fn(rep) == 1.0 def test_write_targets_delay_end_time_when_that_is_what_device_reports(self): - from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours') - rep = {'x.com.samsung.da.delayEndTime': '00:00:00'} + from custom_components.localthings.registry.capabilities.operational import ( + OPERATIONAL_STATE, + ) + + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "delay_start_hours") + rep = {"x.com.samsung.da.delayEndTime": "00:00:00"} path, body = desc.write_fn(1.5, rep) - assert path == ['operational', 'state', 'vs', '0'] - assert body == {'x.com.samsung.da.delayEndTime': '1:30:00'} + assert path == ["operational", "state", "vs", "0"] + assert body == {"x.com.samsung.da.delayEndTime": "1:30:00"} def test_write_targets_delay_start_time_by_default(self): - from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE - desc = next(e for e in OPERATIONAL_STATE.entities if e.key == 'delay_start_hours') - rep = {'x.com.samsung.da.delayStartTime': '00:00:00'} - path, body = desc.write_fn(1.5, rep) - assert body == {'x.com.samsung.da.delayStartTime': '1:30:00'} \ No newline at end of file + from custom_components.localthings.registry.capabilities.operational import ( + OPERATIONAL_STATE, + ) + + desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "delay_start_hours") + rep = {"x.com.samsung.da.delayStartTime": "00:00:00"} + _path, body = desc.write_fn(1.5, rep) + assert body == {"x.com.samsung.da.delayStartTime": "1:30:00"} diff --git a/tests/test_oven_capabilities.py b/tests/test_oven_capabilities.py index 926fd76..800977e 100644 --- a/tests/test_oven_capabilities.py +++ b/tests/test_oven_capabilities.py @@ -1,24 +1,27 @@ """Unit tests for oven-family capabilities.""" + from custom_components.localthings.registry.by_type import for_device_by_model from custom_components.localthings.registry.capabilities import oven from custom_components.localthings.registry.discovery import discover - # --------------------------------------------------------------------------- # Device-type detection + full-dump coverage (issue #55) # --------------------------------------------------------------------------- + def test_oven_fixture_resolves_and_has_no_unbound_hrefs(): """The issue #55 dump previously came back device_type='unknown' with /connected/vs/0 unbound -- resolving via the '-OVEN-' modelNum token fallback must leave every href in the oven registry bound or ignored.""" from tests.conftest import _load_device - resources = _load_device('oven') - info = resources['/information/vs/0'] + + resources = _load_device("oven") + 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"] + ) assert reg is not None - assert reg.name == 'oven' + assert reg.name == "oven" unbound = [] discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) @@ -29,45 +32,53 @@ def test_oven_fixture_resolves_and_has_no_unbound_hrefs(): # OVEN_SETPOINT — NumberDesc with RMW write semantics # --------------------------------------------------------------------------- + def test_oven_setpoint_write_is_read_modify_write(): cap = oven.OVEN_SETPOINT desc = cap.entities[0] - rep = {'x.com.samsung.da.items': [ - {'x.com.samsung.da.id': 'Target', 'x.com.samsung.da.temperature': 0}]} + rep = { + "x.com.samsung.da.items": [ + {"x.com.samsung.da.id": "Target", "x.com.samsung.da.temperature": 0} + ] + } path, body = desc.write_fn(200, rep) - assert path[-1] == '0' # writes back to the resource + assert path[-1] == "0" # writes back to the resource # the produced body preserves the items-array shape the oven expects - assert 'x.com.samsung.da.items' in body or 'x.com.samsung.da.temperature' in str(body) + assert "x.com.samsung.da.items" in body or "x.com.samsung.da.temperature" in str(body) def test_oven_setpoint_rmw_preserves_other_item_fields(): """RMW must not drop sibling fields (e.g. x.com.samsung.da.current).""" desc = oven.OVEN_SETPOINT.entities[0] - rep = {'x.com.samsung.da.items': [{ - 'x.com.samsung.da.current': '180', - 'x.com.samsung.da.desired': '180', - }]} - path, body = desc.write_fn(200, rep) - item = body['x.com.samsung.da.items'][0] - assert item['x.com.samsung.da.desired'] == '200' + rep = { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.current": "180", + "x.com.samsung.da.desired": "180", + } + ] + } + _path, body = desc.write_fn(200, rep) + item = body["x.com.samsung.da.items"][0] + assert item["x.com.samsung.da.desired"] == "200" # sibling field preserved - assert item['x.com.samsung.da.current'] == '180' + assert item["x.com.samsung.da.current"] == "180" def test_oven_setpoint_clamps_to_step(): """Setpoint must be a multiple of SETPOINT_STEP_C.""" desc = oven.OVEN_SETPOINT.entities[0] - rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '0'}]} + rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "0"}]} _, body = desc.write_fn(202, rep) # 202 → nearest 5 = 200 - assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '200' + assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "200" def test_oven_setpoint_rejects_out_of_range(): desc = oven.OVEN_SETPOINT.entities[0] - rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.desired': '100'}]} - assert desc.write_fn(10, rep) is None # below min (30) - assert desc.write_fn(300, rep) is None # above max (270) + rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.desired": "100"}]} + assert desc.write_fn(10, rep) is None # below min (30) + assert desc.write_fn(300, rep) is None # above max (270) def test_oven_setpoint_rejects_missing_items(): @@ -81,11 +92,16 @@ def test_oven_setpoint_rejects_missing_items(): # to the Celsius defaults) # --------------------------------------------------------------------------- -def _fahrenheit_rep(desired='0'): - return {'x.com.samsung.da.items': [{ - 'x.com.samsung.da.desired': desired, - 'x.com.samsung.da.unit': 'Fahrenheit', - }]} + +def _fahrenheit_rep(desired="0"): + return { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.desired": desired, + "x.com.samsung.da.unit": "Fahrenheit", + } + ] + } def test_oven_setpoint_write_uses_fahrenheit_bounds(): @@ -94,19 +110,19 @@ def test_oven_setpoint_write_uses_fahrenheit_bounds(): # 350 is within F bounds (175-550) but above the C max (270) -- # confirms the write path isn't silently still clamping to Celsius. _, body = desc.write_fn(350, rep) - assert body['x.com.samsung.da.items'][0]['x.com.samsung.da.desired'] == '350' + assert body["x.com.samsung.da.items"][0]["x.com.samsung.da.desired"] == "350" def test_oven_setpoint_rejects_out_of_range_fahrenheit(): desc = oven.OVEN_SETPOINT.entities[0] rep = _fahrenheit_rep() - assert desc.write_fn(100, rep) is None # below F min (175) - assert desc.write_fn(600, rep) is None # above F max (550) + assert desc.write_fn(100, rep) is None # below F min (175) + assert desc.write_fn(600, rep) is None # above F max (550) def test_oven_setpoint_native_bounds_track_live_unit(): desc = oven.OVEN_SETPOINT.entities[0] - celsius_rep = {'x.com.samsung.da.items': [{'x.com.samsung.da.unit': 'Celsius'}]} + celsius_rep = {"x.com.samsung.da.items": [{"x.com.samsung.da.unit": "Celsius"}]} assert desc.native_min_fn(celsius_rep) == 30.0 assert desc.native_max_fn(celsius_rep) == 270.0 fahrenheit_rep = _fahrenheit_rep() @@ -119,6 +135,7 @@ def test_oven_setpoint_native_bounds_track_live_unit(): # OVEN_MODE — SelectDesc with non-empty options # --------------------------------------------------------------------------- + def test_oven_mode_options_nonempty(): desc = oven.OVEN_MODE.entities[0] assert callable(desc.options) @@ -134,23 +151,25 @@ def test_oven_mode_options_reads_live_supported_modes(): """issue #138: the device's own supportedModes list is used verbatim when present, instead of the static _OVEN_MODES guess.""" desc = oven.OVEN_MODE.entities[0] - resources = {'/mode/vs/0': { - 'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean'], - }} - assert desc.options(resources) == ['Bake', 'AirFryer', 'SelfClean'] + resources = { + "/mode/vs/0": { + "x.com.samsung.da.supportedModes": ["Bake", "AirFryer", "SelfClean"], + } + } + assert desc.options(resources) == ["Bake", "AirFryer", "SelfClean"] def test_oven_mode_write_round_trips(): desc = oven.OVEN_MODE.entities[0] - valid_mode = desc.options({})[1] # e.g. 'Bake' + valid_mode = desc.options({})[1] # e.g. 'Bake' path, body = desc.write_fn(valid_mode, {}) - assert path == ['mode', 'vs', '0'] - assert body['x.com.samsung.da.modes'] == [valid_mode] + assert path == ["mode", "vs", "0"] + assert body["x.com.samsung.da.modes"] == [valid_mode] def test_oven_mode_rejects_unknown(): desc = oven.OVEN_MODE.entities[0] - assert desc.write_fn('SpaghettiMode', {}) is None + assert desc.write_fn("SpaghettiMode", {}) is None def test_oven_mode_write_validates_against_live_supported_modes(): @@ -159,11 +178,11 @@ def test_oven_mode_write_validates_against_live_supported_modes(): are accepted, and a mode outside the device's own list is rejected even if some other model's static guess would have allowed it.""" desc = oven.OVEN_MODE.entities[0] - rep = {'x.com.samsung.da.supportedModes': ['Bake', 'AirFryer', 'SelfClean']} - path, body = desc.write_fn('AirFryer', rep) - assert path == ['mode', 'vs', '0'] - assert body['x.com.samsung.da.modes'] == ['AirFryer'] - assert desc.write_fn('FrozenPizzaPlus', rep) is None + rep = {"x.com.samsung.da.supportedModes": ["Bake", "AirFryer", "SelfClean"]} + path, body = desc.write_fn("AirFryer", rep) + assert path == ["mode", "vs", "0"] + assert body["x.com.samsung.da.modes"] == ["AirFryer"] + assert desc.write_fn("FrozenPizzaPlus", rep) is None # --------------------------------------------------------------------------- @@ -173,54 +192,59 @@ def test_oven_mode_write_validates_against_live_supported_modes(): # merges the result into the array itself. No read-modify-write needed. # --------------------------------------------------------------------------- + def _mode_rep(*extra_opts): - return {'x.com.samsung.da.options': [ - 'UpperLamp_Off', 'Sound_On', 'fastpreheat_Off', *extra_opts, - ]} + return { + "x.com.samsung.da.options": [ + "UpperLamp_Off", + "Sound_On", + "fastpreheat_Off", + *extra_opts, + ] + } def test_lamp_write_is_single_token(): - desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'lamp') - path, body = desc.write_fn('On', _mode_rep()) - assert path == ['mode', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['UpperLamp_On']} + desc = next(e for e in oven.OVEN_MODE.entities if e.key == "lamp") + path, body = desc.write_fn("On", _mode_rep()) + assert path == ["mode", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["UpperLamp_On"]} def test_lamp_write_requires_existing_options(): - desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'lamp') - assert desc.write_fn('On', {}) is None + desc = next(e for e in oven.OVEN_MODE.entities if e.key == "lamp") + assert desc.write_fn("On", {}) is None def test_sound_write_is_single_token(): - desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'sound') - path, body = desc.write_fn('Off', _mode_rep()) - assert body == {'x.com.samsung.da.options': ['Sound_Off']} + desc = next(e for e in oven.OVEN_MODE.entities if e.key == "sound") + _path, body = desc.write_fn("Off", _mode_rep()) + assert body == {"x.com.samsung.da.options": ["Sound_Off"]} def test_natural_steam_write_is_single_token(): """NaturalSteam's slot may be absent from the live rep until first write -- the single-token write covers both the insert and replace case identically, since the device merges by prefix either way.""" - desc = next(e for e in oven.OVEN_MODE.entities if e.key == 'natural_steam') - path, body = desc.write_fn('On', _mode_rep()) # no NaturalSteam_* in rep - assert body == {'x.com.samsung.da.options': ['NaturalSteam_On']} + desc = next(e for e in oven.OVEN_MODE.entities if e.key == "natural_steam") + _path, body = desc.write_fn("On", _mode_rep()) # no NaturalSteam_* in rep + assert body == {"x.com.samsung.da.options": ["NaturalSteam_On"]} # --------------------------------------------------------------------------- # OVEN_OPERATIONAL_STATE — cycle_active BinarySensorDesc # --------------------------------------------------------------------------- + def test_cycle_active_true_when_running(): - desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities - if e.key == 'cycle_active') - assert desc.value_fn('Run') is True - assert desc.value_fn('Running') is True + desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities if e.key == "cycle_active") + assert desc.value_fn("Run") is True + assert desc.value_fn("Running") is True def test_cycle_active_false_when_idle(): - desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities - if e.key == 'cycle_active') - assert desc.value_fn('Ready') is False + desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities if e.key == "cycle_active") + assert desc.value_fn("Ready") is False assert desc.value_fn(None) is False @@ -228,18 +252,16 @@ def test_cycle_active_false_when_idle(): # OVEN_OPERATIONAL_STATE — cook_time NumberDesc # --------------------------------------------------------------------------- + def test_cook_time_write_produces_hms(): - desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities - if e.key == 'cook_time') + desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities if e.key == "cook_time") path, body = desc.write_fn(90, {}) - assert path == ['operational', 'state', 'vs', '0'] - assert body['x.com.samsung.da.operationTime'] == '01:30:00' - assert body['x.com.samsung.da.remainingTime'] == '01:30:00' + assert path == ["operational", "state", "vs", "0"] + assert body["x.com.samsung.da.operationTime"] == "01:30:00" + assert body["x.com.samsung.da.remainingTime"] == "01:30:00" def test_cook_time_rejects_out_of_range(): - desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities - if e.key == 'cook_time') + desc = next(e for e in oven.OVEN_OPERATIONAL_STATE.entities if e.key == "cook_time") assert desc.write_fn(-1, {}) is None assert desc.write_fn(1440, {}) is None - diff --git a/tests/test_range_capabilities.py b/tests/test_range_capabilities.py index 82c141d..1bd3633 100644 --- a/tests/test_range_capabilities.py +++ b/tests/test_range_capabilities.py @@ -1,17 +1,18 @@ """Tests for range/cooktop-oven combo support (issue #44).""" + 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 range as range_caps from custom_components.localthings.registry.discovery import discover - from tests.conftest import _load_device def _range(): - resources = _load_device('range') - info = resources['/information/vs/0'] + resources = _load_device("range") + 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 +25,7 @@ def _state(): def test_range_model_resolves_to_range_registry(): reg, _ = _range() - assert reg is not None and reg.name == 'range' + assert reg is not None and reg.name == "range" def test_no_unbound_hrefs(): @@ -39,11 +40,21 @@ def test_no_unbound_hrefs(): def test_expected_entities_present(): state = _state() for key in ( - 'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode', - 'machine_state', 'door_open', 'cloud_connected', 'cooktop_state', - 'cooktop_safety_shutoff_enabled', - 'burner_0_power_level', 'burner_0_state', 'burner_0_hot_surface', - 'burner_3_power_level', 'burner_3_state', 'burner_3_hot_surface', + "power_switch", + "oven_setpoint", + "current_temp_c", + "oven_mode", + "machine_state", + "door_open", + "cloud_connected", + "cooktop_state", + "cooktop_safety_shutoff_enabled", + "burner_0_power_level", + "burner_0_state", + "burner_0_hot_surface", + "burner_3_power_level", + "burner_3_state", + "burner_3_hot_surface", ): assert key in state, key @@ -52,71 +63,69 @@ def test_unreported_burners_gated_out(): """Dump reports numberOfBurners=4 (indices 0-3) -- burner slots 4/5 of MAX_BURNERS must not appear as entities.""" state = _state() - for key in ('burner_4_power_level', 'burner_5_power_level'): + for key in ("burner_4_power_level", "burner_5_power_level"): assert key not in state, key def test_burner_power_level_write_is_read_modify_write(): - desc = next(e for e in range_caps.COOKTOP_STATUS.entities - if e.key == 'burner_1_power_level') - rep = {'burnerList': [ - {'burnerNumber': 0, 'powerLevel': '3'}, - {'burnerNumber': 1, 'powerLevel': '0'}, - ]} - path, body = desc.write_fn('boost', rep) - assert path == ['cooktop', 'status', 'vs', '0'] - burners = {b['burnerNumber']: b['powerLevel'] for b in body['burnerList']} - assert burners[1] == 'boost' - assert burners[0] == '3' # sibling burner untouched + desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == "burner_1_power_level") + rep = { + "burnerList": [ + {"burnerNumber": 0, "powerLevel": "3"}, + {"burnerNumber": 1, "powerLevel": "0"}, + ] + } + path, body = desc.write_fn("boost", rep) + assert path == ["cooktop", "status", "vs", "0"] + burners = {b["burnerNumber"]: b["powerLevel"] for b in body["burnerList"]} + assert burners[1] == "boost" + assert burners[0] == "3" # sibling burner untouched def test_burner_power_level_write_rejects_missing_burner(): - desc = next(e for e in range_caps.COOKTOP_STATUS.entities - if e.key == 'burner_2_power_level') - rep = {'burnerList': [{'burnerNumber': 0, 'powerLevel': '0'}]} - assert desc.write_fn('5', rep) is None + desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == "burner_2_power_level") + rep = {"burnerList": [{"burnerNumber": 0, "powerLevel": "0"}]} + assert desc.write_fn("5", rep) is None def test_burner_hot_surface_true_when_not_normal(): - desc = next(e for e in range_caps.COOKTOP_STATUS.entities - if e.key == 'burner_0_hot_surface') - assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'hot'}]) is True - assert desc.value_fn([{'burnerNumber': 0, 'hotSurfaceState': 'normal'}]) is False + desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == "burner_0_hot_surface") + assert desc.value_fn([{"burnerNumber": 0, "hotSurfaceState": "hot"}]) is True + assert desc.value_fn([{"burnerNumber": 0, "hotSurfaceState": "normal"}]) is False -def test_burner_pan_detected(): # issue #86 - desc = next(e for e in range_caps.COOKTOP_STATUS.entities - if e.key == 'burner_0_pan_detected') - assert desc.value_fn([{'burnerNumber': 0, 'panDetection': True}]) is True - assert desc.value_fn([{'burnerNumber': 0, 'panDetection': False}]) is False - assert desc.value_fn([{'burnerNumber': 1, 'panDetection': True}]) is False # different burner +def test_burner_pan_detected(): # issue #86 + desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == "burner_0_pan_detected") + assert desc.value_fn([{"burnerNumber": 0, "panDetection": True}]) is True + assert desc.value_fn([{"burnerNumber": 0, "panDetection": False}]) is False + assert desc.value_fn([{"burnerNumber": 1, "panDetection": True}]) is False # different burner -def test_cooktop_power_is_read_only(): # issue #86 - desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == 'cooktop_power') - assert desc.value_fn('On') is True - assert desc.value_fn('Off') is False - assert not hasattr(desc, 'write_fn') # BinarySensorDesc has no write path at all +def test_cooktop_power_is_read_only(): # issue #86 + desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == "cooktop_power") + assert desc.value_fn("On") is True + assert desc.value_fn("Off") is False + assert not hasattr(desc, "write_fn") # BinarySensorDesc has no write path at all -def test_cooktop_child_lock_write(): # issue #86 - desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == 'cooktop_child_lock') - assert desc.value_fn('on') is True - assert desc.value_fn('off') is False - path, body = desc.write_fn('On', {}) - assert path == ['cooktop', 'status', 'vs', '0'] - assert body == {'childLock': 'on'} - assert desc.write_fn('Bogus', {}) is None +def test_cooktop_child_lock_write(): # issue #86 + desc = next(e for e in range_caps.COOKTOP_STATUS.entities if e.key == "cooktop_child_lock") + assert desc.value_fn("on") is True + assert desc.value_fn("off") is False + path, body = desc.write_fn("On", {}) + assert path == ["cooktop", "status", "vs", "0"] + assert body == {"childLock": "on"} + assert desc.write_fn("Bogus", {}) is None -def test_probe_status_temperature_unit_reads_live_field(): # issue #86 - desc = next(e for e in range_caps.PROBE_STATUS.entities if e.key == 'probe_temperature') - assert desc.unit_fn({'temperatureUnit': 'C'}) == '°C' - assert desc.unit_fn({'temperatureUnit': 'F'}) == '°F' - assert desc.unit_fn({}) == '°C' +def test_probe_status_temperature_unit_reads_live_field(): # issue #86 + desc = next(e for e in range_caps.PROBE_STATUS.entities if e.key == "probe_temperature") + assert desc.unit_fn({"temperatureUnit": "C"}) == "°C" + assert desc.unit_fn({"temperatureUnit": "F"}) == "°F" + assert desc.unit_fn({}) == "°C" -def test_probe_status_connected_value_fn(): # issue #86 - desc = next(e for e in range_caps.PROBE_STATUS.entities if e.key == 'probe_connected') - assert desc.value_fn('Connected') is True - assert desc.value_fn('Disconnected') is False +def test_probe_status_connected_value_fn(): # issue #86 + desc = next(e for e in range_caps.PROBE_STATUS.entities if e.key == "probe_connected") + assert desc.value_fn("Connected") is True + assert desc.value_fn("Disconnected") is False diff --git a/tests/test_range_hood_capabilities.py b/tests/test_range_hood_capabilities.py index c31badb..110d713 100644 --- a/tests/test_range_hood_capabilities.py +++ b/tests/test_range_hood_capabilities.py @@ -4,16 +4,22 @@ 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 range_hood from custom_components.localthings.registry.discovery import discover -from custom_components.localthings.registry.entities import FanDesc +from custom_components.localthings.registry.entities import ( + BinarySensorDesc, + ButtonDesc, + FanDesc, + SelectDesc, + SwitchDesc, +) from tests.conftest import _load_device def _hood(): - resources = _load_device('range_hood') - info = resources['/information/vs/0'] + resources = _load_device("range_hood") + info = resources["/information/vs/0"] registry = 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 registry, resources @@ -21,7 +27,9 @@ def _hood(): def _state(): registry, resources = _hood() bound = discover( - resources, registry.capabilities, registry.pattern_capabilities, + resources, + registry.capabilities, + registry.pattern_capabilities, ) return flatten(bound, resources) @@ -29,7 +37,7 @@ def _state(): def test_ahd_model_resolves_to_range_hood_registry(): registry, _ = _hood() assert registry is not None - assert registry.name == 'range_hood' + assert registry.name == "range_hood" def test_range_hood_has_no_unbound_hrefs(): @@ -46,99 +54,116 @@ def test_range_hood_has_no_unbound_hrefs(): def test_range_hood_fixture_values(): state = _state() - assert state['fan'] == '14' - assert state['alarm_code'] == 'none' - assert state['automatic_operation'] is False - assert state['lamp'] is False - assert state['lamp_brightness'] == '2' - assert state['hood_filter_usage'] == 100 - assert state['hood_filter_status'] == 'wash' - assert state['energy_kwh'] == 165.62 - assert state['clean_level'] == 2 - assert state['dust'] == 18 - assert state['fine_dust'] == 14 - assert state['super_fine_dust'] == 9 - assert state['periodic_air_sensing'] is False - assert state['air_sensing_state'] == 'NonProcessing' - assert state['last_air_sensing_level'] == 'Kr2' - assert state['after_run_active'] is False - assert state['after_run_progress'] == 0 + assert state["fan"] == "14" + assert state["alarm_code"] == "none" + assert state["automatic_operation"] is False + assert state["lamp"] is False + assert state["lamp_brightness"] == "2" + assert state["hood_filter_usage"] == 100 + assert state["hood_filter_status"] == "wash" + assert state["energy_kwh"] == 165.62 + assert state["clean_level"] == 2 + assert state["dust"] == 18 + assert state["fine_dust"] == 14 + assert state["super_fine_dust"] == 9 + assert state["periodic_air_sensing"] is False + assert state["air_sensing_state"] == "NonProcessing" + assert state["last_air_sensing_level"] == "Kr2" + assert state["after_run_active"] is False + assert state["after_run_progress"] == 0 def test_one_composite_fan_is_bound(): registry, resources = _hood() bound = discover( - resources, registry.capabilities, registry.pattern_capabilities, + resources, + registry.capabilities, + registry.pattern_capabilities, ) fans = [entity for entity in bound if isinstance(entity.desc, FanDesc)] assert len(fans) == 1 - assert fans[0].href == '/hood/fanspeed/vs/0' + assert fans[0].href == "/hood/fanspeed/vs/0" def test_fan_write_contract(): - desc = range_hood.HOOD_FAN.entities[0] + desc = next(e for e in range_hood.HOOD_FAN.entities if isinstance(e, FanDesc)) rep = { - 'x.com.samsung.da.hood.supportedFanSpeed': ['14', '15', '16', '17'], + "x.com.samsung.da.hood.supportedFanSpeed": ["14", "15", "16", "17"], } - assert desc.write_fn(('power', True), rep) == ( - ['power', '0'], {'value': True}, + assert desc.write_fn is not None + assert desc.write_fn(("power", True), rep) == ( + ["power", "0"], + {"value": True}, ) - assert desc.write_fn(('power', False), rep) == ( - ['power', '0'], {'value': False}, + assert desc.write_fn(("power", False), rep) == ( + ["power", "0"], + {"value": False}, ) - assert desc.write_fn(('power', True, '/power/vs/0'), rep) == ( - ['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'}, + assert desc.write_fn(("power", True, "/power/vs/0"), rep) == ( + ["power", "vs", "0"], + {"x.com.samsung.da.power": "On"}, ) - assert desc.write_fn(('power', False, '/power/vs/0'), rep) == ( - ['power', 'vs', '0'], {'x.com.samsung.da.power': 'Off'}, + assert desc.write_fn(("power", False, "/power/vs/0"), rep) == ( + ["power", "vs", "0"], + {"x.com.samsung.da.power": "Off"}, ) - assert desc.write_fn(('power', True, '/not-a-power-resource'), rep) is None - assert desc.write_fn(('speed', '16'), rep) == ( - ['hood', 'fanspeed', 'vs', '0'], - {'x.com.samsung.da.hood.fanSpeed': '16'}, + assert desc.write_fn(("power", True, "/not-a-power-resource"), rep) is None + assert desc.write_fn(("speed", "16"), rep) == ( + ["hood", "fanspeed", "vs", "0"], + {"x.com.samsung.da.hood.fanSpeed": "16"}, ) - assert desc.write_fn(('speed', '99'), rep) is None + assert desc.write_fn(("speed", "99"), rep) is None rep_fallback = { - 'x.com.samsung.da.hood.settableMinFanSpeed': '0', - 'x.com.samsung.da.hood.settableMaxFanSpeed': '3', + "x.com.samsung.da.hood.settableMinFanSpeed": "0", + "x.com.samsung.da.hood.settableMaxFanSpeed": "3", } - assert desc.write_fn(('speed', '2'), rep_fallback) == ( - ['hood', 'fanspeed', 'vs', '0'], - {'x.com.samsung.da.hood.fanSpeed': '2'}, + assert desc.write_fn(("speed", "2"), rep_fallback) == ( + ["hood", "fanspeed", "vs", "0"], + {"x.com.samsung.da.hood.fanSpeed": "2"}, ) - assert desc.write_fn(('speed', '4'), rep_fallback) is None + assert desc.write_fn(("speed", "4"), rep_fallback) is None def test_lamp_write_contract(): - lamp, brightness = range_hood.HOOD_LAMP.entities - rep = {'x.com.samsung.lamp.range': ['1', '2']} - assert lamp.write_fn('On', rep) == ( - ['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'On'}, + lamp = next(e for e in range_hood.HOOD_LAMP.entities if isinstance(e, SwitchDesc)) + brightness = next(e for e in range_hood.HOOD_LAMP.entities if isinstance(e, SelectDesc)) + rep = {"x.com.samsung.lamp.range": ["1", "2"]} + assert lamp.write_fn is not None + assert brightness.write_fn is not None + assert lamp.write_fn("On", rep) == ( + ["hood", "lamp", "vs", "0"], + {"x.com.samsung.lamp.power": "On"}, ) - assert lamp.write_fn('Off', rep) == ( - ['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.power': 'Off'}, + assert lamp.write_fn("Off", rep) == ( + ["hood", "lamp", "vs", "0"], + {"x.com.samsung.lamp.power": "Off"}, ) - assert brightness.write_fn('1', rep) == ( - ['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '1'}, + assert brightness.write_fn("1", rep) == ( + ["hood", "lamp", "vs", "0"], + {"x.com.samsung.lamp.current": "1"}, ) - assert brightness.write_fn('2', rep) == ( - ['hood', 'lamp', 'vs', '0'], {'x.com.samsung.lamp.current': '2'}, + assert brightness.write_fn("2", rep) == ( + ["hood", "lamp", "vs", "0"], + {"x.com.samsung.lamp.current": "2"}, ) - assert brightness.write_fn('Unsupported', rep) is None + assert brightness.write_fn("Unsupported", rep) is None def test_after_run_cancel_write_contract(): """issue #147: /afterrun/vs/0 was previously unbound. runningCancel's only observed value is the command name itself, so cancel is modeled as a ButtonDesc that writes that value back.""" - active, progress, cancel = range_hood.AFTER_RUN.entities - assert cancel.write_fn('Cancel', {}) == ( - ['afterrun', 'vs', '0'], {'x.com.samsung.da.runningCancel': 'Cancel'}, + cancel = next(e for e in range_hood.AFTER_RUN.entities if isinstance(e, ButtonDesc)) + assert cancel.write_fn is not None + assert cancel.write_fn("Cancel", {}) == ( + ["afterrun", "vs", "0"], + {"x.com.samsung.da.runningCancel": "Cancel"}, ) def test_after_run_active_reads_on_off(): - active, progress, cancel = range_hood.AFTER_RUN.entities - assert active.value_fn('On') is True - assert active.value_fn('Off') is False + active = next(e for e in range_hood.AFTER_RUN.entities if isinstance(e, BinarySensorDesc)) + assert active.value_fn is not None + assert active.value_fn("On") is True + assert active.value_fn("Off") is False diff --git a/tests/test_range_hood_fan.py b/tests/test_range_hood_fan.py index 2e03392..9043589 100644 --- a/tests/test_range_hood_fan.py +++ b/tests/test_range_hood_fan.py @@ -1,5 +1,7 @@ """HA fan-entity mapping tests for the range hood.""" +from typing import ClassVar + from custom_components.localthings.fan import LocalThingsRangeHoodFan from custom_components.localthings.registry.by_type import microwave, range_hood from custom_components.localthings.registry.discovery import discover @@ -8,9 +10,9 @@ from tests.conftest import _load_device class _FakeCoordinator: - device_serial = 'TEST-HOOD-SERIAL' - device_info = {} - data = {} + device_serial = "TEST-HOOD-SERIAL" + device_info: ClassVar[dict] = {} + data: ClassVar[dict] = {} def __init__(self, resources): self.last_resources = resources @@ -37,7 +39,8 @@ def _entity(resources, coordinator=None, registry=range_hood.REGISTRY): ) fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc)) return LocalThingsRangeHoodFan( - coordinator or _FakeCoordinator(resources), fan_bound, + coordinator or _FakeCoordinator(resources), + fan_bound, ) @@ -46,56 +49,48 @@ def _microwave_entity(resources, coordinator=None): def test_power_off_maps_to_zero_percent_and_four_retained_speeds(): - entity = _entity(_load_device('range_hood')) + entity = _entity(_load_device("range_hood")) assert entity.is_on is False assert entity.speed_count == 4 assert entity.percentage == 0 def test_active_codes_map_to_ordered_percentages(): - resources = _load_device('range_hood') - resources['/power/0']['value'] = True + resources = _load_device("range_hood") + resources["/power/0"]["value"] = True - resources['/hood/fanspeed/vs/0'][ - 'x.com.samsung.da.hood.fanSpeed' - ] = '14' + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "14" assert _entity(resources).percentage == 25 - resources['/hood/fanspeed/vs/0'][ - 'x.com.samsung.da.hood.fanSpeed' - ] = '15' + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "15" assert _entity(resources).percentage == 50 - resources['/hood/fanspeed/vs/0'][ - 'x.com.samsung.da.hood.fanSpeed' - ] = '16' + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "16" assert _entity(resources).percentage == 75 - resources['/hood/fanspeed/vs/0'][ - 'x.com.samsung.da.hood.fanSpeed' - ] = '17' + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "17" assert _entity(resources).percentage == 100 async def test_power_write_prefers_standard_resource_when_both_exist(): - resources = _load_device('range_hood') + resources = _load_device("range_hood") 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('range_hood') - resources.pop('/power/0') + resources = _load_device("range_hood") + 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") # --------------------------------------------------------------------------- @@ -104,19 +99,20 @@ async def test_power_write_falls_back_to_vendor_resource(): # itself the off state. # --------------------------------------------------------------------------- + def test_standalone_hood_has_separate_power(): """Explicit converse of the microwave case below: guards the discriminator itself, not just its downstream effects, so a future change to it fails loudly here instead of only via behavioral drift.""" - entity = _entity(_load_device('range_hood')) + entity = _entity(_load_device("range_hood")) assert entity._has_separate_power() is True assert entity._speed_zero_is_off() is False def test_microwave_vent_fan_has_no_separate_power_resource(): - resources = _load_device('microwave_me7500d') - assert '/power/0' not in resources - assert '/power/vs/0' not in resources + resources = _load_device("microwave_me7500d") + assert "/power/0" not in resources + assert "/power/vs/0" not in resources entity = _microwave_entity(resources) assert entity._has_separate_power() is False assert entity._speed_zero_is_off() is True @@ -129,9 +125,9 @@ async def test_combi_microwave_with_cavity_power_still_treats_zero_speed_as_fan_ _speed_zero_is_off must key off the hood resource itself, not merely "some power resource exists on this device", so turning the fan off writes fan speed rather than the shared cavity power resource.""" - resources = _load_device('microwave_me7500d') - resources['/power/0'] = {'value': True} - resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + resources = _load_device("microwave_me7500d") + resources["/power/0"] = {"value": True} + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "2" coordinator = _FakeCoordinator(resources) entity = _microwave_entity(resources, coordinator) @@ -140,13 +136,13 @@ async def test_combi_microwave_with_cavity_power_still_treats_zero_speed_as_fan_ await entity.async_turn_off() - assert coordinator.commands[-1][1] == ('speed', '0') + assert coordinator.commands[-1][1] == ("speed", "0") def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes(): """supportedFanSpeed reports ['0'..'4'] with 0 meaning off -- unlike the standalone hood's codes, which never include an off entry.""" - resources = _load_device('microwave_me7500d') + resources = _load_device("microwave_me7500d") entity = _microwave_entity(resources) assert entity.is_on is False assert entity.percentage == 0 @@ -154,59 +150,59 @@ def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes(): def test_microwave_vent_fan_nonzero_speed_reads_as_on(): - resources = _load_device('microwave_me7500d') - resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + resources = _load_device("microwave_me7500d") + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "2" entity = _microwave_entity(resources) assert entity.is_on is True assert entity.percentage == 50 # 2nd of ['1', '2', '3', '4'] async def test_microwave_vent_fan_turn_off_writes_zero_speed_not_power(): - resources = _load_device('microwave_me7500d') - resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + resources = _load_device("microwave_me7500d") + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "2" coordinator = _FakeCoordinator(resources) entity = _microwave_entity(resources, coordinator) await entity.async_turn_off() - assert coordinator.commands[-1][1] == ('speed', '0') + assert coordinator.commands[-1][1] == ("speed", "0") async def test_microwave_vent_fan_turn_on_without_percentage_picks_lowest_speed(): - resources = _load_device('microwave_me7500d') + resources = _load_device("microwave_me7500d") coordinator = _FakeCoordinator(resources) entity = _microwave_entity(resources, coordinator) await entity.async_turn_on() - assert coordinator.commands[-1][1] == ('speed', '1') + assert coordinator.commands[-1][1] == ("speed", "1") async def test_microwave_vent_fan_set_percentage_writes_speed_only(): - resources = _load_device('microwave_me7500d') + resources = _load_device("microwave_me7500d") coordinator = _FakeCoordinator(resources) entity = _microwave_entity(resources, coordinator) await entity.async_set_percentage(100) - assert coordinator.commands == [(entity._bound, ('speed', '4'))] + assert coordinator.commands == [(entity._bound, ("speed", "4"))] async def test_microwave_vent_fan_turn_on_with_percentage_writes_speed_directly(): - resources = _load_device('microwave_me7500d') + resources = _load_device("microwave_me7500d") coordinator = _FakeCoordinator(resources) entity = _microwave_entity(resources, coordinator) await entity.async_turn_on(percentage=75) - assert coordinator.commands == [(entity._bound, ('speed', '3'))] + assert coordinator.commands == [(entity._bound, ("speed", "3"))] async def test_microwave_vent_fan_turn_on_without_percentage_when_already_on_is_a_noop(): """A scene or automation calling fan.turn_on on an already-running vent fan must not reset it to the lowest speed.""" - resources = _load_device('microwave_me7500d') - resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '3' + resources = _load_device("microwave_me7500d") + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "3" coordinator = _FakeCoordinator(resources) entity = _microwave_entity(resources, coordinator) @@ -216,27 +212,26 @@ async def test_microwave_vent_fan_turn_on_without_percentage_when_already_on_is_ async def test_microwave_vent_fan_set_percentage_zero_turns_off(): - resources = _load_device('microwave_me7500d') - resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2' + resources = _load_device("microwave_me7500d") + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.fanSpeed"] = "2" coordinator = _FakeCoordinator(resources) entity = _microwave_entity(resources, coordinator) await entity.async_set_percentage(0) - assert coordinator.commands[-1][1] == ('speed', '0') + assert coordinator.commands[-1][1] == ("speed", "0") def test_microwave_vent_fan_missing_supported_fan_speed_falls_back_to_settable_min_max(): """Hardware omitting supportedFanSpeed (e.g. ME8000T / issue #172) falls back to building speed codes from settableMinFanSpeed ('0') and settableMaxFanSpeed ('3').""" - resources = _load_device('microwave_me7500d') - resources['/hood/fanspeed/vs/0'].pop('x.com.samsung.da.hood.supportedFanSpeed', None) - resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.settableMinFanSpeed'] = '0' - resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.settableMaxFanSpeed'] = '3' + resources = _load_device("microwave_me7500d") + resources["/hood/fanspeed/vs/0"].pop("x.com.samsung.da.hood.supportedFanSpeed", None) + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.settableMinFanSpeed"] = "0" + resources["/hood/fanspeed/vs/0"]["x.com.samsung.da.hood.settableMaxFanSpeed"] = "3" entity = _microwave_entity(resources) - assert entity._all_speed_codes() == ['0', '1', '2', '3'] - assert entity._active_speed_codes() == ['1', '2', '3'] + assert entity._all_speed_codes() == ["0", "1", "2", "3"] + assert entity._active_speed_codes() == ["1", "2", "3"] assert entity.speed_count == 3 assert entity.supported_features & 1 # FanEntityFeature.SET_SPEED is present - diff --git a/tests/test_range_ne63a6511_capabilities.py b/tests/test_range_ne63a6511_capabilities.py index c52da95..436bf4e 100644 --- a/tests/test_range_ne63a6511_capabilities.py +++ b/tests/test_range_ne63a6511_capabilities.py @@ -4,16 +4,16 @@ range_no_info detection path still resolves this model with zero unbound hrefs, plus oven.OVEN_MODE reading this dump's ConvectionRoast/KeepWarm/ BreadProof/AirFryer/Dehydrate/SelfClean/SteamClean modes live from its own /mode/vs/0 supportedModes rather than needing them hardcoded.""" + from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import for_device_by_resources from custom_components.localthings.registry.capabilities import oven from custom_components.localthings.registry.discovery import discover - from tests.conftest import _load_device def _range(): - resources = _load_device('range_ne63a6511') + resources = _load_device("range_ne63a6511") reg = for_device_by_resources(resources) return reg, resources @@ -26,7 +26,7 @@ def _state(): def test_resolves_to_range_registry(): reg, _ = _range() - assert reg is not None and reg.name == 'range' + assert reg is not None and reg.name == "range" def test_no_unbound_hrefs(): @@ -39,9 +39,16 @@ def test_no_unbound_hrefs(): def test_expected_entities_present(): state = _state() for key in ( - 'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode', - 'machine_state', 'door_open', 'cloud_connected', 'child_lock', - 'cooktop_running_state', 'warming_center_state', + "power_switch", + "oven_setpoint", + "current_temp_c", + "oven_mode", + "machine_state", + "door_open", + "cloud_connected", + "child_lock", + "cooktop_running_state", + "warming_center_state", ): assert key in state, key @@ -56,13 +63,18 @@ def test_oven_mode_accepts_this_devices_supported_modes(): coordinator.py's async_send_command), so this passes without needing those modes added to any Python list.""" _, resources = _range() - live_rep = resources['/mode/vs/0'] + live_rep = resources["/mode/vs/0"] desc = oven.OVEN_MODE.entities[0] - assert desc.options(resources) == live_rep['x.com.samsung.da.supportedModes'] + assert desc.options(resources) == live_rep["x.com.samsung.da.supportedModes"] for mode in ( - 'ConvectionRoast', 'KeepWarm', 'BreadProof', 'AirFryer', - 'Dehydrate', 'SelfClean', 'SteamClean', + "ConvectionRoast", + "KeepWarm", + "BreadProof", + "AirFryer", + "Dehydrate", + "SelfClean", + "SteamClean", ): path, body = desc.write_fn(mode, live_rep) - assert path == ['mode', 'vs', '0'] - assert body['x.com.samsung.da.modes'] == [mode] + assert path == ["mode", "vs", "0"] + assert body["x.com.samsung.da.modes"] == [mode] diff --git a/tests/test_range_ne6516a_capabilities.py b/tests/test_range_ne6516a_capabilities.py index c59c1cb..5090735 100644 --- a/tests/test_range_ne6516a_capabilities.py +++ b/tests/test_range_ne6516a_capabilities.py @@ -8,15 +8,15 @@ phantom-switch bug (both were shipped with no exists_fn, so they bound unconditionally, always read off, and any write went to a token this firmware never recognized). """ + from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import for_device_by_resources from custom_components.localthings.registry.discovery import discover - from tests.conftest import _load_device def _range(): - resources = _load_device('range_ne6516a') + resources = _load_device("range_ne6516a") reg = for_device_by_resources(resources) return reg, resources @@ -29,7 +29,7 @@ def _state(): def test_resolves_to_range_registry(): reg, _ = _range() - assert reg is not None and reg.name == 'range' + assert reg is not None and reg.name == "range" def test_no_unbound_hrefs(): @@ -43,14 +43,14 @@ def test_fast_preheat_and_natural_steam_absent_without_their_tokens(): """Neither token is in this dump's options[] -- both switches must stay unbound rather than silently reading as an always-off phantom control.""" state = _state() - assert 'fast_preheat' not in state - assert 'natural_steam' not in state + assert "fast_preheat" not in state + assert "natural_steam" not in state def test_energy_saving_and_cooktop_alert_present(): state = _state() - assert state['energy_saving'] is True - assert state['cooktop_on_alert'] is False + assert state["energy_saving"] is True + assert state["cooktop_on_alert"] is False def test_child_lock_already_reads_correctly(): @@ -62,4 +62,4 @@ def test_child_lock_already_reads_correctly(): contract), whose 'lock' device class is inverted from a switch's plain on/off: False means locked/closed, matching 'Run' here.""" state = _state() - assert state['child_lock'] is False + assert state["child_lock"] is False diff --git a/tests/test_range_no_info_capabilities.py b/tests/test_range_no_info_capabilities.py index 71d11ff..4b63377 100644 --- a/tests/test_range_no_info_capabilities.py +++ b/tests/test_range_no_info_capabilities.py @@ -1,15 +1,15 @@ """Tests for the no-/information/vs/0, no-burner-status range variant (NE63B8411SS-class, issue #74).""" + from custom_components.localthings.registry.adapter import flatten from custom_components.localthings.registry.by_type import for_device_by_resources from custom_components.localthings.registry.capabilities import range as range_caps from custom_components.localthings.registry.discovery import discover - from tests.conftest import _load_device def _range(): - resources = _load_device('range_no_info') + resources = _load_device("range_no_info") reg = for_device_by_resources(resources) return reg, resources @@ -22,7 +22,7 @@ def _state(): def test_resolves_to_range_registry(): reg, _ = _range() - assert reg is not None and reg.name == 'range' + assert reg is not None and reg.name == "range" def test_no_unbound_hrefs(): @@ -38,21 +38,31 @@ def test_no_burner_entities(): """This board reports no /cooktop/status/vs/0, so none of range.py's per-burner entities should appear -- only COOKTOP_MONITORING's.""" state = _state() - assert not any(k.startswith('burner_') for k in state) + assert not any(k.startswith("burner_") for k in state) def test_expected_entities_present(): state = _state() for key in ( - 'power_switch', 'oven_setpoint', 'current_temp_c', 'oven_mode', - 'machine_state', 'door_open', 'cloud_connected', - 'cooktop_running_state', 'warming_center_state', + "power_switch", + "oven_setpoint", + "current_temp_c", + "oven_mode", + "machine_state", + "door_open", + "cloud_connected", + "cooktop_running_state", + "warming_center_state", ): assert key in state, key def test_cooktop_monitoring_reads_live_fields(): - desc = next(e for e in range_caps.COOKTOP_MONITORING.entities if e.key == 'cooktop_running_state') - assert desc.value_fn('Ready') == 'Ready' - desc = next(e for e in range_caps.COOKTOP_MONITORING.entities if e.key == 'warming_center_state') - assert desc.value_fn('Off') == 'Off' + desc = next( + e for e in range_caps.COOKTOP_MONITORING.entities if e.key == "cooktop_running_state" + ) + assert desc.value_fn("Ready") == "Ready" + desc = next( + e for e in range_caps.COOKTOP_MONITORING.entities if e.key == "warming_center_state" + ) + assert desc.value_fn("Off") == "Off" diff --git a/tests/test_redact.py b/tests/test_redact.py index 917bea3..733972d 100644 --- a/tests/test_redact.py +++ b/tests/test_redact.py @@ -1,109 +1,125 @@ """Tests for registry.redact — the safety net for diagnostics downloads.""" + import json from pathlib import Path from custom_components.localthings.registry.batch import parse_device0_batch from custom_components.localthings.registry.redact import REDACTED, redact_resources -FIXTURES = Path(__file__).resolve().parent / 'fixtures' +FIXTURES = Path(__file__).resolve().parent / "fixtures" def _load(name: str) -> dict: data = json.loads((FIXTURES / name).read_text()) - return parse_device0_batch(data['device0']) + return parse_device0_batch(data["device0"]) def test_redacts_known_sensitive_fields_in_dishwasher_dump(): - resources = _load('dishwasher_device.json') + resources = _load("dishwasher_device.json") redacted = redact_resources(resources) - info = redacted['/information/vs/0'] - assert info['x.com.samsung.da.serialNum'] == REDACTED - assert info['x.com.samsung.da.otnDUID'] == REDACTED + info = redacted["/information/vs/0"] + assert info["x.com.samsung.da.serialNum"] == REDACTED + assert info["x.com.samsung.da.otnDUID"] == REDACTED - wireless = redacted['/wirelessinfo/vs/0'] - assert wireless['macaddressWiFi'] == REDACTED - assert wireless['macaddressBLE'] == REDACTED + wireless = redacted["/wirelessinfo/vs/0"] + assert wireless["macaddressWiFi"] == REDACTED + assert wireless["macaddressBLE"] == REDACTED - provisioning = redacted['/voice/provisioning/vs/0'] - headers = provisioning['voice.provisioning.headers'] - assert headers['login_id'] == REDACTED - deviceinfo = provisioning['voice.provisioning.deviceinfo'] - assert deviceinfo['voice.provisioning.deviceinfo.accesstoken'] == REDACTED - assert deviceinfo['voice.provisioning.deviceinfo.deviceid'] == REDACTED - assert deviceinfo['voice.provisioning.deviceinfo.userid'] == REDACTED + provisioning = redacted["/voice/provisioning/vs/0"] + headers = provisioning["voice.provisioning.headers"] + assert headers["login_id"] == REDACTED + deviceinfo = provisioning["voice.provisioning.deviceinfo"] + assert deviceinfo["voice.provisioning.deviceinfo.accesstoken"] == REDACTED + assert deviceinfo["voice.provisioning.deviceinfo.deviceid"] == REDACTED + assert deviceinfo["voice.provisioning.deviceinfo.userid"] == REDACTED def test_ordinary_state_fields_survive_untouched(): - resources = _load('dishwasher_device.json') + resources = _load("dishwasher_device.json") redacted = redact_resources(resources) - op_state = redacted['/operational/state/vs/0'] - assert op_state['x.com.samsung.da.state'] == 'Run' - assert op_state['x.com.samsung.da.progress'] == 'Finish' + op_state = redacted["/operational/state/vs/0"] + assert op_state["x.com.samsung.da.state"] == "Run" + assert op_state["x.com.samsung.da.progress"] == "Finish" - power = redacted['/power/vs/0'] - assert power['x.com.samsung.da.power'] == 'On' + power = redacted["/power/vs/0"] + assert power["x.com.samsung.da.power"] == "On" - dishwasher = redacted['/dishwasher/vs/0'] - assert dishwasher['x.com.samsung.da.sanitize'] == 'On' - assert dishwasher['x.com.samsung.da.rinseLevel'] == '4' + dishwasher = redacted["/dishwasher/vs/0"] + assert dishwasher["x.com.samsung.da.sanitize"] == "On" + assert dishwasher["x.com.samsung.da.rinseLevel"] == "4" - alarms = redacted['/alarms/vs/0']['x.com.samsung.da.items'] - assert alarms[0]['x.com.samsung.da.code'] == 'SNSF_Reached' + alarms = redacted["/alarms/vs/0"]["x.com.samsung.da.items"] + assert alarms[0]["x.com.samsung.da.code"] == "SNSF_Reached" def test_redacts_known_sensitive_fields_in_refrigerator_dump(): - resources = _load('refrigerator_device.json') + resources = _load("refrigerator_device.json") redacted = redact_resources(resources) - info = redacted['/information/vs/0'] - assert info['x.com.samsung.da.serialNum'] == REDACTED + info = redacted["/information/vs/0"] + assert info["x.com.samsung.da.serialNum"] == REDACTED - wireless = redacted['/wirelessinfo/vs/0'] - assert wireless['macaddressWiFi'] == REDACTED - assert wireless['macaddressBLE'] == REDACTED + wireless = redacted["/wirelessinfo/vs/0"] + assert wireless["macaddressWiFi"] == REDACTED + assert wireless["macaddressBLE"] == REDACTED def test_redact_resources_does_not_mutate_input(): - resources = _load('dishwasher_device.json') - original_serial = resources['/information/vs/0']['x.com.samsung.da.serialNum'] + resources = _load("dishwasher_device.json") + original_serial = resources["/information/vs/0"]["x.com.samsung.da.serialNum"] redact_resources(resources) - assert resources['/information/vs/0']['x.com.samsung.da.serialNum'] == original_serial + assert resources["/information/vs/0"]["x.com.samsung.da.serialNum"] == original_serial def test_redacts_bare_ocf_identity_keys(): """/oic/d and /oic/p identify the unit with two-letter keys ('di', 'pi') that the substring rules can't see.""" - redacted = redact_resources({ - '/oic/d': {'di': 'ab-cd-ef', 'n': "Marc's Fridge", - 'rt': ['oic.wk.d', 'oic.d.refrigerator']}, - '/oic/p': {'pi': '12-34-56', 'mnmo': 'RF9000B'}, - }) + redacted = redact_resources( + { + "/oic/d": { + "di": "ab-cd-ef", + "n": "Marc's Fridge", + "rt": ["oic.wk.d", "oic.d.refrigerator"], + }, + "/oic/p": {"pi": "12-34-56", "mnmo": "RF9000B"}, + } + ) - assert redacted['/oic/d']['di'] == REDACTED - assert redacted['/oic/p']['pi'] == REDACTED + assert redacted["/oic/d"]["di"] == REDACTED + assert redacted["/oic/p"]["pi"] == REDACTED # 'n' is free text the owner sets from the SmartThings app, so it can # carry a person's name -- redacted too. `rt`, the device-type signal # we actually want out of /oic/d, is not. - assert redacted['/oic/d']['n'] == REDACTED - assert redacted['/oic/d']['rt'] == ['oic.wk.d', 'oic.d.refrigerator'] - assert redacted['/oic/p']['mnmo'] == 'RF9000B' + assert redacted["/oic/d"]["n"] == REDACTED + assert redacted["/oic/d"]["rt"] == ["oic.wk.d", "oic.d.refrigerator"] + assert redacted["/oic/p"]["mnmo"] == "RF9000B" def test_bare_key_redaction_does_not_leak_into_substring_matching(): """The bare keys are whole-key matches only -- plenty of ordinary appliance fields contain those letters and must survive untouched.""" - redacted = redact_resources({ - '/x': { - 'condition': 'Normal', 'display': 'On', 'dispenser': 'Cubed', - 'humidity': '45', 'spinSpeed': '1200', 'name': 'FilterProgress', - }, - }) + redacted = redact_resources( + { + "/x": { + "condition": "Normal", + "display": "On", + "dispenser": "Cubed", + "humidity": "45", + "spinSpeed": "1200", + "name": "FilterProgress", + }, + } + ) - assert redacted['/x'] == { - 'condition': 'Normal', 'display': 'On', 'dispenser': 'Cubed', - 'humidity': '45', 'spinSpeed': '1200', 'name': 'FilterProgress', + assert redacted["/x"] == { + "condition": "Normal", + "display": "On", + "dispenser": "Cubed", + "humidity": "45", + "spinSpeed": "1200", + "name": "FilterProgress", } diff --git a/tests/test_registry.py b/tests/test_registry.py index d122dc4..8b57247 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -1,4 +1,5 @@ """Tests for the CAPABILITIES registry.""" + from custom_components.localthings.registry.registry import CAPABILITIES @@ -16,4 +17,4 @@ def test_registry_has_no_duplicate_href(): def test_operational_state_present(): """At least one capability should have 'state' in its href.""" - assert any('state' in href for href in CAPABILITIES) + assert any("state" in href for href in CAPABILITIES) diff --git a/tests/test_registry_ignored.py b/tests/test_registry_ignored.py index b66d80c..7b9f958 100644 --- a/tests/test_registry_ignored.py +++ b/tests/test_registry_ignored.py @@ -6,6 +6,7 @@ unbound. This guards two things at once: that the ignored hrefs are actually suppressed, and (via a plain import) that no ignored href collides with a real capability declared elsewhere for the same family. """ + import json from pathlib import Path @@ -13,33 +14,33 @@ from custom_components.localthings.registry.batch import parse_device0_batch from custom_components.localthings.registry.by_type import dishwasher, refrigerator from custom_components.localthings.registry.capabilities.ignored import IGNORED -FIXTURES = Path(__file__).resolve().parent / 'fixtures' +FIXTURES = Path(__file__).resolve().parent / "fixtures" _IGNORED_HREFS = {cap.href for cap in IGNORED} def _load(name: str) -> dict: data = json.loads((FIXTURES / name).read_text()) - return parse_device0_batch(data['device0']) + return parse_device0_batch(data["device0"]) def _unbound_hrefs(resources, registry) -> list[str]: unbound = [] from custom_components.localthings.registry.discovery import discover - discover(resources, registry.capabilities, registry.pattern_capabilities, - log=unbound.append) + + discover(resources, registry.capabilities, registry.pattern_capabilities, log=unbound.append) return unbound def test_ignored_hrefs_absent_from_dishwasher_gaps(): - resources = _load('dishwasher_device.json') + resources = _load("dishwasher_device.json") unbound = _unbound_hrefs(resources, dishwasher.REGISTRY) present_ignored = _IGNORED_HREFS & set(unbound) assert not present_ignored, f"ignored hrefs leaked into gap report: {present_ignored}" def test_ignored_hrefs_absent_from_refrigerator_gaps(): - resources = _load('refrigerator_device.json') + resources = _load("refrigerator_device.json") unbound = _unbound_hrefs(resources, refrigerator.REGISTRY) present_ignored = _IGNORED_HREFS & set(unbound) assert not present_ignored, f"ignored hrefs leaked into gap report: {present_ignored}" @@ -54,7 +55,7 @@ def test_dishwasher_fixture_has_no_remaining_gaps(): look at (add the href to ignored.py or map it, don't just delete this assertion). """ - resources = _load('dishwasher_device.json') + resources = _load("dishwasher_device.json") unbound = set(_unbound_hrefs(resources, dishwasher.REGISTRY)) assert unbound == set() @@ -62,14 +63,15 @@ def test_dishwasher_fixture_has_no_remaining_gaps(): class TestWasherIgnoredHrefs: def test_washer_hrefs_are_ignored(self): from custom_components.localthings.registry.capabilities.ignored import IGNORED + ignored_hrefs = {cap.href for cap in IGNORED} for href in ( - '/st/washercourse/vs/0', - '/wm/welcomemsg/vs/0', - '/wm/personalcourse/vs/0', - '/energy/consumption/0', - '/cycleinterface/vs/0', - '/drlc/0', - '/operational/state/0', + "/st/washercourse/vs/0", + "/wm/welcomemsg/vs/0", + "/wm/personalcourse/vs/0", + "/energy/consumption/0", + "/cycleinterface/vs/0", + "/drlc/0", + "/operational/state/0", ): assert href in ignored_hrefs, f"{href} should be in ignored.IGNORED" diff --git a/tests/test_select_display.py b/tests/test_select_display.py index ab9d8c8..1294874 100644 --- a/tests/test_select_display.py +++ b/tests/test_select_display.py @@ -1,46 +1,47 @@ """Tests for select-option display casing (custom_components/localthings/select.py).""" + from custom_components.localthings.select import _display _UNTRANSLATED = None -_TRANSLATED = 'ice_type' +_TRANSLATED = "ice_type" def test_display_titlecases_a_fully_lowercase_device_native_token(): """Samsung's sound-mode field is genuinely lowercase on the wire ('voice'/'tone'/'mute') -- these have no other casing signal to key off, so title-case them for display.""" - assert _display('voice', _UNTRANSLATED) == 'Voice' - assert _display('mute', _UNTRANSLATED) == 'Mute' + assert _display("voice", _UNTRANSLATED) == "Voice" + assert _display("mute", _UNTRANSLATED) == "Mute" def test_display_inserts_a_space_at_a_camelcase_boundary(): """'ExtraHigh' (from supportedHeatedDry) should read as two words.""" - assert _display('ExtraHigh', _UNTRANSLATED) == 'Extra High' + assert _display("ExtraHigh", _UNTRANSLATED) == "Extra High" def test_display_passes_through_an_already_human_friendly_value(): """'AI Wash' etc. (dishwasher cycle names) already read fine and have no camelCase boundary or all-lowercase pattern -- must not be mangled.""" - assert _display('AI Wash', _UNTRANSLATED) == 'AI Wash' - assert _display('Low', _UNTRANSLATED) == 'Low' - assert _display('Off', _UNTRANSLATED) == 'Off' + assert _display("AI Wash", _UNTRANSLATED) == "AI Wash" + assert _display("Low", _UNTRANSLATED) == "Low" + assert _display("Off", _UNTRANSLATED) == "Off" def test_display_lowercases_for_translation_key_lookup(): """An entity with a translation_key must match the catalog's lowercase keys exactly -- unlike the untranslated cases above, this is not a cosmetic transform.""" - assert _display('Whiskey_IceBall_3', _TRANSLATED) == 'whiskey_iceball_3' + assert _display("Whiskey_IceBall_3", _TRANSLATED) == "whiskey_iceball_3" def test_unknown_translated_vendor_value_keeps_readable_fallback(): """A firmware-added value must remain readable instead of being mangled.""" - assert _display('FutureVendorMode', _TRANSLATED) == 'Future Vendor Mode' + assert _display("FutureVendorMode", _TRANSLATED) == "Future Vendor Mode" def test_known_camel_case_state_uses_snake_case_translation_key(): - assert _display('ExtraHigh', 'heated_dry') == 'extra_high' + assert _display("ExtraHigh", "heated_dry") == "extra_high" def test_display_passes_through_non_string_values(): diff --git a/tests/test_select_options.py b/tests/test_select_options.py index aaeba47..54be04c 100644 --- a/tests/test_select_options.py +++ b/tests/test_select_options.py @@ -2,6 +2,9 @@ (custom_components/localthings/select.py) -- the static tuple, options_field, and callable forms of SelectDesc.options. """ + +from typing import ClassVar + from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.discovery import BoundEntity from custom_components.localthings.registry.entities import SelectDesc @@ -9,7 +12,7 @@ from custom_components.localthings.select import LocalThingsSelect class _FakeCoordinator: - device_serial = 'TEST-SERIAL' + device_serial = "TEST-SERIAL" def __init__(self, last_resources): self.last_resources = last_resources @@ -28,15 +31,15 @@ def _make_select(desc, href, last_resources): def test_static_options_unaffected(): - desc = SelectDesc(key='x', options=('A', 'B')) - entity = _make_select(desc, '/x/vs/0', {}) - assert entity.options == ['A', 'B'] + desc = SelectDesc(key="x", options=("A", "B")) + entity = _make_select(desc, "/x/vs/0", {}) + assert entity.options == ["A", "B"] def test_options_field_unaffected(): - desc = SelectDesc(key='x', options_field='supported') - entity = _make_select(desc, '/x/vs/0', {'/x/vs/0': {'supported': ['Lo', 'Hi']}}) - assert entity.options == ['Lo', 'Hi'] + desc = SelectDesc(key="x", options_field="supported") + entity = _make_select(desc, "/x/vs/0", {"/x/vs/0": {"supported": ["Lo", "Hi"]}}) + assert entity.options == ["Lo", "Hi"] def test_callable_options_receives_full_resource_snapshot(): @@ -47,21 +50,21 @@ def test_callable_options_receives_full_resource_snapshot(): def _options_fn(resources): calls.append(resources) - return list(resources.get('/other/vs/0', {}).get('codes', [])) + return list(resources.get("/other/vs/0", {}).get("codes", [])) - desc = SelectDesc(key='cycle', translation_key='fake_cycle', options=_options_fn) + desc = SelectDesc(key="cycle", translation_key="fake_cycle", options=_options_fn) resources = { - '/x/vs/0': {}, - '/other/vs/0': {'codes': ['1C', '1D']}, + "/x/vs/0": {}, + "/other/vs/0": {"codes": ["1C", "1D"]}, } - entity = _make_select(desc, '/x/vs/0', resources) - assert entity.options == ['1C', '1D'] + entity = _make_select(desc, "/x/vs/0", resources) + assert entity.options == ["1C", "1D"] assert calls == [resources] def test_callable_options_empty_result(): - desc = SelectDesc(key='cycle', options=lambda resources: []) - entity = _make_select(desc, '/x/vs/0', {}) + desc = SelectDesc(key="cycle", options=lambda resources: []) + entity = _make_select(desc, "/x/vs/0", {}) assert entity.options == [] @@ -73,19 +76,20 @@ def test_callable_translation_key_reresolves_live_not_once_at_construction(): (see entity.py's _is_included docstring), and a one-time resolution would permanently show untranslated codes even after a later poll populates the real value.""" - desc = SelectDesc(key='cycle', translation_key=lambda resources: resources.get('key')) - resources = {'key': None} - entity = _make_select(desc, '/x/vs/0', resources) + desc = SelectDesc(key="cycle", translation_key=lambda resources: resources.get("key")) + resources = {"key": None} + entity = _make_select(desc, "/x/vs/0", resources) assert entity.translation_key is None - resources['key'] = 'washer_cycle_table_02' - assert entity.translation_key == 'washer_cycle_table_02' + resources["key"] = "washer_cycle_table_02" + assert entity.translation_key == "washer_cycle_table_02" async def test_unknown_vendor_option_round_trips_to_exact_raw_value(): """Readable fallback labels must still write the exact Samsung token.""" + class _WritableCoordinator(_FakeCoordinator): - data = {'mode': 'FutureVendorMode'} + data: ClassVar[dict] = {"mode": "FutureVendorMode"} def __init__(self, last_resources): super().__init__(last_resources) @@ -95,14 +99,16 @@ async def test_unknown_vendor_option_round_trips_to_exact_raw_value(): self.writes.append(value) desc = SelectDesc( - key='mode', translation_key='door_alert', - options=('Known', 'FutureVendorMode'), write_fn=lambda *args: None, + key="mode", + translation_key="door_alert", + options=("Known", "FutureVendorMode"), + write_fn=lambda *args: None, ) - capability = Capability(href='/x/vs/0', entities=(desc,)) - bound = BoundEntity(href='/x/vs/0', capability=capability, desc=desc) + capability = Capability(href="/x/vs/0", entities=(desc,)) + bound = BoundEntity(href="/x/vs/0", capability=capability, desc=desc) coordinator = _WritableCoordinator({}) entity = LocalThingsSelect(coordinator, bound) - assert entity.options[-1] == 'Future Vendor Mode' - await entity.async_select_option('Future Vendor Mode') - assert coordinator.writes == ['FutureVendorMode'] + assert entity.options[-1] == "Future Vendor Mode" + await entity.async_select_option("Future Vendor Mode") + assert coordinator.writes == ["FutureVendorMode"] diff --git a/tests/test_sensor_hysteresis.py b/tests/test_sensor_hysteresis.py index bd90bf2..0f9145f 100644 --- a/tests/test_sensor_hysteresis.py +++ b/tests/test_sensor_hysteresis.py @@ -1,14 +1,15 @@ """Unit tests for LocalThingsSensor's hysteresis gate (finish_time churn).""" + from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from custom_components.localthings.const import CONF_FINISH_TIME_HYSTERESIS_MINUTES from custom_components.localthings.registry.capabilities.operational import OPERATIONAL_STATE from custom_components.localthings.registry.discovery import BoundEntity from custom_components.localthings.sensor import LocalThingsSensor -_FINISH_TIME_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == 'finish_time') +_FINISH_TIME_DESC = next(e for e in OPERATIONAL_STATE.entities if e.key == "finish_time") class _FakeConfigEntry: @@ -20,17 +21,21 @@ class _FakeCoordinator: """Just enough surface for LocalThingsEntity/LocalThingsSensor.""" def __init__(self, threshold_minutes): - self.device_serial = 'TEST-SERIAL' - self.config_entry = _FakeConfigEntry({ - CONF_FINISH_TIME_HYSTERESIS_MINUTES: threshold_minutes, - }) + self.device_serial = "TEST-SERIAL" + self.config_entry = _FakeConfigEntry( + { + CONF_FINISH_TIME_HYSTERESIS_MINUTES: threshold_minutes, + } + ) self.data: dict = {} def _sensor(threshold_minutes=3): coordinator = _FakeCoordinator(threshold_minutes) bound = BoundEntity( - href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=_FINISH_TIME_DESC, + href=OPERATIONAL_STATE.href, + capability=OPERATIONAL_STATE, + desc=_FINISH_TIME_DESC, ) sensor = LocalThingsSensor(coordinator, bound) return sensor, coordinator @@ -38,63 +43,63 @@ def _sensor(threshold_minutes=3): def test_small_change_is_suppressed(): sensor, coordinator = _sensor(threshold_minutes=3) - base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc) + base = datetime(2026, 7, 31, 17, 0, tzinfo=UTC) - coordinator.data = {'finish_time': base} + coordinator.data = {"finish_time": base} assert sensor.native_value == base - coordinator.data = {'finish_time': base + timedelta(minutes=1)} + coordinator.data = {"finish_time": base + timedelta(minutes=1)} assert sensor.native_value == base, "a 1-minute wobble should be held back" def test_change_past_threshold_is_reported(): sensor, coordinator = _sensor(threshold_minutes=3) - base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc) + base = datetime(2026, 7, 31, 17, 0, tzinfo=UTC) - coordinator.data = {'finish_time': base} + coordinator.data = {"finish_time": base} assert sensor.native_value == base new = base + timedelta(minutes=5) - coordinator.data = {'finish_time': new} + coordinator.data = {"finish_time": new} assert sensor.native_value == new def test_zero_threshold_disables_hysteresis(): sensor, coordinator = _sensor(threshold_minutes=0) - base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc) + base = datetime(2026, 7, 31, 17, 0, tzinfo=UTC) - coordinator.data = {'finish_time': base} + coordinator.data = {"finish_time": base} assert sensor.native_value == base new = base + timedelta(seconds=1) - coordinator.data = {'finish_time': new} + coordinator.data = {"finish_time": new} assert sensor.native_value == new def test_cycle_end_passes_through_immediately(): """A cycle ending (finish_time -> None) must never be held back.""" sensor, coordinator = _sensor(threshold_minutes=3) - base = datetime(2026, 7, 31, 17, 0, tzinfo=timezone.utc) + base = datetime(2026, 7, 31, 17, 0, tzinfo=UTC) - coordinator.data = {'finish_time': base} + coordinator.data = {"finish_time": base} assert sensor.native_value == base - coordinator.data = {'finish_time': None} + coordinator.data = {"finish_time": None} assert sensor.native_value is None def test_non_hysteresis_sensor_is_unaffected(): """A SensorDesc without hysteresis=True reads straight through, unchanged.""" - machine_state_desc = next( - e for e in OPERATIONAL_STATE.entities if e.key == 'machine_state' - ) + machine_state_desc = next(e for e in OPERATIONAL_STATE.entities if e.key == "machine_state") coordinator = _FakeCoordinator(threshold_minutes=3) bound = BoundEntity( - href=OPERATIONAL_STATE.href, capability=OPERATIONAL_STATE, desc=machine_state_desc, + href=OPERATIONAL_STATE.href, + capability=OPERATIONAL_STATE, + desc=machine_state_desc, ) sensor = LocalThingsSensor(coordinator, bound) - coordinator.data = {'machine_state': 'active'} - assert sensor.native_value == 'active' - coordinator.data = {'machine_state': 'idle'} - assert sensor.native_value == 'idle' + coordinator.data = {"machine_state": "active"} + assert sensor.native_value == "active" + coordinator.data = {"machine_state": "idle"} + assert sensor.native_value == "idle" diff --git a/tests/test_subdevice_discovery.py b/tests/test_subdevice_discovery.py index b446e42..ca54d9f 100644 --- a/tests/test_subdevice_discovery.py +++ b/tests/test_subdevice_discovery.py @@ -5,39 +5,47 @@ what actually exercises _enumerate_subdevices_blocking + _run_discovery together, including device_info_for/via_device and the "no phantom /device/2 entities" guarantee. """ + from __future__ import annotations -import pytest from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.localthings.const import ( - CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN, + CONF_HOST, + CONF_LEAF_CERT_PEM, + CONF_LEAF_KEY_PEM, + CONF_PORT, + DOMAIN, ) from custom_components.localthings.coordinator import LocalThingsCoordinator from custom_components.localthings.registry.entities import ClimateDesc from custom_components.localthings.registry.identity import DeviceIdentity - from tests.conftest import FakeCoapSession, _load_device_full ENTRY_DATA = { - CONF_HOST: '10.0.0.177', + CONF_HOST: "10.0.0.177", CONF_PORT: 49154, - CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----', - CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----', + CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----", + CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----", } def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator: entry = MockConfigEntry( - domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SUBDEVICE-TEST', + domain=DOMAIN, + data=ENTRY_DATA, + unique_id="localthings_SUBDEVICE-TEST", ) entry.add_to_hass(hass) return LocalThingsCoordinator(hass, entry) async def _discover_with( - coordinator: LocalThingsCoordinator, resources: dict, oic_res, seeds: dict, + coordinator: LocalThingsCoordinator, + resources: dict, + oic_res, + seeds: dict, ) -> None: """Run the same two-step sequence _async_update_data's first cycle does (enumerate, then discover) against arbitrary resources/oic_res/seeds, @@ -50,11 +58,16 @@ async def _discover_with( # the fixture's real /oic/res so enumeration sees the same links a live # read_identity call would have captured. coordinator._identity = DeviceIdentity( - manufacturer='Samsung Electronics', model='', name='', serial=None, - device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': oic_res}, + manufacturer="Samsung Electronics", + model="", + name="", + serial=None, + device_types=(), + raw={"/oic/p": {}, "/oic/d": {}, "/oic/res": oic_res}, ) merged = await coordinator.hass.async_add_executor_job( - coordinator._enumerate_subdevices_blocking, resources, + coordinator._enumerate_subdevices_blocking, + resources, ) # Mirror _async_update_data's first-cycle order exactly: discover, then # drop the candidates the liveness gate rejected, then apply what's left @@ -66,7 +79,7 @@ async def _discover_with( # longer uses. coordinator._run_discovery(merged) for href, rep in coordinator._live_subdevice_resources(merged).items(): - coordinator._observe.apply(href, rep, source='poll') + coordinator._observe.apply(href, rep, source="poll") async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None: @@ -77,6 +90,7 @@ async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None: def _climate_bound(coordinator, subdevice_key: str): from custom_components.localthings.registry.subdevices import MAIN + for b in coordinator.bound: if isinstance(b.desc, ClimateDesc): if subdevice_key is None and b.subdevice == MAIN: @@ -90,18 +104,19 @@ def _climate_bound(coordinator, subdevice_key: str): # Pattern A reporter -- ARTIK051_DONGLE_FAC_18K, indexed siblings # --------------------------------------------------------------------------- + async def test_pattern_a_materializes_master_and_bedroom_subdevice(hass: HomeAssistant): coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + await _discover(coordinator, "airconditioner_artik051_dongle_fac_18k") - assert [su.key for su in coordinator.subdevices] == ['1'] + assert [su.key for su in coordinator.subdevices] == ["1"] main_climate = _climate_bound(coordinator, None) - sub1_climate = _climate_bound(coordinator, '1') + sub1_climate = _climate_bound(coordinator, "1") assert main_climate is not None assert sub1_climate is not None - assert main_climate.href == '/mode/vs/0' - assert sub1_climate.href == '/mode/vs/1' + assert main_climate.href == "/mode/vs/0" + assert sub1_climate.href == "/mode/vs/1" async def test_pattern_a_device_2_produces_no_entities_at_all(hass: HomeAssistant): @@ -110,31 +125,31 @@ async def test_pattern_a_device_2_produces_no_entities_at_all(hass: HomeAssistan batch, but every climate-state rep on it is empty. It must be recorded as skipped, not materialized, and must contribute zero bound entities.""" coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + await _discover(coordinator, "airconditioner_artik051_dongle_fac_18k") - assert '2' not in [su.key for su in coordinator.subdevices] + assert "2" not in [su.key for su in coordinator.subdevices] assert any( - skip.subdevice.kind == 'indexed' and skip.subdevice.key == '2' + skip.subdevice.kind == "indexed" and skip.subdevice.key == "2" for skip in coordinator._skipped_subdevices ) - assert not any(b.subdevice.key == '2' for b in coordinator.bound) - assert not any(href.endswith('/2') for href in coordinator._hot_hrefs) - assert not any(href.endswith('/2') for href in coordinator._warm_hrefs) + assert not any(b.subdevice.key == "2" for b in coordinator.bound) + assert not any(href.endswith("/2") for href in coordinator._hot_hrefs) + assert not any(href.endswith("/2") for href in coordinator._warm_hrefs) async def test_pattern_a_sub1_device_info_links_via_device_to_master(hass: HomeAssistant): coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k') + await _discover(coordinator, "airconditioner_artik051_dongle_fac_18k") - sub1 = next(su for su in coordinator.subdevices if su.key == '1') + sub1 = next(su for su in coordinator.subdevices if su.key == "1") info = coordinator.device_info_for(sub1) master_serial = coordinator.device_serial - assert info['identifiers'] == {(DOMAIN, f'{master_serial}_1')} - assert info['via_device'] == (DOMAIN, master_serial) + assert info["identifiers"] == {(DOMAIN, f"{master_serial}_1")} + assert info["via_device"] == (DOMAIN, master_serial) # The subdevice's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K) # is what names/models this device, not the master's. - assert info['model'] == 'ARTIK051_DONGLE_FAC_RAC_18K' + assert info["model"] == "ARTIK051_DONGLE_FAC_RAC_18K" # --------------------------------------------------------------------------- @@ -142,6 +157,7 @@ async def test_pattern_a_sub1_device_info_links_via_device_to_master(hass: HomeA # answers with an unused slot that reports the appliance's energy counter. # --------------------------------------------------------------------------- + async def test_krac_18k_energy_only_slot_is_not_materialized(hass: HomeAssistant): """The issue #214 reporter's single-split AR12NXWXCWKNEU (ARTIK051_KRAC_18K, one indoor unit) answers /device/1 with the Pattern A /device/2 shape -- @@ -154,27 +170,31 @@ async def test_krac_18k_energy_only_slot_is_not_materialized(hass: HomeAssistant slot, so the candidate must now be recorded as skipped and contribute nothing: no bound entities, no HA device, no hot/warm hrefs.""" coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_artik051_krac_18k_slot') + await _discover(coordinator, "airconditioner_artik051_krac_18k_slot") assert coordinator.subdevices == [] - assert [s.subdevice.key for s in coordinator._skipped_subdevices] == ['1'] + assert [s.subdevice.key for s in coordinator._skipped_subdevices] == ["1"] # The gate ran against real bindings, not against nothing -- these are the # six hrefs the reporter's own diagnostics reported for the (then # materialized) subdevice, /energy/consumption/vs/1 among them. assert coordinator._skipped_subdevices[0].hrefs == ( - '/alarms/vs/1', '/diagnosis/vs/1', '/energy/consumption/vs/1', - '/humidity/vs/1', '/mode/vs/1', '/temperature/current/1', + "/alarms/vs/1", + "/diagnosis/vs/1", + "/energy/consumption/vs/1", + "/humidity/vs/1", + "/mode/vs/1", + "/temperature/current/1", ) - assert coordinator._subdevice_probes['/device/1'] is True + assert coordinator._subdevice_probes["/device/1"] is True - assert not any(b.subdevice.key == '1' for b in coordinator.bound) - assert not any(href.endswith('/1') for href in coordinator._hot_hrefs) - assert not any(href.endswith('/1') for href in coordinator._warm_hrefs) + assert not any(b.subdevice.key == "1" for b in coordinator.bound) + assert not any(href.endswith("/1") for href in coordinator._hot_hrefs) + assert not any(href.endswith("/1") for href in coordinator._warm_hrefs) # The master is untouched -- one climate entity, on the master's own # device, exactly as this board behaved before subdevice support existed. assert _climate_bound(coordinator, None) is not None - assert _climate_bound(coordinator, '1') is None + assert _climate_bound(coordinator, "1") is None async def test_krac_18k_slot_state_never_reaches_the_cache(hass: HomeAssistant): @@ -185,49 +205,49 @@ async def test_krac_18k_slot_state_never_reaches_the_cache(hass: HomeAssistant): from it -- at its first-discovery value forever, since nothing polls the slot again.""" coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_artik051_krac_18k_slot') + await _discover(coordinator, "airconditioner_artik051_krac_18k_slot") - assert not any(href.endswith('/1') for href in coordinator.last_resources) + assert not any(href.endswith("/1") for href in coordinator.last_resources) # It's kept aside for diagnostics only, which is where a reader can still # check the gate's call for themselves. - assert '/energy/consumption/vs/1' in coordinator._skipped_subdevice_resources + assert "/energy/consumption/vs/1" in coordinator._skipped_subdevice_resources # --------------------------------------------------------------------------- # Issue #177's Pattern B reporter -- TP2X_FAC_BORA_21K, UUID-prefixed tree # --------------------------------------------------------------------------- -_SUB_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001' +_SUB_UUID = "6c2dff6d-ee5c-dad1-6a5e-000000000001" async def test_fac_bora_2in1_materializes_prefixed_wall_subdevice(hass: HomeAssistant): coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_fac_bora_2in1') + await _discover(coordinator, "airconditioner_fac_bora_2in1") assert [su.key for su in coordinator.subdevices] == [_SUB_UUID] - assert coordinator.subdevices[0].kind == 'prefixed' + assert coordinator.subdevices[0].kind == "prefixed" main_climate = _climate_bound(coordinator, None) sub_climate = _climate_bound(coordinator, _SUB_UUID) assert main_climate is not None assert sub_climate is not None - assert main_climate.href == '/mode/vs/0' - assert sub_climate.href == f'/{_SUB_UUID}/mode/vs/0' + assert main_climate.href == "/mode/vs/0" + assert sub_climate.href == f"/{_SUB_UUID}/mode/vs/0" async def test_fac_bora_2in1_subdevice_device_info(hass: HomeAssistant): coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_fac_bora_2in1') + await _discover(coordinator, "airconditioner_fac_bora_2in1") subdevice = coordinator.subdevices[0] info = coordinator.device_info_for(subdevice) master_serial = coordinator.device_serial - assert info['identifiers'] == {(DOMAIN, f'{master_serial}_{_SUB_UUID}')} - assert info['via_device'] == (DOMAIN, master_serial) + assert info["identifiers"] == {(DOMAIN, f"{master_serial}_{_SUB_UUID}")} + assert info["via_device"] == (DOMAIN, master_serial) # Confirmed live by the reporter (DESIGN-177.md section 1): the wall # subdevice's own identity, distinct from the master's TP2X_FAC_BORA_21K. - assert info['model'] == 'TP2X_FAC_BORA_RAC_21K' + assert info["model"] == "TP2X_FAC_BORA_RAC_21K" async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssistant): @@ -235,12 +255,13 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis (non-alphanumerics stripped), not a truncation or an ordinal -- see Subdevice.key_prefix.""" coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_fac_bora_2in1') + await _discover(coordinator, "airconditioner_fac_bora_2in1") from custom_components.localthings.entity import LocalThingsEntity + sub_climate = _climate_bound(coordinator, _SUB_UUID) entity = LocalThingsEntity(coordinator, sub_climate) - expected_slug = _SUB_UUID.replace('-', '') + expected_slug = _SUB_UUID.replace("-", "") assert entity._attr_unique_id == ( f"{DOMAIN}_{coordinator.device_serial}_subdevice_{expected_slug}_climate" ) @@ -253,6 +274,7 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis # capture instead of the synthetic sessions test_subdevices.py uses. # --------------------------------------------------------------------------- + async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back( hass: HomeAssistant, ): @@ -266,17 +288,17 @@ async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back materializing a phantom climate card from unconfirmed hrefs. This is the honest current state of issue #205, not a guess at its resolution.""" coordinator = _coordinator(hass) - await _discover(coordinator, 'airconditioner_fac_bora_205_flat') + await _discover(coordinator, "airconditioner_fac_bora_205_flat") assert coordinator.subdevices == [] assert [s.subdevice.key for s in coordinator._skipped_subdevices] == [_SUB_UUID] skipped = coordinator._skipped_subdevices[0].subdevice - assert skipped.kind == 'prefixed' + assert skipped.kind == "prefixed" assert skipped.seed_path == () - assert skipped.flat_hrefs == ('/information/vs/0',) + assert skipped.flat_hrefs == ("/information/vs/0",) - assert coordinator._subdevice_probes[f'/{_SUB_UUID}/device/0'] is False - assert coordinator._subdevice_probes[f'/{_SUB_UUID}/information/vs/0'] is True + assert coordinator._subdevice_probes[f"/{_SUB_UUID}/device/0"] is False + assert coordinator._subdevice_probes[f"/{_SUB_UUID}/information/vs/0"] is True # Confirms the master itself is completely unaffected by its sibling's # Collection endpoint not answering -- same guarantee every other @@ -301,7 +323,7 @@ async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssi add GETs, not freshness, so they're the ones this method must skip. /option/autoclean/vs/0 is cold-tier and is what actually needs this path.""" - resources, oic_res, _real_seeds = _load_device_full('airconditioner_fac_bora_2in1') + resources, oic_res, _real_seeds = _load_device_full("airconditioner_fac_bora_2in1") seeds = { # No (_SUB_UUID, 'device', '0') entry -- forces the flat fallback, # same as the real issue #205 capture above, but this time with @@ -310,23 +332,27 @@ async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssi # Collection-batch seed, just served individually instead of # batched, to isolate "does flat mode produce the same result as # Collection mode" as the only variable. - f'/{_SUB_UUID}/power/vs/0': {'x.com.samsung.da.power': 'On'}, - f'/{_SUB_UUID}/mode/vs/0': { - 'x.com.samsung.da.supportedModes': ['Cool', 'Dry', 'Wind', 'Auto'], - 'x.com.samsung.da.modes': ['Cool'], - 'x.com.samsung.da.options': [], + f"/{_SUB_UUID}/power/vs/0": {"x.com.samsung.da.power": "On"}, + f"/{_SUB_UUID}/mode/vs/0": { + "x.com.samsung.da.supportedModes": ["Cool", "Dry", "Wind", "Auto"], + "x.com.samsung.da.modes": ["Cool"], + "x.com.samsung.da.options": [], }, - f'/{_SUB_UUID}/temperature/current/0': { - 'range': [18.0, 30.0], 'units': 'C', 'temperature': 26.0, + f"/{_SUB_UUID}/temperature/current/0": { + "range": [18.0, 30.0], + "units": "C", + "temperature": 26.0, }, - f'/{_SUB_UUID}/temperature/desired/0': { - 'range': [18.0, 30.0], 'units': 'C', 'temperature': 24.0, + f"/{_SUB_UUID}/temperature/desired/0": { + "range": [18.0, 30.0], + "units": "C", + "temperature": 24.0, }, # Cold-tier -- not covered by _run_subpolls, so this is the href # that actually depends on _poll_subdevice_flat_hrefs to ever # refresh at all. - f'/{_SUB_UUID}/option/autoclean/vs/0': { - 'x.com.samsung.da.settingStatus': 'Off', + f"/{_SUB_UUID}/option/autoclean/vs/0": { + "x.com.samsung.da.settingStatus": "Off", }, } coordinator = _coordinator(hass) @@ -343,29 +369,31 @@ async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssi # Re-poll: a fresh reading under the prefix should reach # canonical_resources through _poll_subdevice_seed's flat-mode branch, # not just sit frozen at the one-time enumeration snapshot. - coordinator._session.seeds[f'/{_SUB_UUID}/temperature/current/0'] = { - 'range': [18.0, 30.0], 'units': 'C', 'temperature': 27.5, + coordinator._session.seeds[f"/{_SUB_UUID}/temperature/current/0"] = { + "range": [18.0, 30.0], + "units": "C", + "temperature": 27.5, } - coordinator._session.seeds[f'/{_SUB_UUID}/option/autoclean/vs/0'] = { - 'x.com.samsung.da.settingStatus': 'On', + coordinator._session.seeds[f"/{_SUB_UUID}/option/autoclean/vs/0"] = { + "x.com.samsung.da.settingStatus": "On", } refreshed = coordinator._poll_subdevice_seed(subdevice) # The warm-tier temperature href is skipped here -- already covered by # _run_subpolls at a faster cadence -- so it does NOT show up refreshed # through this path. - assert f'/{_SUB_UUID}/temperature/current/0' not in refreshed + assert f"/{_SUB_UUID}/temperature/current/0" not in refreshed assert refreshed == { - f'/{_SUB_UUID}/option/autoclean/vs/0': {'x.com.samsung.da.settingStatus': 'On'}, + f"/{_SUB_UUID}/option/autoclean/vs/0": {"x.com.samsung.da.settingStatus": "On"}, } for href, rep in refreshed.items(): - coordinator._observe.apply(href, rep, source='poll') + coordinator._observe.apply(href, rep, source="poll") res = coordinator.canonical_resources(subdevice) - assert res['/option/autoclean/vs/0']['x.com.samsung.da.settingStatus'] == 'On' + assert res["/option/autoclean/vs/0"]["x.com.samsung.da.settingStatus"] == "On" # Confirms the skip is about redundant re-fetching, not stale data -- # the warm-tier value from initial discovery is still there, untouched. - assert res['/temperature/current/0']['temperature'] == 26.0 + assert res["/temperature/current/0"]["temperature"] == 26.0 class _FakeCollectionSession: @@ -382,8 +410,9 @@ class _FakeCollectionSession: self.calls.append(tuple(path)) body = self.table.get(tuple(path)) if body is None: - return 0x84, b'' + return 0x84, b"" import cbor2 + return 0x45, cbor2.dumps(body) def pace(self): @@ -399,19 +428,22 @@ def test_poll_subdevice_seed_collection_mode_unaffected_by_flat_fallback( from custom_components.localthings.registry.subdevices import Subdevice coordinator = _coordinator(hass) - devcol_rep = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']} - sess = _FakeCollectionSession({ - (_SUB_UUID, 'device', '0'): [ - devcol_rep, {'href': '/mode/vs/0', 'rep': {'mode': 'cool'}}, - ], - }) + devcol_rep = {"rt": ["x.com.samsung.devcol", "oic.wk.col"]} + sess = _FakeCollectionSession( + { + (_SUB_UUID, "device", "0"): [ + devcol_rep, + {"href": "/mode/vs/0", "rep": {"mode": "cool"}}, + ], + } + ) coordinator._session = sess - subdevice = Subdevice(kind='prefixed', key=_SUB_UUID, seed_path=(_SUB_UUID, 'device', '0')) + subdevice = Subdevice(kind="prefixed", key=_SUB_UUID, seed_path=(_SUB_UUID, "device", "0")) result = coordinator._poll_subdevice_seed(subdevice) - assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}} - assert sess.calls == [(_SUB_UUID, 'device', '0')] + assert result == {f"/{_SUB_UUID}/mode/vs/0": {"mode": "cool"}} + assert sess.calls == [(_SUB_UUID, "device", "0")] def test_poll_subdevice_seed_flat_mode_polls_each_href_individually( @@ -425,21 +457,26 @@ def test_poll_subdevice_seed_flat_mode_polls_each_href_individually( from custom_components.localthings.registry.subdevices import Subdevice coordinator = _coordinator(hass) - sess = _FakeCollectionSession({ - (_SUB_UUID, 'mode', 'vs', '0'): {'mode': 'cool'}, - # (_SUB_UUID, 'power', 'vs', '0') deliberately absent -> drops out. - }) + sess = _FakeCollectionSession( + { + (_SUB_UUID, "mode", "vs", "0"): {"mode": "cool"}, + # (_SUB_UUID, 'power', 'vs', '0') deliberately absent -> drops out. + } + ) coordinator._session = sess subdevice = Subdevice( - kind='prefixed', key=_SUB_UUID, seed_path=(), - flat_hrefs=('/mode/vs/0', '/power/vs/0'), + kind="prefixed", + key=_SUB_UUID, + seed_path=(), + flat_hrefs=("/mode/vs/0", "/power/vs/0"), ) result = coordinator._poll_subdevice_seed(subdevice) - assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}} + assert result == {f"/{_SUB_UUID}/mode/vs/0": {"mode": "cool"}} assert sess.calls == [ - (_SUB_UUID, 'mode', 'vs', '0'), (_SUB_UUID, 'power', 'vs', '0'), + (_SUB_UUID, "mode", "vs", "0"), + (_SUB_UUID, "power", "vs", "0"), ] @@ -453,21 +490,25 @@ def test_poll_subdevice_seed_flat_mode_skips_hrefs_covered_by_hot_warm_subpolls( from custom_components.localthings.registry.subdevices import Subdevice coordinator = _coordinator(hass) - sess = _FakeCollectionSession({ - (_SUB_UUID, 'mode', 'vs', '0'): {'mode': 'cool'}, - (_SUB_UUID, 'power', 'vs', '0'): {'power': 'On'}, - }) + sess = _FakeCollectionSession( + { + (_SUB_UUID, "mode", "vs", "0"): {"mode": "cool"}, + (_SUB_UUID, "power", "vs", "0"): {"power": "On"}, + } + ) coordinator._session = sess - coordinator._warm_hrefs = [f'/{_SUB_UUID}/mode/vs/0'] + coordinator._warm_hrefs = [f"/{_SUB_UUID}/mode/vs/0"] subdevice = Subdevice( - kind='prefixed', key=_SUB_UUID, seed_path=(), - flat_hrefs=('/mode/vs/0', '/power/vs/0'), + kind="prefixed", + key=_SUB_UUID, + seed_path=(), + flat_hrefs=("/mode/vs/0", "/power/vs/0"), ) result = coordinator._poll_subdevice_seed(subdevice) - assert result == {f'/{_SUB_UUID}/power/vs/0': {'power': 'On'}} - assert sess.calls == [(_SUB_UUID, 'power', 'vs', '0')] + assert result == {f"/{_SUB_UUID}/power/vs/0": {"power": "On"}} + assert sess.calls == [(_SUB_UUID, "power", "vs", "0")] async def test_multidevice_probe_never_reaches_discovery_or_the_cache( @@ -485,24 +526,31 @@ async def test_multidevice_probe_never_reaches_discovery_or_the_cache( Driven with a washer fixture precisely because the AC registry's own ignore entry would mask the coverage half of this on an AC. """ - resources, _oic, _seeds = _load_device_full('washer_flexwash') + resources, _oic, _seeds = _load_device_full("washer_flexwash") coordinator = _coordinator(hass) - coordinator._session = FakeCoapSession({ - '/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'}, - }) + coordinator._session = FakeCoapSession( + { + "/multidevice/vs/0": {"x.com.samsung.da.numofsubdevice": "2"}, + } + ) coordinator._identity = DeviceIdentity( - manufacturer='Samsung Electronics', model='', name='', serial=None, - device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': []}, + manufacturer="Samsung Electronics", + model="", + name="", + serial=None, + device_types=(), + raw={"/oic/p": {}, "/oic/d": {}, "/oic/res": []}, ) merged = await hass.async_add_executor_job( - coordinator._enumerate_subdevices_blocking, resources, + coordinator._enumerate_subdevices_blocking, + resources, ) coordinator._run_discovery(merged) for href, rep in coordinator._live_subdevice_resources(merged).items(): - coordinator._observe.apply(href, rep, source='poll') + coordinator._observe.apply(href, rep, source="poll") - assert '/multidevice/vs/0' not in merged - assert '/multidevice/vs/0' not in coordinator._unbound_hrefs - assert '/multidevice/vs/0' not in coordinator.last_resources + assert "/multidevice/vs/0" not in merged + assert "/multidevice/vs/0" not in coordinator._unbound_hrefs + assert "/multidevice/vs/0" not in coordinator.last_resources # Still captured, just not as device state. - assert coordinator._multidevice == {'x.com.samsung.da.numofsubdevice': '2'} + assert coordinator._multidevice == {"x.com.samsung.da.numofsubdevice": "2"} diff --git a/tests/test_subdevices.py b/tests/test_subdevices.py index 19d3ebd..4074a59 100644 --- a/tests/test_subdevices.py +++ b/tests/test_subdevices.py @@ -3,6 +3,7 @@ device") support, issue #177. See DESIGN-177.md for the two board patterns (`ARTIK051_DONGLE_FAC_18K`'s indexed siblings, `TP2X_FAC_BORA_21K`'s UUID-prefixed tree) this module unifies. """ + from __future__ import annotations import cbor2 @@ -10,116 +11,122 @@ import cbor2 from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.entities import BinarySensorDesc, SensorDesc from custom_components.localthings.registry.subdevices import ( - MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices, + MAIN, + Subdevice, + canonical_view, + discover_partitioned, + enumerate_subdevices, normalize_seed_batch, ) -_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001' +_UUID = "6c2dff6d-ee5c-dad1-6a5e-000000000001" def _indexed(n: str) -> Subdevice: - return Subdevice(kind='indexed', key=n, seed_path=('device', n)) + return Subdevice(kind="indexed", key=n, seed_path=("device", n)) def _prefixed(sub_id: str) -> Subdevice: - return Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0')) + return Subdevice(kind="prefixed", key=sub_id, seed_path=(sub_id, "device", "0")) # --------------------------------------------------------------------------- # Subdevice.to_actual / to_canonical / owns / key_prefix # --------------------------------------------------------------------------- + class TestMainIsIdentity: def test_to_actual_unchanged(self): - assert MAIN.to_actual('/mode/vs/0') == '/mode/vs/0' + assert MAIN.to_actual("/mode/vs/0") == "/mode/vs/0" def test_to_canonical_unchanged(self): - assert MAIN.to_canonical('/mode/vs/0') == '/mode/vs/0' + assert MAIN.to_canonical("/mode/vs/0") == "/mode/vs/0" def test_owns_is_always_false(self): """MAIN never "owns" a href by this definition -- it gets whatever's left after every other subdevice's hrefs are excluded (canonical_view).""" - assert MAIN.owns('/mode/vs/0') is False - assert MAIN.owns('/mode/vs/1') is False + assert MAIN.owns("/mode/vs/0") is False + assert MAIN.owns("/mode/vs/1") is False def test_key_prefix_is_empty(self): """The master's flattened state keys must stay byte-identical to every device shipped before issue #177 -- see adapter._key.""" - assert MAIN.key_prefix == '' + assert MAIN.key_prefix == "" class TestIndexedTransform: def test_to_actual_rewrites_trailing_zero(self): - subdevice = _indexed('1') - assert subdevice.to_actual('/mode/vs/0') == '/mode/vs/1' - assert subdevice.to_actual('/device/0') == '/device/1' + subdevice = _indexed("1") + assert subdevice.to_actual("/mode/vs/0") == "/mode/vs/1" + assert subdevice.to_actual("/device/0") == "/device/1" def test_to_actual_leaves_non_zero_trailing_segment_alone(self): """Deliberately not a "replace any trailing digit" rule -- a genuine multi-instance resource (the fridge's pattern-cap hrefs, e.g. '/door/vs/1') must not be misread as a subdevice's own href.""" - subdevice = _indexed('1') - assert subdevice.to_actual('/door/vs/1') == '/door/vs/1' + subdevice = _indexed("1") + assert subdevice.to_actual("/door/vs/1") == "/door/vs/1" def test_to_canonical_round_trips(self): - subdevice = _indexed('1') - assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0' + subdevice = _indexed("1") + assert subdevice.to_canonical(subdevice.to_actual("/mode/vs/0")) == "/mode/vs/0" def test_to_canonical_rejects_wrong_index(self): - subdevice = _indexed('1') - assert subdevice.to_canonical('/mode/vs/2') is None + subdevice = _indexed("1") + assert subdevice.to_canonical("/mode/vs/2") is None def test_to_canonical_rejects_index_zero(self): """Index 0 belongs to MAIN, never to an indexed subdevice.""" - subdevice = _indexed('1') - assert subdevice.to_canonical('/mode/vs/0') is None + subdevice = _indexed("1") + assert subdevice.to_canonical("/mode/vs/0") is None def test_owns(self): - subdevice = _indexed('1') - assert subdevice.owns('/mode/vs/1') is True - assert subdevice.owns('/mode/vs/0') is False - assert subdevice.owns('/mode/vs/2') is False + subdevice = _indexed("1") + assert subdevice.owns("/mode/vs/1") is True + assert subdevice.owns("/mode/vs/0") is False + assert subdevice.owns("/mode/vs/2") is False def test_key_prefix(self): - assert _indexed('1').key_prefix == 'subdevice1_' - assert _indexed('2').key_prefix == 'subdevice2_' + assert _indexed("1").key_prefix == "subdevice1_" + assert _indexed("2").key_prefix == "subdevice2_" class TestPrefixedTransform: def test_to_actual_prepends_id(self): subdevice = _prefixed(_UUID) - assert subdevice.to_actual('/mode/vs/0') == f'/{_UUID}/mode/vs/0' + assert subdevice.to_actual("/mode/vs/0") == f"/{_UUID}/mode/vs/0" def test_to_canonical_round_trips(self): subdevice = _prefixed(_UUID) - assert subdevice.to_canonical(subdevice.to_actual('/mode/vs/0')) == '/mode/vs/0' + assert subdevice.to_canonical(subdevice.to_actual("/mode/vs/0")) == "/mode/vs/0" def test_to_canonical_rejects_missing_prefix(self): subdevice = _prefixed(_UUID) - assert subdevice.to_canonical('/mode/vs/0') is None + assert subdevice.to_canonical("/mode/vs/0") is None def test_to_canonical_requires_path_boundary(self): """A different, longer id that merely starts with the same characters must not be mistaken for this one's own href.""" subdevice = _prefixed(_UUID) - assert subdevice.to_canonical(f'/{_UUID}extra/mode/vs/0') is None + assert subdevice.to_canonical(f"/{_UUID}extra/mode/vs/0") is None def test_owns(self): subdevice = _prefixed(_UUID) - assert subdevice.owns(f'/{_UUID}/mode/vs/0') is True - assert subdevice.owns('/mode/vs/0') is False + assert subdevice.owns(f"/{_UUID}/mode/vs/0") is True + assert subdevice.owns("/mode/vs/0") is False def test_key_prefix_strips_non_alphanumerics(self): - assert _prefixed(_UUID).key_prefix == 'subdevice_6c2dff6dee5cdad16a5e000000000001_' + assert _prefixed(_UUID).key_prefix == "subdevice_6c2dff6dee5cdad16a5e000000000001_" # --------------------------------------------------------------------------- # canonical_view # --------------------------------------------------------------------------- + class TestCanonicalView: def test_main_with_no_subdevices_is_unchanged(self): - resources = {'/mode/vs/0': {'a': 1}, '/power/vs/0': {'b': 2}} + resources = {"/mode/vs/0": {"a": 1}, "/power/vs/0": {"b": 2}} assert canonical_view(MAIN, resources, []) == resources def test_main_excludes_subdevice_owned_hrefs(self): @@ -128,63 +135,65 @@ class TestCanonicalView: checks that scan the whole dict would see two subdevices' state mixed together under one key.""" resources = { - '/mode/vs/0': {'unit': 'main'}, - '/mode/vs/1': {'unit': 'sibling'}, + "/mode/vs/0": {"unit": "main"}, + "/mode/vs/1": {"unit": "sibling"}, } - sub1 = _indexed('1') + sub1 = _indexed("1") view = canonical_view(MAIN, resources, [sub1]) - assert view == {'/mode/vs/0': {'unit': 'main'}} + assert view == {"/mode/vs/0": {"unit": "main"}} def test_indexed_view_is_rewritten_to_canonical_hrefs(self): resources = { - '/mode/vs/0': {'unit': 'main'}, - '/mode/vs/1': {'unit': 'sibling'}, - '/power/vs/1': {'p': 'On'}, + "/mode/vs/0": {"unit": "main"}, + "/mode/vs/1": {"unit": "sibling"}, + "/power/vs/1": {"p": "On"}, } - sub1 = _indexed('1') + sub1 = _indexed("1") view = canonical_view(sub1, resources, [sub1]) assert view == { - '/mode/vs/0': {'unit': 'sibling'}, - '/power/vs/0': {'p': 'On'}, + "/mode/vs/0": {"unit": "sibling"}, + "/power/vs/0": {"p": "On"}, } def test_prefixed_view_is_rewritten_to_canonical_hrefs(self): resources = { - f'/{_UUID}/mode/vs/0': {'unit': 'sub'}, - '/mode/vs/0': {'unit': 'main'}, + f"/{_UUID}/mode/vs/0": {"unit": "sub"}, + "/mode/vs/0": {"unit": "main"}, } subdevice = _prefixed(_UUID) view = canonical_view(subdevice, resources, [subdevice]) - assert view == {'/mode/vs/0': {'unit': 'sub'}} + assert view == {"/mode/vs/0": {"unit": "sub"}} def test_including_main_in_subdevices_is_harmless(self): """MAIN.owns() is always False, so passing the full roster (including MAIN itself) to canonical_view must not change anything.""" - resources = {'/mode/vs/0': {'unit': 'main'}, '/mode/vs/1': {'unit': 'sib'}} - sub1 = _indexed('1') - assert (canonical_view(MAIN, resources, [MAIN, sub1]) - == canonical_view(MAIN, resources, [sub1])) + resources = {"/mode/vs/0": {"unit": "main"}, "/mode/vs/1": {"unit": "sib"}} + sub1 = _indexed("1") + assert canonical_view(MAIN, resources, [MAIN, sub1]) == canonical_view( + MAIN, resources, [sub1] + ) # --------------------------------------------------------------------------- # normalize_seed_batch # --------------------------------------------------------------------------- + def test_normalize_seed_batch_indexed_is_a_no_op(): - subdevice = _indexed('1') - batch = {'/mode/vs/1': {'a': 1}} + subdevice = _indexed("1") + batch = {"/mode/vs/1": {"a": 1}} assert normalize_seed_batch(subdevice, batch) == batch def test_normalize_seed_batch_prefixed_adds_missing_prefix(): subdevice = _prefixed(_UUID) - batch = {'/mode/vs/0': {'a': 1}} - assert normalize_seed_batch(subdevice, batch) == {f'/{_UUID}/mode/vs/0': {'a': 1}} + batch = {"/mode/vs/0": {"a": 1}} + assert normalize_seed_batch(subdevice, batch) == {f"/{_UUID}/mode/vs/0": {"a": 1}} def test_normalize_seed_batch_prefixed_leaves_already_prefixed_alone(): subdevice = _prefixed(_UUID) - batch = {f'/{_UUID}/mode/vs/0': {'a': 1}} + batch = {f"/{_UUID}/mode/vs/0": {"a": 1}} assert normalize_seed_batch(subdevice, batch) == batch @@ -192,6 +201,7 @@ def test_normalize_seed_batch_prefixed_leaves_already_prefixed_alone(): # enumerate_subdevices # --------------------------------------------------------------------------- + class _FakeSession: """path-tuple -> raw batch list, cbor-encoded on GET -- same shape as test_identity.py's FakeSession, extended to serve Collection batches @@ -203,29 +213,39 @@ class _FakeSession: def get(self, path, timeout=10.0): body = self.table.get(tuple(path)) if body is None: - return 0x84, b'' + return 0x84, b"" return 0x45, cbor2.dumps(body) def pace(self): pass -_DEVCOL_REP = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']} +_DEVCOL_REP = {"rt": ["x.com.samsung.devcol", "oic.wk.col"]} def test_enumerate_indexed_from_oic_res_links(): - oic_res = [{'di': 'aaaa', 'links': [ - {'href': '/device/0'}, {'href': '/device/1'}, {'href': '/device/2'}, - ]}] - sess = _FakeSession({ - ('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}], - ('device', '2'): [_DEVCOL_REP, {'href': '/mode/vs/2', 'rep': {'m': 2}}], - }) + oic_res = [ + { + "di": "aaaa", + "links": [ + {"href": "/device/0"}, + {"href": "/device/1"}, + {"href": "/device/2"}, + ], + } + ] + sess = _FakeSession( + { + ("device", "1"): [_DEVCOL_REP, {"href": "/mode/vs/1", "rep": {"m": 1}}], + ("device", "2"): [_DEVCOL_REP, {"href": "/mode/vs/2", "rep": {"m": 2}}], + } + ) subdevices, extra = enumerate_subdevices(sess, {}, oic_res) assert sorted((u.kind, u.key) for u in subdevices) == [ - ('indexed', '1'), ('indexed', '2'), + ("indexed", "1"), + ("indexed", "2"), ] - assert extra == {'/mode/vs/1': {'m': 1}, '/mode/vs/2': {'m': 2}} + assert extra == {"/mode/vs/1": {"m": 1}, "/mode/vs/2": {"m": 2}} def test_enumerate_indexed_falls_back_to_speculative_probe_when_oic_res_hides_the_tree(): @@ -233,20 +253,22 @@ def test_enumerate_indexed_falls_back_to_speculative_probe_when_oic_res_hides_th it hides the whole tree, TP2X_FAC_BORA_21K-style) falls back to probing /device/1 and /device/2 directly -- the same bound the old identity.py-level probe used.""" - sess = _FakeSession({ - ('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}], - # /device/2 not in the table -> 4.04 -> not materialized. - }) + sess = _FakeSession( + { + ("device", "1"): [_DEVCOL_REP, {"href": "/mode/vs/1", "rep": {"m": 1}}], + # /device/2 not in the table -> 4.04 -> not materialized. + } + ) subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[]) - assert [(u.kind, u.key) for u in subdevices] == [('indexed', '1')] - assert extra == {'/mode/vs/1': {'m': 1}} + assert [(u.kind, u.key) for u in subdevices] == [("indexed", "1")] + assert extra == {"/mode/vs/1": {"m": 1}} def test_enumerate_indexed_only_materializes_subdevices_with_a_non_empty_batch(): """Per issue #177's design call: any seed that answers with a non-empty batch is materialized, but an empty ({}) answer is not -- no separate "is this subdevice real" gate.""" - sess = _FakeSession({}) # neither /device/1 nor /device/2 answers + sess = _FakeSession({}) # neither /device/1 nor /device/2 answers subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[]) assert subdevices == [] assert extra == {} @@ -254,17 +276,20 @@ def test_enumerate_indexed_only_materializes_subdevices_with_a_non_empty_batch() def test_enumerate_prefixed_from_subdevice_id_list(): resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]}, } - sess = _FakeSession({ - (_UUID, 'device', '0'): [ - _DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'cool'}}, - ], - }) + sess = _FakeSession( + { + (_UUID, "device", "0"): [ + _DEVCOL_REP, + {"href": "/mode/vs/0", "rep": {"m": "cool"}}, + ], + } + ) subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[]) - assert [(u.kind, u.key) for u in subdevices] == [('prefixed', _UUID)] + assert [(u.kind, u.key) for u in subdevices] == [("prefixed", _UUID)] # Batch echoed the bare (unprefixed) href -- normalized to carry the id. - assert extra == {f'/{_UUID}/mode/vs/0': {'m': 'cool'}} + assert extra == {f"/{_UUID}/mode/vs/0": {"m": "cool"}} def test_enumerate_prefixed_falls_back_to_flat_hrefs_when_device0_collection_is_empty(): @@ -274,30 +299,32 @@ def test_enumerate_prefixed_falls_back_to_flat_hrefs_when_device0_collection_is_ enumeration probes every href the master itself answered this cycle, individually, under the UUID prefix, and keeps whichever ones answer.""" resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, - '/mode/vs/0': {'m': 'cool'}, - '/power/vs/0': {'p': 'On'}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]}, + "/mode/vs/0": {"m": "cool"}, + "/power/vs/0": {"p": "On"}, } - sess = _FakeSession({ - # (_UUID, 'device', '0') deliberately absent -> Collection probe fails. - (_UUID, 'mode', 'vs', '0'): {'mode': 'cool'}, - # (_UUID, 'power', 'vs', '0') deliberately absent -> drops out. - }) + sess = _FakeSession( + { + # (_UUID, 'device', '0') deliberately absent -> Collection probe fails. + (_UUID, "mode", "vs", "0"): {"mode": "cool"}, + # (_UUID, 'power', 'vs', '0') deliberately absent -> drops out. + } + ) subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[]) assert len(subdevices) == 1 subdevice = subdevices[0] - assert (subdevice.kind, subdevice.key) == ('prefixed', _UUID) + assert (subdevice.kind, subdevice.key) == ("prefixed", _UUID) assert subdevice.seed_path == () - assert subdevice.flat_hrefs == ('/mode/vs/0',) - assert extra == {f'/{_UUID}/mode/vs/0': {'mode': 'cool'}} + assert subdevice.flat_hrefs == ("/mode/vs/0",) + assert extra == {f"/{_UUID}/mode/vs/0": {"mode": "cool"}} def test_enumerate_prefixed_flat_fallback_materializes_nothing_when_no_href_answers(): """Same posture as every other candidate check in this module: nothing answering means no candidate, not a crash.""" resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, - '/mode/vs/0': {'m': 'cool'}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]}, + "/mode/vs/0": {"m": "cool"}, } subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[]) assert subdevices == [] @@ -306,16 +333,18 @@ def test_enumerate_prefixed_flat_fallback_materializes_nothing_when_no_href_answ def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried(): resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, - '/mode/vs/0': {'m': 'cool'}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]}, + "/mode/vs/0": {"m": "cool"}, } - sess = _FakeSession({ - (_UUID, 'mode', 'vs', '0'): {'mode': 'cool'}, - }) + sess = _FakeSession( + { + (_UUID, "mode", "vs", "0"): {"mode": "cool"}, + } + ) probes: dict[str, bool] = {} enumerate_subdevices(sess, resources, oic_res_links=[], probe_log=probes.__setitem__) - assert probes[f'/{_UUID}/device/0'] is False - assert probes[f'/{_UUID}/mode/vs/0'] is True + assert probes[f"/{_UUID}/device/0"] is False + assert probes[f"/{_UUID}/mode/vs/0"] is True def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op(): @@ -325,7 +354,7 @@ def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_ /subdevices/vs/0 itself, which the fake session doesn't answer under the prefix either, so nothing materializes.""" resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]}, } subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[]) assert subdevices == [] @@ -336,30 +365,33 @@ def test_enumerate_prefixed_flat_fallback_does_not_cross_contaminate_a_second_uu """Two prefixed candidates in the same subdeviceIdList, one whose Collection endpoint works and one that needs the flat fallback -- each must end up with only its own hrefs, no bleed between them.""" - uuid_a, uuid_b = _UUID, '11111111-1111-1111-1111-111111111111' + uuid_a, uuid_b = _UUID, "11111111-1111-1111-1111-111111111111" resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [uuid_a, uuid_b]}, - '/mode/vs/0': {'m': 'cool'}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [uuid_a, uuid_b]}, + "/mode/vs/0": {"m": "cool"}, } - sess = _FakeSession({ - (uuid_a, 'device', '0'): [ - _DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'a-collection'}}, - ], - # uuid_b's Collection deliberately absent -> falls back to the flat - # per-href probe. - (uuid_b, 'mode', 'vs', '0'): {'m': 'b-flat'}, - }) + sess = _FakeSession( + { + (uuid_a, "device", "0"): [ + _DEVCOL_REP, + {"href": "/mode/vs/0", "rep": {"m": "a-collection"}}, + ], + # uuid_b's Collection deliberately absent -> falls back to the flat + # per-href probe. + (uuid_b, "mode", "vs", "0"): {"m": "b-flat"}, + } + ) subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[]) by_key = {u.key: u for u in subdevices} assert set(by_key) == {uuid_a, uuid_b} - assert by_key[uuid_a].seed_path == (uuid_a, 'device', '0') + assert by_key[uuid_a].seed_path == (uuid_a, "device", "0") assert by_key[uuid_a].flat_hrefs == () assert by_key[uuid_b].seed_path == () - assert by_key[uuid_b].flat_hrefs == ('/mode/vs/0',) + assert by_key[uuid_b].flat_hrefs == ("/mode/vs/0",) assert extra == { - f'/{uuid_a}/mode/vs/0': {'m': 'a-collection'}, - f'/{uuid_b}/mode/vs/0': {'m': 'b-flat'}, + f"/{uuid_a}/mode/vs/0": {"m": "a-collection"}, + f"/{uuid_b}/mode/vs/0": {"m": "b-flat"}, } @@ -368,7 +400,7 @@ def test_enumerate_prefixed_tolerates_redacted_string_id_list(): real airconditioner_fac_bora_device.json fixture carries the literal redacted string there -- this must yield zero subdevices, not crash.""" resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': 'REDACTED'}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": "REDACTED"}, } subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[]) assert subdevices == [] @@ -387,26 +419,33 @@ def test_enumerate_indexed_materializes_candidate_regardless_of_content(): /device/2) -- that's discover_partitioned's job (see its own tests below). A same-shaped batch of otherwise-empty reps is still returned as a candidate here.""" - oic_res = [{'di': 'a', 'links': [{'href': '/device/2'}]}] - sess = _FakeSession({ - ('device', '2'): [_DEVCOL_REP, {'href': '/power/vs/2', 'rep': {}}, - {'href': '/configuration/vs/2', 'rep': {'region': '123'}}], - }) + oic_res = [{"di": "a", "links": [{"href": "/device/2"}]}] + sess = _FakeSession( + { + ("device", "2"): [ + _DEVCOL_REP, + {"href": "/power/vs/2", "rep": {}}, + {"href": "/configuration/vs/2", "rep": {"region": "123"}}, + ], + } + ) subdevices, extra = enumerate_subdevices(sess, {}, oic_res) - assert [(u.kind, u.key) for u in subdevices] == [('indexed', '2')] - assert extra == {'/power/vs/2': {}, '/configuration/vs/2': {'region': '123'}} + assert [(u.kind, u.key) for u in subdevices] == [("indexed", "2")] + assert extra == {"/power/vs/2": {}, "/configuration/vs/2": {"region": "123"}} def test_enumerate_probe_log_reports_every_attempt(): - oic_res = [{'di': 'aaaa', 'links': [{'href': '/device/1'}]}] - sess = _FakeSession({ - ('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 1}}], - }) + oic_res = [{"di": "aaaa", "links": [{"href": "/device/1"}]}] + sess = _FakeSession( + { + ("device", "1"): [_DEVCOL_REP, {"href": "/mode/vs/1", "rep": {"m": 1}}], + } + ) probes: dict[str, bool] = {} enumerate_subdevices(sess, {}, oic_res, probe_log=probes.__setitem__) # /multidevice/vs/0 is always probed too (issue #177 follow-up) -- not # in this session's table, so it reports "checked, nothing there". - assert probes == {'/device/1': True, '/multidevice/vs/0': False} + assert probes == {"/device/1": True, "/multidevice/vs/0": False} def test_enumerate_probe_log_reports_empty_answers_too(): @@ -416,7 +455,9 @@ def test_enumerate_probe_log_reports_empty_answers_too(): probes: dict[str, bool] = {} enumerate_subdevices(_FakeSession({}), {}, oic_res_links=[], probe_log=probes.__setitem__) assert probes == { - '/device/1': False, '/device/2': False, '/multidevice/vs/0': False, + "/device/1": False, + "/device/2": False, + "/multidevice/vs/0": False, } @@ -424,13 +465,15 @@ def test_enumerate_multidevice_vs_0_is_captured_for_diagnostics_not_a_gate(): """/multidevice/vs/0 (issue #177 follow-up) is corroborating evidence only -- captured into the merged resources so diagnostics can report it, never consulted by the liveness gate itself.""" - sess = _FakeSession({ - ('multidevice', 'vs', '0'): {'x.com.samsung.da.numofsubdevice': '2'}, - }) + sess = _FakeSession( + { + ("multidevice", "vs", "0"): {"x.com.samsung.da.numofsubdevice": "2"}, + } + ) subdevices, extra = enumerate_subdevices(sess, {}, oic_res_links=[]) - assert subdevices == [] # no /device/ or subdevice id answered + assert subdevices == [] # no /device/ or subdevice id answered assert extra == { - '/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'}, + "/multidevice/vs/0": {"x.com.samsung.da.numofsubdevice": "2"}, } @@ -438,16 +481,19 @@ def test_enumerate_both_patterns_checked_independently(): """No board needs disambiguation logic -- enumeration just checks both signals and takes whatever answers.""" resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]}, } - oic_res = [{'di': 'a', 'links': [{'href': '/device/1'}]}] - sess = _FakeSession({ - (_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}], - ('device', '1'): [_DEVCOL_REP, {'href': '/mode/vs/1', 'rep': {'m': 2}}], - }) - subdevices, extra = enumerate_subdevices(sess, resources, oic_res) + oic_res = [{"di": "a", "links": [{"href": "/device/1"}]}] + sess = _FakeSession( + { + (_UUID, "device", "0"): [_DEVCOL_REP, {"href": "/mode/vs/0", "rep": {"m": 1}}], + ("device", "1"): [_DEVCOL_REP, {"href": "/mode/vs/1", "rep": {"m": 2}}], + } + ) + subdevices, _extra = enumerate_subdevices(sess, resources, oic_res) assert sorted((u.kind, u.key) for u in subdevices) == [ - ('indexed', '1'), ('prefixed', _UUID), + ("indexed", "1"), + ("prefixed", _UUID), ] @@ -457,16 +503,23 @@ def test_enumerate_prefixed_id_named_by_both_subdevice_id_list_and_oic_res_is_no (Pattern C's) -- the TP2X_FAC_BORA_21K reporter's own board does this. The two signals must resolve to one candidate, not two.""" resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [_UUID]}, } - oic_res = [{'di': 'a', 'links': [ - {'href': f'/{_UUID}/multidevice/vs/0'}, - ]}] - sess = _FakeSession({ - (_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}], - }) + oic_res = [ + { + "di": "a", + "links": [ + {"href": f"/{_UUID}/multidevice/vs/0"}, + ], + } + ] + sess = _FakeSession( + { + (_UUID, "device", "0"): [_DEVCOL_REP, {"href": "/mode/vs/0", "rep": {"m": 1}}], + } + ) subdevices, _extra = enumerate_subdevices(sess, resources, oic_res) - assert [(u.kind, u.key) for u in subdevices] == [('prefixed', _UUID)] + assert [(u.kind, u.key) for u in subdevices] == [("prefixed", _UUID)] def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated(): @@ -475,18 +528,25 @@ def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated() same physical subdevice materializes twice under two different keys.""" upper_uuid = _UUID.upper() resources = { - '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [upper_uuid]}, + "/subdevices/vs/0": {"x.com.samsung.da.subdeviceIdList": [upper_uuid]}, } - oic_res = [{'di': 'a', 'links': [ - {'href': f'/{_UUID}/multidevice/vs/0'}, - ]}] - sess = _FakeSession({ - (upper_uuid, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}], - (_UUID, 'device', '0'): [_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 1}}], - }) + oic_res = [ + { + "di": "a", + "links": [ + {"href": f"/{_UUID}/multidevice/vs/0"}, + ], + } + ] + sess = _FakeSession( + { + (upper_uuid, "device", "0"): [_DEVCOL_REP, {"href": "/mode/vs/0", "rep": {"m": 1}}], + (_UUID, "device", "0"): [_DEVCOL_REP, {"href": "/mode/vs/0", "rep": {"m": 1}}], + } + ) subdevices, _extra = enumerate_subdevices(sess, resources, oic_res) assert len(subdevices) == 1 - assert subdevices[0].kind == 'prefixed' + assert subdevices[0].kind == "prefixed" assert subdevices[0].key.lower() == _UUID @@ -494,6 +554,7 @@ def test_enumerate_prefixed_id_case_mismatch_between_sources_is_not_duplicated() # discover_partitioned # --------------------------------------------------------------------------- + class _FakeRegistry: def __init__(self, name, capabilities, pattern_capabilities=()): self.name = name @@ -503,32 +564,35 @@ class _FakeRegistry: def test_discover_partitioned_binds_main_and_subdevice_separately(): mode_cap = Capability( - href='/mode/vs/0', - entities=(BinarySensorDesc(key='mode', field='m'),), + href="/mode/vs/0", + entities=(BinarySensorDesc(key="mode", field="m"),), ) - registry = {'/mode/vs/0': [mode_cap]} - reg = _FakeRegistry('airconditioner', registry) + registry = {"/mode/vs/0": [mode_cap]} + reg = _FakeRegistry("airconditioner", registry) - sub1 = _indexed('1') + sub1 = _indexed("1") resources = { - '/mode/vs/0': {'m': 'main'}, - '/mode/vs/1': {'m': 'sibling'}, + "/mode/vs/0": {"m": "main"}, + "/mode/vs/1": {"m": "sibling"}, } def resolve(_resources, **_kwargs): return reg bound, device_type_name, materialized, skipped = discover_partitioned( - resources, [sub1], resolve, fallback_capabilities={}, + resources, + [sub1], + resolve, + fallback_capabilities={}, ) - assert device_type_name == 'airconditioner' + assert device_type_name == "airconditioner" assert materialized == [sub1] assert skipped == [] by_href = {b.href: b for b in bound} - assert set(by_href) == {'/mode/vs/0', '/mode/vs/1'} - assert by_href['/mode/vs/0'].subdevice == MAIN - assert by_href['/mode/vs/1'].subdevice == sub1 + assert set(by_href) == {"/mode/vs/0", "/mode/vs/1"} + assert by_href["/mode/vs/0"].subdevice == MAIN + assert by_href["/mode/vs/1"].subdevice == sub1 def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound(): @@ -545,17 +609,20 @@ def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound(): is keyed on the literal string '/mode/vs/1'. With correct partitioning the main pass never sees that href at all (canonical_view excludes it), and the subdevice pass resolves it via its own canonical '/mode/vs/0'.""" - cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='mode', field='m'),)) - reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]}) - sub1 = _indexed('1') + cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="mode", field="m"),)) + reg = _FakeRegistry("airconditioner", {"/mode/vs/0": [cap]}) + sub1 = _indexed("1") resources = { - '/mode/vs/0': {'m': 'main'}, - '/mode/vs/1': {'m': 'sibling'}, + "/mode/vs/0": {"m": "main"}, + "/mode/vs/1": {"m": "sibling"}, } unbound = [] discover_partitioned( - resources, [sub1], lambda r, **_: reg, fallback_capabilities={}, + resources, + [sub1], + lambda r, **_: reg, + fallback_capabilities={}, log=unbound.append, ) assert unbound == [] @@ -565,27 +632,30 @@ def test_discover_partitioned_subdevice_resolves_its_own_registry(): """A subdevice reporting its own /information/vs/0 resolves its own device type (issue #177's real wall subdevice: TP2X_FAC_BORA_RAC_21K -> RAC -> airconditioner) independent of the master's.""" - main_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),)) - sub_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m2', field='x'),)) - main_reg = _FakeRegistry('main_type', {'/mode/vs/0': [main_cap]}) - sub_reg = _FakeRegistry('sub_type', {'/mode/vs/0': [sub_cap]}) + main_cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),)) + sub_cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m2", field="x"),)) + main_reg = _FakeRegistry("main_type", {"/mode/vs/0": [main_cap]}) + sub_reg = _FakeRegistry("sub_type", {"/mode/vs/0": [sub_cap]}) - sub1 = _indexed('1') - resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}} + sub1 = _indexed("1") + resources = {"/mode/vs/0": {"x": 1}, "/mode/vs/1": {"x": 2}} def resolve(view, **_kwargs): # The subdevice's canonical view is exactly {'/mode/vs/0': {'x': 2}}. - return sub_reg if view.get('/mode/vs/0', {}).get('x') == 2 else main_reg + return sub_reg if view.get("/mode/vs/0", {}).get("x") == 2 else main_reg bound, device_type_name, materialized, skipped = discover_partitioned( - resources, [sub1], resolve, fallback_capabilities={}, + resources, + [sub1], + resolve, + fallback_capabilities={}, ) - assert device_type_name == 'main_type' + assert device_type_name == "main_type" assert materialized == [sub1] assert skipped == [] keys = {(b.href, b.desc.key) for b in bound} - assert ('/mode/vs/0', 'm') in keys - assert ('/mode/vs/1', 'm2') in keys + assert ("/mode/vs/0", "m") in keys + assert ("/mode/vs/1", "m2") in keys def test_discover_partitioned_subdevice_falls_back_to_master_registry(): @@ -593,44 +663,50 @@ def test_discover_partitioned_subdevice_falls_back_to_master_registry(): master's registry instead of the global fallback -- the master itself must actually resolve here (it reports /information/vs/0), or there is no master registry for the fallback to reach for.""" - cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),)) - main_reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]}) - sub1 = _indexed('1') + cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),)) + main_reg = _FakeRegistry("airconditioner", {"/mode/vs/0": [cap]}) + sub1 = _indexed("1") resources = { - '/information/vs/0': {'model': 'main'}, - '/mode/vs/0': {'x': 1}, - '/mode/vs/1': {'x': 2}, + "/information/vs/0": {"model": "main"}, + "/mode/vs/0": {"x": 1}, + "/mode/vs/1": {"x": 2}, } def resolve(view, **_kwargs): - return main_reg if view.get('/information/vs/0') else None + return main_reg if view.get("/information/vs/0") else None bound, _, materialized, skipped = discover_partitioned( - resources, [sub1], resolve, fallback_capabilities={}, + resources, + [sub1], + resolve, + fallback_capabilities={}, ) assert materialized == [sub1] assert skipped == [] hrefs = {b.href for b in bound} - assert '/mode/vs/1' in hrefs + assert "/mode/vs/1" in hrefs def test_discover_partitioned_oic_device_types_resolves_main_pass(): """`oic_device_types` reaches the main-pass resolve call as `device_types=`, letting a fake `resolve_registry` mirror the real resolve()'s precedence between /oic/d and model-based detection.""" - cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),)) - oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]}) - resources = {'/mode/vs/0': {'x': 1}} + cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),)) + oic_reg = _FakeRegistry("washer", {"/mode/vs/0": [cap]}) + resources = {"/mode/vs/0": {"x": 1}} def resolve(_resources, device_types=()): - return oic_reg if 'oic.d.washer' in device_types else None + return oic_reg if "oic.d.washer" in device_types else None bound, device_type_name, _, _ = discover_partitioned( - resources, [], resolve, fallback_capabilities={}, - oic_device_types=('oic.d.washer',), + resources, + [], + resolve, + fallback_capabilities={}, + oic_device_types=("oic.d.washer",), ) - assert device_type_name == 'washer' - assert {b.href for b in bound} == {'/mode/vs/0'} + assert device_type_name == "washer" + assert {b.href for b in bound} == {"/mode/vs/0"} def test_discover_partitioned_oic_device_types_not_applied_to_subdevices(): @@ -639,25 +715,28 @@ def test_discover_partitioned_oic_device_types_not_applied_to_subdevices(): resolver keyed purely on device_types resolves nothing for the subdevice pass and it falls back to the master's registry, per the documented (and unchanged) subdevice-fallback behavior.""" - cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),)) - oic_reg = _FakeRegistry('washer', {'/mode/vs/0': [cap]}) - sub1 = _indexed('1') - resources = {'/mode/vs/0': {'x': 1}, '/mode/vs/1': {'x': 2}} + cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),)) + oic_reg = _FakeRegistry("washer", {"/mode/vs/0": [cap]}) + sub1 = _indexed("1") + resources = {"/mode/vs/0": {"x": 1}, "/mode/vs/1": {"x": 2}} def resolve(_resources, device_types=()): - return oic_reg if 'oic.d.washer' in device_types else None + return oic_reg if "oic.d.washer" in device_types else None bound, device_type_name, materialized, skipped = discover_partitioned( - resources, [sub1], resolve, fallback_capabilities={}, - oic_device_types=('oic.d.washer',), + resources, + [sub1], + resolve, + fallback_capabilities={}, + oic_device_types=("oic.d.washer",), ) - assert device_type_name == 'washer' + assert device_type_name == "washer" assert materialized == [sub1] assert skipped == [] # The subdevice's own resolve() call sees no device_types and returns # None, falling back to the master's oic_reg -- same capabilities, so # /mode/vs/1 still binds via that shared registry. - assert {b.href for b in bound} == {'/mode/vs/0', '/mode/vs/1'} + assert {b.href for b in bound} == {"/mode/vs/0", "/mode/vs/1"} def test_discover_partitioned_no_subdevices_matches_plain_discover(): @@ -666,12 +745,15 @@ def test_discover_partitioned_no_subdevices_matches_plain_discover(): design exists to protect.""" from custom_components.localthings.registry.discovery import discover - cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),)) - reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]}) - resources = {'/mode/vs/0': {'x': 1}} + cap = Capability(href="/mode/vs/0", entities=(BinarySensorDesc(key="m", field="x"),)) + reg = _FakeRegistry("airconditioner", {"/mode/vs/0": [cap]}) + resources = {"/mode/vs/0": {"x": 1}} bound_via_helper, _, materialized, skipped = discover_partitioned( - resources, [], lambda r, **_: reg, fallback_capabilities={}, + resources, + [], + lambda r, **_: reg, + fallback_capabilities={}, ) bound_direct = discover(resources, reg.capabilities, reg.pattern_capabilities) @@ -688,28 +770,32 @@ def test_discover_partitioned_no_subdevices_matches_plain_discover(): # same shape. # --------------------------------------------------------------------------- + def test_discover_partitioned_skips_candidate_with_no_live_primary_entity(): """A candidate whose only populated entity is diagnostic-category doesn't count -- exactly the reporter's /device/2 shape (an alarm_code sensor reading something even though the subdevice itself is empty).""" diag_cap = Capability( - href='/alarms/vs/0', - entities=(SensorDesc(key='alarm_code', field='code', entity_category='diagnostic'),), + href="/alarms/vs/0", + entities=(SensorDesc(key="alarm_code", field="code", entity_category="diagnostic"),), ) - reg = _FakeRegistry('airconditioner', {'/alarms/vs/0': [diag_cap]}) - unit2 = _indexed('2') + reg = _FakeRegistry("airconditioner", {"/alarms/vs/0": [diag_cap]}) + unit2 = _indexed("2") resources = { - '/alarms/vs/0': {'code': 'main-alarm'}, - '/alarms/vs/2': {'code': 'ErrorCode_OFF'}, # populated, but diagnostic-only + "/alarms/vs/0": {"code": "main-alarm"}, + "/alarms/vs/2": {"code": "ErrorCode_OFF"}, # populated, but diagnostic-only } bound, _, materialized, skipped = discover_partitioned( - resources, [unit2], lambda r, **_: reg, fallback_capabilities={}, + resources, + [unit2], + lambda r, **_: reg, + fallback_capabilities={}, ) assert materialized == [] assert len(skipped) == 1 assert skipped[0].subdevice == unit2 - assert skipped[0].hrefs == ('/alarms/vs/2',) + assert skipped[0].hrefs == ("/alarms/vs/2",) # The candidate contributes nothing at all -- not even its diagnostic # entity -- once skipped. assert all(b.subdevice != unit2 for b in bound) @@ -720,31 +806,35 @@ def test_discover_partitioned_materializes_candidate_with_live_primary_entity(): is materialized, even alongside an all-empty diagnostic sibling href -- exactly the reporter's /device/1 shape.""" climate_cap = Capability( - href='/mode/vs/0', - entities=(BinarySensorDesc(key='mode', field='m'),), # no entity_category -> primary + href="/mode/vs/0", + entities=(BinarySensorDesc(key="mode", field="m"),), # no entity_category -> primary ) diag_cap = Capability( - href='/alarms/vs/0', - entities=(SensorDesc(key='alarm_code', field='code', entity_category='diagnostic'),), + href="/alarms/vs/0", + entities=(SensorDesc(key="alarm_code", field="code", entity_category="diagnostic"),), ) reg = _FakeRegistry( - 'airconditioner', {'/mode/vs/0': [climate_cap], '/alarms/vs/0': [diag_cap]}, + "airconditioner", + {"/mode/vs/0": [climate_cap], "/alarms/vs/0": [diag_cap]}, ) - sub1 = _indexed('1') + sub1 = _indexed("1") resources = { - '/mode/vs/0': {'m': 'main'}, - '/alarms/vs/0': {'code': 'main-alarm'}, - '/mode/vs/1': {'m': 'Cool'}, - '/alarms/vs/1': {}, # empty -- not what makes this subdevice live + "/mode/vs/0": {"m": "main"}, + "/alarms/vs/0": {"code": "main-alarm"}, + "/mode/vs/1": {"m": "Cool"}, + "/alarms/vs/1": {}, # empty -- not what makes this subdevice live } bound, _, materialized, skipped = discover_partitioned( - resources, [sub1], lambda r, **_: reg, fallback_capabilities={}, + resources, + [sub1], + lambda r, **_: reg, + fallback_capabilities={}, ) assert materialized == [sub1] assert skipped == [] hrefs = {b.href for b in bound if b.subdevice == sub1} - assert hrefs == {'/mode/vs/1', '/alarms/vs/1'} + assert hrefs == {"/mode/vs/1", "/alarms/vs/1"} def test_discover_partitioned_skips_candidate_whose_only_live_primary_is_a_meter(): @@ -755,29 +845,34 @@ def test_discover_partitioned_skips_candidate_whose_only_live_primary_is_a_meter air conditioner. It's the appliance's own counter, not proof a second indoor unit is installed at that slot.""" energy_cap = Capability( - href='/energy/consumption/vs/0', - entities=(SensorDesc(key='energy_kwh', field='kwh', - device_class='energy', - state_class='total_increasing'),), # primary, but a meter + href="/energy/consumption/vs/0", + entities=( + SensorDesc( + key="energy_kwh", field="kwh", device_class="energy", state_class="total_increasing" + ), + ), # primary, but a meter ) climate_cap = Capability( - href='/mode/vs/0', - entities=(BinarySensorDesc(key='mode', field='m'),), + href="/mode/vs/0", + entities=(BinarySensorDesc(key="mode", field="m"),), ) reg = _FakeRegistry( - 'airconditioner', - {'/energy/consumption/vs/0': [energy_cap], '/mode/vs/0': [climate_cap]}, + "airconditioner", + {"/energy/consumption/vs/0": [energy_cap], "/mode/vs/0": [climate_cap]}, ) - unit1 = _indexed('1') + unit1 = _indexed("1") resources = { - '/energy/consumption/vs/0': {'kwh': 1175.2}, - '/mode/vs/0': {'m': 'Cool'}, - '/energy/consumption/vs/1': {'kwh': 1175.2}, # populated, but a meter - '/mode/vs/1': {}, # the slot's real state: empty + "/energy/consumption/vs/0": {"kwh": 1175.2}, + "/mode/vs/0": {"m": "Cool"}, + "/energy/consumption/vs/1": {"kwh": 1175.2}, # populated, but a meter + "/mode/vs/1": {}, # the slot's real state: empty } bound, _, materialized, skipped = discover_partitioned( - resources, [unit1], lambda r, **_: reg, fallback_capabilities={}, + resources, + [unit1], + lambda r, **_: reg, + fallback_capabilities={}, ) assert materialized == [] assert [s.subdevice for s in skipped] == [unit1] @@ -789,34 +884,40 @@ def test_discover_partitioned_meter_carve_out_does_not_gate_out_a_live_subdevice candidate reporting its own operational state alongside a meter still materializes, and still gets its meter entity once it does.""" energy_cap = Capability( - href='/energy/consumption/vs/0', - entities=(SensorDesc(key='energy_kwh', field='kwh', - device_class='energy', - state_class='total_increasing'),), + href="/energy/consumption/vs/0", + entities=( + SensorDesc( + key="energy_kwh", field="kwh", device_class="energy", state_class="total_increasing" + ), + ), ) climate_cap = Capability( - href='/mode/vs/0', - entities=(BinarySensorDesc(key='mode', field='m'),), + href="/mode/vs/0", + entities=(BinarySensorDesc(key="mode", field="m"),), ) reg = _FakeRegistry( - 'airconditioner', - {'/energy/consumption/vs/0': [energy_cap], '/mode/vs/0': [climate_cap]}, + "airconditioner", + {"/energy/consumption/vs/0": [energy_cap], "/mode/vs/0": [climate_cap]}, ) - unit1 = _indexed('1') + unit1 = _indexed("1") resources = { - '/energy/consumption/vs/0': {'kwh': 1175.2}, - '/mode/vs/0': {'m': 'Auto'}, - '/energy/consumption/vs/1': {'kwh': 1175.2}, - '/mode/vs/1': {'m': 'Cool'}, # its own state -- this is what counts + "/energy/consumption/vs/0": {"kwh": 1175.2}, + "/mode/vs/0": {"m": "Auto"}, + "/energy/consumption/vs/1": {"kwh": 1175.2}, + "/mode/vs/1": {"m": "Cool"}, # its own state -- this is what counts } bound, _, materialized, skipped = discover_partitioned( - resources, [unit1], lambda r, **_: reg, fallback_capabilities={}, + resources, + [unit1], + lambda r, **_: reg, + fallback_capabilities={}, ) assert materialized == [unit1] assert skipped == [] assert {b.href for b in bound if b.subdevice == unit1} == { - '/mode/vs/1', '/energy/consumption/vs/1', + "/mode/vs/1", + "/energy/consumption/vs/1", } @@ -824,16 +925,21 @@ def test_discover_partitioned_skipped_candidate_contributes_no_hot_warm_hrefs(): """A skipped candidate's hrefs must not appear via tier_log either -- 'no entities, no hot/warm hrefs' (the skip is total, not just entity-level).""" - cap = Capability(href='/mode/vs/0', poll_tier='warm', - entities=(SensorDesc(key='alarm_code', field='code', - entity_category='diagnostic'),)) - reg = _FakeRegistry('airconditioner', {'/mode/vs/0': [cap]}) - unit2 = _indexed('2') - resources = {'/mode/vs/0': {'code': 'x'}, '/mode/vs/2': {'code': 'y'}} + cap = Capability( + href="/mode/vs/0", + poll_tier="warm", + entities=(SensorDesc(key="alarm_code", field="code", entity_category="diagnostic"),), + ) + reg = _FakeRegistry("airconditioner", {"/mode/vs/0": [cap]}) + unit2 = _indexed("2") + resources = {"/mode/vs/0": {"code": "x"}, "/mode/vs/2": {"code": "y"}} tiers = [] discover_partitioned( - resources, [unit2], lambda r, **_: reg, fallback_capabilities={}, + resources, + [unit2], + lambda r, **_: reg, + fallback_capabilities={}, tier_log=lambda href, tier: tiers.append(href), ) - assert '/mode/vs/2' not in tiers + assert "/mode/vs/2" not in tiers diff --git a/tests/test_translations.py b/tests/test_translations.py index 6ccc147..1191576 100644 --- a/tests/test_translations.py +++ b/tests/test_translations.py @@ -19,17 +19,12 @@ from string import Formatter from custom_components.localthings.registry.capability import Capability from custom_components.localthings.registry.entities import PLATFORM_OF - -INTEGRATION = ( - Path(__file__).parents[1] / "custom_components" / "localthings" -) +INTEGRATION = Path(__file__).parents[1] / "custom_components" / "localthings" TRANSLATIONS = INTEGRATION / "translations" def _load(language: str) -> dict: - return json.loads( - (TRANSLATIONS / f"{language}.json").read_text(encoding="utf-8") - ) + return json.loads((TRANSLATIONS / f"{language}.json").read_text(encoding="utf-8")) def _languages() -> list[str]: @@ -46,9 +41,7 @@ def _topology(value): def _placeholders(value: str) -> set[str]: return { - field_name - for _, field_name, _, _ in Formatter().parse(value) - if field_name is not None + field_name for _, field_name, _, _ in Formatter().parse(value) if field_name is not None } @@ -105,9 +98,7 @@ def test_every_language_mirrors_the_english_catalog(): for path, value in english_strings.items(): # Placeholders are substituted by name, so a translation that # drops or invents one renders a literal '{...}' in the UI. - assert _placeholders(value) == _placeholders( - translated_strings[path] - ), (language, path) + assert _placeholders(value) == _placeholders(translated_strings[path]), (language, path) def test_no_catalog_carries_unresolved_core_references(): @@ -118,9 +109,7 @@ def test_no_catalog_carries_unresolved_core_references(): """ for language in _languages(): unresolved = [ - (path, value) - for path, value in _walk_strings(_load(language)) - if "[%key:" in value + (path, value) for path, value in _walk_strings(_load(language)) if "[%key:" in value ] assert unresolved == [], language @@ -138,7 +127,8 @@ def test_no_catalog_carries_unresolved_core_references(): # PERFORMANCE/OFF), which Home Assistant translates itself via the # entity_component fallback, so no per-state entry is needed either way. UNNAMED_DESCRIPTORS = { - ("fan", "fan"), ("fan", "airflow_fan"), + ("fan", "fan"), + ("fan", "airflow_fan"), } @@ -192,22 +182,40 @@ def test_every_ac_convenient_mode_code_has_a_preset_label(): en.json. """ from homeassistant.components.climate.const import ( - PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, PRESET_COMFORT, - PRESET_ECO, PRESET_HOME, PRESET_SLEEP, + PRESET_ACTIVITY, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_HOME, + PRESET_SLEEP, ) - standard = {PRESET_ACTIVITY, PRESET_AWAY, PRESET_BOOST, PRESET_COMFORT, - PRESET_ECO, PRESET_HOME, PRESET_SLEEP} + + standard = { + PRESET_ACTIVITY, + PRESET_AWAY, + PRESET_BOOST, + PRESET_COMFORT, + PRESET_ECO, + PRESET_HOME, + PRESET_SLEEP, + } preset_labels = set( - _load("en")["entity"]["climate"]["airconditioner"]["state_attributes"] - ["preset_mode"]["state"] + _load("en")["entity"]["climate"]["airconditioner"]["state_attributes"]["preset_mode"][ + "state" + ] ) fixtures_dir = Path(__file__).parent / "fixtures" missing = [] for path in sorted(fixtures_dir.glob("airconditioner*_device.json")): dump = json.loads(path.read_text()) conv = next( - (item for item in dump.get("device0", []) - if item.get("href") == "/mode/convenient/vs/0"), None, + ( + item + for item in dump.get("device0", []) + if item.get("href") == "/mode/convenient/vs/0" + ), + None, ) if not conv: continue @@ -228,9 +236,7 @@ def test_every_kimchi_zone_supportmode_code_has_a_state_label(): code across any /status/kimchi//vs/0 resource would silently render as its raw device token instead of the translated state. """ - state_labels = set( - _load("en")["entity"]["select"]["kimchi_zone_mode"]["state"] - ) + state_labels = set(_load("en")["entity"]["select"]["kimchi_zone_mode"]["state"]) fixtures_dir = Path(__file__).parent / "fixtures" missing = [] for path in sorted(fixtures_dir.glob("*_device.json")): @@ -239,8 +245,9 @@ def test_every_kimchi_zone_supportmode_code_has_a_state_label(): href = item.get("href", "") if not (href.startswith("/status/kimchi/") and href.endswith("/vs/0")): continue - for code in item["rep"].get("x.com.samsung.da.supportMode", []): - if code.lower() not in state_labels: - missing.append((path.name, href, code)) + missing.extend( + (path.name, href, code) + for code in item["rep"].get("x.com.samsung.da.supportMode", []) + if code.lower() not in state_labels + ) assert missing == [] - diff --git a/tests/test_unique_ids.py b/tests/test_unique_ids.py index ddb9bca..b4f9d6f 100644 --- a/tests/test_unique_ids.py +++ b/tests/test_unique_ids.py @@ -5,6 +5,7 @@ protect this (see DESIGN-177.md section 3/6). Run over the entire fixture set, not just the two new subdevice fixtures, so a future dump -- subdevice- capable or not -- exercises it automatically. """ + from collections import Counter import pytest @@ -12,12 +13,9 @@ import pytest from custom_components.localthings.entity import _is_included from custom_components.localthings.registry.adapter import _key from custom_components.localthings.registry.entities import PLATFORM_OF - from tests.conftest import FIXTURES, _discover_full, _load_device_full -_FIXTURE_NAMES = sorted( - p.name[:-len('_device.json')] for p in FIXTURES.glob('*_device.json') -) +_FIXTURE_NAMES = sorted(p.name[: -len("_device.json")] for p in FIXTURES.glob("*_device.json")) class _FakeCoordinator: @@ -31,10 +29,11 @@ class _FakeCoordinator: def canonical_resources(self, subdevice): from custom_components.localthings.registry.subdevices import canonical_view + return canonical_view(subdevice, self.last_resources, self._subdevices) -@pytest.mark.parametrize('name', _FIXTURE_NAMES) +@pytest.mark.parametrize("name", _FIXTURE_NAMES) def test_key_is_unique_across_all_bound_entities(name): """`_key(b)` only has to be unique among entities `_is_included` would actually register -- discover() alone can (deliberately) produce two @@ -53,8 +52,10 @@ def test_key_is_unique_across_all_bound_entities(name): BinarySensorDesc both named 'power_switch' on /power/0). """ resources, oic_res, seeds = _load_device_full(name) - bound, materialized, skipped, full_resources, device_type_name = _discover_full( - resources, oic_res, seeds, + bound, materialized, _skipped, full_resources, _device_type_name = _discover_full( + resources, + oic_res, + seeds, ) coordinator = _FakeCoordinator(full_resources, materialized) included = [b for b in bound if _is_included(b, coordinator)] @@ -75,8 +76,7 @@ def test_subdevice_capable_fixtures_actually_exercise_a_subdevice(): the two fixtures this design added actually produce a materialized subdevice, so that silent-vacuous-pass failure mode is caught here instead.""" - for name in ('airconditioner_artik051_dongle_fac_18k', - 'airconditioner_fac_bora_2in1'): + for name in ("airconditioner_artik051_dongle_fac_18k", "airconditioner_fac_bora_2in1"): resources, oic_res, seeds = _load_device_full(name) _, materialized, _, _, _ = _discover_full(resources, oic_res, seeds) assert materialized, f"{name}: expected at least one materialized subdevice" diff --git a/tests/test_washer_capabilities.py b/tests/test_washer_capabilities.py index a131ec8..519c1a0 100644 --- a/tests/test_washer_capabilities.py +++ b/tests/test_washer_capabilities.py @@ -4,6 +4,9 @@ Shared laundry capabilities (cycle-select machinery, buzzer, job status) are tested in test_laundry_capabilities.py; the OCF power/kids-lock/remote-control fallback pairs and the energy meter in test_common_capabilities.py. """ + +from datetime import UTC + from custom_components.localthings.registry.capabilities import laundry, washer @@ -14,30 +17,30 @@ def _rep_by_href(cap, href): class TestWasherSettings: def test_href(self): - assert washer.WASHER_SETTINGS.href == '/washer/vs/0' + assert washer.WASHER_SETTINGS.href == "/washer/vs/0" def test_wash_temperature_read(self): - desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature') - assert desc.field == 'x.com.samsung.da.waterTemperature' - assert desc.options_field == 'x.com.samsung.da.supportedWaterTemperature' + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == "wash_temperature") + assert desc.field == "x.com.samsung.da.waterTemperature" + assert desc.options_field == "x.com.samsung.da.supportedWaterTemperature" def test_wash_temperature_write(self): - desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'wash_temperature') - path, body = desc.write_fn('60', {}) - assert path == ['washer', 'vs', '0'] - assert body == {'x.com.samsung.da.waterTemperature': '60'} + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == "wash_temperature") + path, body = desc.write_fn("60", {}) + assert path == ["washer", "vs", "0"] + assert body == {"x.com.samsung.da.waterTemperature": "60"} def test_spin_speed_write(self): - desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'spin_speed') - path, body = desc.write_fn('1400', {}) - assert path == ['washer', 'vs', '0'] - assert body == {'x.com.samsung.da.spinLevel': '1400'} + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == "spin_speed") + path, body = desc.write_fn("1400", {}) + assert path == ["washer", "vs", "0"] + assert body == {"x.com.samsung.da.spinLevel": "1400"} def test_rinse_cycles_write(self): - desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'rinse_cycles') - path, body = desc.write_fn('3', {}) - assert path == ['washer', 'vs', '0'] - assert body == {'x.com.samsung.da.rinseCycles': '3'} + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == "rinse_cycles") + path, body = desc.write_fn("3", {}) + assert path == ["washer", "vs", "0"] + assert body == {"x.com.samsung.da.rinseCycles": "3"} class TestDryLevel: @@ -45,20 +48,20 @@ class TestDryLevel: /washer/vs/0 itself, self-gated off on plain washers -- issue #22.""" def test_exists_only_when_supported_dry_level_present(self): - desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level') - assert desc.exists_fn({'x.com.samsung.da.supportedDryLevel': ['None', '30']}, {}) is True + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == "dry_level") + assert desc.exists_fn({"x.com.samsung.da.supportedDryLevel": ["None", "30"]}, {}) is True assert desc.exists_fn({}, {}) is False def test_write(self): - desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == 'dry_level') - path, body = desc.write_fn('Cupboard', {}) - assert path == ['washer', 'vs', '0'] - assert body == {'x.com.samsung.da.dryLevel': 'Cupboard'} + desc = next(e for e in washer.WASHER_SETTINGS.entities if e.key == "dry_level") + path, body = desc.write_fn("Cupboard", {}) + assert path == ["washer", "vs", "0"] + assert body == {"x.com.samsung.da.dryLevel": "Cupboard"} class TestWasherCourse: def test_href(self): - assert washer.WASHER_COURSE.href == '/course/vs/0' + assert washer.WASHER_COURSE.href == "/course/vs/0" def test_translation_key(self): """Table-scoped (issue: course codes aren't guaranteed consistent @@ -66,110 +69,113 @@ class TestWasherCourse: board reports Table_00, not the Table_02 every washer_cycle_table_02 name was confirmed against) -- see laundry.cycle_select. Only a verified table gets table-specific state translations.""" - desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle") assert callable(desc.translation_key) - table_02 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_02'}} - assert desc.translation_key(table_02) == 'washer_cycle_table_02' - table_00 = {'/st/washercourse/vs/0': {'x.com.samsung.da.st.courseTable': 'Table_00'}} - assert desc.translation_key(table_00) == 'cycle' - assert desc.translation_key({}) == 'cycle' + table_02 = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_02"}} + assert desc.translation_key(table_02) == "washer_cycle_table_02" + table_00 = {"/st/washercourse/vs/0": {"x.com.samsung.da.st.courseTable": "Table_00"}} + assert desc.translation_key(table_00) == "cycle" + assert desc.translation_key({}) == "cycle" def test_reads_raw_course_code_from_options_array(self): """rep_fn returns the raw device code; display names come from translations/en.json via translation_key, not Python (see select.py's _display()).""" - desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') - rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']} - assert desc.rep_fn(rep) == '1C' + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle") + rep = {"x.com.samsung.da.options": ["DeviceType_0167", "Course_1C", "GMT_04"]} + assert desc.rep_fn(rep) == "1C" def test_missing_course_option_returns_none(self): - desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') - assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle") + assert desc.rep_fn({"x.com.samsung.da.options": ["GMT_04"]}) is None def test_cycle_desc_uses_cycle_options_callable(self): - desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle") assert desc.options is laundry.cycle_options def test_exists_only_when_edit_course_list_is_live(self): """No hardcoded course table is kept -- the selector only appears when a device actually populates editCourseList (see _cycle_options's docstring for why MostUsed_ isn't used either).""" - desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle") assert desc.exists_fn({}, {}) is False - assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False - live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_1C'}} + assert desc.exists_fn({}, {"/wm/editcourse/vs/0": {}}) is False + live = {"/wm/editcourse/vs/0": {"x.com.samsung.da.editCourseList": "EditCourseList_1C"}} assert desc.exists_fn({}, live) is True def test_cycle_write(self): """Confirmed on real hardware (issue #54): the write only needs to carry the changed token -- the device matches by prefix, evicts the stale token, and merges the result into the array itself.""" - desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle') - rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']} - path, body = desc.write_fn('1D', rep) - assert path == ['course', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['Course_1D']} + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "cycle") + rep = {"x.com.samsung.da.options": ["DeviceType_0167", "Course_1C", "GMT_04"]} + path, body = desc.write_fn("1D", rep) + assert path == ["course", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["Course_1D"]} class TestDrumClean: def test_cycles_remaining(self): """DrumCleanProposal_40 - WashingTimes_3 == 37, matching a live app screenshot's 'Potreba cistenia po 37 cykloch'.""" - desc = next(e for e in washer.WASHER_COURSE.entities - if e.key == 'drum_clean_cycles_remaining') - rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']} + desc = next( + e for e in washer.WASHER_COURSE.entities if e.key == "drum_clean_cycles_remaining" + ) + rep = {"x.com.samsung.da.options": ["WashingTimes_3", "DrumCleanProposal_40"]} assert desc.rep_fn(rep) == 37 def test_cycles_remaining_never_negative(self): - desc = next(e for e in washer.WASHER_COURSE.entities - if e.key == 'drum_clean_cycles_remaining') - rep = {'x.com.samsung.da.options': ['WashingTimes_50', 'DrumCleanProposal_40']} + desc = next( + e for e in washer.WASHER_COURSE.entities if e.key == "drum_clean_cycles_remaining" + ) + rep = {"x.com.samsung.da.options": ["WashingTimes_50", "DrumCleanProposal_40"]} assert desc.rep_fn(rep) == 0 def test_cycles_remaining_missing_fields(self): - desc = next(e for e in washer.WASHER_COURSE.entities - if e.key == 'drum_clean_cycles_remaining') - assert desc.rep_fn({'x.com.samsung.da.options': []}) is None + desc = next( + e for e in washer.WASHER_COURSE.entities if e.key == "drum_clean_cycles_remaining" + ) + assert desc.rep_fn({"x.com.samsung.da.options": []}) is None def test_cycles_remaining_exists_only_when_computable(self): - desc = next(e for e in washer.WASHER_COURSE.entities - if e.key == 'drum_clean_cycles_remaining') - assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False - rep = {'x.com.samsung.da.options': ['WashingTimes_3', 'DrumCleanProposal_40']} + desc = next( + e for e in washer.WASHER_COURSE.entities if e.key == "drum_clean_cycles_remaining" + ) + assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False + rep = {"x.com.samsung.da.options": ["WashingTimes_3", "DrumCleanProposal_40"]} assert desc.exists_fn(rep, {}) is True def test_last_cleaned(self): """DrumCleanLog_2026-07-01T20:18:07 -> a UTC-aware datetime, matching the same screenshot's '10 days ago' (as of 2026-07-11).""" - desc = next(e for e in washer.WASHER_COURSE.entities - if e.key == 'drum_clean_last_cleaned') - rep = {'x.com.samsung.da.options': ['DrumCleanLog_2026-07-01T20:18:07']} - from datetime import datetime, timezone - assert desc.rep_fn(rep) == datetime(2026, 7, 1, 20, 18, 7, tzinfo=timezone.utc) + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "drum_clean_last_cleaned") + rep = {"x.com.samsung.da.options": ["DrumCleanLog_2026-07-01T20:18:07"]} + from datetime import datetime + + assert desc.rep_fn(rep) == datetime(2026, 7, 1, 20, 18, 7, tzinfo=UTC) def test_last_cleaned_missing(self): - desc = next(e for e in washer.WASHER_COURSE.entities - if e.key == 'drum_clean_last_cleaned') - assert desc.rep_fn({'x.com.samsung.da.options': []}) is None - assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False + desc = next(e for e in washer.WASHER_COURSE.entities if e.key == "drum_clean_last_cleaned") + assert desc.rep_fn({"x.com.samsung.da.options": []}) is None + assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False # Raw options array from issue #9's diagnostic dump (trimmed to the fields # relevant to detergent/softener dosing). _DOSING_OPTIONS = [ - 'Course_1C', - 'DetergentAlarm_Off', - 'SoftenerAlarm_Off', - 'DetergentLevelCtrl_3', - 'SoftenerLevelCtrl_3', - 'SupportedDetergentLevelCtrl_00010203', - 'SupportedSoftenerLevelCtrl_00010203', - 'DetergentLevel2Ctrl_2', - 'SoftenerLevel2Ctrl_2', - 'SupportedDetergentLevel2Ctrl_010203', - 'SupportedSoftenerLevel2Ctrl_010203', + "Course_1C", + "DetergentAlarm_Off", + "SoftenerAlarm_Off", + "DetergentLevelCtrl_3", + "SoftenerLevelCtrl_3", + "SupportedDetergentLevelCtrl_00010203", + "SupportedSoftenerLevelCtrl_00010203", + "DetergentLevel2Ctrl_2", + "SoftenerLevel2Ctrl_2", + "SupportedDetergentLevel2Ctrl_010203", + "SupportedSoftenerLevel2Ctrl_010203", ] -_DOSING_RESOURCES = {'/course/vs/0': {'x.com.samsung.da.options': _DOSING_OPTIONS}} +_DOSING_RESOURCES = {"/course/vs/0": {"x.com.samsung.da.options": _DOSING_OPTIONS}} class TestDetergentSoftenerDosing: @@ -182,45 +188,71 @@ class TestDetergentSoftenerDosing: translation keys are zero-padded supported codes ('03'); rep_fn normalizes to the supported code so the value is a valid option (issue #9 -- otherwise HA renders the select 'unknown').""" - rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} - assert self._desc('detergent_quantity').rep_fn(rep) == '03' - assert self._desc('detergent_water_hardness').rep_fn(rep) == '02' - assert self._desc('softener_quantity').rep_fn(rep) == '03' - assert self._desc('softener_concentration').rep_fn(rep) == '02' + rep = {"x.com.samsung.da.options": _DOSING_OPTIONS} + assert self._desc("detergent_quantity").rep_fn(rep) == "03" + assert self._desc("detergent_water_hardness").rep_fn(rep) == "02" + assert self._desc("softener_quantity").rep_fn(rep) == "03" + assert self._desc("softener_concentration").rep_fn(rep) == "02" def test_current_value_is_a_valid_option_for_every_dosing_select(self): """The core regression: HA shows a select 'unknown' when current_option is not in options. Each dosing select's value must be one of its own options.""" - rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} - for key in ('detergent_quantity', 'detergent_water_hardness', - 'softener_quantity', 'softener_concentration'): + rep = {"x.com.samsung.da.options": _DOSING_OPTIONS} + for key in ( + "detergent_quantity", + "detergent_water_hardness", + "softener_quantity", + "softener_concentration", + ): desc = self._desc(key) assert desc.rep_fn(rep) in desc.options(_DOSING_RESOURCES), key def test_read_passes_through_when_no_supported_match(self): """A value with no matching supported code is returned as-is rather than dropped, so an unexpected device stays visible instead of blank.""" - rep = {'x.com.samsung.da.options': ['DetergentLevelCtrl_7', - 'SupportedDetergentLevelCtrl_00010203']} - assert self._desc('detergent_quantity').rep_fn(rep) == '7' + rep = { + "x.com.samsung.da.options": [ + "DetergentLevelCtrl_7", + "SupportedDetergentLevelCtrl_00010203", + ] + } + assert self._desc("detergent_quantity").rep_fn(rep) == "7" def test_translation_keys(self): """Each entity gets its own translated name and state vocabulary.""" - assert self._desc('detergent_quantity').translation_key == 'detergent_quantity' - assert self._desc('softener_quantity').translation_key == 'softener_quantity' - assert self._desc('detergent_water_hardness').translation_key == 'detergent_water_hardness' - assert self._desc('softener_concentration').translation_key == 'softener_concentration' + assert self._desc("detergent_quantity").translation_key == "detergent_quantity" + assert self._desc("softener_quantity").translation_key == "softener_quantity" + assert self._desc("detergent_water_hardness").translation_key == "detergent_water_hardness" + assert self._desc("softener_concentration").translation_key == "softener_concentration" def test_quantity_and_hardness_options_decode_supported_list(self): - assert self._desc('detergent_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03'] - assert self._desc('softener_quantity').options(_DOSING_RESOURCES) == ['00', '01', '02', '03'] - assert self._desc('detergent_water_hardness').options(_DOSING_RESOURCES) == ['01', '02', '03'] - assert self._desc('softener_concentration').options(_DOSING_RESOURCES) == ['01', '02', '03'] + assert self._desc("detergent_quantity").options(_DOSING_RESOURCES) == [ + "00", + "01", + "02", + "03", + ] + assert self._desc("softener_quantity").options(_DOSING_RESOURCES) == [ + "00", + "01", + "02", + "03", + ] + assert self._desc("detergent_water_hardness").options(_DOSING_RESOURCES) == [ + "01", + "02", + "03", + ] + assert self._desc("softener_concentration").options(_DOSING_RESOURCES) == ["01", "02", "03"] def test_exists_only_when_supported_list_present(self): - for key in ('detergent_quantity', 'detergent_water_hardness', - 'softener_quantity', 'softener_concentration'): + for key in ( + "detergent_quantity", + "detergent_water_hardness", + "softener_quantity", + "softener_concentration", + ): desc = self._desc(key) assert desc.exists_fn({}, {}) is False assert desc.exists_fn({}, _DOSING_RESOURCES) is True @@ -233,32 +265,32 @@ class TestDetergentSoftenerDosing: the changed token -- the device matches by prefix, evicts the stale token, and merges the result into the array itself. No need to read the current array back and rewrite it whole.""" - rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)} - path, body = self._desc('detergent_quantity').write_fn('01', rep) - assert path == ['course', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['DetergentLevelCtrl_1']} + rep = {"x.com.samsung.da.options": list(_DOSING_OPTIONS)} + path, body = self._desc("detergent_quantity").write_fn("01", rep) + assert path == ["course", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["DetergentLevelCtrl_1"]} def test_hardness_write(self): - rep = {'x.com.samsung.da.options': list(_DOSING_OPTIONS)} - path, body = self._desc('softener_concentration').write_fn('03', rep) - assert path == ['course', 'vs', '0'] - assert body == {'x.com.samsung.da.options': ['SoftenerLevel2Ctrl_3']} + rep = {"x.com.samsung.da.options": list(_DOSING_OPTIONS)} + path, body = self._desc("softener_concentration").write_fn("03", rep) + assert path == ["course", "vs", "0"] + assert body == {"x.com.samsung.da.options": ["SoftenerLevel2Ctrl_3"]} def test_low_reservoir_off_when_alarm_off(self): - rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} - assert self._desc('detergent_low').rep_fn(rep) is False - assert self._desc('softener_low').rep_fn(rep) is False + rep = {"x.com.samsung.da.options": _DOSING_OPTIONS} + assert self._desc("detergent_low").rep_fn(rep) is False + assert self._desc("softener_low").rep_fn(rep) is False def test_low_reservoir_on_when_alarm_active(self): - opts = ['DetergentAlarm_On', 'SoftenerAlarm_On'] - rep = {'x.com.samsung.da.options': opts} - assert self._desc('detergent_low').rep_fn(rep) is True - assert self._desc('softener_low').rep_fn(rep) is True + opts = ["DetergentAlarm_On", "SoftenerAlarm_On"] + rep = {"x.com.samsung.da.options": opts} + assert self._desc("detergent_low").rep_fn(rep) is True + assert self._desc("softener_low").rep_fn(rep) is True def test_low_reservoir_exists_only_when_alarm_field_present(self): - assert self._desc('detergent_low').exists_fn({'x.com.samsung.da.options': []}, {}) is False - rep = {'x.com.samsung.da.options': _DOSING_OPTIONS} - assert self._desc('detergent_low').exists_fn(rep, {}) is True + assert self._desc("detergent_low").exists_fn({"x.com.samsung.da.options": []}, {}) is False + rep = {"x.com.samsung.da.options": _DOSING_OPTIONS} + assert self._desc("detergent_low").exists_fn(rep, {}) is True class TestWashOptionToggles: @@ -272,32 +304,34 @@ class TestWashOptionToggles: @staticmethod def _keys(): - return ('bubble_soak', 'pre_wash', 'intensive') + return ("bubble_soak", "pre_wash", "intensive") @staticmethod def _prefix(key): - return {'bubble_soak': 'BubbleSoak', - 'pre_wash': 'PreWashSetting', - 'intensive': 'IntensiveSetting'}[key] + return { + "bubble_soak": "BubbleSoak", + "pre_wash": "PreWashSetting", + "intensive": "IntensiveSetting", + }[key] def test_exists_only_when_field_present(self): for key in self._keys(): desc = self._desc(key) - assert desc.exists_fn({'x.com.samsung.da.options': []}, {}) is False + assert desc.exists_fn({"x.com.samsung.da.options": []}, {}) is False prefix = self._prefix(key) - rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + rep = {"x.com.samsung.da.options": [f"{prefix}_Off"]} assert desc.exists_fn(rep, {}) is True def test_reads_off(self): for key in self._keys(): prefix = self._prefix(key) - rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} + rep = {"x.com.samsung.da.options": [f"{prefix}_Off"]} assert self._desc(key).rep_fn(rep) is False def test_reads_on(self): for key in self._keys(): prefix = self._prefix(key) - rep = {'x.com.samsung.da.options': [f'{prefix}_On']} + rep = {"x.com.samsung.da.options": [f"{prefix}_On"]} assert self._desc(key).rep_fn(rep) is True def test_write_on_and_off(self): @@ -310,21 +344,21 @@ class TestWashOptionToggles: the device merges by prefix itself), not the whole options array.""" for key in self._keys(): prefix = self._prefix(key) - rep = {'x.com.samsung.da.options': [f'{prefix}_Off', 'GMT_02']} - path, body = self._desc(key).write_fn('On', rep) - assert path == ['course', 'vs', '0'] - assert body == {'x.com.samsung.da.options': [f'{prefix}_On']} + rep = {"x.com.samsung.da.options": [f"{prefix}_Off", "GMT_02"]} + path, body = self._desc(key).write_fn("On", rep) + assert path == ["course", "vs", "0"] + assert body == {"x.com.samsung.da.options": [f"{prefix}_On"]} - rep = {'x.com.samsung.da.options': [f'{prefix}_On']} - path, body = self._desc(key).write_fn('Off', rep) - assert body == {'x.com.samsung.da.options': [f'{prefix}_Off']} - assert f'{prefix}_On' not in body['x.com.samsung.da.options'] + rep = {"x.com.samsung.da.options": [f"{prefix}_On"]} + path, body = self._desc(key).write_fn("Off", rep) + assert body == {"x.com.samsung.da.options": [f"{prefix}_Off"]} + assert f"{prefix}_On" not in body["x.com.samsung.da.options"] def test_write_rejects_non_on_off_payload(self): for key in self._keys(): prefix = self._prefix(key) - rep = {'x.com.samsung.da.options': [f'{prefix}_Off']} - assert self._desc(key).write_fn('bogus', rep) is None + rep = {"x.com.samsung.da.options": [f"{prefix}_Off"]} + assert self._desc(key).write_fn("bogus", rep) is None # editCourseList and availability bitmaps from the reporter's issue #22 @@ -333,14 +367,13 @@ class TestWashOptionToggles: # (available) on all three bitmaps; course '1C' at position 0 reads '00' on # BubbleSoakSet (matching the app graying that control out for Eco 40-60). _EDIT_COURSE_RESOURCES = { - '/wm/editcourse/vs/0': { - 'x.com.samsung.da.editCourseList': - 'EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729', + "/wm/editcourse/vs/0": { + "x.com.samsung.da.editCourseList": "EditCourseList_1C301E26361B1D1F253324322022232F212D272838393729", # noqa: E501 }, } -_BUBBLE_SOAK_SET = 'BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000' -_PRE_WASH_AVAILABLE_SET = 'PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000' -_INTENSIVE_AVAILABLE_SET = 'IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000' +_BUBBLE_SOAK_SET = "BubbleSoakSet_00F000F000F000F0F0F0F00000F000F0F00000F000000000" +_PRE_WASH_AVAILABLE_SET = "PreWashAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000" +_INTENSIVE_AVAILABLE_SET = "IntensiveAvailableSet_F0F000F0F0F000F0F0F0F00000F0F0F0F00000F000000000" class TestWashOptionToggleValidation: @@ -354,43 +387,37 @@ class TestWashOptionToggleValidation: return next(e for e in washer.WASHER_COURSE.entities if e.key == key) def test_allowed_on_a_supported_course(self): - rep = {'x.com.samsung.da.options': ['Course_30', _BUBBLE_SOAK_SET]} - assert self._desc('bubble_soak').validate_fn( - 'On', rep, _EDIT_COURSE_RESOURCES) is None + rep = {"x.com.samsung.da.options": ["Course_30", _BUBBLE_SOAK_SET]} + assert self._desc("bubble_soak").validate_fn("On", rep, _EDIT_COURSE_RESOURCES) is None def test_rejected_on_an_unsupported_course(self): - rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} - translation_key = self._desc('bubble_soak').validate_fn( - 'On', rep, _EDIT_COURSE_RESOURCES - ) - assert translation_key == 'bubble_soak_unavailable_for_cycle' + rep = {"x.com.samsung.da.options": ["Course_1C", _BUBBLE_SOAK_SET]} + translation_key = self._desc("bubble_soak").validate_fn("On", rep, _EDIT_COURSE_RESOURCES) + assert translation_key == "bubble_soak_unavailable_for_cycle" def test_pre_wash_and_intensive_use_their_own_availableset_field(self): - rep = {'x.com.samsung.da.options': ['Course_30', _PRE_WASH_AVAILABLE_SET]} - assert self._desc('pre_wash').validate_fn( - 'On', rep, _EDIT_COURSE_RESOURCES) is None - rep = {'x.com.samsung.da.options': ['Course_30', _INTENSIVE_AVAILABLE_SET]} - assert self._desc('intensive').validate_fn( - 'On', rep, _EDIT_COURSE_RESOURCES) is None + rep = {"x.com.samsung.da.options": ["Course_30", _PRE_WASH_AVAILABLE_SET]} + assert self._desc("pre_wash").validate_fn("On", rep, _EDIT_COURSE_RESOURCES) is None + rep = {"x.com.samsung.da.options": ["Course_30", _INTENSIVE_AVAILABLE_SET]} + assert self._desc("intensive").validate_fn("On", rep, _EDIT_COURSE_RESOURCES) is None def test_turning_off_is_never_blocked(self): - rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} - assert self._desc('bubble_soak').validate_fn( - 'Off', rep, _EDIT_COURSE_RESOURCES) is None + rep = {"x.com.samsung.da.options": ["Course_1C", _BUBBLE_SOAK_SET]} + assert self._desc("bubble_soak").validate_fn("Off", rep, _EDIT_COURSE_RESOURCES) is None def test_allows_write_when_course_unresolvable(self): """No editCourseList, no Course_ token, or a bitmap whose length doesn't match editCourseList -- in every case, fail open rather than block a write we can't actually verify.""" - desc = self._desc('bubble_soak') - rep = {'x.com.samsung.da.options': ['Course_1C', _BUBBLE_SOAK_SET]} - assert desc.validate_fn('On', rep, {}) is None + desc = self._desc("bubble_soak") + rep = {"x.com.samsung.da.options": ["Course_1C", _BUBBLE_SOAK_SET]} + assert desc.validate_fn("On", rep, {}) is None - rep = {'x.com.samsung.da.options': [_BUBBLE_SOAK_SET]} - assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None + rep = {"x.com.samsung.da.options": [_BUBBLE_SOAK_SET]} + assert desc.validate_fn("On", rep, _EDIT_COURSE_RESOURCES) is None - rep = {'x.com.samsung.da.options': ['Course_1C', 'BubbleSoakSet_00F0']} - assert desc.validate_fn('On', rep, _EDIT_COURSE_RESOURCES) is None + rep = {"x.com.samsung.da.options": ["Course_1C", "BubbleSoakSet_00F0"]} + assert desc.validate_fn("On", rep, _EDIT_COURSE_RESOURCES) is None class TestAiEnergyLevel: @@ -407,7 +434,7 @@ class TestAiEnergyLevel: from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('washer') + resources = _load_device("washer") unbound = [] bound = discover( resources, @@ -417,7 +444,7 @@ class TestAiEnergyLevel: ) assert unbound == [] state = flatten(bound, resources) - assert state['ai_energy_level'] is False # aiLevel '0' -- off + assert state["ai_energy_level"] is False # aiLevel '0' -- off class TestFlexWashAndComboFixturesHaveCompleteCoverage: @@ -431,7 +458,7 @@ class TestFlexWashAndComboFixturesHaveCompleteCoverage: from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('washer_flexwash') + resources = _load_device("washer_flexwash") unbound = [] bound = discover( resources, @@ -441,7 +468,7 @@ class TestFlexWashAndComboFixturesHaveCompleteCoverage: ) assert unbound == [] state = flatten(bound, resources) - assert 'dry_level' not in state # plain washer -- no supportedDryLevel field + assert "dry_level" not in state # plain washer -- no supportedDryLevel field def test_dryer_combo(self): from custom_components.localthings.registry.adapter import flatten @@ -449,7 +476,7 @@ class TestFlexWashAndComboFixturesHaveCompleteCoverage: from custom_components.localthings.registry.discovery import discover from tests.conftest import _load_device - resources = _load_device('washer_dryer_combo') + resources = _load_device("washer_dryer_combo") unbound = [] bound = discover( resources, @@ -460,4 +487,4 @@ class TestFlexWashAndComboFixturesHaveCompleteCoverage: assert unbound == [] state = flatten(bound, resources) # the device's own "off" sentinel is the literal string 'None', not absence - assert state['dry_level'] == 'None' + assert state["dry_level"] == "None" diff --git a/tests/test_water_heater_ehs.py b/tests/test_water_heater_ehs.py index cae7ee5..d3a6b3c 100644 --- a/tests/test_water_heater_ehs.py +++ b/tests/test_water_heater_ehs.py @@ -1,5 +1,7 @@ """HA water_heater-entity mapping tests for the EHS DHW loop.""" +from typing import ClassVar + from homeassistant.components.water_heater import ( STATE_ECO, STATE_HEAT_PUMP, @@ -17,9 +19,9 @@ from tests.conftest import _load_device class _FakeCoordinator: - device_serial = 'TEST-EHS-SERIAL' - device_info = {} - data = {} + device_serial = "TEST-EHS-SERIAL" + device_info: ClassVar[dict] = {} + data: ClassVar[dict] = {} def __init__(self, resources): self.last_resources = resources @@ -40,20 +42,22 @@ class _FakeCoordinator: def _entity(resources, coordinator=None): bound = discover( - resources, ehs.REGISTRY.capabilities, ehs.REGISTRY.pattern_capabilities, + resources, + ehs.REGISTRY.capabilities, + ehs.REGISTRY.pattern_capabilities, ) water_heater_bound = next(item for item in bound if isinstance(item.desc, WaterHeaterDesc)) return LocalThingsWaterHeater(coordinator or _FakeCoordinator(resources), water_heater_bound) def test_current_operation_reads_eco_when_on(): - entity = _entity(_load_device('ehs')) + entity = _entity(_load_device("ehs")) assert entity.current_operation == STATE_ECO def test_current_operation_is_off_when_powered_off(): - resources = _load_device('ehs') - resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off' + resources = _load_device("ehs") + resources["/power/dhw/vs/0"]["x.com.samsung.da.power"] = "Off" entity = _entity(resources) assert entity.current_operation == STATE_OFF @@ -62,14 +66,18 @@ def test_operation_list_includes_off_and_mapped_modes(): """Fixture's supportedModes is Eco/Std/Power/Force -- all four map onto HA's own standard water_heater states (see water_heater.py's module docstring for the SmartThings-cloud precedent this mirrors).""" - entity = _entity(_load_device('ehs')) + entity = _entity(_load_device("ehs")) assert entity.operation_list == [ - STATE_OFF, STATE_ECO, STATE_HEAT_PUMP, STATE_PERFORMANCE, STATE_HIGH_DEMAND, + STATE_OFF, + STATE_ECO, + STATE_HEAT_PUMP, + STATE_PERFORMANCE, + STATE_HIGH_DEMAND, ] def test_temperature_reads_current_and_target(): - entity = _entity(_load_device('ehs')) + entity = _entity(_load_device("ehs")) assert entity.current_temperature == 38.0 assert entity.target_temperature == 40.0 assert entity.temperature_unit == UnitOfTemperature.CELSIUS @@ -82,8 +90,8 @@ def test_temperature_bounds_fall_back_together(): """A board reporting only one end of the range must not pair a real device bound with an HA default -- both ends or neither, same rule as climate._range().""" - resources = _load_device('ehs') - del resources['/temperatures/dhw/vs/0']['x.com.samsung.da.maximum'] + resources = _load_device("ehs") + del resources["/temperatures/dhw/vs/0"]["x.com.samsung.da.maximum"] entity = _entity(resources) # Neither end comes from the device: the device minimum (40.0) is @@ -98,16 +106,16 @@ def test_temperature_bounds_fall_back_together(): def test_zero_increment_is_not_collapsed_into_the_default(): """`or` would turn a genuine 0 into 0.5 (issue #160).""" - resources = _load_device('ehs') - resources['/temperatures/dhw/vs/0']['x.com.samsung.da.increment'] = '0' + resources = _load_device("ehs") + resources["/temperatures/dhw/vs/0"]["x.com.samsung.da.increment"] = "0" entity = _entity(resources) assert entity.target_temperature_step == 0.0 def test_current_operation_tolerates_lowercase_device_codes(): - resources = _load_device('ehs') - resources['/mode/dhw/vs/0']['x.com.samsung.da.modes'] = ['eco'] + resources = _load_device("ehs") + resources["/mode/dhw/vs/0"]["x.com.samsung.da.modes"] = ["eco"] entity = _entity(resources) assert entity.current_operation == STATE_ECO @@ -116,55 +124,54 @@ def test_current_operation_tolerates_lowercase_device_codes(): def test_entity_is_named_rather_than_taking_the_device_name(): """Unlike climate.py's AC, DHW is one loop of a two-loop device, so it takes a catalog name instead of presenting as the device itself.""" - entity = _entity(_load_device('ehs')) + entity = _entity(_load_device("ehs")) # No _attr_name override (that would present as the bare device name, # and would also beat the catalog); the name comes from the catalog key. - assert '_attr_name' not in entity.__dict__ - assert entity.translation_key == 'dhw' + assert "_attr_name" not in entity.__dict__ + assert entity.translation_key == "dhw" async def test_set_temperature_writes_dhw_temperature(): - resources = _load_device('ehs') + resources = _load_device("ehs") coordinator = _FakeCoordinator(resources) entity = _entity(resources, coordinator) await entity.async_set_temperature(temperature=45.0) - assert coordinator.commands == [(entity._bound, ('temperature', 45.0))] + assert coordinator.commands == [(entity._bound, ("temperature", 45.0))] async def test_set_temperature_honours_operation_mode(): """water_heater.set_temperature carries an optional operation_mode (SET_TEMPERATURE_SCHEMA); dropping it would move the setpoint without ever changing mode -- same bug fixed for climate's hvac_mode.""" - resources = _load_device('ehs') - resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off' + resources = _load_device("ehs") + resources["/power/dhw/vs/0"]["x.com.samsung.da.power"] = "Off" coordinator = _FakeCoordinator(resources) entity = _entity(resources, coordinator) - await entity.async_set_temperature(temperature=55.0, - operation_mode=STATE_HIGH_DEMAND) + await entity.async_set_temperature(temperature=55.0, operation_mode=STATE_HIGH_DEMAND) assert coordinator.commands == [ - (entity._bound, ('power', True)), - (entity._bound, ('mode', 'Force')), - (entity._bound, ('temperature', 55.0)), + (entity._bound, ("power", True)), + (entity._bound, ("mode", "Force")), + (entity._bound, ("temperature", 55.0)), ] async def test_set_temperature_with_off_mode_skips_the_setpoint_write(): - resources = _load_device('ehs') + resources = _load_device("ehs") coordinator = _FakeCoordinator(resources) entity = _entity(resources, coordinator) await entity.async_set_temperature(temperature=55.0, operation_mode=STATE_OFF) - assert coordinator.commands == [(entity._bound, ('power', False))] + assert coordinator.commands == [(entity._bound, ("power", False))] async def test_turn_on_and_off_write_dhw_power(): - resources = _load_device('ehs') + resources = _load_device("ehs") coordinator = _FakeCoordinator(resources) entity = _entity(resources, coordinator) @@ -172,41 +179,41 @@ async def test_turn_on_and_off_write_dhw_power(): await entity.async_turn_off() assert coordinator.commands == [ - (entity._bound, ('power', True)), - (entity._bound, ('power', False)), + (entity._bound, ("power", True)), + (entity._bound, ("power", False)), ] async def test_set_operation_mode_off_turns_off_without_writing_mode(): - resources = _load_device('ehs') + resources = _load_device("ehs") coordinator = _FakeCoordinator(resources) entity = _entity(resources, coordinator) await entity.async_set_operation_mode(STATE_OFF) - assert coordinator.commands == [(entity._bound, ('power', False))] + assert coordinator.commands == [(entity._bound, ("power", False))] async def test_set_operation_mode_writes_mapped_device_code(): - resources = _load_device('ehs') + resources = _load_device("ehs") coordinator = _FakeCoordinator(resources) entity = _entity(resources, coordinator) await entity.async_set_operation_mode(STATE_HIGH_DEMAND) # DHW power is already on (fixture default) -- no extra power write. - assert coordinator.commands == [(entity._bound, ('mode', 'Force'))] + assert coordinator.commands == [(entity._bound, ("mode", "Force"))] async def test_set_operation_mode_powers_on_first_when_off(): - resources = _load_device('ehs') - resources['/power/dhw/vs/0']['x.com.samsung.da.power'] = 'Off' + resources = _load_device("ehs") + resources["/power/dhw/vs/0"]["x.com.samsung.da.power"] = "Off" coordinator = _FakeCoordinator(resources) entity = _entity(resources, coordinator) await entity.async_set_operation_mode(STATE_PERFORMANCE) assert coordinator.commands == [ - (entity._bound, ('power', True)), - (entity._bound, ('mode', 'Power')), + (entity._bound, ("power", True)), + (entity._bound, ("mode", "Power")), ] diff --git a/tests/test_water_purifier_capabilities.py b/tests/test_water_purifier_capabilities.py index 2f29b5a..32ec34a 100644 --- a/tests/test_water_purifier_capabilities.py +++ b/tests/test_water_purifier_capabilities.py @@ -3,19 +3,20 @@ HA-free like the rest of the suite: exercises the registry, discovery/ flatten, and the write contracts. """ + 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 custom_components.localthings.registry.entities import NumberDesc - from tests.conftest import _load_device def _water_purifier(): - resources = _load_device('water_purifier') - info = resources['/information/vs/0'] + resources = _load_device("water_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 @@ -37,7 +38,7 @@ def _desc(key): def test_model_resolves_to_water_purifier_registry(): reg, _ = _water_purifier() - assert reg is not None and reg.name == 'water_purifier' + assert reg is not None and reg.name == "water_purifier" def test_no_unbound_hrefs(): @@ -51,30 +52,39 @@ def test_no_unbound_hrefs(): def test_expected_state_keys_present(): state = _state() - for key in ('dispense_type', 'hot_water_temperature', 'dispense_capacity', - 'pouring', 'waterpurifier_status', 'filter_usage', 'filter_status', - 'hotwater_lock', 'coldwater_lock', 'buzz_lock'): + for key in ( + "dispense_type", + "hot_water_temperature", + "dispense_capacity", + "pouring", + "waterpurifier_status", + "filter_usage", + "filter_status", + "hotwater_lock", + "coldwater_lock", + "buzz_lock", + ): assert key in state, key def test_dispense_type_options_come_from_live_supported_types(): - desc = _desc('dispense_type') - assert desc.options_field == 'x.com.samsung.da.supportedTypes' + desc = _desc("dispense_type") + assert desc.options_field == "x.com.samsung.da.supportedTypes" def test_dispense_type_write_contract(): - desc = _desc('dispense_type') - path, body = desc.write_fn('hotwater', {}) - assert path == ['setting', 'waterpurifier', 'vs', '0'] - assert body == {'x.com.samsung.da.desiredType': 'hotwater'} + desc = _desc("dispense_type") + path, body = desc.write_fn("hotwater", {}) + assert path == ["setting", "waterpurifier", "vs", "0"] + assert body == {"x.com.samsung.da.desiredType": "hotwater"} def test_hot_water_temperature_is_a_select_not_a_number(): """Only a handful of discrete temperatures are selectable (not a continuous range) -- confirmed by supportedHotTemperatures being a short enumerated list, not a [min, max] range field.""" - desc = _desc('hot_water_temperature') - assert desc.options_field == 'x.com.samsung.da.supportedHotTemperatures' + desc = _desc("hot_water_temperature") + assert desc.options_field == "x.com.samsung.da.supportedHotTemperatures" def test_dispense_capacity_bounds_come_live_not_hardcoded(): @@ -82,51 +92,59 @@ def test_dispense_capacity_bounds_come_live_not_hardcoded(): capacityResolution fields, not a hardcoded constant -- see the adding-device-support skill's 'never hard-code the one dump's values' section.""" - desc = _desc('dispense_capacity') + desc = _desc("dispense_capacity") assert isinstance(desc, NumberDesc) assert desc.native_min is None assert desc.native_max is None - assert desc.range_field == 'x.com.samsung.da.desiredCapacityRange' - rep = {'x.com.samsung.da.desiredCapacityRange': ['50', '2000'], - 'x.com.samsung.da.capacityResolution': '10'} + assert desc.range_field == "x.com.samsung.da.desiredCapacityRange" + rep = { + "x.com.samsung.da.desiredCapacityRange": ["50", "2000"], + "x.com.samsung.da.capacityResolution": "10", + } assert desc.step_fn(rep) == 10 def test_dispense_capacity_write_contract(): - desc = _desc('dispense_capacity') - path, body = desc.write_fn('550', {}) - assert path == ['setting', 'waterpurifier', 'vs', '0'] - assert body == {'x.com.samsung.da.desiredCapacity': '550'} + desc = _desc("dispense_capacity") + path, body = desc.write_fn("550", {}) + assert path == ["setting", "waterpurifier", "vs", "0"] + assert body == {"x.com.samsung.da.desiredCapacity": "550"} def test_lock_switches_read_unlocked_as_off(): state = _state() - assert state['hotwater_lock'] is False - assert state['coldwater_lock'] is False - assert state['buzz_lock'] is False + assert state["hotwater_lock"] is False + assert state["coldwater_lock"] is False + assert state["buzz_lock"] is False def test_lock_switch_write_contracts(): - hot = _desc('hotwater_lock') - cold = _desc('coldwater_lock') - buzz = _desc('buzz_lock') - assert hot.write_fn('On', {}) == ( - ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.hotwaterLock': 'Locked'}) - assert cold.write_fn('Off', {}) == ( - ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.coldwaterLock': 'Unlocked'}) - assert buzz.write_fn('On', {}) == ( - ['status', 'lock', 'vs', '0'], {'x.com.samsung.da.buzzLock': 'Locked'}) + hot = _desc("hotwater_lock") + cold = _desc("coldwater_lock") + buzz = _desc("buzz_lock") + assert hot.write_fn("On", {}) == ( + ["status", "lock", "vs", "0"], + {"x.com.samsung.da.hotwaterLock": "Locked"}, + ) + assert cold.write_fn("Off", {}) == ( + ["status", "lock", "vs", "0"], + {"x.com.samsung.da.coldwaterLock": "Unlocked"}, + ) + assert buzz.write_fn("On", {}) == ( + ["status", "lock", "vs", "0"], + {"x.com.samsung.da.buzzLock": "Locked"}, + ) def test_favorite_capacity_options_come_from_live_capacity_list(): - desc = _desc('favorite_capacity') - assert desc.options_field == 'x.com.samsung.da.capacityList' + desc = _desc("favorite_capacity") + assert desc.options_field == "x.com.samsung.da.capacityList" def test_sterilize_timestamps_parsed_as_utc(): state = _state() - assert state['sterilize_last_time'].tzinfo is not None - assert state['sterilize_plan_time'].tzinfo is not None + assert state["sterilize_last_time"].tzinfo is not None + assert state["sterilize_plan_time"].tzinfo is not None def test_mode_hrefs_are_ignored_not_guessed(): @@ -135,9 +153,10 @@ def test_mode_hrefs_are_ignored_not_guessed(): plumbing, left unmodeled per the 'don't guess' rule rather than exposed as a nonsensical select.""" from custom_components.localthings.registry.capabilities import water_purifier + ignored_hrefs = {cap.href for cap in water_purifier.COVERAGE} - assert '/mode/vs/0' in ignored_hrefs - assert '/automation/waterpurifier/vs/0' in ignored_hrefs + assert "/mode/vs/0" in ignored_hrefs + assert "/automation/waterpurifier/vs/0" in ignored_hrefs # --------------------------------------------------------------------------- @@ -145,11 +164,13 @@ def test_mode_hrefs_are_ignored_not_guessed(): # /favorite/hotwater/vs/0, not present in issue #90's original dump. # --------------------------------------------------------------------------- + def _water_purifier_coffee(): - resources = _load_device('water_purifier_coffee') - info = resources['/information/vs/0'] + resources = _load_device("water_purifier_coffee") + 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 @@ -188,17 +209,25 @@ def test_coffee_variant_no_unbound_hrefs(): def test_coffee_variant_expected_state_keys_present(): state = _state_coffee() - for key in ('favorite_coffee_enabled', 'coffee_brew_status', - 'hotwater_lock', 'favorite_hotwater_temperature'): + for key in ( + "favorite_coffee_enabled", + "coffee_brew_status", + "hotwater_lock", + "favorite_hotwater_temperature", + ): assert key in state, key def test_favorite_coffee_write_contract(): - desc = _desc_coffee('favorite_coffee_enabled') - assert desc.write_fn('On', {}) == ( - ['favorite', 'coffee', 'vs', '0'], {'favorite.activate': 'On'}) - assert desc.write_fn('Off', {}) == ( - ['favorite', 'coffee', 'vs', '0'], {'favorite.activate': 'Off'}) + desc = _desc_coffee("favorite_coffee_enabled") + assert desc.write_fn("On", {}) == ( + ["favorite", "coffee", "vs", "0"], + {"favorite.activate": "On"}, + ) + assert desc.write_fn("Off", {}) == ( + ["favorite", "coffee", "vs", "0"], + {"favorite.activate": "Off"}, + ) def test_favorite_hotwater_switch_is_a_lock_not_an_enable_flag(): @@ -207,13 +236,17 @@ def test_favorite_hotwater_switch_is_a_lock_not_an_enable_flag(): hot-water lock as LOCK.hotwater_lock, just surfaced through this href on boards (like this fixture's) that don't populate /status/lock/vs/0's hotwaterLock field.""" - lock = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0') - assert lock.write_fn('On', {}) == ( - ['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Locked'}) - assert lock.write_fn('Off', {}) == ( - ['favorite', 'hotwater', 'vs', '0'], {'x.com.samsung.da.switchHotwater': 'Unlocked'}) - assert lock.value_fn('Unlocked') is False - assert lock.value_fn('Locked') is True + lock = _desc_coffee_by_href("hotwater_lock", "/favorite/hotwater/vs/0") + assert lock.write_fn("On", {}) == ( + ["favorite", "hotwater", "vs", "0"], + {"x.com.samsung.da.switchHotwater": "Locked"}, + ) + assert lock.write_fn("Off", {}) == ( + ["favorite", "hotwater", "vs", "0"], + {"x.com.samsung.da.switchHotwater": "Unlocked"}, + ) + assert lock.value_fn("Unlocked") is False + assert lock.value_fn("Locked") is True def test_favorite_hotwater_lock_wins_in_flattened_state(): @@ -229,7 +262,7 @@ def test_favorite_hotwater_lock_wins_in_flattened_state(): and flatten() would pick whichever of the two entities happens to be processed last.""" state = _state_coffee() - assert state['hotwater_lock'] is False + assert state["hotwater_lock"] is False def test_exactly_one_hotwater_lock_descriptor_exists_per_resource_state(): @@ -239,12 +272,11 @@ def test_exactly_one_hotwater_lock_descriptor_exists_per_resource_state(): the two must ever pass, regardless of iteration order, or two switch entities would be registered with the same unique_id.""" bound, resources = _bound_coffee() - candidates = [b for b in bound if b.desc.key == 'hotwater_lock'] + candidates = [b for b in bound if b.desc.key == "hotwater_lock"] assert len(candidates) == 2 - included = [b for b in candidates - if b.desc.exists_fn(resources.get(b.href) or {}, resources)] + included = [b for b in candidates if b.desc.exists_fn(resources.get(b.href) or {}, resources)] assert len(included) == 1 - assert included[0].href == '/favorite/hotwater/vs/0' + assert included[0].href == "/favorite/hotwater/vs/0" def test_hotwater_lock_fallback_gating_across_status_lock_states(): @@ -252,29 +284,29 @@ def test_hotwater_lock_fallback_gating_across_status_lock_states(): activate only once /status/lock/vs/0 is confirmed to lack hotwaterLock -- never while that resource is an unfetched stub ({}), and never when it does carry the field. LOCK's own descriptor is the mirror image.""" - fallback = _desc_coffee_by_href('hotwater_lock', '/favorite/hotwater/vs/0') - primary = _desc_coffee_by_href('hotwater_lock', '/status/lock/vs/0') - own_rep = {'x.com.samsung.da.switchHotwater': 'Unlocked'} + fallback = _desc_coffee_by_href("hotwater_lock", "/favorite/hotwater/vs/0") + primary = _desc_coffee_by_href("hotwater_lock", "/status/lock/vs/0") + own_rep = {"x.com.samsung.da.switchHotwater": "Unlocked"} # /status/lock/vs/0 absent entirely -- device genuinely lacks it. assert fallback.exists_fn(own_rep, {}) is True # /status/lock/vs/0 present but not yet fetched (a stub): outcome # pending, so the fallback must defer to LOCK rather than assume absence. - stub_resources = {'/status/lock/vs/0': {}} + stub_resources = {"/status/lock/vs/0": {}} assert fallback.exists_fn(own_rep, stub_resources) is False assert primary.exists_fn({}, stub_resources) is True # /status/lock/vs/0 fetched and confirmed to lack hotwaterLock (this # fixture's actual shape) -- the fallback wins. - absent_resources = {'/status/lock/vs/0': {'x.com.samsung.da.coldwaterLock': 'Unlocked'}} + absent_resources = {"/status/lock/vs/0": {"x.com.samsung.da.coldwaterLock": "Unlocked"}} assert fallback.exists_fn(own_rep, absent_resources) is True - assert primary.exists_fn(absent_resources['/status/lock/vs/0'], absent_resources) is False + assert primary.exists_fn(absent_resources["/status/lock/vs/0"], absent_resources) is False # /status/lock/vs/0 fetched and does carry hotwaterLock -- primary wins. - present_resources = {'/status/lock/vs/0': {'x.com.samsung.da.hotwaterLock': 'Unlocked'}} + present_resources = {"/status/lock/vs/0": {"x.com.samsung.da.hotwaterLock": "Unlocked"}} assert fallback.exists_fn(own_rep, present_resources) is False - assert primary.exists_fn(present_resources['/status/lock/vs/0'], present_resources) is True + assert primary.exists_fn(present_resources["/status/lock/vs/0"], present_resources) is True # The fallback also re-asserts its own field, since it no longer gets # that check for free once it shares LOCK's key (issue #144 review). @@ -290,8 +322,8 @@ def test_favorite_hotwater_temperature_options_come_from_live_show_list(): rendered as 'unknown' in HA (issue #196) -- see the ailite fixture's version of this test below for the case that actually exercises it (this fixture's showList == supportedList, no custom value set).""" - desc = _desc_coffee('favorite_hotwater_temperature') - assert desc.options_field == 'x.com.samsung.da.favorite.showList' + desc = _desc_coffee("favorite_hotwater_temperature") + assert desc.options_field == "x.com.samsung.da.favorite.showList" def test_coffee_recipe_hrefs_are_ignored_not_guessed(): @@ -299,9 +331,14 @@ def test_coffee_recipe_hrefs_are_ignored_not_guessed(): 'current recipe'/'current custom slot' field to expose, per the 'don't guess' rule.""" from custom_components.localthings.registry.capabilities import water_purifier + ignored_hrefs = {cap.href for cap in water_purifier.COVERAGE} - for href in ('/brand/recipe/info/vs/0', '/coffee/custom/recipe/vs/0', - '/recipe/coffee/vs/0', '/recipe/coffee/deletion/vs/0'): + for href in ( + "/brand/recipe/info/vs/0", + "/coffee/custom/recipe/vs/0", + "/recipe/coffee/vs/0", + "/recipe/coffee/deletion/vs/0", + ): assert href in ignored_hrefs, href @@ -315,11 +352,13 @@ def test_coffee_recipe_hrefs_are_ignored_not_guessed(): # this device type. # --------------------------------------------------------------------------- + def _water_purifier_ailite(): - resources = _load_device('water_purifier_ailite_25k') - info = resources['/information/vs/0'] + resources = _load_device("water_purifier_ailite_25k") + 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 @@ -341,7 +380,7 @@ def _desc_ailite(key): def test_ailite_model_resolves_to_water_purifier_not_refrigerator(): reg, _ = _water_purifier_ailite() - assert reg is not None and reg.name == 'water_purifier' + assert reg is not None and reg.name == "water_purifier" def test_ailite_no_unbound_hrefs(): @@ -353,8 +392,15 @@ def test_ailite_no_unbound_hrefs(): def test_ailite_expected_state_keys_present(): state = _state_ailite() - for key in ('cup_state', 'sound_mode', 'sound_output', 'sound_volume', - 'alarm_in_mute', 'last_pour_type', 'last_pour_capacity'): + for key in ( + "cup_state", + "sound_mode", + "sound_output", + "sound_volume", + "alarm_in_mute", + "last_pour_type", + "last_pour_capacity", + ): assert key in state, key @@ -366,7 +412,7 @@ def test_ailite_hot_water_temperature_gated_off_without_supported_list(): an empty options list. The descriptor must gate off entirely rather than register with empty options.""" state = _state_ailite() - assert 'hot_water_temperature' not in state + assert "hot_water_temperature" not in state def test_ailite_favorite_hotwater_temperature_options_include_the_custom_value(): @@ -379,16 +425,16 @@ def test_ailite_favorite_hotwater_temperature_options_include_the_custom_value() doesn't contain the current default, so HA would render the entity as 'unknown'. The descriptor must read from showList so '50' is in options.""" - desc = _desc_ailite('favorite_hotwater_temperature') - assert desc.options_field == 'x.com.samsung.da.favorite.showList' + desc = _desc_ailite("favorite_hotwater_temperature") + assert desc.options_field == "x.com.samsung.da.favorite.showList" _, resources = _water_purifier_ailite() - rep = resources['/favorite/hotwater/vs/0'] + rep = resources["/favorite/hotwater/vs/0"] # defaultTemperature='50' must be a member of the field the descriptor # actually reads -- this is the precise assertion that would fail under # the old supportedList behavior. - assert rep['x.com.samsung.da.favorite.defaultTemperature'] == '50' - assert '50' in rep[desc.options_field] - assert '50' not in rep['x.com.samsung.da.favorite.supportedList'] + assert rep["x.com.samsung.da.favorite.defaultTemperature"] == "50" + assert "50" in rep[desc.options_field] + assert "50" not in rep["x.com.samsung.da.favorite.supportedList"] def test_ailite_sound_mode_options_come_from_live_supported_modes(): @@ -396,16 +442,16 @@ def test_ailite_sound_mode_options_come_from_live_supported_modes(): laundry.SOUND_MODE's hardcoded voice/tone/mute and air_purifier.SOUND_MODE's mute/buzzer -- reusing either would reject a value this device actually supports, per the oven._OVEN_MODES lesson from issue #138.""" - desc = _desc_ailite('sound_mode') - assert desc.options_field == 'supportedModes' - assert desc.translation_key == 'water_purifier_sound_mode' + desc = _desc_ailite("sound_mode") + assert desc.options_field == "supportedModes" + assert desc.translation_key == "water_purifier_sound_mode" def test_ailite_sound_volume_bounds_come_live_not_hardcoded(): - desc = _desc_ailite('sound_volume') + desc = _desc_ailite("sound_volume") assert isinstance(desc, NumberDesc) assert desc.native_min is None and desc.native_max is None - rep = {'minLevel': '0', 'maxLevel': '15', 'resolution': '5'} + rep = {"minLevel": "0", "maxLevel": "15", "resolution": "5"} assert desc.native_min_fn(rep) == 0 assert desc.native_max_fn(rep) == 15 assert desc.step_fn(rep) == 5 @@ -416,7 +462,8 @@ def test_ailite_alarm_in_mute_is_read_only(): as a read-only diagnostic per the 'don't guess' rule rather than an invented switch.""" from custom_components.localthings.registry.entities import BinarySensorDesc - desc = _desc_ailite('alarm_in_mute') + + desc = _desc_ailite("alarm_in_mute") assert isinstance(desc, BinarySensorDesc) - assert desc.value_fn('true') is True - assert desc.value_fn('false') is False + assert desc.value_fn("true") is True + assert desc.value_fn("false") is False