Files
samplez/tunie/viewer/build_viewer.py
uhryniuk 80c9373ded Mark device-flag confidence honestly (SAI confirmed, O2 probable)
The NO-SAI-NO-O2 reference map bundles SAI + O2 + airbox deletes, so it validates
the three flag bytes as a GROUP but doesn't isolate O2 individually; no single-mod
map exists and the x.a() device-layout branch for this ECU is too nested to trace.

- SAI @ 0x53801: confirmed (code lc() -> Devices[0] via fe[33], + the delete map).
- O2 @ 0x53818/0x53819: probable (2 O2 sensors declared off <-> 2 adjacent bytes
  cleared; 865 has no air-flap so airbox removal is fuel-only).

Viewer now shows confirmed/probable badges and a warning that flags alone are not
a tune: O2 delete forces open-loop and needs fuel enrichment, so prefer flashing a
complete matching delete map over hand-toggling a stock one. See DEVICES.md.
2026-08-11 08:16:22 -05:00

161 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Build a self-contained HTML viewer for the Triumph tuning project.
Extracts TuneECU's editable-surface definitions (tunable parameters, table
axes, toggleable devices, live sensors, actuator tests) from the decompiled
APK resources, combines them with the already-extracted map catalogue
(maps.json), and embeds everything into a single double-click-to-open HTML file
with no server and no external dependencies.
Usage:
python3 viewer/build_viewer.py \
--arrays ../work/apktool_out/res/values/arrays.xml \
--maps maps.json \
--template viewer/template.html \
--out viewer/tunie-viewer.html
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
# Resource arrays worth surfacing, with a friendly label and short note.
DEF_ARRAYS = {
"Parameters": ("Tunable parameters", "Single-value settings TuneECU can change."),
"title_axis": ("Table axes", "The axes every fuel/ignition map is built on."),
"Devices": ("Toggleable devices", "Emissions/hardware features that can be turned off."),
"eSensor": ("Live sensors", "Channels the ECU streams while running."),
"test_devices": ("Actuator tests", "Outputs TuneECU can command for bench testing."),
"reset_devices": ("Reset routines", "Adaptations/adjustments that can be reset."),
"ftrim_name": ("Fuel-trim tables", "Per-cylinder fuel trim table names."),
"ecu": ("ECU families", "Supported ECUs; the code after ':' is the type char."),
}
# Devices/parameters directly relevant to this bike's planned mods.
MOD_TARGETS = {"SAI", "O2 Sensor", "O2 Sensor (2)", "Exhaust Valve", "Air Flap"}
def _extract_arrays(arrays_xml: Path) -> dict:
s = arrays_xml.read_text(encoding="utf-8")
def arr(name: str) -> list[str] | None:
m = re.search(
rf'<string-array name="{name}"[^>]*>(.*?)</string-array>', s, re.S
)
if not m:
return None
return [
re.sub(r"\s+", " ", i.strip().strip('"'))
for i in re.findall(r"<item>(.*?)</item>", m.group(1), re.S)
]
out = {}
for name, (label, note) in DEF_ARRAYS.items():
items = arr(name) or []
out[name] = {
"label": label,
"note": note,
"items": [{"i": i, "text": t} for i, t in enumerate(items) if t],
}
return out
# fe-field -> named table map (from research/reference-maps/table_map.py, validated).
_TABLE_DEFS = [
("Main fuel — cylinder 1", 11, "fuel"),
("Main fuel — cylinder 2", 12, "fuel"),
("Low-throttle fuel — cyl 1", 15, "fuel"),
("Low-throttle fuel — cyl 2", 16, "fuel"),
("Fuel — base/idle", 9, "fuel"),
("Ignition advance — gear 1", 19, "ignition"),
("Ignition advance — gears 2–5", 20, "ignition"),
("Ignition advance — gear 6", 21, "ignition"),
("Ignition advance — neutral", 22, "ignition"),
]
def _extract_romdefs(args) -> dict:
"""Extract the c.a and s.a directory arrays needed to decode a real map in-browser.
Requires --tuneecu-src pointing at the decompiled com/tuneecu sources. If not
given or not found, returns {} and the viewer's Triumph-tables tab is disabled.
"""
src = getattr(args, "tuneecu_src", None)
if not src:
return {}
src = Path(src)
if not (src / "c.java").exists():
return {}
def arr(cls: str, field: str) -> list[int]:
s = (src / f"{cls}.java").read_text()
m = re.search(rf"\b{field} = \{{(.*?)\}};", s, re.S)
return [int(t.strip().rstrip("L")) for t in m.group(1).split(",") if t.strip()]
return {
"ca": arr("c", "a"),
"sa": arr("s", "a"),
"tables": [{"name": n, "fe": f, "kind": k} for n, f, k in _TABLE_DEFS],
"rows": 32, "cols": 20, "rpmFe": 8, "throttleFe": 27,
# Device-enable flags: byte at base + fe[33] + rel (1=on, 0=off).
# Validated against a NO-SAI-NO-O2 reference map (see research DEVICES.md).
"deviceFe": 33,
"devices": [
{"name": "SAI (Secondary Air Injection)", "rel": 0x00, "confidence": "confirmed"},
{"name": "O2 sensor 1", "rel": 0x17, "confidence": "probable"},
{"name": "O2 sensor 2", "rel": 0x18, "confidence": "probable"},
],
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--arrays", required=True, type=Path)
ap.add_argument("--maps", required=True, type=Path)
ap.add_argument("--template", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--tuneecu-src", default=None,
help="decompiled com/tuneecu dir (enables the Triumph-tables tab)")
args = ap.parse_args()
defs = _extract_arrays(args.arrays)
maps = json.loads(args.maps.read_text(encoding="utf-8"))
# Directory arrays + table map for decoding/rendering real Triumph maps.
romdefs = _extract_romdefs(args)
# Optional: table geometry from extract_mapdefs.py, if it has been run.
mapdefs_path = args.out.parent / "mapdefs.json"
geometry = []
if mapdefs_path.exists():
geometry = json.loads(mapdefs_path.read_text(encoding="utf-8")).get("geometry", [])
payload = {
"definitions": defs,
"maps": maps,
"geometry": geometry,
"romdefs": romdefs,
"modTargets": sorted(MOD_TARGETS),
"meta": {
"mapCount": len(maps),
"tableGroups": len(geometry),
"source": "TuneECU APK resources (arrays.xml) + maps.json + mapdefs.json",
},
}
template = args.template.read_text(encoding="utf-8")
blob = json.dumps(payload, separators=(",", ":"))
html = template.replace("/*__DATA__*/", blob)
args.out.write_text(html, encoding="utf-8")
kb = len(html.encode("utf-8")) / 1024
print(f"wrote {args.out} ({kb:.0f} KB, {len(maps)} maps, "
f"{sum(len(d['items']) for d in defs.values())} definition rows)")
return 0
if __name__ == "__main__":
raise SystemExit(main())