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

220 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.