Files
samplez/tunie/research/WRITE_PATH.md
uhryniuk 4aa5da53d2 Commit tune maps and research so tunes are reachable from the phone
Removes the *.hex/maps_cache gitignore rule (explicit user call, reversing
the earlier no-redistribution stance) so the official TuneECU catalogue
maps, derived SAI/O2-delete composites, and the checksum/composition
tooling are actually available to pull up on a phone browser when using
the real TuneECU app. Also folds in tonight's KWP2000 fixes (TesterPresent
keep-alive, connect-failure cleanup, slow-init StartCommunication fix) and
the accumulated research docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP2GaxS9HkUdL5sLBnjKje
2026-08-27 01:34:05 -05:00

11 KiB
Raw Blame History

The write/reprogram protocol — traced from the shared routine (Aug 2026)

Started by following the mode flags af() sets (RECOVERY_MODE.md) into the actual write routine both normal reprogram and recovery share. This turned into real protocol-level detail for docs/ROADMAP.md B4 (the future write/flash path) — this is that research, started early because the trace led here naturally, not because B4 is starting now.

Nothing here changes tunie's current posture. safety.py still refuses every service traced below. This is knowledge-gathering for when that changes deliberately, per safety.py's own standing instruction.

The chain, start to finish

fg = true (set by H8's case 12:, RECOVERY_MODE.md) is polled by several per-response-tick handlers in m.java (found at lines 2130, 3436, 5682, 10274 — all structurally identical: if (fg) { sc(false); } in place of the normal se() live-dashboard poll). So entering write mode simply means: stop polling live sensor data, start driving the write sequence instead — same tick loop, different branch, not a separate scheduler.

sc(boolean z) (m.java:9330) — policy gate before any wire activity

  • Walbro ECUs (nf) branch off entirely to MODE_WALBRO_BREAK — a different family, different sequence, not traced here.
  • License/authorization check: compares MainActivity.Zb (loaded map's associated identifier) against MainActivity.Lb; mismatch (with i7 > 0) aborts with UNAUTHORIZED_MAP and resets to MODE_NULL. This is TuneECU's commercial licensing gate (paid maps tied to a registered bike) — not a protocol safety check, irrelevant to tunie (which has no such licensing model), but worth knowing it exists so it's not mistaken for something protocol-relevant if this code is read again later.
  • The Of-family branch (recall Of = Rf | yf | wf | xf, some ECU-family flag combination) does a k7 classification check, then just resets to MODE_NULL and returns — does not proceed to a write at all via this function. Unclear whether Of families use a completely different write path elsewhere, or whether this is a genuine "not supported this way" bail. Not traced further.
  • Otherwise: if not G5/H5 (a device-generation flag pair — plausibly CAN-era vs. K-line-era Triumphs, unconfirmed), calls Fc(213) — 213 = 0xD5, the Triumph K-line ECU address triumph.py already documents. If G5/H5, takes a different path (Jg.D8(false), not traced) — plausibly the CAN-bus-generation equivalent.

Fc(int address) (m.java:1271) — connection/retry driver

  • Resets a batch of protocol-state flags, sets baud to 10400 (Ig.Yb(10400, true)) — the normal K-line fast-init rate, matching tunie's own kline.py default.
  • Retry counter Ed: gives up after 5 attempts with one of three error dialogs (ERR_FAILED / ERR_BAD_DEVICE / ERR_NO_ECU) depending on which flags are set, and resets z.Td = 0.
  • Computes a UI status code (i3: 13 for address 0x43, 9 for 0xD5, 8 otherwise) shown via MainActivity.Hb, then spins up a background thread: new Thread(new a(address)).start() — everything past this point runs off the UI thread.

a / RunnableC0059a (m.java:273) — the actual 5-baud slow-init bit-bang

This is the concrete protocol-level payoff of the trace. After a 100ms sleep, it runs (on the UI thread, via runOnUiThread):

int i2 = (address * 4) + 1025;
int i3 = 0;
byte b = 0;
while (i3 < 11) {
    SystemClock.sleep(200L);
    byte b2 = (byte) (i2 & 1);
    if (b2 != b) {
        z.Zb(b2);       // toggles the K-line
    }
    i2 /= 2;
    i3++;
    b = b2;
}
z.Wd = 25;
z.ec(null, 3, false, false);   // not traced further

This is a standard ISO 14230 5-baud slow-init address transmission, bit-banged in software: 200ms per bit = exactly 5 bps, 11 iterations covering a start bit + address byte + stop bit framing, encoded as (address * 4) + 1025 (for 0xD5: 1877) and shifted out LSB-first via z.Zb() toggling the line directly. This is the same class of sequence tunie's ELM_INIT_SLOW/--init slow already implements for a different trigger (fast-init timeout) — worth comparing bit-for-bit against kline.py's existing slow-init once this gets picked up seriously, since they may already be equivalent or may reveal a discrepancy worth fixing.

z.ec() traced — bottoms out in raw FTDI USB control transfers

z.ec(null, 3, false, false) (z.java:1427) — with bArr=null, the only action is Bd.v((byte)1). Bd (type f.c.a.d, f.c.a package) is TuneECU's own hand-rolled FTDI driver, talking directly to the Android USB Host API (UsbDeviceConnection.controlTransfer etc. — no external SDK, they wrote their own FTDI D2XX-equivalent). Traced v() → w(boolean, boolean):

private boolean w(boolean z, boolean z2) {
    if (z) {
        for (int i = 0; i < 6; i++) {
            controlTransfer(0x40, 0, 1, ...);   // FTDI SIO_RESET, purge RX -- x6
        }
        ...
    }
    return z2 && controlTransfer(0x40, 0, 2, ...) == 0;   // purge TX
}

0x40/bRequest=0/wValue=1|2 is FTDI's standard SIO_RESET_REQUEST with the documented RX-purge/TX-purge sub-codes (matches FTDI's own AN232B-04 application note). v((byte)1) (our call) purges RX only, repeated 6× (defensive retry against a known-flaky USB reset); recovery's earlier v((byte)3) purges both RX and TX. So this step isn't KWP2000 protocol logic at all — it's low-level USB hygiene: clear out whatever garbage may have accumulated on the wire during the slow-init bit-bang before starting to listen for the ECU's response.

Bonus, while in f.c.a.d: found A(byte, byte) (controlTransfer(0x40, 11, ...)) — bRequest=11 is FTDI's SIO_SET_BITMODE_REQUEST, i.e. FTDI bit-bang mode. This is almost certainly what z.Zb() (the line-toggle call inside the 5-baud bit-bang loop in WRITE_PATH.md's earlier section) actually calls into — standard technique for software 5-baud init on FTDI hardware, since normal UART framing can't produce an arbitrary custom baud rate for just the wake-up byte, so the driver drops into raw GPIO bit-bang mode to toggle TXD by hand at the required timing. Worth checking whether tunie's existing kline.py slow-init already does the equivalent, or uses a different technique (e.g. relying on the OS UART driver's own 5-baud support if the FTDI chip/driver combo permits it directly).

The response handler — found it, and the loop closes cleanly

Found what reads the response: z.java's inner Handler class a (z.java:57, the standard Android USB-receive-callback pattern) — handleMessage() dispatches on a message type: 0 resets the he timestamp and calls Sb() (receive setup), 1 is the actual data-received case, logging the bytes then (for non-Walbro ECUs) calling m.Uc(ke, Nd, Ld, Zd) — the core frame parser.

Uc() (m.java:4670) is the ISO14230/KWP2000 response dispatcher, and its very first branch is exactly the slow-init handshake this trace has been building toward:

if (Cd == MODE_NULL) {                 // via the ordinal lookup table
    if (bArr[i] == 0x55) {              // ISO14230 sync byte
        int kb1 = bArr[i+1];
        if (kb1 == 0x08 || kb1 == 0xD9) {   // recognized key byte 1 values
            // ...reset ~20 session/ECU-family flags to false...
            Fd = bArr[i+2] ^ 0xFF;       // complement of key byte 2
            Ue(d.MODE_INIT);             // advance the state machine
        }
    }
}

This is the textbook ISO 14230-2 slow-init handshake, confirmed end-to-end: the ECU replies to the 5-baud wake-up with sync (0x55) + key byte 1 + key byte 2; the tester's required response is the bitwise complement of key byte 2, computed right here (Fd = kb2 ^ 0xFF) and presumably transmitted in whatever MODE_INIT does next (not opened — natural following link, but the interesting part — recognizing and correctly answering the handshake — is now confirmed). Full session flag reset happens at the same moment, consistent with this being a clean restart of connection state.

Uc() is also the general-purpose ongoing frame dispatcher, not just the wake-up handler — later branches (Cd in other modes) pattern-match various header byte sequences (e.g. 0x18 0xDA 0xF1..., the CAN/ISO15765 extended-addressing header for the newer CAN-based Triumphs triumph.py already notes) and route to a wide array of specific handler functions (Ic, Mc, Hc, Xe, bc, cc, Gc, Kc, ...) — meaning one function handles both the K-line (0xD5/0xF5) and CAN-era (0x18DAxx) Triumph protocols in a single cascade, differentiated purely by header pattern.

Where the trace stops

The full initial connection sequence is now traced end-to-end: mode-flag entry → policy gate → connection/retry driver → 5-baud bit-bang wake-up (FTDI bit-bang mode) → USB buffer purge (FTDI reset/purge) → Handler receives bytes → Uc() validates the sync+key-byte response and computes the required handshake reply → state machine advances to MODE_INIT. What MODE_INIT does with that computed complement byte (send it back, await the ECU's own complement-of-tester-address reply, per standard ISO14230 slow init) is the natural next thread, not yet pulled. This closes the loop this trace set out to close — from a UI menu click down to the literal handshake math — everything from here forward is deeper (session establishment past the handshake, then SecurityAccess, then the actual transfer) rather than a missing link in what's already traced.

Why this matters beyond Triumph (cross-pollination, per the original ask)

The Of-family bailout in sc() and the nf (Walbro)/G5/H5 branches all being different code paths from the same entry point reinforces the same lesson RECOVERY_MODE.md already drew from the changelog: the high-level architecture (tick-loop flag-polling, shared write driver, retry-with-backoff, slow-init wake-up) is generic across ECU brands/families, but the specific sequence diverges by family at multiple points along the way (Walbro splits off immediately; Of families don't even reach the wire; G5/H5 skip the slow-init path entirely). A future tunie write path built by generalizing from this trace should expect to need Keihin-specific validation at each of those fork points, not assume the Triumph/Keihin branch generalizes to other ECU families it hasn't been checked against — same caution as before, now with concrete fork points identified instead of just "expect divergence somewhere."

Status

Real protocol detail recovered, genuinely useful for docs/ROADMAP.md B4 when that work starts for real. Still gated on the same prerequisites ROADMAP.md already lists: validate the AES seed/key against a captured real pair, and do this against a spare ECU before the bike's only one. Nothing in this document changes that — it's ahead-of-time reconnaissance, not a green light.