From 8b44fd57106be835a6d8e6d4cb8c9c380b70718e Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 27 Jul 2026 14:11:06 +0000 Subject: [PATCH] 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. --- .../localthings/registry/by_type/__init__.py | 10 +- .../registry/by_type/vacuum_station.py | 22 +++ .../registry/capabilities/vacuum_station.py | 123 +++++++++++++ tests/fixtures/golden/vacuum_station.json | 19 ++ tests/fixtures/vacuum_station_device.json | 170 ++++++++++++++++++ tests/test_by_type.py | 16 ++ tests/test_golden_regression.py | 20 +++ 7 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 custom_components/localthings/registry/by_type/vacuum_station.py create mode 100644 custom_components/localthings/registry/capabilities/vacuum_station.py create mode 100644 tests/fixtures/golden/vacuum_station.json create mode 100644 tests/fixtures/vacuum_station_device.json diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 9039566..13107d8 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -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. diff --git a/custom_components/localthings/registry/by_type/vacuum_station.py b/custom_components/localthings/registry/by_type/vacuum_station.py new file mode 100644 index 0000000..80c1f3f --- /dev/null +++ b/custom_components/localthings/registry/by_type/vacuum_station.py @@ -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, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/vacuum_station.py b/custom_components/localthings/registry/capabilities/vacuum_station.py new file mode 100644 index 0000000..d4b5287 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/vacuum_station.py @@ -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), + ), +) diff --git a/tests/fixtures/golden/vacuum_station.json b/tests/fixtures/golden/vacuum_station.json new file mode 100644 index 0000000..ae896c2 --- /dev/null +++ b/tests/fixtures/golden/vacuum_station.json @@ -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" + ] +} \ No newline at end of file diff --git a/tests/fixtures/vacuum_station_device.json b/tests/fixtures/vacuum_station_device.json new file mode 100644 index 0000000..962a252 --- /dev/null +++ b/tests/fixtures/vacuum_station_device.json @@ -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" + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/test_by_type.py b/tests/test_by_type.py index b773098..15ac1e2 100644 --- a/tests/test_by_type.py +++ b/tests/test_by_type.py @@ -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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index d94fc99..96f7c32 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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 = {