03223dfd6c
Rename: the app is vehicle-agnostic, so 'ford-obd' was wrong. Rebranded all code/docs/profile authors to OBDash; Gitea repo renamed justin/ford-obd -> justin/obdash (remote + description updated). Ford the make and the ford-6.0-powerstroke profile are unchanged (that vehicle really is a Ford). Multi-axis upgrade (per request): - MultiAxisPlot now gives each METRIC its own Y axis, each axis colored to match its line; the primary metric owns the LEFT axis, others stack right. - Click a line to promote it to the left axis (sigClicked -> set_primary). - Cleaner teardown (no removeItem warnings); axis label no longer doubles the unit; Normalize round-trips. Validated headless: colored per-metric axes, promote-to-left, gauge view, normalize toggle, profile switch; obdcore + diagnostics tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
350 lines
12 KiB
Python
350 lines
12 KiB
Python
"""Custom widgets for the OBDash GUI: a multi-axis plot (one colored axis per
|
|
metric), a single-axis plot, and a car-style gauge / gauge grid.
|
|
|
|
All three plot/gauge containers share a small interface so the main window can
|
|
swap between them: add_curve(key,name,unit,color) / set_data(key,xs,ys) /
|
|
remove_curve(key) / clear().
|
|
"""
|
|
import math
|
|
|
|
from PySide6 import QtCore, QtGui, QtWidgets
|
|
import pyqtgraph as pg
|
|
|
|
|
|
class SinglePlot(QtWidgets.QWidget):
|
|
"""One shared Y axis. Used for the Normalize (% of range) mode."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
lay = QtWidgets.QVBoxLayout(self)
|
|
lay.setContentsMargins(0, 0, 0, 0)
|
|
self.pw = pg.PlotWidget()
|
|
self.pw.addLegend(offset=(10, 10))
|
|
self.pw.showGrid(x=True, y=True, alpha=0.25)
|
|
self.pw.setLabel("bottom", "time", units="s")
|
|
lay.addWidget(self.pw)
|
|
self._curves = {}
|
|
|
|
def set_y_label(self, text):
|
|
self.pw.setLabel("left", text)
|
|
|
|
def add_curve(self, key, name, unit, color):
|
|
self._curves[key] = self.pw.plot([], [], name=name, pen=pg.mkPen(color, width=2))
|
|
|
|
def set_data(self, key, xs, ys):
|
|
c = self._curves.get(key)
|
|
if c is not None:
|
|
c.setData(xs, ys)
|
|
|
|
def remove_curve(self, key):
|
|
c = self._curves.pop(key, None)
|
|
if c is not None:
|
|
self.pw.removeItem(c)
|
|
leg = self.pw.plotItem.legend
|
|
if leg:
|
|
try:
|
|
leg.removeItem(c)
|
|
except Exception:
|
|
pass
|
|
|
|
def clear(self):
|
|
for key in list(self._curves):
|
|
self.remove_curve(key)
|
|
|
|
def set_background(self, bg):
|
|
self.pw.setBackground(bg)
|
|
|
|
|
|
class MultiAxisPlot(QtWidgets.QWidget):
|
|
"""Overlay with one Y axis PER METRIC, each axis colored to match its line,
|
|
so mixed-scale signals are all readable at their true values.
|
|
|
|
The 'primary' metric owns the LEFT axis; the rest stack on the right.
|
|
Click a line to promote it to the left axis. Beyond MAX_RIGHT extra metrics,
|
|
overflow curves share the primary axis (rare; plot fewer to compare)."""
|
|
|
|
MAX_RIGHT = 5
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
lay = QtWidgets.QVBoxLayout(self)
|
|
lay.setContentsMargins(0, 0, 0, 0)
|
|
self.glw = pg.GraphicsLayoutWidget()
|
|
lay.addWidget(self.glw)
|
|
self.p = self.glw.addPlot()
|
|
self.p.showGrid(x=True, y=True, alpha=0.2)
|
|
self.p.setLabel("bottom", "time", units="s")
|
|
self.legend = self.p.addLegend(offset=(10, 10))
|
|
self.base_vb = self.p.vb
|
|
self._curves = {} # key -> {color,name,unit,curve,vb,ax}
|
|
self._order = [] # insertion order of keys
|
|
self._primary = None
|
|
self.base_vb.sigResized.connect(self._sync)
|
|
|
|
def add_curve(self, key, name, unit, color):
|
|
if key in self._curves:
|
|
return
|
|
curve = pg.PlotCurveItem(pen=pg.mkPen(color, width=2), name=name)
|
|
curve.setClickable(True, width=8)
|
|
curve.sigClicked.connect(lambda *_a, k=key: self.set_primary(k))
|
|
self._curves[key] = {"color": color, "name": name, "unit": unit,
|
|
"curve": curve, "vb": None, "ax": None}
|
|
self._order.append(key)
|
|
if self._primary is None:
|
|
self._primary = key
|
|
self._rebuild()
|
|
|
|
def set_primary(self, key):
|
|
"""Move this metric's axis to the LEFT (make it the primary axis)."""
|
|
if key in self._curves and key != self._primary:
|
|
self._primary = key
|
|
self._rebuild()
|
|
|
|
def set_data(self, key, xs, ys):
|
|
c = self._curves.get(key)
|
|
if c is not None:
|
|
c["curve"].setData(xs, ys)
|
|
|
|
def remove_curve(self, key):
|
|
c = self._curves.pop(key, None)
|
|
if not c:
|
|
return
|
|
self._order.remove(key)
|
|
self._detach(c)
|
|
if self._primary == key:
|
|
self._primary = self._order[0] if self._order else None
|
|
self._rebuild()
|
|
|
|
def clear(self):
|
|
for c in self._curves.values():
|
|
self._detach(c)
|
|
self._curves = {}
|
|
self._order = []
|
|
self._primary = None
|
|
self.legend.clear()
|
|
|
|
def set_y_label(self, _text):
|
|
pass # labels itself per metric
|
|
|
|
def set_background(self, bg):
|
|
self.glw.setBackground(bg)
|
|
|
|
# -- internals --
|
|
def _detach(self, c):
|
|
"""Remove a curve from its current ViewBox and tear down its extra axis
|
|
(the base left axis is kept). Idempotent -- safe to call repeatedly."""
|
|
cur = c["curve"]
|
|
vb = c.get("vb")
|
|
if vb is not None:
|
|
try:
|
|
vb.removeItem(cur)
|
|
except Exception:
|
|
pass
|
|
ax = c.get("ax")
|
|
if ax is not None and ax is not self.p.getAxis("left"):
|
|
try:
|
|
self.p.layout.removeItem(ax)
|
|
except Exception:
|
|
pass
|
|
sc = ax.scene()
|
|
if sc is not None:
|
|
sc.removeItem(ax)
|
|
if vb is not None and vb is not self.base_vb:
|
|
sc = vb.scene()
|
|
if sc is not None:
|
|
sc.removeItem(vb)
|
|
c["vb"] = None
|
|
c["ax"] = None
|
|
|
|
def _rebuild(self):
|
|
for c in self._curves.values():
|
|
self._detach(c)
|
|
self.legend.clear()
|
|
if self._primary is None:
|
|
return
|
|
order = [self._primary] + [k for k in self._order if k != self._primary]
|
|
col = 3
|
|
left = self.p.getAxis("left")
|
|
for idx, key in enumerate(order):
|
|
c = self._curves[key]
|
|
cur = c["curve"]
|
|
pen = pg.mkPen(c["color"])
|
|
if idx == 0: # primary -> left axis
|
|
self.base_vb.addItem(cur)
|
|
left.setLabel(c["name"]) # name already carries the unit
|
|
left.setPen(pen); left.setTextPen(pen)
|
|
c["vb"] = self.base_vb; c["ax"] = left
|
|
elif idx <= self.MAX_RIGHT: # right axis, colored to line
|
|
vb = pg.ViewBox()
|
|
ax = pg.AxisItem("right")
|
|
self.p.layout.addItem(ax, 2, col); col += 1
|
|
self.p.scene().addItem(vb)
|
|
ax.linkToView(vb); vb.setXLink(self.p)
|
|
ax.setLabel(c["name"])
|
|
ax.setPen(pen); ax.setTextPen(pen)
|
|
vb.addItem(cur)
|
|
c["vb"] = vb; c["ax"] = ax
|
|
else: # overflow -> primary axis
|
|
self.base_vb.addItem(cur)
|
|
c["vb"] = self.base_vb; c["ax"] = None
|
|
self.legend.addItem(cur, c["name"])
|
|
self._sync()
|
|
|
|
def _sync(self):
|
|
rect = self.base_vb.sceneBoundingRect()
|
|
for c in self._curves.values():
|
|
vb = c.get("vb")
|
|
if vb is not None and vb is not self.base_vb:
|
|
vb.setGeometry(rect)
|
|
vb.linkedViewChanged(self.base_vb, vb.XAxis)
|
|
|
|
|
|
class ArcGauge(QtWidgets.QWidget):
|
|
"""A 270-degree arc gauge for a single signal: filled arc to the current
|
|
value, peak tick, big numeric readout, name + unit."""
|
|
|
|
def __init__(self, name, unit, vmin, vmax, accent="#3cb44b"):
|
|
super().__init__()
|
|
self.name = name
|
|
self.unit = unit
|
|
self.vmin = float(vmin)
|
|
self.vmax = float(vmax) if vmax != vmin else float(vmin) + 1.0
|
|
self.accent = QtGui.QColor(accent)
|
|
self.value = None
|
|
self.peak = None
|
|
self.setMinimumSize(172, 176)
|
|
|
|
def set_value(self, v, peak=None):
|
|
self.value = v
|
|
self.peak = peak
|
|
self.update()
|
|
|
|
def _frac(self, v):
|
|
return max(0.0, min(1.0, (v - self.vmin) / (self.vmax - self.vmin)))
|
|
|
|
# gauge geometry: a 270-degree dial, gap at the bottom (like a tach)
|
|
_START = 225.0 # value=min angle (math degrees, 0=3 o'clock, CCW+)
|
|
_SWEEP = 270.0 # clockwise as value rises
|
|
|
|
def _ang(self, frac):
|
|
return self._START - self._SWEEP * max(0.0, min(1.0, frac))
|
|
|
|
def paintEvent(self, _ev):
|
|
p = QtGui.QPainter(self)
|
|
p.setRenderHint(QtGui.QPainter.Antialiasing)
|
|
w, h = self.width(), self.height()
|
|
p.fillRect(self.rect(), QtGui.QColor("#141414"))
|
|
|
|
side = min(w, h - 14)
|
|
R = side / 2 - 6
|
|
cx, cy = w / 2.0, R + 8
|
|
|
|
def pt(ang_deg, r):
|
|
a = math.radians(ang_deg)
|
|
return QtCore.QPointF(cx + r * math.cos(a), cy - r * math.sin(a))
|
|
|
|
# dial face + bezel
|
|
p.setPen(QtGui.QPen(QtGui.QColor("#2a2a2a"), 3))
|
|
p.setBrush(QtGui.QColor("#0c0c0c"))
|
|
p.drawEllipse(QtCore.QPointF(cx, cy), R, R)
|
|
|
|
# value progress arc just inside the rim
|
|
rimrect = QtCore.QRectF(cx - R + 7, cy - R + 7, 2 * (R - 7), 2 * (R - 7))
|
|
if self.value is not None:
|
|
pen = QtGui.QPen(QtGui.QColor(self.accent), 4)
|
|
pen.setCapStyle(QtCore.Qt.FlatCap)
|
|
p.setPen(pen)
|
|
p.drawArc(rimrect, int(self._START * 16),
|
|
int(-self._SWEEP * self._frac(self.value) * 16))
|
|
|
|
# tick marks + numeric scale
|
|
majors = 6
|
|
fnt = p.font(); fnt.setPointSize(7); p.setFont(fnt)
|
|
for i in range(majors * 5 + 1):
|
|
frac = i / (majors * 5)
|
|
a = self._ang(frac)
|
|
major = (i % 5 == 0)
|
|
p.setPen(QtGui.QPen(QtGui.QColor("#ddd" if major else "#555"),
|
|
2 if major else 1))
|
|
p.drawLine(pt(a, R - 3), pt(a, R - (13 if major else 7)))
|
|
if major:
|
|
val = self.vmin + frac * (self.vmax - self.vmin)
|
|
span = abs(self.vmax - self.vmin)
|
|
lbl = f"{val:.0f}" if span >= 50 else f"{val:.1f}" if span >= 5 else f"{val:g}"
|
|
lp = pt(a, R - 25)
|
|
p.setPen(QtGui.QColor("#9a9a9a"))
|
|
p.drawText(QtCore.QRectF(lp.x() - 18, lp.y() - 7, 36, 14),
|
|
QtCore.Qt.AlignCenter, lbl)
|
|
|
|
# peak marker (thin amber tick)
|
|
if self.peak is not None and self.value is not None and self.peak != self.value:
|
|
a = self._ang(self._frac(self.peak))
|
|
p.setPen(QtGui.QPen(QtGui.QColor("#e6c84b"), 2))
|
|
p.drawLine(pt(a, R - 3), pt(a, R - 14))
|
|
|
|
# needle
|
|
if self.value is not None:
|
|
a = self._ang(self._frac(self.value))
|
|
tip = pt(a, R - 16)
|
|
b1 = pt(a + 90, 4.5)
|
|
b2 = pt(a - 90, 4.5)
|
|
tail = pt(a + 180, 10)
|
|
path = QtGui.QPainterPath()
|
|
path.moveTo(b1); path.lineTo(tip); path.lineTo(b2); path.lineTo(tail)
|
|
path.closeSubpath()
|
|
p.setPen(QtCore.Qt.NoPen)
|
|
p.setBrush(QtGui.QColor("#e6194B"))
|
|
p.drawPath(path)
|
|
# hub
|
|
p.setBrush(QtGui.QColor("#999")); p.setPen(QtCore.Qt.NoPen)
|
|
p.drawEllipse(QtCore.QPointF(cx, cy), 5, 5)
|
|
|
|
# digital readout (lower face)
|
|
p.setPen(QtGui.QColor("#fff"))
|
|
fnt.setPointSize(13); fnt.setBold(True); p.setFont(fnt)
|
|
val = "--" if self.value is None else f"{self.value:g}"
|
|
p.drawText(QtCore.QRectF(cx - 55, cy + R * 0.30, 110, 22),
|
|
QtCore.Qt.AlignCenter, val)
|
|
# name + unit
|
|
fnt.setPointSize(8); fnt.setBold(False); p.setFont(fnt)
|
|
p.setPen(QtGui.QColor("#9a9a9a"))
|
|
p.drawText(QtCore.QRectF(0, h - 15, w, 14), QtCore.Qt.AlignCenter,
|
|
f"{self.name} ({self.unit})" if self.unit else self.name)
|
|
p.end()
|
|
|
|
|
|
class GaugeGrid(QtWidgets.QScrollArea):
|
|
"""Scrollable grid of ArcGauges, one per signal."""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWidgetResizable(True)
|
|
self.setStyleSheet("QScrollArea, QWidget { background: #111; }")
|
|
self._inner = QtWidgets.QWidget()
|
|
self._grid = QtWidgets.QGridLayout(self._inner)
|
|
self._grid.setSpacing(8)
|
|
self.setWidget(self._inner)
|
|
self._gauges = {}
|
|
self._cols = 4
|
|
|
|
def rebuild(self, specs):
|
|
"""specs: list of (key, name, unit, vmin, vmax, accent)."""
|
|
while self._grid.count():
|
|
item = self._grid.takeAt(0)
|
|
wdg = item.widget()
|
|
if wdg:
|
|
wdg.deleteLater()
|
|
self._gauges = {}
|
|
for i, (key, name, unit, vmin, vmax, accent) in enumerate(specs):
|
|
g = ArcGauge(name, unit, vmin, vmax, accent)
|
|
self._gauges[key] = g
|
|
self._grid.addWidget(g, i // self._cols, i % self._cols)
|
|
|
|
def keys(self):
|
|
return set(self._gauges)
|
|
|
|
def set_value(self, key, v, peak=None):
|
|
g = self._gauges.get(key)
|
|
if g is not None:
|
|
g.set_value(v, peak)
|