Add tunie: read-only KWP2000 diagnostics for Triumph Keihin ECU

A Python tool to safely read the Keihin ECU on a 2010 Bonneville T100 over
K-Line (KKL cable) or a Bluetooth ELM327, plus the reverse-engineering research
behind it. Phase 1 (read-only comms) of an open tuning toolchain to replace the
closed TuneECU app.

Read-only by construction: safety.assert_read_only() runs on every outbound
request before it hits the wire and refuses all write/flash services (0x27,
0x31, 0x34/0x36, 0x35, 0x37, 0x14, 0x11, 0x2E) and programming sessions, so a
bug cannot brick the ECU. Verified frames match TuneECU byte-for-byte in
tests/verify_protocol.py.

Protocol constants recovered from the TuneECU APK (not guessed): ECU address
0xD5, K-Line tester 0xF5, format byte 0x80|len, additive mod-256 checksum.
Includes the full TuneECU map catalogue (1811 entries) extracted to maps.json,
searchable and filterable by ECU type and mechanical-vs-LCD odometer.

research/ documents the Security Access seed/key algorithm, recovered as
standard AES-128 (three embedded keys), with a self-testing reference impl
verified against FIPS-197. This is write-path material, kept outside the
read-only package.

STATUS.md and README.md capture full context, the risk register, and where we
left off: comms built but not yet run against the bike; next step is wiring the
VAG KKL cable to the Triumph connector and running the first scan.
This commit is contained in:
2026-08-10 14:29:50 -05:00
parent f8904382a3
commit 4c44933b5d
20 changed files with 25326 additions and 0 deletions

245
tunie/STATUS.md Normal file
View File

@@ -0,0 +1,245 @@
# Triumph Bonneville tuning project — status & context
**Bike:** 2010 Triumph Bonneville T100, 865cc air-cooled parallel twin, Keihin
ECU (Renesas SH7054), **mechanical/analog odometer**.
**Goal:** an open-source Python toolchain to read and (eventually) tune the ECU,
replacing the closed TuneECU app. Ultimate hardware path: SAI removal, O2 delete,
airbox removal, full exhaust — each needs a matching recalibration.
**Last updated:** 2026-08-10. Not getting to actual tuning this week — this doc
is the cold-start reference to pick it back up.
---
## TL;DR — where we are
- **Phase 1 (read-only comms) is built, not yet run against the bike.** Nothing
has touched the ECU. No hardware connected yet.
- **Next real step:** wire the cable to the Triumph connector correctly, then run
`tunie info` to read the ECU's identity, current map ID, and fault codes.
- **Blocker:** the wired cable is a *VAG* KKL — right cable type, wrong plug for
this bike. Needs an adapter/re-pin to the Triumph diagnostic connector. This is
the one genuine (electrical) risk and must be confirmed against a wiring
diagram before plugging in.
- **Big win:** the ECU's security-access seed/key algorithm was fully recovered
from the TuneECU APK — it's **AES-128** with three embedded keys. Documented,
reference-implemented, and verified. This is write-path work, quarantined
outside the read-only tool.
---
## Directory map
```
/Users/dylan/dojo/tuner/
tunie/ the read-only Python tool (installed, working)
src/tunie/ package source
tests/ protocol verification (passes)
maps.json 1811 extracted TuneECU maps
README.md tool-level docs
research/ WRITE-PATH reverse engineering (kept OUT of the tool)
keihin_seedkey.py AES-128 seed/key reference impl (self-testing)
FINDINGS.md seed/key writeup
work/ decompiled TuneECU
jadx_out/ Java decompile (read-only, best for reading logic)
apktool_out/ smali decompile (rebuildable)
TuneECU.apk original
TuneECU-logging.apk a clean rebuild (no real instrumentation yet)
samplez/ pristine original TuneECU.apk (git repo)
STATUS.md this file
RESEARCH.md the original (LLM-written, partly wrong) research brief
```
---
## What has been built
### `tunie` — read-only KWP2000 diagnostic tool
Installed and working. Commands:
```sh
tunie ports # list serial devices
tunie info --port /dev/cu.usbserial-XXXX # interrogate ECU (KKL cable)
tunie info --port /dev/cu.OBDII --adapter elm327
tunie dtc --port ... # fault codes only
tunie maps Bonneville --ecu 0 --odometer mechanical
tunie extract-maps <arrays.xml> # rebuild maps.json
tunie -v info --port ... # verbose: log every frame
```
Supports both a wired K-Line KKL cable (`--adapter kline`) and a Bluetooth
ELM327 (`--adapter elm327`) behind one interface.
**Read-only by construction.** `src/tunie/safety.py::assert_read_only()` runs on
every outbound request inside `Transport.request()`, before any byte reaches the
serial port. It allows only an allowlist of query services and refuses all
write/flash services (`0x27` SecurityAccess, `0x31` erase, `0x34`/`0x36` write,
`0x35` upload, `0x37`, `0x14`, `0x11`, `0x2E`) and all programming diagnostic
sessions. A bug cannot brick the ECU because the write path is not implemented.
Module layout:
```
safety.py read-only allowlist, enforced before transmit
kwp2000.py ISO 14230-3 framing, checksums, negative-response decoding
triumph.py Triumph constants recovered from the APK
identify.py the read-only interrogation sweep
maps.py TuneECU map catalogue extraction + search
cli.py command-line entry point
transport/
base.py Transport ABC; routes every request through safety
kline.py FTDI/KKL cable, raw serial, break-condition fast init
elm327.py ELM327 adapter, using TuneECU's own AT init sequence
```
`tests/verify_protocol.py` passes: it asserts our frames are byte-identical to
TuneECU's and that every write service / programming session is blocked.
---
## What was learned (all recovered from the APK, not guessed)
### Protocol facts (source: `com/tuneecu/m.java` + `MainActivity.java`)
| Parameter | Value | Notes |
|---|---|---|
| ECU address | `0xD5` | from `Ld()` framing + `ATSH81D5F5` |
| Tester address (K-Line) | `0xF5` | **not** 0xF1 — that's another brand |
| Format byte | `0x80 \| length` | long form (0x80 + length byte) when >120 B |
| Checksum | additive sum mod 256 | `Ub()` in m.java |
| Init | fast (`ATTP5`) or 5-baud (`ATTP4`+`ATIIAD5`) | address 0xD5 |
| ECU type code | `0`=Keihin, `1`=Sagem, `P`=Bosch | `ecu` string-array |
Reference frames (verified in tests):
```
81 d5 f5 81 cc StartCommunication
82 d5 f5 1a 80 e6 ReadEcuIdentification 0x80
84 d5 f5 18 00 ff 00 65 ReadDtcByStatus
```
> RESEARCH.md guessed target `0x10` / source `0xF1` and a 16-pin OBD-II port.
> Both wrong for this bike.
### Map database
Extracted TuneECU's full catalogue: **1811 entries** in `tunie/maps.json`.
TuneECU's own database distinguishes **"Mechanical odometer"** from **"LCD
odometer"** Bonnevilles — they are not interchangeable, and this is the ECU
generation split. For a 2010 mechanical-odo 865:
- **Stock baselines (`:0:` Keihin, mechanical odo):**
- `20187` production silencers, `20188` aftermarket silencers
- `20191`/`20192` same, up to VIN 739050, E25 fuel
- **Exhaust maps:** `20262`–`20265`, `20313`–`20316` (Arrow 2-in-1 / 2-in-2)
> RESEARCH.md recommended `20498` as an "OEM Arrow, safe rich baseline." It is
> actually a **Thruxton, LCD-odometer** map — wrong model AND wrong ECU
> generation. Do not use it.
### Security Access seed/key — RECOVERED, it's AES-128
Source: `com/tuneecu/m.java` method `Vb()`. Full writeup in
`research/FINDINGS.md`; working reference in `research/keihin_seedkey.py`
(`python3 research/keihin_seedkey.py` → all self-tests pass).
- The algorithm is **standard AES-128** (verified: all 256 T-table entries match
AES Te0; reference passes the FIPS-197 known-answer vector). It is **not** the
Honda XOR/bit-shift scheme RESEARCH.md predicted.
- `Vb()` AES-encrypts a 128-bit seed block with one of **three** embedded keys
(`iArr4`, m.java:5351), selected by `MainActivity.h7 ∈ {0,1,2}`, and returns
the first ciphertext word as the 4-byte key (`Gd()` sends `27 02 <key>`).
- The three keys (little-endian):
```
h7=0: ef704ca051b800cc9287df6a3511a978
h7=1: d4b15ff4c92ab7f098316e7a5b11ac39
h7=2: dc9fdba46f2fad18a4b8e1123c7183c2
```
- **Still open (framing, not crypto):** which h7 applies to the mechanical-odo
865, and the exact seed-block padding (`Ie[1..3]` from the response + fixed
`0x03`/`0x01`; `Ie[0]` set on an earlier path). **One captured (seed, key) pair
resolves both.**
### The logging APK
`work/TuneECU-logging.apk` is a clean apktool rebuild of the original (~17 KB
delta = re-signing/recompression). No custom instrumentation; all `Log` calls are
TuneECU's own, gated behind its debug boolean; no `debuggable` flag. It does not
currently capture seed/key at runtime — but it's the right vehicle for the
validation step (see next steps).
---
## The cable (VAG KKL) — what to do
A "VAG KKL" 409.1 cable is the **correct cable class** (K-Line), but wired for the
**VW/Audi OBD-II socket**, which this bike does not have.
**Step 1 — identify the chip.** Plug into the Mac, run `tunie ports`:
- `/dev/cu.usbserial-XXXX` → FTDI, ideal, no driver.
- `/dev/cu.wchusbserialXXXX` → CH340; works but install the CH340 macOS driver.
- nothing → driver missing.
**Step 2 — adapt the connector (the risk step).** The VAG plug puts K-Line on
OBD-II pin 7, +12V on 16, ground on 4/5. The 2010 Bonneville uses Triumph's
proprietary diagnostic connector (under seat/side panel), not OBD-II. You must
adapt/re-pin K-Line + switched-12V + ground to the correct three Triumph pins.
> **Do not plug in until the Triumph connector pinout is confirmed** against a
> wiring diagram or the known TuneECU-cable wiring. Wrong pin damages the
> transceiver. This is the one risk software can't remove.
---
## Next steps (in order)
1. **Identify the cable chip** — `tunie ports`, install CH340 driver if needed.
2. **Confirm the Triumph diagnostic connector pinout** — service manual / wiring
diagram. Build the adapter (K-Line, switched +12V, ground).
3. **First scan (safe):** ignition on / engine off, `tunie info --port …`.
Yields ECU ID, currently-flashed map ID, DTCs. If fast init times out, try
`--init slow`. This resolves which stock map is actually on the bike.
4. *(Later, Phase 2)* ROM dump for a recovery image — requires deliberately
enabling `0x35` upload; blocked in the read-only tool by design.
5. *(Later, Phase 3)* Checksum patching — not yet investigated; needed before any
modified map can boot.
6. *(Later, Phase 4)* Write path — validate the AES seed/key against a captured
pair first; ideally against a spare ECU, not the bike's only one.
### Optional now: capture a real seed/key pair
Write a minimal smali patch to `work/apktool_out` that logs every KWP frame
(patch the BT send/receive in `d.smali`) to logcat, rebuild, run one real TuneECU
Security Access against the bike, and capture the `27 01` seed + `27 02` key.
Then check `compute_key(seed, h7)` reproduces it — validates the whole AES port
and pins down h7 + padding.
---
## Risk register
| Area | Status |
|---|---|
| Software bricking | **Eliminated** — write services structurally unreachable in `tunie`. |
| Electrical / wiring | **LIVE** — VAG cable ≠ Triumph connector; confirm pinout before plugging in. |
| Seed/key correctness | Algorithm recovered + AES-verified; which-key + padding need one captured pair. Phase 4 only. |
| Firmware checksum | Not yet investigated. Needed before flashing a modified map. Phase 3. |
| Single ECU, no spare | Read-only-first mandatory; validate any write path against TuneECU before trusting ours. |
---
## Tuning theory (from RESEARCH.md §9 — this part is sound)
- **SAI removal:** flip the software SAI flag or the ECU throws a DTC; SAI air
also corrupts AFR readings on a dyno, so disable it before any fuel tuning.
- **O2 delete / open loop:** removing narrowband O2 + disabling closed-loop lets
you command a richer light-load AFR (~13.5–13.8:1 vs 14.7) — cools the
air-cooled top end and fixes the snatchy off-idle response.
- **Airbox removal & full exhaust:** both raise cylinder fill (VE / scavenging);
fuel tables must be enriched or it runs lean under load. Community airbox maps
derived from `20188` provide pre-calculated enrichment.
XDF definition files (to edit the .bin in TunerPro) are sold by OldSkullTuning
and Tuniverse (~€70) for the SH7054 — a later purchase, only once we're editing.
```