diff --git a/docs/investigations/download-cycle.md b/docs/investigations/download-cycle.md index 94fc715..99d0f81 100644 --- a/docs/investigations/download-cycle.md +++ b/docs/investigations/download-cycle.md @@ -7,15 +7,29 @@ records the byte-level work behind it, including a decode that fit one device perfectly and collapsed on the second — the reason nothing in the shipped code interprets a program payload at all. -Two real devices carry these tokens: +Four devices in the corpus carry these tokens (a survey of every laundry +diagnostics dump attached to an issue turned up 14 devices; the other 10 have +no cloud tokens at all, so this is a minority feature): -| dump | model | slots advertised | blob width | -| --- | --- | --- | --- | -| `washer_ww5000c_cloud` | WW5000C, `DA_WM_TP1_21_COMMON`, Table_02 | 9 | 20 bytes | -| `washer_wa55a7700av` | WA55A7700AV, `DA_WM_TP1_21_COMMON`, Table_02 | 2 | 16 bytes | +| dump | model | slots advertised | payloads seen | blob width | +| --- | --- | --- | --- | --- | +| `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** | — | +| (not fixtured) | WW5000C `_B048`, issues #259/#343, Table_02 | 9 | 1 | 20 bytes | -Both are Table_02. Note that already: same course table, different -everything else. +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. +- **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 + something each owner curates, or the two dumps share an owner — unresolved, + but worth knowing before assuming a user picked their own programs. ## The three tokens @@ -136,11 +150,44 @@ WA55 00 | 1C59 | 05 | 49:16 4D:11 4A:22 4C:20 | 37:F0 | AC: - Then a fixed tail, terminated on both by an `AC:` pair. The entire width difference is two trailing pairs the WA55 doesn't carry. -The tail is not program data. Across all nine WW5000C programs, byte 3 is -always `04` and bytes 12–19 are byte-identical — every trailing pair carries -value `F0` except the `AC` terminator, and `35` vs `37` looks like a board or -profile marker rather than a field. (Byte 3 is not a field count: the board -with the *higher* value has *fewer* pairs.) +The tail is not program data. Across all nine WW5000C programs bytes 12–19 +are byte-identical — every trailing pair carries value `F0` except the `AC` +terminator, and `35` vs `37` looks like a board or profile marker rather than +a field. (It is not a field count either: the board with the *higher* leading +byte has *fewer* pairs.) + +### Byte 3 is not part of a program's identity + +Worth its own heading, because it is the single strongest argument against +ever shipping a table of payloads. The second WW5000C (issues #259/#343, +firmware `_B048`) has its saved `CloudCourse` set to the same program as the +first one's "Towels" capture — and the two payloads differ at exactly one +byte: + +``` +_B06C "Towels" 00 04 0A 04 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00 +_B048 CloudCourse 00 04 0A 06 49 40 4D 13 4A B8 4C 00 35 F0 04 F0 05 F0 AC 00 + ^^ +``` + +Same program id, same slot, same values on all four varying tags, same tail. +Only byte 3 moves, `04` → `06`. So it is neither a per-board constant (both +are WW5000C) nor a property of the program (identical in every other +respect) — most likely a download revision or sequence counter. + +A hardcoded catalog keyed on program id would therefore have shipped one +unit's byte 3 to the other unit. Whether the appliance would reject that, or +accept it and do something unintended, is untested and does not need to be: +every payload is learned from the device it will be replayed to. + +### Sentinels + +The `FFFF` "nothing loaded" payload takes its board's own width (16 bytes on +the WA55, 20 on the WW5000C `_B048`) and always carries byte 3 = `00`. Its +byte 2 is *not* reliably meaningful: it equals the currently selected course +on the WA55 (`01`, on `Course_01`) and does not on the `_B048` (`1B`, on +`Course_1C`). Nothing keys off it — a sentinel is rejected on its `FFFF` +prefix, and its byte 2 is not an advertised slot in either dump anyway. So the payload is tag/value, not fixed offsets — but knowing the grammar doesn't recover the values. The same four tags carry non-overlapping ranges @@ -194,5 +241,20 @@ sensors this decode would have enabled were dropped for the same reason. Care for "Baby care intensive", `30` Cloudy Day for "Cloudy heaven") and sometimes doesn't (`21` Colors for "Sports"). Probably a base-course reference; not reliable enough to use. -3. A third device with downloaded programs would settle the tag/value - reading of the payload. +3. What does byte 3 count? It moves between two units holding the identical + program (`04` vs `06`) and is `00` on every sentinel. A revision or + download counter is the obvious guess; a dump taken before and after + re-downloading the same program would confirm it. +4. **The value encoding is still open, and a third *washer* won't + necessarily settle it.** The survey found one, but its saved program is a + duplicate of one already captured, so it adds no new tag values. What is + actually needed is a dump from a board whose `/washer/vs/0` speaks in + named levels (Cold/Warm/Hot, Low/High) *while that unit is sitting on a + downloaded program* — then the payload's `49`/`4A` values can be read + against settings that describe the same program. The WA55 is such a + board but was captured on a local course, which is why it can't be used + (see the trap above). +5. The DW5000C's four slots (`8E 8D 8F 02`) are in the same numeric range as + dishwasher course codes (its selected course is `86`), unlike the + washers' slots. One payload from that machine would show whether slot ids + are drawn from the course-code space on some boards. diff --git a/tests/fixtures/dishwasher_dw5000c_cloud_device.json b/tests/fixtures/dishwasher_dw5000c_cloud_device.json new file mode 100644 index 0000000..d74b125 --- /dev/null +++ b/tests/fixtures/dishwasher_dw5000c_cloud_device.json @@ -0,0 +1,297 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "DishA_Disable", + "x.com.samsung.da.triggeredTime": "2022-07-28T13:45:57", + "x.com.samsung.da.state": "Deleted" + } + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": { + "x.com.samsung.da.diagnosisStart": "Ready" + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeSavedPower": "0" + } + }, + { + "href": "/energy/consumption/0", + "rep": {} + }, + { + "href": "/course/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "HOMECARE_WIZARD_V2" + ], + "x.com.samsung.da.options": [ + "DeviceType_0812", + "UpdateAllow_NotAllowed", + "CourseDefaultTimeSet_008B008E009A006D003C000A006600A50059009D", + "CourseDefaultTempSet_3C41363F41413737373700003C3C464649493C3C", + "CourseDefaultFahrenheitSet_8C95819195958383838300008C8C9E9EA3A38C8C", + "Course_86", + "DetergentOnce_0", + "DetergentLeft_0", + "DetergentBase_0", + "DetergentAlarm_Off", + "DetergentType_0", + "DetergentTotal_0", + "ProgressTimeSet_420622820456B20384", + "SendToDevice_Off", + "GMT_F2", + "SavingModeCondition_01010207828485868E8D020300", + "SavingMode_Off", + "DownloadCourseList_8F", + "StormWashZone_Off", + "AutoDoorRelease_On", + "Sound_On", + "WaterLevelSet_050404050302010205010400", + "CloudExtraCourse_8E8D8F02", + "EnergyLevelSet_050403050202010305040300", + "UsagesDB_ok", + "EnergyKW_396", + "DrumCleanLog_Empty", + "TimeSync_NotSupported" + ], + "x.com.samsung.da.supportedOptions": [ + "482830CB002C002D00283830CB002C002D00284830CB002C002D00285830CB000C000D00086830CB002C002D002908308B000C000D0008E830CB000C000D0008D830CB002C002D0028F8308B000C000D00002830CB002C002D002" + ] + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "On" + } + }, + { + "href": "/power/0", + "rep": { + "value": true + } + }, + { + "href": "/kidslock/vs/0", + "rep": { + "x.com.samsung.da.kidsLock": "Ready" + } + }, + { + "href": "/kidslock/0", + "rep": { + "value": false + } + }, + { + "href": "/operational/state/vs/0", + "rep": { + "x.com.samsung.da.state": "Run", + "x.com.samsung.da.remainingTime": "00:44:00", + "x.com.samsung.da.progressPercentage": "28", + "x.com.samsung.da.delayStartTime": "00:00:00", + "x.com.samsung.da.progress": "Wash", + "x.com.samsung.da.supportedProgress": [ + "None", + "Predrain", + "Wash", + "Rinse", + "Drying", + "Finish" + ] + } + }, + { + "href": "/operational/state/0", + "rep": { + "currentMachineState": "**REDACTED**", + "machineStates": "**REDACTED**", + "jobStates": [ + "None", + "Predrain", + "Wash", + "Rinse", + "Drying", + "Finish" + ], + "currentJobState": "Wash", + "remainingTime": "00:44:00", + "progressPercentage": "28" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000", + "x.com.samsung.da.description": "DA_DW_TP1_21_COMMON_DW5000C/DD92-0010741_0001", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "WD0", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "DA_DW_TP1_21_COMMON|30010741|40000200001711004981000000200000", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "00081A230213(A214)", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Firmware_1_DB_30010741230602142FFFFFFFFFFFFFFFFFFFFFFFFFFE(081230010741FFFFFFFF_30000000)(FileDown:0)(Type:0)", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "23060214,FFFFFFFF", + "x.com.samsung.da.newVersionAvailable": "0" + } + ] + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+00:00" + } + }, + { + "href": "/wm/editcourse/vs/0", + "rep": {} + }, + { + "href": "/wm/setinfo/vs/0", + "rep": { + "x.com.samsung.da.isModelSettingWithoutSC": "false", + "x.com.samsung.da.isModelSettingPowerOnOff": "false" + } + }, + { + "href": "/dishwasher/vs/0", + "rep": { + "x.com.samsung.da.highTemperatureDry": "Off", + "x.com.samsung.da.sanitize": "Off", + "x.com.samsung.da.selectedZone": "ON_ON", + "x.com.samsung.da.rinseLevel": "0", + "x.com.samsung.da.supportedSelectedZone": [ + "OFF_ON", + "ON_ON" + ], + "x.com.samsung.da.supportedSanitize": [ + "Off", + "On" + ], + "x.com.samsung.da.supportedHighTemperatureDry": [ + "Off", + "On" + ], + "x.com.samsung.da.supportedRinseLevel": [ + "0", + "1", + "2", + "3", + "4", + "5", + "6" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "Micom", + "x.com.samsung.da.newVersionAvailable": "false" + } + }, + { + "href": "/remotectrl/vs/0", + "rep": { + "x.com.samsung.da.remoteControlEnabled": "true" + } + }, + { + "href": "/remotectrl/0", + "rep": { + "value": true + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "0000000000", + "x.com.samsung.da.countryCode": "CA" + } + }, + { + "href": "/filter/waterfilter/vs/0", + "rep": {} + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "0000-00-00T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.durationminutes": "0", + "x.com.samsung.da.start": "0000-00-00T00:00:00Z", + "x.com.samsung.da.override": "Off", + "x.com.samsung.da.realSaving": "Off" + } + }, + { + "href": "/water/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeWater": "2662000" + } + }, + { + "href": "/wm/submode/vs/0", + "rep": { + "setTemperatureUnit": "F" + } + } + ] +} diff --git a/tests/fixtures/golden/dishwasher_dw5000c_cloud.json b/tests/fixtures/golden/dishwasher_dw5000c_cloud.json new file mode 100644 index 0000000..74ccb56 --- /dev/null +++ b/tests/fixtures/golden/dishwasher_dw5000c_cloud.json @@ -0,0 +1,26 @@ +{ + "state_keys": [ + "alarm_code", + "auto_release_dry", + "child_lock", + "completion_minutes", + "cycle", + "cycle_active", + "delay_start_hours", + "diagnosis_status", + "energy_saved_kwh", + "filter_status", + "filter_usage", + "finish_time", + "firmware_update", + "heated_dry", + "machine_state", + "power_switch", + "progress", + "progress_percentage", + "remote_control", + "sanitize", + "storm_wash", + "water_liters" + ] +} diff --git a/tests/test_cloud_courses.py b/tests/test_cloud_courses.py index 39e201d..29956f1 100644 --- a/tests/test_cloud_courses.py +++ b/tests/test_cloud_courses.py @@ -122,6 +122,59 @@ 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. + + 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 + + store = cloudcourse.CloudCourses() + assert store.observe(rep) is False + assert store.view() == {} + assert cloudcourse.undiscovered(rep, store.snapshot()) == ["8E", "8D", "8F", "02"] + + def test_byte_three_is_not_part_of_a_programs_identity(self): + """Two WW5000C units on different firmware (issue #342's _B06C and + issues #259/#343's _B048) report the same program -- same id, same + slot, same field values, same tail -- differing only at byte 3. + + This is the evidence against a shipped catalog of payloads: keyed on + program id, one unit's capture would have carried the other unit's + byte 3. Both are recorded and replayed per device instead, so + whatever byte 3 tracks never has to be understood.""" + b06c = "00040A0449404D134AB84C0035F004F005F0AC00" + b048 = "00040A0649404D134AB84C0035F004F005F0AC00" + assert b06c != b048 + # Same program by every identifier the device gives us. + assert cloudcourse.slot_of(b06c) == cloudcourse.slot_of(b048) == "0A" + assert b06c[:6] == b048[:6] + assert b06c[8:] == b048[8:] + # Learned separately per device, and each replays what it saw. + for blob in (b06c, b048): + store = cloudcourse.CloudCourses() + store.observe(_rep(["CloudExtraCourse_0A", f"CloudCourse_{blob}"])) + assert store.blob("0A") == blob + + def test_a_sentinel_slot_byte_is_not_the_current_course(self): + """The two sentinels in the corpus disagree about this -- WA55's byte + 2 happens to equal its selected course, the WW5000C _B048's does not + (1B against Course_1C). Nothing keys off it: a sentinel is rejected + on its FFFF prefix, and its byte 2 is not an advertised slot either + way.""" + wa55 = "FFFF010049004D004A804C0037F0AC00" + b048 = "FFFF1B0049004D004A804C0035F004F005F0AC00" + assert cloudcourse.slot_of(wa55) is None + assert cloudcourse.slot_of(b048) is None + # Different board widths, same sentinel rule. + assert len(wa55) != len(b048) + def test_undiscovered_counts_against_what_the_device_advertises(self): rep = _load_device("washer_ww5000c_cloud")["/course/vs/0"] store = cloudcourse.CloudCourses() diff --git a/tests/test_golden_regression.py b/tests/test_golden_regression.py index bf84d29..3af2723 100644 --- a/tests/test_golden_regression.py +++ b/tests/test_golden_regression.py @@ -1519,3 +1519,23 @@ def test_registry_reproduces_golden_state_keys_for_washer_ww5000c_cloud(): f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" ) + + +def test_registry_reproduces_golden_state_keys_for_dishwasher_dw5000c_cloud(): + """A DW5000C dishwasher (issues #113/#123) that advertises four + downloaded programs it has never loaded -- CloudExtraCourse_ with no + CloudCourse_/OneTimeCloudCourse_ payload alongside it (issue #342). + + Proves the cloud-cycle machinery is not washer-only and, more usefully, + that a device can advertise programs whose payloads have never been + seen. It adds no entity of its own either way.""" + from tests.conftest import _load_device + + resources = _load_device("dishwasher_dw5000c_cloud") + golden = json.loads((GOLDEN / "dishwasher_dw5000c_cloud.json").read_text()) + state_keys = _new_state_keys("dishwasher_dw5000c_cloud", resources) + assert set(state_keys) == set(golden["state_keys"]), ( + f"state_keys mismatch:\n" + f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n" + f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}" + )