Make app vehicle-agnostic: JSON vehicle profiles + menu bar
Vehicle data is now DATA, not code. PIDs/scaling/DTCs/presets live in profiles/*.json; the app loads them at runtime, so it works across vehicles and others can contribute profiles (open source). Core: - obdcore/formula.py: safe AST evaluator for scaling formulas (A/B/... byte vars, Torque/FORScan convention). Only arithmetic/bitwise + min/max/abs/ round/int/float; names/attrs/arbitrary calls rejected at load -> a community profile CANNOT execute code. - obdcore/profile.py: load/save/list profiles; compiles each formula into a decode callable. registry.py now profile-backed (PidRegistry/DtcDatabase take a Profile); hardcoded Ford table removed. - store.py: clear()/snapshot()/export_csv() for capture management. Profiles: - profiles/ford-6.0-powerstroke.json (27 PIDs, verified formulas, DTCs) - profiles/generic-obd2.json (standard SAE Mode-01 base, any vehicle) - profiles/README.md (schema + formula language + contributing) GUI: - Menu bar: File (new/record/export/replay capture, quit), Profile (switch/ load/import/reload/edit-JSON/export, live profile list), View (Graph/Table views, gauges P2, toggle PID dock, normalize, light/dark theme), Help (about/confidence legend/profile info). - PID browser + presets rebuild on profile switch; added Table view; raw-JSON profile editor dialog (validates schema+formulas before saving). Tests: profiles load+compile, formula sandbox rejects hostile input, decoders still match real truck bytes, crank/derived/dead-PID/replay -- all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""Vehicle profiles -- load/save/list the JSON files under profiles/.
|
||||
|
||||
A profile is pure data: vehicle metadata, PID definitions (with safe formula
|
||||
strings), DTC meanings, and named presets (perspectives). Loading a profile
|
||||
compiles each PID's formula into a decode callable; nothing in a profile can
|
||||
execute arbitrary code (see formula.py).
|
||||
|
||||
JSON schema (schema=1):
|
||||
{
|
||||
"schema": 1,
|
||||
"meta": {"name","make","model","years","engine","author","version",
|
||||
"protocol","notes"},
|
||||
"pids": [{"key","name","mode","pid","nbytes","formula","unit","group",
|
||||
"vmin","vmax","confidence","round","deps","notes"}, ...],
|
||||
"presets": {"crank":[keys...], ...},
|
||||
"dtcs": [{"code","desc","system","no_start","causes"}, ...]
|
||||
}
|
||||
"""
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .formula import compile_formula
|
||||
from .registry import Pid, Dtc
|
||||
|
||||
SCHEMA = 1
|
||||
BYTE_VARS = [chr(65 + i) for i in range(8)] # A..H
|
||||
|
||||
|
||||
@dataclass
|
||||
class Profile:
|
||||
meta: dict
|
||||
pids: list
|
||||
dtcs: list
|
||||
presets: dict
|
||||
path: str = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.meta.get("name", "Unnamed profile")
|
||||
|
||||
|
||||
def profiles_dir():
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"profiles")
|
||||
|
||||
|
||||
def _round(v, rnd):
|
||||
if rnd is None:
|
||||
return v
|
||||
return int(round(v)) if rnd == 0 else round(v, rnd)
|
||||
|
||||
|
||||
def _build_decode(d):
|
||||
mode = d.get("mode", "22")
|
||||
rnd = d.get("round")
|
||||
if mode == "atrv":
|
||||
return None
|
||||
formula = d.get("formula", "")
|
||||
if mode == "derived":
|
||||
deps = tuple(d.get("deps", ()))
|
||||
fn = compile_formula(formula, deps)
|
||||
def dec(vals, fn=fn, deps=deps, rnd=rnd):
|
||||
return _round(fn(dict(zip(deps, vals))), rnd)
|
||||
return dec
|
||||
fn = compile_formula(formula, BYTE_VARS)
|
||||
def dec(raw, fn=fn, rnd=rnd):
|
||||
names = {BYTE_VARS[i]: raw[i] for i in range(min(len(raw), 8))}
|
||||
return _round(fn(names), rnd)
|
||||
return dec
|
||||
|
||||
|
||||
def _pid_from_dict(d):
|
||||
return Pid(
|
||||
key=d["key"], name=d.get("name", d["key"]), mode=d.get("mode", "22"),
|
||||
pid=d.get("pid", ""), nbytes=d.get("nbytes", 2),
|
||||
formula=d.get("formula", ""), decode=_build_decode(d),
|
||||
unit=d.get("unit", ""), group=d.get("group", "misc"),
|
||||
vmin=d.get("vmin", 0.0), vmax=d.get("vmax", 100.0),
|
||||
confidence=d.get("confidence", "verified"), round=d.get("round"),
|
||||
deps=tuple(d.get("deps", ())), notes=d.get("notes", ""),
|
||||
)
|
||||
|
||||
|
||||
def load_profile(path):
|
||||
with open(path) as f:
|
||||
raw = json.load(f)
|
||||
if raw.get("schema", 1) != SCHEMA:
|
||||
raise ValueError(f"unsupported profile schema {raw.get('schema')} in {path}")
|
||||
pids = [_pid_from_dict(d) for d in raw.get("pids", [])]
|
||||
dtcs = [Dtc(code=x["code"], desc=x.get("desc", ""), system=x.get("system", "powertrain"),
|
||||
no_start=x.get("no_start", False), causes=x.get("causes", ""))
|
||||
for x in raw.get("dtcs", [])]
|
||||
return Profile(meta=raw.get("meta", {}), pids=pids, dtcs=dtcs,
|
||||
presets=raw.get("presets", {}), path=path)
|
||||
|
||||
|
||||
def _pid_to_dict(p):
|
||||
d = {"key": p.key, "name": p.name, "mode": p.mode}
|
||||
if p.pid:
|
||||
d["pid"] = p.pid
|
||||
if p.mode in ("01", "22"):
|
||||
d["nbytes"] = p.nbytes
|
||||
if p.formula:
|
||||
d["formula"] = p.formula
|
||||
if p.deps:
|
||||
d["deps"] = list(p.deps)
|
||||
d.update({"unit": p.unit, "group": p.group, "vmin": p.vmin, "vmax": p.vmax,
|
||||
"confidence": p.confidence})
|
||||
if p.round is not None:
|
||||
d["round"] = p.round
|
||||
if p.notes:
|
||||
d["notes"] = p.notes
|
||||
return d
|
||||
|
||||
|
||||
def save_profile(profile, path=None):
|
||||
path = path or profile.path
|
||||
out = {
|
||||
"schema": SCHEMA,
|
||||
"meta": profile.meta,
|
||||
"pids": [_pid_to_dict(p) for p in profile.pids],
|
||||
"presets": profile.presets,
|
||||
"dtcs": [{"code": d.code, "desc": d.desc, "system": d.system,
|
||||
"no_start": d.no_start, "causes": d.causes} for d in profile.dtcs],
|
||||
}
|
||||
with open(path, "w") as f:
|
||||
json.dump(out, f, indent=2)
|
||||
return path
|
||||
|
||||
|
||||
def list_profiles(directory=None):
|
||||
"""Return [(path, meta_dict), ...] for every *.json profile in directory."""
|
||||
directory = directory or profiles_dir()
|
||||
out = []
|
||||
for p in sorted(glob.glob(os.path.join(directory, "*.json"))):
|
||||
try:
|
||||
with open(p) as f:
|
||||
meta = json.load(f).get("meta", {})
|
||||
out.append((p, meta))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
DEFAULT_PROFILE = "ford-6.0-powerstroke.json"
|
||||
|
||||
|
||||
def default_profile_path():
|
||||
return os.path.join(profiles_dir(), DEFAULT_PROFILE)
|
||||
|
||||
|
||||
def load_default():
|
||||
return load_profile(default_profile_path())
|
||||
Reference in New Issue
Block a user