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:
Binary file not shown.
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 129 KiB |
+44
-71
@@ -46,7 +46,6 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
self._build_menubar()
|
||||
self._build_connection_bar()
|
||||
self._build_pid_browser()
|
||||
self._build_diag_dock()
|
||||
self._build_center()
|
||||
self._build_statusbar()
|
||||
self._refresh_title()
|
||||
@@ -93,9 +92,6 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
self.show_pids = self._act(viewm, "Show PID Panel", self._toggle_pid_dock,
|
||||
checkable=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._sync_norm_from_menu, checkable=True)
|
||||
viewm.addSeparator()
|
||||
@@ -213,53 +209,24 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
self.tree.resizeColumnToContents(0)
|
||||
self.tree.blockSignals(False)
|
||||
|
||||
# ---------- diagnostics dock (DTCs) ----------
|
||||
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)
|
||||
|
||||
# ---------- diagnostics (DTCs) -- menu-driven dialogs, no docked panel ----------
|
||||
_DIAG_GROUPS = [("stored", "Stored (mode 03)"),
|
||||
("pending", "Pending (mode 07)"),
|
||||
("permanent", "Permanent (mode 0A)")]
|
||||
|
||||
def _populate_diag(self, codes):
|
||||
"""codes: {'stored':[...], 'pending':[...], 'permanent':[...]}"""
|
||||
self.diag_tree.clear()
|
||||
def _codes_tree(self, codes):
|
||||
"""Build a populated QTreeWidget of codes for a dialog (not docked)."""
|
||||
tree = QtWidgets.QTreeWidget()
|
||||
tree.setColumnCount(3)
|
||||
tree.setHeaderLabels(["Code", "Description", "System"])
|
||||
tree.header().setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch)
|
||||
total = 0
|
||||
for bucket, label in self._DIAG_GROUPS:
|
||||
lst = codes.get(bucket, [])
|
||||
total += len(lst)
|
||||
top = QtWidgets.QTreeWidgetItem([f"{label} ({len(lst)})", "", ""])
|
||||
f = top.font(0); f.setBold(True); top.setFont(0, f)
|
||||
top.setFirstColumnSpanned(False)
|
||||
self.diag_tree.addTopLevelItem(top)
|
||||
tree.addTopLevelItem(top)
|
||||
if not lst:
|
||||
none = QtWidgets.QTreeWidgetItem(["—", "(no codes)", ""])
|
||||
none.setForeground(1, QtGui.QBrush(QtGui.QColor("#888")))
|
||||
@@ -267,18 +234,33 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
for code in lst:
|
||||
d = self.ctl.dtcdb.get(code)
|
||||
it = QtWidgets.QTreeWidgetItem([code, d.desc, d.system])
|
||||
it.setData(0, QtCore.Qt.UserRole, code)
|
||||
if getattr(d, "no_start", False):
|
||||
red = QtGui.QBrush(QtGui.QColor("#e6194B"))
|
||||
bf = it.font(0); bf.setBold(True)
|
||||
for c in range(3):
|
||||
it.setFont(c, bf)
|
||||
it.setForeground(c, red)
|
||||
it.setFont(c, bf); it.setForeground(c, red)
|
||||
it.setToolTip(0, "No-start / drive-disabling fault")
|
||||
top.addChild(it)
|
||||
top.setExpanded(True)
|
||||
self.diag_tree.resizeColumnToContents(0)
|
||||
self.diag_tree.resizeColumnToContents(2)
|
||||
tree.resizeColumnToContents(0)
|
||||
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
|
||||
|
||||
def _read_codes(self):
|
||||
@@ -289,17 +271,10 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
try:
|
||||
codes = self.ctl.read_dtcs()
|
||||
except Exception as e:
|
||||
QtWidgets.QMessageBox.critical(self, "Read failed", str(e))
|
||||
return
|
||||
total = self._populate_diag(codes)
|
||||
self.diag_dock.setVisible(True)
|
||||
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.")
|
||||
QtWidgets.QMessageBox.critical(self, "Read failed", str(e)); return
|
||||
total = self._show_codes_dialog(codes, "Trouble Codes")
|
||||
self.status.showMessage(f"Read codes: {total} found." if total
|
||||
else "Read codes: none stored.")
|
||||
|
||||
def _clear_codes(self):
|
||||
if not self.ctl.connected:
|
||||
@@ -310,8 +285,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
self, "Clear codes?",
|
||||
"This erases stored + pending codes AND freeze-frame data, and "
|
||||
"resets emissions monitors.\n\n"
|
||||
"Write the codes down first — and read them on a no-start before "
|
||||
"clearing. If the fault is still present the code comes right back.\n"
|
||||
"Read the codes first — on a no-start especially. If the fault is "
|
||||
"still present the code comes right back.\n"
|
||||
"Permanent codes (mode 0A) will NOT clear until the fault is fixed "
|
||||
"and the vehicle self-clears them over several drive cycles.\n\n"
|
||||
"Clear codes now?",
|
||||
@@ -323,32 +298,30 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
try:
|
||||
ok = self.ctl.clear_dtcs()
|
||||
except Exception as e:
|
||||
QtWidgets.QMessageBox.critical(self, "Clear failed", str(e))
|
||||
return
|
||||
QtWidgets.QMessageBox.critical(self, "Clear failed", str(e)); return
|
||||
if not ok:
|
||||
QtWidgets.QMessageBox.warning(
|
||||
self, "No acknowledgement",
|
||||
"The ECU did not acknowledge the clear.\n"
|
||||
"Make sure the key is in RUN and the vehicle is connected, then "
|
||||
"try again.")
|
||||
"Make sure the key is in RUN and connected, then try again.")
|
||||
self.status.showMessage("Clear not acknowledged by ECU.")
|
||||
return
|
||||
# Re-read immediately so anything that came straight back is shown.
|
||||
try:
|
||||
codes = self.ctl.read_dtcs()
|
||||
except Exception:
|
||||
codes = {}
|
||||
returned = self._populate_diag(codes)
|
||||
returned = sum(len(codes.get(b, [])) for b, _ in self._DIAG_GROUPS)
|
||||
if returned:
|
||||
self.status.showMessage(
|
||||
f"Cleared — but {returned} code(s) returned immediately "
|
||||
"(active fault present).")
|
||||
self._show_codes_dialog(
|
||||
codes, "Cleared — codes returned",
|
||||
"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:
|
||||
QtWidgets.QMessageBox.information(self, "Codes cleared",
|
||||
"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) ----------
|
||||
def _build_center(self):
|
||||
self.stack = QtWidgets.QStackedWidget()
|
||||
|
||||
+78
-30
@@ -156,7 +156,7 @@ class ArcGauge(QtWidgets.QWidget):
|
||||
self.accent = QtGui.QColor(accent)
|
||||
self.value = None
|
||||
self.peak = None
|
||||
self.setMinimumSize(150, 130)
|
||||
self.setMinimumSize(172, 176)
|
||||
|
||||
def set_value(self, v, peak=None):
|
||||
self.value = v
|
||||
@@ -166,45 +166,93 @@ class ArcGauge(QtWidgets.QWidget):
|
||||
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")) # own dark bg (theme-proof)
|
||||
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
|
||||
p.fillRect(self.rect(), QtGui.QColor("#141414"))
|
||||
|
||||
def arc_pen(color, width):
|
||||
pen = QtGui.QPen(QtGui.QColor(color), width)
|
||||
pen.setCapStyle(QtCore.Qt.RoundCap)
|
||||
return pen
|
||||
side = min(w, h - 14)
|
||||
R = side / 2 - 6
|
||||
cx, cy = w / 2.0, R + 8
|
||||
|
||||
p.setPen(arc_pen("#333", 9))
|
||||
p.drawArc(rect, start, span)
|
||||
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:
|
||||
frac = self._frac(self.value)
|
||||
p.setPen(arc_pen(self.accent, 9))
|
||||
p.drawArc(rect, start, int(span * frac))
|
||||
if self.peak is not None and self.peak != self.value:
|
||||
pf = self._frac(self.peak)
|
||||
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)))
|
||||
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))
|
||||
|
||||
p.setPen(QtGui.QColor("#eee"))
|
||||
f = p.font(); f.setPointSize(15); f.setBold(True); p.setFont(f)
|
||||
# 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(rect, QtCore.Qt.AlignCenter, val)
|
||||
f.setPointSize(8); f.setBold(False); p.setFont(f)
|
||||
p.setPen(QtGui.QColor("#999"))
|
||||
p.drawText(QtCore.QRectF(0, h - 16, w, 14), QtCore.Qt.AlignCenter,
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user