diff --git a/custom_components/localthings/registry/by_type/air_dresser.py b/custom_components/localthings/registry/by_type/air_dresser.py index 7c54bb0..db3d526 100644 --- a/custom_components/localthings/registry/by_type/air_dresser.py +++ b/custom_components/localthings/registry/by_type/air_dresser.py @@ -16,6 +16,7 @@ REGISTRY = DeviceRegistry( *common.POWER, air_dresser.AIR_DRESSER_SETTINGS, air_dresser.AIR_DRESSER_COURSE, + air_dresser.AIR_DRESSER_SANITIZE, 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 index 4b4a376..3fcfb11 100644 --- a/custom_components/localthings/registry/capabilities/air_dresser.py +++ b/custom_components/localthings/registry/capabilities/air_dresser.py @@ -1,30 +1,36 @@ -"""Capabilities specific to the AirDresser family (Samsung DA_DF_A51-class, -issue #162). +"""Capabilities specific to the AirDresser family (Samsung DA_DF-class, +issues #162/#157). -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 +This board family 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.DRYER_SETTINGS wholesale: this board 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. + /course/vs/0 -> AIR_DRESSER_COURSE (cycle select), table id from + /st/airdressercourse/vs/0 (issue #157's + DA_DF_TP2_20_COMMON reports "Table_00"; issue #162's + DA_DF_A51_20_COMMON has no table resource at all and + falls back to the name-only 'cycle' key, same as an + unrecognized table on washer/dryer). Options come + from laundry.cycle_options -- #157's board populates + /wm/editcourse/vs/0's editCourseList directly; #162's + has no editcourse resource at all and falls through + to cycle_options' supportedOptions decode instead. + Course names aren't identified yet for either table + (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 + /airdresseroption/sanitize/vs/0 -> AIR_DRESSER_SANITIZE (issue #157 only; + #162's board doesn't report this resource at all) """ from ..capability import Capability from ..entities import SwitchDesc @@ -37,6 +43,12 @@ def _wrinkle_write(p, rep, href=None): return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p} +def _sanitize_write(p, rep, href=None): + if p not in ('On', 'Off'): + return None + return ['airdresseroption', 'sanitize', 'vs', '0'], {'x.com.samsung.da.sanitize': p} + + AIR_DRESSER_SETTINGS = Capability( href='/washer/vs/0', poll_tier='warm', @@ -51,6 +63,18 @@ AIR_DRESSER_SETTINGS = Capability( AIR_DRESSER_COURSE = Capability( href='/course/vs/0', entities=( - cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew'), + cycle_select(translation_key='air_dresser_cycle', icon='mdi:tshirt-crew', + table_href='/st/airdressercourse/vs/0'), + ), +) + +AIR_DRESSER_SANITIZE = Capability( + href='/airdresseroption/sanitize/vs/0', + poll_tier='warm', + entities=( + SwitchDesc(key='sanitize', field='x.com.samsung.da.sanitize', + icon='mdi:weather-sunny', + value_fn=lambda v: v == 'On', + write_fn=_sanitize_write), ), ) diff --git a/custom_components/localthings/registry/capabilities/ignored.py b/custom_components/localthings/registry/capabilities/ignored.py index 3171475..ff064de 100644 --- a/custom_components/localthings/registry/capabilities/ignored.py +++ b/custom_components/localthings/registry/capabilities/ignored.py @@ -107,6 +107,11 @@ IGNORED: list[Capability] = [ # exposed by dryer.DRYER_COURSE at /course/vs/0 # (x.com.samsung.da.st.dryerMode is "Table_03_Course_"). Capability(href='/st/dryercourse/vs/0'), + # AirDresser counterpart of the above (issue #157): read only for its + # courseTable id (air_dresser.AIR_DRESSER_COURSE's table_href), no + # entity of its own -- same "no entity, just the table id" role as + # /st/washercourse/vs/0 and /st/dryercourse/vs/0. + Capability(href='/st/airdressercourse/vs/0'), # Empty on every washer dump seen so far. Capability(href='/wm/welcomemsg/vs/0'), # User-saved custom course slots (F1-FA). No controllable/observable diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index a9bc478..6b9fe90 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -193,9 +193,6 @@ "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 2fb2366..87e3e18 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -193,9 +193,6 @@ "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_tp2_20_device.json b/tests/fixtures/air_dresser_tp2_20_device.json new file mode 100644 index 0000000..8e96d3c --- /dev/null +++ b/tests/fixtures/air_dresser_tp2_20_device.json @@ -0,0 +1,282 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/airdresseroption/sanitize/vs/0", + "rep": { + "x.com.samsung.da.sanitize": "Off", + "x.com.samsung.da.supportedSanitize": [ + "On", + "Off" + ] + } + }, + { + "href": "/alarms/vs/0", + "rep": {} + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "3171000000", + "x.com.samsung.da.countryCode": "KR" + } + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_015E", + "UpdateAllow_NotAllowed", + "Course_22", + "AiOption_On", + "SteamPush_None", + "SeamlessControl_Disable", + "KidsLockBypass_On", + "FilterUsage_384019D5", + "WashingTimes_6", + "DrumCleanProposal_30", + "ProgressTimeSet_AA01A4AC00F0B20690", + "WrinklePreventRunning_Off", + "SendToDevice_Off", + "WeatherPush_Off", + "GMT_12", + "Alarm_Off", + "WrinklePreventSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF00F0F0F0F0F0F0F0F0F0F0FF0F0", + "DelayEndSet_0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0F0FF0F0F00F0F0FF0F0F0F0F00F0F", + "EnergyLevelSet_0502010102010202030303010401040505050503030502040503030303030405", + "UsagesDB_ok", + "DrumCleanLog_2023-04-02T08:08:55|2023-10-02T03:38:19|2024-05-26T03:25:41|2024-10-01T01:07:20|2026-06-16T02:23:29" + ], + "x.com.samsung.da.supportedOptions": [ + "12261062361020E61020961021261020C61021E61020B61021061020A61061461021361061661022462062562062F62062062040F62042761023061021561021A61021B61021C61022A61022B61022C61022D61022E6102076102086102" + ] + } + }, + { + "href": "/cycleinterface/vs/0", + "rep": { + "x.com.samsung.da.cycleInterfaceEnabled": "Off" + } + }, + { + "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.instantaneousPower": "-500", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePower": "63600", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.cumulativeDate": "1784404800", + "x.com.samsung.da.cumulativeDateUTC": "1784372400" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON_DF9500A/DC92-02888A_0002", + "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": "A00", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DF_TP2_20_COMMON|20286141|380101010015110F0201000100010000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02673A250416(F822)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "21020115,23030652", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Firmware_2_DB", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "19111852,FFFFFFFF" + } + ] + } + }, + { + "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": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "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": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": false + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "false" + } + }, + { + "href": "/st/airdressercourse/vs/0", + "rep": { + "x.com.samsung.da.st.airdresserMode": "Table_00_Course_22", + "x.com.samsung.da.st.courseTable": "Table_00" + } + }, + { + "href": "/washer/vs/0", + "rep": { + "x.com.samsung.da.wrinklePrevent": "Off" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": { + "x.com.samsung.da.editCourseList": "EditCourseList_22230C10252F0F0E09121E0B0A14131624202730151A1B1C2A2B2C2D2E0708", + "x.com.samsung.da.fixedCourseList": "FixedCourseList_22270F" + } + }, + { + "href": "/wm/jobbeginingstatus/vs/0", + "rep": {} + }, + { + "href": "/wm/personalcourse/vs/0", + "rep": { + "x.com.samsung.da.courses": [ + "F1_00", + "F2_00", + "F3_00", + "F4_00", + "F5_00", + "F6_00", + "F7_00", + "F8_00", + "F9_00", + "FA_00" + ], + "x.com.samsung.da.maxCourseNum": "10" + } + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", + "x.com.samsung.da.aiCourse": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "true" + } + }, + { + "href": "/wm/welcomemsg/vs/0", + "rep": {} + } + ] +} diff --git a/tests/fixtures/golden/air_dresser_tp2_20.json b/tests/fixtures/golden/air_dresser_tp2_20.json new file mode 100644 index 0000000..a935010 --- /dev/null +++ b/tests/fixtures/golden/air_dresser_tp2_20.json @@ -0,0 +1,22 @@ +{ + "state_keys": [ + "alarm_code", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_kwh", + "finish_time", + "firmware_update", + "job_beginning_status", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "sanitize", + "wrinkle_prevent" + ] +} diff --git a/tests/test_air_dresser_tp2_20_capabilities.py b/tests/test_air_dresser_tp2_20_capabilities.py new file mode 100644 index 0000000..2d3ef00 --- /dev/null +++ b/tests/test_air_dresser_tp2_20_capabilities.py @@ -0,0 +1,82 @@ +"""Tests for the DA_DF_TP2_20_COMMON AirDresser (model DF9500A, issue #157). + +A different board generation than issue #162's DA_DF_A51_20_COMMON, routed +into the same air_dresser registry (both carry the '_DF_' modelNum token). +Exercises the two things this board does differently: a populated +/wm/editcourse/vs/0 (so cycle_options() never needs the supportedOptions +fallback #162 relies on) and the new /airdresseroption/sanitize/vs/0 +capability #162's dump doesn't report at all. +""" +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_tp2_20') + 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(): + """Confirms /st/airdressercourse/vs/0 and /airdresseroption/sanitize/vs/0 + -- the two hrefs this board reports that #162's dump doesn't -- are + covered (ignored.py and AIR_DRESSER_SANITIZE respectively).""" + reg, resources = _air_dresser() + unbound = [] + discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append) + assert unbound == [] + + +def test_course_options_come_from_edit_course_list_not_the_fallback(): + """Unlike issue #162's board, this one populates /wm/editcourse/vs/0 + directly, so cycle_options() should never reach the + supportedOptions-decode fallback.""" + from custom_components.localthings.registry.capabilities.laundry import cycle_options + _, resources = _air_dresser() + codes = cycle_options(resources) + assert codes[:3] == ['22', '23', '0C'] + assert codes == cycle_options({'/wm/editcourse/vs/0': resources['/wm/editcourse/vs/0']}) + + +def test_course_select_reads_current_selection(): + state = _state() + assert state['cycle'] == '22' + + +def test_course_translation_key_falls_back_to_cycle_for_unidentified_table(): + """Table_00's course codes aren't identified yet, so the select's + translation_key resolves to the generic 'cycle' catalog entry rather + than a table-specific one that doesn't exist.""" + _, resources = _air_dresser() + desc = air_dresser.REGISTRY.capabilities['/course/vs/0'][0].entities[0] + assert desc.translation_key(resources) == 'cycle' + + +def test_sanitize_present_and_toggles(): + state = _state() + assert state['sanitize'] is False + + desc = next( + e for e in air_dresser.REGISTRY.capabilities['/airdresseroption/sanitize/vs/0'][0].entities + if e.key == 'sanitize' + ) + path, body = desc.write_fn('On', {}) + assert path == ['airdresseroption', 'sanitize', 'vs', '0'] + assert body == {'x.com.samsung.da.sanitize': 'On'} + assert desc.write_fn('Sparkle', {}) is None diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index f96f7f7..4b584b4 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -747,6 +747,27 @@ def test_registry_reproduces_golden_state_keys_for_air_dresser(): ) +def test_registry_reproduces_golden_state_keys_for_air_dresser_tp2_20(): + """DA_DF_TP2_20_COMMON AirDresser (model DF9500A, issue #157) -- a + different board generation than issue #162's DA_DF_A51_20_COMMON, also + routed via the '_DF_' modelNum fallback into the same air_dresser + registry. Unlike #162's board, this one populates /wm/editcourse/vs/0's + editCourseList directly (no supportedOptions fallback needed) and + reports two AirDresser-specific resources #162 doesn't have: + /st/airdressercourse/vs/0 (course table id, ignored.py) and + /airdresseroption/sanitize/vs/0 (air_dresser.AIR_DRESSER_SANITIZE). + Binds cleanly with zero unbound hrefs.""" + from tests.conftest import _load_device + resources = _load_device('air_dresser_tp2_20') + golden = json.loads((GOLDEN / 'air_dresser_tp2_20.json').read_text()) + state_keys = _new_state_keys('air_dresser_tp2_20', 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 = {