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:
@@ -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")]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+35
-18
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user