diff --git a/README.md b/README.md index 3ac35d0..f98a9e3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Your state stays on your LAN: HA talks to the appliance over a direct DTLS sessi | Washer | `by_type/washer.py` | | Water purifier | `by_type/water_purifier.py` | | Vacuum clean/auto-empty station | `by_type/vacuum_station.py` | +| Air dresser | `by_type/air_dresser.py` | Each registry composes shared and family-specific `Capability` objects from `registry/capabilities/`; those modules document the individual resources/entities in more depth than a README table can stay current with. diff --git a/custom_components/localthings/registry/by_type/__init__.py b/custom_components/localthings/registry/by_type/__init__.py index 1a9b9aa..0bed782 100644 --- a/custom_components/localthings/registry/by_type/__init__.py +++ b/custom_components/localthings/registry/by_type/__init__.py @@ -3,9 +3,9 @@ from typing import Optional from ._base import DeviceRegistry from . import ( - air_purifier, airconditioner, cooktop, dehumidifier, dishwasher, dryer, - induction_cooktop, microwave, oven, range as _range, range_hood, - refrigerator, vacuum_station, washer, water_purifier, + air_dresser, air_purifier, airconditioner, cooktop, dehumidifier, + dishwasher, dryer, induction_cooktop, microwave, oven, range as _range, + range_hood, refrigerator, vacuum_station, washer, water_purifier, ) __all__ = [ @@ -15,6 +15,7 @@ __all__ = [ _REGISTRY_BY_KEY: dict[str, DeviceRegistry] = { + 'air_dresser': air_dresser.REGISTRY, 'air_purifier': air_purifier.REGISTRY, 'airpurifier': air_purifier.REGISTRY, 'airconditioner': airconditioner.REGISTRY, @@ -270,6 +271,14 @@ def for_device_by_model(model_num: str, description: str) -> Optional[DeviceRegi # station state) shares no hrefs with anything else already modeled. if key is None and '-VSKR-' in (model_num or '').upper(): key = 'vacuum_station' + # AirDresser (e.g. DA_DF_A51_20_COMMON, issue #162) -- reports no + # oneUiVersion and carries the '_DF_' (Dresser Function) board-family + # token. Every resource it exposes (course select, wrinkle-prevent + # setting, diagnosis) is already handled by the shared laundry + # machinery; it needs its own device type only because none of the + # washer/dryer/dishwasher consumer-model prefixes below match it. + if key is None and '_DF_' in (model_num or ''): + key = 'air_dresser' # 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/air_dresser.py b/custom_components/localthings/registry/by_type/air_dresser.py new file mode 100644 index 0000000..7c54bb0 --- /dev/null +++ b/custom_components/localthings/registry/by_type/air_dresser.py @@ -0,0 +1,23 @@ +"""AirDresser device registry (issue #162). + +Reuses washer/dryer/dishwasher's shared laundry surface -- job-beginning +status, operational state, and diagnosis. air_dresser.py holds the two +pieces specific to this device type: a minimal wrinkle-prevent-only +/washer/vs/0 capability, and the course select's own translation key. +""" +from ..capabilities import air_dresser, common, dishwasher, ignored, laundry, operational +from ._base import DeviceRegistry, _build + +REGISTRY = DeviceRegistry( + name='air_dresser', + capabilities=_build([ + *ignored.IGNORED, + *common.UNIVERSAL, + *common.POWER, + air_dresser.AIR_DRESSER_SETTINGS, + air_dresser.AIR_DRESSER_COURSE, + laundry.JOB_BEGINNING_STATUS, + operational.OPERATIONAL_STATE, + dishwasher.DIAGNOSIS, + ]), +) diff --git a/custom_components/localthings/registry/capabilities/air_dresser.py b/custom_components/localthings/registry/capabilities/air_dresser.py new file mode 100644 index 0000000..4b4a376 --- /dev/null +++ b/custom_components/localthings/registry/capabilities/air_dresser.py @@ -0,0 +1,56 @@ +"""Capabilities specific to the AirDresser family (Samsung DA_DF_A51-class, +issue #162). + +This board reports no oneUiVersion and no /information/vs/0 token any +existing family routes on, so it gets its own device type -- but most of +the resources it exposes are already handled by the shared laundry +machinery: + + /washer/vs/0 -> AIR_DRESSER_SETTINGS (wrinkle_prevent only -- a + dedicated capability rather than reusing + dryer.DRYER_SETTINGS wholesale: this board's dump + never populates dryLevel/dryTime/dryerType at all, + and those are permanently-empty-not-just-absent + dryer-only fields on an AirDresser, not merely unset + ones, so binding them here would ship three sensors + that can never read anything on this device type) + /course/vs/0 -> AIR_DRESSER_COURSE (cycle select). This board has no + /wm/editcourse/vs/0 at all, so cycle_options() falls + through entirely to its supportedOptions fallback -- + confirmed against the issue #162 dump: header nibble + '0' + 10 self-indexed one-byte records + (01/02/04/03/05/1A/1B/1C/07/08), all distinct, current + selection 'Course_01' among them. Course names aren't + identified yet (no code->name mapping was reported), + so they render as their raw codes until named in + translations, same as dryer.py's unidentified codes. + /diagnosis/vs/0 -> reuses dishwasher.DIAGNOSIS +""" +from ..capability import Capability +from ..entities import SwitchDesc +from .laundry import cycle_select + + +def _wrinkle_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p} + + +AIR_DRESSER_SETTINGS = Capability( + href='/washer/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='wrinkle_prevent', field='x.com.samsung.da.wrinklePrevent', + icon='mdi:iron', + value_fn=lambda v: v == 'On', + write_fn=_wrinkle_write), + ), +) + +AIR_DRESSER_COURSE = Capability( + href='/course/vs/0', + entities=( + cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew'), + ), +) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 6b9fe90..a9bc478 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -193,6 +193,9 @@ "ai_energy_level": { "name": "AI Energy Mode level" }, + "air_dresser_cycle": { + "name": "Cycle" + }, "beverage_zone_mode": { "name": "Beverage zone mode", "state": { diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 87e3e18..2fb2366 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -193,6 +193,9 @@ "ai_energy_level": { "name": "Niveau AI Energy Mode" }, + "air_dresser_cycle": { + "name": "Cyclus" + }, "beverage_zone_mode": { "name": "Modus drankenzone", "state": { diff --git a/tests/fixtures/air_dresser_device.json b/tests/fixtures/air_dresser_device.json new file mode 100644 index 0000000..23847c2 --- /dev/null +++ b/tests/fixtures/air_dresser_device.json @@ -0,0 +1,209 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "VN" + } + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_015E", + "UpdateAllow_NotAllowed", + "Course_01", + "SteamPush_None", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "WashingTimes_58", + "DrumCleanProposal_30", + "SpecialFunction_4", + "AvailableDelayTime_0", + "ProgressTimeSet_AA01E0AC00B4B20690", + "WrinklePreventRunning_Off", + "SendToDevice_Off", + "GMT_12", + "Alarm_Off", + "SilentSet_0F0FF0F0F0F0F0F0F0F00F0F0F0FF0F0F00FF0", + "Silent_Off", + "WrinklePreventSet_0F0F0F0F0F0F0F0FF0F00F0F0F0F0F0F0F0FF0", + "DelayEndSet_0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0F0F0F0F", + "OneTimeCourse_761F00200000000000000000000000", + "DownloadCourseList_090A0B12100C140D0F", + "EnergyLevelSet_0502020104030204050405020303010302010305", + "MostUsed_01", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_2024-01-01T00:00:00", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "001020403051A1B1C0708" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": {} + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.instantaneousPower": "-500", + "x.com.samsung.da.cumulativePower": "78200", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1785272400", + "x.com.samsung.da.cumulativeDateUTC": "1785240000" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000", + "x.com.samsung.da.description": "DA_DF_A51_20_COMMON_DF8600T/DC92-02656A_0018", + "x.com.samsung.da.serialNum": "REDACTED", + "x.com.samsung.da.otnDUID": "REDACTED", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DF_A51_20_COMMON|20261441|3801010200131100020100FF00000000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02672A230710(E431)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "DA_DF_A51_20_COMMON", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "20060507,20072702", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "REDACTED", + "machineStates": "REDACTED", + "jobStates": [ + "None", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ], + "currentJobState": "None", + "remainingTime": "00:39:00", + "progressPercentage": "1" + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Ready", + "x.com.samsung.da.remainingTime": "00:39:00", + "x.com.samsung.da.progressPercentage": "1", + "x.com.samsung.da.progress": "None", + "x.com.samsung.da.delayEndTime": "00:00:00", + "x.com.samsung.da.supportedProgress": [ + "None", + "Steaming", + "Airwashing", + "Drying", + "Finish" + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "10", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": {} + } + ] +} diff --git a/tests/fixtures/golden/air_dresser.json b/tests/fixtures/golden/air_dresser.json new file mode 100644 index 0000000..fdc4b43 --- /dev/null +++ b/tests/fixtures/golden/air_dresser.json @@ -0,0 +1,20 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_kwh", + "finish_time", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "wrinkle_prevent" + ] +} diff --git a/tests/test_air_dresser_capabilities.py b/tests/test_air_dresser_capabilities.py new file mode 100644 index 0000000..3756a4e --- /dev/null +++ b/tests/test_air_dresser_capabilities.py @@ -0,0 +1,70 @@ +"""Tests for the AirDresser device type (DA_DF_A51_20_COMMON, issue #162).""" +from custom_components.localthings.registry.adapter import flatten +from custom_components.localthings.registry.by_type import air_dresser, for_device_by_model +from custom_components.localthings.registry.discovery import discover + +from tests.conftest import _load_device + + +def _air_dresser(): + resources = _load_device('air_dresser') + info = resources['/information/vs/0'] + reg = for_device_by_model( + info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description']) + return reg, resources + + +def _state(): + reg, resources = _air_dresser() + bound = discover(resources, reg.capabilities, reg.pattern_capabilities) + return flatten(bound, resources) + + +def test_resolves_to_air_dresser_registry(): + reg, _ = _air_dresser() + assert reg is not None and reg.name == 'air_dresser' + + +def test_no_unbound_hrefs(): + reg, resources = _air_dresser() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_course_select_present_and_reads_current_selection(): + """This was the actual reported gap: /course/vs/0 was previously + entirely unbound, so no cycle/mode select existed at all.""" + state = _state() + assert state['cycle'] == '01' + + +def test_course_options_derived_from_supported_options_fallback(): + """No /wm/editcourse/vs/0 on this board at all, so the option list must + come from laundry.cycle_options' supportedOptions decode rather than + editCourseList -- confirmed distinct codes, current selection included.""" + from custom_components.localthings.registry.capabilities.laundry import cycle_options + _, resources = _air_dresser() + codes = cycle_options(resources) + assert codes == ['01', '02', '04', '03', '05', '1A', '1B', '1C', '07', '08'] + + +def test_wrinkle_prevent_present_dry_level_fields_absent(): + """AIR_DRESSER_SETTINGS only binds wrinkle_prevent -- dryLevel/dryTime/ + dryerType are dryer-only fields this device never reports, so they + should not appear as always-empty entities.""" + state = _state() + assert state['wrinkle_prevent'] is False + for key in ('dry_level', 'dry_time', 'dryer_type'): + assert key not in state, key + + +def test_expected_entities_present(): + state = _state() + for key in ( + 'power_switch', 'child_lock', 'remote_control', 'cycle', + 'machine_state', 'progress', 'progress_percentage', 'finish_time', + 'completion_minutes', 'delay_start_hours', 'diagnosis_status', + 'job_beginning_status', 'wrinkle_prevent', 'energy_kwh', + ): + assert key in state, key diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index 089e036..f96f7f7 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -730,6 +730,23 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_tp1x_rac_01001 ) +def test_registry_reproduces_golden_state_keys_for_air_dresser(): + """DA_DF_A51_20_COMMON AirDresser (model DF8600T, issue #162) -- reports + no oneUiVersion; resolved via the '_DF_' modelNum token fallback. Has no + /wm/editcourse/vs/0 at all, so the course select's option list comes + entirely from laundry.cycle_options' supportedOptions fallback. Binds + cleanly against the new air_dresser registry with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_dresser') + golden = json.loads((GOLDEN / 'air_dresser.json').read_text()) + state_keys = _new_state_keys('air_dresser', 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 = {