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:
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
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user