Add device support for the stick-vacuum clean/auto-empty station (issue #131)

A-VSKR-TP1-22-VS9500AL connects successfully but its dump shows no
vacuum-body state at all -- no suction level, no battery, no cleaning
mode -- only the clean/auto-empty 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, so the station is the only "device" this integration's local
API can reach at all.

New vacuum_station device type (these hrefs share nothing with any
existing family, so there's no shared-href ambiguity to resolve
against another type) routed via a new '-VSKR-' modelNum fallback.
Binds with zero unbound hrefs: dust-bag full/usage sensors, auto-empty
and dustbin auto-close switches, a discharging-time select, and
clean-station status including UV-C intensive mode, operation time,
and finished/emitted timestamps. A couple of fields with unconfirmed
exact semantics (stick_status, dustbag_usage's unit) are exposed as
plain diagnostic values rather than an asserted binary/percentage
meaning.
This commit is contained in:
Marc Billow committed 2026-07-27 14:11:06 +00:00
1 parent 4e1eb7d03d
commit 8b44fd5710
7 files changed
+379 -1

No files matched your search

@@ -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,
}
@@ -250,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.
@@ -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,
]),
)
@@ -0,0 +1,123 @@
"""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 datetime import datetime, timezone
from ..capability import Capability
from ..entities import BinarySensorDesc, 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 water_purifier.py's own _parse_iso_utc)."""
if not raw:
return None
try:
return datetime.fromisoformat(raw).replace(tzinfo=timezone.utc)
except ValueError:
return None
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),
),
)
+19
View File
@@ -0,0 +1,19 @@
{
"state_keys": [
"alarm_code",
"auto_empty",
"cleanstation_status",
"discharging_time",
"dustbag_full",
"dustbag_usage",
"dustbin_auto_close",
"energy_kwh",
"firmware_update",
"stick_status",
"uvc_emitted_time",
"uvc_finished_time",
"uvc_intensive_mode",
"uvc_operation_time",
"uvc_total_operation_time"
]
}
+170
View File
@@ -0,0 +1,170 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.timeforlongnoti": "false",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/alarms/vs/0",
"rep": {}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativePower": "915",
"x.com.samsung.da.cumulativeUnit": "Wh"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "A-VSKR-TP1-22-VS9500AL|50023541|80030100001611000800000000000000",
"x.com.samsung.da.description": "A-VSKR-TP1-22-VS9500AL",
"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": "VS1",
"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": "023741250507",
"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": "25050800",
"x.com.samsung.da.newVersionAvailable": "0"
}
]
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "2650000000",
"x.com.samsung.da.airconOptionList": [
"HOMECARE_WIZARD_V2",
"CLEAN_OPTION_SUPPORT"
]
}
},
{
"href": "/component/station/dustbag/vs/0",
"rep": {
"x.com.samsung.da.status": "normal",
"x.com.samsung.da.supportedStatus": [
"full",
"normal"
]
}
},
{
"href": "/setting/dustbin/vs/0",
"rep": {
"x.com.samsung.da.autoEmpty": "On",
"x.com.samsung.da.autoClose": "On",
"x.com.samsung.da.desiredDischargingTime": "3",
"x.com.samsung.da.supportedDischargingTime": [
"1",
"3"
]
}
},
{
"href": "/status/cleanstation/vs/0",
"rep": {
"x.com.samsung.da.status": "Ready",
"x.com.samsung.da.uvcTotalOperationTime": "90",
"x.com.samsung.da.uvcOperationTime": "90",
"x.com.samsung.da.uvcFinishedTime": "2026-07-27T10:26Z",
"x.com.samsung.da.emittedTime": "2026-07-26T12:26Z",
"x.com.samsung.da.uvcIntensive": "Off",
"x.com.samsung.da.stickStatus": "On"
}
},
{
"href": "/component/station/dustbagusage/vs/0",
"rep": {
"x.com.samsung.da.dustbagUsage": "506",
"x.com.samsung.da.dustbagPrevUsage": "0"
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"flashingProgress": ""
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/connectionconfig/vs/0",
"rep": {
"autoReconnectionMinVersion": "1.0",
"autoReconnection": "true",
"autoReconnectionProtocolType": [
"helper_hotspot",
"ble_ocf"
],
"supportedWiFiAuthType": [
"OPEN",
"WEP",
"WPA-PSK",
"WPA2-PSK",
"SAE"
],
"supportedWiFiCryptoType": [
"TKIP",
"AES",
"WEP-64",
"WEP-128"
],
"supportedWiFiFreq": [
"2.4G"
]
}
}
]
}
+16
View File
@@ -343,6 +343,22 @@ class TestForDeviceByModel:
assert reg is not None
assert reg.name == 'oven'
def test_vacuum_station_via_vskr_token(self):
"""Issue #131: a stick-vacuum clean/auto-empty station
(A-VSKR-TP1-22-VS9500AL) reports no oneUiVersion and no
washer/dryer/dishwasher consumer prefix; falls back to the
'-VSKR-' token in modelNum onto a new vacuum_station registry (its
dump has no vacuum-body state at all, only station-specific
dustbag/dustbin/UV-sanitize resources -- see
capabilities/vacuum_station.py's module docstring)."""
from custom_components.localthings.registry.by_type import for_device_by_model
reg = for_device_by_model(
'A-VSKR-TP1-22-VS9500AL|50023541|80030100001611000800000000000000',
'A-VSKR-TP1-22-VS9500AL',
)
assert reg is not None
assert reg.name == 'vacuum_station'
def test_cooktop_via_legacy_model_description(self):
"""Older cooktops identify themselves as ARTIK051_GLOBAL_COOKTOP."""
from custom_components.localthings.registry.by_type import for_device_by_model
+20
View File
@@ -609,6 +609,26 @@ def test_registry_reproduces_golden_state_keys_for_air_purifier_tp1x_da_ac_air()
)
def test_registry_reproduces_golden_state_keys_for_vacuum_station():
"""A-VSKR-TP1-22-VS9500AL stick-vacuum clean/auto-empty station (issue
#131) -- reports no oneUiVersion; resolved via the new '-VSKR-'
modelNum fallback onto a new vacuum_station registry (see
capabilities/vacuum_station.py's module docstring for why this needed
a new device type rather than reusing an existing one: the dump has no
vacuum-body state at all, only station-specific dustbag/dustbin/
UV-sanitize resources that share no hrefs with anything else already
modeled). Binds with zero unbound hrefs."""
from tests.conftest import _load_device
resources = _load_device('vacuum_station')
golden = json.loads((GOLDEN / 'vacuum_station.json').read_text())
state_keys = _new_state_keys('vacuum_station', resources)
assert set(state_keys) == set(golden['state_keys']), (
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 = {