Discover and offer cloud "Download" cycles (issue #342) - #347
Merged
Conversation
A washer whose course table includes "Download"/"Downloaded" runs whichever program the SmartThings cloud last pushed down. Those programs are now selectable from the ordinary cycle select, so a downloaded Jeans or Sports cycle can be started without giving the appliance internet access. The device turns out to enumerate them itself. `CloudExtraCourse_` on /course/vs/0 lists one byte per downloaded program, and byte 2 of a program's payload is exactly that slot id -- verified against all nine programs on the reporter's WW5000C and against the WA55A7700AV dump already in the corpus. So nothing here is hardcoded: the appliance says which programs exist, the payloads are learned by watching what it reports, and the names come from the user. That last part is unavoidable rather than a shortcut. A payload is only visible while its program is loaded, and the appliance never reports a name for one. So cloudcourse.py persists what has been seen (same rationale as learned.py's mode store), a Repairs issue tells the owner how many programs are still unaccounted for, and an options-flow step collects the names. A program appears in the cycle select only once it is both learned and named. Selecting one issues the only two-token options write in the codebase -- the course token has to switch to Download in the same write, or the appliance accepts the program token and silently ignores it (confirmed on hardware). The Download course code is learned by observation but never applied until the user confirms it: tokens in this array are replaced by prefix and never evicted, so a stale program token can appear alongside an unrelated course, and acting on that would start the wrong wash cycle. For the same reason a stale token is never reported as the running program. Also of note: - There is no single "Download" course code. The WW5000C uses 87, the WA55A7700AV uses 17 -- same Table_02. Any per-table lookup would have been wrong on one of the only two devices available to check. - Payloads are replayed byte-for-byte and never decomposed or rebuilt. Bytes 5/7/9 do decode to temperature/rinse/spin on the WW5000C, 9 for 9, and produce nonsense on the WA55A7700AV -- so that decode is written up in docs/investigations/download-cycle.md and not shipped, and the read-only sensors it would have enabled were dropped. - The store reaches the registry as a namespaced synthetic field merged onto /course/vs/0's rep at read time, so exists_fn/rep_fn/options/write_fn all see it through their existing signatures. It never enters the state cache, so it can't be polled over, written to the device, or land in a diagnostics dump. - A name that would render identically to another cycle in the same dropdown is rejected in the flow: the select maps a chosen label back to a raw value by matching display text. Non-English catalogs carry the new strings in English for now; they need real translations.
Byte-aligning the WA55A7700AV's 16-byte payload against the WW5000C's 20-byte one: identical header, and the first four tag/value pairs are the same tags in the same order at the same offsets -- the part that carries per-program data has one shape on both boards. The whole width difference is two trailing pairs the WA55 doesn't carry, and on the WW5000C that trailing section is byte-identical across all nine programs, so it isn't program data at all. Doesn't change the conclusion -- the four shared tags carry non-overlapping value ranges between the boards, so the encoding is still board-specific and blobs are still replayed whole. Also records why the WA55's /washer/vs/0 readings can't be used to confirm a decode: that unit is on a local course, not its cloud course.
A survey of every laundry diagnostics dump attached to an issue turned up 14 devices, 4 of which carry cloud-course tokens. Two were already known; the two new ones are both useful, and one contradicts something the investigation write-up asserted. A DW5000C dishwasher (issues #113/#123) advertises four downloaded programs and carries no payload token for any of them. That is a shape the corpus didn't have: the feature is not washer-only (DA_DW, not DA_WM), and a device can name programs whose payloads have never been observed. The existing code already handles it correctly -- nothing learnable, nothing offered, gap still counted for the Repairs issue -- so this adds the fixture, golden, and tests that keep it that way. A second WW5000C (issues #259/#343, firmware _B048) holds the same saved program as the first one's captured "Towels", and the two payloads differ at exactly one byte: byte 3, 04 against 06. Everything else -- id, slot, all four varying tag values, the whole tail -- is identical. So byte 3 is neither a per-board constant nor a property of the program, and the doc's claim that it is always 04 on this board was wrong. That is also the strongest argument yet for learning payloads per device: a catalog keyed on program id would have shipped one unit's byte 3 to the other. Nothing changes in the implementation as a result -- it never had a catalog -- but the reasoning is now backed by evidence rather than caution. Also recorded: both WW5000C units advertise the byte-identical slot list despite different firmware, so the program set looks factory- or region-assigned rather than user-curated; and a sentinel's byte 2 equals the selected course on one dump but not the other, so it stays unused.
Also drops appliance-specific wording from the new user-facing strings. The setup step said "your washer" and told people to "turn the dial", which is wrong for the DW5000C dishwasher that advertises the same tokens. The two that could have caused a wrong wash cycle: - The Download-course candidate was counted on every poll that saw a loaded one-time payload, not on the polls where one was actually loaded. Since a stale token is never evicted, it keeps being reported through however long the appliance then sits on some ordinary course -- so "most frequent" ranked by dwell time. Reproduced: one poll on Course_87 then 200 on Course_1B suggests 1B, and accepting the suggestion makes picking a download program start a Cotton wash. Now only a change of the payload counts, which is the moment the device is known to accept a program. - The Download-course dropdown had custom_value=True, contradicting its own comment, so a typed-in code went into the Course_ token of a real write unchecked. Off now, plus a server-side check against the device's own course list where the value is stored. Two that quietly broke things beyond this feature: - cycle_select now always supplies a display_fn (to label cloud programs), which defeated select._display's "no state table and no fallback -> return raw" exit. Every dryer, dishwasher and air dresser on an unrecognized course table would have had its options and state reshaped from '0E' to '0 E', breaking automations and recorder history. The exit now keys off whether anything actually named the value, not whether a fallback existed. - The synthetic cloud field reached diagnostics, which reads canonical_resources -- publishing user-typed program names in a dump people paste into issues, directly against the comment claiming it never could. Dropped at the redaction boundary, with a matching strip for the debug read service, which wants device state unredacted but shouldn't present our bookkeeping as something the appliance said. And two smaller ones: - The repair fired on any device advertising slots, so the DW5000C -- four advertised, none ever loaded -- got a permanent warning nothing the owner did in Home Assistant could clear. It now waits until a payload has been seen, which is the only evidence that household uses downloaded programs. - The name-collision check read only the translation catalog, missing the device's own personal-course labels, which the select renders identically.
Four parallel reviews (reuse, simplification, efficiency, altitude). The two that change behavior: - observe() could report "changed" on every poll forever, rewriting the config entry each time. If both tokens name the same slot with different payloads -- a downloaded program with its settings tweaked for one run is exactly that shape -- each pass wrote the default's blob then the one-shot's over it, so neither was ever already stored. On the SD-card installs this integration runs on, sustained entry rewrites are the one cost here that bites. The end state is stable, so "changed" is now start-vs-end, not per-assignment. - The write path copied every tracked href to read one rep, walking past the accessor added to avoid exactly that. New entity_rep() does the merge for a single href; cycle_write drops the resources parameter it never used. Structure: - device_resources() is a second accessor giving the pure device view, used by diagnostics and the debug read service. That deletes strip_synthetic, the _SYNTHETIC_KEY_PREFIX convention and the redact filter added last commit: "a dump is what the device said" is now which method you call rather than something every future exporter has to remember. - apply_cloud_courses() is the single mutation path. The flow was reaching past the coordinator into the store and relying on a later call to persist and invalidate for it; nine names are also now one entry write, not nine. - option_value/hex_pairs move to capabilities/common.py. The duplicate's stated reason -- that the coordinator shouldn't import from registry.capabilities -- was simply false; it already does, and so does learned.py. The real constraint is narrower: laundry.py imports cloudcourse, so the reverse would be a cycle. Dropped rather than kept: - The cloud-vs-translated-local-course name check, and catalog. translated_state_labels with it. The catalog this process can read is English while the dropdown is localized in the frontend, so it rejected "Cotton" for a German user seeing "Baumwolle" and missed the real collision when they typed "Baumwolle" -- wrong in both directions outside one locale, against an outcome option ordering already makes deterministic. The checks that survive compare strings that are the same in every locale: the user's own names, and the device's personal-course labels. - stored(), clear()/forget_cloud_courses(), blob(), download_course() -- no production callers. stored() was a template artifact whose docstring described a caller that cannot exist here. Diagnostics gains a cloud_courses block, which the store was missing next to learned_modes -- payloads and which slots are named, but not the names themselves, since those are the user's words and dumps get pasted publicly. Kept against one reviewer's advice: option_tokens (two others called generalizing option_write the right direction) and select._display's uncatalogued branch, which names a condition the old fallback-is-None proxy only got right by accident. Deferred: making the store per-subdevice. It is MAIN-only today and no device seen advertises cloud programs elsewhere; the limitation is now documented where it is made.
Trimming the names out of the dump last commit was the wrong call. Half of what goes wrong with this feature is a configuration question -- which programs got named, which Download course was confirmed, whether a payload was ever captured for a slot the device advertises -- and none of that is answerable from the payloads alone. A report saying "my download cycle isn't showing up" is exactly the case that needs it. The names are still the user's own words, so this block stays the one place they appear; they reach a dump only because its owner chose to download and share it. `resources` is unaffected either way -- it goes on reporting exactly what the appliance said, via device_resources().
mbillow
force-pushed
the
claude/cloud-cycle-download-select-onx14l
branch
from
August 10, 2026 03:35
58f5697 to
cef187b
Compare
The eight new strings shipped as English placeholders in every non-English catalog. Translated for cs/de/es/it/ko/nl. Where a locale's course table already names the Download course itself, that existing term is reused rather than a fresh coinage -- Korean's 다운로드 코스 is the catalog's own translation of course code 17, so the options flow now says what the appliance display says. German and Dutch had no equally distinctive existing term and build on the adjective already used for the downloaded state. Counts are not pluralized. The strings have no plural support and the placeholders are raw numbers, so Czech, Italian and Dutch use the plural form regardless of count -- the same simplification the rest of these catalogs already make.
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.
Naming downloaded programs from a list of hex slot ids was the weak part of this feature: it asked about programs in the abstract, long after the user had touched the appliance, and the per-slot fields rendered as raw keys because Home Assistant can't translate dynamic ones. Guided setup asks in the moment instead. It waits on a progress step while the user selects a program on the appliance, then asks for that one's name -- so the field is a single static key, and "which one is this?" is answered by the user having just turned the dial to it. The prompt also shows the appliance's own reported remaining time, which differs per program and is device-reported rather than decoded. Two things it has to get right: - It waits for a *transition*, not a state. After naming a program the appliance is still sitting on it, so a loop keyed on "a known slot is loaded" would re-offer the same one forever. Each round baselines on whatever is loaded when it starts. - Re-selecting an already-named program is not an error -- it is how someone checks their work -- so it gets the existing name pre-filled and the counter deliberately does not move, rather than a rejection. Names persist as they are entered rather than batching to the end of the flow, which makes closing the dialog a clean "save and exit" with nothing pending to lose, and makes the flow resumable: reopening picks up from the store. async_remove cancels an in-flight round, so walking away actually stops the probing instead of holding the session lock every few seconds until the timeout. /course/vs/0 is cold-tier, so passively a selection can take a whole poll interval to appear. async_probe_cloud_courses live-reads it through the normal apply path, keeping learning and persistence in one place. The bulk form stays, under its own step, as the way to rename things later -- which guided setup is bad at. New strings ship in English in every catalog and need translating.
A nine-program walk is hard to keep your place in. The counter alone doesn't say what you've already done, and the programs still to do can't be listed -- they're unnamed by definition, which is the whole premise. So "named so far" is the only orientation available, and it now appears on all three guided screens. It doubles as duplicate avoidance on the naming form: a repeated name is rejected, so seeing the others while typing beats being bounced afterwards. Listed in the appliance's own advertised order rather than the order they were named -- a stable order either way, and not numbered: whether it matches the dial is plausible but unverified, and implying it would be worse than saying nothing.
…the course A program is only offerable once it has both a name and the Download course code that goes in the Course_ token. Guided setup collected names and never asked about the course, so a user could walk all nine programs, watch every name save, and end up with nothing in the cycle list. Worse, it actively cleared the course. _apply_cloud_course_names read download_course out of the submitted form; the guided name form has no such field, so it passed None and apply_cloud_courses stored that. Naming a program therefore removed every previously-named program from the list. Traced on the fixture: 87 -> name one -> None -> nothing offerable. Two changes. apply_cloud_courses now defaults download_course to "leave it alone" rather than None, so silence can't be mistaken for a clear, and the guided path forwards the field only when its form actually carried it. And the first guided name form now asks for the course, prefilled from what was just observed, dropping the field once confirmed. Asking there rather than up front is deliberate: it is the first moment there is evidence to prefill, since the user has just loaded a program and the course showing alongside it is the Download one. That makes the walk stand on its own, which is the whole point of offering it as the primary path. The bulk form's course dropdown now shares the guided one's builder. Translations for the guided-setup strings are in for cs/de/es/it/ko/nl, matching the vocabulary the earlier pass established. The new field on the name form reuses each locale's existing label from the bulk form rather than adding an untranslated string.
The Download-course candidate came from "Course_ read while a non-sentinel one-time payload is loaded". On the first rep after any restart that is indistinguishable from a payload left over from a previous run, so an appliance holding cloud payloads while sitting on an ordinary course proposed that ordinary course as the Download one. Accepting the prefill would then make selecting a downloaded program start, say, a cotton wash. Only a transition actually watched counts now. "Never observed" is a distinct state from "observed, nothing loaded" -- absent-then-loaded is a genuine selection and still counts -- so a restored store deliberately re-enters the unobserved state, since a restart cannot tell the two apart. Payloads are still learned from that first rep either way; which programs exist is device fact regardless of when they were loaded. It is only the inference about which course means Download that needs the timing. Both corpus dumps taken off the Download course show the appliance clearing its one-time token to the FFFF sentinel, so this may never fire on these boards. That is a reason to expect them to behave, not to depend on it.
A write schedules a debounced refresh, which polled through a fake session that only implements post(), crashed on the missing get(), and left its timer running past the end of the test. CI's lingering-timer check caught it; it passed locally only by timing luck. Stubbed the same way test_coordinator_send_command's fixture already does, which is what this test should have copied to begin with.
The one that could run the wrong program: guided setup accepted a name another program already had. The duplicate check only compared names within the form it was handed, and guided setup submits one program at a time, so it never saw the others. Two programs sharing a label resolve to whichever option comes first, so picking the second would have run the first one's payload -- the exact failure the check exists to prevent, working correctly in the bulk form and blind in the guided one. The taken set now includes every other named program, excluding the slot being edited so confirming an unchanged name doesn't reject itself. The rest: - The timeout screen's copy interpolates the same counters as the other two but was shown without placeholders, so it rendered literal braces. - The probe reports whatever payload is loaded, while observe() declines one whose slot the device doesn't advertise. Guided setup could reach the name form for such a slot, take a name, and silently discard it -- there is no record to hang it on and no payload to replay. It now waits instead. - The prefilled Download course came from the raw candidate list while the dropdown filters to courses the appliance still offers, so a stale candidate prefilled a value the selector rejects and the form failed validation on something the user never chose.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #342 (the download-cycle half; the missing course names landed in #344).
A washer whose course table includes "Download"/"Downloaded" runs whichever program the SmartThings cloud last pushed down. Those programs are now selectable from the ordinary cycle select, so a downloaded Jeans or Sports cycle can be started without giving the appliance internet access.
The device enumerates them itself
The finding that shaped the design.
/course/vs/0carriesCloudExtraCourse_, which lists one byte per downloaded program, and byte 2 of a program's payload is exactly that slot id:Verified against all nine programs on the reporter's WW5000C and independently on the WA55A7700AV dump already in the corpus.
So nothing here is hardcoded. The appliance says which programs exist; the payloads are learned by watching what it reports; the names come from the user.
CloudExtraCourse_means two different thingsOn the DW5000C dishwasher, all four of its bytes (
8E 8D 8F 02) are course codes in that device's own course list — three already translated as Plastic, Pots and pans, Baby Care. There the token tags which ordinary courses came from the cloud; they select with a plainCourse_write and need no payload, consistent with that dump carrying no payload token at all.On both washers the slots share zero overlap with the course list and a payload is required.
cloud_slots()subtracts the device's own course list, which separates the two readings without guessing at families. Everything user-facing gates on that, so the dishwasher gets nothing and both washers are unaffected.Guided setup
Naming programs from a list of hex slot ids was the weak part of this feature, so guided setup is the primary path: it waits while the user selects a program on the appliance, then asks for that one's name, and loops.
Three things it has to get right:
async_removecancels an in-flight round so walking away actually stops the probing.The first name form also confirms the Download course, prefilled from what was just observed — without it the walk would collect names and produce nothing selectable, since a program needs both.
Things that are deliberately not done
04vs06) — a catalog keyed on program id would have shipped one unit's byte 3 to the other. Payloads are learned per device and replayed byte-for-byte.87, the WA55A7700AV uses17— sameTable_02. It's detected only from a transition actually observed, and confirmed by the user before any write, because a wrong code starts a real wash cycle.docs/investigations/download-cycle.mdrather than shipped.Notes for review
/course/vs/0's rep at read time, soexists_fn/rep_fn/options/write_fnall see it through their existing signatures.device_resources()is the pure view used by diagnostics and the debug read service, so "a dump is what the device said" is a property of which accessor you call./course/vs/0isn't covered. No device seen advertises cloud programs anywhere but MAIN; making it per-subdevice means keying the store by actual href likeLearnedModesdoes, and migrating the persisted shape. Documented where it's made.washer_ww5000c_cloud,dishwasher_dw5000c_cloud); neither grows the entity set, which is the guard that discovering programs never adds an entity.1492 tests pass;
ruffandtyclean. The branch has been through a correctness review (six findings, all fixed and pinned), a four-angle cleanup pass, and several rounds of walking the flow end to end — which is what turned up the two worst bugs, both of which could have started the wrong wash cycle.Known rough edges
name_55), because HA takes field labels from static translation keys and slot ids are only known at runtime. Cosmetic rather than blocking now that guided setup is the primary path: that form is reached for renaming, when each field's value already shows the current name.🤖 Generated with Claude Code
https://claude.ai/code/session_01EuQYdzXFoEqEgtjmZs7Cyn