First full dryer dump (issue #14, DV90BB5245AES1) surfaced 5 unbound hrefs and an "incomplete capability coverage" repair. Handle them and, while here, make the washer/dryer/dishwasher families consistent instead of each carrying a bespoke variant of the same controls. Dryer coverage: - /power/0, /kidslock/0, /remotectrl/0: bind via the OCF-native + vendor fallback pairs (prefer the standard OCF resource, fall back to -vs). - /buzzersound/vs/0: new Buzzer sound select. - /course/vs/0: cycle select shared with washer/dishwasher; ignore the /st/dryercourse/vs/0 re-encoding (mirror of /st/washercourse/vs/0). Consistency / de-duplication: - Move generic OCF controls (power/kids-lock/remote-control fallback pairs, energy meter) into common.py; every registry uses them. - Move shared laundry controls (buzzer, job-beginning-status, and the /course/vs/0 cycle-select machinery) into laundry.py; washer and dishwasher stop hand-rolling their own copies. - Energy meter is now sentinel-aware everywhere: the dead '-500' instantaneousPower reading no longer shows a misleading 0 W (fixes it on dryers and dishwashers, matching the earlier washer fix). - Job-beginning-status reads x.com.samsung.da.currentStatus, the field every dump actually carries; the dryer sensor was previously blank. Adds a scrubbed dryer fixture, golden, and capability tests, plus an .claude/skills/adding-device-support skill capturing the dump-reading, OCF-vs-vendor, entity-taxonomy, and coverage workflow. Bumps to 0.6.0.
96 lines
4.3 KiB
Python
96 lines
4.3 KiB
Python
"""Tests for the shared laundry capabilities (washer/dryer/dishwasher)."""
|
|
from custom_components.localthings.registry.capabilities import laundry
|
|
|
|
|
|
class TestCourseHelpers:
|
|
def test_parses_edit_course_list(self):
|
|
raw = 'EditCourseList_1C1D211B1E29243328262722202325322F2E30662D8F96'
|
|
assert laundry.parse_edit_course_list(raw) == [
|
|
'1C', '1D', '21', '1B', '1E', '29', '24', '33', '28', '26', '27',
|
|
'22', '20', '23', '25', '32', '2F', '2E', '30', '66', '2D', '8F', '96',
|
|
]
|
|
|
|
def test_parse_edit_course_list_handles_missing_or_malformed(self):
|
|
assert laundry.parse_edit_course_list(None) == []
|
|
assert laundry.parse_edit_course_list('') == []
|
|
assert laundry.parse_edit_course_list('no underscore') == []
|
|
|
|
def test_cycle_options_reads_live_edit_course_list(self):
|
|
"""The device's own course list -- including a code ('65') never seen
|
|
on the primary dump -- is used as-is; there is no hardcoded table."""
|
|
resources = {
|
|
'/wm/editcourse/vs/0': {
|
|
'x.com.samsung.da.editCourseList': 'EditCourseList_651C',
|
|
},
|
|
}
|
|
assert laundry.cycle_options(resources) == ['65', '1C']
|
|
|
|
def test_cycle_options_empty_when_resource_absent_or_empty(self):
|
|
assert laundry.cycle_options({}) == []
|
|
assert laundry.cycle_options({'/wm/editcourse/vs/0': {}}) == []
|
|
|
|
def test_option_value(self):
|
|
opts = ['DeviceType_0167', 'Course_1C', 'GMT_04']
|
|
assert laundry.option_value(opts, 'Course') == '1C'
|
|
assert laundry.option_value(opts, 'Missing') is None
|
|
|
|
|
|
class TestCycleSelect:
|
|
def test_builds_labelled_cycle_select(self):
|
|
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='mdi:tumble-dryer')
|
|
assert desc.key == 'cycle'
|
|
assert desc.translation_key == 'dryer_cycle'
|
|
assert desc.icon == 'mdi:tumble-dryer'
|
|
assert desc.options is laundry.cycle_options
|
|
|
|
def test_reads_raw_course_code_from_options(self):
|
|
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
|
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']}
|
|
assert desc.rep_fn(rep) == '16'
|
|
assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None
|
|
|
|
def test_exists_only_when_edit_course_list_is_live(self):
|
|
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
|
assert desc.exists_fn({}, {}) is False
|
|
assert desc.exists_fn({}, {'/wm/editcourse/vs/0': {}}) is False
|
|
live = {'/wm/editcourse/vs/0': {'x.com.samsung.da.editCourseList': 'EditCourseList_16'}}
|
|
assert desc.exists_fn({}, live) is True
|
|
|
|
def test_cycle_write_rmw_on_options(self):
|
|
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
|
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04']}
|
|
path, body = desc.write_fn('1D', rep)
|
|
assert path == ['course', 'vs', '0']
|
|
assert body == {
|
|
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1D', 'GMT_04'],
|
|
}
|
|
|
|
def test_cycle_write_noop_without_options(self):
|
|
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
|
assert desc.write_fn('1D', {}) is None
|
|
|
|
|
|
class TestBuzzerSound:
|
|
def test_href(self):
|
|
assert laundry.BUZZER_SOUND.href == '/buzzersound/vs/0'
|
|
|
|
def test_buzzer_sound_write(self):
|
|
desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'buzzer_sound')
|
|
assert desc.options_field == 'supportedBuzzerSound'
|
|
path, body = desc.write_fn('On', {})
|
|
assert path == ['buzzersound', 'vs', '0']
|
|
assert body == {'setBuzzerSound': 'On'}
|
|
|
|
def test_finish_sound_exists_only_when_supported(self):
|
|
desc = next(e for e in laundry.BUZZER_SOUND.entities if e.key == 'finish_sound')
|
|
assert desc.exists_fn({'setBuzzerSound': 'On'}, {}) is False
|
|
assert desc.exists_fn({'supportedFinishSound': ['FinishSound_1']}, {}) is True
|
|
|
|
|
|
class TestJobBeginningStatus:
|
|
def test_href_and_field(self):
|
|
assert laundry.JOB_BEGINNING_STATUS.href == '/wm/jobbeginingstatus/vs/0'
|
|
desc = laundry.JOB_BEGINNING_STATUS.entities[0]
|
|
assert desc.field == 'x.com.samsung.da.currentStatus'
|
|
assert desc.entity_category == 'diagnostic'
|