diff --git a/custom_components/localthings/fan.py b/custom_components/localthings/fan.py index 9c9d623..68f18fa 100644 --- a/custom_components/localthings/fan.py +++ b/custom_components/localthings/fan.py @@ -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( diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 0bed782..3860fb1 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -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' diff --git a/custom_components/localthings/registry/by_type/air_purifier.py b/custom_components/localthings/registry/by_type/air_purifier.py index 2648aec..cf87fba 100644 --- a/custom_components/localthings/registry/by_type/air_purifier.py +++ b/custom_components/localthings/registry/by_type/air_purifier.py @@ -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, diff --git a/custom_components/localthings/registry/capabilities/air_purifier.py b/custom_components/localthings/registry/capabilities/air_purifier.py index f9ba661..c860084 100644 --- a/custom_components/localthings/registry/capabilities/air_purifier.py +++ b/custom_components/localthings/registry/capabilities/air_purifier.py @@ -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'), ] diff --git a/tests/fixtures/air_purifier_vtww_device.json b/tests/fixtures/air_purifier_vtww_device.json new file mode 100644 index 0000000..e3c7df1 --- /dev/null +++ b/tests/fixtures/air_purifier_vtww_device.json @@ -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" + ] + } + } + ] +} diff --git a/tests/fixtures/golden/air_purifier_vtww.json b/tests/fixtures/golden/air_purifier_vtww.json new file mode 100644 index 0000000..892ba89 --- /dev/null +++ b/tests/fixtures/golden/air_purifier_vtww.json @@ -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" + ] +} diff --git a/tests/test_air_purifier_vtww_fan.py b/tests/test_air_purifier_vtww_fan.py new file mode 100644 index 0000000..81d3351 --- /dev/null +++ b/tests/test_air_purifier_vtww_fan.py @@ -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 diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 4b584b4..5385931 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -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 = {