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
11 KiB
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 toMODE_WALBRO_BREAK— a different family, different sequence, not traced here. - License/authorization check: compares
MainActivity.Zb(loaded map's associated identifier) againstMainActivity.Lb; mismatch (withi7 > 0) aborts withUNAUTHORIZED_MAPand resets toMODE_NULL. This is TuneECU's commercial licensing gate (paid maps tied to a registered bike) — not a protocol safety check, irrelevant totunie(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 (recallOf = Rf | yf | wf | xf, some ECU-family flag combination) does ak7classification check, then just resets toMODE_NULLand returns — does not proceed to a write at all via this function. Unclear whetherOffamilies 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), callsFc(213)—213=0xD5, the Triumph K-line ECU addresstriumph.pyalready documents. IfG5/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, matchingtunie's ownkline.pydefault. - 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 resetsz.Td = 0. - Computes a UI status code (
i3: 13 for address0x43, 9 for0xD5, 8 otherwise) shown viaMainActivity.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.