Fix washer course enum display

This commit is contained in:
galaxysj committed 2026-08-02 10:19:45 +09:00
1 parent 93744e80f4
commit f8849df8a6
12 files changed
+315 -19

No files matched your search

+48
View File
@@ -35,6 +35,54 @@ class TestCourseHelpers:
assert laundry.option_value(opts, 'Course') == '1C'
assert laundry.option_value(opts, 'Missing') is None
def test_decodes_device_provided_personal_course_names(self):
"""Real /wm/personalcourse/vs/0 entries from the reported washer.
The first TLV field contains a byte-counted UTF-8 name. It is the only
course-name metadata in the diagnostics and is safe to display without
assigning an inferred meaning to any standard course code.
"""
resources = {
'/wm/personalcourse/vs/0': {
'x.com.samsung.da.courses': [
'F1_0106EC868DEC98B7021EED8CACED8BB020EB93B1',
'F2_00',
'F3_0109EC9A94EAB8B0EBB3B40220ECA084EC9AA9',
],
},
}
first_name = bytes.fromhex('EC868DEC98B7').decode('utf-8')
second_name = bytes.fromhex('EC9A94EAB8B0EBB3B4').decode('utf-8')
assert laundry.personal_course_labels(resources) == {
'F1': first_name,
'F3': second_name,
}
def test_personal_course_names_reject_malformed_payloads(self):
resources = {
'/wm/personalcourse/vs/0': {
'x.com.samsung.da.courses': [
'F1_not-hex',
'F2_0106AA',
'F3_020141',
None,
],
},
}
assert laundry.personal_course_labels(resources) == {}
def test_washer_cycle_fallback_uses_labels_then_safe_unknown_text(self):
resources = {
'/wm/personalcourse/vs/0': {
'x.com.samsung.da.courses': ['F1_0106EC868DEC98B7'],
},
}
expected = bytes.fromhex('EC868DEC98B7').decode('utf-8')
assert laundry.washer_cycle_fallback('F1', resources) == expected
assert laundry.washer_cycle_fallback('69', resources) == 'Unknown (0x69)'
assert laundry.washer_cycle_fallback('6f', resources) == 'Unknown (0x6F)'
assert laundry.washer_cycle_fallback('Normal', resources) is None
class TestCourseCodesFromSupportedOptions:
"""cycle_options()'s fallback for boards that populate
+6
View File
@@ -45,3 +45,9 @@ def test_known_camel_case_state_uses_snake_case_translation_key():
def test_display_passes_through_non_string_values():
assert _display(None, _UNTRANSLATED) is None
def test_display_uses_fallback_when_translation_has_no_state_table():
assert _display('69', 'cycle', lambda value: f'Unknown (0x{value})') == (
'Unknown (0x69)'
)
+78
View File
@@ -2,6 +2,9 @@
(custom_components/localthings/select.py) -- the static tuple, options_field,
and callable forms of SelectDesc.options.
"""
from custom_components.localthings.registry.capabilities.laundry import (
cycle_select, washer_cycle_fallback,
)
from custom_components.localthings.registry.capability import Capability
from custom_components.localthings.registry.discovery import BoundEntity
from custom_components.localthings.registry.entities import SelectDesc
@@ -106,3 +109,78 @@ async def test_unknown_vendor_option_round_trips_to_exact_raw_value():
assert entity.options[-1] == 'Future Vendor Mode'
await entity.async_select_option('Future Vendor Mode')
assert coordinator.writes == ['FutureVendorMode']
async def test_washer_diagnostic_cycle_values_share_one_display_and_write_path():
"""Regression for Course_69/EditCourseList_696F... from real hardware."""
class _WritableCoordinator(_FakeCoordinator):
data = {'cycle': '69'}
def __init__(self, last_resources):
super().__init__(last_resources)
self.writes = []
async def async_send_command(self, bound, value):
self.writes.append(value)
desc = cycle_select(
translation_key='washer_cycle',
icon='mdi:washing-machine',
table_href='/st/washercourse/vs/0',
display_fn=washer_cycle_fallback,
)
capability = Capability(href='/course/vs/0', entities=(desc,))
bound = BoundEntity(href='/course/vs/0', capability=capability, desc=desc)
resources = {
'/course/vs/0': {'x.com.samsung.da.options': ['Course_69']},
'/st/washercourse/vs/0': {
'x.com.samsung.da.st.courseTable': 'Table_02',
},
'/wm/editcourse/vs/0': {
'x.com.samsung.da.editCourseList': (
'EditCourseList_696F757801719688706D6A7376726C6E6B777479F1F3'
),
},
'/wm/personalcourse/vs/0': {
'x.com.samsung.da.courses': [
'F1_0106EC868DEC98B7',
'F3_0109EC9A94EAB8B0EBB3B4',
],
},
}
coordinator = _WritableCoordinator(resources)
entity = LocalThingsSelect(coordinator, bound)
first_name = bytes.fromhex('EC868DEC98B7').decode('utf-8')
second_name = bytes.fromhex('EC9A94EAB8B0EBB3B4').decode('utf-8')
# Known catalog states stay as HA translation keys; the frontend renders
# this confirmed Table_02 mapping as "AI Wash".
assert entity.current_option == '69'
assert entity.options == [
'69',
'6f',
'75',
'78',
'01',
'71',
'96',
'88',
'70',
'6d',
'6a',
'73',
'76',
'72',
'6c',
'6e',
'6b',
'77',
'74',
'79',
first_name,
second_name,
]
await entity.async_select_option('6f')
await entity.async_select_option(first_name)
assert coordinator.writes == ['6F', 'F1']
+27 -1
View File
@@ -125,6 +125,33 @@ def test_no_catalog_carries_unresolved_core_references():
assert unresolved == [], language
def test_confirmed_korean_table_02_washer_course_names():
states = _load('en')['entity']['select']['washer_cycle_table_02']['state']
assert {code: states[code] for code in (
'69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71',
'72', '73', '74', '75', '76', '77', '78', '79', '88',
)} == {
'69': 'AI Wash',
'6a': 'Wool',
'6b': 'Denim',
'6c': 'Blouses',
'6d': 'Delicates',
'6e': 'Active Wear',
'6f': 'Bedding',
'70': 'Towels',
'71': 'Quick Wash',
'72': 'Shirts',
'73': 'Sanitize',
'74': 'Drum Clean',
'75': 'Outdoor',
'76': 'Baby Care',
'77': 'Cottons',
'78': 'Rinse + Spin',
'79': 'Spin Only',
'88': 'Pet Care',
}
# The hood fan is its device's primary feature: fan.py sets _attr_name = None
# so it presents as the device itself, and never reads a catalog name. Same
# for the ARTIK051 air-purifier's airflow_fan (issue #56) -- ordered speed
@@ -233,4 +260,3 @@ def test_every_kimchi_zone_supportmode_code_has_a_state_label():
if code.lower() not in state_labels:
missing.append((path.name, href, code))
assert missing == []
+11
View File
@@ -82,6 +82,16 @@ class TestWasherCourse:
rep = {'x.com.samsung.da.options': ['DeviceType_0167', 'Course_1C', 'GMT_04']}
assert desc.rep_fn(rep) == '1C'
def test_reported_table_02_course_codes_are_translated(self):
"""The owner confirmed this washer's newer Table_02 course family."""
from custom_components.localthings.catalog import translated_states
confirmed = {
'69', '6a', '6b', '6c', '6d', '6e', '6f', '70', '71',
'72', '73', '74', '75', '76', '77', '78', '79', '88',
}
assert confirmed <= translated_states('select', 'washer_cycle_table_02')
def test_missing_course_option_returns_none(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.rep_fn({'x.com.samsung.da.options': ['GMT_04']}) is None
@@ -89,6 +99,7 @@ class TestWasherCourse:
def test_cycle_desc_uses_cycle_options_callable(self):
desc = next(e for e in washer.WASHER_COURSE.entities if e.key == 'cycle')
assert desc.options is laundry.cycle_options
assert desc.display_fn is laundry.washer_cycle_fallback
def test_exists_only_when_edit_course_list_is_live(self):
"""No hardcoded course table is kept -- the selector only appears