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'),
]
+310
View File
@@ -0,0 +1,310 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/airlevelcheck/vs/0",
"rep": {
"x.com.samsung.da.periodicSensingActivationState": "Off",
"x.com.samsung.da.periodicSensingInterval": "600",
"x.com.samsung.da.startSensingOnce": "On",
"x.com.samsung.da.sensingState": "NonProcessing",
"x.com.samsung.da.lastSensingTime": "1785195000",
"x.com.samsung.da.lastSensingLevel": "Kr1",
"x.com.samsung.da.autoExeState": "Off",
"x.com.samsung.da.supportedAutoExeState": [
"Off",
"Airpurify",
"Alarm"
],
"x.com.samsung.da.periodicSensingSkipStatus": "Off",
"x.com.samsung.da.periodicSensingSkipTime": "15002100"
}
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2024-01-01T00:00:00",
"x.com.samsung.da.state": "Deleted"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "0000000000000000000000000000",
"x.com.samsung.da.id": "VTL",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3017000000",
"x.com.samsung.da.airconOptionList": [
"WELCOMECARE",
"AI_PURIFY"
],
"x.com.samsung.da.countryCode": ""
}
},
{
"href": "/da/softreset/vs/0",
"rep": {
"x.com.samsung.da.softwarereset": "false"
}
},
{
"href": "/devicespecificinfo/vs/0",
"rep": {
"x.com.samsung.da.deviceActive": true
}
},
{
"href": "/dnd/autosleep/vs/0",
"rep": {
"x.com.samsung.da.startTime": "14:00:00",
"x.com.samsung.da.endTime": "22:00:00",
"x.com.samsung.da.visible": "true",
"x.com.samsung.da.useTimeSetting": "true",
"x.com.samsung.da.functionState": "true"
}
},
{
"href": "/energy/consumption/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "196696",
"x.com.samsung.da.cumulativeDate": "1785245199",
"x.com.samsung.da.cumulativeDateUTC": "1785212799",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00"
}
},
{
"href": "/filter/hepafilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "0",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "8760",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"replaceable"
]
}
},
{
"href": "/humidity/0",
"rep": {}
},
{
"href": "/humidity/vs/0",
"rep": {}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "A-VTWW-TP2-21-COMMON|10231241|70000535001511EC0505090201000000",
"x.com.samsung.da.description": "A-VTWW-TP2-21-COMMON",
"x.com.samsung.da.serialNum": "REDACTED",
"x.com.samsung.da.otnDUID": "REDACTED",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "101",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02308A260310",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "21063003,21063002",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.options": [
"CountOfSlave_0",
"SlavePower01_NotSupported",
"Pollution_Off",
"OptionCode_23418",
"SmartSleep_Off"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "A-VTWW-TP2-21-COMMON",
"versions": [
"12260310"
],
"visVersion": "260310"
},
{
"type": "Micom",
"modelId": "09501023124110231341",
"versions": [
"21063003",
"21063002"
],
"visVersion": "210630"
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "7",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/sensors/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Sensor for Dust",
"x.com.samsung.da.type": "Dust",
"x.com.samsung.da.value": [
"5",
"1"
]
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Sensor for FineDust",
"x.com.samsung.da.type": "FineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Sensor for Odor",
"x.com.samsung.da.type": "Odor",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "3",
"x.com.samsung.da.description": "Sensor for CleanLevel",
"x.com.samsung.da.type": "CleanLevel",
"x.com.samsung.da.value": [
"1"
]
},
{
"x.com.samsung.da.id": "4",
"x.com.samsung.da.description": "Sensor for SuperFineDust",
"x.com.samsung.da.type": "SuperFineDust",
"x.com.samsung.da.value": [
"5",
"1"
]
}
],
"x.com.samsung.da.total": "5"
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "87",
"x.com.samsung.da.supportedModes": [
"87",
"89",
"90",
"91"
],
"x.com.samsung.da.modesName": [
"SMART",
"MAX",
"WINDFREE",
"Sleep"
]
}
}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"state_keys": [
"alarm_code",
"clean_level",
"device_active",
"dust",
"energy_kwh",
"fan",
"fine_dust",
"firmware_update",
"hepa_filter_status",
"hepa_filter_usage",
"odor",
"power_switch",
"super_fine_dust"
]
}
+91
View File
@@ -0,0 +1,91 @@
"""HA fan-entity mapping tests for the A-VTWW-TP2-21-COMMON BESPOKE Cube Air
(issue #151).
This board's /wind/strength/vs/0 reports numeric wind-strength codes
("87"/"89"/"90"/"91") with a separate modesName array ("SMART"/"MAX"/
"WINDFREE"/"Sleep") giving the actual names, unlike the TP1X_DA-AC-AIR
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 custom_components.localthings.fan import LocalThingsAirPurifierFan
from custom_components.localthings.registry.by_type import air_purifier, 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 = {}
def __init__(self, resources):
self.last_resources = resources
self.commands = []
def resource(self, href):
return self.last_resources.get(href, {})
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def _resources():
return _load_device('air_purifier_vtww')
def _reg(resources):
info = resources['/information/vs/0']
return for_device_by_model(
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
)
return LocalThingsAirPurifierFan(coordinator or _FakeCoordinator(resources), fan_bound)
def test_resolves_to_air_purifier_registry():
assert _reg(_resources()).name == 'air_purifier'
def test_no_unbound_hrefs():
resources = _resources()
reg = _reg(resources)
unbound = []
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
assert unbound == []
def test_preset_modes_come_from_modes_name_not_the_raw_codes():
entity = _entity(_resources())
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'
async def test_set_preset_mode_writes_back_the_raw_code():
resources = _resources()
coordinator = _FakeCoordinator(resources)
entity = _entity(resources, coordinator)
await entity.async_set_preset_mode('windfree')
assert coordinator.commands[-1][1] == ('mode', '90')
def test_is_on_reads_vendor_power():
entity = _entity(_resources())
assert entity.is_on is False
+18
View File
@@ -768,6 +768,24 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser_tp2_20():
)
def test_registry_reproduces_golden_state_keys_for_air_purifier_vtww():
"""A-VTWW-TP2-21-COMMON BESPOKE Cube Air (issue #151) -- reports no
oneUiVersion; resolved via the '-VTWW-' modelNum token fallback into
the existing air_purifier registry. Its fan lives on
/wind/strength/vs/0 (air_purifier.WIND_STRENGTH_FAN) rather than the
/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']), (
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))}"
)
def test_resources_from_batch_preferred_over_flat():
from tests.conftest import _resources_from_dump
dump = {