laundry: CloudExtraCourse_ means two different things; tell them apart

Its bytes are not payload slots everywhere. On the DW5000C dishwasher all
four (8E 8D 8F 02) are course codes in that device's own course list, three
already translated -- Plastic, Pots and pans, Baby Care. There the token
marks which ordinary courses came from the cloud; they select with a plain
Course_ write and need no payload, which is consistent with it carrying no
payload token at all. It also has a DownloadCourseList_ token the washers
lack. On both washers the slots share zero overlap with the course list and
a payload is required to select one.

So the "this is not washer-only" claim was wrong, and gating on
advertised_slots offered that dishwasher's owner a naming flow for programs
that already work and are already named. The Repairs card was spared only
because the payload gate added earlier happens to catch it.

cloud_slots() subtracts the device's own course list, which separates the two
readings without guessing at families: what remains is slots that cannot be
selected any other way, which is what this module is for. Everything
user-facing now gates on that -- the options menu entry, the naming flow, the
Repairs count. The dishwasher gets nothing, both washers are unchanged.

Found by reading the dishwasher fixture's options array while answering a
question about it, which is also why diagnostics now reports advertised and
cloud slots separately: the difference between them is the whole distinction.
This commit is contained in:
Marc Billow
2026-08-10 03:55:13 +00:00
parent bee4466b45
commit d27bfc6405
6 changed files with 100 additions and 43 deletions
+35 -14
View File
@@ -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 2. Blob width is *not* fixed across boards (20 bytes vs 16), which is one
reason nothing here ever synthesizes one. reason nothing here ever synthesizes one.
This is not washer-only, and nothing here assumes an appliance type: the ``CloudExtraCourse_`` does not mean the same thing on every family, so
DW5000C dishwasher in ``tests/fixtures`` advertises four slots on the same nothing keys off it directly -- see ``cloud_slots``, which is what the rest
token. It also carries no payload token at all, so a device can name of this module and its callers gate on. Even then, a device can advertise a
programs whose payloads have never been observed -- ``advertised_slots`` slot whose payload has never been observed, so ``cloud_slots`` answers
answers "which exist", the store answers "which are usable", and the two "which exist" while the store answers "which are usable"; the two are
are deliberately allowed to disagree. deliberately allowed to disagree.
What this module does and deliberately does not do 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()))) return list(dict.fromkeys(hex_pairs(raw.upper())))
def supports_cloud_courses(rep) -> bool: def cloud_slots(rep, courses) -> list[str]:
"""True for a device that advertises any downloaded-program slot.""" """Advertised slots that are not already selectable courses.
return bool(advertised_slots(rep))
`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]]]: def _coerce(stored) -> tuple[str | None, dict[str, dict[str, str]]]:
@@ -314,9 +334,10 @@ class CloudCourses:
return bool(record["name"]) return bool(record["name"])
def undiscovered(rep: dict, record: dict) -> list[str]: def undiscovered(rep: dict, record: dict, courses) -> list[str]:
"""Advertised slots that aren't yet usable -- unlearned or unnamed. What """Cloud slots that aren't yet usable -- unlearned or unnamed. What the
the Repairs issue counts, and what the options flow asks the user to walk Repairs issue counts, and what the options flow asks the user to walk the
the appliance through.""" 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 {} 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")]
+4 -2
View File
@@ -840,7 +840,9 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
# programs (issue #342) -- every other device would get a menu entry # programs (issue #342) -- every other device would get a menu entry
# leading to an empty screen. # leading to an empty screen.
coord = self._coordinator() 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") menu.insert(1, "cloud_courses")
return self.async_show_menu(step_id="init", menu_options=menu) 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() rep = coord.cloud_course_rep()
record = store.snapshot() record = store.snapshot()
slots = record["slots"] 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 # Learned slots keep the appliance's own ordering; anything learned
# but no longer advertised still gets a row so a name isn't stranded. # 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] known = [s for s in advertised if s in slots] + [s for s in slots if s not in advertised]
+7 -3
View File
@@ -56,6 +56,7 @@ from .registry.capabilities.common import (
remote_control_enabled, remote_control_enabled,
remote_control_required_for_write, remote_control_required_for_write,
) )
from .registry.capabilities.laundry import cycle_options
from .registry.discovery import BoundEntity from .registry.discovery import BoundEntity
from .registry.entities import ClimateDesc from .registry.entities import ClimateDesc
from .registry.identity import ( from .registry.identity import (
@@ -66,6 +67,7 @@ from .registry.identity import (
resolve_serial, resolve_serial,
) )
from .registry.subdevices import ( from .registry.subdevices import (
MAIN,
Subdevice, Subdevice,
canonical_view, canonical_view,
discover_partitioned, discover_partitioned,
@@ -548,8 +550,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
issue_id = f"cloud_courses_{self._entry.entry_id}" issue_id = f"cloud_courses_{self._entry.entry_id}"
rep = self.cloud_course_rep() rep = self.cloud_course_rep()
record = self._cloud.snapshot() record = self._cloud.snapshot()
pending = cloudcourse.undiscovered(rep, record) courses = cycle_options(self.canonical_resources(MAIN))
needs_course = bool(cloudcourse.advertised_slots(rep)) and not record["download_course"] 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): if record["slots"] and (pending or needs_course):
ir.async_create_issue( ir.async_create_issue(
self.hass, self.hass,
@@ -561,7 +565,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_placeholders={ translation_placeholders={
"device_name": self.device_info.get("name") or "This appliance", "device_name": self.device_info.get("name") or "This appliance",
"pending": str(len(pending)), "pending": str(len(pending)),
"total": str(len(cloudcourse.advertised_slots(rep))), "total": str(len(slots)),
}, },
learn_more_url=DEVICE_SUPPORT_ISSUE_URL, learn_more_url=DEVICE_SUPPORT_ISSUE_URL,
) )
@@ -19,6 +19,7 @@ from homeassistant.loader import async_get_integration
from . import cloudcourse from . import cloudcourse
from .const import DOMAIN from .const import DOMAIN
from .coordinator import LocalThingsCoordinator from .coordinator import LocalThingsCoordinator
from .registry.capabilities.laundry import cycle_options
from .registry.redact import redact_resources from .registry.redact import redact_resources
from .registry.subdevices import MAIN 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. # because its owner chose to download and share it.
"cloud_courses": { "cloud_courses": {
"advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()), "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, **cloud_courses,
}, },
"integration_version": integration.version, "integration_version": integration.version,
+15 -6
View File
@@ -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_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 | | `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 | | (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes |
Three things follow immediately from that table: Three things follow immediately from that table:
- **It isn't washer-only.** The DW5000C is a `DA_DW_` dishwasher. - **`CloudExtraCourse_` does not mean the same thing on every family.** On
- **A device can advertise programs it has never loaded.** The DW5000C names the DW5000C all four of its bytes (`8E 8D 8F 02`) are course codes in that
four slots and carries no `CloudCourse_`/`OneTimeCloudCourse_` token at dishwasher's *own* course list, three already translated (Plastic, Pots and
all. Nothing about it is learnable until its owner runs one, which is pans, Baby Care). There it tags which ordinary courses came from the cloud;
exactly the situation the Repairs issue exists to explain. 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** - **Both WW5000C units advertise the byte-identical slot list**
(`0A5C286B2D0C55301A`, same nine slots in the same order) despite different (`0A5C286B2D0C55301A`, same nine slots in the same order) despite different
firmware builds. Either the set is a factory/regional default rather than firmware builds. Either the set is a factory/regional default rather than
+35 -18
View File
@@ -78,7 +78,7 @@ class TestBlobParsing:
] ]
def test_no_cloud_tokens_means_unsupported(self): 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: class TestRealDumps:
@@ -122,23 +122,39 @@ class TestRealDumps:
store.observe(rep) store.observe(rep)
assert store.download_candidates() == [] assert store.download_candidates() == []
def test_a_dishwasher_advertises_programs_it_has_never_loaded(self): def test_a_dishwasher_tags_its_own_courses_not_payload_slots(self):
"""DW5000C (issues #113/#123): CloudExtraCourse_ names four slots """DW5000C (issues #113/#123). Its CloudExtraCourse_ names four
with no CloudCourse_/OneTimeCloudCourse_ token anywhere in the array. 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.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() def test_washer_slots_share_nothing_with_the_course_list(self):
assert store.observe(rep) is False """The distinguishing property, on both washers -- which is what
assert store.view() == {} makes subtracting the course list a safe way to tell the two
assert cloudcourse.undiscovered(rep, store.snapshot()) == ["8E", "8D", "8F", "02"] 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): def test_byte_three_is_not_part_of_a_programs_identity(self):
"""Two WW5000C units on different firmware (issue #342's _B06C and """Two WW5000C units on different firmware (issue #342's _B06C and
@@ -180,10 +196,11 @@ class TestRealDumps:
store = cloudcourse.CloudCourses() store = cloudcourse.CloudCourses()
store.observe(rep) store.observe(rep)
# Both learned slots are still unnamed, so all nine are outstanding. # 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") store.set_name("55", "Sports")
assert "55" not in cloudcourse.undiscovered(rep, store.snapshot()) assert "55" not in cloudcourse.undiscovered(rep, store.snapshot(), courses)
assert len(cloudcourse.undiscovered(rep, store.snapshot())) == 8 assert len(cloudcourse.undiscovered(rep, store.snapshot(), courses)) == 8
class TestStoreRules: class TestStoreRules: