Merge pull request #132 from mbillow/claude/triage-issues-111-127-on5l50
Triage issues #111-#131: AC/oven/air-purifier detection fixes, new vacuum-station type, reconnect log noise
This commit is contained in:
35 files changed
+3959
-49
No files matched your search
@@ -48,6 +48,7 @@ from .registry.capabilities.airconditioner import (
|
||||
HREF_TEMPS_VS as TEMPS_VS_HREF,
|
||||
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
|
||||
HREF_WIND_DIRECTION as WIND_DIRECTION_HREF,
|
||||
HREF_WIND_OSCILLATION as WIND_OSCILLATION_HREF,
|
||||
HREF_CONVENIENT as CONVENIENT_HREF,
|
||||
)
|
||||
from .registry.capabilities.common import normalize_temp_unit
|
||||
@@ -120,6 +121,26 @@ _DEVICE_TO_SWING: dict[str, str] = {
|
||||
}
|
||||
_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')
|
||||
if vertical is None and horizontal is None:
|
||||
return None
|
||||
v = vertical == 'Swing'
|
||||
h = horizontal == 'Swing'
|
||||
if v and h:
|
||||
return 'both'
|
||||
if v:
|
||||
return 'vertical'
|
||||
if h:
|
||||
return 'horizontal'
|
||||
return 'off'
|
||||
|
||||
# Preset (convenient mode): resolved dynamically from the device's own
|
||||
# /mode/convenient/vs/0 supportedModes -- no per-model table. The device 'Off'
|
||||
# code maps to HA's PRESET_NONE ("no preset active"); every other code is
|
||||
@@ -358,13 +379,25 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def fan_modes(self) -> list[str]:
|
||||
return self._read_modes(WIND_STRENGTH_HREF, _DEVICE_TO_FAN)
|
||||
|
||||
def _swing_via_direction(self) -> bool:
|
||||
"""True when WIND_DIRECTION_HREF is the swing channel to use --
|
||||
signalled by its presence. Boards without it (issue #126) report
|
||||
the 2-axis oscillation resource instead; see _oscillation_swing."""
|
||||
return bool(self._rep(WIND_DIRECTION_HREF))
|
||||
|
||||
@property
|
||||
def swing_mode(self):
|
||||
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||
if self._swing_via_direction():
|
||||
return self._read_mode(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||
return _oscillation_swing(self._rep(WIND_OSCILLATION_HREF))
|
||||
|
||||
@property
|
||||
def swing_modes(self) -> list[str]:
|
||||
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||
if self._swing_via_direction():
|
||||
return self._read_modes(WIND_DIRECTION_HREF, _DEVICE_TO_SWING)
|
||||
if self._rep(WIND_OSCILLATION_HREF):
|
||||
return list(_SWING_TO_DEVICE.keys())
|
||||
return []
|
||||
|
||||
@property
|
||||
def preset_mode(self):
|
||||
@@ -438,7 +471,12 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
await self._set_mapped('fan', _FAN_TO_DEVICE, fan_mode)
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
|
||||
if self._swing_via_direction():
|
||||
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))
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
if preset_mode == PRESET_AI_COMFORT:
|
||||
|
||||
@@ -85,6 +85,24 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
_OBSERVE_GRACE_PERIOD_S: float = GRACE_PERIOD_S
|
||||
_RECONNECT_PAUSE_S: float = 5.0
|
||||
|
||||
# A single reconnect is normal appliance-side behavior (see the
|
||||
# README's "Known device behavior" section) -- Samsung's firmware
|
||||
# drops the DTLS session briefly every now and then, and the
|
||||
# coordinator recovering from that on its own isn't something a user
|
||||
# needs to see at WARNING. Only escalate once reconnects pile up
|
||||
# within a trailing window (issue #119).
|
||||
#
|
||||
# The window can't be a literal 60s: consecutive reconnect attempts are
|
||||
# never closer together than one summary poll interval (SUMMARY_INTERVAL_S,
|
||||
# 30s) plus _RECONNECT_PAUSE_S, so at most ~2 can ever land inside a 60s
|
||||
# window regardless of how unhealthy the connection is -- a threshold of
|
||||
# 5 there could never fire, silently downgrading every reconnect
|
||||
# (including a persistently broken one) to INFO forever. 300s/3 instead:
|
||||
# reachable under normal polling, and 3 reconnects inside 5 minutes is
|
||||
# still a reasonable proxy for the README's "actually broken" case.
|
||||
_RECONNECT_WARN_WINDOW_S: float = 300.0
|
||||
_RECONNECT_WARN_THRESHOLD: int = 3
|
||||
|
||||
# A block-level ACK timeout on the summary GET doesn't prove the
|
||||
# session is dead (see _poll_once) — require this many in a row
|
||||
# before treating it as one. A single slow transfer on an otherwise
|
||||
@@ -136,6 +154,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self.one_ui_version: str = ''
|
||||
self._consecutive_poll_timeouts = 0
|
||||
self._unbound_hrefs: list[str] = []
|
||||
self._reconnect_times: list[float] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session management (all blocking — must run in executor)
|
||||
@@ -444,6 +463,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._consecutive_poll_timeouts += 1
|
||||
return self._consecutive_poll_timeouts < self._POLL_TIMEOUT_LIMIT
|
||||
|
||||
def _reconnect_is_frequent(self) -> bool:
|
||||
"""True once this cycle's reconnect is the Nth within the trailing
|
||||
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
|
||||
]
|
||||
self._reconnect_times.append(now)
|
||||
return len(self._reconnect_times) >= self._RECONNECT_WARN_THRESHOLD
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DataUpdateCoordinator hook
|
||||
# ------------------------------------------------------------------
|
||||
@@ -470,7 +500,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._consecutive_poll_timeouts = 0
|
||||
# One reconnect attempt — pause briefly so the device can
|
||||
# clean up its DTLS session state before we knock again.
|
||||
self._log.warning("poll failed, reconnecting: %s", e)
|
||||
# A lone reconnect is routine (see the README's "Known
|
||||
# device behavior" section); only warn once they're piling
|
||||
# up within the trailing window.
|
||||
if self._reconnect_is_frequent():
|
||||
self._log.warning("poll failed, reconnecting: %s", e)
|
||||
else:
|
||||
self._log.info("poll failed, reconnecting: %s", e)
|
||||
await self.hass.async_add_executor_job(self._close_session)
|
||||
await asyncio.sleep(self._RECONNECT_PAUSE_S)
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"""Fan platform for Samsung range hoods."""
|
||||
"""Fan platform for Samsung range hoods and air purifiers.
|
||||
|
||||
Two FanDesc-bound hrefs exist, one per family, dispatched by href in
|
||||
async_setup_entry below since they need different HA fan semantics: the
|
||||
range hood's fan speed is an ordered set of numeric levels (SET_SPEED), while
|
||||
the newer air-purifier board family's modes (Smart/Max/Mid/WindFree/Sleep,
|
||||
issue #130) are named behaviors with no linear order (PRESET_MODE)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -14,14 +22,19 @@ from homeassistant.util.percentage import (
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
from .registry.capabilities.air_purifier import HREF_MODE as AIR_PURIFIER_FAN_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'
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_MODES_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -29,11 +42,15 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities(
|
||||
LocalThingsRangeHoodFan(coordinator, bound)
|
||||
for bound in coordinator.bound
|
||||
if isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)
|
||||
)
|
||||
entities = []
|
||||
for bound in coordinator.bound:
|
||||
if not (isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)):
|
||||
continue
|
||||
if bound.href == AIR_PURIFIER_FAN_HREF:
|
||||
entities.append(LocalThingsAirPurifierFan(coordinator, bound))
|
||||
else:
|
||||
entities.append(LocalThingsRangeHoodFan(coordinator, bound))
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
@@ -120,3 +137,80 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
|
||||
)
|
||||
code = percentage_to_ordered_list_item(codes, percentage)
|
||||
await self.coordinator.async_send_command(self._bound, ('speed', code))
|
||||
|
||||
|
||||
class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
|
||||
"""Air-purifier fan: named preset modes, not an ordered percentage --
|
||||
see air_purifier.FAN's comment for why (Smart/WindFree/Sleep aren't
|
||||
"faster/slower" than Max/Mid)."""
|
||||
|
||||
_enable_turn_on_off_backwards_compatibility = False
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.PRESET_MODE
|
||||
| FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
)
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator, bound) -> None:
|
||||
super().__init__(coordinator, bound)
|
||||
self._attr_name = None
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
return self.coordinator.resource(href) or {}
|
||||
|
||||
def _mode_rep(self) -> dict:
|
||||
return self._rep(self._bound.href)
|
||||
|
||||
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
|
||||
"""Target whichever power resource this unit actually exposes --
|
||||
same pattern as LocalThingsRangeHoodFan._power_payload above.
|
||||
Writing a hardcoded href here would silently no-op on a board that
|
||||
only reports the other one, even though is_on already falls back
|
||||
correctly."""
|
||||
resources = self.coordinator.last_resources
|
||||
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
|
||||
return 'power', enabled, target
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
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'))
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
return [
|
||||
str(code).lower()
|
||||
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
|
||||
]
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
modes = self._mode_rep().get(_MODES_FIELD)
|
||||
code = modes[0] if isinstance(modes, (list, tuple)) and modes else modes
|
||||
return str(code).lower() if code is not None else None
|
||||
|
||||
async def async_turn_on(
|
||||
self, percentage: int | None = None, preset_mode: str | None = None,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(True))
|
||||
if preset_mode is not None:
|
||||
await self.async_set_preset_mode(preset_mode)
|
||||
|
||||
async def async_turn_off(self, **kwargs) -> None:
|
||||
await self.coordinator.async_send_command(self._bound, self._power_payload(False))
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
# Reverse-resolve against the unit's own supportedModes -- the
|
||||
# write needs the raw device code (e.g. 'WindFree'), not the
|
||||
# lowercased HA value.
|
||||
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
|
||||
if str(code).lower() == preset_mode:
|
||||
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,
|
||||
)
|
||||
@@ -5,7 +5,7 @@ from ._base import DeviceRegistry
|
||||
from . import (
|
||||
air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer,
|
||||
induction_cooktop, oven, range as _range, range_hood, refrigerator,
|
||||
washer, water_purifier,
|
||||
vacuum_station, washer, water_purifier,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -29,6 +29,7 @@ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = {
|
||||
'range': _range.REGISTRY,
|
||||
'range_hood': range_hood.REGISTRY,
|
||||
'refrigerator': refrigerator.REGISTRY,
|
||||
'vacuum_station': vacuum_station.REGISTRY,
|
||||
'washer': washer.REGISTRY,
|
||||
'water_purifier': water_purifier.REGISTRY,
|
||||
}
|
||||
@@ -198,6 +199,16 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
# here) -- no WAC-specific resources needed.
|
||||
if key is None and '_WAC_' in (model_num or ''):
|
||||
key = 'airconditioner'
|
||||
# ARA-WW-class wall-mount RACs (e.g. ARA-WW-TP1-22-COMMON, issues #115/
|
||||
# #116/#117/#120) report no oneUiVersion and no '_RAC_'/'-RAC-' token at
|
||||
# all -- the board family is spelled 'ARA-WW-' instead. Same resource
|
||||
# surface as the other TP1X-class room ACs above (mode/convenient/wind/
|
||||
# temperature/power/filter/humidity all confirmed against these dumps
|
||||
# binding cleanly against the existing airconditioner registry once
|
||||
# routed here) -- no ARA-WW-specific resources needed, so this reuses
|
||||
# the same registry rather than adding a new device type.
|
||||
if key is None and 'ARA-WW-' in (model_num or '').upper():
|
||||
key = 'airconditioner'
|
||||
# Air purifiers (e.g. ARTIK051_TVTL_18K, issue #56) report no
|
||||
# oneUiVersion either, and carry the '_TVTL_' board-family token.
|
||||
if key is None and '_TVTL_' in (model_num or ''):
|
||||
@@ -221,6 +232,15 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
# oneUiVersion and doesn't match the washer/dryer/dishwasher prefix map.
|
||||
if key is None and '-OVEN-' in (model_num or '').upper():
|
||||
key = 'oven'
|
||||
# Combi microwaves (e.g. TP1X_DA-KS-MICROWAVE-01041, issue #121) -- same
|
||||
# board family as the wall oven above (an '/oven/vs/0' cavity resource
|
||||
# with Convection/AirFryer/Grill/MicroWave modes on /mode/vs/0), just a
|
||||
# different cavity. Reports no oneUiVersion and doesn't match the
|
||||
# washer/dryer/dishwasher prefix map either. Binds cleanly against the
|
||||
# existing oven registry once routed here (confirmed against the issue
|
||||
# #121 dump) -- no microwave-specific device type needed.
|
||||
if key is None and '-MICROWAVE-' in (model_num or '').upper():
|
||||
key = 'oven'
|
||||
# Standalone induction cooktops (e.g. TP1X_DA-KS-COOKTOP-01011, issue
|
||||
# #86) -- same board family and '/cooktop/status/vs/0' resource shape
|
||||
# as the range combo above, but no oven attached at all. Distinct from
|
||||
@@ -231,6 +251,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
|
||||
# board family's.
|
||||
if key is None and '-COOKTOP-' in (model_num or '').upper():
|
||||
key = 'induction_cooktop'
|
||||
# Stick-vacuum clean/auto-empty station (e.g. A-VSKR-TP1-22-VS9500AL,
|
||||
# issue #131) -- reports no oneUiVersion and carries the '-VSKR-'
|
||||
# board-family token. See capabilities/vacuum_station.py for why this
|
||||
# is its own device type: the resource set (dustbag/dustbin/UV-sanitize
|
||||
# station state) shares no hrefs with anything else already modeled.
|
||||
if key is None and '-VSKR-' in (model_num or '').upper():
|
||||
key = 'vacuum_station'
|
||||
# Consumer-model prefix from `description` (washer/dryer/dishwasher) --
|
||||
# last, since it's the fuzziest match (a bare 2-letter prefix) and would
|
||||
# otherwise shadow the more specific board-family tokens above.
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
"""Air-purifier device registry (Samsung ARTIK051_TVTL-class, issue #56).
|
||||
"""Air-purifier device registry.
|
||||
|
||||
Reports no oneUiVersion; resolved via for_device_by_model's '_TVTL_' modelNum
|
||||
token (see registry.py). Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0
|
||||
(identical field/write contract).
|
||||
Spans two board generations sharing this one registry (see
|
||||
capabilities/air_purifier.py's module docstring for the per-href
|
||||
match_fn discriminators that keep them from colliding):
|
||||
|
||||
- ARTIK051_TVTL-class (issue #56). Reports no oneUiVersion; resolved via
|
||||
for_device_by_model's '_TVTL_' modelNum token (see registry.py).
|
||||
- TP1X_DA-AC-AIR-class (issue #130). Self-reports oneUiVersion "7.0 Air
|
||||
purifier", resolved via for_device(). Adds real fan-mode control plus
|
||||
display/HEPA-filter/pet-filter/sound resources the older family never
|
||||
reported; reuses airconditioner.DISPLAY_LIGHT and airconditioner.MUTE_ONCE
|
||||
for /light/vs/0 and /option/muteonce/vs/0, which are identical shapes on
|
||||
the shared DA-AC- board family.
|
||||
|
||||
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
|
||||
contract).
|
||||
"""
|
||||
from ..capabilities import air_purifier, common, dishwasher, ignored
|
||||
from ..capabilities import air_purifier, airconditioner, common, dishwasher, ignored
|
||||
from ._base import DeviceRegistry, _build
|
||||
|
||||
REGISTRY = DeviceRegistry(
|
||||
@@ -20,6 +32,16 @@ REGISTRY = DeviceRegistry(
|
||||
air_purifier.AIRFLOW_GENERIC,
|
||||
air_purifier.AIRFLOW_VS_FALLBACK,
|
||||
air_purifier.MODE,
|
||||
air_purifier.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,
|
||||
]),
|
||||
)
|
||||
@@ -25,6 +25,7 @@ REGISTRY = DeviceRegistry(
|
||||
airconditioner.DISPLAY_LIGHT,
|
||||
airconditioner.MUTE_ONCE,
|
||||
airconditioner.CURRENT_LIMIT,
|
||||
airconditioner.ANOMALY_LOAD,
|
||||
airconditioner.CURRENT_TEMPERATURE,
|
||||
airconditioner.CURRENT_TEMPERATURE_VS,
|
||||
airconditioner.HUMIDITY,
|
||||
|
||||
@@ -15,5 +15,6 @@ REGISTRY = DeviceRegistry(
|
||||
oven.OVEN_DOOR,
|
||||
oven.OVEN_CONNECTED,
|
||||
oven.OVEN_SPEC,
|
||||
oven.OVEN_RECIPE_COOK,
|
||||
]),
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Stick-vacuum clean/auto-empty station device registry (issue #131).
|
||||
|
||||
See capabilities/vacuum_station.py's module docstring for why this only
|
||||
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,
|
||||
]),
|
||||
)
|
||||
@@ -49,10 +49,26 @@ diagnostics snapshot was taken. Exposed read-only pending a confirmed,
|
||||
stable capture -- see the issue #56 discussion for what's needed.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none, sensor_item_value
|
||||
from ..entities import (
|
||||
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
|
||||
|
||||
# Newer TP1X_DA-AC-AIR-class boards (e.g. TP1X_DA-AC-AIR-01031_0000, issue
|
||||
# #130) report fan modes directly on /mode/vs/0's top-level `modes`/
|
||||
# `supportedModes` fields (Smart/Max/Mid/WindFree/Sleep) instead of packing
|
||||
# everything into the options[] array the way the older ARTIK051_TVTL
|
||||
# family above does -- that older family's /mode/vs/0 has no top-level
|
||||
# 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'
|
||||
|
||||
|
||||
def _has_top_level_modes(rep, resources):
|
||||
return isinstance(rep.get('x.com.samsung.da.supportedModes'), (list, tuple))
|
||||
|
||||
_AIR_QUALITY_SENSORS = (
|
||||
('dust', 'mdi:blur', 'Dust'),
|
||||
('fine_dust', 'mdi:blur', 'FineDust'),
|
||||
@@ -156,6 +172,7 @@ def _light_write(payload, rep, href=None):
|
||||
MODE = Capability(
|
||||
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',
|
||||
@@ -171,11 +188,200 @@ MODE = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _fan_write(payload, rep, href=None):
|
||||
kind, value, *args = payload
|
||||
if kind == 'power':
|
||||
# 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.
|
||||
power_href = args[0] if args else '/power/vs/0'
|
||||
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 kind == 'mode':
|
||||
return ['mode', 'vs', '0'], {'x.com.samsung.da.modes': [value]}
|
||||
return None
|
||||
|
||||
|
||||
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')
|
||||
if isinstance(modes, (list, tuple)):
|
||||
return modes[0] if modes else None
|
||||
return modes
|
||||
|
||||
|
||||
# Named preset modes (Smart/Max/Mid/WindFree/Sleep), not an ordered
|
||||
# percentage -- WindFree/Smart/Sleep are named behaviors, not
|
||||
# "faster/slower" positions relative to Max/Mid, so fan.py's entity for
|
||||
# this only exposes PRESET_MODE, matching how the AC family's own named
|
||||
# convenient modes are modeled as a preset rather than a speed number.
|
||||
FAN = Capability(
|
||||
href=HREF_MODE,
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several
|
||||
# resources the older ARTIK051_TVTL family never did.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Screen/indicator-panel on/off -- distinct from LIGHT below (ambient mood
|
||||
# light): both report the same {mode, supportedModes: [On, Off]} shape on
|
||||
# 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',
|
||||
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'})),
|
||||
),
|
||||
)
|
||||
|
||||
# Same filterUsage/filterCapacity/filterStatus shape as the AC family's own
|
||||
# AIR_FILTER (airconditioner.py) -- confirmed normal/wash/replace values not
|
||||
# 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',
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
# Physical panel/cover status -- meaning of the one value seen ('Close') is
|
||||
# plausible (the HEPA-filter access cover) but unconfirmed, and no
|
||||
# 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',
|
||||
entities=(
|
||||
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',
|
||||
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'})),
|
||||
),
|
||||
)
|
||||
|
||||
# Sound mode/volume shapes look like laundry.py's SOUND_MODE/SOUND_VOLUME at
|
||||
# a glance, but this board's actual values differ (supportedModes here is
|
||||
# ['mute', 'buzzer'], not laundry's hardcoded voice/tone/mute; volume range
|
||||
# is 0-3, not laundry's fixed 0-15) -- reusing those would either reject a
|
||||
# valid write ('buzzer') or expose the wrong number range, so these are
|
||||
# separate descriptors reading the live supported values instead of a
|
||||
# hardcoded table.
|
||||
SOUND_MODE = Capability(
|
||||
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})),
|
||||
),
|
||||
)
|
||||
|
||||
# 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',
|
||||
entities=(
|
||||
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',
|
||||
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))})),
|
||||
),
|
||||
)
|
||||
|
||||
# /humidity/0 and /humidity/vs/0 are empty {} on both dumps this family has
|
||||
# been verified against -- covered here (not globally, per ignored.py's
|
||||
# module docstring) since those hrefs collide with fridge/AC schemas
|
||||
# elsewhere. Same two hrefs and reasoning as airconditioner.py's _AC_IGNORED.
|
||||
#
|
||||
# The next six hrefs (issue #130, TP1X_DA-AC-AIR board) are the exact same
|
||||
# resources, same shapes, same reasoning as airconditioner.py's
|
||||
# _AC_IGNORED on the shared DA-AC- board family -- duplicated here rather
|
||||
# than promoted to the global ignored.py list, since that would require
|
||||
# also removing them from _AC_IGNORED in the same change (a global entry
|
||||
# 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
|
||||
# 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'),
|
||||
]
|
||||
@@ -16,7 +16,7 @@ by_type registry.
|
||||
"""
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, ClimateDesc, SensorDesc, SwitchDesc
|
||||
from .common import normalize_temp_unit
|
||||
from .common import filter_usage_percent, normalize_temp_unit
|
||||
from .laundry import option_write
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -35,13 +35,19 @@ HREF_TEMP_DESIRED = '/temperature/desired/0' # target_temperature (write ta
|
||||
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)
|
||||
|
||||
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_CONVENIENT,
|
||||
HREF_WIND_OSCILLATION, HREF_CONVENIENT,
|
||||
]
|
||||
|
||||
|
||||
@@ -71,18 +77,6 @@ def _temps_vs_unit(rep):
|
||||
return normalize_temp_unit(_temps_vs_item(rep).get('x.com.samsung.da.unit'), '°C')
|
||||
|
||||
|
||||
def _filter_usage_percent(rep):
|
||||
"""Filter usage as a percentage of rated capacity. The device reports
|
||||
`filterUsage` as a raw count in `filterCapacityUnit` (Hours here, 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'))
|
||||
if used is None or not cap:
|
||||
return None
|
||||
return round(used / cap * 100)
|
||||
|
||||
|
||||
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."""
|
||||
@@ -177,6 +171,15 @@ def _climate_write(payload, rep, href=None):
|
||||
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 == 'preset':
|
||||
return (['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': value})
|
||||
return None
|
||||
@@ -236,7 +239,7 @@ AIR_FILTER = Capability(
|
||||
href='/filter/airdustfilter/vs/0',
|
||||
poll_tier='cold',
|
||||
entities=(
|
||||
SensorDesc(key='air_filter_usage', rep_fn=_filter_usage_percent,
|
||||
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_status', field='x.com.samsung.da.filterStatus',
|
||||
@@ -299,6 +302,31 @@ CURRENT_LIMIT = Capability(
|
||||
),
|
||||
)
|
||||
|
||||
# Overload-response setting (issue #126, TP1X_DA-AC-RAC-01011 WindFree
|
||||
# variant): `operation` toggles the feature and `mode` picks 'Alarm' vs
|
||||
# 'PowerSaving' out of `supportedModes`, with a `savingTime` duration
|
||||
# ('20'/'40'/'60' minutes) alongside. Plausible shape (compressor-overload
|
||||
# alarm vs. automatic power throttling), but nothing in the dump confirms
|
||||
# the exact behavioral difference between the two modes or whether writing
|
||||
# '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',
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
# The climate entity already surfaces current_temperature as a card
|
||||
# attribute, but that's not enough for history graphs/automations/
|
||||
# statistics -- issue #75 asked for a standalone sensor. Same OCF-standard-
|
||||
|
||||
@@ -10,6 +10,8 @@ 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 ..capability import Capability
|
||||
from ..entities import (
|
||||
BinarySensorDesc, ButtonDesc, SelectDesc, SensorDesc, SwitchDesc,
|
||||
@@ -40,6 +42,35 @@ def wh_to_kwh(v):
|
||||
return round(n / 1000.0, 2) if n is not None else None
|
||||
|
||||
|
||||
def parse_iso_utc(raw):
|
||||
"""ISO datetime defaulting to UTC when the string carries no timezone
|
||||
of its own (this integration's convention for other bare ISO datetime
|
||||
fields -- see washer.py's drum-clean-log comment). A few boards do
|
||||
ship a 'Z'/offset suffix (fromisoformat parses that natively since
|
||||
Python 3.11) -- only fill in UTC when parsing left the result naive,
|
||||
rather than unconditionally overwriting whatever offset was parsed."""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return dt if dt.tzinfo is not None else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def filter_usage_percent(rep):
|
||||
"""Filter usage as a percentage of rated capacity. Several families
|
||||
(AC, air purifier) report `filterUsage` as a raw count in
|
||||
`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'))
|
||||
if used is None or not cap:
|
||||
return None
|
||||
return round(used / cap * 100)
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -338,6 +338,12 @@ OVEN_CONNECTED = Capability(
|
||||
# fields worth exposing.
|
||||
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_MODE = Capability(
|
||||
href='/mode/vs/0',
|
||||
poll_tier='warm',
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Capabilities for the Samsung stick-vacuum clean/auto-empty station
|
||||
(model A-VSKR-TP1-22-VS9500AL, "Bespoke Jet" clean station, issue #131).
|
||||
|
||||
The reporter's diagnostics dump shows no vacuum-body state at all (no
|
||||
suction level, no battery, no cleaning-mode/room-mapping control, no
|
||||
docked/undocked status even) -- only the clean station's own dustbag,
|
||||
dustbin auto-empty settings, and UV-C sanitizing-cycle status. This
|
||||
strongly suggests the WiFi/DTLS module lives in the station, not the
|
||||
handheld stick: the station is the only "device" this integration's local
|
||||
API can see, and the wand's own controls are unrelated hardware not
|
||||
reachable this way. Modeled as its own device type -- these hrefs don't
|
||||
overlap with any existing family, so there's no shared-href ambiguity to
|
||||
resolve against another type (see registry/by_type/__init__.py's
|
||||
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
|
||||
|
||||
|
||||
DUSTBAG = Capability(
|
||||
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'),
|
||||
),
|
||||
)
|
||||
|
||||
# dustbagUsage/dustbagPrevUsage are raw counters with no capacity/resolution
|
||||
# field alongside them to normalize into a percentage (unlike the AC/range
|
||||
# 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',
|
||||
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),
|
||||
),
|
||||
)
|
||||
|
||||
DUSTBIN_SETTING = Capability(
|
||||
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})),
|
||||
),
|
||||
)
|
||||
|
||||
# stickStatus's exact meaning (docked? powered? charging?) isn't confirmed
|
||||
# from this single On/Off dump -- exposed as a plain diagnostic sensor
|
||||
# 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',
|
||||
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),
|
||||
),
|
||||
)
|
||||
@@ -3,23 +3,9 @@ issue #90, model TP2X_WATERPURIFIER_20K).
|
||||
|
||||
Resources verified against the issue #90 diagnostics dump.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..capability import Capability
|
||||
from ..entities import BinarySensorDesc, NumberDesc, SelectDesc, SensorDesc, SwitchDesc
|
||||
from .common import int_or_none
|
||||
|
||||
|
||||
def _parse_iso_utc(raw):
|
||||
"""Bare ISO datetime with no timezone field alongside it -- treated as
|
||||
UTC, matching this integration's convention for other bare ISO datetime
|
||||
fields (see washer.py's drum-clean-log comment)."""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
from .common import int_or_none, parse_iso_utc as _parse_iso_utc
|
||||
|
||||
|
||||
DISPENSE = Capability(
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"cloud_connected": {
|
||||
"name": "Cloud connected"
|
||||
},
|
||||
"dustbag_full": {
|
||||
"name": "Dust bag full"
|
||||
},
|
||||
"cooktop_power": {
|
||||
"name": "Cooktop power"
|
||||
},
|
||||
@@ -25,6 +28,9 @@
|
||||
"current_limit_enabled": {
|
||||
"name": "Current limit enabled"
|
||||
},
|
||||
"overload_protection_active": {
|
||||
"name": "Overload protection active"
|
||||
},
|
||||
"cycle_active": {
|
||||
"name": "Running"
|
||||
},
|
||||
@@ -128,6 +134,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_purifier_fan": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"smart": "Smart",
|
||||
"max": "Max",
|
||||
"mid": "Mid",
|
||||
"windfree": "WindFree",
|
||||
"sleep": "Sleep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"cook_time": {
|
||||
"name": "Cook time"
|
||||
@@ -185,6 +206,13 @@
|
||||
"on": "On"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
"name": "Discharging time",
|
||||
"state": {
|
||||
"1": "1 minute",
|
||||
"3": "3 minutes"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"name": "Cycle"
|
||||
},
|
||||
@@ -385,6 +413,13 @@
|
||||
"mute": "Mute"
|
||||
}
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Sound mode",
|
||||
"state": {
|
||||
"mute": "Mute",
|
||||
"buzzer": "Buzzer"
|
||||
}
|
||||
},
|
||||
"spin_speed": {
|
||||
"name": "Spin speed",
|
||||
"state": {
|
||||
@@ -515,6 +550,43 @@
|
||||
"current_limit_level": {
|
||||
"name": "Current limit level"
|
||||
},
|
||||
"hepa_filter_usage": {
|
||||
"name": "HEPA filter usage"
|
||||
},
|
||||
"panel_status": {
|
||||
"name": "Panel status"
|
||||
},
|
||||
"sound_output": {
|
||||
"name": "Sound output"
|
||||
},
|
||||
"dustbag_usage": {
|
||||
"name": "Dust bag usage"
|
||||
},
|
||||
"cleanstation_status": {
|
||||
"name": "Clean station status"
|
||||
},
|
||||
"stick_status": {
|
||||
"name": "Stick status"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "UV-C operation time"
|
||||
},
|
||||
"uvc_total_operation_time": {
|
||||
"name": "UV-C total operation time"
|
||||
},
|
||||
"uvc_finished_time": {
|
||||
"name": "UV-C finished time"
|
||||
},
|
||||
"uvc_emitted_time": {
|
||||
"name": "UV-C emitted time"
|
||||
},
|
||||
"overload_protection_mode": {
|
||||
"name": "Overload protection mode",
|
||||
"state": {
|
||||
"alarm": "Alarm",
|
||||
"powersaving": "Power saving"
|
||||
}
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
@@ -729,6 +801,21 @@
|
||||
"auto_clean": {
|
||||
"name": "Auto clean"
|
||||
},
|
||||
"display": {
|
||||
"name": "Display"
|
||||
},
|
||||
"pet_filter_activation": {
|
||||
"name": "Pet filter activation"
|
||||
},
|
||||
"auto_empty": {
|
||||
"name": "Auto empty"
|
||||
},
|
||||
"dustbin_auto_close": {
|
||||
"name": "Dustbin auto-close"
|
||||
},
|
||||
"uvc_intensive_mode": {
|
||||
"name": "UV-C intensive mode"
|
||||
},
|
||||
"auto_door_opener": {
|
||||
"name": "Auto door opener"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"cloud_connected": {
|
||||
"name": "Verbonden met cloud"
|
||||
},
|
||||
"dustbag_full": {
|
||||
"name": "Stofzak vol"
|
||||
},
|
||||
"cooktop_power": {
|
||||
"name": "Kookplaat voeding"
|
||||
},
|
||||
@@ -25,6 +28,9 @@
|
||||
"current_limit_enabled": {
|
||||
"name": "Stroombegrenzing ingeschakeld"
|
||||
},
|
||||
"overload_protection_active": {
|
||||
"name": "Overbelastingsbeveiliging actief"
|
||||
},
|
||||
"cycle_active": {
|
||||
"name": "Actief"
|
||||
},
|
||||
@@ -128,6 +134,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fan": {
|
||||
"air_purifier_fan": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"smart": "Slim",
|
||||
"max": "Max",
|
||||
"mid": "Gemiddeld",
|
||||
"windfree": "WindFree",
|
||||
"sleep": "Slaap"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"cook_time": {
|
||||
"name": "Bereidingstijd"
|
||||
@@ -185,6 +206,13 @@
|
||||
"on": "Aan"
|
||||
}
|
||||
},
|
||||
"discharging_time": {
|
||||
"name": "Leegtijd",
|
||||
"state": {
|
||||
"1": "1 minuut",
|
||||
"3": "3 minuten"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"name": "Programma"
|
||||
},
|
||||
@@ -385,6 +413,13 @@
|
||||
"mute": "Dempen"
|
||||
}
|
||||
},
|
||||
"air_purifier_sound_mode": {
|
||||
"name": "Geluidsmodus",
|
||||
"state": {
|
||||
"mute": "Dempen",
|
||||
"buzzer": "Zoemer"
|
||||
}
|
||||
},
|
||||
"spin_speed": {
|
||||
"name": "Centrifugesnelheid",
|
||||
"state": {
|
||||
@@ -515,6 +550,43 @@
|
||||
"current_limit_level": {
|
||||
"name": "Niveau stroombegrenzing"
|
||||
},
|
||||
"hepa_filter_usage": {
|
||||
"name": "HEPA-filtergebruik"
|
||||
},
|
||||
"panel_status": {
|
||||
"name": "Paneelstatus"
|
||||
},
|
||||
"sound_output": {
|
||||
"name": "Geluidsuitvoer"
|
||||
},
|
||||
"dustbag_usage": {
|
||||
"name": "Stofzakgebruik"
|
||||
},
|
||||
"cleanstation_status": {
|
||||
"name": "Status schoonmaakstation"
|
||||
},
|
||||
"stick_status": {
|
||||
"name": "Status steel"
|
||||
},
|
||||
"uvc_operation_time": {
|
||||
"name": "UV-C bedrijfstijd"
|
||||
},
|
||||
"uvc_total_operation_time": {
|
||||
"name": "UV-C totale bedrijfstijd"
|
||||
},
|
||||
"uvc_finished_time": {
|
||||
"name": "UV-C voltooid op"
|
||||
},
|
||||
"uvc_emitted_time": {
|
||||
"name": "UV-C uitgestraald op"
|
||||
},
|
||||
"overload_protection_mode": {
|
||||
"name": "Modus overbelastingsbeveiliging",
|
||||
"state": {
|
||||
"alarm": "Alarm",
|
||||
"powersaving": "Energiebesparing"
|
||||
}
|
||||
},
|
||||
"current_temp_c": {
|
||||
"name": "Temperatuur"
|
||||
},
|
||||
@@ -729,6 +801,21 @@
|
||||
"auto_clean": {
|
||||
"name": "Automatisch reinigen"
|
||||
},
|
||||
"display": {
|
||||
"name": "Display"
|
||||
},
|
||||
"pet_filter_activation": {
|
||||
"name": "Huisdierfilter actief"
|
||||
},
|
||||
"auto_empty": {
|
||||
"name": "Automatisch legen"
|
||||
},
|
||||
"dustbin_auto_close": {
|
||||
"name": "Stofreservoir automatisch sluiten"
|
||||
},
|
||||
"uvc_intensive_mode": {
|
||||
"name": "Intensieve UV-C-modus"
|
||||
},
|
||||
"auto_door_opener": {
|
||||
"name": "Automatische deuropener"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user