Canon MegaTank maintenance protocol — validated formal spec¶
Validated reference code: src/canon_megatank/protocol/wicreset.py
(cipher), scripts/canon_sr5_cipher.py (mirror encoder),
src/canon_megatank/protocol/servicemode_transport.py (transport),
src/canon_megatank/ops.py (gated session). Proofs:
tests/test_cipher_groundtruth_regression.py,
tests/test_canon_sr5_cipher.py, tests/test_servicemode_transport.py,
tests/test_protocol_model.py (Hypothesis). SSOT:
printers/canon-g6020/maintenance.yaml.
This document specifies the hardware-validated Canon G6020 service-mode absorber-reset protocol. It agrees factually with the field guide (the model-agnostic RE method) and the reference runbook (the end-to-end operator procedure); all three describe the same protocol.
1. Status¶
Validated, hardware-confirmed (2026-06-01). A pure-libusb native clear built
from the protocol below cleared 5B00 (markerWasteInkReceptacleFull) on the
dedicated debug G6020 — no WICReset, no VM, no Wine, no cloud call, no purchased
key. The printer rebooted out of service mode and re-enumerated as the normal
PID 04a9:1865 after a clean power-button shutdown, and IPP reported
printer-state = idle.
The write cipher reproduces WICReset's real captured frames byte-exact (23/23)
through both code mirrors (§4), and the property tests pass offline with no key
or device. The transport (§2) and reset payload (§4) were confirmed on the wire
and supersede the earlier normal-mode usbscan/bulk hypothesis (which ACK'd but
never cleared 5B00).
The shipped SSOT keeps
absorber_reset.status: derived-unvalidatedby repo convention: theverified-capturedpromotion is a manual, per-physical-unit decision gated on a pads-installed validation run with a fresh EEPROM baseline, not an automatic flip from one debug-unit success.--executetherefore still requires the logged one-run--accept-derivedoverride on the shipped SSOT.
2. Transport — EP0 vendor control transfers¶
Service mode is a device-side firmware state entered by a front-panel button
combo (power + resume taps); it cannot be driven over USB. On entry the printer
re-enumerates with a different USB identity: normal 04a9:1865 (6
interfaces) → service 04a9:12fe (single printer-class interface). The
native tool opens the service PID on interface 0, alt 0; EP0 control needs no
bulk-interface claim.
Every maintenance frame is an EP0 VENDOR control transfer, reproducing what
Windows usbprint.sys emits for its VENDOR_SET_COMMAND (IOCTL 0x220038) and
VENDOR_GET_COMMAND (0x22003c) IOCTLs:
| direction | bmRequestType | bRequest | wValue | wIndex | data stage |
|---|---|---|---|---|---|
| SET (host→device) | 0x41 (vendor, interface, OUT) |
frame[0] |
(frame[1]<<8)\|frame[2] |
0x0000 |
the entire frame, verbatim |
| GET (device→host) | 0xC1 (vendor, interface, IN) |
frame[0] |
(frame[1]<<8)\|frame[2] |
0x0000 |
reply of the requested length |
The first three bytes seed bRequest/wValue and remain the first three bytes
of the data stage — the whole InputBuffer is the OUT data with
wLength = len(frame). Never split the header from the payload; earlier
native attempts STALLed (libusb "Pipe error") precisely because they stripped the
prefix. usbprint.sys also caps a control buffer at one page (4096 bytes), so a
large read must be clamped to ≤4096.
ServiceModeTransport.send_and_receive (servicemode_transport.py) routes by
frame shape: a bare 3-byte read header (82 00 00 / 86 00 00) goes
VENDOR_GET IN; any longer (write-shaped) frame goes VENDOR_SET OUT and returns
b''. Reads are SEND-primed, not free-running — a cold bare RECV with nothing
armed times out (errno 110).
Setup builders: vendor_set_setup, vendor_get_setup, get_1284_id_setup.
3. Session — the four-step ordered handshake¶
1. set_session 81 00 00 03 OUT 0x41/0x81 open the service session (enciphered, §4)
2. get_keyword 82 00 00 IN 0xC1/0x82 -> live 3-byte device keyword
3. set_command 85 00 00 | 10 07 7c OUT 0x41/0x85 waste-row SELECTOR (23-byte enciphered frame)
4. set_command 85 00 00 | 0d 00 00 OUT 0x41/0x85 'common' CLEAR ← THE 5B00 WRITE
get_command 86 00 00 IN 0xC1/0x86 read-back (EMPTY by design)
State machine:
┌──────────────┐ set_session(0x81) ┌─────────┐ get_keyword(0x82) ┌────────┐
│ DISCONNECTED │ ──────────────────▶ │ SESSION │ ────────────────▶ │ KEYED │
└──────────────┘ └─────────┘ live keyword → └────────┘
▲ seed encoder │
close │ │ set_command SELECTOR (0x85, 10 07 7c)
+ clean│ power-button ▼
shutdown (commit) ┌──────────┐ set_command ┌──────────┐
┌──────────────┐ get_command(0x86) │ COMMITTED │ CLEAR(0x85, 0d00) │ SELECTED │
│ RE-ENUM 1865 │◀── (empty read) ─── │ (in-RAM) │◀───────────────── │ │
└──────────────┘ └──────────┘ └──────────┘
- Step 2 keyword is the ONLY runtime input. Everything else is statically
derived. The reply is a fresh per-session 3-byte value (e.g.
e4 7c 5a), right-padded to 4 (e4 7c 5a 00) and fed toencoder.seed_keyword. The keyword read MUST precede the writes — it keys them to the live session. - Step 3 SELECTOR (
10 07 7c) addresses thecommonwaste row; step 4 CLEAR (0d 00 00) is the operand that zeroes the absorber counter (the 5B00 write). get_command(0x86) returns EMPTY, and there is no finalize command. This is by design — an ACK is acceptance, not a commit. Do not block, retry, or fail on an empty0x86. Proven bytest_native_reset_empty_get_command_does_not_gate_the_clearandtest_empty_get_command_readback_does_not_fail_the_clear.- Commit is a clean power-button shutdown (not the writes alone, not an
unplug): release the USB handle, then power off with the button so the
printhead parks and the firmware flushes the cleared EEPROM page. An abrupt
unplug skips the flush and 5B00 returns. The op surfaces this verbatim as
COMMIT_INSTRUCTIONinops.py.
4. Write cipher — functor-3 envelope + functor-2 role-swap (23/23 byte-exact)¶
The genuine set_command is enciphered. The decisive correction over earlier
attempts is a buffer-role swap: the functor-2 transform's SUBJECT is the
20-byte functor-3 envelope and its SEED is the 4-byte bound keyword —
not the keyword seeded by the envelope. With that fix the encoder emits all 20
payload bytes and reproduces the captured frames exactly.
app = 85 00 00 || operand (e.g. 85 00 00 10 07 7c)
envelope = envelope3(method=3, app) (20 bytes)
payload = functor2_transform(method, envelope, seed_source=bound_keyword) (20 bytes)
wire = 85 00 00 || payload (23 bytes)
envelope3(wicreset.py) is the deterministic 20-byte functor-3 envelope:[00 12 01 frame[3]]+ 16 fixed MSVC-LCG bytes (seed0x12345678), then the function-block<special>overwrite (env[4+off]:=val) and the<indexes>payload scatter for the block keyed byframe[3]. The operand therefore rides the envelope, so SELECTOR ≠ CLEAR on the wire.functor2_transformwalks the index/codes/shift permutation tables per output position with a keystream byte(seed >> shift) ^ code, whereseed = seed_fold(envelope). It is provably invertible (send vs recv differ only by which buffer indexes the XOR), so the firmware decrypts our ciphertext back to a legitimate command. Keystream derives from a CANON-SR5 schedule.bind_keywordmaps the live padded keyword to the bound SEED. For the captured keyworde4 7c 5a 00it yields00 35 a9 09.- set_session is enciphered the same way; get_keyword/get_command are bare 3-byte READ headers that go on the wire verbatim (the RECV reply is what matters).
Ground-truth anchor — for live keyword e4 7c 5a 00 → bound 00 35 a9 09, both
build_encoder('canon-g6020')/load_method_from_ssot and
scripts/canon_sr5_cipher.encode_command(method_no=3) emit, byte-exact (23/23):
SELECTOR 85 00 00 db bb 00 67 59 a1 b0 1f 84 2f d5 83 04 4a 3a c3 51 d2 b1 ef
CLEAR 85 00 00 4d bb 00 67 59 a1 b0 1f 84 2f d5 83 19 a8 3a 62 7b af b1 ef ← 5B00 write
These equal maintenance.yaml
supported.absorber_reset.derived_sequence.hardware_validated_frames and
WICReset's real captured wire frames. The frames change with the live keyword
(device binding); a fresh seed_keyword(live) re-derives the 20-byte payload per
printer. Pinned by test_cipher_groundtruth_regression.py (both mirrors agree
byte-for-byte) and test_canon_sr5_cipher.py.
5. Template provenance — APP.BIN → devices.xml¶
The per-model command template is bundled, PE-embedded, cloud-independent.
WICReset ships its model DB inside an encrypted APP.BIN container; the offline
decrypt path is: strip footer → 3DES-EDE3-CBC (zero key/IV from empty-string
construction) → strip pad → zlib inflate → devices.xml
(scripts/appbin_decrypt.py). From the decrypted devices.xml
(sha256 6031555f…d86db3) we resolve the G6000-series row (no G6020 literal;
G6020 ∈ G6000 series): <method>3</method>, functor-3, support=query;waste:common
(the family clears only the common absorber, i.e. the 5B00 main). The session
headers, the functor-3 keystream/permutation/shift tables, and the per-code
function blocks all come straight from that decrypted DB and are mirrored into
the SSOT derived_template. The reference encoder builds its SR5Method from the
SSOT (load_method_from_ssot), never from devices.xml at runtime.
6. DRM — licensing-only, off the repair data path¶
By decompile, zero cloud bytes feed the reset payload, the keyword binding, or
the completion test. The vendor cloud has only three roles, none of which
sources device-bound bytes: a key-validation boolean gate (QUERY_KEYS —
authorizes, carries no bytes), an optional device-list refresh (a
network/enabled-gated superset of the always-present embedded DB), and a
post-reset accounting report. The Service Tool's license/anti-tamper checks
likewise gate whether a command runs, not what bytes it sends. The native
tool needs no key and no network: embedded-derived template + local cipher + one
live keyword read.
7. Implementation + tests (validated)¶
| Concern | Code | Tests |
|---|---|---|
| Transport (0x41 OUT / 0xC1 IN, frame-shape routing, 1284-id) | protocol/servicemode_transport.py |
tests/test_servicemode_transport.py |
Write cipher (envelope3, functor2_transform, functor3_encrypt, bind_keyword, build_encoder) |
protocol/wicreset.py |
tests/test_canon_sr5_cipher.py, tests/test_cipher_groundtruth_regression.py |
Cipher mirror / devices.xml reference encoder |
scripts/canon_sr5_cipher.py |
tests/test_cipher_groundtruth_regression.py (both paths agree 23/23) |
| Gated 4-step session + commit | ops.py::reset_absorber_wicreset, COMMIT_INSTRUCTION |
tests/test_cipher_groundtruth_regression.py (native-sequence frames), tests/test_reset.py |
| CLI | main.py::cmd_reset_native (canon-megatank reset-native [--execute --accept-derived]) |
tests/test_cli_reset_native.py |
The --execute gate ladder (UUID isolation → validation status → EEPROM dump →
write budget → lockfile → live-keyword guard) is detailed in the runbook §5.
8. Generic invariants (offline, no key)¶
These are protocol-shape invariants that hold independent of the specific cipher.
They are encoded by src/canon_megatank/protocol/model.py and asserted by
tests/test_protocol_model.py (Hypothesis):
- Round-trip:
decode_frame(encode_send(c,a,p)) == (c,a,p). - Determinism:
encode_sendandderive_reset_frameare pure functions. - Byte order / length:
argserializes big-endian; the header is exactly 3 bytes; a SEND frame is3 + len(payload). - Idempotency:
apply_resetzeroes the counter from any state and is re-applicable (apply_reset(apply_reset(s)) == apply_reset(s)) — re-issuing the clear is safe. - Write-budget monotonicity:
consumedonly grows,remainingonly shrinks,exhaustedlatches at the cap (lockfile gate). - UUID gate: only the locked
test_unitUUID permits a write (uuid_permits_write). - No SSOT drift: model transport constants
==maintenance.yaml.
These remain a useful methodology record and a guard rail; the runnable native reset is the cipher + transport + session of §2–§4, validated on hardware.