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:
@@ -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'),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user