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,98 @@
|
||||
"""Safe formula evaluator for vehicle-profile PID scaling.
|
||||
|
||||
Profiles are community-contributed data, so decode formulas must NOT be able to
|
||||
execute arbitrary code. Formulas are arithmetic expressions over named
|
||||
variables -- the de-facto OBD convention used by Torque / FORScan / ScanGauge:
|
||||
|
||||
raw-mode PIDs: variables A, B, C, ... = response data bytes 0, 1, 2, ...
|
||||
e.g. "(A*256+B)*0.57" "A-40" "(A>>1)&1" "A//2"
|
||||
derived PIDs: variables are other PID keys
|
||||
e.g. "MAP - BARO"
|
||||
|
||||
Only numeric literals, the named variables, arithmetic/bitwise operators, and a
|
||||
small whitelist of functions are allowed. No names, attributes, subscripts,
|
||||
comprehensions, or calls outside the whitelist -- anything else raises
|
||||
FormulaError at compile time, so a bad/hostile profile fails loudly on load.
|
||||
"""
|
||||
import ast
|
||||
import operator
|
||||
|
||||
_BIN = {
|
||||
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.BitAnd: operator.and_, ast.BitOr: operator.or_, ast.BitXor: operator.xor,
|
||||
ast.LShift: operator.lshift, ast.RShift: operator.rshift,
|
||||
}
|
||||
_UNARY = {ast.USub: operator.neg, ast.UAdd: operator.pos, ast.Invert: operator.invert}
|
||||
_FUNCS = {"min": min, "max": max, "abs": abs, "round": round,
|
||||
"int": int, "float": float}
|
||||
|
||||
|
||||
class FormulaError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _validate(node, allowed):
|
||||
if isinstance(node, ast.Expression):
|
||||
return _validate(node.body, allowed)
|
||||
if isinstance(node, ast.BinOp):
|
||||
if type(node.op) not in _BIN:
|
||||
raise FormulaError(f"operator not allowed: {type(node.op).__name__}")
|
||||
_validate(node.left, allowed)
|
||||
_validate(node.right, allowed)
|
||||
return
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
if type(node.op) not in _UNARY:
|
||||
raise FormulaError(f"unary op not allowed: {type(node.op).__name__}")
|
||||
_validate(node.operand, allowed)
|
||||
return
|
||||
if isinstance(node, ast.Constant):
|
||||
if not isinstance(node.value, (int, float)) or isinstance(node.value, bool):
|
||||
raise FormulaError("only numeric constants allowed")
|
||||
return
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id not in allowed:
|
||||
raise FormulaError(f"unknown variable {node.id!r} (allowed: {sorted(allowed)})")
|
||||
return
|
||||
if isinstance(node, ast.Call):
|
||||
if not isinstance(node.func, ast.Name) or node.func.id not in _FUNCS:
|
||||
raise FormulaError("only min/max/abs/round/int/float calls allowed")
|
||||
if node.keywords:
|
||||
raise FormulaError("keyword args not allowed")
|
||||
for a in node.args:
|
||||
_validate(a, allowed)
|
||||
return
|
||||
raise FormulaError(f"expression not allowed: {type(node).__name__}")
|
||||
|
||||
|
||||
def _eval(node, names):
|
||||
if isinstance(node, ast.Expression):
|
||||
return _eval(node.body, names)
|
||||
if isinstance(node, ast.BinOp):
|
||||
return _BIN[type(node.op)](_eval(node.left, names), _eval(node.right, names))
|
||||
if isinstance(node, ast.UnaryOp):
|
||||
return _UNARY[type(node.op)](_eval(node.operand, names))
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
if isinstance(node, ast.Name):
|
||||
return names[node.id]
|
||||
if isinstance(node, ast.Call):
|
||||
return _FUNCS[node.func.id](*[_eval(a, names) for a in node.args])
|
||||
raise FormulaError(f"expression not allowed: {type(node).__name__}")
|
||||
|
||||
|
||||
def compile_formula(expr, allowed_names):
|
||||
"""Return fn(names_dict) -> number. Raises FormulaError on disallowed input."""
|
||||
try:
|
||||
tree = ast.parse(expr, mode="eval")
|
||||
except SyntaxError as e:
|
||||
raise FormulaError(f"bad formula {expr!r}: {e}")
|
||||
allowed = set(allowed_names)
|
||||
_validate(tree, allowed)
|
||||
|
||||
def fn(names):
|
||||
return _eval(tree, names)
|
||||
|
||||
fn.expr = expr
|
||||
return fn
|
||||
Reference in New Issue
Block a user