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
220 lines
11 KiB
Markdown
220 lines
11 KiB
Markdown
# 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`):
|
||
|
||
```java
|
||
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)`:
|
||
|
||
```java
|
||
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:**
|
||
|
||
```java
|
||
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.
|