Add device support for BESPOKE Cube Air A-VTWW-TP2-21-COMMON (#151)

This board reports no oneUiVersion and no modelNum token any existing
family routed on, so it fell back to unknown -- exposing nothing but power
even though most of its resources (air quality sensors, HEPA filter,
device-active, diagnosis, plumbing hrefs) are already handled generically
by the existing air_purifier registry via the '-VTWW-' modelNum fallback.

The one genuinely new piece is its fan: this board reports wind strength as
numeric codes ("87"/"89"/"90"/"91") on /wind/strength/vs/0 with a separate
modesName array ("SMART"/"MAX"/"WINDFREE"/"Sleep") giving the actual names,
unlike the existing TP1X_DA-AC-AIR family where supportedModes IS the name
list already. Generalized LocalThingsAirPurifierFan to resolve a mode code
through modesName when present (same live-label pattern as climate.py's AC
wind-strength fix, issue #155) instead of adding a second hardcoded fan
class, and added WIND_STRENGTH_FAN reusing the same 'air_purifier_fan'
translation catalog -- both board generations land on the identical
smart/max/windfree/sleep vocabulary already labelled there.

/mode/convenient/vs/0 is empty on this dump and added to COVERAGE alongside
the existing plumbing hrefs this board also shares with the TP1X_DA-AC-AIR
family.
This commit is contained in:
Marc Billow
2026-07-28 13:58:08 +00:00
parent a3d9cce164
commit fcd34f73e5
8 changed files with 519 additions and 12 deletions
+35 -11
View File
@@ -1,15 +1,18 @@
"""Fan platform for Samsung range hoods and air purifiers.
Three FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
Four FanDesc-bound hrefs exist, dispatched by href in async_setup_entry
below since each needs different HA fan semantics: the range hood's fan
speed and the older ARTIK051_TVTL air-purifier family's Auto/Sleep/Low/
Medium/High (issue #56) are both an ordered set of numeric levels
(SET_SPEED) -- the latter confirmed monotonic in capabilities/
air_purifier.py's module docstring, with no named-mode list to preserve
since this board never self-reports one. The newer TP1X air-purifier
family's modes (Smart/Max/Mid/WindFree/Sleep, issue #130) are named
behaviors with no linear order (PRESET_MODE), reported directly by that
board's own supportedModes."""
since this board never self-reports one. The TP1X air-purifier family's
modes (Smart/Max/Mid/WindFree/Sleep, issue #130) and the A-VTWW-TP2-21
family's /wind/strength/vs/0 modes (issue #151) are both named behaviors
with no linear order (PRESET_MODE) -- LocalThingsAirPurifierFan handles
both hrefs, the only difference being whether the label comes straight
from supportedModes or from a parallel modesName array (see
_label_for_code)."""
from __future__ import annotations
@@ -29,6 +32,7 @@ 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.entities import FanDesc
_LOGGER = logging.getLogger(__name__)
@@ -42,6 +46,7 @@ _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'
async def async_setup_entry(
@@ -54,7 +59,7 @@ async def async_setup_entry(
for bound in coordinator.bound:
if not (isinstance(bound.desc, FanDesc) and _is_included(bound, coordinator)):
continue
if bound.href == AIR_PURIFIER_FAN_HREF:
if bound.href in (AIR_PURIFIER_FAN_HREF, AIR_PURIFIER_WIND_STRENGTH_HREF):
entities.append(LocalThingsAirPurifierFan(coordinator, bound))
elif bound.href == HREF_AIRFLOW:
entities.append(LocalThingsAirflowFan(coordinator, bound))
@@ -243,10 +248,29 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
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.
The TP1X_DA-AC-AIR board (issue #130) reports its named modes
directly as supportedModes ('Smart'/'Max'/...), so the code IS the
label. The A-VTWW-TP2-21 board (issue #151) instead reports numeric
wind-strength codes ('87'/'89'/...) with a separate modesName array
(parallel-indexed with supportedModes) giving the actual names --
same shape as climate.py's _wind_strength_label, and coincidentally
the same word set (Smart/Max/WindFree/Sleep), so both board
generations land on identical HA preset values without needing
their own translation catalog entry."""
rep = self._mode_rep()
supported = list(rep.get(_SUPPORTED_MODES_FIELD, ()))
names = rep.get(_MODES_NAME_FIELD)
if names and code in supported and len(names) == len(supported):
return str(names[supported.index(code)]).lower()
return str(code).lower()
@property
def preset_modes(self) -> list[str]:
return [
str(code).lower()
self._label_for_code(code)
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ())
]
@@ -254,7 +278,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
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
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,
@@ -269,10 +293,10 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
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.
# write needs the raw device code (e.g. 'WindFree', or '90' on the
# modesName-labelled board), not the lowercased HA value.
for code in self._mode_rep().get(_SUPPORTED_MODES_FIELD, ()):
if str(code).lower() == preset_mode:
if self._label_for_code(code) == preset_mode:
await self.coordinator.async_send_command(self._bound, ('mode', code))
return
_LOGGER.warning(
@@ -223,6 +223,13 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi
key = 'airconditioner'
if key is None and '_TVTL_' in (model_num or ''):
key = 'air_purifier'
# BESPOKE Cube Air (e.g. A-VTWW-TP2-21-COMMON, issue #151) reports no
# oneUiVersion and carries the hyphenated '-VTWW-' board-family token
# (distinct from the underscore-delimited '_TVTL_' ARTIK051 family
# above). Its fan lives on /wind/strength/vs/0 rather than /mode/vs/0 --
# see capabilities/air_purifier.py's WIND_STRENGTH_FAN.
if key is None and '-VTWW-' in (model_num or '').upper():
key = 'air_purifier'
model_identity = f'{model_num} {description}'.upper()
if key is None and ('_COOKTOP' in model_identity or '_GB_CT_' in model_identity):
key = 'cooktop'
@@ -1,6 +1,6 @@
"""Air-purifier device registry.
Spans two board generations sharing this one registry (see
Spans three 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):
@@ -12,6 +12,10 @@ match_fn discriminators that keep them from colliding):
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.
- A-VTWW-TP2-21-COMMON-class (issue #151). Reports no oneUiVersion and no
existing modelNum token; falls back to unknown until routed here. Its fan
is WIND_STRENGTH_FAN on /wind/strength/vs/0 rather than FAN on
/mode/vs/0 -- see that capability's comment.
Reuses dishwasher.DIAGNOSIS for /diagnosis/vs/0 (identical field/write
contract).
@@ -33,6 +37,7 @@ REGISTRY = DeviceRegistry(
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,
@@ -74,6 +74,7 @@ from .laundry import bool_option_exists, bool_option_value, option_value, option
# 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'
def _has_top_level_modes(rep, resources):
@@ -272,6 +273,36 @@ FAN = Capability(
),
)
def _wind_strength_fan_write(payload, rep, href=None):
kind, value, *args = payload
if kind == 'power':
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 ['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': value}
return None
# A-VTWW-TP2-21-COMMON (issue #151): named preset modes like FAN above, but
# on a distinct href with numeric codes ("87"/"89"/"90"/"91") instead of
# self-describing supportedModes -- x.com.samsung.da.modesName gives the
# actual names (SMART/MAX/WINDFREE/Sleep), read live by fan.py's
# LocalThingsAirPurifierFan._label_for_code rather than a hardcoded
# per-model map. modes here is a bare string ('87'), not a single-element
# list like HREF_MODE's -- _wind_strength_fan_write writes it back as-is.
WIND_STRENGTH_FAN = Capability(
href=HREF_WIND_STRENGTH,
poll_tier='warm',
entities=(
FanDesc(key='fan', translation_key='air_purifier_fan',
field='x.com.samsung.da.modes', write_fn=_wind_strength_fan_write),
),
)
# ---------------------------------------------------------------------------
# TP1X_DA-AC-AIR-class additions (issue #130). This board reports several
# resources the older ARTIK051_TVTL family never did.
@@ -425,4 +456,8 @@ COVERAGE = [
# 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'),
# 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'),
]