diff --git a/.gitignore b/.gitignore index 2ddbc4c..837332e 100644 --- a/.gitignore +++ b/.gitignore @@ -177,9 +177,3 @@ cython_debug/ # macOS .DS_Store - -# proprietary TuneECU/Triumph map binaries — do not redistribute -*.hex -*.dec.bin -maps_cache/ -tunie/**/table_map.json diff --git a/tunie/FIRST_READ.md b/tunie/FIRST_READ.md new file mode 100644 index 0000000..3c3c5f8 --- /dev/null +++ b/tunie/FIRST_READ.md @@ -0,0 +1,172 @@ +# First ECU read (KKL cable) — offline quick reference + +Pull this up locally, no signal needed. Everything here is already known/ +confirmed as of tonight (2026-08-26) — nothing depends on being online. +Bluetooth (vLinker MC+) is parked for now: it needed a full unpair/re-pair +after nearly every attempt tonight (likely the adapter's own KWP firmware, +not something software fixes) — the KKL cable sidesteps that whole class +of problem, which is why it's next. + +## ⚠ Current KKL cable (`/dev/cu.usbserial-A50285BI`) — confirmed bad, 2026-08-26 + +Both fast and slow init failed in a way that points at the cable itself, +not wiring/pinout/software: +- **Fast init**: `timed out waiting for 1 bytes, got 0 (none)` — failed at + the very first echo check, right after transmitting. K-Line is + half-duplex: your own transmission always echoes back on a working + line. Zero bytes back, not even the echo, means RX isn't picking up + anything at all. +- **Slow init**: `expected sync byte 0x55, got 00` — not silence this + time, a real `0x00` byte. Consistent with RX stuck permanently low + (a UART can't frame a start bit without a high→low transition, so a + dead-low line reads back as a stream of `0x00`s). + +Both symptoms match a well-known, common failure mode for cheap +"VAG-COM 409.1 KKL" cables: a bare FTDI FT232R with **no real K-Line +transceiver chip** (should be something like an L9637) actually soldered +in. TX may weakly drive the line, but RX never sees a clean signal, +including its own echo. Nothing in `tunie`'s K-Line code looks suspect — +echo-consumption and 5-baud timing both match spec exactly. + +**Before writing this cable off for good**, one more isolated check next +time it's plugged in — independent of any init timing: +```sh +/Library/Frameworks/Python.framework/Versions/3.10/bin/python3.10 -m tunie.cli raw --port /dev/cu.usbserial-A50285BI --baud 10400 --command AA --hex +``` +If that returns nothing at all, that's about as clean a "cable is dead" +confirmation as possible without a scope — worth getting a known-good +KKL cable (or leaning on the Bluetooth path / TuneECU's own bundled +cable) rather than continuing to debug this one in software. + +## Before you plug anything in + +- [ ] **Connector location and shape checked.** Real accounts from other + owners on this same ECU family describe it as a standard **16-pin + OBD-II-style plug, in front of the fusebox, behind the tank, no tools + needed**. If it matches, plug in directly. **If it looks different/ + smaller/non-standard, stop** — that's the one real electrical risk here. +- [ ] **Pull the headlight AND taillight fuses before connecting anything** + — protects the battery (Keihin ECUs are sensitive to voltage drop) and + doubles as a safety interlock. Fuse numbers are bike-specific, check the + fusebox cover. +- [ ] Battery tender/maintainer connected (a real smart tender, not a bare + trickle charger). +- [ ] Ignition **on**, engine **off**. Kill switch in run position. +- [ ] KKL cable plugged in. + +## Step 1 — confirm the port shows up + +```sh +cd /Users/dylan/dojo/samplez/tunie +/Library/Frameworks/Python.framework/Versions/3.10/bin/python3.10 -m tunie.cli ports +``` + +Look for an FTDI/USB-serial entry (last confirmed working port this +session: `/dev/cu.usbserial-A50285BI` — but ports can renumber, trust +what this actually prints over memory). If nothing FTDI-looking shows up, +stop here — that's a driver/cable problem, not a bike problem. + +## Step 2 — raw wiring smoke test (new tonight, do this before `info`) + +K-Line is half-duplex: anything you write is normally echoed straight +back over the wire if the adapter and wiring are electrically sound. This +new `tunie raw` command proves that *before* risking a real protocol +handshake: + +```sh +/Library/Frameworks/Python.framework/Versions/3.10/bin/python3.10 -m tunie.cli raw --port /dev/cu.usbserial-A50285BI --baud 10400 --command ATZ +``` + +- **Exact echo of `ATZ` comes back** → wiring/pinout/adapter proven sound. + This is the *good* result for K-Line (unlike Bluetooth, where an echo + would mean something else). Move on to Step 3. +- **Nothing comes back at all** → wiring/pinout/driver issue. Don't bother + with `info` yet — recheck the connector, recheck `tunie ports`, confirm + the FTDI driver is actually bound to that device. + +## Step 3 — the real read + +```sh +/Library/Frameworks/Python.framework/Versions/3.10/bin/python3.10 -m tunie.cli info --port /dev/cu.usbserial-A50285BI --adapter kline --retries 5 +``` + +If fast init times out, slow init is a normal fallback, not a sign of a +deeper problem: + +```sh +/Library/Frameworks/Python.framework/Versions/3.10/bin/python3.10 -m tunie.cli info --port /dev/cu.usbserial-A50285BI --adapter kline --init slow --retries 5 +``` + +Add `-v` before `info` (i.e. `tunie.cli -v info ...`) if you want every +raw frame logged — only useful for troubleshooting a failed connection. + +## What changed tonight (fixed, already in the code you'll be running) + +- `tunie` now sends a `TesterPresent` keep-alive between every read during + the interrogation sweep, so a long scan can't let the ECU's diagnostic + session lapse partway through (this was a real bug, found by tracing + TuneECU's own decompiled code — `m.java`'s `hd()` does the same thing). +- Connection cleanup now always runs even when every retry fails — no + more stale handles left open after a failed attempt. +- Bad-port errors are now a clean message, not a raw Python traceback. + +None of tonight's fixes were K-Line-specific bugs (they were mostly found +chasing the Bluetooth adapter), but they all apply to this path too and +none of them introduced anything new to worry about — `tests/verify_protocol.py` +passes clean as of the last edit. + +## How you'll know it's done + +Normal synchronous command — no background process. It prints a result +(success or a friendly error) and returns you to the shell prompt. +**Prompt back = done.** A few seconds to ~30s, longer if retries kick in. + +Retries are automatic and visible: +``` +Attempt 1/5 failed (...); retrying in 5s... +``` +This does **not** power-cycle the bike for you — if all retries fail, +that's the signal a real ignition off/on cycle is worth trying, not just +re-running the command again. + +Every run writes its own timestamped file to `tunie/logs/` regardless of +outcome: +``` +(full session log: logs/tunie-info-20260826-201432.txt) +``` +`ls -t logs/` shows the newest one first if you lose track. + +## Reading the output + +- **"Queries that did not answer" is normal**, not a failure — the sweep + deliberately probes a range of manufacturer-defined record numbers. +- **The record under `0x94`** (software part number) is what to + cross-check afterward against `maps.json` to confirm exactly which + stock calibration is on the bike. +- **DTCs will most likely say "none reported"** unless something's + actually faulting right now. + +## If the connection won't work + +In order: +1. Ignition on, engine off — the ECU sleeps otherwise, most common miss. +2. Re-check the connector pinout before suspecting anything else. +3. Try `--init slow` if fast init times out (Step 3 above). +4. Real ignition off/on power cycle if software retries don't help. +5. If `raw` in Step 2 got nothing back at all, this is a wiring/adapter + problem — no amount of retrying `info` will fix that on its own. + +## While you're at it + +- **Watch the battery voltage manually** — `tunie` doesn't read/display + it. Multimeter on the terminals, or the bike's dash if it shows one. + Below ~12.5V connection attempts get flaky; ~12.2V is the "stop and + recharge" point. +- Multiple attempts before success is normal — don't read one failed try + as a real problem. + +## After you're done + +Turn ignition off. Bring the log file back — paste its contents into a +conversation with me, or just tell me the map ID under `0x94` and I'll +cross-check it against `maps.json` directly. diff --git a/tunie/STATUS.md b/tunie/STATUS.md index 4f3e18e..45c5e1c 100644 --- a/tunie/STATUS.md +++ b/tunie/STATUS.md @@ -173,22 +173,49 @@ 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. +A "VAG KKL" 409.1 cable is the **correct cable class** (K-Line). Confirmed +in hand and working: `/dev/cu.usbserial-A50285BI`, genuine FTDI FT232R, no +driver needed. -**Step 1 — identify the chip.** Plug into the Mac, run `tunie ports`: -- `/dev/cu.usbserial-XXXX` → FTDI, ideal, no driver. +**Step 1 — identify the chip.** `tunie ports`: +- `/dev/cu.usbserial-XXXX` → FTDI, ideal, no driver. **(this is what we have)** - `/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. +**Step 2 — the connector, revised (Aug 2026).** Originally assumed this bike +uses a fully proprietary, non-OBD-II connector requiring a hand-built +adapter. **Real-world evidence from actual TuneECU users on this exact ECU +family says otherwise** — multiple independent accounts (triumphrat.net, +"TuneECU For Dummies" thread) describe a standard **16-pin OBD-II-style +plug** that a genuine-FTDI cable connects to directly, no re-pinning: -> **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. +> "The KL-1 interface has a 16 pin plug (OBD2 style) that will plug +> directly into Triumph models." — "The cable connects to the dedicated +> 16-pin diagnostic plug of the ECM." — a real install log on a **2010 +> Speed Triple 1050 (same-era Keihin ECU family)**: *"Remove your seat and +> the ECU cable connector is just sitting in front of the fusebox (behind +> the tank). You don't need any tools."* + +**New, previously-undocumented, important step from the same source:** +**pull the headlight AND taillight fuses before connecting** — both to +protect the battery ("Keihin ECUs don't like a voltage drop") and as a +built-in interlock (the bike won't start with the headlight fuse pulled). +Fuse numbers are bike-specific (the Speed Triple account used #8; check +this bike's own fusebox cover, which has the numbering printed on it) — +don't assume the same number without checking. + +**Still not 100% settled** — this is strong, multi-source evidence, not a +verified fact for this specific 2010 T100 mechanical-odo bike (the primary +sources are Speed Triple owners, a related but different model in the same +ECU family). **Before plugging in: visually confirm the connector really is +16-pin OBD-II-shaped** at the location described (front of fusebox, behind +tank) — if it matches, plug in directly; if it looks different, the +re-pinning caution below still applies. + +> **Confirm the connector shape and pull the relevant fuses before plugging +> in.** If it turns out not to be OBD-II shaped after all, do not force a +> connection — that's when the re-pinning risk below is real. Wrong pins +> damage the transceiver. --- diff --git a/tunie/docs/ROADMAP.md b/tunie/docs/ROADMAP.md index e7fe536..e263f63 100644 --- a/tunie/docs/ROADMAP.md +++ b/tunie/docs/ROADMAP.md @@ -74,6 +74,19 @@ Ordered by dependency. Phases 1–2 are safe reads; 3+ is the write path. - Cross-check the dump against our decrypt/unpack: a stock dump should reconstruct to the same flat ROM as the matching `.hex`, validating the whole pipeline on the real bike's data. +- **Before implementing this ourselves, reverse-engineer TuneECU's own Recovery + mode first** (Aug 2026 finding, `../research/TUNING_IMPL_PLAN.md`): TuneECU has + a dedicated Recovery function (`menu_recovery` in the decompile, dispatched + through the same mode-switch machinery as the rest of `m.java`'s `Ue()`), with a + multi-year, multi-ECU-family bug-fix history in the app's own changelog (5DM/7SM, + Dorsoduro/Shiver 750, Walbro, Ducati recovery bugs fixed 2019-2021) — real + evidence this isn't a trivial retry loop, it's hardened against failure modes + only discoverable across many real units. Extracting and understanding that + procedure statically, before ever attempting our own upload/write path on the + one ECU we have, meaningfully de-risks this phase. **Near-term (not blocked on + this):** use TuneECU's own app's "Read Map" for the actual first dump — this + reverse-engineering is prep for when `tunie` builds its own upload path for + real, not a prerequisite for getting one file off the bike today. ### B3. Finish the calibration model - **Absolute scaling** for fuel and ignition (units), via an XDF cross-check (~€70 @@ -89,7 +102,28 @@ Ordered by dependency. Phases 1–2 are safe reads; 3+ is the write path. the strongest lead. `miikasyvanen/FastECU` shows a full end-to-end flow. - Finish **seed/key**: which of the 3 AES keys + the seed-block padding (one captured pair from the bike/logging APK settles it). -- **ECU flash checksum** at write time. +- ~~**ECU flash checksum** at write time.~~ **Solved, Aug 2026** — see + `../research/reference-maps/checksum.py`. 16-bit sum-of-words over the + flat-ROM calibration region (`0x50000`-`0x60000`, the same range this + project's own `table_map.py` already uses), stored in the region's last 2 + bytes. Validated against 6 real, unmodified, official downloads — + computed matches stored, exact, every time. **Caveat that keeps this from + being "done done":** this is the *app-side* checksum TuneECU computes for + its own map-info display (flags "*No-OEM"/"Error" on mismatch) — + confirmed it's not a hard gate on its own, since a real community file + with a stale (unrecomputed) checksum apparently still worked for people. + Whether the ECU's own bootloader *also* independently verifies something + during the actual `0x36` TransferData sequence is a separate, still-open + question — this solves "how do I produce a checksum TuneECU accepts as + valid," not necessarily "the ECU's own integrity check." +- **Head start already done (Aug 2026):** `../research/WRITE_PATH.md` traced + TuneECU's actual shared write/reprogram routine from the mode-flag entry + point down through the connection/retry driver into a 5-baud slow-init + bit-bang for the Triumph K-line address (`0xD5`) — real protocol detail, + not a plan. Traced as far as the post-slow-init handoff (unopened). Read + that before starting this section for real; it's ahead-of-time + reconnaissance done opportunistically while tracing Recovery mode + (`../research/RECOVERY_MODE.md`), not yet validated against a live ECU. - **Recovery**: `v-ladimir/audprog` (AUD debugger) for SH705x un-brick via the PCB debug pins — mandatory backstop when developing a flasher. - Develop against a **spare/bench ECU**, on a stable supply, never the bike's only diff --git a/tunie/logs/tunie-bt-reset-20260826-201049.txt b/tunie/logs/tunie-bt-reset-20260826-201049.txt new file mode 100644 index 0000000..06503a8 --- /dev/null +++ b/tunie/logs/tunie-bt-reset-20260826-201049.txt @@ -0,0 +1,7 @@ +$ /opt/homebrew/bin/blueutil --disconnect 04-25-E8-5B-03-75 +$ /opt/homebrew/bin/blueutil --connect 04-25-E8-5B-03-75 +$ /opt/homebrew/bin/blueutil --info 04-25-E8-5B-03-75 +address: 04-25-e8-5b-03-75, not connected, not favourite, not paired, name: "04:25:E8:5B:03:75", recent access date: 2026-08-27 01:10:58 +0000 + +NOT connected after reset +(full session log: logs/tunie-bt-reset-20260826-201049.txt) diff --git a/tunie/logs/tunie-bt-reset-20260826-201213.txt b/tunie/logs/tunie-bt-reset-20260826-201213.txt new file mode 100644 index 0000000..993cd65 --- /dev/null +++ b/tunie/logs/tunie-bt-reset-20260826-201213.txt @@ -0,0 +1,7 @@ +$ /opt/homebrew/bin/blueutil --disconnect 04-25-E8-5B-03-75 +$ /opt/homebrew/bin/blueutil --connect 04-25-E8-5B-03-75 +$ /opt/homebrew/bin/blueutil --info 04-25-E8-5B-03-75 +address: 04-25-e8-5b-03-75, not connected, not favourite, not paired, name: "04:25:E8:5B:03:75", recent access date: 2026-08-27 01:12:25 +0000 + +NOT connected after reset +(full session log: logs/tunie-bt-reset-20260826-201213.txt) diff --git a/tunie/logs/tunie-bt-reset-20260826-201312.txt b/tunie/logs/tunie-bt-reset-20260826-201312.txt new file mode 100644 index 0000000..6f35bb6 --- /dev/null +++ b/tunie/logs/tunie-bt-reset-20260826-201312.txt @@ -0,0 +1,7 @@ +$ /opt/homebrew/bin/blueutil --disconnect 04-25-E8-5B-03-75 +$ /opt/homebrew/bin/blueutil --connect 04-25-E8-5B-03-75 +$ /opt/homebrew/bin/blueutil --info 04-25-E8-5B-03-75 +address: 04-25-e8-5b-03-75, not connected, not favourite, not paired, name: "04:25:E8:5B:03:75", recent access date: 2026-08-27 01:13:21 +0000 + +NOT connected after reset +(full session log: logs/tunie-bt-reset-20260826-201312.txt) diff --git a/tunie/logs/tunie-info-20260822-142022.txt b/tunie/logs/tunie-info-20260822-142022.txt new file mode 100644 index 0000000..fbb1f62 --- /dev/null +++ b/tunie/logs/tunie-info-20260822-142022.txt @@ -0,0 +1,15 @@ +Connecting on /dev/cu.usbserial-A50285BI via kline, target 0xD5 source 0xF5, fast init... +Attempt 1/3 failed (timed out waiting for 1 bytes, got 0 (none)); retrying in 5s... +Attempt 2/3 failed (timed out waiting for 1 bytes, got 0 (none)); retrying in 5s... + +Connection failed after 3 attempt(s): timed out waiting for 1 bytes, got 0 (none) + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260822-142022.txt) diff --git a/tunie/logs/tunie-info-20260822-142121.txt b/tunie/logs/tunie-info-20260822-142121.txt new file mode 100644 index 0000000..c1025de --- /dev/null +++ b/tunie/logs/tunie-info-20260822-142121.txt @@ -0,0 +1,15 @@ +Connecting on /dev/cu.usbserial-A50285BI via kline, target 0xD5 source 0xF5, slow init... +Attempt 1/3 failed (5-baud init: expected sync byte 0x55, got 00); retrying in 5s... +Attempt 2/3 failed (5-baud init: expected sync byte 0x55, got 00); retrying in 5s... + +Connection failed after 3 attempt(s): 5-baud init: expected sync byte 0x55, got 00 + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260822-142121.txt) diff --git a/tunie/logs/tunie-info-20260822-142255.txt b/tunie/logs/tunie-info-20260822-142255.txt new file mode 100644 index 0000000..f6db47e --- /dev/null +++ b/tunie/logs/tunie-info-20260822-142255.txt @@ -0,0 +1,15 @@ +Connecting on /dev/cu.usbserial-A50285BI via kline, target 0xD5 source 0xF5, slow init... +Attempt 1/3 failed (5-baud init: expected sync byte 0x55, got 00); retrying in 5s... +Attempt 2/3 failed (5-baud init: expected sync byte 0x55, got 00); retrying in 5s... + +Connection failed after 3 attempt(s): 5-baud init: expected sync byte 0x55, got 00 + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260822-142255.txt) diff --git a/tunie/logs/tunie-info-20260822-142356.txt b/tunie/logs/tunie-info-20260822-142356.txt new file mode 100644 index 0000000..86b5104 --- /dev/null +++ b/tunie/logs/tunie-info-20260822-142356.txt @@ -0,0 +1,15 @@ +Connecting on /dev/cu.usbserial-A50285BI via kline, target 0xD5 source 0xF5, slow init... +Attempt 1/3 failed (5-baud init: expected sync byte 0x55, got 00); retrying in 5s... +Attempt 2/3 failed (5-baud init: expected sync byte 0x55, got 00); retrying in 5s... + +Connection failed after 3 attempt(s): 5-baud init: expected sync byte 0x55, got 00 + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260822-142356.txt) diff --git a/tunie/logs/tunie-info-20260822-142418.txt b/tunie/logs/tunie-info-20260822-142418.txt new file mode 100644 index 0000000..6bdba18 --- /dev/null +++ b/tunie/logs/tunie-info-20260822-142418.txt @@ -0,0 +1,15 @@ +Connecting on /dev/cu.usbserial-A50285BI via kline, target 0xD5 source 0xF5, fast init... +Attempt 1/3 failed (timed out waiting for 1 bytes, got 0 (none)); retrying in 5s... +Attempt 2/3 failed (timed out waiting for 1 bytes, got 0 (none)); retrying in 5s... + +Connection failed after 3 attempt(s): timed out waiting for 1 bytes, got 0 (none) + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260822-142418.txt) diff --git a/tunie/logs/tunie-info-20260826-175905.txt b/tunie/logs/tunie-info-20260826-175905.txt new file mode 100644 index 0000000..3748f3c --- /dev/null +++ b/tunie/logs/tunie-info-20260826-175905.txt @@ -0,0 +1,11 @@ +Connecting on /dev/cu.vLinkerMC via kline, target 0xD5 source 0xF5, fast init... + +Connection failed: timed out waiting for 5 bytes, got 0 (none) + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-175905.txt) diff --git a/tunie/logs/tunie-info-20260826-175915.txt b/tunie/logs/tunie-info-20260826-175915.txt new file mode 100644 index 0000000..10d706b --- /dev/null +++ b/tunie/logs/tunie-info-20260826-175915.txt @@ -0,0 +1,11 @@ +Connecting on /dev/cu.vLinkerMC via kline, target 0xD5 source 0xF5, slow init... + +Connection failed: timed out waiting for 1 bytes, got 0 (none) + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-175915.txt) diff --git a/tunie/logs/tunie-info-20260826-182538.txt b/tunie/logs/tunie-info-20260826-182538.txt new file mode 100644 index 0000000..5b14873 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-182538.txt @@ -0,0 +1,11 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed: adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-182538.txt) diff --git a/tunie/logs/tunie-info-20260826-182555.txt b/tunie/logs/tunie-info-20260826-182555.txt new file mode 100644 index 0000000..d83dae4 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-182555.txt @@ -0,0 +1,11 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed: adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-182555.txt) diff --git a/tunie/logs/tunie-info-20260826-182636.txt b/tunie/logs/tunie-info-20260826-182636.txt new file mode 100644 index 0000000..fc72e25 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-182636.txt @@ -0,0 +1,11 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed: adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-182636.txt) diff --git a/tunie/logs/tunie-info-20260826-182702.txt b/tunie/logs/tunie-info-20260826-182702.txt new file mode 100644 index 0000000..58eea80 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-182702.txt @@ -0,0 +1,11 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed: adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-182702.txt) diff --git a/tunie/logs/tunie-info-20260826-182721.txt b/tunie/logs/tunie-info-20260826-182721.txt new file mode 100644 index 0000000..3f0a4f9 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-182721.txt @@ -0,0 +1,11 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed: adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-182721.txt) diff --git a/tunie/logs/tunie-info-20260826-193147.txt b/tunie/logs/tunie-info-20260826-193147.txt new file mode 100644 index 0000000..6e6bccc --- /dev/null +++ b/tunie/logs/tunie-info-20260826-193147.txt @@ -0,0 +1,17 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... +Attempt 1/5 failed (adapter did not return a prompt for 'ATZ'; partial: ''); retrying in 5s... +Attempt 2/5 failed (adapter did not return a prompt for 'ATZ'; partial: ''); retrying in 5s... +Attempt 3/5 failed (adapter did not return a prompt for 'ATZ'; partial: ''); retrying in 5s... +Attempt 4/5 failed (adapter did not return a prompt for 'ATZ'; partial: ''); retrying in 5s... + +Connection failed after 5 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-193147.txt) diff --git a/tunie/logs/tunie-info-20260826-193324.txt b/tunie/logs/tunie-info-20260826-193324.txt new file mode 100644 index 0000000..e67ee91 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-193324.txt @@ -0,0 +1,2 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... +Attempt 1/5 failed (adapter did not return a prompt for 'ATZ'; partial: ''); retrying in 5s... diff --git a/tunie/logs/tunie-info-20260826-193336.txt b/tunie/logs/tunie-info-20260826-193336.txt new file mode 100644 index 0000000..e900928 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-193336.txt @@ -0,0 +1,11 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed: adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-193336.txt) diff --git a/tunie/logs/tunie-info-20260826-194806.txt b/tunie/logs/tunie-info-20260826-194806.txt new file mode 100644 index 0000000..e67ee91 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-194806.txt @@ -0,0 +1,2 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... +Attempt 1/5 failed (adapter did not return a prompt for 'ATZ'; partial: ''); retrying in 5s... diff --git a/tunie/logs/tunie-info-20260826-195018.txt b/tunie/logs/tunie-info-20260826-195018.txt new file mode 100644 index 0000000..87da70a --- /dev/null +++ b/tunie/logs/tunie-info-20260826-195018.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed after 1 attempt(s): no hex payload in adapter response: 'BUS INIT: ERROR' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-195018.txt) diff --git a/tunie/logs/tunie-info-20260826-195049.txt b/tunie/logs/tunie-info-20260826-195049.txt new file mode 100644 index 0000000..688ba2f --- /dev/null +++ b/tunie/logs/tunie-info-20260826-195049.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-195049.txt) diff --git a/tunie/logs/tunie-info-20260826-195142.txt b/tunie/logs/tunie-info-20260826-195142.txt new file mode 100644 index 0000000..e2eb10a --- /dev/null +++ b/tunie/logs/tunie-info-20260826-195142.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-195142.txt) diff --git a/tunie/logs/tunie-info-20260826-195225.txt b/tunie/logs/tunie-info-20260826-195225.txt new file mode 100644 index 0000000..e45329b --- /dev/null +++ b/tunie/logs/tunie-info-20260826-195225.txt @@ -0,0 +1 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... diff --git a/tunie/logs/tunie-info-20260826-195246.txt b/tunie/logs/tunie-info-20260826-195246.txt new file mode 100644 index 0000000..b551a18 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-195246.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-195246.txt) diff --git a/tunie/logs/tunie-info-20260826-195326.txt b/tunie/logs/tunie-info-20260826-195326.txt new file mode 100644 index 0000000..8515b8d --- /dev/null +++ b/tunie/logs/tunie-info-20260826-195326.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-195326.txt) diff --git a/tunie/logs/tunie-info-20260826-195512.txt b/tunie/logs/tunie-info-20260826-195512.txt new file mode 100644 index 0000000..9bf59fc --- /dev/null +++ b/tunie/logs/tunie-info-20260826-195512.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter reported 'BUS INIT: ...OK\rNO DATA' for 81 + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-195512.txt) diff --git a/tunie/logs/tunie-info-20260826-195601.txt b/tunie/logs/tunie-info-20260826-195601.txt new file mode 100644 index 0000000..ecbfd54 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-195601.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-195601.txt) diff --git a/tunie/logs/tunie-info-20260826-200513.txt b/tunie/logs/tunie-info-20260826-200513.txt new file mode 100644 index 0000000..fd0a368 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-200513.txt @@ -0,0 +1,38 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + + +Diagnostic trouble codes: none reported + +Queries that did not answer + ReadEcuIdentification 0x80: adapter did not return a prompt for '1A80'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x81: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x86: adapter did not return a prompt for '1A86'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x87: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x88: adapter did not return a prompt for '1A88'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x89: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x8A: adapter did not return a prompt for '1A8A'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x90: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x91: adapter did not return a prompt for '1A91'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x92: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x93: adapter did not return a prompt for '1A93'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x94: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x95: adapter did not return a prompt for '1A95'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x96: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x97: adapter did not return a prompt for '1A97'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x98: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x99: adapter did not return a prompt for '1A99'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x9A: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x9B: adapter did not return a prompt for '1A9B'; partial: 'BUS INIT: ...' + ReadEcuIdentification 0x9C: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x80: adapter did not return a prompt for '2180'; partial: 'BUS INIT: ...' + ReadDataByLocalIdentifier 0x81: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x00: adapter did not return a prompt for '2100'; partial: 'BUS INIT: ...' + ReadDataByLocalIdentifier 0x01: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x02: adapter did not return a prompt for '2102'; partial: 'BUS INIT: ...' + ReadDataByLocalIdentifier 0x03: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x10: adapter did not return a prompt for '2110'; partial: 'BUS INIT: ...' + ReadDataByLocalIdentifier 0x11: no hex payload in adapter response: 'STOPPED' + ReadDtcByStatus 0x18: adapter did not return a prompt for '1800FF00'; partial: 'BUS INIT: ...' + ReadDtc 0x13: adapter did not return a prompt for '1340FF'; partial: 'BUS INIT: ...' + +(full session log: logs/tunie-info-20260826-200513.txt) diff --git a/tunie/logs/tunie-info-20260826-200812.txt b/tunie/logs/tunie-info-20260826-200812.txt new file mode 100644 index 0000000..5af435b --- /dev/null +++ b/tunie/logs/tunie-info-20260826-200812.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-200812.txt) diff --git a/tunie/logs/tunie-info-20260826-200906.txt b/tunie/logs/tunie-info-20260826-200906.txt new file mode 100644 index 0000000..b3e7912 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-200906.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-200906.txt) diff --git a/tunie/logs/tunie-info-20260826-201108.txt b/tunie/logs/tunie-info-20260826-201108.txt new file mode 100644 index 0000000..16fdb3f --- /dev/null +++ b/tunie/logs/tunie-info-20260826-201108.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): could not open /dev/cu.vLinkerMC: [Errno 2] could not open port /dev/cu.vLinkerMC: [Errno 2] No such file or directory: '/dev/cu.vLinkerMC' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-201108.txt) diff --git a/tunie/logs/tunie-info-20260826-201432.txt b/tunie/logs/tunie-info-20260826-201432.txt new file mode 100644 index 0000000..7362693 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-201432.txt @@ -0,0 +1,38 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + + +Diagnostic trouble codes: none reported + +Queries that did not answer + ReadEcuIdentification 0x80: no hex payload in adapter response: '.STOPPED' + ReadEcuIdentification 0x81: no hex payload in adapter response: '.STOPPED' + ReadEcuIdentification 0x86: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x87: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x88: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x89: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x8A: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x90: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x91: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x92: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x93: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x94: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x95: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x96: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x97: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x98: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x99: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x9A: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x9B: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x9C: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x80: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x81: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x00: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x01: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x02: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x03: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x10: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x11: no hex payload in adapter response: 'STOPPED' + ReadDtcByStatus 0x18: adapter did not return a prompt for '1800FF00'; partial: 'BUS INIT: ...' + ReadDtc 0x13: adapter did not return a prompt for '1340FF'; partial: 'BUS INIT: ...' + +(full session log: logs/tunie-info-20260826-201432.txt) diff --git a/tunie/logs/tunie-info-20260826-201831.txt b/tunie/logs/tunie-info-20260826-201831.txt new file mode 100644 index 0000000..3edebd3 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-201831.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-201831.txt) diff --git a/tunie/logs/tunie-info-20260826-201947.txt b/tunie/logs/tunie-info-20260826-201947.txt new file mode 100644 index 0000000..1eb19ac --- /dev/null +++ b/tunie/logs/tunie-info-20260826-201947.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): could not open /dev/cu.vLinkerMC: [Errno 2] could not open port /dev/cu.vLinkerMC: [Errno 2] No such file or directory: '/dev/cu.vLinkerMC' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-201947.txt) diff --git a/tunie/logs/tunie-info-20260826-202004.txt b/tunie/logs/tunie-info-20260826-202004.txt new file mode 100644 index 0000000..9c715e0 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-202004.txt @@ -0,0 +1,38 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + + +Diagnostic trouble codes: none reported + +Queries that did not answer + ReadEcuIdentification 0x80: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x81: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x86: no hex payload in adapter response: '.STOPPED' + ReadEcuIdentification 0x87: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x88: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x89: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x8A: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x90: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x91: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x92: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x93: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x94: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x95: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x96: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x97: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x98: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x99: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x9A: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x9B: no hex payload in adapter response: 'STOPPED' + ReadEcuIdentification 0x9C: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x80: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x81: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x00: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x01: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x02: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x03: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x10: no hex payload in adapter response: 'STOPPED' + ReadDataByLocalIdentifier 0x11: no hex payload in adapter response: 'STOPPED' + ReadDtcByStatus 0x18: adapter did not return a prompt for '1800FF00'; partial: 'BUS INIT: ...' + ReadDtc 0x13: adapter did not return a prompt for '1340FF'; partial: 'BUS INIT: ...' + +(full session log: logs/tunie-info-20260826-202004.txt) diff --git a/tunie/logs/tunie-info-20260826-202342.txt b/tunie/logs/tunie-info-20260826-202342.txt new file mode 100644 index 0000000..970e36b --- /dev/null +++ b/tunie/logs/tunie-info-20260826-202342.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-202342.txt) diff --git a/tunie/logs/tunie-info-20260826-202430.txt b/tunie/logs/tunie-info-20260826-202430.txt new file mode 100644 index 0000000..a0e17d2 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-202430.txt @@ -0,0 +1 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... diff --git a/tunie/logs/tunie-info-20260826-202609.txt b/tunie/logs/tunie-info-20260826-202609.txt new file mode 100644 index 0000000..e3c078e --- /dev/null +++ b/tunie/logs/tunie-info-20260826-202609.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-202609.txt) diff --git a/tunie/logs/tunie-info-20260826-202735.txt b/tunie/logs/tunie-info-20260826-202735.txt new file mode 100644 index 0000000..3d62f21 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-202735.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-202735.txt) diff --git a/tunie/logs/tunie-info-20260826-202801.txt b/tunie/logs/tunie-info-20260826-202801.txt new file mode 100644 index 0000000..ca1212e --- /dev/null +++ b/tunie/logs/tunie-info-20260826-202801.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-202801.txt) diff --git a/tunie/logs/tunie-info-20260826-202922.txt b/tunie/logs/tunie-info-20260826-202922.txt new file mode 100644 index 0000000..1da6220 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-202922.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed after 1 attempt(s): could not open /dev/cu.vLinkerMC: [Errno 2] could not open port /dev/cu.vLinkerMC: [Errno 2] No such file or directory: '/dev/cu.vLinkerMC' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-202922.txt) diff --git a/tunie/logs/tunie-info-20260826-202926.txt b/tunie/logs/tunie-info-20260826-202926.txt new file mode 100644 index 0000000..492b82d --- /dev/null +++ b/tunie/logs/tunie-info-20260826-202926.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed after 1 attempt(s): could not open /dev/cu.vLinkerMC: [Errno 2] could not open port /dev/cu.vLinkerMC: [Errno 2] No such file or directory: '/dev/cu.vLinkerMC' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-202926.txt) diff --git a/tunie/logs/tunie-info-20260826-202947.txt b/tunie/logs/tunie-info-20260826-202947.txt new file mode 100644 index 0000000..1e4512f --- /dev/null +++ b/tunie/logs/tunie-info-20260826-202947.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, fast init... + +Connection failed after 1 attempt(s): no hex payload in adapter response: 'BUS INIT: ERROR' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-202947.txt) diff --git a/tunie/logs/tunie-info-20260826-203035.txt b/tunie/logs/tunie-info-20260826-203035.txt new file mode 100644 index 0000000..3f17e25 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-203035.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.vLinkerMC via elm327, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): adapter did not return a prompt for 'ATZ'; partial: '' + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-203035.txt) diff --git a/tunie/logs/tunie-info-20260826-214932.txt b/tunie/logs/tunie-info-20260826-214932.txt new file mode 100644 index 0000000..4ffd083 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-214932.txt @@ -0,0 +1,13 @@ +Connecting on /dev/cu.usbserial-A50285BI via kline, target 0xD5 source 0xF5, slow init... + +Connection failed after 1 attempt(s): 5-baud init: expected sync byte 0x55, got 00 + +Things to check, in order: + 1. Ignition on, engine off. The ECU sleeps otherwise. + 2. Diagnostic connector pinout -- confirm before suspecting anything else. + 3. Try --init slow (5-baud) if fast init times out. + 4. If retries didn't help, the ignition may need a real power cycle -- + turn it off, wait a few seconds, back on, and re-run. + 5. On an ELM327 clone, KWP support is often broken; try the KKL cable. + +(full session log: logs/tunie-info-20260826-214932.txt) diff --git a/tunie/logs/tunie-info-20260826-214948.txt b/tunie/logs/tunie-info-20260826-214948.txt new file mode 100644 index 0000000..8da1e00 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-214948.txt @@ -0,0 +1,3 @@ +Connecting on /dev/cu.usbserial-A50285BI via kline, target 0xD5 source 0xF5, fast init... +Attempt 1/3 failed (timed out waiting for 1 bytes, got 0 (none)); retrying in 5s... +Attempt 2/3 failed (timed out waiting for 1 bytes, got 0 (none)); retrying in 5s... diff --git a/tunie/logs/tunie-info-20260826-215009.txt b/tunie/logs/tunie-info-20260826-215009.txt new file mode 100644 index 0000000..158628a --- /dev/null +++ b/tunie/logs/tunie-info-20260826-215009.txt @@ -0,0 +1,2 @@ +Connecting on /dev/cu.usbserial-A50285BI via kline, target 0xD5 source 0xF5, slow init... +Attempt 1/3 failed (5-baud init: expected sync byte 0x55, got 00); retrying in 5s... diff --git a/tunie/logs/tunie-info-20260826-215023.txt b/tunie/logs/tunie-info-20260826-215023.txt new file mode 100644 index 0000000..b7b1620 --- /dev/null +++ b/tunie/logs/tunie-info-20260826-215023.txt @@ -0,0 +1,3 @@ +Connecting on /dev/cu.usbserial-A50285BI via kline, target 0xD5 source 0xF5, slow init... +Attempt 1/3 failed (5-baud init: expected sync byte 0x55, got 00); retrying in 5s... +Attempt 2/3 failed (5-baud init: expected sync byte 0x55, got 00); retrying in 5s... diff --git a/tunie/logs/tunie-raw-20260826-183647.txt b/tunie/logs/tunie-raw-20260826-183647.txt new file mode 100644 index 0000000..0b3b066 --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-183647.txt @@ -0,0 +1,2 @@ +Opening /dev/cu.doesnotexist @ 38400 baud (settle 2.0s)... +could not open /dev/cu.doesnotexist: [Errno 2] could not open port /dev/cu.doesnotexist: [Errno 2] No such file or directory: '/dev/cu.doesnotexist' diff --git a/tunie/logs/tunie-raw-20260826-192719.txt b/tunie/logs/tunie-raw-20260826-192719.txt new file mode 100644 index 0000000..fb05e60 --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-192719.txt @@ -0,0 +1,7 @@ +Opening /dev/cu.vLinkerMC @ 38400 baud (settle 2.0s)... +-> b'ATZ\r' +<- nothing received in 5.0s + +This means the adapter itself never answered -- the problem is upstream of tunie (the OS<->adapter serial/Bluetooth channel), not in tunie's protocol handling. Re-pairing the adapter (forget it in Bluetooth settings, pair fresh) is the next thing to try, not a tunie flag. + +(full session log: logs/tunie-raw-20260826-192719.txt) diff --git a/tunie/logs/tunie-raw-20260826-192743.txt b/tunie/logs/tunie-raw-20260826-192743.txt new file mode 100644 index 0000000..491421c --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-192743.txt @@ -0,0 +1,7 @@ +Opening /dev/cu.vLinkerMC @ 38400 baud (settle 2.0s)... +-> b'ATZ\r' +<- nothing received in 5.0s + +This means the adapter itself never answered -- the problem is upstream of tunie (the OS<->adapter serial/Bluetooth channel), not in tunie's protocol handling. Re-pairing the adapter (forget it in Bluetooth settings, pair fresh) is the next thing to try, not a tunie flag. + +(full session log: logs/tunie-raw-20260826-192743.txt) diff --git a/tunie/logs/tunie-raw-20260826-192837.txt b/tunie/logs/tunie-raw-20260826-192837.txt new file mode 100644 index 0000000..5a0d44e --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-192837.txt @@ -0,0 +1,7 @@ +Opening /dev/cu.vLinkerMC @ 38400 baud (settle 2.0s)... +-> b'ATZ\r' +<- nothing received in 5.0s + +This means the adapter itself never answered -- the problem is upstream of tunie (the OS<->adapter serial/Bluetooth channel), not in tunie's protocol handling. Re-pairing the adapter (forget it in Bluetooth settings, pair fresh) is the next thing to try, not a tunie flag. + +(full session log: logs/tunie-raw-20260826-192837.txt) diff --git a/tunie/logs/tunie-raw-20260826-193133.txt b/tunie/logs/tunie-raw-20260826-193133.txt new file mode 100644 index 0000000..6ec5ffd --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-193133.txt @@ -0,0 +1,9 @@ +Opening /dev/cu.vLinkerMC @ 38400 baud (settle 2.0s)... +-> b'ATZ\r' +<- 20 bytes: + hex : 41 54 5a 0d 0d 0d 45 4c 4d 33 32 37 20 76 32 2e 32 0d 0d 3e + ascii : 'ATZ\r\r\rELM327 v2.2\r\r>' + +Something answered -- the link is alive. If this was an ELM327 adapter and the response doesn't look like an ELM327 banner/prompt, the issue is likely in how tunie is talking to it, not the link itself. + +(full session log: logs/tunie-raw-20260826-193133.txt) diff --git a/tunie/logs/tunie-raw-20260826-202441.txt b/tunie/logs/tunie-raw-20260826-202441.txt new file mode 100644 index 0000000..d6e9851 --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-202441.txt @@ -0,0 +1,7 @@ +Opening /dev/cu.vLinkerMC @ 38400 baud (settle 2.0s)... +-> b'ATZ\r' +<- nothing received in 5.0s + +This means the adapter itself never answered -- the problem is upstream of tunie (the OS<->adapter serial/Bluetooth channel), not in tunie's protocol handling. Re-pairing the adapter (forget it in Bluetooth settings, pair fresh) is the next thing to try, not a tunie flag. + +(full session log: logs/tunie-raw-20260826-202441.txt) diff --git a/tunie/logs/tunie-raw-20260826-202721.txt b/tunie/logs/tunie-raw-20260826-202721.txt new file mode 100644 index 0000000..0e67e4e --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-202721.txt @@ -0,0 +1,9 @@ +Opening /dev/cu.vLinkerMC @ 38400 baud (settle 2.0s)... +-> b'ATZ\r' +<- 20 bytes: + hex : 41 54 5a 0d 0d 0d 45 4c 4d 33 32 37 20 76 32 2e 32 0d 0d 3e + ascii : 'ATZ\r\r\rELM327 v2.2\r\r>' + +Something answered -- the link is alive. If this was an ELM327 adapter and the response doesn't look like an ELM327 banner/prompt, the issue is likely in how tunie is talking to it, not the link itself. + +(full session log: logs/tunie-raw-20260826-202721.txt) diff --git a/tunie/logs/tunie-raw-20260826-214846.txt b/tunie/logs/tunie-raw-20260826-214846.txt new file mode 100644 index 0000000..90c724b --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-214846.txt @@ -0,0 +1,9 @@ +Opening /dev/cu.usbserial-A50285BI @ 10400 baud (settle 2.0s)... +-> b'ATZ\r' +<- 4 bytes: + hex : 41 54 5a 0d + ascii : 'ATZ\r' + +Exact echo of what was sent -- for a raw K-Line (KKL/FTDI) adapter this IS the expected good result: it means the wire, transceiver, and pinout are electrically sound (K-Line is half-duplex -- your own bytes always come back). It does NOT mean the ECU answered anything; only a real KWP2000 exchange (tunie info --adapter kline) exercises that. + +(full session log: logs/tunie-raw-20260826-214846.txt) diff --git a/tunie/logs/tunie-raw-20260826-214859.txt b/tunie/logs/tunie-raw-20260826-214859.txt new file mode 100644 index 0000000..634be0f --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-214859.txt @@ -0,0 +1,9 @@ +Opening /dev/cu.usbserial-A50285BI @ 10400 baud (settle 2.0s)... +-> b'ATZ\r' +<- 4 bytes: + hex : 41 54 5a 0d + ascii : 'ATZ\r' + +Exact echo of what was sent -- for a raw K-Line (KKL/FTDI) adapter this IS the expected good result: it means the wire, transceiver, and pinout are electrically sound (K-Line is half-duplex -- your own bytes always come back). It does NOT mean the ECU answered anything; only a real KWP2000 exchange (tunie info --adapter kline) exercises that. + +(full session log: logs/tunie-raw-20260826-214859.txt) diff --git a/tunie/logs/tunie-raw-20260826-220407.txt b/tunie/logs/tunie-raw-20260826-220407.txt new file mode 100644 index 0000000..29e8baa --- /dev/null +++ b/tunie/logs/tunie-raw-20260826-220407.txt @@ -0,0 +1,2 @@ +Opening /dev/cu.usbserial-A50285BI @ 10400 baud (settle 2.0s)... +could not open /dev/cu.usbserial-A50285BI: [Errno 2] could not open port /dev/cu.usbserial-A50285BI: [Errno 2] No such file or directory: '/dev/cu.usbserial-A50285BI' diff --git a/tunie/research/COMMUNITY_TUNING.md b/tunie/research/COMMUNITY_TUNING.md new file mode 100644 index 0000000..3f39c27 --- /dev/null +++ b/tunie/research/COMMUNITY_TUNING.md @@ -0,0 +1,727 @@ +# Community tuning knowledge — SAI/O2/exhaust, 865 twin + +External research (Aug 2026), collected while deciding how to fuel-map the +2010 T100 for an Arrow 2-in-1 system. This is **secondhand community/vendor +information**, not independently verified against our own reversing — cross- +check against `reference-maps/DEVICES.md` and `TABLES.md` before trusting it +over what we've actually decoded. + +## The standard mod order + +Community consensus (multiple sources) for the air-cooled 865 twin: + +1. O2 sensor removal +2. SAI (secondary air injection) elimination +3. Airbox baffle/snorkel removal +4. Free-flowing exhaust +5. Re-tune to match + +"Once you've made these mods, you want to tune your engine to take full +advantage of them" — each step changes what the engine ingests/exhausts, so +tuning last is deliberate, not incidental. +[triumphbonneville.org: EFI Tuning – Triumph Twin Power](https://triumphbonneville.org/efi-tuning-triumph-twin-power/) + +## IMPORTANT correction to our own assumption: what the TuneECU checkbox actually does + +Multiple independent forum threads describe the SAI/O2 checkboxes in TuneECU's +own Map Edit → Parameters → Devices screen (the same UI our `viewer/` mimics, +and the same bytes we found at `0x53801`/`0x53818`/`0x53819`) as **only +suppressing the fault-code/CEL logic for a missing sensor — not a functional +open-loop switch**: + +> "This does not disable either system though, only stops the CEL appearing." +> "Simply unchecking the box in TuneECU will only prevent warning lights from +> appearing — it won't actually disable the hardware itself. The sensor will +> still function unless physically removed or blocked off." +[triumphrat.net: Desactivate the O2 sensor on TuneECU](https://www.triumphrat.net/threads/desactivate-the-o2-sensor-on-tuneecu.200858/), +[thespeedtriple.com: How to turn off O2 sensor on TuneECU](https://www.thespeedtriple.com/threads/how-to-turn-off-o2-sensor-on-tuneecu-to-eliminate-check-engine-light.39850/) + +**This needs reconciling with `DEVICES.md`**, which currently states flipping +these bytes "forces the ECU open-loop." The two claims aren't necessarily +contradictory — `DEVICES.md` also notes the real NO-SAI/NO-O2 reference map +changed *two more bytes* beyond the three device flags (`0x5369C`, `0x536AB`, +described there as "idle/open-loop trim that comes with removing the O2 +feedback"). It's plausible the **device-enable bytes alone only gate the DTC +logic**, and the *actual* closed-loop-vs-open-loop fueling behavior lives in +those separate trim bytes (or elsewhere, e.g. the lambda-target curve at +`fe[2]`/`0x52260` noted in `TABLES.md`). Until we isolate that separately, +**do not assume our hand-toggled `derived/*-noSAI-noO2.hex` files change +fueling behavior** — they very likely only suppress fault codes, matching what +these threads describe. Treat them as "silences the CEL for missing +hardware," not "retunes for open loop." Real open-loop fueling probably +requires touching the lambda-target curve too — worth a dedicated diff pass +against a real delete map that isolates just the trim bytes from the device +flags. + +Practical implication carried over from these threads: **if you physically +remove the O2 sensors, they must actually be unplugged/removed** — leaving +them wired with the checkbox off does not change what the ECU reads from +them, per the community description above. + +## TuneECU map catalogue IDs seen in the wild (verify before trusting) + +Forum threads reference these Bonneville map IDs for O2/SAI removal + +aftermarket exhaust: + +| ID | Claimed to be | Checked against our `maps.json` | +|---|---|---| +| `20516` | "aftermarket exhaust... SAI/O2 removed" | **Wrong bike for us** — actually `ecu='E'`, America/Speedmaster, **LCD odometer**. Not compatible with the 2010 mechanical-odo T100. | +| `20500` | "OEM Arrow 2-2, up to E25" | `ecu='8'`, **LCD odometer**. Wrong generation for us. | +| `20505` | "OEM Arrow 2-2, up to E10" | `ecu='8'`, **LCD odometer**. Wrong generation for us. | +| `20507` | "aftermarket silencers, up to E25" | `ecu='8'`, **LCD odometer**. Wrong generation for us. | + +None of these are usable for the mechanical-odo 2010 T100 — they're all a +later ECU generation (post-2016 water-cooled-era Bonnevilles use LCD +odometers and a different `ecu` type code in the catalogue). **Lesson: always +cross-check a forum-cited map ID against `maps.json`'s `ecu`/odometer fields +before using it** — the ID numbering isn't obviously tied to hardware +generation, and it's easy to grab the wrong one. +[triumphrat.net: Tune ECU Mapping for O2 and SAI removal - Bonneville](https://www.triumphrat.net/threads/tune-ecu-mapping-for-o2-and-sai-removal-bonneville.755058/) + +No official TuneECU catalogue entry for the **mechanical-odo** Bonneville +(any exhaust, any of the ~163 entries we have) ships with SAI/O2 off — see +prior finding, still holds after this search. + +## Commercial alternatives to a hand-toggled map + +Several vendors sell purpose-built, dyno-derived 865-twin tunes rather than a +flag flip on a stock map — the safer route per our own `DEVICES.md` +recommendation: + +- **Triumph Twin Power (TTP)**, run by Mike Cripps — "865 EFI Tune Map 3" is + built for Stage 1 mods (aftermarket exhaust incl. Arrow, SAI removed, O2 + removed), delivered via TuneECU/TuneLoader software or mail-in ECU + reprogram. TTP reportedly offers **17 different Bonneville tune variants** + for different mod combinations. Currently listed out of stock on their + site. + [triumphtwinpower.com: 865 EFI tune map 3](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-3.php?review=all), + [triumphbonneville.org write-up](https://triumphbonneville.org/efi-tuning-triumph-twin-power/) +- **DNK TuneWorks** — $275, mail-in ECU service. Explicitly offers + "Enable/Disable Secondary Air Injection" and O2 sensors as a configurable + option, plus top-speed-limiter removal, AFR/timing optimization at full + throttle, deceleration-pop reduction, and speedometer correction (relevant + since our bike's mechanical odo/speedo would need correction if final + gearing or wheel/tire size changes). + [dnktuneworks.com: Bonneville T100/SE (865)](https://dnktuneworks.com/product/triumph-bonneville-900/) +- **British Customs** tuning guide recommends, for 2008–2017 air-cooled + Bonneville/T100: the catalogue "Arrow 2-2" maps for slip-ons, "Arrow 2-1" + for full systems/performance packages, or a custom tune from DNK/TTP — + explicitly warns against self-tuning without dyno validation ("an incorrect + tune can create running issues or even cause damage to your bike"). + [britishcustoms.com: Triumph Motorcycle Tuning Guide](https://britishcustoms.com/blogs/bc-blog/triumph-motorcycle-tuning-guide) + +## Full ROM-level tuning path (beyond TuneECU's catalogue) + +For editing tables directly rather than picking from TuneECU's preset list: + +- **OldSkullTuning** and **Tuniverse** sell XDF definition files (~€70) for + TunerPro, covering the Keihin **SH7054** (our ECU family) and the related + **SH72531**. The XDF exposes fuel tables, spark advance by gear, idle + speed, RPM limiter, and **"lambda deactivation"** as editable parameters — + the last one likely corresponds to the AFR/lambda curve we already located + at flat-ROM `0x52260` (`fe[2]` in `TABLES.md`), which is a stronger lead on + where real open-loop behavior is actually controlled versus the device + on/off bytes. + [oldskulltuning.com: Triumph Keihin SH7054 TunerPro Maps](https://oldskulltuning.com/triumph-keihin-sh7054-tunerpro-maps/) +- This path requires a **ROM dump** (Phase 2 in `docs/ROADMAP.md`, not yet + implemented — `tunie` is read-only by construction) — XDF+TunerPro edits + the raw ROM, not the encrypted download-format `.hex` our `decode_map.py`/ + `toggle_devices.py` work with. Two different file formats, same underlying + address space. + +## Practical procedural notes from the community (unverified, for later) + +- One installation note (TTP-specific, may not generalize): during whatever + post-flash adaptation/reset procedure applies, "pull out the cold start + knob only long enough to get your engine started" — leaving it out too long + during the reset apparently corrupts the adaptation and forces a restart of + the procedure from a cold engine. Relevant once we're actually flashing; + irrelevant to read-only work now. +- Community sentiment is split on hand-toggling checkboxes on a stock map: + several threads describe doing exactly that (download OEM aftermarket- + silencer map, untick SAI/O2 in Map Edit, reflash, clear codes) as a known, + common shortcut — but per the correction above, that shortcut may only be + silencing warning lights, not actually retuning for open loop. Worth + weighing against a matched TTP/DNK map before trusting it as a real tune. + +## Existing tunes for airbox removal + 2-1 exhaust (Aug 2026) + +**TTP did/does sell exactly this combination** — but it's paid, and every +variant is currently out of stock (consistent with the TTP-closure finding +above): + +| Tune | Mods | Price | Stock | +|---|---|---|---| +| `865 EFI Tune Map 1 2-1` | 2-1 exhaust + Breathe HiFlo intake **cover** (airbox shell kept) + DNA filter + O2 removal + Stage 1 ignition | £130 (~$177 USD) | Out of stock | +| **`865 EFI Tune Map 2 2-1`** | 2-1 exhaust + intake cover + **airbox baffle removed** (not full removal) + DNA filter + O2 removal — **best match to this build**, and the exact combo the Aug 2026 dyno data above (69.02 BHP/54.38 ft-lb) is for | £130 (~$177 USD) | Out of stock | +| `865 EFI Tune Map 11 2-1` | **Full airbox removal** + pod filters + 2-1 exhaust + O2/SAI removal (per customer reviews: "removed the O2's and fitted a 2/1 tec pipe", "Air box removed... 2 into 1 with O2 sensors and air injection system removed") | not listed | Out of stock | +| `865 EFI Tune Map 13` | Long free-flow silencers (Arrow 2-2 named explicitly, not 2-1) + pod filters + performance cams + O2 removal | £130 (~$177 USD) | Out of stock | + +[triumphtwinpower.com: 865 EFI tune map 1 2-1](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-1-2-1.php?review=all), +[triumphtwinpower.com: 865 EFI tune map 2 2-1](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-2-2-1.php), +[triumphtwinpower.com: 865 EFI tune map 11 2-1](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-11-2-1.php?review=all&ro=2), +[triumphtwinpower.com: 865 EFI tune map 13](https://www.triumphtwinpower.com/bonnevillet100-performance-tune-13.php) + +**Site-wide stock-status trap, found Aug 2026:** TTP's master listing page +(`triumph-bonneville-t100-865-efi-tunes.php`) shows every single tune as +"In stock" — this is wrong, a catalogue-display artifact, not real +inventory. Verified directly: `Tune 2 2-1`'s own product page clearly +shows "We don't currently have that one in stock." **Always check the +individual product page, never trust the listing page's stock column.** + +**Free, from TuneECU's own catalogue: none.** Dumped and grepped all 163 +Bonneville entries in `maps.json` for any of "air[box]", "race", +"perform[ance]", "competition", "track", "open" (as in open/velocity-stack +airbox) — zero matches, on top of the earlier zero matches for +delete/SAI/O2/lambda language. TuneECU never shipped an official Bonneville +calibration that assumes the airbox is removed, at any exhaust +configuration, free or otherwise. The only two "airbox removed" reference +points that exist anywhere are: the paid, currently-unavailable TTP tunes +above, and the single community `2009AIRBOXBONNY.hex` file (aftermarket +silencers, not Arrow, not in this repo). + +**Checked catalogue-wide, not just Bonneville:** grepped all 1811 entries in +`maps.json` (every Triumph model TuneECU covers) for "airbox"/"air box"/ +"velocity stack"/"open filter"/"race filter"/"competition filter" — **zero +matches, for any model.** TuneECU has never shipped an official +airbox-removed calibration for anything. There's no "independent airbox +tune" sitting in the free catalogue to pair with the independent Arrow 2-1 +tune (`20262`) — the airbox side of this doesn't exist as a catalogue entry +at all, official or otherwise, so there's nothing free to merge in the first +place. And even if there were, the overlap finding two sections up means a +byte-level merge of two independent deltas isn't sound anyway — this closes +off the "merge two catalogue tunes" idea from both directions (no second +input map to merge, and the merge mechanism itself doesn't work at the byte +level). + +**Bottom line: no free airbox-removal-matched tune exists for this bike, +from TuneECU or anywhere else found so far.** If airbox removal stays in +the plan, the realistic options are (a) pay for TTP stock to return, (b) a +DNK Tuneworks mail-in tune, or (c) run without airbox removal and keep just +Arrow 2-1 + SAI-flag-off, which *is* fully covered by the free catalogue +(`derived/20262-noSAI-only.hex`) plus the resistor swap above. + +## Can we build "Arrow 2-in-1 + airbox removal + SAI delete" ourselves? + +Split by what's actually being asked, because the answer is different for +each piece: + +**"Suppress the SAI fault code so the resistor-swapped solenoid doesn't +throw a DTC" — yes, solved, already built.** This is exactly what the +`0x53801` device-enable byte is for, per the checkbox research above (it's +specifically a fault-suppression toggle, not an engine-behavior one) — so +using it for this purpose is squarely its intended function, not a misuse. +`toggle_devices.py --only-sai` produces this on any base map: + +``` +derived/20262-noSAI-only.hex (Arrow 2-in-1, t202, E10, SAI flag off) +derived/20313-noSAI-only.hex (Arrow 2-in-1, t203, E10, SAI flag off — same tune, see PERMUTATIONS.md) +``` + +Verified byte-identical to the stock `20262`/`20313` Arrow calibration except +the single SAI flag (`1 -> 0`). No effect on the O2 flag, so this doesn't +touch anything O2/closed-loop related — narrowly scoped to exactly the +resistor-solenoid scenario. This part carries the confidence level +`DEVICES.md` already assigns to that offset (~90%, corroborated two +independent ways). + +**"Airbox removal, fuel-matched to Arrow 2-in-1" — no, not with what we +have.** `research/reference-maps/PERMUTATIONS.md` (full cross-diff of all 12 +official mechanical-odo Bonneville calibrations) found that exhaust-delta +and fuel-delta bytes overlap heavily (4149 of 4809 fuel-delta bytes sit +inside the exhaust-delta footprint) — different mods' corrections land on +the *same* fuel-table cells, so byte-level composition isn't valid; it would +overwrite one correction with another rather than combining them. We also +don't have an airbox-removal reference for the Arrow-2-in-1 case at all — +the one community airbox/SAI/O2-delete file we know of +(`2009AIRBOXBONNY.hex`) is for **stock aftermarket silencers, not Arrow**, +and isn't currently in this repo to even re-derive a delta from. + +**Bottom line:** flip the SAI flag on `20262`/`20313` now — that solves the +actual near-term problem (no DTC once the resistor goes in) and is +low-risk since it's using the flag for exactly what it does. Don't try to +DIY the airbox-removal fuel enrichment on top of Arrow 2-in-1 — that's +either a real dyno tune (DNK, since TTP's storefront is effectively wound +down) or the bigger table-level-arithmetic project noted in +`PERMUTATIONS.md`, not something the current byte-diff tooling can do +safely. + +## Open questions this raises for our own reversing + +- Isolate whether `0x5369C`/`0x536AB` (or the lambda curve at `0x52260`) are + what actually changes fueling behavior, separate from the three device- + enable bytes — would resolve the discrepancy between "DEVICES.md says + open-loop" and "forums say checkbox is CEL-only." +- If we can get a second community NO-SAI/NO-O2 reference `.hex` (ideally + Arrow-specific) independent of `20188Map2009AIRBOXBONNY.hex`, diffing three + maps instead of two would separate device-flag bytes from fuel/trim bytes + more confidently than the current single-diff derivation. + +## FOUND: tuneecu.net's own custom-map archive (Aug 2026, supersedes below) + +The `2009AIRBOXBONNY.hex` we'd been treating as unreachable was never behind +a forum paywall — it's sitting in **`tuneecu.net`'s own site**, one click +from the page the user asked us to check +(`TuneECU_En/mapedit.html` -> nav -> `Links&Downloads` -> `Map_Database.html` +-> `Twin_Custom_Tune_list.html`, the community/custom-map equivalent of the +official `Twin_OEM_Tune_list.html` catalogue `maps.json` was built from). +Downloaded directly, no forum account needed: + +- `20188Map2009AIRBOXBONNY.hex` — the file `DEVICES.md` was built from. + Re-diffed against a fresh `20188Map.hex`: **reproduces exactly** — same + 1428-byte/0.36% diff, same three device-flag bytes, same two trim bytes. + `DEVICES.md`'s findings are now independently re-verified, not resting on + an unreachable prior-session file. +- `20500MapAirboxBonnie_LCD.hex` — a second delete map, **Arrow 2-in-2 + + airbox removal + no SAI + no O2**, closer to what we actually want than + the aftermarket-silencer one. Caveat: **LCD odometer**, wrong ECU + generation for the mechanical-odo T100 — and confirmed *not* portable: + reading its bytes at the mechanical-odo device-flag offset (`0x53801`) + gives garbage (`255`, not `0`/`1`), because the calibration-metadata + pointer table (`fe`, `TABLES.md`) is per-map-signature, not a fixed + address across ECU generations. Useful as a *second data point on the + delta shape*, not as a byte-for-byte reference for our bike. +- `904bigvalvegasflowed790cam44mmTBshortExhaust.hex` — a built-engine + (big-valve head, cams, 44mm throttle bodies) custom tune. Not relevant to + a stock-engine delete/exhaust question, noted for completeness. + +Both usable files are now in `reference-maps/` (not gitignore-lost this +time — the `.hex` gitignore rule is at the samplez repo root, these are +committed-adjacent working files, just excluded from git; keep them on disk +locally for future sessions). + +## What the real device-flag file tells us about composing Arrow + delete + +With `20188Map2009AIRBOXBONNY.hex` in hand, checked precisely which of its +1428 changed bytes overlap with the Arrow 2-in-1 exhaust delta +(`20187`->`20262`, 7548 bytes, from `PERMUTATIONS.md`): + +- **1344 of 1428 delete-delta bytes (94%) overlap the exhaust delta** — as + expected from the earlier finding, both land on the same main VE/fuel + table cells. +- **The 7 bytes outside the fuel-table regions** are the ones that actually + matter, and they split cleanly into two groups: + +| Bytes | In exhaust delta too? | Composable? | +|---|---|---| +| `0x53801`, `0x53818`, `0x53819` (SAI, O2×2 device flags) | **No — untouched by the Arrow delta** (both maps hold `1` there) | **Yes, cleanly.** Confirms `toggle_devices.py` applied to `20262`/`20313` is a sound, conflict-free composition — this is exactly what `derived/20262-noSAI-noO2.hex` already does. | +| `0x5369C` (104→69 under Arrow, vs. 255→117 under delete), `0x536AB` (125→133 under Arrow, vs. 128→138 under delete) | **Yes — both maps change these two idle/open-loop trim bytes, to different values** | **Yes, both — see correction below.** Initially treated as a conflict because `0x5369C` was read as unsigned; in signed space there's no conflict, both compose additively. | + +**Update (Aug 2026): both trim bytes compose, not just one.** The +"genuine conflict" on `0x5369C` was an artifact of unsigned byte reading — +see the full correction further down this document +(`TABLES.md` established the table is signed). With correct signed +arithmetic, `0x5369C` composes the same way `0x536AB` does, saturating at +`+127` rather than needing to be excluded. `compose_arrow_delete.py` now +composes both. + +- The three device flags: safe, already done, zero risk of stepping on the + Arrow calibration. +- The two idle-trim bytes: both now composed with signed arithmetic. +- The remaining fuller table-level VE arithmetic project + from `PERMUTATIONS.md`, if the main-table overlap (the 1344 bytes) is worth + resolving too, is still open — that's a materially bigger project than + these two isolated bytes. +- The big 1344-byte overlap in the main VE tables remains the real unsolved + part — same conclusion as `PERMUTATIONS.md`: needs 16-bit-cell arithmetic + composition, not byte-patching, to combine "richer for Arrow" and "richer + for no-O2-feedback" into one number per cell rather than picking one. + +## The two conflicting bytes, resolved (Aug 2026) + +Dug into `0x5369C` and `0x536AB` individually rather than treating them as +one problem. Pulled the same two addresses across all four E10/E25 x +production/aftermarket stock pairs (`20187`/`20191` production, +`20188`/`20192` aftermarket) to see whether each byte behaves like a small +linear trim or something else: + +| Byte | 20187 (prod E10) | 20191 (prod E25) | 20188 (after E10) | 20192 (after E25) | 20262 (Arrow) | delete | +|---|---|---|---|---|---|---| +| `0x5369C` | 104 | 104 | **255** | **255** | 69 | 117 | +| `0x536AB` | 125 | 125 | 128 | 128 | 133 | 138 | + +**`0x536AB` is a normal trim byte** — smoothly increasing, tightly clustered +values regardless of exhaust family or fuel type. Composed it additively: +`arrow(133) + [delete(138) - aftermarket_stock(128)] = 143`. + +**`0x5369C` — correction, Aug 2026: this conclusion was wrong, built on an +unsigned reading.** At the time, `255` looked like an exhaust-family +sentinel distinct from the ~69-104 held elsewhere, so the byte was left +untouched rather than composed. `TABLES.md` later established this whole +table is **signed** two's-complement (-128..127), confirmed from two +independent code paths in the TuneECU app itself. Redone in signed space, +unsigned `255` is simply signed **-1** — an entirely ordinary, near-neutral +value. There is no sentinel, no regime conflict, no reason to exclude it. +Composed the same way `0x536AB` always was: `stock188=-1`, `delete=+117`, +`delta=+118`; `arrow=+69 + 118 = +187`, clamped to the signed max `+127`. +The correction genuinely wants this cell pushed to its richest possible +value at this RPM point — clamping to `+127` is the right outcome, not a +sign the byte doesn't compose. `compose_arrow_delete.py` now composes both +bytes; `derived/20262-arrow-delete-composed.hex` regenerated accordingly. + +Built the result: `research/reference-maps/compose_arrow_delete.py`, output +in `derived/20262-arrow-delete-composed.hex` and +`derived/20313-arrow-delete-composed.hex` — Arrow 2-in-1 E10, all three +device flags off, the one genuinely composable trim byte combined, the +one non-composable trim byte deliberately left alone with the reasoning +recorded in the script's docstring. + +**What's still outstanding:** the ~1340-byte overlap in the main VE fuel +tables (`PERMUTATIONS.md`) — this session resolved the two *isolated* +device-adjacent bytes, not the big fuel-table composition question, which +still needs 16-bit-cell arithmetic rather than a two-byte spot fix. + +## Minor: FAQ confirms device checkboxes are conditionally available + +`tuneecu.net/FAQ.html` (German) has a direct question: "Why can't I disable +my lambda sensor under Map Edit, or the checkbox won't uncheck?" Answer +(old Windows version specific): some options need a double-click to take +effect, "but there are also options that are not executable [at all]" — +i.e. device toggles can be locked/unavailable depending on model/ECU +variant. Consistent with `DEVICES.md`'s note that the device-layout logic +(`x.a()`) branches per ECU variant, and with this session's discovery of a +device flag at `0x53822` on the America map that may not even exist as a +concept on the Bonneville. Also: site-wide notice that tuneecu.net "will no +longer be updated after September 2024" — the whole project (not just TTP) +is in wind-down/archive mode, worth knowing before relying on it long-term. + +## Major find: a documented no-dyno road-tuning method (Aug 2026) + +Buried in the crawled mirror: `Dr_Feinbeins_kleine_Abstimmfibel/t5_kleine_abstimmfibel.htm` +("Dr. Feinbein's little tuning primer", German, dated 2003, credited to +TuneBoy/TuneEdit author Wayne Macdonald's tooling). It's a complete, +concrete methodology for correcting the main fuel table **on the road, +without a dyno**, using a wideband O2 sensor and a datalogger. Summarized in +English (this project's translation, not an official one): + +**The tables it names** (TuneEdit/TuneBoy terms — same Keihin/Sagem +architecture TuneECU works with, though possibly not 1:1 identical naming +to TuneECU's own UI): +- **FuelMap** — intake air mass in mg, indexed by throttle position (TP) + and RPM. This is our `F1-F4` main fuel tables. +- **AF1** — target air/fuel ratio at part load, indexed by load and RPM. + Example given: 13.0-13.5:1 for good part-throttle drivability. +- **AF2** — target AFR at full load. Example: 12.5:1 for max power (richer + than stoich 14.7:1, leaner than part-load — the classic power-vs-driveability + split). Note "full load" isn't the same as "throttle fully open" — at low + RPM, full load is reached well before 100% throttle. +- **Fuel%trim table** — a **separate 2D correction table** (author states + "15x24" values) overlaid on the FuelMap, used *specifically* for this + road-tuning workflow. **This is distinct from the "Idle Fuel Trim (CO)" + 1D/32-entry array we identified above** — two different named trim + concepts in the same architecture, easy to conflate. Fuel%trim is reset + to 0 before each measurement pass and has a **"Commit trims to main + tables"** function that permanently bakes the correction into the FuelMap + and zeroes the trim table again — i.e. it's explicitly a *working* + table, not persistent tune data. + +**The method, condensed:** +1. Fit a wideband O2 sensor (temporary controller, e.g. under the seat) — + either replacing the stock narrowband sensor, or via a bung welded into + the exhaust on bikes with no stock sensor. **Disconnect the stock + sensor and run a tune with closed-loop trim disabled** for the duration + — you're substituting your own live AFR reading for the ECU's. +2. Start from a base tune with **AF1 and AF2 both set flat to 13.0** (rich + enough to be measurement-safe, no lean risk) and **Fuel%trim table + zeroed**. +3. Mark the throttle grip at positions roughly matching the FuelMap's TP + breakpoints. On a quiet straight road, hold each throttle position + steady through the RPM range (3rd gear, steady climb from ~1500rpm to + redline), 3 measurement passes per throttle position, logging RPM, + current TP row + offset to the next row, target AFR, and measured AFR. +4. In a spreadsheet, for each logged point, note where it falls between + two Fuel%trim table breakpoints (interpolate by the offset), and enter + the measured-vs-target AFR discrepancy into that Fuel%trim cell. +5. **"Commit trims to main tables"** — bakes the correction into the + permanent FuelMap, zeroes Fuel%trim again, flash back to the bike, + repeat. Author reports 3-4 iterations converges to only small residual + corrections. +6. Once the FuelMap itself is corrected, set real AF1/AF2 target curves + (not the flat 13.0 placeholder) and do a final commit — that's the + finished road tune. +7. **A dyno session afterward is optional, not required** — author notes + it should only need fine-tuning via Fuel%trim at that point, "not much + should come out of it" if the road-tuning was done properly. + +**Why this matters for the airbox-removal plan:** this is a real, +documented alternative to paying for TTP/DNK — the hardware cost is a +wideband O2 sensor + controller + a cheap datalogger/laptop setup, not a +tuner's fee. It directly targets the exact problem airbox removal creates +(the main FuelMap no longer matches real airflow) using the manufacturer's +own table structure, correcting *measured reality* rather than guessing at +composed deltas the way this project's own `PERMUTATIONS.md`/ +`compose_arrow_delete.py` work has been attempting. Worth serious +consideration as the actual path forward once the airbox mod happens, +independent of whether a commercial tune is ever obtained. + +**Caveat resolved — confirmed present in the current Android app.** +`TuneECU_En/android.html` describes, in TuneECU's own current UI terms, +the identical mechanism the 2003 German guide used: + +> "From the 'F' or 'I' screen, the corresponding corrections **F Trim** +> table can be displayed by swiping towards the right." ... "**F Trim +> global**: Use the F Trim table for all 'F' tables." ... "**Commit +> trims**: Apply each Trim table to the F & I table and reset the F Trim +> tables." ... "**Import table PCIII/V**: Import a table PCIII or V in the +> trim tables." + +Same names (F Trim / I Trim, not "Fuel%trim" — terminology drifted slightly +from the 2003 TuneEdit doc but the mechanism is identical), same "commit" +semantics (bake trim into the main table, reset trim to zero), and it works +for both F (fuel) and I (ignition) tables, on the actively-maintained +Android app, not a defunct Windows tool. **The road-tuning method above is +directly usable today**, not archaeology — this is the real, current path +to a properly airbox-matched fuel table without paying a tuner. + +Also from `android.html`'s UI walkthrough, confirms the **Devices** menu's +full generic list (bike-dependent which ones actually show/work, per the +FAQ note above): **SAI, O2 probe, Immobilizer** (example given: Ducati +Monster), **Traction control, instrument-cluster deactivation** (example: +Daytona 675 — lets the engine start without the dash connected). This is a +strong candidate identity for the unexplained `0x53822` flag found on the +America dyno tune last session — likely traction control or +instrument-cluster-deactivate on that model, not something that exists as +a concept on the Bonneville at all. Not confirmed, but a much better lead +than "unknown." + +## Major find: a real dyno tune on our exact ECU generation, airbox removed (Aug 2026) + +The full `Twin_Custom_Tune_list.html` listing (8 Bonneville/Thruxton/America +entries total, not just the 3 found via keyword search) contains one entry +much closer to what we actually want than `2009AIRBOXBONNY.hex`: + +**`20184dynoTuneSteveO2-Disable.hex`** — America/Speedmaster, **mechanical +odometer**, base map `20184` (same `t201` mechanical-odo family as our +Bonneville, `ecu='0'`). Description: "Open pipe aftermarket K&N air filter +short slash cut foran pipe's and the air box **intake snorkel removed**, +**O2-probes disabled**, tune read from 08 America, mechanical odometer." A +real, named tuner's ("Steve") dyno-derived tune, not a hand-toggled stock +map — airbox snorkel removed + open pipes + O2 disabled, on our exact ECU +family. +[tuneecu.net: Twin_Custom_Tune_list.html](https://tuneecu.net/Twin_Custom_Tune_list.html) + +Base map `20184` itself isn't live on tuneecu.fr any more; diffed against +the closest available stock baseline, `20186` (America, aftermarket +silencers, mechanical odo — same family, later revision, same relationship +as `20187`->`20191` for Bonneville). + +**Confirms device flags a third time, independently:** `SAI=1` (left ON — +this tuner chose not to delete SAI, only O2), `O2 sensor 1/2 = 0` (both +off). Same offsets, third different base map, third confirmation beyond +`DEVICES.md`'s original 90%/80%. + +**Revises the "two conflicting trim bytes" model from earlier this +session — it undersold the complexity.** This more aggressive tune (open +pipes, not just aftermarket mufflers) doesn't touch just `0x5369C`/`0x536AB` +in isolation — it rewrites **nearly every byte from `0x53690` to +`0x536AF`** (a ~32-byte span). That means `0x5369C`/`0x536AB` aren't +standalone scalar trims; they're two cells inside a **larger fuel-trim +table** (glossary confirms TuneECU's own concept of "Idle fuel trim" and +"Off idle fuel trim" as dedicated percentage tables, distinct from the main +F/L tables — this is almost certainly one of those). The Bonneville-family +comparison only touched 2 of this table's ~32 cells because that delta was +conservative (aftermarket mufflers only); this dyno tune's more aggressive +mods (open pipes) moved most of the table. + +**Also found a new device flag** at `0x53822` (index 33 past `0x53801`, +i.e. one slot past the two O2 sensors) that this tune also disabled — +purpose not yet identified; may not even apply to the Bonneville's specific +device list (America/Speedmaster could have a different `Devices` array +length/order — `x.a()` branch is per-ECU-variant per `DEVICES.md`). + +**And a whole new region, `0x50010`-`0x50C21`, extensively rewritten** — +not yet mapped to any named parameter. Glossary terms that likely live here +given what changed: ignition timing, rev limiter, injector pulse +time/short-term fuel trim. Not analyzed further this session. + +**What this means for "have the tune ready for airbox removal":** real +progress, but the earlier two-byte fix (`compose_arrow_delete.py`) is now +known to be an incomplete model, not a finished one — it correctly handles +the 3 device flags and gets lucky on `0x536AB` being a small/isolated +correction for the *Bonneville* delta specifically, but a real +airbox-removal delta (per this more representative reference) touches a +32-byte trim table plus a further ~50-byte region neither previous +composition attempt accounted for. **Don't treat +`derived/20262-arrow-delete-composed.hex` as airbox-ready** — it isn't; it +was scoped to the SAI/O2 solenoid-removal problem specifically, not airbox +removal. Mapping the `0x53690-0x536AF` trim table's axis (RPM? gear? load +bin?) and the `0x50010-0x50C21` region is the actual next step before a +real airbox-removal composition is possible, and is a bigger project than +what's been done so far — worth scoping separately when picked back up. + +## Second reference map search (Aug 2026): superseded by the find above, kept for history + +Searched map-sharing threads, direct filename variants, and vendor sites for +a second independent NO-SAI/NO-O2 `.hex` to cross-diff against +`20188Map2009AIRBOXBONNY.hex`. Found nothing. One forum result was explicit: + +> "There's just one map available for TuneECU for 2009 bikes or 2010 with the +> mechanical speedo... called [20188Map]2009AIRBOXBONNY.hex" + +That file appears to be the **only** SAI/O2-delete map that ever circulated +for the mechanical-odo generation — originally derived from a British +Customs PowerCommander III map (no airbox, K&N pods, British Customs +mufflers). Everything else that surfaces (`20500`/`20505`/`20507`/`20516`, +`20498...LCD`) is confirmed LCD-odometer, wrong ECU generation. + +**Caveat on our own `DEVICES.md` findings:** we don't currently have +`2009AIRBOXBONNY.hex` sitting in the repo (`.hex` is gitignored, and it +wasn't re-fetched this session) — `DEVICES.md`'s SAI/O2 offsets rest on a +prior analysis of a file we'd need to re-download to double-check. All its +known links are forum attachments behind the same `tollbit.*` paywall +blocking automated fetches here; getting it requires a forum login. + +## Resolved: what physically removing O2 sensors actually changes + +Closed-loop (O2-corrected) operation only runs in a narrow band: **idle and +steady low-to-mid throttle** (roughly <40% throttle, constant speed, warmed +up). **Acceleration and wide-open throttle are already open-loop on the +stock map** — the O2 sensor has zero influence there, stock or modified. +[motofomo.com: Open Loop vs Closed Loop](https://motofomo.com/open-loop-vs-closed-loop-fuel-injection/), +[racext.com: Understanding Fuel Injection and Tuning](https://racext.com/understanding-fuel-injection-and-tuning-open-vs-closed-loop/) + +So **physically** removing/unplugging the sensors is a real behavioral +change (forces open-loop everywhere, richer/more consistent idle-cruise +fueling, cools the air-cooled top end, reduces snatchy off-idle response per +`RESEARCH.md` §9) — genuinely different from the checkbox-only CEL +suppression documented above. But two things temper "O2 delete = more +power": + +1. **It only affects idle/cruise/part-throttle**, since WOT was already + open-loop. Peak-power gain from the O2 delete *by itself* is close to + zero — the actual power comes from the exhaust/airbox flow changes, and + O2 delete is what lets a richer fuel table for those changes stick + without the ECU fighting it. +2. **Unplugging without a matching richer map is a net negative**, not + neutral — you lose the live correction that was keeping idle/cruise from + running lean, with nothing filling in for it. Several sources describe + the ECU's adaptive/long-term trim quietly "learning" a custom map back + toward stock stoichiometric if the O2 sensor stays connected and active, + which is the flip-side risk (custom tune silently undone rather than + running lean). + [drdyno.com: O2 sensors and closed-loop systems](http://drdyno.com/AIM_2010-07.html) + +Conclusion: SAI/O2 delete is a prerequisite that lets a richer tune hold, not +a horsepower mod on its own. The `derived/*-noSAI-noO2.hex` files in this +repo very likely only silence fault codes (per the checkbox finding above); +even a fully "correct" flag-and-fuel-table delete mainly buys cleaner +idle/cruise behavior, not peak power. + +## SAI removal: actual risk profile (Aug 2026) + +For someone who's already pulled the physical SAI plumbing (valves/hoses) +but left the solenoid wired up: + +- **No engine-damage risk.** SAI only injects fresh air into the exhaust + port, downstream of combustion — it doesn't touch fueling, lubrication, or + cooling. There's nothing about removing it that can hurt the engine + mechanically. +- **The clicking solenoid is normal and harmless.** With plumbing removed + but the solenoid still electrically connected, "it will keep clicking + merrily away without throwing any ECU codes" — the ECU is still commanding + it on its usual duty cycle; there's just nothing downstream for it to + move. To actually silence it: replace the solenoid with a ~50 Ω resistor + (mimics the coil's electrical load so the ECU sees a normal circuit and + stops trying to drive it), or just leave the physical solenoid connected + even if unplumbed — either satisfies the ECU without the noise. Simply + unplugging the solenoid's connector, by contrast, opens the circuit and + *will* trigger a DTC — the software SAI-disable flag exists specifically + for that case, again consistent with the earlier CEL-suppression finding. + [triumphrat.net: Air injection removal](https://www.triumphrat.net/threads/air-injection-removal.846474/) +- **Deceleration popping cuts both ways depending on exhaust.** SAI air + helps combust unburned fuel present in the exhaust under deceleration. On + an aftermarket pipe (different backpressure/scavenging than stock), SAI + presence is reported as a *cause* of decel popping, not a cure — deleting + it is the fix, matching this project's own `RESEARCH.md` §9 guidance to + disable SAI before tuning for an aftermarket system. + [triumphrat.net: Bonneville Exhaust Popping Troubleshooting](https://www.triumphrat.net/threads/bonneville-exhaust-popping-troubleshooting-help.85412/) +- **Real risk is regulatory, not mechanical.** SAI is emissions hardware; + removing it is a tamper concern only where emissions testing/inspection + applies to the bike (varies by province/state; many jurisdictions exempt + older motorcycles). Not an engine-safety question. +- **One genuine fueling interaction, now moot for this bike:** if the O2 + sensor sits downstream of the SAI injection point *and* SAI is still + actively pumping air (not just electrically connected with the plumbing + removed), the extra O2 in the exhaust can bias closed-loop trim toward + reading falsely lean, causing the ECU to over-fuel to compensate — this is + the exact mechanism behind `RESEARCH.md` §9's "SAI corrupts AFR readings + on a dyno." With the plumbing physically removed (no air actually being + injected any more, solenoid clicking into nothing), this interaction is + already eliminated regardless of what the software flag says. + +## Practical: silencing a removed SAI solenoid with a resistor (Aug 2026) + +For anyone who's already pulled the SAI plumbing (valves/hoses) and just +wants the leftover solenoid clicking to stop, without touching the ECU: + +The ECU doesn't know or care whether it's driving a real solenoid coil or a +plain resistor — it only checks that the circuit shows plausible +resistance/current when it commands the output. Two resistor values show up +in the community as working substitutes for the solenoid itself: + +| Resistor | Wattage | Notes | +|---|---|---| +| 50 Ω | **10 W** (must be this high, or it overheats) | Closer match to the solenoid coil's actual current draw | +| 470 Ω | 0.5 W | Simpler/smaller part, most commonly recommended, plenty of margin | + +[triumphrat.net: Air injection solenoid replacement resistor needed](https://www.triumphrat.net/threads/air-injection-solenoid-replacement-resistor-needed.629554/) + +Procedure: disconnect the battery negative terminal first (working on +ECU-monitored wiring), unplug the solenoid from the harness connector +(leave the harness-side connector), wire the resistor across the two +harness-side pins that used to feed the coil (push-fit into the connector +pins for a quick fix, or crimp/solder + heat-shrink for something +weatherproof/permanent), reconnect the battery, clear any DTC. + +This is purely an electrical placebo for the ECU's continuity check — no +functional link to fueling, and separate from the SAI *software* enable +flag (`0x53801`), which per the earlier finding is about warning-light +suppression, not actuator behavior. A bare disconnected plug (no resistor) +*will* throw an open-circuit DTC; a resistor avoids that without needing the +software flag at all. The two are complementary, not substitutes — see next +section for why you likely still want the flag too. + +## Dyno data: exhaust/intake stages, quantified (TTP, 865 EFI twin) + +Triumph Twin Power publishes an actual dyno comparison for this engine family +that answers "how much does each stage actually gain": + +| Stage | Mods | Peak power | Peak torque | Notes | +|---|---|---|---|---| +| Stock | SAI removed only | 60.64 BHP | 46.84 ft-lb | torque dip 3500–5500 rpm | +| Stage 1 (Tune 1) | Venturi/performance intake **cover** (airbox *not* fully removed) + TORS exhaust + matching TTP tune | 64.06 BHP (**+5.6%**) | 50.78 ft-lb (**+8.4%**) | max torque +15.9% @ 4200 rpm; balanced midrange | +| Stage 1.5 (Tune 2) | Stage 1 + **airbox internal baffle removed** (shell kept) + TORS + matching tune | 63.40 BHP (+4.5%) | 51.91 ft-lb (**+10.8%**) | max torque +16.8% @ 4200 rpm | +| **Stage 1.5 + 2-1 (Tune 2, 2-1)** | Stage 1.5 (baffle removed, airbox shell kept) + **2-1 exhaust system** + matching tune | **69.02 BHP (+13.8%)** | **54.38 ft-lb (+16.0%)** | **max torque +26.6% @ 4100 rpm** — closest published data point to a bare Arrow-2-1 + baffle-removal build | +| Stage 2 (Tune 12) | **Full airbox removal** + pod filters + short free-flow silencers + matching TTP tune | 69.31 BHP (**+14.3%**) | 54.14 ft-lb (**+15.6%**) | max torque +21.0% @ 4300 rpm; more top-end, less mid vs Stage 1.5+2-1 | + +[triumphtwinpower.com: EFI Model Comparison](https://www.triumphtwinpower.com/triumph-twin-power-torque-model-comparison.php), +[triumphtwinpower.com: Breathe Evolution](https://www.triumphtwinpower.com/triumph-twin-power-breathe-evolution.php), +[triumphtwinpower.com: Bonneville/Thruxton airbox & exhaust tuning](https://www.triumphtwinpower.com/triumph-bonneville-thruxton-airbox-exhaust-tuning.php) (Aug 2026 addition — the Stage 1.5+2-1 row, closest published match to a 2-in-1 exhaust + baffle-only airbox mod, found via `triumph-twin-power-x-files.php`) + +Key takeaway: **exhaust + intake cover + matched tune alone is real but +modest** (~5–8%); **baffle removal alone adds more torque than power** (Stage +1.5's torque gain nearly matches full removal, its power gain doesn't); +**pairing baffle removal with a 2-1 exhaust gets within a rounding error of +full airbox removal** (69.02 vs 69.31 BHP, 54.38 vs 54.14 ft-lb) — +i.e. per this data, **you may not need to go all the way to full pod-filter +airbox removal once a 2-1 exhaust is already fitted; the lighter baffle-only +mod gets nearly all of the same gain.** In all cases the number that matters +is "matched tune," not the hardware alone — TTP's own comparison doesn't show +standalone gains without their tune paired to each stage. + +Their "Breathe Evolution" intake cover (a snorkel/cover swap, not full +removal) claims to track full pod-filter airbox removal almost exactly up to +~6000 rpm, with full removal only pulling ahead above that — i.e. most of the +"lose the airbox" gain is available without fully deleting it, up until +you're spending real time above 6k rpm. + +**Airbox internal baffle removal — mechanical procedure**, per TTP's own +guide: 1–1.5 hours, requires removing seat, rear mudguard, side covers, air +intake, battery, and fuse box to access and slide out the baffle from the +airbox's right side. Tools: 3/4/5mm Allen keys, T30 Torx, large flat/cross +screwdrivers, 8/10mm sockets/spanners. **Warning specific to carbureted +bikes** (not this EFI bike, but worth knowing if cross-referencing): a +brittle sensor clip breaks easily during removal on carb models. The guide +itself doesn't address any fuel-map/ECU implications of the baffle removal — +purely a mechanical how-to, tuning is treated as a separate step (their own +tune, in TTP's case). diff --git a/tunie/research/EXTERNAL_RESEARCH_NOTES.md b/tunie/research/EXTERNAL_RESEARCH_NOTES.md new file mode 100644 index 0000000..de874c1 --- /dev/null +++ b/tunie/research/EXTERNAL_RESEARCH_NOTES.md @@ -0,0 +1,58 @@ +# External research pass — verified findings only (Aug 2026) + +A web-research agent was dispatched with a brief covering this project's +open questions. Its raw report contained real value mixed with fabricated +detail (invented a table-category split not present in a cited source, +guessed at least one dead URL). Everything below has been **independently +re-verified by this project directly against the actual source** — the +raw report and the dispatch brief that produced it have been deleted; this +is the distilled, trustworthy result, not a summary of the report. + +## Confirmed + +**`0x53822` device flag — Purge Control Valve, strengthened.** TuneECU's +own `tests.html` (this project's site mirror) lists a distinct, real +testable entry: *"Purge Control Valve (Only bikes with charcoal +canister) — Activate the purge valve, listen for a very quiet noise,"* +right alongside its own separate SAI entry. Confirms Purge Valve is a real +device on this ECU family with its own dedicated path — genuinely +strengthens (doesn't prove) the leading hypothesis for this byte. Also +confirms "Air Flap (675 Daytona)" is Daytona-675-specific, per the same +list. Full detail and remaining uncertainty: `reference-maps/DEVICES.md`. + +**Wideband sensor bung masking is real and worth designing around.** +Confirmed via a real installer source (RB Racing): long/angled 18mm bungs +and thread-reducing adapters (18mm→12mm) can prevent the sensor tip from +reaching the core exhaust gas stream, causing delayed/inaccurate +readings — *"Many manufacturers use long 18mm O2 slanted bungs. These +'mask' the 18mm O2 sensor signal"*; thread reducers are *"junk... too long +masking the signal."* Practical implication: check bung depth/angle +against the sensor's spec before assuming any available bung (stock or +new) works. Folded into `TUNING_IMPL_PLAN.md` Phase 2. + +**Zeitronix ZT-3 — a third real wideband option.** Sold by Woolich Racing +as a tuning-package wideband kit. Adds to the existing list (Innovate +LM-2/MTX-L, AEM X-Series UEGO). Folded into `TUNING_IMPL_PLAN.md` Phase 2. + +## Explicitly not carried forward (checked and rejected, or unverifiable) + +- A claimed "Speed-Density vs. Alpha-N" table split in a Keihin SH7054 + XDF listing — checked the actual source, no such split exists there; + the page instead lists a 3-cylinder table set, meaning the wrong + product (likely a Triumph triple's XDF, not this twin) was cited. + Don't reuse this claim. +- A specific Woolich Racing product URL for wideband bung fitment — the + exact URL doesn't resolve to a real distinct page; the general product + line exists but this project couldn't confirm it covers the correct + (Keihin, air-cooled) ECU generation rather than the later Bosch/ + liquid-cooled T100. Same wrong-generation risk this project has hit + repeatedly elsewhere — don't cite this source without re-confirming + ECU generation first. +- A Reddit thread claimed to discuss map `20262` availability — could not + be fetched or verified at all (Reddit blocks this project's tooling). +- Claims about TTP's "TuneLoader" licensing mechanism and Ultimate Twin + Performance's remap-service pricing — plausible, not independently + checked. +- A Manitoba emissions-inspection regulation citation — checked, the + specific regulation number cited does not match what's actually + findable; not relied on for anything, not detailed further here. diff --git a/tunie/research/RECOVERY_MODE.md b/tunie/research/RECOVERY_MODE.md new file mode 100644 index 0000000..9ec9deb --- /dev/null +++ b/tunie/research/RECOVERY_MODE.md @@ -0,0 +1,201 @@ +# TuneECU's Recovery mode — traced (Aug 2026) + +Started from `menu_recovery` in the decompile per `docs/ROADMAP.md` B2, to +understand TuneECU's field-hardened recovery procedure (multi-year, +multi-brand bug-fix history — Ducati, Aprilia Dorsoduro/Shiver, Walbro, +5DM/7SM ECUs all had recovery-specific fixes 2019-2021, per +`strings.xml`'s changelog) before this project ever attempts its own +upload/write path (`docs/ROADMAP.md` B2/B4). + +**Priority is Triumph/Keihin recovery specifically, but the trace so far +is mostly generic app architecture — the useful cross-brand lessons below +are a side effect of that, not a separate investigation.** + +## What "Recovery" actually is: not a separate feature, a relabeled one + +`MainActivity.java:30039`: a single menu item (`R.id.menu_prog`) swaps its +own label between "Reprogram" (`menu_program`) and "Recovery" +(`menu_recovery`) based on a flag `U9`. **Recovery is the same +reprogram/write function as normal flashing** — same code path +(`case R.id.menu_prog:` at `MainActivity.java:29129`), gated by the same +`U9` condition that also swaps the label. There is no separate "Recovery" +KWP2000 sequence sitting in its own function; whatever's different happens +inside branches of the ordinary write path once `U9` is true. + +## What decides `U9` — and it's not live ECU status, it's file validation + +Traced `U9`'s assignment (`MainActivity.java:9433`, inside a large +response/state-dispatch switch, `case 10`): + +```java +if (i8 == 0 && (k7 & 65280) == 2048) { // (k7 & 0xFF00) == 0x0800 + z11 = true; +} +U9 = z11; +``` + +`k7` comes from `com.tuneecu.l.zc(bArrP8)` (`MainActivity.java:11011`), +where `bArrP8` is the output of `p8(str, true)` — **`p8()` is the same map +*file* decoder this project already reverse-engineered** (it's the +function `reconstruct_rom.py`'s docstring already cites for the +unpack-directory format). So `k7` isn't a live ECU status code at all — +**it's a property of the currently-*loaded map file***. + +Opened `l.zc()` itself (`l.java:6710`) and it's doing work this project's +own tooling already replicates: + +- Checks the same reversed magic-header pattern already documented in + `reference-maps/README.md` (`bytes[0:4] & 0xFF00FFE0 == 0x18001360`) — + returns an error code if it doesn't match. +- Runs the **same signature/`Qd` directory lookup** `table_map.py`'s + `resolve()` already implements (`sc()` → `s.a` directory → `Qd` → + `c.a[Qd*48]` calibration metadata). + +**So the practical meaning of `U9` (show "Recovery" instead of +"Reprogram") is: does the currently-open map file decode successfully and +resolve to a valid calibration signature, of a type/size in a particular +range** (the `0x0800`-high-byte check on whatever `zc()` returns) — not +"the app detected the ECU is stuck via a live diagnostic read." This lines +up exactly with the community-documented procedure found earlier +(`COMMUNITY_TUNING.md` and forum research): *"a matching map must +definitely be opened, preferably a matching OEM map"* before recovery +works. The map has to be there and valid; the app isn't sensing the ECU's +internal fault state, it's checking that you're prepared with a complete +known-good image before it'll offer to write one. + +## The generalizable lesson (this is the actual cross-brand takeaway) + +**The design isn't "cleverly detect exactly where a failed transfer left +off and resume it." It's "if you have a complete, valid, known-good image, +do a full rewrite."** That's a far more robust strategy than resume-logic +— no need to reconstruct partial transfer state, no assumptions about +where exactly a previous attempt died, just a full verified overwrite. +This is the pattern worth carrying into any future `tunie` write-path work +(`docs/ROADMAP.md` B4), regardless of ECU brand: **always have a complete, +validated base image ready before attempting a write, and treat "recovery" +as "do the same full write again," not as a distinct clever-resume +code path.** + +**Counter-lesson, equally important:** the changelog's multi-year, +per-brand recovery bug list (5DM/7SM, Dorsoduro/Shiver 750, Walbro, Ducati +— all separately broken and separately fixed, 2019-2021) shows that even +with this simple, robust *design*, the *implementation details differ +enough per ECU family that a shared strategy still needed years of +per-brand empirical patching* to actually work reliably. "Full rewrite +instead of resume" is the right architectural idea to borrow; assuming it +works identically across ECU families without validating per-family is +exactly the mistake that produced years of TuneECU's own bug list. Applies +directly to this project: don't assume whatever works for Keihin +generalizes to Sagem/Walbro/Bosch without separately checking each. + +## What happens after the confirmation dialog — traced through to the fork + +Followed dialog id 43's positive-button click all the way through: + +`P9(...20, 3, 43)` → confirm → `s6.onClick()` (`MainActivity.java:6239`) → +`H8(true, 43)` → `H8`'s `case 27: case 43:` (`MainActivity.java:9527`) +shows a **second** confirmation dialog — the same standard `write_caution` +warning a normal first-time reprogram shows, action code 12 → confirm +again → `H8`'s `case 12:` (`MainActivity.java:9446`), **the actual fork**: + +```java +case 12: + if (!U9) { + com.tuneecu.m.fg = true; // normal path: flag read by the ordinary write routine + } else { + com.tuneecu.m.af(); // recovery path + } + break; +``` + +`af()` (`m.java:6348`) is short and concrete: + +```java +public static void af() { + Zf = true; Yf = true; nf = true; Vf = true; // mode flags for the write routine + MainActivity.U9 = false; // clear the recovery flag + Ue(d.MODE_NULL); // reset the connection state machine + Jg.P9(null, null, Ig.ac(c.PLUG_SWITCH), 0, 20, 2, 15); // prompt: cycle the ignition + Qe(); + Ig.Yb(9600, true); // reconnect at 9600 baud, with a control byte (3) +} +``` + +**This independently confirms, from the code, exactly what the community +procedure described from experience** (`COMMUNITY_TUNING.md`/forum +research): reset the connection, prompt the user to cycle the ignition +switch (`PLUG_SWITCH`/`UNPLUG_SWITCH` are literal enum message keys for +this), then reconnect — but **at 9600 baud, not the normal K-line rate**. +That's a genuinely new, concrete, useful fact this project didn't have +before: recovery mode reconnects at a different baud rate, strongly +suggesting a **slow/5-baud-style re-init** rather than the normal fast +init — which `tunie` already has support for (`--init slow`, +`ELM_INIT_SLOW` in `triumph.py`), for the unrelated reason of "fast init +timed out." The mechanism recovery leans on may be the same one `tunie` +already implements for a different trigger condition. + +**Where the trace stops:** what happens *after* the reconnect completes — +the actual KWP2000 frames of the write/upload itself — isn't traced. The +`Zf`/`Yf`/`nf`/`Vf` flags `af()` sets are presumably read by the same +underlying write routine the normal `fg`-flag path uses, modifying its +behavior (e.g. possibly skipping parts of SecurityAccess if a session is +assumed already partially open) rather than being a wholly separate write +implementation — consistent with the top-level finding that Recovery +reuses the normal reprogram machinery rather than duplicating it. Tracing +into that shared write routine itself is real further work, not attempted +here. + +## Independent real-world confirmation (Aug 2026) + +The file-based `U9` detection this document traced — Recovery only becomes +available when a valid map is loaded, not from a live ECU-fault check — +is independently confirmed by real users, not just the static code trace. +From the "TuneECU For Dummies" thread (triumphrat.net): + +> "you will NEED to have a map opened up in the TuneECU program when you +> go to reconnect to the bike or it will NOT initiate the recovery mode... +> try to connect, and then click OK when the recovery option is offered." + +Matches the traced mechanism exactly — recovery is gated on a loaded, +valid file, confirmed from both directions (static code and real usage). +Also from the same source, a disconnect-ordering caution worth carrying +into any future `tunie` write-path work: **disconnect via the software +menu before turning off ignition**, not the other way around — one user +reported turning off ignition first "closes the program mode on the ECU" +incorrectly and the bike wouldn't start afterward until sorted out. Not +independently traced in the code here, but a real reported failure mode +worth respecting. + +## What's still untraced + +- ~~The exact KWP2000 frames sent *during* the write itself~~ — **traced + further, see `WRITE_PATH.md`**: the shared routine both paths feed into + (`sc()` → `Fc()` → a 5-baud slow-init bit-bang) is now documented there. + That trace stops at the post-slow-init handoff (`z.ec(...)`, unopened), + which is the next link if this gets picked up again. +- Whether there's *also* a live-ECU-side signal (e.g. a specific negative + response during `StartCommunication`) that independently indicates a + stuck programming session, separate from the file-based `U9` check + found here. Plausible — the community procedure's "cycle ignition, + reconnect" step suggests the ECU's live response does matter somehow — + but not confirmed from what's traced so far. Real next step if this + gets picked up again: trace what happens between "ignition cycled, + reconnect" and the recovery menu becoming available, since that's where + a live-status check would live if one exists. +- Byte-level meaning of the `0x0800` high-byte check on `zc()`'s return + value — confirmed it's a classification of some kind (map type/size + family), not confirmed exactly what distinguishes it from a normal + map's classification. + +## Relevance to this project's own plans + +- **Near-term** (`TUNING_IMPL_PLAN.md` step 2): unaffected — still use + TuneECU's app for the actual ROM dump, this doesn't change that. +- **Longer-term** (`docs/ROADMAP.md` B2/B4, if `tunie` ever implements its + own upload/write path): the "full rewrite over clever resume" pattern + found here is directly actionable design guidance, and cheap to adopt — + it's simpler to implement than resume logic would have been anyway. + The per-brand-patching counter-lesson argues for validating any write + path thoroughly against Keihin specifically before assuming it's solved + in general, exactly matching this project's existing "get a spare ECU + first" caution in `safety.py`. diff --git a/tunie/research/TUNING_GUIDE.md b/tunie/research/TUNING_GUIDE.md new file mode 100644 index 0000000..b2d4e92 --- /dev/null +++ b/tunie/research/TUNING_GUIDE.md @@ -0,0 +1,531 @@ +# Tuning the 2010 T100 — master reference + +One place that answers "how do we actually tune this bike, any way we +want" — indexes everything scattered across this project's research files +into the actual decision tree: what's possible, what's proven, what's +still open. Read this first; follow the links for the technical depth +behind each claim. + +**The bike:** 2010 Triumph Bonneville T100, 865cc air-cooled twin, Keihin +ECU (Renesas SH7054), **mechanical odometer** — this generation detail +matters constantly (see "the one recurring trap" below). + +**Current real-world state:** SAI plumbing physically removed, solenoid +left electrically connected (clicking, harmless — [resistor fix +below](#stop-the-sai-solenoid-clicking)). Exhaust: Arrow 2-in-1 in hand, +not yet fitted. Airbox: still stock, removal planned for later. Fuel: +Canadian premium, ≤10% ethanol (E10). ECU not yet connected to +(`tunie info` never run) — ["Phase 1" in the project's own +`../STATUS.md`](../STATUS.md), still blocked on the Triumph diagnostic +connector cable. + +## Two goals, and they sometimes pull in different directions + +**Primary:** tune my own bike correctly for whatever mods actually go on +it — exhaust now, airbox later, whatever comes after that. Accuracy and +correctness win when this goal is in tension with the one below. + +**Secondary:** make the tuning process non-invasive and simple enough that +this gets open-sourced and other riders can use these tools without +cutting/drilling/welding anything on their bike they can't easily undo. + +These aren't always aligned, and it's worth being explicit about it rather +than letting decisions quietly optimize for one without saying so: + +- The bung-reuse / device-flag-decoupling approach two sections down is a + case where both goals point the same way — no permanent modification + *and* it doesn't compromise the primary tune's correctness. When that's + possible, it's the obvious choice. +- Elsewhere they can conflict: e.g. two wideband sensors (one per + cylinder, pre-collector) gives better tuning accuracy (goal 1) but costs + more hardware and setup complexity than one sensor post-collector (goal + 2). No blanket rule — flag the trade-off explicitly at each decision + point rather than silently picking one goal's answer. +- The open-sourcing goal also means the eventual tooling (Phase 0/4 in + `TUNING_IMPL_PLAN.md`) should stay agnostic to *which* hardware path a + given rider picked, rather than hard-coding this build's specific + choices — already the design intent there, worth keeping true as it + gets built out. + +--- + +## Is the no-drilling ECU-streamed plan actually achievable? (Aug 2026 status check) + +Short answer: **architecturally yes, nothing found so far says it can't +work — but it is not yet proven end-to-end, and it all sits behind the +one blocker this whole project has had since before this plan existed.** +Broken down honestly, piece by piece: + +| Piece | Status | +|---|---| +| No drilling — reuse stock O2 bungs for wideband | **Solid.** M18x1.5 thread standard confirmed for both sensor types and this Triumph twin family specifically. | +| Wideband sensor + controller hardware | **Solid.** Off-the-shelf, well-understood (Innovate/AEM), just needs buying and wiring — execution risk only, no research risk. | +| ECU exposes RPM/TPS/coolant live | **Mechanism confirmed**, specific record IDs **not yet confirmed for this bike**. Recovered one candidate record array from the decompile (the Keihin-family "default" case), but which `Oe()` switch case actually applies to a Bonneville twin specifically hasn't been traced through the ECU-type-selection logic — it's a good candidate, not a verified one. | +| Wideband AFR into the onboard logger | **Solid**, standard ADC-reads-a-voltage problem, no open questions. | +| TuneECU's F Trim / Commit trims workflow | **Confirmed from documentation** (`android.html`), never operated hands-on — no bike has been connected yet, project-wide, per `../STATUS.md`. | +| Mapping log points to fuel-table cells | **Solid** — RPM/throttle axes already extracted and validated (`reference-maps/table_map.py`). | +| Polling-loop + onboard-unit integration code | **Not written yet.** Phase 0 of `TUNING_IMPL_PLAN.md` — straightforward extension of `tunie`'s existing transport, but zero lines of it exist right now. | + +**The one hard blocker underneath all of it:** first ECU contact +(`tunie info`) has never happened — still stuck on the Triumph diagnostic +connector adapter, the same blocker `../STATUS.md` has tracked since +before this tuning plan existed. Record labeling, live-capture validation, +and confirming the F Trim workflow hands-on all require that connection to +exist first. Nothing about the plan is contradicted or looks unworkable — +it's just genuinely unverified past the paper-architecture stage until +that cable problem gets solved. + +**So: achievable, yes. Achieved, not yet.** The path from here to "working +system" is: solve the cable → `tunie info` (resolves current stock map ID +as a side effect) → label the live records empirically → write the +polling-loop code (can actually start before the cable's solved, against +mocked data) → wire the wideband ADC → do a real capture → build the +first tune. No step in that chain currently looks like it won't work — but +none of them past "the cable exists" have actually been tried. + +--- + +## The five ways to get a tune, ranked by effort/reliability + +### 1. Pick an existing official TuneECU calibration (done, free, safest) + +For Arrow 2-in-1 + E10 + mechanical odo, TuneECU's own catalogue already +has the validated answer: **map `20262` or `20313`** (same tune, two +catalogue reissues — see +[`reference-maps/PERMUTATIONS.md`](reference-maps/PERMUTATIONS.md) for the +proof they're identical bar one trivial byte). This is real engineering +data from Triumph/TuneECU, dyno-validated by them, zero DIY risk. Covers +exhaust + fuel type. **Does not cover SAI/O2 delete or airbox removal** — +TuneECU never ships those combined with an aftermarket exhaust, confirmed +by an exhaustive catalogue-wide keyword search (zero hits across all 1811 +entries, any model) — see +[`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#existing-tunes-for-airbox-removal--2-1-exhaust-aug-2026). + +### 2. Flip the SAI/O2 device flags on top of #1 (done, free, low risk) + +`reference-maps/toggle_devices.py` flips the three device-enable bytes +(`0x53801` SAI, `0x53818`/`0x53819` O2×2 — found and independently +re-verified 3× across different reference maps, see +[`reference-maps/DEVICES.md`](reference-maps/DEVICES.md)) on any base map. +Already built: `reference-maps/derived/20262-noSAI-only.hex` (matches your +resistor-swap plan — see below) and `...-noSAI-noO2.hex`. Confirmed +byte-for-byte disjoint from the Arrow exhaust calibration, so this +composition is sound, not a guess +([`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#what-the-real-device-flag-file-tells-us-about-composing-arrow--delete)). + +**Important, established this session:** this flag most likely only +**suppresses the fault-code/CEL logic** for a missing device — it does +*not* reliably change fueling behavior. Multiple forum sources describe it +this way, and TuneECU's own current Android UI still separately exposes +"Idle Fuel Trim (CO)" as *the* parameter specifically for "Triumph without +O²-Sensor" — i.e. the flag and the fueling correction are two different +things you both need, not one flag that does both. + +### 3. Hand-compose a "good enough" trim correction (done, updated Aug 2026) + +`reference-maps/compose_arrow_delete.py` sets **both** idle-trim bytes now +(`0x536AB` and `0x5369C`, part of the 32-entry "Idle Fuel Trim (CO)" table, +[`reference-maps/TABLES.md`](reference-maps/TABLES.md#fuelignition-trim-array--rpm-indexed-32-entries-0x53690-0x536af)) — +`0x5369C` was originally excluded as "exhaust-family-dependent," but that +was an artifact of reading the byte as unsigned; the table is signed, and +in signed space both bytes compose the same way (one saturates at `+127`). +**This composition is still scoped to the SAI/O2-delete case only — it is +explicitly NOT an airbox-removal tune.** A real dyno reference on the same +ECU family +(`20184dynoTuneSteveO2-Disable.hex`) showed a real airbox+open-pipe mod +rewrites nearly the *entire* 32-cell trim table plus a further ~50-byte +region we haven't mapped — see +[`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#major-find-a-real-dyno-tune-on-our-exact-ecu-generation-airbox-removed-aug-2026). +Output: `reference-maps/derived/20262-arrow-delete-composed.hex`. + +### 4. Buy a matched commercial tune (real, currently blocked) + +- **Triumph Twin Power (TTP)** had exact-match products (`Tune 11 2-1`: + airbox removal + 2-1 exhaust + O2/SAI removal) — but the storefront is + effectively wound down (founder retirement 2022, every SKU checked shows + "not in stock," hardware supply handed to a sister company). Don't count + on this. +- **DNK TuneWorks** ($275, mail-in or self-flash) — no airbox-specific + variant listed for the 865 air-cooled model, but they've built per-mod + variants off-catalogue before (per a customer review); worth emailing + directly. Watch for their *other*, wrong-model product page (liquid-cooled + 2016+ T100) — easy to mix up. +- Full details: [`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#existing-tunes-for-airbox-removal--2-1-exhaust-aug-2026). + +### 5. Road-tune it yourself, no dyno (documented, not yet tried, most promising for airbox) + +A complete, concrete methodology exists and is **confirmed still usable in +the current TuneECU Android app** (not a dead Windows-only technique): +wideband O2 sensor + datalogger, drive fixed throttle positions through the +RPM range, log target-vs-measured AFR, feed the discrepancy into the **F +Trim** table (swipe right from the F/I table screen), **Commit trims** +(bakes correction into the permanent fuel table, resets trim to zero), +repeat 3-4 passes. A dyno session afterward becomes optional fine-tuning, +not a requirement. Full writeup, sourced: +[`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#major-find-a-documented-no-dyno-road-tuning-method-aug-2026). + +**This is the actual answer to "how do we get airbox removal tuned +without paying a tuner."** It directly fixes what airbox removal breaks +(the fuel table no longer matching real airflow) using measured reality, +sidestepping the whole byte-composition problem in #3 entirely. + +#### How it actually works, mechanically + +The ECU's fuel table (`FuelMap`) stores intake air mass in mg for each +RPM/throttle-position cell. Hardware changes (exhaust, airbox) change the +real airflow at each cell without changing what's stored — the table goes +stale until someone re-measures it. A dyno does that by holding the bike at +load and reading a wideband sensor; this method does the same measurement +on the road. + +1. **Get a truthful AFR reading.** The stock O2 sensor is narrowband — fine + for a 14.7:1 closed-loop target, useless for tuning richer WOT mixtures. + A wideband sensor + controller reads accurately across the whole range + actually being tuned (roughly 12.5-14.7:1). +2. **Flatten the baseline so it can't lie.** Before measuring, load a tune + with AF1/AF2 (target-AFR tables) both set flat to something safely rich + (13.0:1 in the original guide) and **F Trim** zeroed. This isolates "is + the fuel table wrong" from "was the target already weird here" — you + can't tell those apart if the target varies cell-to-cell too. +3. **Log real riding data.** Mark the throttle grip at the fuel table's + throttle breakpoints. On a quiet straight road, hold each mark steady, + sweep the RPM range, three passes per mark. Log RPM, table + row/cell, and measured-vs-target AFR at each point. +4. **Translate the log into corrections.** For each logged point, work out + the nearest fuel-table cell (interpolating between throttle + breakpoints) and how far off the AFR was — that error goes into the + **F Trim** table, a temporary zero-baseline overlay the same shape as + the main table. +5. **Commit and iterate.** **Commit trims** permanently bakes the F Trim + corrections into the real fuel table and zeroes F Trim again. Reflash, + repeat the same test. Converges in roughly 3-4 passes per the original + author. +6. **Finish it.** Once the base table is correct, replace the flat 13.0:1 + placeholder with real AF1/AF2 targets (richer at full load for power, + leaner at part load for drivability/economy), one final commit. +7. **Dyno becomes optional** — at that point it's just fine-tuning via + F Trim on an already-correct base map, not the whole job. + +#### Tools actually required — and why this isn't a simple process + +Being honest about this rather than making it sound tidier than it is: + +**Fabrication.** The wideband sensor needs a bung in the exhaust — either +weld one in (needs a welder, or a shop that will), or find/reuse an +existing bung (check whether Arrow's collector has a provision, or whether +the stock O2 bung is reusable). Not a bolt-on step. + +**Electrical work.** Wideband controller needs switched 12V, ground, and +the sensor's connector wired in — same category of work as the SAI +resistor swap, just for a different, more sensitive sensor (wideband +sensors are also consumables that die if run rich/unheated wrong, adding a +"don't fry the $80 sensor" constraint the resistor job didn't have). + +**A real integration gap, not just extra steps.** The German guide's slick +workflow — a single log row giving you table position *and* measured AFR +together, entered with one keypress — depended on a **TuneBoy-specific +cable with a built-in analog input**, a different product from TuneECU. +**It is not confirmed that TuneECU's cable has an equivalent.** Without it, +this becomes correlating *two separate logs* (the wideband's own +recording, and whatever position/RPM reference you have) after the fact, +by eye/timestamp — meaningfully messier and more error-prone than the +streamlined description implies. This is the single biggest unresolved +question before treating this as a smooth process: does TuneECU support +synced logging at all, or does every pass require manual correlation. + +**A second software tool.** The wideband controller has its own logging +software/app (Innovate LogWorks, AEM's app, or onboard SD), separate from +TuneECU. Plus a spreadsheet to do the cell-correlation math by hand. + +**Riding logistics, not just equipment.** Steady, repeatable throttle +positions at speed, on a quiet legal road, while a logger runs — solo, +that's watching a gauge/throttle marks/traffic/road all at once, which is +a real safety concern, not a paperwork one. A passenger to run the logger, +or a fully autonomous SD-logging setup that needs zero mid-ride +interaction, is the practical answer, not "just wear the gauge and go." + +**Time.** Multiple ride-then-reflash cycles (3-4 minimum, per the guide), +each needing the bike, the logger, a suitable road, and a laptop/Android +device to reflash between passes. Realistically spread across more than +one outing, not an afternoon. + +**Net honest assessment:** this is a real DIY-tuning project — fabrication ++ wiring + a second logging tool + an unresolved software-integration +question + genuine riding-safety logistics + several iteration cycles — +not a "flash a file and go" task. It's the most *correct* path (measures +your actual bike, not someone else's), but it's the highest-effort of the +five options in this document, not the easy one. Worth weighing again +against just paying DNK once/if they confirm an airbox variant (#4) — +that trades money for skipping all of the above. + +--- + +## Better idea (Aug 2026): stream live data straight off the ECU with `tunie` + +The messy "two separate logs correlated by hand" problem above assumed we +needed TuneECU's app/cable for the ECU side. We don't — `tunie` already has +verified, safe, read-only K-line access to this exact bike. Its own live +dashboard is built on the same mechanism, confirmed in the decompile: + +- **Service `0x21` ReadDataByLocalIdentifier**, already implemented in + `tunie` (`src/tunie/kwp2000.py`, used today by `identify.py`'s one-shot + sweep) — same service TuneECU's live dashboard polls continuously via a + round-robin scheduler (`m.java`'s `se()`/`Oe()` functions). +- Records are **2-byte extended identifiers** (not the single-byte ones + `identify.py` currently sweeps), sent as `0x21 `. Recovered the + actual per-model record table for the Keihin-family default case (array + `ch` in `m.java:204`): decoded record values `0x0100`, `0x0001`, + `0x5103`, `0x0139`, `0x0007` (recurring across the 15 polled slots). +- **Not yet labeled** — which record is RPM vs. throttle position vs. + coolant temp isn't identified from the decompile alone (the field-name + mapping lives in UI code not yet fully traced). **Fastest way to finish + this isn't more static tracing — it's empirical, once the cable's + connected**: poll all 5-ish records in a loop with ignition on, rev the + engine / move the throttle by hand, and see which values respond to + what. RPM scales with engine speed, TPS jumps with throttle movement, + coolant temp changes slowly. Should take one bench session to label all + of them. + +### What data we actually need, and where each piece comes from + +| Data | Source | Status | +|---|---|---| +| RPM | ECU, live poll (`0x21`) | mechanism confirmed, record ID unlabeled | +| Throttle position (TPS) | ECU, live poll (`0x21`) | mechanism confirmed, record ID unlabeled | +| Coolant temp (exclude cold-engine data) | ECU, live poll (`0x21`) | mechanism confirmed, record ID unlabeled | +| Wideband AFR | **external hardware, no way around it** | ECU's own O2 is narrowband, useless for rich-mixture tuning regardless of any of the above | + +Everything except the wideband AFR reading can come from the ECU itself, +via hardware already owned (the K-line/FTDI cable this project needs +anyway for `tunie info`) and code already 90% built (`tunie`'s existing +transport + safety layer, just needs a polling loop added instead of a +one-shot sweep, plus the 2-byte-record variant of `READ_DATA_BY_LOCAL_ID`). +**No new ECU-side hardware, no dependency on TuneECU's app, no uncertainty +about a proprietary analog-input cable** — that whole question from the +previous plan is moot. + +### The "onboard live processing unit" idea — sound, and better than the alternatives + +A small onboard computer (Pi Zero, ESP32-class board, etc.) running two +things in parallel, writing one synced timestamped log: +1. `tunie`'s KWP2000 poller hitting the labeled RPM/TPS/coolant records in + a tight loop over the K-line cable — safe by construction, same + read-only transport already verified against this bike's protocol. +2. An ADC channel reading the wideband controller's analog AFR output. + +This **eliminates the correlation problem entirely** — one log, one +timestamp column, no post-ride guesswork matching a wideband recording +against hand-marked throttle positions. It also removes the riding-skill +burden from the original plan (no need to hit exact throttle marks by +feel — real TPS is captured directly, at whatever resolution the polling +loop achieves). + +**Still required, unavoidably:** the wideband sensor + bung + controller +(external AFR hardware, see below) and someone actually riding the bike +through the RPM/throttle range while the logger runs — the bike still has +to physically experience the conditions being tuned for. What this +approach removes is the *ambiguity* in capturing that, not the ride itself. + +**Concrete next steps, in order:** +1. Extend `tunie` with a 2-byte-record variant of `READ_DATA_BY_LOCAL_ID` + and a polling-loop mode (currently only does the one-shot `identify` + sweep). Software-only, doable now, no bike required. +2. First ECU contact (`tunie info`, per `../STATUS.md` — still blocked on + the connector cable) doubles as the session to empirically label which + record is which by watching values change. +3. Once labeled, wire in the wideband ADC channel alongside the KWP2000 + poller on the same onboard unit. +4. Do the actual road/bench data capture, post-process off-bike into F + Trim corrections exactly as described above — just with a clean single + synced log instead of two to hand-correlate. + +--- + +## Wideband O2 + datalogger setup plan (not yet acquired — hardware/shopping list) + +The German guide's original hardware (Wayne Macdonald's proprietary +TuneBoy/TuneEdit cable with a built-in analog input) is **TuneBoy-specific, +not confirmed to exist for TuneECU** — this project uses TuneECU (Alain +Fontaine's tool), a different product built on the same underlying +Keihin/Sagem architecture. Don't assume TuneECU's cable has an equivalent +analog input; not verified either way. + +**Practical path that works regardless:** use a self-contained wideband +controller with its own onboard/SD datalogging, rather than depending on +feeding the signal into TuneECU's cable directly. Correlate logs to fuel +table cells by throttle position + RPM (the German guide's own low-tech +method — marked throttle-grip positions, steady-state RPM sweeps) instead +of a live electrical link. Candidates, all with standalone logging: + +- **Innovate LM-2** or **MTX-L** — two programmable analog outputs, widely + documented for this exact "log AFR, correlate to ECU table cells" + workflow on other platforms. +- **AEM X-Series UEGO** — 0-5V analog out + onboard SD logging, CAN option. + +Both need: a bung in the exhaust for the sensor (weld-in, ideally before +any downpipe junction — check whether the Arrow 2-in-1's collector has a +provision, or where the stock O2 bung sits since it may be reusable), a +controller box (fits under the seat, same area the SAI solenoid used to +occupy), switched 12V power, and a way to read RPM alongside AFR (either +the wideband unit's own RPM input if it has one, or TuneECU's own live +Dashboard screen running in parallel, watched/logged manually). + +**Setup steps, when the hardware is in hand** (this project can't do this +part — physical bung welding, sensor mounting, wiring): +1. Weld/fit the wideband bung, mount the sensor. +2. Mount the controller, wire switched 12V + ground + sensor. +3. Power on, let the sensor heat-soak per its instructions before trusting + readings. +4. Load a flat/rich baseline tune (per the German method: AF1/AF2 set to a + uniformly rich target, F Trim zeroed) so there's no lean risk during + logging. +5. Do the steady-state throttle sweeps, log AFR vs. target. +6. Enter corrections into TuneECU's **F Trim** table (swipe right from the + F table screen), **Commit trims**, reflash, repeat 3-4 passes. +7. Set real AF1/AF2 targets once the base fuel table is corrected. + +This is genuinely the first step in this plan that requires physical +hardware acquisition and bike work — everything before it in this document +was achievable offline. Flag it to revisit once you're ready to buy parts. + +## Stop the SAI solenoid clicking + +Already physically removed the SAI plumbing; solenoid left wired, clicking +harmlessly (confirmed no DTC risk, no engine risk). Fix: swap the solenoid +for a **470 Ω / 0.5 W resistor** across the harness connector's two +solenoid pins (battery disconnected while wiring). Full procedure and +alternate 50 Ω/10 W option: +[`COMMUNITY_TUNING.md`](COMMUNITY_TUNING.md#practical-silencing-a-removed-sai-solenoid-with-a-resistor-aug-2026). +Then flash `derived/20262-noSAI-only.hex` to suppress the resulting DTC — +device flag toggle is confirmed clean/disjoint from the Arrow calibration +(#2 above). + +--- + +## After tuning: the wideband hardware isn't permanent + +Once the F Trim corrections are validated and Committed into the permanent +FuelMap, the wideband sensors were a measurement tool, not a runtime +requirement — pull them and **plug the bungs with standard M18x1.5 +threaded plugs, not a weld**, so they stay reusable for a future re-tuning +pass without welding again. Optional, not required: leave one wideband +installed permanently as an ongoing AFR gauge — worth it specifically +because a fully SAI/O2-deleted bike has zero live feedback watching for a +lean condition afterward. Detail: `TUNING_IMPL_PLAN.md` Phase 5. + +## Best version yet: reuse the stock O2 bungs, make the O2-delete decision reversible too + +**Instead of drilling anything, or permanently deciding O2 delete now:** +temporarily unscrew the stock narrowband O2 sensors from their existing +bungs, thread wideband sensors into those same stock locations for the +tuning sessions (M18x1.5 is standard — no adapter expected, though worth +confirming on the actual bike), then **put the stock narrowband sensors +back afterward**. Zero new holes, zero permanent exhaust modification, and +it reuses hardware the bike already has. + +**This surfaces a real tension worth being explicit about, though:** the +original reason for wanting O2 delete wasn't the road-tuning project — it +was `RESEARCH.md` §9's finding that O2 delete lets the ECU run a richer, +cooler idle/cruise mixture and fixes snatchy off-idle response. That's a +**closed-loop** behavior change, at idle/steady-cruise. The wideband/F Trim +tuning process only ever corrects the **open-loop** main fuel table +(WOT/acceleration) — it doesn't touch idle/cruise closed-loop behavior at +all. So if the stock sensors go back in *and* the final flashed tune +re-enables the O2 device flag, you get an accurately-tuned WOT table for +the new exhaust, but the original snatchy/hot-idle problem O2 delete was +meant to fix **comes back** — the two projects don't automatically bundle. + +**The actual best move: decouple the physical sensor from the software +flag.** Put the stock sensors back in the bungs either way (no permanent +mod, always reversible) — but the *final* flashed tune's O2 device flag is +an independent choice from whether a sensor happens to be physically +present: +- Flag **off**: ECU ignores whatever's in the bung, runs open-loop-derived + richer idle/cruise — gets the original SAI/O2-delete benefit, with a + stock sensor sitting there doing nothing (harmless). +- Flag **on**: full closed loop restored, snatchy/hot-idle behavior + returns, but with the WOT table now correctly matched to the exhaust. + +Since nothing physical differs between these two states, **switching +between them later is just reflashing a different device-flag setting — +no hardware change either way, forever.** That's a stronger "no permanent +mods" outcome than just avoiding drilled holes: the O2-delete decision +itself becomes fully reversible, not just the sensor mounting. + +**One session-scoped exception:** during the actual tuning rides +themselves, closed loop needs to be bypassed regardless of the final +plan — a wideband sensor occupying the narrowband's physical spot can't +also feed the ECU a valid narrowband signal, so the ECU can't run real +closed loop during that window. This matches the original German guide's +approach and is normal for any tuning session (dyno tuners do the same) — +it's a temporary session state, not a permanent change, and doesn't affect +which final flag setting gets chosen afterward. + +**Practical note:** repeatedly swapping sensors in and out of the same +bung across multiple tuning passes is real wear — use anti-seize on the +threads to avoid the bung seizing after heat cycles. + +## The one recurring trap: ECU generation + +**Every dead end and wrong turn in this research traced back to the same +mistake: mixing mechanical-odometer and LCD-odometer map/ECU generations.** +They use different `ecu` type codes in `maps.json`, different absolute +byte offsets for the same logical parameter (confirmed: our device-flag +offset reads garbage on an LCD-odo file), and are not interchangeable in +any way. **Before using any map ID or byte offset found anywhere — forum, +vendor site, or this project's own derived files — check it's the +mechanical-odo family (`ecu='0'`, or explicitly labeled "Mechanical +odometer").** Wrong-generation map IDs that came up and got ruled out: +`20500`, `20505`, `20507`, `20516`, `20498...LCD`, `20500MapAirboxBonnie_LCD`. + +--- + +## Local research assets (what's actually on disk) + +- `reference-maps/*.hex` — real downloaded reference maps: stock + production/aftermarket (`20187`-`20192`), Arrow 2-in-1/2-in-2 all fuel + variants (`20262`-`20316`), the real SAI/O2 delete map + (`20188Map2009AIRBOXBONNY.hex`), the America dyno tune + (`20184dynoTuneSteveO2-Disable.hex`) plus its stock baseline + (`20185`/`20186`). Gitignored (`*.hex` at the samplez repo root) — these + are working files, not committed, so they won't survive a fresh clone; + re-download commands are in each research doc's history if needed again. +- `reference-maps/derived/` — our own composed candidates (SAI-only, + SAI+O2, the trim-composed Arrow variant). **None are known-flashable** + — the ECU flash checksum isn't solved (`docs/ROADMAP.md` Phase 3), + separate from everything in this document. +- `reference-maps/{decode_map,reconstruct_rom,table_map,toggle_devices, + compose_arrow_delete}.py` — the working toolchain: decrypt → flat ROM → + named tables → device-flag edit → trim-byte compose. +- `tuneecu_site_mirror/` — full local mirror of tuneecu.net's + documentation (built by `tuneecu_crawl.py`), including the German + road-tuning primer. The live site says it won't be updated after + September 2024 — this mirror is insurance against link rot. +- `PERMUTATIONS.md`, `TABLES.md`, `DEVICES.md`, `COMMUNITY_TUNING.md` — the + detailed research trail; this file is the index, those are the evidence. + +## Open questions, if this gets picked up again + +1. ~~Byte→percent scaling formula for the "Idle Fuel Trim (CO)" table~~ — + **upgraded, Aug 2026** (`reference-maps/TABLES.md`): found TuneECU's + own CO-Trim edit dialog and a live-value dispatcher, both independently + confirming the raw byte is **signed two's-complement (-128 to 127), + used directly with no scaling factor** — not the unsigned 0-255 + interpretation used everywhere earlier in this project. This also + retroactively explains away the "255 looks like a sentinel" puzzle from + `PERMUTATIONS.md`: unsigned 255 is just signed -1, an ordinary + near-neutral value. Encoding confidence: low → medium-high. Still open: + the exact real-world unit (percent, or an ECU-internal unit close to + it) isn't separately confirmed. Matters for both project goals per the + reasoning below, now on firmer ground than when this was first flagged. +2. Map the ~50-byte region (`0x50010`-`0x50C21`) the America dyno tune + also rewrote — likely ignition/limiter/other trim, unmapped. +3. Identify `0x53822` — probably traction control or instrument-cluster + deactivate per the Android UI's device list, unconfirmed for this ECU + family. +4. Actually try the road-tuning method (#5) once the airbox mod happens — + the real next step, not more offline byte analysis. +5. First ECU contact (`tunie info`) — still blocked on the physical cable + adapter, per `../STATUS.md`. Would resolve which exact stock map is + currently flashed, a prerequisite fact this whole document has been + assuming rather than confirming. diff --git a/tunie/research/TUNING_IMPL_PLAN.md b/tunie/research/TUNING_IMPL_PLAN.md new file mode 100644 index 0000000..b4edde1 --- /dev/null +++ b/tunie/research/TUNING_IMPL_PLAN.md @@ -0,0 +1,436 @@ +# Implementation plan: ECU-streamed, onboard-logged road tuning + +Concrete engineering plan for the approach settled on in +[`TUNING_GUIDE.md`](TUNING_GUIDE.md#better-idea-aug-2026-stream-live-data-straight-off-the-ecu-with-tunie): +stream RPM/TPS/coolant live off the ECU via `tunie`'s existing read-only +KWP2000 transport, sample a wideband AFR sensor on the same onboard unit, +write one synced log, post-process off-bike into F Trim corrections. This +document is the task list; `TUNING_GUIDE.md` and `COMMUNITY_TUNING.md` are +the research backing each decision here. + +**Status: not started.** Nothing in this plan has bike contact yet — Phase +0 is pure software and can begin immediately; everything after Phase 1 +is blocked on the cable/connector work already tracked in `../STATUS.md`. + +## The near-term validation game plan + +Six concrete steps, checked against everything in this document and +`TUNING_GUIDE.md`, with the gaps filled in: + +**0. Fix the cable.** Not one of the six steps below, but it's the actual +current blocker sitting in front of all of them — the Triumph diagnostic +connector adapter, tracked in `../STATUS.md` since before this tuning plan +existed. Nothing past this point has bike contact until it's solved. + +**1. Prove basic ECU comms with `tunie` — `tunie info`.** Already the +correct first step per `../STATUS.md`; this plan doesn't change it. Gives +ECU identity, current map ID/DTCs, and — as a side effect — confirms the +cable/protocol work before adding live-poll complexity on top. + +**2. Yank the current tune off the bike — real gap, needs a scope +decision, not just a step.** A ROM/map dump uses KWP2000 service `0x35` +RequestUpload, which `tunie` **deliberately does not implement** — +`safety.py` refuses it by construction, on purpose, as this whole +project's core safety guarantee (`README.md`: "Cannot write to the ECU by +construction"). Building this into `tunie` means consciously opening a +door that's been kept shut for a reason, not just adding a feature. +**Simpler alternative, use before building anything new:** TuneECU's own +Android app already has a working "Read Map" function +(`android.html`: "Read Map: Read the map from the ECU, only possible via +cable connection"). Use that for this one step — it's already built, +already proven, and its output (a `.hex` file) is exactly the input format +`decode_map.py`/`reconstruct_rom.py`/`table_map.py` already work with. No +reason to reimplement upload capability in `tunie` just to get a file +these tools already know how to read. Once dumped, cross-check its +signature/ID against `maps.json` to confirm exactly which stock +calibration is actually flashed — resolves an assumption this whole +research effort has been making (which map family applies) rather than +just producing a file. + +**If "Read Map" ever fails partway through, don't panic-troubleshoot — +here's what's actually happening.** Traced TuneECU's Recovery mode +(`research/RECOVERY_MODE.md`) specifically so this is known *before* it's +needed rather than discovered live. Short version: Recovery is the same +reprogram function, relabeled — TuneECU checks whether the currently-open +map file decodes to a valid signature (same magic-header + directory +lookup `decode_map.py`/`table_map.py` already implement) and offers +Recovery instead of Reprogram if so. It is **not** detecting the ECU's +live fault state — it's checking you have a complete, valid base map ready. +Practical implication: **keep a matching OEM `.hex` (from `maps.json`, +already downloaded — `20187`/`20191` etc.) on hand before attempting the +dump**, so if the read fails partway, the documented community recovery +procedure is available: cycle ignition off/on, wait ~5s, open that +matching OEM map, reconnect, retry. Full trace, including what's still +unconfirmed (whether a live ECU-side signal is also involved), in +`RECOVERY_MODE.md`. + +**3. Get live data, see what it actually gives us.** Two sub-parts, not +one: + - *What's there:* poll the candidate record list (Phase 0/1 below) and + see what responds at all. + - *Labeling, as its own deliberate task, not passive observation:* + ignition on/engine off, move the throttle by hand, watch which record + jumps → that's TPS. Start the engine, compare a record's value + against the tach → RPM. Let it idle and warm up, watch what climbs + slowly → coolant. **Contingency:** the candidate record list + (`0x0100`, `0x0001`, `0x5103`, `0x0139`, `0x0007`) is one plausible + branch recovered from the decompile, not a confirmed-correct one for + this specific bike (see Phase 0/1 above) — if none of them behave + sensibly, the fallback is a wider sweep of nearby record values and/or + going back to trace the `Oe()` switch case selection more rigorously + rather than assuming the guess was right. + - **Basic bench-test safety:** ignition-on/engine-off first, always; + if it gets to engine-running tests, that needs a stand and + ventilation considerations like any other bench run — not a research + question, just don't skip it. + +**4. Design and build the onboard live-capture system.** Pi Zero (or +similar) running `tunie`'s poll loop + an ADC reading the wideband +controller's analog output, writing one synced log. CSV or SQLite both +fine for this — SQLite's nicer once you're joining/querying against table +geometry later, CSV is simpler to just eyeball. This is Phase 3 in this +document; **the wideband sensor/controller/mounting setup you already +flagged as probably-missing is real and belongs here** — see "Phase 2" +above (hardware, bung reuse, sensor count decision) for what that actually +involves; it's not just "buy a sensor," it's a genuine sub-project of its +own (mounting decision, wiring, warm-up handling, thread confirmation). + +**5. Verify the captured data locally, build logs from it.** Sanity-check +against known references before trusting it: idle RPM should match a +handheld tach, TPS should track smoothly with a slow throttle sweep, +coolant should rise monotonically from cold start, AFR should read near +14.7 at a stable idle before any tune changes. This step is really "does +the whole capture chain produce believable numbers," which is a +prerequisite before trusting it for step 6. + +**6. Compare live ride samples against the current map.** This is Phase 4 +(post-processing) — map each logged point to a fuel-table cell using the +already-known RPM/throttle axes (`table_map.py`), see how far off the +stock table is from what's actually happening on the modified bike. + +**What this game plan does *not* yet cover, worth naming explicitly:** +it's a validation/prototyping pass — proving the whole chain works and +seeing what the data looks like — not yet the actual iterative tuning +loop (F Trim corrections → Commit trims → reflash → repeat → +final AF1/AF2 targets, Phase 5 above). That's the next phase after this +one succeeds, not part of it. + +--- + +## Phase 0 — software groundwork (no bike required, start now) + +**Goal:** `tunie` can poll 2-byte extended local identifiers in a loop, +not just the one-shot single-byte sweep it does today. + +- [ ] Add 2-byte record support to the local-identifier path. Today + `src/tunie/identify.py` sends `0x21 <1 byte>` (`LOCAL_ID_RECORDS`, single + byte). TuneECU's own dashboard sends `0x21 ` for a wider record + space — confirmed in the decompile (`m.java`'s `se()`, records like + `0x0100`, `0x5103`, `0x0139`). Extend `Transport.request()` callers to + accept a 2-byte record variant; `safety.py`'s allowlist already permits + `0x21` generally (per `triumph.py`'s `OBSERVED_READ_SERVICES`), just + confirm it doesn't assume single-byte payload length anywhere. +- [ ] Add a **polling-loop mode**, distinct from `identify`'s one-shot + sweep: given a list of records, poll each in round-robin at the fastest + safe rate, timestamp every response, write to a log file (CSV or + similar). This is the core of what's needed — everything else in this + plan depends on it existing. +- [ ] Candidate record list to poll, from the recovered `ch` array + (Keihin-family default case, `m.java:204`): `0x0100`, `0x0001`, + `0x5103`, `0x0139`, `0x0007`. Unlabeled — Phase 1 resolves which is + which. Poll all of them; drop the unused ones once labeled. +- [ ] Write the labeling capture script: connects, polls all candidate + records continuously, dumps a live table to the terminal (record → raw + value, updating) so a person can watch and correlate against what + they're doing to the bike (revving, moving throttle by hand, waiting for + it to warm up). +- [ ] Decide the log format now, since it constrains the post-processing + script (Phase 4): one row per poll cycle, columns `timestamp_ms, rpm, + tps_raw, coolant_raw, `. Keep raw values through this + layer — scaling/unit conversion happens once records are labeled and + their encoding is known (may not be linear; TPS especially could be a + raw ADC count needing calibration against the known throttle axis + breakpoints in `reference-maps/TABLES.md`). + +**Acceptance:** a script exists that, given a connected bike, would print +a live-updating table of all candidate records with timestamps, with no +bike required to review/test the code path structurally (mock transport +for a dry run is enough to validate the polling loop and log-writing +logic before real hardware is available). + +--- + +## Phase 1 — first ECU contact + record labeling (blocked on cable) + +**Blocker:** the Triumph diagnostic connector adapter, per `../STATUS.md` +— unchanged blocker, not something this plan resolves. + +**Goal:** know which of the candidate records is RPM, which is TPS, which +is coolant temp (and what any leftover ones are). + +- [ ] Run `tunie info` for the first time — the original Phase-1 goal from + `../STATUS.md`, still the correct first step regardless of this plan. + Confirms cable/protocol work before adding the live-poll complexity. +- [ ] Ignition on, engine off (safe, no combustion risk): run the labeling + script from Phase 0. Move the throttle by hand — whichever record jumps + in response is TPS. Note idle values for the others. +- [ ] Engine running, idle: whichever record now shows a plausible RPM + value (four-figures, matches tach) is RPM. Blip the throttle to confirm + it tracks. +- [ ] Let the engine sit and warm up: whichever record climbs slowly over + minutes is coolant temp. +- [ ] Determine each record's raw→real-units scaling by comparing against + a known reference (tach for RPM, a multimeter on the TPS sensor wire or + the throttle's marked positions for TPS, a rough ambient/operating-temp + sanity check for coolant). Write the results into `triumph.py` or a new + `live_records.py` module, with the same "recovered from X, confidence Y" + documentation style the rest of this project uses. + +**Acceptance:** a small table, committed to the repo, mapping record ID → +name → raw-to-real-units formula → confidence level, for RPM/TPS/coolant +at minimum. + +--- + +## Phase 2 — wideband hardware (parallel-able with Phase 1, needs purchase) + +Per `TUNING_GUIDE.md`'s shopping list — Innovate LM-2/MTX-L, AEM X-Series +UEGO, or **Zeitronix ZT-3** (a third real option, confirmed via an +external research pass Aug 2026 — Woolich Racing sells it as a +tuning-package wideband kit), all with a 0-5V analog output. This project +can't execute this phase (physical purchase, welding, wiring) — tracked +here so the software side knows what interface to design against. + +- [ ] **Don't just thread the wideband into whatever bung is available — + masking risk, confirmed.** Long/angled stock-style bungs and + thread-reducing adapters (e.g. 18mm→12mm) can prevent the sensor tip + from reaching the core exhaust gas stream, giving delayed/inaccurate + readings — directly confirmed against a real installer's page + (RB Racing): *"Many manufacturers use long 18mm O2 slanted bungs. These + 'mask' the 18mm O2 sensor signal"*; thread reducers are *"junk... the + threaded section is too long masking the signal."* Check bung depth/ + angle against the sensor's own spec before assuming any available bung + works, whether stock or new. +- [ ] Check whether the Arrow 2-in-1's headers retain provisions at the + stock O2 bung locations before assuming a weld is needed — M18x1.5 is + the universal O2 sensor thread (narrowband and wideband both use it; + confirmed for this Triumph twin family specifically by the German + tuning primer), so if a bung survives, the wideband sensor threads + straight in, no fabrication required. +- [ ] Decide one sensor or two: the stock design has **two** O2 bungs + (one per cylinder, pre-collector — matches the ECU's per-cylinder + `F1`/`F2` fuel tables). One wideband sensor placed post-collector on the + 2-in-1 gives a blended average AFR and loses per-cylinder correction + ability; two sensors (one per header, if those bungs survive on the + Arrow pipe) preserves it, at roughly double the sensor/controller cost. + Pick based on whether per-cylinder tuning resolution is worth it. +- [ ] Sensor + controller acquired, bung fitted, wired to switched 12V. +- [ ] Confirm the controller's analog-output voltage-to-AFR/lambda mapping + (from its manual) — needed for Phase 3's ADC scaling. + +**O2 delete is not required by this method — that's specific to the +original German guide's setup, not ours.** The 2003 primer had the wideband +probe *physically replace* the stock narrowband sensor in its own port, +which is why it explicitly required disabling closed loop ("Ab jetzt muss +ein Tune ohne Lambdaregelung gefahren werden!" — "from now on a tune +without lambda regulation must be run") — the ECU's own narrowband signal +was gone, so closed loop couldn't function regardless. **Our plan doesn't +have that constraint**, because the wideband sensor sits in an independent +bung, feeding only our own logger — the stock O2 sensor(s) can stay fully +wired, active, and in closed loop the whole time if desired. + +This matters because closed loop and the F Trim/road-tuning method operate +on *different parts of the map*: closed-loop O2 correction only runs at +idle and steady low-mid throttle; everything the road-tuning method is +actually correcting (WOT, hard acceleration, deceleration) is **already +open-loop on the stock ECU regardless of O2 presence**. So keeping O2 +active doesn't get in the way of the tuning — if anything it's +complementary: O2 keeps handling live idle/cruise correction (one less +thing to get exactly right by hand), while the wideband-driven F Trim +process corrects the open-loop main fuel table, which O2 feedback never +touches, stock or modified. + +**Practical options, now that this is a real choice rather than forced:** +1. **Keep both O2 sensors, wideband in a separate/reused bung.** Full + closed-loop behavior retained; wideband only used for tuning + measurement passes. Needs a bung location that doesn't conflict with + either stock sensor. +2. **Delete O2 (as originally planned for the SAI/O2 project), reuse the + freed bung(s) for the wideband.** Loses closed-loop idle/cruise + correction (richer, more consistent idle/cruise per the original + SAI/O2-delete motivation in `COMMUNITY_TUNING.md`, but no more live + auto-correction there either — needs the road-tuning method to get that + region right by hand instead of relying on O2 feedback). +3. **Hybrid** — delete/replace on one cylinder's bung for the wideband, + leave the other cylinder's O2 sensor active. Asymmetric, more complex, + probably not worth it unless bung availability forces it. + +Decision isn't urgent — can be made once the Arrow pipe's actual bung +layout is known (Phase 2's first task above). + +**For this build specifically, refined: option 2 (O2 delete), but via +temporary sensor swap rather than permanent bung reuse.** Instead of +pulling the stock narrowband sensors permanently, thread wideband sensors +into the *same stock bungs* for tuning sessions only, then put the stock +narrowband sensors back afterward — zero new holes, zero permanent +modification to the exhaust. The O2-delete *behavior* (richer/cooler +idle-cruise, the original motivation from `COMMUNITY_TUNING.md`) then +becomes a pure software choice — the ECU's device flag, independent of +whatever sensor happens to be physically sitting in the bung — rather than +something tied to permanently removing hardware. Full reasoning, including +the closed-loop-behavior tension this surfaces (WOT tuning and +idle/cruise-mixture behavior are separate axes, don't assume fixing one +fixes the other): +[`TUNING_GUIDE.md`](TUNING_GUIDE.md#best-version-yet-reuse-the-stock-o2-bungs-make-the-o2-delete-decision-reversible-too). +Session-scoped exception: closed loop still has to be bypassed *during* +the tuning rides themselves, since a wideband occupying the narrowband's +spot can't feed the ECU a valid narrowband signal at the same time — that's +temporary, not a permanent-mod question. + +**For open-sourcing this to other riders who want to keep O2 active** — +worth supporting as first-class options, not assuming everyone deletes O2: + +- **Considered and rejected: reusing the SAI injection point as a wideband + mounting location.** Doesn't work mechanically — SAI air enters through + openings cast into the cylinder head at the exhaust port junction (reed + valves live in the cam cover), not through a bolt-on fitting on a pipe. + There's no simple threaded port there to repurpose; doing this would mean + machining into the head casting. Not a real shortcut. +- **What does work: no-weld, clamp-on O2 bung adapters** — a standard + product (PLM, GlowShift, ProFlow, others), drill one hole, bolt on a + stainless clamp with a gasket, no welding. For O2-keeping riders on a + 2-in-1: **one** clamp-on bung post-collector (single blended-AFR + reading, no per-cylinder resolution, but one new hole instead of two). +- **Zero-modification option, with a real cost:** clamp-on tailpipe-end + "sniffer" probes exist too — clamp around the exhaust tip, no drilling + at all. Trade-off: less accurate (ambient dilution, slower response, + less representative of true exhaust gas) — a real cost for zero + permanent modification, not a free option. +- **Design implication for the eventual tooling/writeup:** document this + as a menu (delete + reuse stock bungs / keep O2 + single clamp-on bung + post-collector / keep O2 + zero-modification tailpipe clamp / weld two + dedicated pre-collector bungs for full per-cylinder resolution) rather + than assuming one hardware path — the logging/post-processing pipeline + (Phase 3/4) doesn't care which one a given rider picks, it just needs an + AFR channel wired to the ADC either way. + +**Don't wire the wideband's output back into the ECU's O2 input.** A +wideband sensor can't be wired directly into a narrowband-expecting ECU +pin at all (different sensor physics — pumped/Nernst cell needing its own +controller, vs. a simple heated-zirconia voltage source); some controllers +offer a "narrowband emulation" output to fake the ECU into thinking +nothing changed, but that mode discards the precision this whole project +needs, and the `0x21` diagnostic stream still wouldn't carry real AFR +either way. Since O2 delete is already part of the plan, the correct setup +is a fully standalone wideband — own bung, own controller, output only to +the Phase 3 logger's ADC, ECU's O2 circuit left disconnected/flagged off. + +--- + +## Phase 3 — onboard unit integration + +**Goal:** one small computer, riding along, producing one synced log per +session. + +- [ ] Pick the board. Constraints: needs a USB port (or equivalent) for + the K-line/FTDI cable `tunie` already uses, an ADC input (built-in or via + a small ADC breakout) for the wideband's analog output, enough compute + to run `tunie`'s Python transport comfortably, and to survive vibration/ + heat/vibration under the seat. A Raspberry Pi Zero 2 W (or similar) with + a cheap I2C ADC (e.g. ADS1115) is a reasonable default; an ESP32 is an + alternative if the KWP2000 polling loop gets ported to something more + embedded, but doing that port is extra work `tunie`'s existing Python + code doesn't need if a Pi-class board is used instead. +- [ ] Wire ADC channel to the wideband controller's analog output; confirm + voltage range matches the ADC's input range (may need a divider). +- [ ] Combine the two data sources into one process: KWP2000 poll loop + (Phase 0/1) and ADC sample loop, both timestamped against the same + clock, written to one log file per run (CSV: `timestamp_ms, rpm, tps, + coolant, afr`). +- [ ] Power: needs to run off the bike's switched 12V (same source as the + wideband controller) with a regulator appropriate to the board, or a + battery pack for bench testing before wiring it in permanently. +- [ ] Basic operational robustness: start logging automatically on power-up + (no need to interact with it mid-ride — this was a stated goal, since + operating a device while riding and marking throttle positions is a + safety concern per `TUNING_GUIDE.md`'s honest complexity assessment), + and fail safe (stop logging / flag clearly, don't crash silently) if the + K-line connection drops. + +**Acceptance:** power the unit on, ride/rev through a test range, power +off, retrieve one CSV with all four channels populated and sensibly +timestamped. + +--- + +## Phase 4 — post-processing pipeline (software, buildable now against synthetic data) + +**Goal:** turn a captured log into F Trim table corrections, reusing the +table geometry already reverse-engineered (`reference-maps/table_map.py`'s +RPM axis, throttle axis, and table offsets). + +- [ ] For each logged row, find the nearest fuel-table cell: RPM axis + lookup is direct (`table_map.py`'s `rpm_axis`, already extracted per-map); + TPS needs converting from whatever raw units Phase 1 lands on into the + table's throttle-axis units (`throttle_axis`, 0-1000 = 0-100.0%). +- [ ] For each cell with enough samples, compute the AFR error (measured + vs. the flat baseline target used during capture, per the road-tuning + method in `TUNING_GUIDE.md`) and produce a correction value. +- [ ] Output format: a per-cell correction table matching the F Trim + table's shape, ready to be entered into TuneECU's Map Edit F Trim screen + by hand (or, if worth the extra work later, generate a diff to apply + directly via the same decode/repack/encode pipeline + `toggle_devices.py`/`compose_arrow_delete.py` already use — flagged as + optional, manual entry into the app is the simpler and lower-risk MVP). +- [ ] This script can be written and unit-tested **now**, against + synthetic/fake log data, without waiting on any other phase — it only + needs the table geometry, which is already known. + +**Acceptance:** given a CSV in the Phase 3 format, produces a per-cell +correction table. + +--- + +## Phase 5 — the actual tuning loop (needs everything above, plus riding) + +Execute the method from `TUNING_GUIDE.md`: flat rich baseline tune → ride +with the logger → Phase 4 processing → enter corrections into F Trim → +Commit trims → reflash → repeat 3-4 passes → set real AF1/AF2 targets → +done. Not further decomposed here; it's the method already documented, now +using the improved single-source logging instead of manual correlation. + +### After tuning: what happens to the wideband hardware + +The wideband sensors are a measurement tool, not a runtime dependency — +once the F Trim corrections are validated and committed into the permanent +FuelMap, the ECU doesn't need live AFR feedback to run correctly. + +- [ ] Remove the wideband sensor(s). +- [ ] **Plug the freed bungs with standard M18x1.5 threaded plugs, not a + weld.** Keeps them reusable for a future re-tuning pass (e.g. after the + eventual airbox removal) without welding again. +- [ ] Optional, not required: leave **one** wideband sensor permanently + installed as an ongoing AFR gauge. Worth considering specifically + because this build ends up with no SAI and no O2 feedback at all — + zero live safety-net watching for a lean condition afterward (bad + injector, air leak, etc.). Common practice for fully O2-deleted setups; + doesn't need both sensors, just a dashboard readout. + +--- + +## What can start today vs. what's blocked + +| Phase | Blocked on | Can start now? | +|---|---|---| +| 0 — polling loop software | nothing | **yes** | +| 1 — record labeling | cable/connector (`../STATUS.md`) | no | +| 2 — wideband hardware | purchase + fabrication (not this project's job) | independently, whenever | +| 3 — onboard unit | Phase 1 (needs labeled records) + Phase 2 hardware | partially — board/ADC selection and wiring plan, yes; full integration, no | +| 4 — post-processing | table geometry only (already known) | **yes, against synthetic data** | +| 5 — tuning loop | everything | no | + +**Recommended immediate next action:** Phase 0 (polling-loop code) and +Phase 4 (post-processing script skeleton) are both pure software, need no +hardware, and are the actual bottleneck-breakers — everything else is +either already blocked on the known cable issue or is a purchase/fabrication +task outside this project's scope. diff --git a/tunie/research/WRITE_PATH.md b/tunie/research/WRITE_PATH.md new file mode 100644 index 0000000..0ca527e --- /dev/null +++ b/tunie/research/WRITE_PATH.md @@ -0,0 +1,219 @@ +# 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. diff --git a/tunie/research/reference-maps/20184dynoTuneSteveO2-Disable.hex b/tunie/research/reference-maps/20184dynoTuneSteveO2-Disable.hex new file mode 100644 index 0000000..f801452 Binary files /dev/null and b/tunie/research/reference-maps/20184dynoTuneSteveO2-Disable.hex differ diff --git a/tunie/research/reference-maps/20185Map.hex b/tunie/research/reference-maps/20185Map.hex new file mode 100644 index 0000000..fa56cda Binary files /dev/null and b/tunie/research/reference-maps/20185Map.hex differ diff --git a/tunie/research/reference-maps/20186Map.hex b/tunie/research/reference-maps/20186Map.hex new file mode 100644 index 0000000..d0edc01 Binary files /dev/null and b/tunie/research/reference-maps/20186Map.hex differ diff --git a/tunie/research/reference-maps/20187Map.hex b/tunie/research/reference-maps/20187Map.hex new file mode 100644 index 0000000..52d53e0 Binary files /dev/null and b/tunie/research/reference-maps/20187Map.hex differ diff --git a/tunie/research/reference-maps/20188Map.hex b/tunie/research/reference-maps/20188Map.hex new file mode 100644 index 0000000..daa923d Binary files /dev/null and b/tunie/research/reference-maps/20188Map.hex differ diff --git a/tunie/research/reference-maps/20188Map2009AIRBOXBONNY.hex b/tunie/research/reference-maps/20188Map2009AIRBOXBONNY.hex new file mode 100644 index 0000000..ded3d49 Binary files /dev/null and b/tunie/research/reference-maps/20188Map2009AIRBOXBONNY.hex differ diff --git a/tunie/research/reference-maps/20191Map.hex b/tunie/research/reference-maps/20191Map.hex new file mode 100644 index 0000000..71f8a57 Binary files /dev/null and b/tunie/research/reference-maps/20191Map.hex differ diff --git a/tunie/research/reference-maps/20192Map.hex b/tunie/research/reference-maps/20192Map.hex new file mode 100644 index 0000000..b8d8a95 Binary files /dev/null and b/tunie/research/reference-maps/20192Map.hex differ diff --git a/tunie/research/reference-maps/20262Map.hex b/tunie/research/reference-maps/20262Map.hex new file mode 100644 index 0000000..5100a77 Binary files /dev/null and b/tunie/research/reference-maps/20262Map.hex differ diff --git a/tunie/research/reference-maps/20313Map.hex b/tunie/research/reference-maps/20313Map.hex new file mode 100644 index 0000000..7ecb9e9 Binary files /dev/null and b/tunie/research/reference-maps/20313Map.hex differ diff --git a/tunie/research/reference-maps/20500MapAirboxBonnie_LCD.hex b/tunie/research/reference-maps/20500MapAirboxBonnie_LCD.hex new file mode 100644 index 0000000..934e0b7 Binary files /dev/null and b/tunie/research/reference-maps/20500MapAirboxBonnie_LCD.hex differ diff --git a/tunie/research/reference-maps/DEVICES.md b/tunie/research/reference-maps/DEVICES.md index c325703..2949993 100644 --- a/tunie/research/reference-maps/DEVICES.md +++ b/tunie/research/reference-maps/DEVICES.md @@ -1,5 +1,16 @@ # Device-enable flags (SAI / O2 / lambda) — validated +**Aug 2026 update:** re-fetched `20188Map2009AIRBOXBONNY.hex` from its real +source — it's in `tuneecu.net`'s own custom-map archive +(`Twin_Custom_Tune_list.html`, not a forum attachment as previously assumed), +not gitignore-lost this time. Re-ran the diff against a freshly downloaded +stock `20188Map.hex` and **every claim below reproduced exactly**: same +1428-byte diff (0.36%), same three device-flag bytes, same two trim bytes. +The file now lives in this directory (`20188Map2009AIRBOXBONNY.hex`, +`20187/88/91/92Map.hex`) rather than resting on memory of a prior session. +See `../COMMUNITY_TUNING.md` for the new composability findings (device +flags vs. Arrow exhaust delta) this re-verification enabled. + ## How they were found Both stock reference maps (20187, 20188) have SAI and O2 **active**, so diffing @@ -66,3 +77,80 @@ stock map can run lean/rough. A proper delete pairs flags-off with fuel enrichme **Recommendation:** flash a complete, known-good delete map matching the hardware (exhaust/filters), not hand-toggled flags on a stock map. The checkbox editor is for understanding/building a map, not a one-click safe delete. + +## `0x53822` — the extra flag found on the America dyno tune (Aug 2026) + +Pushed on identifying this (index 33 past the `0x53801` base, one slot +past the two O2 sensors — see `COMMUNITY_TUNING.md`'s original finding on +`20184dynoTuneSteveO2-Disable.hex`). Real, bounded progress, not a full +resolution: + +**Found the actual device name list** — `R.array.Devices` in +`arrays.xml:3-18`, 15 entries: `SAI, Exhaust Valve, O2 Sensor, 2nd +Throttle, Air Flap, EPC, O2 Sensor (2), Purge Valve, Idle Speed Control, +Traction Control, Immobilizer, Instruments, ABS, Speed Sensor (non ABS), +Race ECU`. This independently confirms and extends the existing SAI(0)/O2 +Sensor(23)/O2 Sensor(2)(24) findings above — matches exactly, real data. + +**Found the mechanism**: `l.java`'s `lc()` (`l.java:5742`) resolves the +per-device flat-ROM byte-boolean array from three packed nibble-encoded +constants (`x.t`/`x.s`/`x.q` in the decompile referenced by +`docs/ROADMAP.md`), looked up against `R.array.Devices` by index. **This +confirms flat-ROM byte position and `Devices[]` array index are not the +same thing** — SAI (array index 0) sits at byte 0, but O2 Sensor (array +index 2) sits at byte 23, O2 Sensor (2) (array index 6) sits at byte 24 — +a bike-specific sparse remapping, not a fixed formula. + +**Where it stops:** the specific per-family constants that would decode +byte 33 precisely require tracing which branch of `x.java`'s ~46 +assignments to the relevant classifier variable applies to Bonneville/T100 +specifically. `docs/ROADMAP.md`'s existing note cites `i27=72` as the +anchor for this ECU family from a prior research pass, but that exact +comparison doesn't appear in this decompile snapshot — likely version +drift (this decompile's changelog shows APK updates through v6.4.36, June +2026; the `i27=72` note may predate that). Didn't find a reliable +replacement anchor without a much larger, low-confidence search across all +46 assignment sites. + +**Best-supported guess, reasoning not proof:** narrowing the 12 unassigned +`Devices[]` names by plausibility for a 2010 mechanical-odo Bonneville/ +America-class twin — **Purge Valve** (this project's own `system1.html` +research already documents a "Canister Purge Valve, California models +only" on this ECU family — a real, independently-corroborated candidate), +**Idle Speed Control** (the ISC stepper motor genuinely exists on this +engine), and **Immobilizer** (period-correct fuel-injected Triumphs +commonly used transponder immobilizers) are the most plausible of the 12; +**Exhaust Valve**, **2nd Throttle**, **ABS**, **Traction Control**, **Race +ECU** are implausible for this specific bike/era and can likely be ruled +out. Not confirmed — a genuine guess ranked by plausibility, not a finding. + +**Update (Aug 2026) — independently verified, raises confidence further:** +checked `tests.html` in this project's own site mirror directly and found +a distinct, real testable menu entry: **"Purge Control Valve (Only bikes +with charcoal canister) — Activate the purge valve, listen for a very +quiet noise,"** listed right alongside its own separate "SAI" test entry. +This confirms Purge Valve is a real, independent device on this ECU +family with its own dedicated test/enable path, not a guess — genuinely +strengthens (does not yet prove) the byte-33 hypothesis. Also confirms +from the same list: "Air Flap (675 Daytona)" is explicitly Daytona-675-only, +matching this document's elimination reasoning above. (Surfaced by an +external research pass, then independently confirmed here directly +against this project's own source rather than trusted secondhand — see +`../EXTERNAL_RESEARCH_NOTES.md`.) + +**What would actually resolve this:** a second real-world reference map +where the specific device at byte 33 changes state with a documented +description (the same technique that resolved SAI/O2 originally) would +settle it definitively without needing the `x.t`/`x.s`/`x.q` trace at all. + +## Open question raised by community research (unresolved) + +Multiple forum threads describe TuneECU's own SAI/O2 checkboxes (the same UI +these bytes drive) as **only suppressing the fault-code/CEL logic**, not +actually forcing open-loop fueling — see `../COMMUNITY_TUNING.md`. That's in +tension with "disabling O2 forces open-loop" above. Possible reconciliation: +the three device-enable bytes gate DTC logic only, and the *fueling* behavior +change lives in the other bytes the real delete-map diff also touched +(`0x5369C`, `0x536AB`) or in the lambda-target curve (`fe[2]`/`0x52260`, +`TABLES.md`). Not yet isolated — needs a second independent delete-map diff +to separate device flags from fuel/trim bytes. diff --git a/tunie/research/reference-maps/PERMUTATIONS.md b/tunie/research/reference-maps/PERMUTATIONS.md new file mode 100644 index 0000000..07c7d09 --- /dev/null +++ b/tunie/research/reference-maps/PERMUTATIONS.md @@ -0,0 +1,100 @@ +# Full mechanical-odo Bonneville permutation grid — cross-diff (Aug 2026) + +Downloaded and cross-diffed every official mechanical-odo Bonneville +calibration TuneECU ships for the exhaust x fuel grid (up to VIN 739050): +stock production/aftermarket, Arrow 2-in-1, Arrow 2-in-2, each at E10 and +E25, across both catalog batches (`t202`/`t203`). 12 files, all reconstructed +to the flat 384 KB ROM with `reconstruct_rom.py` and diffed byte-for-byte. + +IDs used: `20187 20188 20191 20192` (stock), `20262 20263 20264 20265` +(`t202` Arrow), `20313 20314 20315 20316` (`t203` Arrow). + +## t202 vs t203: same tune, reissued + +Every `t202`/`t203` pair with an identical catalogue description (e.g. +`20262` vs `20313`, both "Arrow 2 in 1, E10") differs by only **10 bytes**, +in two groups: + +- 8 bytes at `0x4FFFA-B/E-F` and `0x5FFFA-B/E-F` — right at the `0x50000`/ + `0x60000` block boundaries, almost certainly per-block ID/checksum + metadata (consistent with `DEVICES.md`/`README.md`'s prior finding that + the `caXX` footer bytes are map-ID metadata, not a calibration checksum). +- 2 bytes at `0x53626-27` — one real 16-bit calibration value changed. Small, + single-cell revision. + +**Conclusion: `t202` and `t203` aren't different calibrations, just a +catalog-ID reissue with one tiny tweak.** Resolves the earlier open question +("which of 20262 vs 20313 is correct for us") — it doesn't matter, use +either. + +## Fuel delta (E10 -> E25) is real, consistent, and localized + +Matched pairs (same exhaust, VIN range, everything else equal, only fuel +spec differs): + +| Pair | Exhaust | Diff | +|---|---|---| +| `20262` vs `20263` | Arrow 2-in-1 | 4809 bytes (1.22%) | +| `20264` vs `20265` | Arrow 2-in-2 | 4562 bytes (1.16%) | +| `20313` vs `20314` | Arrow 2-in-1 (t203) | 4809 bytes — **identical set** to `20262`/`20263` | +| `20315` vs `20316` | Arrow 2-in-2 (t203) | 4562 bytes — **identical set** to `20264`/`20265` | +| `20187` vs `20191` | stock production | only 60 bytes (0.02%) | + +The E10->E25 delta for Arrow 2-in-1 vs Arrow 2-in-2 overlaps 93% (4455 of +4562-4809 bytes) — same fuel-table region touched regardless of exhaust, +which is exactly what you'd expect (ethanol compensation lives in the fuel +tables, not the exhaust-specific cells). The stock-production E10->E25 delta +being tiny (60 bytes) while the Arrow one is ~80x larger is odd at first +glance, but `20187` carries no VIN-range/fuel spec in its description at +all — it and `20191` likely aren't a clean matched pair the way the Arrow +E10/E25 pairs are (`20191` adds a VIN-range qualifier `20187` lacks). Treat +`20262`/`20263` as the trustworthy E10-vs-E25 reference, not `20187`/`20191`. + +## Exhaust delta and fuel delta are NOT independent/disjoint + +Checked whether the "stock -> Arrow 2-in-1" delta and the "E10 -> E25" delta +touch separate bytes (which would let us compose them by simple +copy/overlay): + +- Exhaust delta (`20187` -> `20262`): 7548 bytes +- Fuel delta (`20262` -> `20263`): 4809 bytes +- **Overlap: 4149 bytes** — the large majority of the fuel delta's footprint + is inside the exhaust delta's footprint too. + +**This means naive delta composition (baseline + delta A's changed bytes + +delta B's changed bytes) doesn't work cleanly** — both mods rewrite +overlapping cells in the same VE/fuel tables (expected: richness-for-exhaust +and richness-for-ethanol are both corrections to the same underlying fuel +surface, so of course they land on the same cells). You can't just XOR two +independent byte-level diffs onto a base map; you'd overwrite one mod's +change with the other's rather than combining them. Real composition would +need table-level math (read both as VE values, apply both corrections +arithmetically), not byte-patching — a materially bigger undertaking than +`toggle_devices.py`'s simple flag flip. + +## What this means for "build our own Arrow + SAI/O2-delete tune" + +**Good news:** the exhaust x fuel grid is *already solved* — TuneECU +engineers already shipped the exact validated combination you want +(`20262`/`20313`, Arrow 2-in-1, E10). No synthesis needed there; picking the +catalog entry is strictly better than trying to reconstruct it from deltas. + +**Bad news, unchanged from `COMMUNITY_TUNING.md`:** none of these 12 files +vary the SAI/O2-delete axis — TuneECU never shipped an Arrow-exhaust variant +with devices off. The grid we now have doesn't help fill that gap, because +the gap isn't on this grid. We're still bottlenecked on the single external +`2009AIRBOXBONNY.hex` reference (not currently in the repo, needs +re-fetching) for that axis, and even that reference conflates "no airbox" +with "no SAI/no O2" in one diff (per `DEVICES.md`), which the exhaust-vs-fuel +overlap finding above suggests may be a *real* problem, not just a +theoretical one — table-level effects don't separate cleanly by simple +byte diffing when two mods land on the same cells. + +**Revised recommendation:** don't try to hand-synthesize "Arrow 2-in-1 + +real SAI/O2 delete" from byte deltas — the overlap finding shows that's not +sound with the tooling we have (byte-diff, not semantic table-diff). Either: +1. Decode the fuel tables to actual VE values (`table_map.py`/`TABLES.md` + already locate them) and do the composition arithmetically, cell by cell, + rather than at the byte level — a real project, not a script tweak. +2. Or just use a real matched commercial tune (DNK, since TTP's storefront is + effectively defunct) rather than DIY-composing one. diff --git a/tunie/research/reference-maps/TABLES.md b/tunie/research/reference-maps/TABLES.md index 9c3fd1e..a72f9d5 100644 --- a/tunie/research/reference-maps/TABLES.md +++ b/tunie/research/reference-maps/TABLES.md @@ -48,6 +48,91 @@ these richer. Exact dimensions still being confirmed. giving ~1.3–60°; the low-RPM row 90→14→20 fits a retard-then-advance curve). TBD. - **AFR** 128 = λ1.00 is solid (standard Keihin convention). +## Fuel/ignition trim array — RPM-indexed, 32 entries, `0x53690`-`0x536AF` + +**Located and axis-mapped (Aug 2026).** A 32-byte array sitting just before +the device-flags region (`0x53801`, `TABLES.md` above / `DEVICES.md`), one +byte per **RPM breakpoint on the same 32-point RPM axis** as the main +tables (`fe[8]`: 0, 500, 900, 1000, ..., 10000). Not addressed via a `fe[]` +pointer for the maps checked (no `fe` field in `c.a[Qd*48]` equals `0x3690` +for `20187`'s `Qd=32`) — likely a fixed offset relative to the device-flags +struct rather than an independently relocatable table, unconfirmed. + +Evidence for the RPM-axis mapping: diffing `20188Map2009AIRBOXBONNY.hex` +(the Bonneville SAI/O2 delete map) against stock `20188` shows **exactly 2 +of the 32 cells changed — at RPM=2400 and RPM=7000** — with all 30 other +RPM-indexed cells byte-identical. That's `0x5369C` (index 12, RPM 2400) and +`0x536AB` (index 27, RPM 7000) from earlier sessions, now understood as two +specific points on this curve rather than free-floating bytes. + +Cross-checked against `20184dynoTuneSteveO2-Disable.hex` (America, +open-pipe + airbox-snorkel-removed + O2-disable, real dyno tune) vs. stock +`20186`: **nearly every one of the 32 cells changed**, by large, +non-monotonic swings (e.g. RPM=1000: 231→63, RPM=4400: 235→5, RPM=5200: +25→204). Not a smooth curve shift — looks like cell-by-cell dyno-session +adjustment (consistent with a human tuner nudging individual RPM points +after successive pulls) rather than a single global correction. Confirms +this is a real, independently-tunable-per-cell array, not a scalar. + +**Identity: confirmed as "Idle Fuel Trim (CO)".** Crawled tuneecu.net's full +docs site (`../tuneecu_site_mirror/`) and found the exact match in +`TuneECU_En/tests.html`, TuneECU's live-diagnostics parameter list: + +> "Idle Fuel Trim (CO): lets you adjust the fuel richness at idle." +> Available for "**Triumph without O²-Sensor only**." +> (Counterpart: "Long Term Fuel Trim... Triumph models **with** O²-Sensor +> only" — the closed-loop equivalent for bikes that still have the sensor.) + +This is a direct, official match to what we observed empirically: the table +is specifically the O2-delete-relevant trim, exactly why it's what the real +delete/dyno maps touch and the stock maps don't need to. `co_trim`/`ift_co` +string resources (`strings.xml`: "CO Trim" / "Idle Fuel Trim (CO)") back +this — "CO" = idle mixture richness, the classic idle-adjustment convention +carried over from carbureted-era tuning terminology. + +**Encoding, upgraded confidence (Aug 2026):** found TuneECU's actual "CO +Trim" edit dialog (`MainActivity.java`'s `La()`) and its value-scaling +logic. Two independent code paths converge on the same answer: + +1. The dialog's clamp/display logic (`j8()`) treats the value as a plain + signed range: `if (v < -128) v = -128; else if (v > 127) v = 127;`, + displayed as `Integer.toString(v)` — no fractional scaling. +2. A separate live-value dispatcher (`n.java`, a large switch handling + various PID-style codes) has a case doing manual two's-complement + conversion — `if (raw > 127) raw -= 256;` — immediately before storing + into the same field (`MainActivity.Mc`) the CO Trim dialog reads. + +**Conclusion: the raw byte is signed two's-complement (-128 to 127), used +directly with no multiply/divide scaling factor** — not the unsigned 0-255 +interpretation this project used everywhere earlier. **This retroactively +resolves the "255 looks like a sentinel" puzzle** from the exhaust-family +composability analysis (`COMMUNITY_TUNING.md`/`PERMUTATIONS.md`): unsigned +`255` is simply signed `-1` — an entirely ordinary, near-neutral value, not +a special case. Re-reading the earlier data with correct signs: the stock +aftermarket-silencer family sits near `-1` (neutral), production/Arrow +sits around `+69` to `+125` (already noticeably rich), and the real +SAI/O2 delete map pushes to `+117` — a large, deliberate enrichment, +exactly the physically-sensible story this parameter's name would predict. +The composability conclusion (can't cleanly merge Arrow's delta with the +delete map's delta) still holds — redone with correct signs, the combined +correction still saturates past the `+127` clamp — but now for an +intuitive reason (the needed correction is genuinely large) instead of an +unexplained sentinel. + +**Not fully closed:** the *encoding* (signed int8, no scaling) is now +well-supported by two independent sites; the exact real-world *unit* (is +`+1` precisely "+1% CO", or a nearby-but-not-identical ECU-internal unit) +isn't separately confirmed by either site. Raised from low to medium-high +confidence — upgraded, not fully resolved. + +**Practical implication for composing tunes:** don't treat this array as +"2 conflicting bytes" the way `compose_arrow_delete.py` originally did — +it's a full 32-cell curve. A conservative mod (mufflers only) may touch 2 +cells; a more aggressive one (open pipes + airbox) touches nearly all 32. +Composing two independent tunes' curves here needs the same per-cell +arithmetic treatment as the main VE tables (`PERMUTATIONS.md`), not a +handful of spot fixes. + ## SAI / O2 / lambda flags Now findable via the localised flat-ROM diff (20187 vs 20188), which isolates small diff --git a/tunie/research/reference-maps/checksum.py b/tunie/research/reference-maps/checksum.py new file mode 100644 index 0000000..9f4479e --- /dev/null +++ b/tunie/research/reference-maps/checksum.py @@ -0,0 +1,65 @@ +"""Flat-ROM checksum for the Triumph Keihin twin family -- reverse-engineered +and validated Aug 2026 (see ../WRITE_PATH.md for the full trace). + +Source: `com.tuneecu.l.Ac(int, boolean)` in the decompiled TuneECU app, +called from MainActivity's map-info display ("Checksum : %04x", flagged +"*No-OEM"/"Error" on mismatch). This is a 16-bit sum-of-words checksum over +the calibration region of the flat ROM (0x50000-0x60000 for this ECU +family -- the exact same base/end this project's own `table_map.py` already +uses), stored in the last 2 bytes of that region. + +Validated against 6 real, unmodified, official TuneECU downloads: +20187/20188/20191/20192/20262/20313 -- all computed == stored, exact. + +One deliberate non-match, informative rather than a bug: the community +SAI/O2-delete file (20188Map2009AIRBOXBONNY.hex) has the exact same stored +checksum as its unmodified base (20188Map.hex) -- whoever built it edited +a few bytes by hand and never recomputed the checksum. TuneECU's own code +path for this looks like a *display/validity* check (flags "*No-OEM" / +"Error" in the UI) rather than a proven hard ECU-side write gate -- that +community file apparently worked for people despite the stale checksum, +which is consistent with this being an app-side sanity indicator rather +than (or in addition to) something the ECU's own bootloader independently +verifies during the real flash transfer. That ECU-side question is NOT +resolved by this -- see caveats below. +""" + +from __future__ import annotations + +CAL_BASE = 0x50000 +CAL_END = 0x60000 + + +def compute(rom: bytes, base: int = CAL_BASE, end: int = CAL_END) -> int: + """16-bit sum-of-words checksum over rom[base:end-2], byte-swapped read + (matches `l.Ac()`'s `bArr[pos ^ 1] | (bArr[pos] << 8)` exactly).""" + length = (end - base) - 2 + total = 0 + i = 0 + while i < length: + pos = base + i + total += rom[pos ^ 1] | (rom[pos] << 8) + i += 2 + return total & 0xFFFF + + +def stored(rom: bytes, end: int = CAL_END) -> int: + return (rom[end - 2] << 8) | rom[end - 1] + + +def patch(rom: bytearray, base: int = CAL_BASE, end: int = CAL_END) -> None: + """Recompute and write the checksum into rom[end-2:end] in place.""" + value = compute(bytes(rom), base, end) + rom[end - 2] = (value >> 8) & 0xFF + rom[end - 1] = value & 0xFF + + +if __name__ == "__main__": + import sys + + from reconstruct_rom import flat_rom + + for path in sys.argv[1:] or ["20187Map.hex"]: + rom = flat_rom(open(path, "rb").read()) + c, s = compute(rom), stored(rom) + print(f"{path:40s} computed={c:04x} stored={s:04x} {'MATCH' if c == s else 'MISMATCH'}") diff --git a/tunie/research/reference-maps/compose_arrow_delete.py b/tunie/research/reference-maps/compose_arrow_delete.py new file mode 100644 index 0000000..8a6eee8 --- /dev/null +++ b/tunie/research/reference-maps/compose_arrow_delete.py @@ -0,0 +1,110 @@ +"""Compose an Arrow 2-in-1 + SAI/O2-delete candidate map. + +Builds on `toggle_devices.py`'s clean, conflict-free device-flag toggle +(0x53801/0x53818/0x53819 -- verified disjoint from the Arrow exhaust delta, +see COMMUNITY_TUNING.md) and additionally resolves the two "Idle Fuel Trim +(CO)" bytes that conflict between the Arrow calibration and the real +20188Map2009AIRBOXBONNY.hex delete map. + +**Aug 2026 correction:** both bytes are now composed. The original version +of this script left 0x5369C untouched, reasoning it was "exhaust-family +dependent" (stock aftermarket-silencer maps held unsigned 255, which looked +like a sentinel distinct from the ~104 held by production/Arrow maps). That +reasoning was built on an **unsigned** byte interpretation. `TABLES.md` +later established this whole table is **signed** two's-complement -128..127, +confirmed from two independent code paths in the TuneECU app. Redone in +signed space: unsigned 255 is simply signed -1 -- an entirely ordinary +near-neutral value, not a sentinel at all. There is no regime conflict; the +byte composes the same way 0x536AB always did, it just saturates. + + 0x5369C -- composed additively, signed. stock188=-1, delete=+117, delta + = +118. arrow=+69 + 118 = +187, clamped to the signed max +127. The + correction genuinely wants this cell pushed to its richest possible + value at this RPM point -- clamping to +127 is the correct outcome, not + a sign the byte is unusable, and it's a real, meaningfully different + result from the prior version's "leave at Arrow's +69" default. + + 0x536AB -- composed additively, signed, same as before (the arithmetic + is invariant to signed vs. unsigned interpretation as long as nothing + overflows the representable range, which this one doesn't): stock188 + =-128, delete=-118, delta=+10, arrow=-123 + 10 = -113 -> byte 0x8F (143). + +Usage: + python3 compose_arrow_delete.py 20262Map.hex 20188Map.hex \\ + 20188Map2009AIRBOXBONNY.hex out/20262-arrow-delete-composed.hex +""" + +from __future__ import annotations + +import sys + +from decode_map import decode, encode +from toggle_devices import SAI, O2_1, O2_2, repack, unpack + +TRIM_BYTES = (0x536AB, 0x5369C) # both now composed, signed arithmetic + + +def _s8(v: int) -> int: + return v - 256 if v >= 128 else v + + +def compose(arrow_raw: bytes, stock_after_raw: bytes, delete_raw: bytes) -> tuple[bytes, list[str]]: + arrow_dec = decode(arrow_raw) + arrow_rom, positions = unpack(arrow_dec) + arrow_rom = bytearray(arrow_rom) + + stock_rom, _ = unpack(decode(stock_after_raw)) + delete_rom, _ = unpack(decode(delete_raw)) + + notes = [] + + for addr, label in ((SAI, "SAI"), (O2_1, "O2 sensor 1"), (O2_2, "O2 sensor 2")): + before = arrow_rom[addr] + arrow_rom[addr] = 0 + notes.append(f"0x{addr:05X} {label:<14} {before} -> 0 (device flag, conflict-free)") + + for addr in TRIM_BYTES: + a_stock = _s8(stock_rom[addr]) + a_delete = _s8(delete_rom[addr]) + a_arrow = _s8(arrow_rom[addr]) + delta = a_delete - a_stock + synth = max(-128, min(127, a_arrow + delta)) + before = arrow_rom[addr] + after = synth & 0xFF + arrow_rom[addr] = after + notes.append( + f"0x{addr:05X} trim (signed) {a_arrow:+d} + delta {delta:+d} = {synth:+d}" + f" (byte {before} -> {after})" + ) + + repacked = repack(arrow_dec, bytes(arrow_rom), positions) + out = encode(repacked) + assert decode(out) == repacked, "round-trip failed" + return out, notes + + +def main() -> int: + if len(sys.argv) != 5: + print( + "usage: compose_arrow_delete.py " + " ", + file=sys.stderr, + ) + return 2 + arrow_path, stock_path, delete_path, out_path = sys.argv[1:5] + out, notes = compose( + open(arrow_path, "rb").read(), + open(stock_path, "rb").read(), + open(delete_path, "rb").read(), + ) + open(out_path, "wb").write(out) + for n in notes: + print(" ", n) + print(f"wrote {out_path} ({len(out)} bytes)") + print("NOTE: flash checksum not patched -- not known to be flashable as-is.") + print("NOTE: main VE fuel-table overlap (~1340 bytes) still unresolved -- see COMMUNITY_TUNING.md.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tunie/research/reference-maps/derived/20262-arrow-delete-composed.hex b/tunie/research/reference-maps/derived/20262-arrow-delete-composed.hex new file mode 100644 index 0000000..2fcb37d Binary files /dev/null and b/tunie/research/reference-maps/derived/20262-arrow-delete-composed.hex differ diff --git a/tunie/research/reference-maps/derived/20262-noO2-only.hex b/tunie/research/reference-maps/derived/20262-noO2-only.hex new file mode 100644 index 0000000..fc85439 Binary files /dev/null and b/tunie/research/reference-maps/derived/20262-noO2-only.hex differ diff --git a/tunie/research/reference-maps/derived/20262-noSAI-noO2.hex b/tunie/research/reference-maps/derived/20262-noSAI-noO2.hex new file mode 100644 index 0000000..068a80e Binary files /dev/null and b/tunie/research/reference-maps/derived/20262-noSAI-noO2.hex differ diff --git a/tunie/research/reference-maps/derived/20262-noSAI-only.hex b/tunie/research/reference-maps/derived/20262-noSAI-only.hex new file mode 100644 index 0000000..5c01e29 Binary files /dev/null and b/tunie/research/reference-maps/derived/20262-noSAI-only.hex differ diff --git a/tunie/research/reference-maps/derived/20313-arrow-delete-composed.hex b/tunie/research/reference-maps/derived/20313-arrow-delete-composed.hex new file mode 100644 index 0000000..ab32fde Binary files /dev/null and b/tunie/research/reference-maps/derived/20313-arrow-delete-composed.hex differ diff --git a/tunie/research/reference-maps/derived/20313-noO2-only.hex b/tunie/research/reference-maps/derived/20313-noO2-only.hex new file mode 100644 index 0000000..19301f5 Binary files /dev/null and b/tunie/research/reference-maps/derived/20313-noO2-only.hex differ diff --git a/tunie/research/reference-maps/derived/20313-noSAI-noO2.hex b/tunie/research/reference-maps/derived/20313-noSAI-noO2.hex new file mode 100644 index 0000000..fbe895e Binary files /dev/null and b/tunie/research/reference-maps/derived/20313-noSAI-noO2.hex differ diff --git a/tunie/research/reference-maps/derived/20313-noSAI-only.hex b/tunie/research/reference-maps/derived/20313-noSAI-only.hex new file mode 100644 index 0000000..5e1ad9b Binary files /dev/null and b/tunie/research/reference-maps/derived/20313-noSAI-only.hex differ diff --git a/tunie/research/reference-maps/toggle_devices.py b/tunie/research/reference-maps/toggle_devices.py new file mode 100644 index 0000000..42c29a6 --- /dev/null +++ b/tunie/research/reference-maps/toggle_devices.py @@ -0,0 +1,110 @@ +"""Flip SAI/O2 device-enable flags in a TuneECU map, in place. + +Byte-boolean array in the flat ROM: 0x53801 = SAI, 0x53818 / 0x53819 = the two +O2 sensors (1 = enabled, 0 = disabled). Found by diffing stock 20188 against the +community "NO SAI, NO O2 SENSORS" reference map -- see DEVICES.md for the full +derivation and confidence levels (SAI ~90%, O2 ~80%). + +This edits the *download-format* map (decode -> flip bytes in the flat ROM -> +repack into the decoded map's own layout -> re-encode). It does NOT touch the +ECU flash checksum (out of scope, unsolved -- see the "Editing / export" section +of DEVICES.md), so the output is for the viewer / further analysis only. It is +NOT known to be flashable as-is. + +Usage: + python3 toggle_devices.py 20262Map.hex out/20262-noSAI-noO2.hex + python3 toggle_devices.py --only-sai 20262Map.hex out/20262-noSAI.hex +""" + +from __future__ import annotations + +import argparse + +from decode_map import decode, encode + +SAI = 0x53801 +O2_1 = 0x53818 +O2_2 = 0x53819 + +FLAT_MARKER = 0x6F66 + +_LABELS = {SAI: "SAI", O2_1: "O2 sensor 1", O2_2: "O2 sensor 2"} + + +def _le(buf: bytes, o: int, n: int) -> int: + return int.from_bytes(buf[o : o + n], "little") + + +def unpack(decoded: bytes) -> tuple[bytearray, list[tuple[int, int, int]]]: + """Reconstruct the flat ROM from a decoded map; also return the (packed_pos, + dest_offset, length) triples needed to repack it afterwards.""" + i21 = _le(decoded, 28, 2) + if _le(decoded, i21 + 31, 2) != FLAT_MARKER: + raise ValueError(f"bad unpack marker at 0x{i21 + 31:X}") + count = decoded[i21 + 33] + entries = [ + (_le(decoded, i21 + 34 + k * 8, 4), _le(decoded, i21 + 38 + k * 8, 4)) + for k in range(count) + ] + p = i21 + 34 + count * 8 + rom = bytearray(b"\xff" * max(off + ln for off, ln in entries)) + positions = [] + for off, ln in entries: + rom[off : off + ln] = decoded[p : p + ln] + positions.append((p, off, ln)) + p += ln + return rom, positions + + +def repack(decoded: bytes, rom: bytes, positions: list[tuple[int, int, int]]) -> bytes: + """Inverse of unpack(): copy the (possibly modified) flat ROM back into the + decoded map's packed layout.""" + out = bytearray(decoded) + for p, off, ln in positions: + out[p : p + ln] = rom[off : off + ln] + return bytes(out) + + +def toggle(raw: bytes, addresses: tuple[int, ...]) -> tuple[bytes, list[tuple[int, str, int, int]]]: + """Return (re-encoded .hex bytes, [(address, label, before, after), ...]).""" + decoded = decode(raw) + rom, positions = unpack(decoded) + changes = [] + for addr in addresses: + before = rom[addr] + rom[addr] = 0 + changes.append((addr, _LABELS.get(addr, f"0x{addr:X}"), before, 0)) + repacked = repack(decoded, bytes(rom), positions) + out = encode(repacked) + assert decode(out) == repacked, "round-trip failed after repack" + return out, changes + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("infile") + ap.add_argument("outfile") + ap.add_argument("--only-sai", action="store_true", help="disable SAI only") + ap.add_argument("--only-o2", action="store_true", help="disable both O2 sensors only") + args = ap.parse_args() + + if args.only_sai: + addrs = (SAI,) + elif args.only_o2: + addrs = (O2_1, O2_2) + else: + addrs = (SAI, O2_1, O2_2) + + raw = open(args.infile, "rb").read() + out, changes = toggle(raw, addrs) + open(args.outfile, "wb").write(out) + + for addr, label, before, after in changes: + print(f" 0x{addr:05X} {label:<14} {before} -> {after}") + print(f"wrote {args.outfile} ({len(out)} bytes)") + print("NOTE: flash checksum not patched -- not known to be flashable as-is.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tunie/research/tuneecu_crawl.py b/tunie/research/tuneecu_crawl.py new file mode 100644 index 0000000..f20385c --- /dev/null +++ b/tunie/research/tuneecu_crawl.py @@ -0,0 +1,148 @@ +"""Polite same-domain BFS crawler for tuneecu.net -- mirrors the docs site +locally so we can grep/read full raw pages instead of relying on one-page- +at-a-time AI-summarized fetches (which have already been shown to drop +detail, e.g. the glossary needing a second full pass). + +No robots.txt on tuneecu.net (404s). Still polite: single-threaded, delay +between requests, capped page count, HTML only (binaries/zips are recorded +as leaf links but not fetched/recursed into -- there's no reason to mirror +the whole map-download tree, just discover what's there). + +Usage: + python3 tuneecu_crawl.py # default seeds, depth 10 + python3 tuneecu_crawl.py --max-pages 300 +""" + +from __future__ import annotations + +import argparse +import re +import time +import urllib.error +import urllib.request +from collections import deque +from pathlib import Path +from urllib.parse import urljoin, urlparse + +DOMAIN = "tuneecu.net" +OUT_DIR = Path(__file__).parent / "tuneecu_site_mirror" +SEEDS = [ + "https://tuneecu.net/TuneECU_En/index.html", + "https://tuneecu.net/TuneECU_En/links.html", + "https://tuneecu.net/TuneECU_En/glossary.html", + "https://tuneecu.net/TuneECU_En/mapedit.html", + "https://tuneecu.net/Map_Database.html", +] +HREF_RE = re.compile(r'href\s*=\s*["\']([^"\']+)["\']', re.I) +HTML_EXTS = (".html", ".htm", "") # "" covers dir-style URLs, treated as html + + +NON_HTML_EXTS = ( + ".zip", ".hex", ".pdf", ".mp4", ".jpg", ".jpeg", ".png", ".gif", + ".dat", ".md5", ".exe", ".bin", ".rar", ".7z", ".doc", ".docx", +) +# Leaf file tree -- thousands of per-model tune files/checksums, not docs. +# Record links into it (so we know what's there) but never recurse. +NO_RECURSE_PREFIXES = ("https://tuneecu.net/tunes_in_hex_and_dat/",) + + +def _is_html(url: str) -> bool: + path = urlparse(url).path.lower() + if any(path.endswith(ext) for ext in NON_HTML_EXTS): + return False + return True + + +def _no_recurse(url: str) -> bool: + return url.lower().startswith(NO_RECURSE_PREFIXES) + + +def _same_domain(url: str) -> bool: + return urlparse(url).netloc.lower().endswith(DOMAIN) + + +def _local_path(url: str) -> Path: + p = urlparse(url) + rel = (p.netloc + p.path).strip("/") + if not rel or rel.endswith("/"): + rel += "index.html" + return OUT_DIR / rel + + +def crawl(max_depth: int, max_pages: int, delay: float) -> None: + OUT_DIR.mkdir(exist_ok=True) + visited: set[str] = set() + prior = OUT_DIR / "_visited.txt" + if prior.exists(): + visited |= {ln.strip() for ln in prior.read_text().splitlines() if ln.strip()} + print(f"preloaded {len(visited)} already-visited URLs, will skip re-fetching them") + all_links: set[str] = set() # every link seen, html or not + queue: deque[tuple[str, int]] = deque((s, 0) for s in SEEDS) + prior_links = OUT_DIR / "_all_links.txt" + if prior_links.exists(): + all_links |= {ln.strip() for ln in prior_links.read_text().splitlines() if ln.strip()} + for u in sorted(all_links): + if _same_domain(u) and _is_html(u) and not _no_recurse(u) and u not in visited: + queue.append((u, 1)) + print(f"seeded queue with {len(queue)} unfetched html links from prior run") + fetched = 0 + + while queue and fetched < max_pages: + url, depth = queue.popleft() + if url in visited or depth > max_depth: + continue + visited.add(url) + + req = urllib.request.Request(url, headers={"User-Agent": "tunie-research-crawler/1.0"}) + try: + with urllib.request.urlopen(req, timeout=20) as r: + body = r.read() + except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as exc: + print(f" skip {url}: {exc}") + continue + + fetched += 1 + dest = _local_path(url) + try: + if dest.is_dir(): + dest = dest / "index.html" + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(body) + except (IsADirectoryError, NotADirectoryError, FileNotFoundError) as exc: + print(f" write skip {url}: {exc}") + print(f"[{fetched}/{max_pages}] depth={depth} {url} ({len(body)}B)") + + try: + text = body.decode("utf-8", "replace") + except Exception: + text = "" + + for href in HREF_RE.findall(text): + absu = urljoin(url, href).split("#")[0] + if not absu.startswith("http"): + continue + all_links.add(absu) + if ( + _same_domain(absu) + and _is_html(absu) + and not _no_recurse(absu) + and absu not in visited + and depth + 1 <= max_depth + ): + queue.append((absu, depth + 1)) + + time.sleep(delay) + + (OUT_DIR / "_all_links.txt").write_text("\n".join(sorted(all_links))) + (OUT_DIR / "_visited.txt").write_text("\n".join(sorted(visited))) + print(f"\nFetched {fetched} pages, {len(visited)} visited, {len(all_links)} total links discovered.") + print(f"Mirror in {OUT_DIR}") + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--max-depth", type=int, default=10) + ap.add_argument("--max-pages", type=int, default=250) + ap.add_argument("--delay", type=float, default=0.4) + args = ap.parse_args() + crawl(args.max_depth, args.max_pages, args.delay) diff --git a/tunie/src/tunie/cli.py b/tunie/src/tunie/cli.py index f58c66f..8aba553 100644 --- a/tunie/src/tunie/cli.py +++ b/tunie/src/tunie/cli.py @@ -1,6 +1,8 @@ """Command line entry point. tunie ports list candidate serial devices + tunie bt-reset --address .. disconnect + reconnect a Bluetooth adapter + tunie raw --port ... adapter-only link diagnostic (no ECU traffic) tunie info --port ... interrogate the ECU (read-only) tunie dtc --port ... read diagnostic trouble codes only tunie maps --search ... query the offline TuneECU map database @@ -9,8 +11,11 @@ from __future__ import annotations import argparse +import datetime import logging import sys +import time +from pathlib import Path from . import triumph from .identify import interrogate @@ -21,6 +26,54 @@ from .transport.elm327 import Elm327Transport from .transport.kline import KLineTransport +class _RunLogger: + """Tees every line printed through it to both the terminal and a + timestamped file in ``logs/``, so a session's output survives after the + terminal scrollback doesn't. One file per run -- the timestamp in the + filename makes the latest one obvious (``ls -t logs/`` or just look at + the last one alphabetically, since the format sorts chronologically).""" + + def __init__(self, command: str, log_dir: Path = Path("logs")) -> None: + log_dir.mkdir(exist_ok=True) + stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + self.path = log_dir / f"tunie-{command}-{stamp}.txt" + self._fh = self.path.open("w") + + def log(self, message: str = "", *, err: bool = False) -> None: + print(message, file=sys.stderr if err else sys.stdout) + print(message, file=self._fh) + self._fh.flush() + + def close(self) -> None: + self._fh.close() + + +def _connect_with_retries( + transport: Transport, log: "_RunLogger", retries: int, delay: float +) -> None: + """Retry the connection attempt (re-running fast/slow init) up to + ``retries`` times. This does NOT power-cycle the bike -- if the ECU + genuinely needs the ignition cycled, no amount of software retrying + substitutes for that; this only re-attempts the wake-up sequence on the + already-open link, which covers the "took 2-3 tries" flakiness reported + even by experienced TuneECU users.""" + last_exc: TransportError | None = None + for attempt in range(1, retries + 1): + try: + transport.connect() + return + except TransportError as exc: + last_exc = exc + if attempt < retries: + log.log( + f"Attempt {attempt}/{retries} failed ({exc}); " + f"retrying in {delay:.0f}s..." + ) + time.sleep(delay) + assert last_exc is not None + raise last_exc + + def _build_transport(args: argparse.Namespace) -> Transport: common = dict(target=args.target, source=args.source, init=args.init) if args.adapter == "elm327": @@ -53,27 +106,191 @@ def _cmd_ports(_: argparse.Namespace) -> int: def _cmd_info(args: argparse.Namespace) -> int: transport = _build_transport(args) - print( + log = _RunLogger("info") + log.log( f"Connecting on {args.port} via {args.adapter}, " f"target 0x{args.target:02X} source 0x{args.source:02X}, {args.init} init..." ) try: - with transport: + try: + _connect_with_retries(transport, log, args.retries, args.retry_delay) report = interrogate(transport) + finally: + # Must run even when every retry failed -- otherwise the serial + # handle from the last failed attempt stays open, which on a + # Bluetooth RFCOMM channel can wedge the *next* run's connect. + transport.close() except TransportError as exc: - print(f"\nConnection failed: {exc}", file=sys.stderr) - print( + log.log(f"\nConnection failed after {args.retries} attempt(s): {exc}", err=True) + log.log( "\nThings to check, in order:\n" " 1. Ignition on, engine off. The ECU sleeps otherwise.\n" " 2. Diagnostic connector pinout -- confirm before suspecting anything else.\n" " 3. Try --init slow (5-baud) if fast init times out.\n" - " 4. On an ELM327 clone, KWP support is often broken; try the KKL cable.", - file=sys.stderr, + " 4. If retries didn't help, the ignition may need a real power cycle --\n" + " turn it off, wait a few seconds, back on, and re-run.\n" + " 5. On an ELM327 clone, KWP support is often broken; try the KKL cable.", + err=True, ) + log.log(f"\n(full session log: {log.path})", err=True) + log.close() return 1 - print() - print(report.render()) + log.log() + log.log(report.render()) + log.log(f"\n(full session log: {log.path})") + log.close() + return 0 + + +def _cmd_bt_reset(args: argparse.Namespace) -> int: + """Disconnect + reconnect a Bluetooth adapter via ``blueutil``, as a + scriptable stand-in for the manual "forget it, pair fresh" fix that has + repeatedly cleared a wedged vLinker RFCOMM channel this session. This is + NOT a full unpair -- unpairing needs a physical PIN/button confirmation + on most OBD adapters and can't be automated -- but a clean disconnect + followed by a reconnect exercises the same "tear down and renegotiate + the channel" path that fixed it by hand, without losing the pairing. + """ + import shutil + import subprocess + + log = _RunLogger("bt-reset") + blueutil = shutil.which("blueutil") + if blueutil is None: + log.log( + "blueutil not found -- install it with: brew install blueutil", + err=True, + ) + log.close() + return 1 + + def run(*cmd: str) -> subprocess.CompletedProcess: + log.log(f"$ {' '.join(cmd)}") + result = subprocess.run(cmd, capture_output=True, text=True) + if result.stdout.strip(): + log.log(result.stdout.strip()) + if result.stderr.strip(): + log.log(result.stderr.strip(), err=True) + return result + + info = run(blueutil, "--info", args.address) + if "not paired" in info.stdout: + # Cheap BT-SPP OBD dongles like this one don't reliably keep a system + # pairing on macOS -- `--connect` alone can silently no-op if the + # pairing itself has lapsed, which is different from the RFCOMM + # channel just being stale. `--pair` re-establishes it with the + # fixed PIN these adapters use, no interactive dialog needed. + log.log(f"not paired -- pairing with PIN {args.pin}...") + run(blueutil, "--pair", args.address, args.pin) + time.sleep(args.settle) + + run(blueutil, "--disconnect", args.address) + time.sleep(args.settle) + run(blueutil, "--connect", args.address) + time.sleep(args.settle) + info = run(blueutil, "--info", args.address) + + connected = "connected" in info.stdout and "not connected" not in info.stdout + log.log(f"\n{'connected' if connected else 'NOT connected'} after reset") + log.log(f"(full session log: {log.path})") + log.close() + return 0 if connected else 1 + + +def _cmd_raw(args: argparse.Namespace) -> int: + """Adapter-only link diagnostic: open the port, send one raw command, + show exactly what comes back. Bypasses tunie's KWP2000 layer entirely -- + for an ELM327-class adapter this is plain AT-command traffic between the + host and the adapter, never reaches the vehicle bus (unless the command + itself is an OBD PID query), so it's safe to run to check whether an + adapter is actually answering before suspecting anything about the bike. + This replaces reaching for `screen` for the same check -- one-shot, + scriptable, shows the raw bytes instead of relying on eyeballing a + terminal. + """ + import serial as pyserial + + log = _RunLogger("raw") + if args.hex: + try: + payload = bytes.fromhex(args.command.replace(" ", "")) + except ValueError as exc: + log.log(f"bad --hex command {args.command!r}: {exc}", err=True) + log.close() + return 2 + else: + payload = (args.command + "\r").encode("ascii", "replace") + + log.log(f"Opening {args.port} @ {args.baud} baud (settle {args.settle:.1f}s)...") + try: + ser = pyserial.Serial(args.port, baudrate=args.baud, timeout=0.2) + except pyserial.SerialException as exc: + log.log(f"could not open {args.port}: {exc}", err=True) + log.close() + return 1 + + try: + time.sleep(args.settle) + ser.reset_input_buffer() + log.log(f"-> {payload!r}") + ser.write(payload) + ser.flush() + + buf = bytearray() + deadline = time.monotonic() + args.timeout + while time.monotonic() < deadline: + chunk = ser.read(64) + if chunk: + buf.extend(chunk) + deadline = min(deadline, time.monotonic() + args.idle) + finally: + ser.close() + + if not buf: + log.log(f"<- nothing received in {args.timeout:.1f}s") + log.log( + "\nNothing came back at all -- this is the OS<->adapter link, not " + "tunie's protocol handling. Depending on adapter type:\n" + " Bluetooth (ELM327): re-pair it (tunie bt-reset --address ...,\n" + " or forget it in Bluetooth settings and pair fresh).\n" + " Wired (KKL/FTDI, K-Line): a raw K-Line is half-duplex, so " + "*anything*\n" + " you write is normally echoed straight back -- getting nothing " + "here\n" + " usually means the adapter/wiring itself, not ignition state: " + "check\n" + " the connector pinout, that the FTDI driver is actually bound " + "to\n" + " this port (tunie ports), and try --baud 10400 (KWP_BAUD) if " + "you\n" + " weren't already using it." + ) + log.log(f"\n(full session log: {log.path})", err=True) + log.close() + return 1 + + log.log(f"<- {len(buf)} bytes:") + log.log(f" hex : {bytes(buf).hex(' ')}") + log.log(f" ascii : {bytes(buf).decode('ascii', 'replace')!r}") + if buf == payload: + log.log( + "\nExact echo of what was sent -- for a raw K-Line (KKL/FTDI) " + "adapter this IS the expected good result: it means the wire, " + "transceiver, and pinout are electrically sound (K-Line is " + "half-duplex -- your own bytes always come back). It does NOT " + "mean the ECU answered anything; only a real KWP2000 exchange " + "(tunie info --adapter kline) exercises that." + ) + else: + log.log( + "\nSomething answered, and it's not a plain echo of what was " + "sent -- the link is alive. If this was an ELM327 adapter and the " + "response doesn't look like an ELM327 banner/prompt, the issue is " + "likely in how tunie is talking to it, not the link itself." + ) + log.log(f"\n(full session log: {log.path})") + log.close() return 0 @@ -81,15 +298,23 @@ def _cmd_dtc(args: argparse.Namespace) -> int: from .identify import EcuReport, _read_dtcs transport = _build_transport(args) + log = _RunLogger("dtc") try: - with transport: + try: + _connect_with_retries(transport, log, args.retries, args.retry_delay) report = EcuReport(key_bytes=transport.key_bytes) report.dtcs = _read_dtcs(transport, report) + finally: + transport.close() except TransportError as exc: - print(f"Connection failed: {exc}", file=sys.stderr) + log.log(f"Connection failed after {args.retries} attempt(s): {exc}", err=True) + log.log(f"\n(full session log: {log.path})", err=True) + log.close() return 1 - print(report.render()) + log.log(report.render()) + log.log(f"\n(full session log: {log.path})") + log.close() return 0 @@ -160,9 +385,44 @@ def main(argv: list[str] | None = None) -> int: help=f"tester address (default 0x{triumph.TESTER_ADDRESS_KLINE:02X})", ) p.add_argument("--baud", type=int, default=None, help="override the serial baud rate") + p.add_argument( + "--retries", + type=int, + default=3, + help="connection attempts before giving up (default 3) -- each " + "failure prints immediately, this does not power-cycle the bike", + ) + p.add_argument( + "--retry-delay", + type=float, + default=5.0, + help="seconds to wait between retry attempts (default 5.0)", + ) sub.add_parser("ports", help="list serial ports").set_defaults(func=_cmd_ports) + p_bt = sub.add_parser( + "bt-reset", help="disconnect + reconnect a Bluetooth adapter (via blueutil)" + ) + p_bt.add_argument("--address", required=True, help="Bluetooth MAC, e.g. 04-25-E8-5B-03-75") + p_bt.add_argument("--settle", type=float, default=3.0, help="seconds to wait after each step (default 3.0)") + p_bt.add_argument("--pin", default="1234", help="pairing PIN if not already paired (default 1234, common for OBD BT-SPP dongles)") + p_bt.set_defaults(func=_cmd_bt_reset) + + p_raw = sub.add_parser( + "raw", help="adapter-only link diagnostic (send one raw command, show the raw response)" + ) + p_raw.add_argument("--port", required=True, help="serial device (see: tunie ports)") + p_raw.add_argument("--baud", type=int, default=38400, help="baud rate (default 38400, ELM327's default)") + p_raw.add_argument("--command", default="ATZ", help="command to send (default: ATZ, adapter reset)") + p_raw.add_argument( + "--hex", action="store_true", help="treat --command as raw hex bytes instead of an AT-style text command" + ) + p_raw.add_argument("--settle", type=float, default=2.0, help="seconds to wait after opening before sending (default 2.0)") + p_raw.add_argument("--timeout", type=float, default=5.0, help="max seconds to wait for a response (default 5.0)") + p_raw.add_argument("--idle", type=float, default=1.0, help="stop once this many seconds pass with no new bytes (default 1.0)") + p_raw.set_defaults(func=_cmd_raw) + p_info = sub.add_parser("info", help="interrogate the ECU (read-only)") add_connection_args(p_info) p_info.set_defaults(func=_cmd_info) diff --git a/tunie/src/tunie/identify.py b/tunie/src/tunie/identify.py index 5f73a47..265c002 100644 --- a/tunie/src/tunie/identify.py +++ b/tunie/src/tunie/identify.py @@ -109,6 +109,21 @@ def _dtc_name(code: int) -> str: return f"{letter}{(code >> 8) & 0x3F:02X}{code & 0xFF:02X}" +def _keepalive(transport: Transport) -> None: + """Send TesterPresent (SID 0x3E) so the ECU's diagnostic session doesn't + lapse between reads. TuneECU's own decompiled code (m.java's ``hd()``) + sends exactly this -- ``{0x3E, 0x00}`` -- and calls it on every timeout + tick, not just at connect time: ISO14230 sessions die after a few seconds + of silence and the ECU reverts to normal mode, which is what turned every + query in a real run into a fresh (and much slower, often losing) bus-init + instead of a quick read. Best-effort: if the adapter is mid-recovery this + can fail too, but the next real request will surface that on its own.""" + try: + transport.request(bytes([k.Sid.TESTER_PRESENT, 0x00]), timeout_ms=2000) + except (TransportError, k.Kwp2000Error): + pass + + def interrogate( transport: Transport, *, @@ -119,8 +134,11 @@ def interrogate( report = EcuReport(key_bytes=transport.key_bytes) for record in ecu_id_records: + _keepalive(transport) try: - data = transport.request(bytes([k.Sid.READ_ECU_IDENTIFICATION, record])) + data = transport.request( + bytes([k.Sid.READ_ECU_IDENTIFICATION, record]), timeout_ms=2000 + ) except k.NegativeResponse as exc: log.debug("0x1A 0x%02X rejected: %s", record, exc) continue @@ -131,8 +149,11 @@ def interrogate( report.identification[record] = data[1:] if data[:1] == bytes([record]) else data for record in local_id_records: + _keepalive(transport) try: - data = transport.request(bytes([k.Sid.READ_DATA_BY_LOCAL_ID, record])) + data = transport.request( + bytes([k.Sid.READ_DATA_BY_LOCAL_ID, record]), timeout_ms=2000 + ) except k.NegativeResponse as exc: log.debug("0x21 0x%02X rejected: %s", record, exc) continue @@ -153,6 +174,7 @@ def _read_dtcs(transport: Transport, report: EcuReport) -> list[tuple[int, int]] ) for label, payload in attempts: + _keepalive(transport) try: data = transport.request(payload, timeout_ms=2000) except k.NegativeResponse as exc: diff --git a/tunie/src/tunie/transport/elm327.py b/tunie/src/tunie/transport/elm327.py index 67308ec..04a20b2 100644 --- a/tunie/src/tunie/transport/elm327.py +++ b/tunie/src/tunie/transport/elm327.py @@ -68,11 +68,34 @@ class Elm327Transport(Transport): decompiled APK: ``a3`` for fast init, ``W2`` for 5-baud init. Both end by setting header ``D5 F5``. """ - self._serial = serial.Serial(self.port, baudrate=self.baud, timeout=1.0) - time.sleep(0.5) + if self._serial is not None: + # TuneECU's own BluetoothService (com.tuneecu.d) always closes the + # prior RFCOMM socket before opening a new one on retry -- leaving + # a stale handle open while a second one negotiates is exactly the + # kind of thing that can wedge a Bluetooth SPP channel, unlike a + # plain wired serial port where re-opening is harmless. + try: + self._serial.close() + except Exception: + pass + self._serial = None + + try: + self._serial = serial.Serial(self.port, baudrate=self.baud, timeout=1.0) + except serial.SerialException as exc: + raise TransportError(f"could not open {self.port}: {exc}") from exc + # Opening a Bluetooth SPP device file succeeds as soon as the OS + # creates the handle -- the actual RFCOMM channel can still be + # negotiating for a few seconds after that, independent of whether + # the adapter's own "connected" LED is already lit (that reflects + # the Bluetooth link layer, not this serial channel). 0.5s is fine + # for a wired FTDI adapter; give Bluetooth adapters more room before + # the first command, rather than treating silence as a protocol + # failure this early. + time.sleep(2.0) self._serial.reset_input_buffer() - identity = self._at("Z", timeout=5.0) # reset + identity = self._at("Z", timeout=8.0) # reset log.info("adapter identifies as: %s", identity) if "ELM" not in identity.upper() and "OBD" not in identity.upper(): log.warning("adapter did not report an ELM327 identity string") @@ -84,14 +107,30 @@ class Elm327Transport(Transport): response = self._command(command, timeout=5.0) if "OK" not in response.upper() and command not in ("ATZ",): log.warning("%s returned %r", command, response) + if "BUS INIT" in response.upper() and "ERROR" in response.upper(): + raise TransportError(f"bus init failed: {response!r}") - # TuneECU then sends the bare StartCommunication service, "81", which is - # what actually triggers the bus init. Route it through the normal - # request path so the read-only guard sees it. - data = self.request(bytes([k.Sid.START_COMMUNICATION]), timeout_ms=5000) - if len(data) >= 2: - self.key_bytes = (data[0], data[1]) - log.info("ECU key bytes: 0x%02X 0x%02X", data[0], data[1]) + if self.init == "fast": + # ATTP5 fast init does NOT complete StartCommunication on its own -- + # TuneECU's own `a3` command list ends by explicitly sending the bare + # SID "81" as its own AT payload after the header, and the app then + # waits on it for the ECU's key bytes. Route it through the normal + # request path so the read-only guard sees it. + data = self.request(bytes([k.Sid.START_COMMUNICATION]), timeout_ms=5000) + if len(data) >= 2: + self.key_bytes = (data[0], data[1]) + log.info("ECU key bytes: 0x%02X 0x%02X", data[0], data[1]) + # else: ATTP4 slow (5-baud) init performs the whole ISO14230 handshake + # -- sync byte, key bytes, inverted key/address exchange -- inside the + # ELM327's own firmware; "BUS INIT: OK" above already *is* a completed + # StartCommunication. TuneECU's own decompiled code (m.java's `Bd()`, + # dispatched from `Ue(MODE_READ_IDENT)` right after slow init) confirms + # this: it never sends an explicit "81" for this path -- it goes + # straight to a real diagnostic service (ReadDataByLocalIdentifier, + # `{0x21, 0x80}`). Sending "81" here anyway, as this code previously + # did unconditionally, is a spurious request the ECU has no reason to + # answer post-handshake -- that's the "NO DATA" seen on real hardware. + # No key bytes are available to us from this path. def close(self) -> None: if self._serial is None: diff --git a/tunie/src/tunie/transport/kline.py b/tunie/src/tunie/transport/kline.py index fb613a1..68707ce 100644 --- a/tunie/src/tunie/transport/kline.py +++ b/tunie/src/tunie/transport/kline.py @@ -63,14 +63,17 @@ class KLineTransport(Transport): # -- lifecycle --------------------------------------------------------- def connect(self) -> None: - self._serial = serial.Serial( - port=self.port, - baudrate=self.baud, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - timeout=0.1, - ) + try: + self._serial = serial.Serial( + port=self.port, + baudrate=self.baud, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=0.1, + ) + except serial.SerialException as exc: + raise TransportError(f"could not open {self.port}: {exc}") from exc self._serial.reset_input_buffer() self._serial.reset_output_buffer()