diff --git a/custom_components/localthings/cloudcourse.py b/custom_components/localthings/cloudcourse.py index 01afffa..35fb0e2 100644 --- a/custom_components/localthings/cloudcourse.py +++ b/custom_components/localthings/cloudcourse.py @@ -20,12 +20,12 @@ and the WA55A7700AV dump in ``tests/fixtures``, whose two-slot 2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one reason nothing here ever synthesizes one. -This is not washer-only, and nothing here assumes an appliance type: the -DW5000C dishwasher in ``tests/fixtures`` advertises four slots on the same -token. It also carries no payload token at all, so a device can name -programs whose payloads have never been observed -- ``advertised_slots`` -answers "which exist", the store answers "which are usable", and the two -are deliberately allowed to disagree. +``CloudExtraCourse_`` does not mean the same thing on every family, so +nothing keys off it directly -- see ``cloud_slots``, which is what the rest +of this module and its callers gate on. Even then, a device can advertise a +slot whose payload has never been observed, so ``cloud_slots`` answers +"which exist" while the store answers "which are usable"; the two are +deliberately allowed to disagree. What this module does and deliberately does not do -------------------------------------------------- @@ -133,9 +133,29 @@ def advertised_slots(rep) -> list[str]: return list(dict.fromkeys(hex_pairs(raw.upper()))) -def supports_cloud_courses(rep) -> bool: - """True for a device that advertises any downloaded-program slot.""" - return bool(advertised_slots(rep)) +def cloud_slots(rep, courses) -> list[str]: + """Advertised slots that are not already selectable courses. + + `CloudExtraCourse_` does not mean the same thing on every family. On the + washers its bytes are opaque payload slots sharing nothing with the + device's own course list, and selecting one needs the full payload. On + the DW5000C dishwasher all four of its bytes *are* course codes in that + device's own list (8E/8D/8F/02 -- Plastic, Pots and pans, Baby Care, and + one untranslated), so there it is tagging which of its ordinary courses + came from the cloud. Those are already selectable as plain `Course_` + writes and need nothing from this module. + + Subtracting the course list tells the two apart without having to guess + the family: what remains is slots that cannot be selected any other way, + which is exactly the set this module exists for. + """ + known = {c.upper() for c in courses or ()} + return [slot for slot in advertised_slots(rep) if slot not in known] + + +def supports_cloud_courses(rep, courses) -> bool: + """True for a device with downloaded programs it cannot otherwise run.""" + return bool(cloud_slots(rep, courses)) def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]: @@ -314,9 +334,10 @@ class CloudCourses: return bool(record["name"]) -def undiscovered(rep: dict, record: dict) -> list[str]: - """Advertised slots that aren't yet usable -- unlearned or unnamed. What - the Repairs issue counts, and what the options flow asks the user to walk - the appliance through.""" +def undiscovered(rep: dict, record: dict, courses) -> list[str]: + """Cloud slots that aren't yet usable -- unlearned or unnamed. What the + Repairs issue counts, and what the options flow asks the user to walk the + appliance through. Counts against cloud_slots, not every advertised byte: + a slot that is already a selectable course is nothing to set up.""" programs = record.get("slots") or {} - return [slot for slot in advertised_slots(rep) if not (programs.get(slot) or {}).get("name")] + return [s for s in cloud_slots(rep, courses) if not (programs.get(s) or {}).get("name")] diff --git a/custom_components/localthings/config_flow.py b/custom_components/localthings/config_flow.py index 1f196a5..bcd6dfb 100644 --- a/custom_components/localthings/config_flow.py +++ b/custom_components/localthings/config_flow.py @@ -840,7 +840,9 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): # programs (issue #342) -- every other device would get a menu entry # leading to an empty screen. coord = self._coordinator() - if coord is not None and cloudcourse.supports_cloud_courses(coord.cloud_course_rep()): + if coord is not None and cloudcourse.supports_cloud_courses( + coord.cloud_course_rep(), cycle_options(coord.canonical_resources(MAIN)) + ): menu.insert(1, "cloud_courses") return self.async_show_menu(step_id="init", menu_options=menu) @@ -935,7 +937,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow): rep = coord.cloud_course_rep() record = store.snapshot() slots = record["slots"] - advertised = cloudcourse.advertised_slots(rep) + advertised = cloudcourse.cloud_slots(rep, cycle_options(coord.canonical_resources(MAIN))) # Learned slots keep the appliance's own ordering; anything learned # but no longer advertised still gets a row so a name isn't stranded. known = [s for s in advertised if s in slots] + [s for s in slots if s not in advertised] diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index c2de6da..151f4eb 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -56,6 +56,7 @@ from .registry.capabilities.common import ( remote_control_enabled, remote_control_required_for_write, ) +from .registry.capabilities.laundry import cycle_options from .registry.discovery import BoundEntity from .registry.entities import ClimateDesc from .registry.identity import ( @@ -66,6 +67,7 @@ from .registry.identity import ( resolve_serial, ) from .registry.subdevices import ( + MAIN, Subdevice, canonical_view, discover_partitioned, @@ -548,8 +550,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): issue_id = f"cloud_courses_{self._entry.entry_id}" rep = self.cloud_course_rep() record = self._cloud.snapshot() - pending = cloudcourse.undiscovered(rep, record) - needs_course = bool(cloudcourse.advertised_slots(rep)) and not record["download_course"] + courses = cycle_options(self.canonical_resources(MAIN)) + pending = cloudcourse.undiscovered(rep, record, courses) + slots = cloudcourse.cloud_slots(rep, courses) + needs_course = bool(slots) and not record["download_course"] if record["slots"] and (pending or needs_course): ir.async_create_issue( self.hass, @@ -561,7 +565,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): translation_placeholders={ "device_name": self.device_info.get("name") or "This appliance", "pending": str(len(pending)), - "total": str(len(cloudcourse.advertised_slots(rep))), + "total": str(len(slots)), }, learn_more_url=DEVICE_SUPPORT_ISSUE_URL, ) diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index 6141e35..b61124b 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -19,6 +19,7 @@ from homeassistant.loader import async_get_integration from . import cloudcourse from .const import DOMAIN from .coordinator import LocalThingsCoordinator +from .registry.capabilities.laundry import cycle_options from .registry.redact import redact_resources from .registry.subdevices import MAIN @@ -153,6 +154,9 @@ async def async_get_config_entry_diagnostics( # because its owner chose to download and share it. "cloud_courses": { "advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()), + "cloud_slots": cloudcourse.cloud_slots( + coordinator.cloud_course_rep(), cycle_options(coordinator.device_resources(MAIN)) + ), **cloud_courses, }, "integration_version": integration.version, diff --git a/docs/investigations/download-cycle.md b/docs/investigations/download-cycle.md index 99d0f81..9b8f5fc 100644 --- a/docs/investigations/download-cycle.md +++ b/docs/investigations/download-cycle.md @@ -15,16 +15,25 @@ no cloud tokens at all, so this is a minority feature): | --- | --- | --- | --- | --- | | `washer_ww5000c_cloud` | WW5000C `_B06C`, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 2 | 20 bytes | | `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 1 | 16 bytes | -| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 | **0** | — | +| `dishwasher_dw5000c_cloud` | DW5000C, `DA_DW_TP1_21_COMMON` | 4 (but see below) | **0** | — | | (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes | Three things follow immediately from that table: -- **It isn't washer-only.** The DW5000C is a `DA_DW_` dishwasher. -- **A device can advertise programs it has never loaded.** The DW5000C names - four slots and carries no `CloudCourse_`/`OneTimeCloudCourse_` token at - all. Nothing about it is learnable until its owner runs one, which is - exactly the situation the Repairs issue exists to explain. +- **`CloudExtraCourse_` does not mean the same thing on every family.** On + the DW5000C all four of its bytes (`8E 8D 8F 02`) are course codes in that + dishwasher's *own* course list, three already translated (Plastic, Pots and + pans, Baby Care). There it tags which ordinary courses came from the cloud; + they select with a plain `Course_` write and need no payload — consistent + with it carrying no payload token at all. It also has a + `DownloadCourseList_8F` token the washers lack. + On both washers the slots share **zero** overlap with the course list and a + payload is required. Subtracting the course list is what tells the two + apart (`cloudcourse.cloud_slots`), so the feature engages on the washers + and correctly does nothing on the dishwasher. +- **A device can advertise a slot it has never loaded.** True on the washers + too — nothing about a program is learnable until its owner runs it, which + is what the Repairs issue exists to explain. - **Both WW5000C units advertise the byte-identical slot list** (`0A5C286B2D0C55301A`, same nine slots in the same order) despite different firmware builds. Either the set is a factory/regional default rather than diff --git a/tests/test_cloud_courses.py b/tests/test_cloud_courses.py index a4cab65..e2b7330 100644 --- a/tests/test_cloud_courses.py +++ b/tests/test_cloud_courses.py @@ -78,7 +78,7 @@ class TestBlobParsing: ] def test_no_cloud_tokens_means_unsupported(self): - assert cloudcourse.supports_cloud_courses(_rep(["Course_1C"])) is False + assert cloudcourse.supports_cloud_courses(_rep(["Course_1C"]), ["1C"]) is False class TestRealDumps: @@ -122,23 +122,39 @@ class TestRealDumps: store.observe(rep) assert store.download_candidates() == [] - def test_a_dishwasher_advertises_programs_it_has_never_loaded(self): - """DW5000C (issues #113/#123): CloudExtraCourse_ names four slots - with no CloudCourse_/OneTimeCloudCourse_ token anywhere in the array. + def test_a_dishwasher_tags_its_own_courses_not_payload_slots(self): + """DW5000C (issues #113/#123). Its CloudExtraCourse_ names four + bytes, and all four are course codes in its *own* course list -- + 8E/8D/8F/02, three of them already translated (Plastic, Pots and + pans, Baby Care). There it marks which ordinary courses came from + the cloud; they select with a plain Course_ write and need nothing + from this module. It carries no payload token at all, consistent + with that. + + So none of this feature applies to it, and offering its owner a + naming flow for programs that already work would be nonsense. The + washers are the other shape: zero overlap with their course lists, + and a payload required to select one.""" + resources = _load_device("dishwasher_dw5000c_cloud") + rep = resources["/course/vs/0"] + courses = laundry.cycle_options(resources) - Two things at once -- the feature is not washer-only (DA_DW, not - DA_WM), and a device can advertise programs whose payloads have never - been observed. Nothing is learnable here, so nothing is offerable, - but the gap is still countable and still worth telling the user - about.""" - rep = _load_device("dishwasher_dw5000c_cloud")["/course/vs/0"] assert cloudcourse.advertised_slots(rep) == ["8E", "8D", "8F", "02"] - assert cloudcourse.supports_cloud_courses(rep) is True + assert set(cloudcourse.advertised_slots(rep)) <= set(courses) + assert cloudcourse.cloud_slots(rep, courses) == [] + assert cloudcourse.supports_cloud_courses(rep, courses) is False + assert cloudcourse.undiscovered(rep, cloudcourse.CloudCourses().snapshot(), courses) == [] - store = cloudcourse.CloudCourses() - assert store.observe(rep) is False - assert store.view() == {} - assert cloudcourse.undiscovered(rep, store.snapshot()) == ["8E", "8D", "8F", "02"] + def test_washer_slots_share_nothing_with_the_course_list(self): + """The distinguishing property, on both washers -- which is what + makes subtracting the course list a safe way to tell the two + meanings of CloudExtraCourse_ apart.""" + for name in ("washer_ww5000c_cloud", "washer_wa55a7700av"): + resources = _load_device(name) + rep = resources["/course/vs/0"] + courses = laundry.cycle_options(resources) + assert set(cloudcourse.advertised_slots(rep)).isdisjoint(courses), name + assert cloudcourse.supports_cloud_courses(rep, courses) is True, name def test_byte_three_is_not_part_of_a_programs_identity(self): """Two WW5000C units on different firmware (issue #342's _B06C and @@ -180,10 +196,11 @@ class TestRealDumps: store = cloudcourse.CloudCourses() store.observe(rep) # Both learned slots are still unnamed, so all nine are outstanding. - assert len(cloudcourse.undiscovered(rep, store.snapshot())) == 9 + courses = laundry.cycle_options(_load_device("washer_ww5000c_cloud")) + assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 9 store.set_name("55", "Sports") - assert "55" not in cloudcourse.undiscovered(rep, store.snapshot()) - assert len(cloudcourse.undiscovered(rep, store.snapshot())) == 8 + assert "55" not in cloudcourse.undiscovered(rep, store.snapshot(), courses) + assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 8 class TestStoreRules: