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