Diagnostics as dialogs (no dock) + real car-style gauges

Diagnostics: removed the always-on right-side Diagnostics dock (it ate screen
space on every view). Read Codes now opens a modal dialog listing codes
grouped stored/pending/permanent with descriptions (no-start codes bold red);
Clear Codes keeps its confirmation, and on re-read pops a dialog only if codes
returned. Dropped the View > Show Diagnostics Panel toggle.

Gauges: replaced the plain progress-arc with a proper automotive dial -- round
face + bezel, major/minor tick marks, a numeric scale (rounded labels), a red
needle + center hub, a colored rim arc for the value, and an amber peak tick.
Digital readout + name/unit below.

Validated headless: no Diagnostics dock; Read Codes opens the Trouble Codes
dialog; gauge view renders. obdcore + diagnostics tests still 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:
2026-06-30 15:12:49 -04:00
parent f2308cd4eb
commit e7e04b740e
3 changed files with 122 additions and 101 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 129 KiB

+44 -71
View File
@@ -46,7 +46,6 @@ class MainWindow(QtWidgets.QMainWindow):
self._build_menubar() self._build_menubar()
self._build_connection_bar() self._build_connection_bar()
self._build_pid_browser() self._build_pid_browser()
self._build_diag_dock()
self._build_center() self._build_center()
self._build_statusbar() self._build_statusbar()
self._refresh_title() self._refresh_title()
@@ -93,9 +92,6 @@ class MainWindow(QtWidgets.QMainWindow):
self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock, self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock,
checkable=True) checkable=True)
self.show_pids.setChecked(True) self.show_pids.setChecked(True)
self.show_diag = self._act(viewm, "Show Diagnostics Panel", self._toggle_diag_dock,
checkable=True)
self.show_diag.setChecked(True)
self.norm_act = self._act(viewm, "Normalize Graph (% of range)", self.norm_act = self._act(viewm, "Normalize Graph (% of range)",
self._sync_norm_from_menu, checkable=True) self._sync_norm_from_menu, checkable=True)
viewm.addSeparator() viewm.addSeparator()
@@ -213,53 +209,24 @@ class MainWindow(QtWidgets.QMainWindow):
self.tree.resizeColumnToContents(0) self.tree.resizeColumnToContents(0)
self.tree.blockSignals(False) self.tree.blockSignals(False)
# ---------- diagnostics dock (DTCs) ---------- # ---------- diagnostics (DTCs) -- menu-driven dialogs, no docked panel ----------
def _build_diag_dock(self):
self.diag_dock = QtWidgets.QDockWidget("Diagnostics", self)
wrap = QtWidgets.QWidget()
lay = QtWidgets.QVBoxLayout(wrap)
lay.setContentsMargins(4, 4, 4, 4)
bar = QtWidgets.QHBoxLayout()
self.diag_read_btn = QtWidgets.QPushButton("Read Codes")
self.diag_read_btn.clicked.connect(self._read_codes)
self.diag_clear_btn = QtWidgets.QPushButton("Clear Codes…")
self.diag_clear_btn.clicked.connect(self._clear_codes)
bar.addWidget(self.diag_read_btn)
bar.addWidget(self.diag_clear_btn)
bar.addStretch(1)
lay.addLayout(bar)
self.diag_tree = QtWidgets.QTreeWidget()
self.diag_tree.setColumnCount(3)
self.diag_tree.setHeaderLabels(["Code", "Description", "System"])
self.diag_tree.setRootIsDecorated(True)
self.diag_tree.header().setStretchLastSection(False)
self.diag_tree.header().setSectionResizeMode(
1, QtWidgets.QHeaderView.Stretch)
lay.addWidget(self.diag_tree)
self.diag_hint = QtWidgets.QLabel(
"Connect, then Read Codes. Bold red = no-start / drive-disabling.")
self.diag_hint.setWordWrap(True)
lay.addWidget(self.diag_hint)
self.diag_dock.setWidget(wrap)
self.diag_dock.visibilityChanged.connect(
lambda vis: self.show_diag.setChecked(vis))
self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.diag_dock)
_DIAG_GROUPS = [("stored", "Stored (mode 03)"), _DIAG_GROUPS = [("stored", "Stored (mode 03)"),
("pending", "Pending (mode 07)"), ("pending", "Pending (mode 07)"),
("permanent", "Permanent (mode 0A)")] ("permanent", "Permanent (mode 0A)")]
def _populate_diag(self, codes): def _codes_tree(self, codes):
"""codes: {'stored':[...], 'pending':[...], 'permanent':[...]}""" """Build a populated QTreeWidget of codes for a dialog (not docked)."""
self.diag_tree.clear() tree = QtWidgets.QTreeWidget()
tree.setColumnCount(3)
tree.setHeaderLabels(["Code", "Description", "System"])
tree.header().setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch)
total = 0 total = 0
for bucket, label in self._DIAG_GROUPS: for bucket, label in self._DIAG_GROUPS:
lst = codes.get(bucket, []) lst = codes.get(bucket, [])
total += len(lst) total += len(lst)
top = QtWidgets.QTreeWidgetItem([f"{label} ({len(lst)})", "", ""]) top = QtWidgets.QTreeWidgetItem([f"{label} ({len(lst)})", "", ""])
f = top.font(0); f.setBold(True); top.setFont(0, f) f = top.font(0); f.setBold(True); top.setFont(0, f)
top.setFirstColumnSpanned(False) tree.addTopLevelItem(top)
self.diag_tree.addTopLevelItem(top)
if not lst: if not lst:
none = QtWidgets.QTreeWidgetItem(["", "(no codes)", ""]) none = QtWidgets.QTreeWidgetItem(["", "(no codes)", ""])
none.setForeground(1, QtGui.QBrush(QtGui.QColor("#888"))) none.setForeground(1, QtGui.QBrush(QtGui.QColor("#888")))
@@ -267,18 +234,33 @@ class MainWindow(QtWidgets.QMainWindow):
for code in lst: for code in lst:
d = self.ctl.dtcdb.get(code) d = self.ctl.dtcdb.get(code)
it = QtWidgets.QTreeWidgetItem([code, d.desc, d.system]) it = QtWidgets.QTreeWidgetItem([code, d.desc, d.system])
it.setData(0, QtCore.Qt.UserRole, code)
if getattr(d, "no_start", False): if getattr(d, "no_start", False):
red = QtGui.QBrush(QtGui.QColor("#e6194B")) red = QtGui.QBrush(QtGui.QColor("#e6194B"))
bf = it.font(0); bf.setBold(True) bf = it.font(0); bf.setBold(True)
for c in range(3): for c in range(3):
it.setFont(c, bf) it.setFont(c, bf); it.setForeground(c, red)
it.setForeground(c, red)
it.setToolTip(0, "No-start / drive-disabling fault") it.setToolTip(0, "No-start / drive-disabling fault")
top.addChild(it) top.addChild(it)
top.setExpanded(True) top.setExpanded(True)
self.diag_tree.resizeColumnToContents(0) tree.resizeColumnToContents(0)
self.diag_tree.resizeColumnToContents(2) tree.resizeColumnToContents(2)
return tree, total
def _show_codes_dialog(self, codes, title, header=""):
dlg = QtWidgets.QDialog(self)
dlg.setWindowTitle(title)
dlg.resize(560, 360)
lay = QtWidgets.QVBoxLayout(dlg)
tree, total = self._codes_tree(codes)
if header:
lbl = QtWidgets.QLabel(header); lbl.setWordWrap(True); lay.addWidget(lbl)
lay.addWidget(tree)
foot = QtWidgets.QLabel("Bold red = no-start / drive-disabling fault.")
foot.setStyleSheet("color:#888;"); lay.addWidget(foot)
bb = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close)
bb.rejected.connect(dlg.reject); bb.accepted.connect(dlg.accept)
lay.addWidget(bb)
dlg.exec()
return total return total
def _read_codes(self): def _read_codes(self):
@@ -289,17 +271,10 @@ class MainWindow(QtWidgets.QMainWindow):
try: try:
codes = self.ctl.read_dtcs() codes = self.ctl.read_dtcs()
except Exception as e: except Exception as e:
QtWidgets.QMessageBox.critical(self, "Read failed", str(e)) QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
return total = self._show_codes_dialog(codes, "Trouble Codes")
total = self._populate_diag(codes) self.status.showMessage(f"Read codes: {total} found." if total
self.diag_dock.setVisible(True) else "Read codes: none stored.")
self.show_diag.setChecked(True)
self.status.showMessage(
f"Read codes: {total} found "
f"(stored {len(codes.get('stored', []))}, "
f"pending {len(codes.get('pending', []))}, "
f"permanent {len(codes.get('permanent', []))})."
if total else "Read codes: none stored.")
def _clear_codes(self): def _clear_codes(self):
if not self.ctl.connected: if not self.ctl.connected:
@@ -310,8 +285,8 @@ class MainWindow(QtWidgets.QMainWindow):
self, "Clear codes?", self, "Clear codes?",
"This erases stored + pending codes AND freeze-frame data, and " "This erases stored + pending codes AND freeze-frame data, and "
"resets emissions monitors.\n\n" "resets emissions monitors.\n\n"
"Write the codes down first — and read them on a no-start before " "Read the codes first — on a no-start especially. If the fault is "
"clearing. If the fault is still present the code comes right back.\n" "still present the code comes right back.\n"
"Permanent codes (mode 0A) will NOT clear until the fault is fixed " "Permanent codes (mode 0A) will NOT clear until the fault is fixed "
"and the vehicle self-clears them over several drive cycles.\n\n" "and the vehicle self-clears them over several drive cycles.\n\n"
"Clear codes now?", "Clear codes now?",
@@ -323,32 +298,30 @@ class MainWindow(QtWidgets.QMainWindow):
try: try:
ok = self.ctl.clear_dtcs() ok = self.ctl.clear_dtcs()
except Exception as e: except Exception as e:
QtWidgets.QMessageBox.critical(self, "Clear failed", str(e)) QtWidgets.QMessageBox.critical(self, "Clear failed", str(e)); return
return
if not ok: if not ok:
QtWidgets.QMessageBox.warning( QtWidgets.QMessageBox.warning(
self, "No acknowledgement", self, "No acknowledgement",
"The ECU did not acknowledge the clear.\n" "The ECU did not acknowledge the clear.\n"
"Make sure the key is in RUN and the vehicle is connected, then " "Make sure the key is in RUN and connected, then try again.")
"try again.")
self.status.showMessage("Clear not acknowledged by ECU.") self.status.showMessage("Clear not acknowledged by ECU.")
return return
# Re-read immediately so anything that came straight back is shown.
try: try:
codes = self.ctl.read_dtcs() codes = self.ctl.read_dtcs()
except Exception: except Exception:
codes = {} codes = {}
returned = self._populate_diag(codes) returned = sum(len(codes.get(b, [])) for b, _ in self._DIAG_GROUPS)
if returned: if returned:
self.status.showMessage( self._show_codes_dialog(
f"Cleared — but {returned} code(s) returned immediately " codes, "Cleared — codes returned",
"(active fault present).") "These codes came back immediately after clearing — an active "
"fault is still present:")
self.status.showMessage(f"Cleared — {returned} code(s) returned (active fault).")
else: else:
QtWidgets.QMessageBox.information(self, "Codes cleared",
"Cleared. No codes on re-read.")
self.status.showMessage("Cleared. No codes on re-read.") self.status.showMessage("Cleared. No codes on re-read.")
def _toggle_diag_dock(self):
self.diag_dock.setVisible(self.show_diag.isChecked())
# ---------- center (graph + table stack) ---------- # ---------- center (graph + table stack) ----------
def _build_center(self): def _build_center(self):
self.stack = QtWidgets.QStackedWidget() self.stack = QtWidgets.QStackedWidget()
+78 -30
View File
@@ -156,7 +156,7 @@ class ArcGauge(QtWidgets.QWidget):
self.accent = QtGui.QColor(accent) self.accent = QtGui.QColor(accent)
self.value = None self.value = None
self.peak = None self.peak = None
self.setMinimumSize(150, 130) self.setMinimumSize(172, 176)
def set_value(self, v, peak=None): def set_value(self, v, peak=None):
self.value = v self.value = v
@@ -166,45 +166,93 @@ class ArcGauge(QtWidgets.QWidget):
def _frac(self, v): def _frac(self, v):
return max(0.0, min(1.0, (v - self.vmin) / (self.vmax - self.vmin))) 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): def paintEvent(self, _ev):
p = QtGui.QPainter(self) p = QtGui.QPainter(self)
p.setRenderHint(QtGui.QPainter.Antialiasing) p.setRenderHint(QtGui.QPainter.Antialiasing)
w, h = self.width(), self.height() w, h = self.width(), self.height()
p.fillRect(self.rect(), QtGui.QColor("#141414")) # own dark bg (theme-proof) p.fillRect(self.rect(), QtGui.QColor("#141414"))
m = 14
side = min(w, h - 18)
rect = QtCore.QRectF((w - side) / 2 + m / 2, m / 2, side - m, side - m)
start, span = 225 * 16, -270 * 16 # 270deg sweep, top-open down
def arc_pen(color, width): side = min(w, h - 14)
pen = QtGui.QPen(QtGui.QColor(color), width) R = side / 2 - 6
pen.setCapStyle(QtCore.Qt.RoundCap) cx, cy = w / 2.0, R + 8
return pen
p.setPen(arc_pen("#333", 9)) def pt(ang_deg, r):
p.drawArc(rect, start, span) 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: if self.value is not None:
frac = self._frac(self.value) pen = QtGui.QPen(QtGui.QColor(self.accent), 4)
p.setPen(arc_pen(self.accent, 9)) pen.setCapStyle(QtCore.Qt.FlatCap)
p.drawArc(rect, start, int(span * frac)) p.setPen(pen)
if self.peak is not None and self.peak != self.value: p.drawArc(rimrect, int(self._START * 16),
pf = self._frac(self.peak) int(-self._SWEEP * self._frac(self.value) * 16))
ang = (225 - 270 * pf)
p.setPen(QtGui.QPen(QtGui.QColor("#e6c84b"), 2))
cx, cy = rect.center().x(), rect.center().y()
r1, r2 = side / 2 - m - 9, side / 2 - m + 2
a = math.radians(ang)
p.drawLine(QtCore.QPointF(cx + r1 * math.cos(a), cy - r1 * math.sin(a)),
QtCore.QPointF(cx + r2 * math.cos(a), cy - r2 * math.sin(a)))
p.setPen(QtGui.QColor("#eee")) # tick marks + numeric scale
f = p.font(); f.setPointSize(15); f.setBold(True); p.setFont(f) 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}" val = "--" if self.value is None else f"{self.value:g}"
p.drawText(rect, QtCore.Qt.AlignCenter, val) p.drawText(QtCore.QRectF(cx - 55, cy + R * 0.30, 110, 22),
f.setPointSize(8); f.setBold(False); p.setFont(f) QtCore.Qt.AlignCenter, val)
p.setPen(QtGui.QColor("#999")) # name + unit
p.drawText(QtCore.QRectF(0, h - 16, w, 14), QtCore.Qt.AlignCenter, 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) f"{self.name} ({self.unit})" if self.unit else self.name)
p.end() p.end()