diff --git a/pyproject.toml b/pyproject.toml
index d5dc95e1b1a7b1..44b91261d11c93 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -122,6 +122,7 @@ dev = [
"tabulate",
"types-requests",
"types-tabulate",
+ "pyside6",
]
tools = [
diff --git a/tools/cabana/PYCABANA_PLAN.md b/tools/cabana/PYCABANA_PLAN.md
new file mode 100644
index 00000000000000..4e0cf2e3ebe78d
--- /dev/null
+++ b/tools/cabana/PYCABANA_PLAN.md
@@ -0,0 +1,503 @@
+# pycabana - Pure PySide6 Rewrite of Cabana
+
+A complete Python rewrite of cabana using PySide6 and existing openpilot Python tools.
+
+## Approach
+
+- **Pure PySide6** - No C++ interop, no shiboken2 wrapping
+- **Leverage existing tools** - Use `LogReader`, `FrameReader`, `Route`, `opendbc.can.dbc`
+- **1:1 translation** - Mechanical port of C++ widgets to Python equivalents
+- **Piecemeal development** - Build and test widget by widget
+
+## Code Style
+
+- **2-space indentation** (matches openpilot Python code)
+- **Type hints** where helpful
+- only use comments where absolutely necessary
+- **Dataclasses** for data structures
+- make sure to run "tools/op.sh lint" periodically to make sure the linter still passes
+
+## evaluating your progress
+
+some bugs i've found
+- blocking the main thread
+- ctrl-c doesn't work and just hangs
+- random console errors when running cabana
+
+absolutely none of these should ever happen
+
+as you work, make sure to run these to make sure they continue passing:
+- fast lint: ruff check
+- full lint: tools/op.sh lint
+- cabana smoketest: source .venv/bin/activate && tools/cabana/pycabana/test_smoke.py
+
+---
+
+## v0.1 Roadmap
+
+### Goal
+A minimal cabana that can load a route, display CAN messages, and show decoded signals from a DBC file. No live replay, no video, no charts.
+
+### v0.1a: Route Log Viewer (~450 lines)
+
+**What it does:**
+- Load a real openpilot route via command line
+- Display all CAN messages in a table (Address, Bus, Count, Freq, Last Data)
+- Live-ish updates as data loads
+
+**Files to create:**
+
+```
+pycabana/
+├── dbc/
+│ └── dbc.py # MessageId, CanEvent, CanData dataclasses
+├── streams/
+│ ├── abstract.py # AbstractStream base class with signals
+│ └── replay.py # ReplayStream using LogReader
+├── widgets/
+│ └── messages.py # MessagesWidget + MessageListModel
+├── main.py # Entry point, arg parsing
+└── mainwindow.py # MainWindow with messages dock
+```
+
+**Data flow:**
+```
+LogReader → CanEvent objects → AbstractStream.events_ dict
+ ↓
+ msgsReceived signal
+ ↓
+ MessagesWidget updates table
+```
+
+### v0.1b: Add DBC Decoding (~250 more lines)
+
+**What it adds:**
+- Load DBC file (from fingerprint or --dbc flag)
+- Show message names in the table
+- Click a message → see its signals + last values in a detail panel
+
+**Files to add/modify:**
+
+```
+pycabana/
+├── dbc/
+│ ├── dbc.py # Add Signal, Msg classes (wrap opendbc types)
+│ └── dbcmanager.py # DBCManager singleton, loads DBC files
+├── widgets/
+│ ├── messages.py # Add Name column, use DBC for display
+│ └── signal.py # SignalView - simple table of signal values
+└── mainwindow.py # Add detail panel, connect selection
+```
+
+**Data flow:**
+```
+User clicks message → msgSelectionChanged signal
+ ↓
+ DetailWidget shows SignalView
+ ↓
+ SignalView reads last CanData + DBC
+ ↓
+ Displays signal names + decoded values
+```
+
+### v0.1 Final Structure
+
+```
+tools/cabana/pycabana/
+├── __init__.py
+├── __main__.py
+├── main.py # Entry point, QApplication, arg parsing
+├── mainwindow.py # MainWindow with docks
+├── dbc/
+│ ├── __init__.py
+│ ├── dbc.py # MessageId, CanEvent, CanData, Signal, Msg
+│ └── dbcmanager.py # DBCManager singleton
+├── streams/
+│ ├── __init__.py
+│ ├── abstract.py # AbstractStream base class
+│ └── replay.py # ReplayStream (LogReader-based)
+├── widgets/
+│ ├── __init__.py
+│ ├── messages.py # MessagesWidget, MessageListModel
+│ └── signal.py # SignalView (simple signal table)
+├── dialogs/
+│ └── __init__.py
+└── utils/
+ └── __init__.py
+```
+
+---
+
+## v0.1a Implementation Details
+
+### Step 1: `dbc/dbc.py` - Core Data Structures
+
+```python
+@dataclass(frozen=True)
+class MessageId:
+ source: int = 0
+ address: int = 0
+
+ def __hash__(self): ...
+ def __str__(self): return f"{self.source}:{self.address:X}"
+
+@dataclass
+class CanEvent:
+ src: int
+ address: int
+ mono_time: int # nanoseconds
+ dat: bytes
+
+@dataclass
+class CanData:
+ """Processed message data for display."""
+ ts: float = 0.0 # last timestamp in seconds
+ count: int = 0 # total message count
+ freq: float = 0.0 # messages per second
+ dat: bytes = b'' # last data bytes
+
+ def update(self, event: CanEvent, start_ts: int): ...
+```
+
+### Step 2: `streams/abstract.py` - Base Stream
+
+```python
+class AbstractStream(QObject):
+ msgsReceived = Signal(set, bool) # (msg_ids, has_new_ids)
+ seekedTo = Signal(float)
+ streamStarted = Signal()
+
+ def __init__(self):
+ self.events: dict[MessageId, list[CanEvent]] = {}
+ self.last_msgs: dict[MessageId, CanData] = {}
+ self.start_ts: int = 0
+
+ def start(self): ...
+ def stop(self): ...
+ def lastMessage(self, msg_id: MessageId) -> CanData | None: ...
+ def allEvents(self) -> list[CanEvent]: ...
+
+ def updateEvent(self, event: CanEvent):
+ """Process a single CAN event, update last_msgs."""
+ ...
+```
+
+### Step 3: `streams/replay.py` - Route Loading
+
+```python
+class ReplayStream(AbstractStream):
+ def __init__(self):
+ super().__init__()
+ self.lr: LogReader | None = None
+ self.route_name: str = ""
+
+ def loadRoute(self, route: str, segment: int = 0) -> bool:
+ """Load route using LogReader, populate events."""
+ from openpilot.tools.lib.logreader import LogReader
+ self.lr = LogReader(route)
+ for msg in self.lr:
+ if msg.which() == 'can':
+ for c in msg.can:
+ event = CanEvent(c.src, c.address, msg.logMonoTime, bytes(c.dat))
+ self.updateEvent(event)
+ self.msgsReceived.emit(set(self.last_msgs.keys()), True)
+ return True
+```
+
+### Step 4: `widgets/messages.py` - Message Table
+
+```python
+class MessageListModel(QAbstractTableModel):
+ """Model for the messages table."""
+ COLUMNS = ['Address', 'Bus', 'Count', 'Freq', 'Data']
+
+ def __init__(self, stream: AbstractStream):
+ self.stream = stream
+ self.msg_ids: list[MessageId] = []
+
+ def rowCount(self, parent=None): return len(self.msg_ids)
+ def columnCount(self, parent=None): return len(self.COLUMNS)
+ def data(self, index, role): ...
+ def headerData(self, section, orientation, role): ...
+
+ def updateMessages(self, msg_ids: set[MessageId], has_new: bool):
+ """Called when stream emits msgsReceived."""
+ ...
+
+class MessagesWidget(QWidget):
+ msgSelectionChanged = Signal(object) # MessageId or None
+
+ def __init__(self, stream: AbstractStream):
+ self.model = MessageListModel(stream)
+ self.view = QTableView()
+ self.view.setModel(self.model)
+ self.filter_input = QLineEdit() # for filtering
+ ...
+```
+
+### Step 5: `mainwindow.py` - Main Window
+
+```python
+class MainWindow(QMainWindow):
+ def __init__(self, stream: AbstractStream):
+ self.stream = stream
+ self.setWindowTitle("pycabana")
+
+ # Messages dock (left)
+ self.messages_widget = MessagesWidget(stream)
+ self.messages_dock = QDockWidget("Messages")
+ self.messages_dock.setWidget(self.messages_widget)
+ self.addDockWidget(Qt.LeftDockWidgetArea, self.messages_dock)
+
+ # Central widget (placeholder for now)
+ self.setCentralWidget(QLabel("Select a message"))
+
+ # Status bar
+ self.status_label = QLabel()
+ self.statusBar().addWidget(self.status_label)
+
+ # Connect signals
+ self.stream.msgsReceived.connect(self._onMsgsReceived)
+
+ def _onMsgsReceived(self, msg_ids, has_new):
+ count = sum(self.stream.last_msgs[m].count for m in self.stream.last_msgs)
+ self.status_label.setText(f"{len(self.stream.last_msgs)} messages, {count} events")
+```
+
+### Step 6: `main.py` - Entry Point
+
+```python
+def main():
+ app = QApplication(sys.argv)
+ app.setApplicationName("pycabana")
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument('route', nargs='?', help='Route to load')
+ parser.add_argument('--demo', action='store_true')
+ args = parser.parse_args()
+
+ route = args.route or (DEMO_ROUTE if args.demo else None)
+
+ stream = ReplayStream()
+ if route:
+ if not stream.loadRoute(route):
+ print(f"Failed to load route: {route}")
+ return 1
+
+ window = MainWindow(stream)
+ window.show()
+ return app.exec_()
+```
+
+---
+
+## v0.1b Implementation Details
+
+### Step 7: `dbc/dbc.py` - Add Signal/Msg (extend)
+
+```python
+@dataclass
+class Signal:
+ """Wraps opendbc.can.dbc.Signal with cabana-specific additions."""
+ name: str
+ start_bit: int
+ size: int
+ is_signed: bool
+ factor: float
+ offset: float
+ is_little_endian: bool
+ # ... other fields
+
+ def getValue(self, data: bytes) -> float | None:
+ """Decode signal value from CAN data."""
+ ...
+
+@dataclass
+class Msg:
+ """Wraps opendbc.can.dbc.Msg."""
+ address: int
+ name: str
+ size: int
+ signals: dict[str, Signal]
+```
+
+### Step 8: `dbc/dbcmanager.py` - DBC Management
+
+```python
+class DBCManager(QObject):
+ """Singleton that manages loaded DBC files."""
+ _instance: 'DBCManager | None' = None
+
+ msgUpdated = Signal(object) # MessageId
+ dbcLoaded = Signal(str) # dbc name
+
+ def __init__(self):
+ self.dbc: DBC | None = None
+ self.msgs: dict[int, Msg] = {} # address -> Msg
+
+ @classmethod
+ def instance(cls) -> 'DBCManager':
+ if cls._instance is None:
+ cls._instance = DBCManager()
+ return cls._instance
+
+ def load(self, dbc_name: str) -> bool:
+ """Load DBC file using opendbc.can.dbc.DBC."""
+ from opendbc.can.dbc import DBC
+ self.dbc = DBC(dbc_name)
+ # Convert to our Msg/Signal types
+ ...
+ self.dbcLoaded.emit(dbc_name)
+ return True
+
+ def msg(self, msg_id: MessageId) -> Msg | None:
+ """Get message definition by ID."""
+ return self.msgs.get(msg_id.address)
+
+# Global accessor
+def dbc() -> DBCManager:
+ return DBCManager.instance()
+```
+
+### Step 9: `widgets/signal.py` - Signal View
+
+```python
+class SignalView(QTableWidget):
+ """Simple table showing signal names and values."""
+
+ def __init__(self):
+ self.setColumnCount(2)
+ self.setHorizontalHeaderLabels(['Signal', 'Value'])
+
+ def setMessage(self, msg_id: MessageId, can_data: CanData):
+ """Update to show signals for the given message."""
+ msg = dbc().msg(msg_id)
+ if not msg:
+ self.setRowCount(0)
+ return
+
+ self.setRowCount(len(msg.signals))
+ for i, (name, sig) in enumerate(msg.signals.items()):
+ value = sig.getValue(can_data.dat)
+ self.setItem(i, 0, QTableWidgetItem(name))
+ self.setItem(i, 1, QTableWidgetItem(f"{value:.2f}" if value else "N/A"))
+```
+
+### Step 10: `mainwindow.py` - Add Detail Panel
+
+```python
+class MainWindow(QMainWindow):
+ def __init__(self, stream: AbstractStream, dbc_file: str = ""):
+ ...
+ # Detail panel (center)
+ self.signal_view = SignalView()
+ self.setCentralWidget(self.signal_view)
+
+ # Connect message selection
+ self.messages_widget.msgSelectionChanged.connect(self._onMsgSelected)
+
+ # Load DBC if provided
+ if dbc_file:
+ dbc().load(dbc_file)
+
+ def _onMsgSelected(self, msg_id: MessageId | None):
+ if msg_id and msg_id in self.stream.last_msgs:
+ self.signal_view.setMessage(msg_id, self.stream.last_msgs[msg_id])
+```
+
+---
+
+## Future Versions (v0.2+)
+
+| Version | Features |
+|---------|----------|
+| v0.2 | BinaryView (byte/bit visualization with colors) |
+| v0.3 | Live replay with seeking (timeline slider) |
+| v0.4 | VideoWidget + CameraView (video playback) |
+| v0.5 | ChartsWidget (signal plotting) |
+| v0.6 | HistoryLog (message history table) |
+| v0.7 | Settings, undo/redo, DBC editing |
+| v1.0 | Feature parity with C++ cabana |
+
+---
+
+## File-by-File C++ to Python Translation Reference
+
+### Core (Tier 1)
+
+| C++ File | Lines | Python Target | Notes |
+|----------|-------|---------------|-------|
+| `dbc/dbc.h` + `.cc` | 343 | `dbc/dbc.py` | MessageId, Signal, Msg dataclasses |
+| `settings.h` + `.cc` | 204 | `settings.py` | QSettings wrapper singleton |
+| `dbc/dbcmanager.h` + `.cc` | 247 | `dbc/dbcmanager.py` | Use opendbc.can.dbc for parsing |
+| `dbc/dbcfile.h` + `.cc` | 318 | `dbc/dbcfile.py` | DBC file I/O |
+| `streams/abstractstream.h` + `.cc` | 482 | `streams/abstract.py` | Base stream, CanEvent, CanData |
+| `commands.h` + `.cc` | 196 | `commands.py` | QUndoCommand subclasses |
+
+### Streams (Tier 2)
+
+| C++ File | Lines | Python Target | Notes |
+|----------|-------|---------------|-------|
+| `streams/replaystream.h` + `.cc` | 233 | `streams/replay.py` | Use LogReader, FrameReader, Route |
+
+### Widgets (Tier 3)
+
+| C++ File | Lines | Python Target | Notes |
+|----------|-------|---------------|-------|
+| `messageswidget.h` + `.cc` | 585 | `widgets/messages.py` | QAbstractTableModel + QTreeView |
+| `binaryview.h` + `.cc` | 611 | `widgets/binary.py` | Custom delegate for bit display |
+| `signalview.h` + `.cc` | 869 | `widgets/signal.py` | Tree model with inline editing |
+| `historylog.h` + `.cc` | 329 | `widgets/history.py` | Time-filtered table |
+| `detailwidget.h` + `.cc` | 365 | `widgets/detail.py` | Container for binary/signal/history |
+| `videowidget.h` + `.cc` | 509 | `widgets/video.py` | Timeline slider, playback controls |
+| `cameraview.h` + `.cc` | 325 | `widgets/camera.py` | Use FrameReader + QLabel/QPixmap |
+
+### Charts (Tier 4)
+
+| C++ File | Lines | Python Target | Notes |
+|----------|-------|---------------|-------|
+| `chart/chartswidget.h` + `.cc` | 698 | `widgets/charts.py` | QtCharts container |
+| `chart/chart.h` + `.cc` | 985 | `widgets/charts.py` | QtCharts QChartView |
+| `chart/sparkline.h` + `.cc` | 126 | `widgets/signal.py` | Inline mini-charts |
+
+### Main (Tier 5)
+
+| C++ File | Lines | Python Target | Notes |
+|----------|-------|---------------|-------|
+| `mainwin.h` + `.cc` | 765 | `mainwindow.py` | QMainWindow with docks |
+| `cabana.cc` | 246 | `main.py` | Entry point, arg parsing |
+
+---
+
+## Existing Python Tools to Use
+
+| Tool | Location | Usage |
+|------|----------|-------|
+| LogReader | `tools/lib/logreader.py` | Read rlog/qlog files |
+| FrameReader | `tools/lib/framereader.py` | Read video frames |
+| Route | `tools/lib/route.py` | Route loading and segment management |
+| DBC | `opendbc/can/dbc.py` | Parse DBC files |
+| CANParser | `opendbc/can/parser.py` | Decode CAN signals |
+| CommaApi | `tools/lib/api.py` | Fetch routes from comma API |
+
+---
+
+## Dependencies
+
+```
+PySide6
+numpy
+```
+
+Plus existing openpilot packages in PYTHONPATH.
+
+---
+
+## Running
+
+```bash
+# After implementation:
+python -m openpilot.tools.cabana.pycabana --demo
+python -m openpilot.tools.cabana.pycabana "a]2a0ccea32023010|2023-07-27--13-01-19"
+python -m openpilot.tools.cabana.pycabana "a]2a0ccea32023010|2023-07-27--13-01-19" --dbc toyota_rav4_2017
+```
diff --git a/tools/cabana/pycabana/__init__.py b/tools/cabana/pycabana/__init__.py
new file mode 100644
index 00000000000000..200236942e6155
--- /dev/null
+++ b/tools/cabana/pycabana/__init__.py
@@ -0,0 +1,10 @@
+"""pycabana - Pure PySide6 rewrite of cabana CAN bus analyzer"""
+
+__all__ = ["main"]
+
+
+def main():
+ """Entry point - import lazily to avoid import-time Qt issues."""
+ from openpilot.tools.cabana.pycabana.main import main as _main
+
+ return _main()
diff --git a/tools/cabana/pycabana/cabana.py b/tools/cabana/pycabana/cabana.py
new file mode 100755
index 00000000000000..4e52bcda7e8aae
--- /dev/null
+++ b/tools/cabana/pycabana/cabana.py
@@ -0,0 +1,14 @@
+#!/usr/bin/env python3
+"""pycabana - PySide6 CAN bus analyzer.
+
+Usage:
+ python cabana.py [route]
+ python cabana.py --demo
+"""
+
+import sys
+
+from openpilot.tools.cabana.pycabana.main import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/cabana/pycabana/commands.py b/tools/cabana/pycabana/commands.py
new file mode 100644
index 00000000000000..ceb0284539fe68
--- /dev/null
+++ b/tools/cabana/pycabana/commands.py
@@ -0,0 +1,263 @@
+"""Undo/redo commands for pycabana."""
+
+from typing import Optional
+
+from PySide6.QtCore import QCoreApplication, QObject
+from PySide6.QtGui import QUndoCommand, QUndoStack
+from PySide6.QtWidgets import QApplication
+
+from opendbc.can.dbc import Signal, Msg
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId
+from openpilot.tools.cabana.pycabana.dbc.dbcmanager import dbc_manager
+from openpilot.tools.cabana.pycabana.streams.abstract import AbstractStream
+
+
+# Signal type constants (matching cabana::Signal::Type)
+class SignalType:
+ Normal = 0
+ Multiplexed = 1
+ Multiplexor = 2
+
+
+def _get_stream() -> Optional[AbstractStream]:
+ """Get the global stream instance from the application."""
+ app = QApplication.instance()
+ if app and hasattr(app, 'stream'):
+ return app.stream
+ return None
+
+
+class EditMsgCommand(QUndoCommand):
+ """Command to edit or create a message."""
+
+ def __init__(
+ self,
+ msg_id: MessageId,
+ name: str,
+ size: int,
+ node: str,
+ comment: str,
+ parent: Optional[QUndoCommand] = None
+ ):
+ super().__init__(parent)
+ self.id = msg_id
+ self.new_name = name
+ self.new_size = size
+ self.new_node = node
+ self.new_comment = comment
+
+ self.old_name = ""
+ self.old_size = 0
+ self.old_node = ""
+ self.old_comment = ""
+
+ msg = dbc_manager().msg(msg_id)
+ if msg:
+ self.old_name = msg.name
+ self.old_size = msg.size
+ self.old_node = getattr(msg, 'transmitter', '')
+ self.old_comment = getattr(msg, 'comment', '')
+ self.setText(QObject.tr(f"edit message {name}:{msg_id.address}"))
+ else:
+ self.setText(QObject.tr(f"new message {name}:{msg_id.address}"))
+
+ def undo(self) -> None:
+ if not self.old_name:
+ dbc_manager().removeMsg(self.id)
+ else:
+ dbc_manager().updateMsg(self.id, self.old_name, self.old_size, self.old_node, self.old_comment)
+
+ def redo(self) -> None:
+ dbc_manager().updateMsg(self.id, self.new_name, self.new_size, self.new_node, self.new_comment)
+
+
+class RemoveMsgCommand(QUndoCommand):
+ """Command to remove a message."""
+
+ def __init__(self, msg_id: MessageId, parent: Optional[QUndoCommand] = None):
+ super().__init__(parent)
+ self.id = msg_id
+ self.message: Optional[Msg] = None
+ self.signals: list[Signal] = []
+
+ msg = dbc_manager().msg(msg_id)
+ if msg:
+ # Store a copy of the message
+ self.message = msg
+ # Store copies of all signals
+ self.signals = list(msg.sigs.values()) if hasattr(msg, 'sigs') else []
+ self.setText(QObject.tr(f"remove message {msg.name}:{msg_id.address}"))
+
+ def undo(self) -> None:
+ if self.message:
+ dbc_manager().updateMsg(
+ self.id,
+ self.message.name,
+ self.message.size,
+ getattr(self.message, 'transmitter', ''),
+ getattr(self.message, 'comment', '')
+ )
+ # Restore all signals
+ for sig in self.signals:
+ dbc_manager().addSignal(self.id, sig)
+
+ def redo(self) -> None:
+ if self.message:
+ dbc_manager().removeMsg(self.id)
+
+
+class AddSigCommand(QUndoCommand):
+ """Command to add a signal to a message."""
+
+ def __init__(self, msg_id: MessageId, sig: Signal, parent: Optional[QUndoCommand] = None):
+ super().__init__(parent)
+ self.id = msg_id
+ self.signal = sig
+ self.msg_created = False
+
+ msg_name = dbc_manager().msgName(msg_id)
+ self.setText(QObject.tr(f"add signal {sig.name} to {msg_name}:{msg_id.address}"))
+
+ def undo(self) -> None:
+ dbc_manager().removeSignal(self.id, self.signal.name)
+ if self.msg_created:
+ dbc_manager().removeMsg(self.id)
+
+ def redo(self) -> None:
+ msg = dbc_manager().msg(self.id)
+ if not msg:
+ # Create a new message if it doesn't exist
+ self.msg_created = True
+ stream = _get_stream()
+ last_msg = stream.lastMessage(self.id) if stream else None
+ msg_size = len(last_msg.dat) if last_msg else 8
+ dbc_manager().updateMsg(
+ self.id,
+ dbc_manager().newMsgName(self.id),
+ msg_size,
+ "",
+ ""
+ )
+
+ # Update signal name
+ self.signal.name = dbc_manager().newSignalName(self.id)
+ # Create a new signal with updated values
+ updated_signal = Signal(
+ name=self.signal.name,
+ start_bit=self.signal.start_bit,
+ msb=self.signal.msb,
+ lsb=self.signal.lsb,
+ size=self.signal.size,
+ is_signed=self.signal.is_signed,
+ factor=self.signal.factor,
+ offset=self.signal.offset,
+ is_little_endian=self.signal.is_little_endian,
+ type=self.signal.type
+ )
+ dbc_manager().addSignal(self.id, updated_signal)
+
+
+class RemoveSigCommand(QUndoCommand):
+ """Command to remove a signal from a message."""
+
+ def __init__(self, msg_id: MessageId, sig: Signal, parent: Optional[QUndoCommand] = None):
+ super().__init__(parent)
+ self.id = msg_id
+ self.sigs: list[Signal] = []
+
+ # Store the signal to be removed
+ self.sigs.append(sig)
+
+ # If removing a multiplexor, also store all multiplexed signals
+ if sig.type == SignalType.Multiplexor:
+ msg = dbc_manager().msg(msg_id)
+ if msg and hasattr(msg, 'sigs'):
+ for s in msg.sigs.values():
+ if s.type == SignalType.Multiplexed:
+ self.sigs.append(s)
+
+ msg_name = dbc_manager().msgName(msg_id)
+ self.setText(QObject.tr(f"remove signal {sig.name} from {msg_name}:{msg_id.address}"))
+
+ def undo(self) -> None:
+ for sig in self.sigs:
+ dbc_manager().addSignal(self.id, sig)
+
+ def redo(self) -> None:
+ for sig in self.sigs:
+ dbc_manager().removeSignal(self.id, sig.name)
+
+
+class EditSignalCommand(QUndoCommand):
+ """Command to edit a signal."""
+
+ def __init__(
+ self,
+ msg_id: MessageId,
+ sig: Signal,
+ new_sig: Signal,
+ parent: Optional[QUndoCommand] = None
+ ):
+ super().__init__(parent)
+ self.id = msg_id
+ # Store pairs of (old_sig, new_sig)
+ self.sigs: list[tuple[Signal, Signal]] = []
+
+ self.sigs.append((sig, new_sig))
+
+ # If converting multiplexor to normal, convert all multiplexed signals to normal
+ if sig.type == SignalType.Multiplexor and new_sig.type == SignalType.Normal:
+ msg = dbc_manager().msg(msg_id)
+ if msg and hasattr(msg, 'sigs'):
+ for s in msg.sigs.values():
+ if s.type == SignalType.Multiplexed:
+ # Create a new signal with Normal type
+ new_s = Signal(
+ name=s.name,
+ start_bit=s.start_bit,
+ msb=s.msb,
+ lsb=s.lsb,
+ size=s.size,
+ is_signed=s.is_signed,
+ factor=s.factor,
+ offset=s.offset,
+ is_little_endian=s.is_little_endian,
+ type=SignalType.Normal
+ )
+ self.sigs.append((s, new_s))
+
+ msg_name = dbc_manager().msgName(msg_id)
+ self.setText(QObject.tr(f"edit signal {sig.name} in {msg_name}:{msg_id.address}"))
+
+ def undo(self) -> None:
+ for old_sig, new_sig in self.sigs:
+ dbc_manager().updateSignal(self.id, new_sig.name, old_sig)
+
+ def redo(self) -> None:
+ for old_sig, new_sig in self.sigs:
+ dbc_manager().updateSignal(self.id, old_sig.name, new_sig)
+
+
+class UndoStack:
+ """Singleton undo stack for pycabana."""
+
+ _instance: Optional[QUndoStack] = None
+
+ @classmethod
+ def instance(cls) -> QUndoStack:
+ """Get the global undo stack instance."""
+ if cls._instance is None:
+ app = QCoreApplication.instance()
+ cls._instance = QUndoStack(app)
+ return cls._instance
+
+ @classmethod
+ def push(cls, cmd: QUndoCommand) -> None:
+ """Push a command onto the undo stack."""
+ cls.instance().push(cmd)
+
+
+def undo_stack() -> QUndoStack:
+ """Get the global undo stack instance."""
+ return UndoStack.instance()
diff --git a/tools/cabana/pycabana/dbc/__init__.py b/tools/cabana/pycabana/dbc/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/tools/cabana/pycabana/dbc/dbc.py b/tools/cabana/pycabana/dbc/dbc.py
new file mode 100644
index 00000000000000..9a3f867fb7893f
--- /dev/null
+++ b/tools/cabana/pycabana/dbc/dbc.py
@@ -0,0 +1,92 @@
+"""Core data structures for pycabana."""
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from opendbc.can.dbc import Signal
+
+
+@dataclass(frozen=True)
+class MessageId:
+ """Unique identifier for a CAN message (bus + address)."""
+
+ source: int = 0 # bus number
+ address: int = 0 # CAN address
+
+ def __hash__(self):
+ return hash((self.source, self.address))
+
+ def __str__(self):
+ return f"{self.source}:{self.address:X}"
+
+ def __lt__(self, other):
+ return (self.source, self.address) < (other.source, other.address)
+
+
+@dataclass
+class CanEvent:
+ """A single CAN message event."""
+
+ src: int # bus number
+ address: int # CAN address
+ mono_time: int # monotonic time in nanoseconds
+ dat: bytes # message data (up to 64 bytes for CAN FD)
+
+ @property
+ def size(self) -> int:
+ return len(self.dat)
+
+
+@dataclass
+class CanData:
+ """Processed message data for display, updated incrementally."""
+
+ ts: float = 0.0 # last timestamp in seconds
+ count: int = 0 # total message count
+ freq: float = 0.0 # messages per second (rolling average)
+ dat: bytes = b'' # last data bytes
+
+ # For frequency calculation
+ _freq_ts: float = field(default=0.0, repr=False)
+ _freq_count: int = field(default=0, repr=False)
+
+ def update(self, event: CanEvent, start_ts: int) -> None:
+ """Update with a new event."""
+ self.count += 1
+ self.dat = event.dat
+ self.ts = (event.mono_time - start_ts) / 1e9 # convert to seconds
+
+ # Update frequency every second
+ if self.ts - self._freq_ts >= 1.0:
+ if self._freq_ts > 0:
+ self.freq = (self.count - self._freq_count) / (self.ts - self._freq_ts)
+ self._freq_ts = self.ts
+ self._freq_count = self.count
+
+
+def decode_signal(sig: "Signal", data: bytes) -> float:
+ """Decode a signal value from CAN data bytes."""
+ if len(data) == 0:
+ return 0.0
+
+ # Build bit array from data
+ bits = []
+ for byte in data:
+ for i in range(8):
+ bits.append((byte >> i) & 1)
+
+ # Extract signal bits
+ value = 0
+ for i in range(sig.size):
+ bit_idx = sig.lsb + i
+ if bit_idx < len(bits):
+ value |= bits[bit_idx] << i
+
+ # Handle signed values
+ if sig.is_signed and sig.size > 0:
+ if value & (1 << (sig.size - 1)):
+ value -= 1 << sig.size
+
+ # Apply factor and offset
+ return value * sig.factor + sig.offset
diff --git a/tools/cabana/pycabana/dbc/dbcfile.py b/tools/cabana/pycabana/dbc/dbcfile.py
new file mode 100644
index 00000000000000..14d8e3ddf9f28c
--- /dev/null
+++ b/tools/cabana/pycabana/dbc/dbcfile.py
@@ -0,0 +1,538 @@
+"""DBC file parser and generator for pycabana."""
+
+import re
+from pathlib import Path
+from typing import Optional
+from enum import Enum
+
+from PySide6.QtCore import QObject
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId
+
+DEFAULT_NODE_NAME = "XXX"
+
+
+def double_to_string(value: float) -> str:
+ """Convert double to string with maximum precision."""
+ # Python's default g format with 15 digits (equivalent to double precision)
+ result = f"{value:.15g}"
+ return result
+
+
+def num_decimals(num: float) -> int:
+ """Count the number of decimal places in a number."""
+ string = str(num)
+ dot_pos = string.find('.')
+ return 0 if dot_pos == -1 else len(string) - dot_pos - 1
+
+
+def flip_bit_pos(start_bit: int) -> int:
+ """Flip bit position for big-endian signals."""
+ return 8 * (start_bit // 8) + 7 - start_bit % 8
+
+
+def update_msb_lsb(sig: 'Signal') -> None:
+ """Update MSB and LSB based on endianness."""
+ if sig.is_little_endian:
+ sig.lsb = sig.start_bit
+ sig.msb = sig.start_bit + sig.size - 1
+ else:
+ sig.lsb = flip_bit_pos(flip_bit_pos(sig.start_bit) + sig.size - 1)
+ sig.msb = sig.start_bit
+
+
+class SignalType(Enum):
+ """Signal type enumeration."""
+ Normal = 0
+ Multiplexed = 1
+ Multiplexor = 2
+
+
+class Signal:
+ """Signal definition in a CAN message."""
+
+ def __init__(self) -> None:
+ self.type: SignalType = SignalType.Normal
+ self.name: str = ""
+ self.start_bit: int = 0
+ self.msb: int = 0
+ self.lsb: int = 0
+ self.size: int = 0
+ self.factor: float = 1.0
+ self.offset: float = 0.0
+ self.is_signed: bool = False
+ self.is_little_endian: bool = True
+ self.min: float = 0.0
+ self.max: float = 0.0
+ self.unit: str = ""
+ self.comment: str = ""
+ self.receiver_name: str = ""
+ self.val_desc: list[tuple[float, str]] = []
+ self.precision: int = 0
+ self.multiplex_value: int = 0
+ self.multiplexor: Optional[Signal] = None
+
+ def update(self) -> None:
+ """Update signal properties after changes."""
+ update_msb_lsb(self)
+ if not self.receiver_name:
+ self.receiver_name = DEFAULT_NODE_NAME
+
+ # Calculate precision
+ self.precision = max(num_decimals(self.factor), num_decimals(self.offset))
+
+ def __eq__(self, other: object) -> bool:
+ """Check if two signals are equal."""
+ if not isinstance(other, Signal):
+ return False
+ return (self.name == other.name and self.size == other.size and
+ self.start_bit == other.start_bit and
+ self.msb == other.msb and self.lsb == other.lsb and
+ self.is_signed == other.is_signed and self.is_little_endian == other.is_little_endian and
+ self.factor == other.factor and self.offset == other.offset and
+ self.min == other.min and self.max == other.max and
+ self.comment == other.comment and self.unit == other.unit and
+ self.val_desc == other.val_desc and
+ self.multiplex_value == other.multiplex_value and self.type == other.type and
+ self.receiver_name == other.receiver_name)
+
+
+class Msg:
+ """Message definition in a DBC file."""
+
+ def __init__(self) -> None:
+ self.address: int = 0
+ self.name: str = ""
+ self.size: int = 0
+ self.comment: str = ""
+ self.transmitter: str = ""
+ self.sigs: list[Signal] = []
+ self.mask: list[int] = []
+ self.multiplexor: Optional[Signal] = None
+
+ def add_signal(self, sig: Signal) -> Signal:
+ """Add a signal to the message."""
+ self.sigs.append(sig)
+ self.update()
+ return sig
+
+ def update_signal(self, sig_name: str, new_sig: Signal) -> Optional[Signal]:
+ """Update an existing signal."""
+ s = self.sig(sig_name)
+ if s:
+ idx = self.sigs.index(s)
+ self.sigs[idx] = new_sig
+ self.update()
+ return new_sig
+ return None
+
+ def remove_signal(self, sig_name: str) -> None:
+ """Remove a signal from the message."""
+ self.sigs = [s for s in self.sigs if s.name != sig_name]
+ self.update()
+
+ def sig(self, sig_name: str) -> Optional[Signal]:
+ """Get signal by name."""
+ for s in self.sigs:
+ if s.name == sig_name:
+ return s
+ return None
+
+ def index_of(self, sig: Signal) -> int:
+ """Get index of signal."""
+ try:
+ return self.sigs.index(sig)
+ except ValueError:
+ return -1
+
+ def new_signal_name(self) -> str:
+ """Generate a new unique signal name."""
+ i = 1
+ while True:
+ new_name = f"NEW_SIGNAL_{i}"
+ if self.sig(new_name) is None:
+ return new_name
+ i += 1
+
+ def update(self) -> None:
+ """Update message properties after changes."""
+ if not self.transmitter:
+ self.transmitter = DEFAULT_NODE_NAME
+
+ self.mask = [0x00] * self.size
+ self.multiplexor = None
+
+ # Sort signals
+ def sort_key(s: Signal) -> tuple:
+ return (s.type != SignalType.Multiplexor, s.multiplex_value, s.start_bit, s.name)
+
+ self.sigs.sort(key=sort_key)
+
+ # Update each signal
+ for sig in self.sigs:
+ if sig.type == SignalType.Multiplexor:
+ self.multiplexor = sig
+ sig.update()
+
+ # Update mask
+ i = sig.msb // 8
+ bits = sig.size
+ while 0 <= i < self.size and bits > 0:
+ lsb = sig.lsb if (sig.lsb // 8) == i else i * 8
+ msb = sig.msb if (sig.msb // 8) == i else (i + 1) * 8 - 1
+
+ sz = msb - lsb + 1
+ shift = lsb - (i * 8)
+
+ self.mask[i] |= ((1 << sz) - 1) << shift
+
+ bits -= sz
+ i = i - 1 if sig.is_little_endian else i + 1
+
+ # Set multiplexor references
+ for sig in self.sigs:
+ sig.multiplexor = self.multiplexor if sig.type == SignalType.Multiplexed else None
+ if not sig.multiplexor:
+ if sig.type == SignalType.Multiplexed:
+ sig.type = SignalType.Normal
+ sig.multiplex_value = 0
+
+ def get_signals(self) -> list[Signal]:
+ """Get all signals in the message."""
+ return self.sigs
+
+
+class DBCFile(QObject):
+ """DBC file parser and generator."""
+
+ def __init__(self, name_or_path: str, content: Optional[str] = None) -> None:
+ """
+ Initialize DBCFile.
+
+ Args:
+ name_or_path: Either a file path (if content is None) or a name (if content is provided)
+ content: Optional DBC content as string
+ """
+ super().__init__()
+
+ self.filename: str = ""
+ self.name_: str = ""
+ self.header: str = ""
+ self.msgs: dict[int, Msg] = {}
+
+ if content is not None:
+ # Create from name and content
+ self.name_ = name_or_path
+ self.filename = ""
+ self._parse(content)
+ else:
+ # Load from file
+ path = Path(name_or_path)
+ if not path.exists():
+ raise RuntimeError("Failed to open file.")
+ self.name_ = path.stem
+ self.filename = str(path)
+ with open(path, encoding='utf-8') as f:
+ self._parse(f.read())
+
+ def save(self) -> bool:
+ """Save to current filename."""
+ assert self.filename, "Filename is empty"
+ return self._write_contents(self.filename)
+
+ def save_as(self, new_filename: str) -> bool:
+ """Save to a new filename."""
+ self.filename = new_filename
+ return self.save()
+
+ def _write_contents(self, fn: str) -> bool:
+ """Write DBC contents to file."""
+ try:
+ with open(fn, 'w', encoding='utf-8') as f:
+ f.write(self.generate_dbc())
+ return True
+ except Exception:
+ return False
+
+ def update_msg(self, msg_id: MessageId, name: str, size: int, node: str, comment: str) -> None:
+ """Update or create a message."""
+ if msg_id.address not in self.msgs:
+ self.msgs[msg_id.address] = Msg()
+
+ m = self.msgs[msg_id.address]
+ m.address = msg_id.address
+ m.name = name
+ m.size = size
+ m.transmitter = DEFAULT_NODE_NAME if not node else node
+ m.comment = comment
+
+ def remove_msg(self, msg_id: MessageId) -> None:
+ """Remove a message."""
+ if msg_id.address in self.msgs:
+ del self.msgs[msg_id.address]
+
+ def get_messages(self) -> dict[int, Msg]:
+ """Get all messages."""
+ return self.msgs
+
+ def msg(self, address_or_name: int | str) -> Optional[Msg]:
+ """Get message by address or name."""
+ if isinstance(address_or_name, int):
+ return self.msgs.get(address_or_name)
+ else:
+ # Search by name
+ for m in self.msgs.values():
+ if m.name == address_or_name:
+ return m
+ return None
+
+ def signal(self, address: int, name: str) -> Optional[Signal]:
+ """Get signal by address and name."""
+ m = self.msg(address)
+ return m.sig(name) if m else None
+
+ def name(self) -> str:
+ """Get the name of the DBC file."""
+ return "untitled" if not self.name_ else self.name_
+
+ def is_empty(self) -> bool:
+ """Check if the DBC file is empty."""
+ return len(self.msgs) == 0 and not self.name_
+
+ def _parse(self, content: str) -> None:
+ """Parse DBC content."""
+ self.msgs.clear()
+
+ lines = content.split('\n')
+ line_num = 0
+ current_msg: Optional[Msg] = None
+ multiplexor_cnt = 0
+ seen_first = False
+
+ i = 0
+ while i < len(lines):
+ line_num += 1
+ raw_line = lines[i]
+ line = raw_line.strip()
+
+ seen = True
+ try:
+ if line.startswith("BO_ "):
+ multiplexor_cnt = 0
+ current_msg = self._parse_bo(line)
+ elif line.startswith("SG_ "):
+ self._parse_sg(line, current_msg, multiplexor_cnt)
+ elif line.startswith("VAL_ "):
+ self._parse_val(line)
+ elif line.startswith("CM_ BO_"):
+ i = self._parse_cm_bo(line, lines, i)
+ elif line.startswith("CM_ SG_ "):
+ i = self._parse_cm_sg(line, lines, i)
+ else:
+ seen = False
+ except Exception as e:
+ raise RuntimeError(f"[{self.filename}:{line_num}]{e}: {line}") from e
+
+ if seen:
+ seen_first = True
+ elif not seen_first:
+ self.header += raw_line + "\n"
+
+ i += 1
+
+ # Update all messages
+ for m in self.msgs.values():
+ m.update()
+
+ def _parse_bo(self, line: str) -> Msg:
+ """Parse BO_ line."""
+ bo_pattern = r'^BO_ (?P
\w+) (?P\w+) *: (?P\w+) (?P\w+)'
+ match = re.match(bo_pattern, line)
+
+ if not match:
+ raise RuntimeError("Invalid BO_ line format")
+
+ address = int(match.group('address'))
+ if address in self.msgs:
+ raise RuntimeError(f"Duplicate message address: {address}")
+
+ msg = Msg()
+ msg.address = address
+ msg.name = match.group('name')
+ msg.size = int(match.group('size'))
+ msg.transmitter = match.group('transmitter').strip()
+
+ self.msgs[address] = msg
+ return msg
+
+ def _parse_cm_bo(self, line: str, lines: list[str], current_idx: int) -> int:
+ """Parse CM_ BO_ (message comment) line."""
+ parse_line = line
+
+ # Handle multi-line comments
+ if not parse_line.endswith('";'):
+ # Find the end of the comment
+ for j in range(current_idx + 1, len(lines)):
+ parse_line += " " + lines[j].strip()
+ if '";' in lines[j]:
+ current_idx = j
+ break
+
+ msg_comment_pattern = r'^CM_ BO_ *(?P\w+) *"(?P(?:[^"\\]|\\.)*)"\s*;'
+ match = re.match(msg_comment_pattern, parse_line)
+
+ if not match:
+ raise RuntimeError("Invalid message comment format")
+
+ address = int(match.group('address'))
+ if address in self.msgs:
+ comment = match.group('comment').strip().replace('\\"', '"')
+ self.msgs[address].comment = comment
+
+ return current_idx
+
+ def _parse_sg(self, line: str, current_msg: Optional[Msg], multiplexor_cnt: int) -> None:
+ """Parse SG_ (signal) line."""
+ if not current_msg:
+ raise RuntimeError("No Message")
+
+ # Try normal signal pattern first
+ sg_pattern = r'^SG_ (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] "(.*)" (.*)'
+ match = re.match(sg_pattern, line)
+ offset = 0
+
+ # Try multiplexed signal pattern
+ if not match:
+ sgm_pattern = r'^SG_ (\w+) (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] "(.*)" (.*)'
+ match = re.match(sgm_pattern, line)
+ offset = 1
+
+ if not match:
+ raise RuntimeError("Invalid SG_ line format")
+
+ name = match.group(1)
+ if current_msg.sig(name) is not None:
+ raise RuntimeError("Duplicate signal name")
+
+ sig = Signal()
+
+ # Handle multiplexing
+ if offset == 1:
+ indicator = match.group(2)
+ if indicator == "M":
+ multiplexor_cnt += 1
+ if multiplexor_cnt >= 2:
+ raise RuntimeError("Multiple multiplexor")
+ sig.type = SignalType.Multiplexor
+ else:
+ sig.type = SignalType.Multiplexed
+ sig.multiplex_value = int(indicator[1:])
+
+ sig.name = name
+ sig.start_bit = int(match.group(offset + 2))
+ sig.size = int(match.group(offset + 3))
+ sig.is_little_endian = int(match.group(offset + 4)) == 1
+ sig.is_signed = match.group(offset + 5) == "-"
+ sig.factor = float(match.group(offset + 6))
+ sig.offset = float(match.group(offset + 7))
+ sig.min = float(match.group(8 + offset))
+ sig.max = float(match.group(9 + offset))
+ sig.unit = match.group(10 + offset)
+ sig.receiver_name = match.group(11 + offset).strip()
+
+ current_msg.sigs.append(sig)
+
+ def _parse_cm_sg(self, line: str, lines: list[str], current_idx: int) -> int:
+ """Parse CM_ SG_ (signal comment) line."""
+ parse_line = line
+
+ # Handle multi-line comments
+ if not parse_line.endswith('";'):
+ # Find the end of the comment
+ for j in range(current_idx + 1, len(lines)):
+ parse_line += " " + lines[j].strip()
+ if '";' in lines[j]:
+ current_idx = j
+ break
+
+ sg_comment_pattern = r'^CM_ SG_ *(\w+) *(\w+) *"((?:[^"\\]|\\.)*)"\s*;'
+ match = re.match(sg_comment_pattern, parse_line)
+
+ if not match:
+ raise RuntimeError("Invalid CM_ SG_ line format")
+
+ address = int(match.group(1))
+ sig_name = match.group(2)
+ s = self.signal(address, sig_name)
+
+ if s:
+ comment = match.group(3).strip().replace('\\"', '"')
+ s.comment = comment
+
+ return current_idx
+
+ def _parse_val(self, line: str) -> None:
+ """Parse VAL_ (value description) line."""
+ val_pattern = r'VAL_ (\w+) (\w+) (\s*[-+]?[0-9]+\s+".+?"[^;]*)'
+ match = re.match(val_pattern, line)
+
+ if not match:
+ raise RuntimeError("invalid VAL_ line format")
+
+ address = int(match.group(1))
+ sig_name = match.group(2)
+ s = self.signal(address, sig_name)
+
+ if s:
+ desc_list = match.group(3).strip().split('"')
+ for i in range(0, len(desc_list), 2):
+ val_str = desc_list[i].strip()
+ if val_str and (i + 1) < len(desc_list):
+ desc = desc_list[i + 1].strip()
+ s.val_desc.append((float(val_str), desc))
+
+ def generate_dbc(self) -> str:
+ """Generate DBC file content."""
+ dbc_string = ""
+ comment = ""
+ val_desc = ""
+
+ for address, m in self.msgs.items():
+ transmitter = DEFAULT_NODE_NAME if not m.transmitter else m.transmitter
+ dbc_string += f"BO_ {address} {m.name}: {m.size} {transmitter}\n"
+
+ if m.comment:
+ escaped_comment = m.comment.replace('"', '\\"')
+ comment += f'CM_ BO_ {address} "{escaped_comment}";\n'
+
+ for sig in m.get_signals():
+ multiplexer_indicator = ""
+ if sig.type == SignalType.Multiplexor:
+ multiplexer_indicator = "M "
+ elif sig.type == SignalType.Multiplexed:
+ multiplexer_indicator = f"m{sig.multiplex_value} "
+
+ endian_char = '1' if sig.is_little_endian else '0'
+ sign_char = '-' if sig.is_signed else '+'
+ receiver = DEFAULT_NODE_NAME if not sig.receiver_name else sig.receiver_name
+
+ dbc_string += (
+ f" SG_ {sig.name} {multiplexer_indicator}: {sig.start_bit}|{sig.size}@{endian_char}{sign_char} "
+ + f"({double_to_string(sig.factor)},{double_to_string(sig.offset)}) "
+ + f"[{double_to_string(sig.min)}|{double_to_string(sig.max)}] "
+ + f'"{sig.unit}" {receiver}\n'
+ )
+
+ if sig.comment:
+ escaped_comment = sig.comment.replace('"', '\\"')
+ comment += f'CM_ SG_ {address} {sig.name} "{escaped_comment}";\n'
+
+ if sig.val_desc:
+ text_parts = []
+ for val, desc in sig.val_desc:
+ text_parts.append(f'{int(val)} "{desc}"')
+ val_desc += f"VAL_ {address} {sig.name} {' '.join(text_parts)};\n"
+
+ dbc_string += "\n"
+
+ return self.header + dbc_string + comment + val_desc
diff --git a/tools/cabana/pycabana/dbc/dbcmanager.py b/tools/cabana/pycabana/dbc/dbcmanager.py
new file mode 100644
index 00000000000000..cf63fece4fc2d3
--- /dev/null
+++ b/tools/cabana/pycabana/dbc/dbcmanager.py
@@ -0,0 +1,172 @@
+"""DBCManager - singleton for managing loaded DBC files."""
+
+from PySide6.QtCore import QObject, Signal as QtSignal
+
+from opendbc.can.dbc import DBC, Msg, Signal
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId
+
+
+class DBCManager(QObject):
+ """Singleton that manages loaded DBC files."""
+
+ _instance: "DBCManager | None" = None
+
+ # Signals
+ dbcLoaded = QtSignal(str) # dbc name
+ signalAdded = QtSignal(MessageId, Signal) # msg_id, signal
+ signalRemoved = QtSignal(Signal) # signal
+ signalUpdated = QtSignal(Signal) # signal
+ msgUpdated = QtSignal(MessageId) # msg_id
+ msgRemoved = QtSignal(MessageId) # msg_id
+ DBCFileChanged = QtSignal()
+ maskUpdated = QtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._dbc: DBC | None = None
+ self._name: str = ""
+
+ @classmethod
+ def instance(cls) -> "DBCManager":
+ if cls._instance is None:
+ cls._instance = DBCManager()
+ return cls._instance
+
+ def load(self, dbc_name: str) -> bool:
+ """Load a DBC file by name."""
+ try:
+ self._dbc = DBC(dbc_name)
+ self._name = dbc_name
+ self.dbcLoaded.emit(dbc_name)
+ return True
+ except Exception as e:
+ print(f"Failed to load DBC {dbc_name}: {e}")
+ return False
+
+ def save(self, filename: str) -> bool:
+ """Save the DBC to a file."""
+ if self._dbc is None:
+ return False
+ try:
+ from openpilot.tools.cabana.pycabana.dbc.dbcfile import DBCFile
+ dbc_file = DBCFile(self._dbc)
+ dbc_string = dbc_file.to_dbc_string()
+ with open(filename, 'w') as f:
+ f.write(dbc_string)
+ self._name = filename
+ return True
+ except Exception as e:
+ print(f"Failed to save DBC to {filename}: {e}")
+ return False
+
+ def clear(self) -> None:
+ """Clear the current DBC and create an empty one."""
+ self._dbc = DBC("")
+ self._name = ""
+ self.DBCFileChanged.emit()
+
+ @property
+ def name(self) -> str:
+ return self._name
+
+ @property
+ def dbc(self) -> DBC | None:
+ return self._dbc
+
+ def msg(self, msg_id: MessageId) -> Msg | None:
+ """Get message definition by ID."""
+ if self._dbc is None:
+ return None
+ return self._dbc.msgs.get(msg_id.address)
+
+ def msgName(self, msg_id: MessageId) -> str:
+ """Get message name, or empty string if unknown."""
+ msg = self.msg(msg_id)
+ return msg.name if msg else ""
+
+ def addSignal(self, msg_id: MessageId, sig: Signal) -> None:
+ """Add a signal to a message."""
+ msg = self.msg(msg_id)
+ if msg and self._dbc:
+ msg.sigs[sig.name] = sig
+ self.signalAdded.emit(msg_id, sig)
+ self.maskUpdated.emit()
+
+ def updateSignal(self, msg_id: MessageId, sig_name: str, new_sig: Signal) -> None:
+ """Update a signal in a message."""
+ msg = self.msg(msg_id)
+ if msg and sig_name in msg.sigs:
+ # If name changed, remove old and add new
+ if sig_name != new_sig.name:
+ del msg.sigs[sig_name]
+ msg.sigs[new_sig.name] = new_sig
+ else:
+ msg.sigs[sig_name] = new_sig
+ self.signalUpdated.emit(new_sig)
+ self.maskUpdated.emit()
+
+ def removeSignal(self, msg_id: MessageId, sig_name: str) -> None:
+ """Remove a signal from a message."""
+ msg = self.msg(msg_id)
+ if msg and sig_name in msg.sigs:
+ sig = msg.sigs[sig_name]
+ self.signalRemoved.emit(sig)
+ del msg.sigs[sig_name]
+ self.maskUpdated.emit()
+
+ def updateMsg(self, msg_id: MessageId, name: str, size: int, node: str, comment: str) -> None:
+ """Update or create a message."""
+ if self._dbc is None:
+ return
+
+ # Get existing message or create new one
+ msg = self.msg(msg_id)
+ if msg:
+ # Update existing message
+ msg.name = name
+ msg.size = size
+ if hasattr(msg, 'transmitter'):
+ msg.transmitter = node
+ if hasattr(msg, 'comment'):
+ msg.comment = comment
+ else:
+ # Create new message
+ new_msg = Msg(name=name, address=msg_id.address, size=size, sigs={})
+ if hasattr(new_msg, 'transmitter'):
+ new_msg.transmitter = node
+ if hasattr(new_msg, 'comment'):
+ new_msg.comment = comment
+ self._dbc.msgs[msg_id.address] = new_msg
+
+ self.msgUpdated.emit(msg_id)
+
+ def removeMsg(self, msg_id: MessageId) -> None:
+ """Remove a message from the DBC."""
+ if self._dbc and msg_id.address in self._dbc.msgs:
+ del self._dbc.msgs[msg_id.address]
+ self.msgRemoved.emit(msg_id)
+ self.maskUpdated.emit()
+
+ def newMsgName(self, msg_id: MessageId) -> str:
+ """Generate a new message name."""
+ return f"NEW_MSG_{msg_id.address:X}"
+
+ def newSignalName(self, msg_id: MessageId) -> str:
+ """Generate a new signal name for a message."""
+ msg = self.msg(msg_id)
+ if not msg:
+ return "NEW_SIGNAL_1"
+
+ # Find the next available signal name
+ i = 1
+ while True:
+ name = f"NEW_SIGNAL_{i}"
+ if name not in msg.sigs:
+ return name
+ i += 1
+
+
+def dbc_manager() -> DBCManager:
+ """Global accessor for DBCManager singleton."""
+ return DBCManager.instance()
diff --git a/tools/cabana/pycabana/dialogs/__init__.py b/tools/cabana/pycabana/dialogs/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/tools/cabana/pycabana/dialogs/streamselector.py b/tools/cabana/pycabana/dialogs/streamselector.py
new file mode 100644
index 00000000000000..a6c667b7ab5bc5
--- /dev/null
+++ b/tools/cabana/pycabana/dialogs/streamselector.py
@@ -0,0 +1,377 @@
+"""StreamSelector - dialog for selecting and opening CAN data streams."""
+
+from typing import Optional
+
+from PySide6.QtWidgets import (
+ QDialog,
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QGridLayout,
+ QFormLayout,
+ QTabWidget,
+ QLineEdit,
+ QLabel,
+ QPushButton,
+ QDialogButtonBox,
+ QFileDialog,
+ QFrame,
+ QComboBox,
+ QCheckBox,
+ QRadioButton,
+ QButtonGroup,
+)
+from PySide6.QtGui import QRegularExpressionValidator
+from PySide6.QtCore import QRegularExpression
+
+from openpilot.tools.cabana.pycabana.streams.abstract import AbstractStream
+from openpilot.tools.cabana.pycabana.streams.replay import ReplayStream
+from openpilot.tools.cabana.pycabana.settings import Settings
+
+
+class AbstractOpenStreamWidget(QWidget):
+ """Base class for stream source widgets.
+
+ Subclasses must implement open() to return a configured stream.
+ """
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+
+ def open(self) -> Optional[AbstractStream]:
+ """Open and return a configured stream, or None on failure."""
+ raise NotImplementedError("Subclasses must implement open()")
+
+
+class OpenReplayWidget(AbstractOpenStreamWidget):
+ """Widget for opening replay streams from routes."""
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+
+ layout = QGridLayout(self)
+
+ # Route input
+ layout.addWidget(QLabel("Route"), 0, 0)
+ self.route_edit = QLineEdit(self)
+ self.route_edit.setPlaceholderText("Enter route name or browse for local/remote route")
+ layout.addWidget(self.route_edit, 0, 1)
+
+ # Remote route button
+ browse_remote_btn = QPushButton("Remote route...", self)
+ layout.addWidget(browse_remote_btn, 0, 2)
+ browse_remote_btn.setEnabled(False) # Not implemented yet
+
+ # Local route button
+ browse_local_btn = QPushButton("Local route...", self)
+ layout.addWidget(browse_local_btn, 0, 3)
+
+ # Camera options
+ camera_layout = QHBoxLayout()
+ self.road_camera = QCheckBox("Road camera", self)
+ self.driver_camera = QCheckBox("Driver camera", self)
+ self.wide_camera = QCheckBox("Wide road camera", self)
+
+ self.road_camera.setChecked(True)
+
+ camera_layout.addWidget(self.road_camera)
+ camera_layout.addWidget(self.driver_camera)
+ camera_layout.addWidget(self.wide_camera)
+ camera_layout.addStretch(1)
+ layout.addLayout(camera_layout, 1, 1)
+
+ self.setMinimumWidth(550)
+
+ # Connect signals
+ browse_local_btn.clicked.connect(self._browse_local_route)
+ browse_remote_btn.clicked.connect(self._browse_remote_route)
+
+ def _browse_local_route(self) -> None:
+ """Browse for a local route directory."""
+ settings = Settings()
+ directory = QFileDialog.getExistingDirectory(
+ self,
+ "Open Local Route",
+ settings.last_route_dir
+ )
+ if directory:
+ self.route_edit.setText(directory)
+ settings.last_route_dir = directory
+ settings.save()
+
+ def _browse_remote_route(self) -> None:
+ """Browse for a remote route (not implemented yet)."""
+ # TODO: Implement remote route browser dialog
+
+ def open(self) -> Optional[AbstractStream]:
+ """Open a replay stream from the specified route."""
+ route = self.route_edit.text().strip()
+ if not route:
+ return None
+
+ stream = ReplayStream()
+ if stream.loadRoute(route):
+ return stream
+
+ return None
+
+
+class OpenPandaWidget(AbstractOpenStreamWidget):
+ """Widget for opening live streams from Panda devices."""
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+
+ form_layout = QFormLayout(self)
+
+ # Serial selection
+ serial_layout = QHBoxLayout()
+ self.serial_edit = QComboBox(self)
+ serial_layout.addWidget(self.serial_edit)
+
+ refresh_btn = QPushButton("Refresh", self)
+ refresh_btn.setSizePolicy(refresh_btn.sizePolicy().horizontalPolicy(), refresh_btn.sizePolicy().verticalPolicy())
+ serial_layout.addWidget(refresh_btn)
+
+ form_layout.addRow("Serial", serial_layout)
+
+ # Note: Panda streaming not fully implemented in Python yet
+ info_label = QLabel("Panda live streaming is not yet fully implemented in pycabana.")
+ info_label.setWordWrap(True)
+ form_layout.addRow(info_label)
+
+ refresh_btn.clicked.connect(self._refresh_serials)
+ self.serial_edit.currentTextChanged.connect(self._on_serial_changed)
+
+ self._refresh_serials()
+
+ def _refresh_serials(self) -> None:
+ """Refresh the list of available Panda devices."""
+ self.serial_edit.clear()
+ # TODO: Implement actual Panda device discovery
+ self.serial_edit.addItem("(No Panda devices found)")
+
+ def _on_serial_changed(self, serial: str) -> None:
+ """Handle serial selection change."""
+
+ def open(self) -> Optional[AbstractStream]:
+ """Open a Panda stream (not yet implemented)."""
+ # TODO: Implement PandaStream
+ return None
+
+
+class OpenSocketCanWidget(AbstractOpenStreamWidget):
+ """Widget for opening SocketCAN streams."""
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+
+ main_layout = QVBoxLayout(self)
+ main_layout.addStretch(1)
+
+ form_layout = QFormLayout()
+
+ # Device selection
+ device_layout = QHBoxLayout()
+ self.device_edit = QComboBox(self)
+ self.device_edit.setFixedWidth(300)
+ device_layout.addWidget(self.device_edit)
+
+ refresh_btn = QPushButton("Refresh", self)
+ refresh_btn.setFixedWidth(100)
+ device_layout.addWidget(refresh_btn)
+
+ form_layout.addRow("Device", device_layout)
+ main_layout.addLayout(form_layout)
+
+ main_layout.addStretch(1)
+
+ # Note: SocketCAN not fully implemented in Python yet
+ info_label = QLabel("SocketCAN streaming is not yet fully implemented in pycabana.")
+ info_label.setWordWrap(True)
+ form_layout.addRow(info_label)
+
+ refresh_btn.clicked.connect(self._refresh_devices)
+ self.device_edit.currentTextChanged.connect(self._on_device_changed)
+
+ self._refresh_devices()
+
+ def _refresh_devices(self) -> None:
+ """Refresh the list of available SocketCAN devices."""
+ self.device_edit.clear()
+ # TODO: Implement actual SocketCAN device discovery
+ self.device_edit.addItem("(No SocketCAN devices found)")
+
+ def _on_device_changed(self, device: str) -> None:
+ """Handle device selection change."""
+
+ def open(self) -> Optional[AbstractStream]:
+ """Open a SocketCAN stream (not yet implemented)."""
+ # TODO: Implement SocketCanStream
+ return None
+
+
+class OpenDeviceWidget(AbstractOpenStreamWidget):
+ """Widget for opening live streams from openpilot devices."""
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+
+ form_layout = QFormLayout(self)
+
+ # Connection type selection
+ self.msgq_radio = QRadioButton("MSGQ", self)
+ self.zmq_radio = QRadioButton("ZMQ", self)
+
+ # IP address input for ZMQ
+ self.ip_address = QLineEdit(self)
+ self.ip_address.setPlaceholderText("Enter device IP Address")
+
+ # IP address validation
+ ip_range = "(?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])"
+ pattern = f"^{ip_range}\\.{ip_range}\\.{ip_range}\\.{ip_range}$"
+ regex = QRegularExpression(pattern)
+ validator = QRegularExpressionValidator(regex, self)
+ self.ip_address.setValidator(validator)
+
+ # Button group for radio buttons
+ self.connection_group = QButtonGroup(self)
+ self.connection_group.addButton(self.msgq_radio, 0)
+ self.connection_group.addButton(self.zmq_radio, 1)
+
+ form_layout.addRow(self.msgq_radio)
+ form_layout.addRow(self.zmq_radio, self.ip_address)
+
+ # Note: Device streaming not fully implemented in Python yet
+ info_label = QLabel("Device live streaming is not yet fully implemented in pycabana.")
+ info_label.setWordWrap(True)
+ form_layout.addRow(info_label)
+
+ # Default to ZMQ
+ self.zmq_radio.setChecked(True)
+
+ # Connect signals
+ self.connection_group.buttonToggled.connect(self._on_connection_toggled)
+
+ def _on_connection_toggled(self, button, checked: bool) -> None:
+ """Handle connection type toggle."""
+ self.ip_address.setEnabled(button == self.zmq_radio and checked)
+
+ def open(self) -> Optional[AbstractStream]:
+ """Open a device stream (not yet implemented)."""
+ # TODO: Implement DeviceStream
+ return None
+
+
+class StreamSelector(QDialog):
+ """Dialog for selecting and opening CAN data streams.
+
+ Provides tabs for different stream sources:
+ - Replay: Load data from openpilot routes
+ - Panda: Connect to Panda hardware
+ - SocketCAN: Connect to SocketCAN devices
+ - Device: Connect to live openpilot devices
+ """
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+
+ self.stream_: Optional[AbstractStream] = None
+ self.settings = Settings()
+
+ self.setWindowTitle("Open stream")
+
+ layout = QVBoxLayout(self)
+
+ # Tab widget for different stream sources
+ self.tab = QTabWidget(self)
+ layout.addWidget(self.tab)
+
+ # DBC file selection
+ dbc_layout = QHBoxLayout()
+
+ self.dbc_file = QLineEdit(self)
+ self.dbc_file.setReadOnly(True)
+ self.dbc_file.setPlaceholderText("Choose a dbc file to open")
+
+ file_btn = QPushButton("Browse...", self)
+
+ dbc_layout.addWidget(QLabel("dbc File"))
+ dbc_layout.addWidget(self.dbc_file)
+ dbc_layout.addWidget(file_btn)
+
+ layout.addLayout(dbc_layout)
+
+ # Separator line
+ line = QFrame(self)
+ line.setFrameStyle(QFrame.Shape.HLine | QFrame.Shadow.Sunken)
+ layout.addWidget(line)
+
+ # Dialog buttons
+ self.btn_box = QDialogButtonBox(
+ QDialogButtonBox.StandardButton.Open | QDialogButtonBox.StandardButton.Cancel,
+ self
+ )
+ layout.addWidget(self.btn_box)
+
+ # Add stream source widgets
+ self._add_stream_widget(OpenReplayWidget(), "&Replay")
+ self._add_stream_widget(OpenPandaWidget(), "&Panda")
+
+ # Only add SocketCAN if available (platform dependent)
+ if self._socketcan_available():
+ self._add_stream_widget(OpenSocketCanWidget(), "&SocketCAN")
+
+ self._add_stream_widget(OpenDeviceWidget(), "&Device")
+
+ # Connect signals
+ self.btn_box.rejected.connect(self.reject)
+ self.btn_box.accepted.connect(self._on_open_clicked)
+ file_btn.clicked.connect(self._browse_dbc_file)
+
+ def _add_stream_widget(self, widget: AbstractOpenStreamWidget, title: str) -> None:
+ """Add a stream source widget as a tab."""
+ self.tab.addTab(widget, title)
+
+ def _socketcan_available(self) -> bool:
+ """Check if SocketCAN is available on this platform."""
+ # SocketCAN is typically only available on Linux
+ import platform
+ return platform.system() == "Linux"
+
+ def _browse_dbc_file(self) -> None:
+ """Browse for a DBC file."""
+ filename = QFileDialog.getOpenFileName(
+ self,
+ "Open File",
+ self.settings.last_dir,
+ "DBC (*.dbc)"
+ )[0]
+
+ if filename:
+ self.dbc_file.setText(filename)
+ from pathlib import Path
+ self.settings.last_dir = str(Path(filename).parent)
+ self.settings.save()
+
+ def _on_open_clicked(self) -> None:
+ """Handle the Open button click."""
+ self.setEnabled(False)
+
+ # Get the current widget and try to open its stream
+ current_widget = self.tab.currentWidget()
+ if isinstance(current_widget, AbstractOpenStreamWidget):
+ self.stream_ = current_widget.open()
+ if self.stream_:
+ self.accept()
+ return
+
+ self.setEnabled(True)
+
+ def dbcFile(self) -> str:
+ """Get the selected DBC file path."""
+ return self.dbc_file.text()
+
+ def stream(self) -> Optional[AbstractStream]:
+ """Get the opened stream."""
+ return self.stream_
diff --git a/tools/cabana/pycabana/main.py b/tools/cabana/pycabana/main.py
new file mode 100644
index 00000000000000..b2f92409ae76e3
--- /dev/null
+++ b/tools/cabana/pycabana/main.py
@@ -0,0 +1,142 @@
+"""pycabana - PySide6 CAN bus analyzer.
+
+Usage:
+ python cabana.py [route]
+ python cabana.py --demo
+"""
+
+import argparse
+import signal
+import socket
+import sys
+import time
+
+DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19/0"
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="pycabana - PySide6 CAN bus analyzer",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "route",
+ nargs="?",
+ help="Route to load (e.g., 'a2a0ccea32023010|2023-07-27--13-01-19')",
+ )
+ parser.add_argument(
+ "--demo",
+ action="store_true",
+ help="Load demo route",
+ )
+ parser.add_argument(
+ "--dbc",
+ help="DBC file to use for decoding",
+ )
+ parser.add_argument(
+ "--strict",
+ action="store_true",
+ help="Exit with error if UI thread is blocked (for testing)",
+ )
+
+ args = parser.parse_args()
+
+ route = args.route
+ if args.demo:
+ route = DEMO_ROUTE
+ elif not route:
+ parser.print_help()
+ print("\nNo route specified. Use --demo to load a demo route.")
+ return 1
+
+ from PySide6.QtCore import QSocketNotifier, QTimer
+ from PySide6.QtWidgets import QApplication
+
+ from openpilot.tools.cabana.pycabana.mainwindow import MainWindow
+ from openpilot.tools.cabana.pycabana.streams.replay import ReplayStream
+
+ app = QApplication(sys.argv)
+ app.setApplicationName("pycabana")
+ app.setOrganizationName("comma.ai")
+
+ # Set up signal handling with socket notifier to properly integrate with Qt event loop
+ rsock, wsock = socket.socketpair()
+ rsock.setblocking(False)
+ wsock.setblocking(False)
+
+ def sigint_handler(*_):
+ wsock.send(b'\x00')
+
+ signal.signal(signal.SIGINT, sigint_handler)
+
+ stream = ReplayStream()
+ exit_code = 0
+
+ window = MainWindow(stream, dbc_name=args.dbc or "")
+
+ def handle_signal():
+ try:
+ rsock.recv(1)
+ except BlockingIOError:
+ pass
+ # Stop stream and camera threads before closing
+ stream.stop()
+ window.video_widget.camera_view.stop()
+ # Process pending events to ensure cleanup completes
+ app.processEvents()
+ window.close()
+ # Delay quit to allow cleanup
+ QTimer.singleShot(200, app.quit)
+
+ notifier = QSocketNotifier(rsock.fileno(), QSocketNotifier.Type.Read)
+ notifier.activated.connect(handle_signal)
+
+ def cleanup_on_quit():
+ """Ensure threads are stopped before Qt destroys objects."""
+ stream.stop()
+ window.video_widget.camera_view.stop()
+
+ app.aboutToQuit.connect(cleanup_on_quit)
+ window.show()
+
+ # Strict mode: detect UI thread blocking (only after loading completes)
+ timer = None
+ if args.strict:
+ last_tick = [0.0]
+ max_allowed_delay = 0.5 # 500ms
+ strict_enabled = [False]
+
+ def enable_strict():
+ strict_enabled[0] = True
+ last_tick[0] = time.monotonic()
+
+ stream.loadFinished.connect(enable_strict)
+
+ def check_responsiveness():
+ nonlocal exit_code
+ now = time.monotonic()
+ if not strict_enabled[0]:
+ return
+ delay = now - last_tick[0]
+ if delay > max_allowed_delay:
+ print(f"STRICT MODE: UI thread blocked for {delay:.2f}s, exiting")
+ exit_code = 2
+ stream.stop()
+ app.quit()
+ last_tick[0] = now
+
+ timer = QTimer()
+ timer.timeout.connect(check_responsiveness)
+ timer.start(100) # Check every 100ms
+
+ print(f"Loading route: {route}")
+ if not stream.loadRoute(route):
+ print(f"Failed to start loading route: {route}")
+ return 1
+
+ app.exec()
+ return exit_code
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/cabana/pycabana/mainwindow.py b/tools/cabana/pycabana/mainwindow.py
new file mode 100644
index 00000000000000..7c8bf128d72fae
--- /dev/null
+++ b/tools/cabana/pycabana/mainwindow.py
@@ -0,0 +1,469 @@
+"""MainWindow - main application window for pycabana."""
+
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QAction, QKeySequence
+from PySide6.QtWidgets import (
+ QMainWindow,
+ QDockWidget,
+ QLabel,
+ QStatusBar,
+ QProgressBar,
+ QSplitter,
+ QWidget,
+ QVBoxLayout,
+ QFileDialog,
+)
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId
+from openpilot.tools.cabana.pycabana.dbc.dbcmanager import dbc_manager
+from openpilot.tools.cabana.pycabana.dialogs.streamselector import StreamSelector
+from openpilot.tools.cabana.pycabana.settings import Settings, SettingsDlg
+from openpilot.tools.cabana.pycabana.streams.abstract import AbstractStream
+from openpilot.tools.cabana.pycabana.streams.replay import ReplayStream
+from openpilot.tools.cabana.pycabana.widgets.charts import ChartsWidget
+from openpilot.tools.cabana.pycabana.widgets.detail import DetailWidget
+from openpilot.tools.cabana.pycabana.widgets.messages import MessagesWidget
+from openpilot.tools.cabana.pycabana.widgets.video import VideoWidget
+
+
+class MainWindow(QMainWindow):
+ """Main application window."""
+
+ def __init__(self, stream: AbstractStream, dbc_name: str = "", parent=None):
+ super().__init__(parent)
+ self.stream = stream
+ self._dbc_name = dbc_name
+ self._selected_msg_id: MessageId | None = None
+
+ self.setWindowTitle("pycabana")
+ self.resize(1400, 900)
+
+ self._setup_ui()
+ self._connect_signals()
+
+ # Load DBC if provided
+ if dbc_name:
+ dbc_manager().load(dbc_name)
+
+ def _setup_ui(self):
+ # ===== Central Widget: DetailWidget =====
+ self.detail_widget = DetailWidget(self.stream, self)
+ self.setCentralWidget(self.detail_widget)
+
+ # ===== Left Dock: Messages =====
+ self.messages_widget = MessagesWidget(self.stream)
+ self.messages_dock = QDockWidget(self.tr("MESSAGES"), self)
+ self.messages_dock.setObjectName("MessagesPanel")
+ self.messages_dock.setWidget(self.messages_widget)
+ self.messages_dock.setAllowedAreas(
+ Qt.DockWidgetArea.LeftDockWidgetArea |
+ Qt.DockWidgetArea.RightDockWidgetArea |
+ Qt.DockWidgetArea.TopDockWidgetArea |
+ Qt.DockWidgetArea.BottomDockWidgetArea
+ )
+ self.messages_dock.setFeatures(
+ QDockWidget.DockWidgetFeature.DockWidgetMovable |
+ QDockWidget.DockWidgetFeature.DockWidgetFloatable |
+ QDockWidget.DockWidgetFeature.DockWidgetClosable
+ )
+ self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.messages_dock)
+
+ # ===== Right Dock: Video + Charts in vertical splitter =====
+ self.video_dock = QDockWidget("", self)
+ self.video_dock.setObjectName("VideoPanel")
+ self.video_dock.setAllowedAreas(
+ Qt.DockWidgetArea.LeftDockWidgetArea |
+ Qt.DockWidgetArea.RightDockWidgetArea
+ )
+ self.video_dock.setFeatures(
+ QDockWidget.DockWidgetFeature.DockWidgetMovable |
+ QDockWidget.DockWidgetFeature.DockWidgetFloatable |
+ QDockWidget.DockWidgetFeature.DockWidgetClosable
+ )
+
+ # Create splitter for video and charts
+ self.video_splitter = QSplitter(Qt.Orientation.Vertical)
+
+ # Video widget (timeline controls)
+ self.video_widget = VideoWidget()
+ self.video_splitter.addWidget(self.video_widget)
+
+ # Charts container
+ charts_container = QWidget()
+ charts_layout = QVBoxLayout(charts_container)
+ charts_layout.setContentsMargins(0, 0, 0, 0)
+ self.charts_widget = ChartsWidget()
+ charts_layout.addWidget(self.charts_widget)
+
+ self.video_splitter.addWidget(charts_container)
+ self.video_splitter.setStretchFactor(0, 0) # Video - don't stretch
+ self.video_splitter.setStretchFactor(1, 1) # Charts - stretch
+ self.video_splitter.setSizes([150, 400])
+
+ self.video_dock.setWidget(self.video_splitter)
+ self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.video_dock)
+
+ # Set dock sizes
+ self.resizeDocks([self.messages_dock], [350], Qt.Orientation.Horizontal)
+ self.resizeDocks([self.video_dock], [450], Qt.Orientation.Horizontal)
+
+ # ===== Status Bar =====
+ self.status_bar = QStatusBar()
+ self.setStatusBar(self.status_bar)
+
+ self.help_label = QLabel(self.tr("For Help, Press F1"))
+ self.status_bar.addWidget(self.help_label)
+
+ self.status_label = QLabel("")
+ self.status_bar.addWidget(self.status_label, 1)
+
+ self.progress_bar = QProgressBar()
+ self.progress_bar.setFixedSize(300, 16)
+ self.progress_bar.setVisible(False)
+ self.status_bar.addPermanentWidget(self.progress_bar)
+
+ self.msg_count_label = QLabel("0 messages")
+ self.status_bar.addPermanentWidget(self.msg_count_label)
+
+ # Create menu bar
+ self._create_menu()
+
+ def _connect_signals(self):
+ # Message selection from messages list
+ self.messages_widget.msgSelectionChanged.connect(self._on_msg_selected)
+
+ # Stream signals
+ self.stream.msgsReceived.connect(self._on_msgs_received)
+
+ # Video widget seeking
+ self.video_widget.seeked.connect(self._on_video_seeked)
+
+ # Replay-specific signals
+ if isinstance(self.stream, ReplayStream):
+ self.stream.loadProgress.connect(self._on_load_progress)
+ self.stream.loadFinished.connect(self._on_load_finished)
+
+ # DBC manager signals
+ dbc_manager().dbcLoaded.connect(self._on_dbc_loaded)
+
+ def _on_msg_selected(self, msg_id: MessageId | None):
+ """Handle message selection from messages list."""
+ self._selected_msg_id = msg_id
+ if msg_id is not None:
+ self.detail_widget.setMessage(msg_id)
+
+ def _on_msgs_received(self, msg_ids: set[MessageId], has_new: bool):
+ """Handle stream message updates."""
+ total_msgs = len(self.stream.last_msgs)
+ total_events = sum(d.count for d in self.stream.last_msgs.values())
+ self.msg_count_label.setText(f"{total_msgs} msgs | {total_events:,} events")
+
+ # Update detail widget
+ if self._selected_msg_id:
+ self.detail_widget.updateState()
+
+ def _on_load_progress(self, can_msgs: int, total_msgs: int):
+ """Handle loading progress."""
+ self.progress_bar.setVisible(True)
+ self.progress_bar.setMaximum(0) # Indeterminate
+ self.status_label.setText(f"Loading... {can_msgs:,} CAN messages")
+
+ def _on_video_seeked(self, time_sec: float):
+ """Handle video widget seeking."""
+ if isinstance(self.stream, ReplayStream):
+ self.stream.seekTo(time_sec)
+ self.charts_widget.setCurrentTime(time_sec)
+
+ def _on_load_finished(self):
+ """Handle loading completion."""
+ self.progress_bar.setVisible(False)
+ total_msgs = len(self.stream.last_msgs)
+ total_events = sum(d.count for d in self.stream.last_msgs.values())
+ self.status_label.setText(f"Loaded {total_events:,} events from {total_msgs} messages")
+
+ if isinstance(self.stream, ReplayStream):
+ route = self.stream.routeName
+ fingerprint = self.stream.carFingerprint
+
+ # Set video widget duration and load video
+ self.video_widget.setDuration(self.stream.duration)
+ self.video_widget.setCurrentTime(0)
+ if route:
+ self.video_widget.loadRoute(route)
+
+ # Pass events to charts widget
+ self.charts_widget.setEvents(self.stream._all_events)
+
+ if route:
+ title = f"pycabana - {route}"
+ if fingerprint:
+ title += f" ({fingerprint})"
+ self.setWindowTitle(title)
+
+ # Auto-load DBC from fingerprint
+ if fingerprint and not self._dbc_name:
+ self._try_load_dbc_for_fingerprint(fingerprint)
+
+ def _on_dbc_loaded(self, dbc_name: str = ""):
+ """Handle DBC file loaded."""
+ self.messages_widget.model.layoutChanged.emit()
+ if self._selected_msg_id:
+ self.detail_widget.refresh()
+
+ def _try_load_dbc_for_fingerprint(self, fingerprint: str):
+ """Try to load a DBC file based on car fingerprint."""
+ # Try to load from fingerprint-to-DBC mapping
+ import json
+ from pathlib import Path
+
+ json_path = Path(__file__).parent.parent / "dbc" / "car_fingerprint_to_dbc.json"
+ if json_path.exists():
+ try:
+ with open(json_path) as f:
+ mapping = json.load(f)
+ if fingerprint in mapping:
+ dbc_name = mapping[fingerprint]
+ if dbc_manager().load(dbc_name):
+ self.status_label.setText(f"Loaded DBC: {dbc_name}")
+ return
+ except Exception as e:
+ print(f"Error loading fingerprint mapping: {e}")
+
+ # Fallback: try generated name
+ dbc_name = fingerprint.lower().replace(" ", "_") + "_pt_generated"
+ if dbc_manager().load(dbc_name):
+ self.status_label.setText(f"Loaded DBC: {dbc_name}")
+
+ def _create_menu(self):
+ """Create the application menu bar."""
+ menubar = self.menuBar()
+
+ # ===== File Menu =====
+ file_menu = menubar.addMenu(self.tr("&File"))
+
+ open_stream_action = QAction(self.tr("Open Stream..."), self)
+ open_stream_action.triggered.connect(self._open_stream)
+ file_menu.addAction(open_stream_action)
+
+ close_stream_action = QAction(self.tr("Close Stream"), self)
+ close_stream_action.setEnabled(False)
+ file_menu.addAction(close_stream_action)
+
+ export_csv_action = QAction(self.tr("Export to CSV..."), self)
+ export_csv_action.setEnabled(False)
+ file_menu.addAction(export_csv_action)
+
+ file_menu.addSeparator()
+
+ new_dbc_action = QAction(self.tr("New DBC File"), self)
+ new_dbc_action.setShortcut(QKeySequence.StandardKey.New)
+ new_dbc_action.triggered.connect(self._new_dbc_file)
+ file_menu.addAction(new_dbc_action)
+
+ open_dbc_action = QAction(self.tr("Open DBC File..."), self)
+ open_dbc_action.setShortcut(QKeySequence.StandardKey.Open)
+ open_dbc_action.triggered.connect(self._open_dbc_file)
+ file_menu.addAction(open_dbc_action)
+
+ file_menu.addSeparator()
+
+ save_dbc_action = QAction(self.tr("Save DBC..."), self)
+ save_dbc_action.setShortcut(QKeySequence.StandardKey.Save)
+ save_dbc_action.triggered.connect(self._save_dbc_file)
+ file_menu.addAction(save_dbc_action)
+
+ save_dbc_as_action = QAction(self.tr("Save DBC As..."), self)
+ save_dbc_as_action.setShortcut(QKeySequence.StandardKey.SaveAs)
+ save_dbc_as_action.triggered.connect(self._save_dbc_as)
+ file_menu.addAction(save_dbc_as_action)
+
+ file_menu.addSeparator()
+
+ settings_action = QAction(self.tr("Settings..."), self)
+ settings_action.setShortcut(QKeySequence.StandardKey.Preferences)
+ settings_action.triggered.connect(self._open_settings)
+ file_menu.addAction(settings_action)
+
+ file_menu.addSeparator()
+
+ exit_action = QAction(self.tr("E&xit"), self)
+ exit_action.setShortcut(QKeySequence.StandardKey.Quit)
+ exit_action.triggered.connect(self.close)
+ file_menu.addAction(exit_action)
+
+ # ===== Edit Menu =====
+ edit_menu = menubar.addMenu(self.tr("&Edit"))
+
+ undo_action = QAction(self.tr("&Undo"), self)
+ undo_action.setShortcut(QKeySequence.StandardKey.Undo)
+ undo_action.triggered.connect(self._undo)
+ edit_menu.addAction(undo_action)
+
+ redo_action = QAction(self.tr("&Redo"), self)
+ redo_action.setShortcut(QKeySequence.StandardKey.Redo)
+ redo_action.triggered.connect(self._redo)
+ edit_menu.addAction(redo_action)
+
+ # ===== View Menu =====
+ view_menu = menubar.addMenu(self.tr("&View"))
+
+ fullscreen_action = QAction(self.tr("Full Screen"), self)
+ fullscreen_action.setShortcut(QKeySequence.StandardKey.FullScreen)
+ fullscreen_action.triggered.connect(self._toggle_fullscreen)
+ view_menu.addAction(fullscreen_action)
+
+ view_menu.addSeparator()
+
+ view_menu.addAction(self.messages_dock.toggleViewAction())
+ view_menu.addAction(self.video_dock.toggleViewAction())
+
+ view_menu.addSeparator()
+
+ reset_layout_action = QAction(self.tr("Reset Window Layout"), self)
+ reset_layout_action.triggered.connect(self._reset_layout)
+ view_menu.addAction(reset_layout_action)
+
+ # ===== Tools Menu =====
+ tools_menu = menubar.addMenu(self.tr("&Tools"))
+
+ find_similar_action = QAction(self.tr("Find Similar Bits"), self)
+ find_similar_action.triggered.connect(self._find_similar_bits)
+ tools_menu.addAction(find_similar_action)
+
+ find_signal_action = QAction(self.tr("Find Signal"), self)
+ find_signal_action.triggered.connect(self._find_signal)
+ tools_menu.addAction(find_signal_action)
+
+ # ===== Help Menu =====
+ help_menu = menubar.addMenu(self.tr("&Help"))
+
+ help_action = QAction(self.tr("Online Help"), self)
+ help_action.setShortcut(QKeySequence.StandardKey.HelpContents)
+ help_menu.addAction(help_action)
+
+ help_menu.addSeparator()
+
+ about_action = QAction(self.tr("&About"), self)
+ about_action.triggered.connect(self._show_about)
+ help_menu.addAction(about_action)
+
+ def _open_stream(self):
+ """Open the stream selector dialog."""
+ dlg = StreamSelector(self)
+ if dlg.exec():
+ self.status_label.setText("Stream selector opened")
+
+ def _new_dbc_file(self):
+ """Create a new empty DBC file."""
+ dbc_manager().clear()
+ self.status_label.setText("New DBC file created")
+ self._on_dbc_loaded()
+
+ def _open_dbc_file(self):
+ """Open a DBC file."""
+ settings = Settings()
+ filename, _ = QFileDialog.getOpenFileName(
+ self,
+ self.tr("Open DBC File"),
+ settings.last_dir,
+ self.tr("DBC Files (*.dbc);;All Files (*)")
+ )
+ if filename:
+ from pathlib import Path
+ settings.last_dir = str(Path(filename).parent)
+ settings.save()
+
+ if dbc_manager().load(filename):
+ self.status_label.setText(f"Loaded DBC: {filename}")
+ else:
+ self.status_label.setText(f"Failed to load DBC: {filename}")
+
+ def _save_dbc_file(self):
+ """Save the current DBC file."""
+ # TODO: Track current filename and save to it
+ self._save_dbc_as()
+
+ def _save_dbc_as(self):
+ """Save the current DBC to a new file."""
+ settings = Settings()
+ filename, _ = QFileDialog.getSaveFileName(
+ self,
+ self.tr("Save DBC File"),
+ settings.last_dir,
+ self.tr("DBC Files (*.dbc)")
+ )
+ if filename:
+ from pathlib import Path
+ settings.last_dir = str(Path(filename).parent)
+ settings.save()
+
+ if dbc_manager().save(filename):
+ self.status_label.setText(f"Saved DBC: {filename}")
+ else:
+ self.status_label.setText(f"Failed to save DBC: {filename}")
+
+ def _open_settings(self):
+ """Open the settings dialog."""
+ dlg = SettingsDlg(self)
+ dlg.exec()
+
+ def _undo(self):
+ """Undo the last action."""
+ from openpilot.tools.cabana.pycabana.commands import undo_stack
+ undo_stack().undo()
+
+ def _redo(self):
+ """Redo the last undone action."""
+ from openpilot.tools.cabana.pycabana.commands import undo_stack
+ undo_stack().redo()
+
+ def _toggle_fullscreen(self):
+ """Toggle fullscreen mode."""
+ if self.isFullScreen():
+ self.showNormal()
+ else:
+ self.showFullScreen()
+
+ def _reset_layout(self):
+ """Reset window layout to default."""
+ # Restore docks to default positions
+ self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self.messages_dock)
+ self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.video_dock)
+ self.messages_dock.show()
+ self.video_dock.show()
+ self.resizeDocks([self.messages_dock], [350], Qt.Orientation.Horizontal)
+ self.resizeDocks([self.video_dock], [450], Qt.Orientation.Horizontal)
+
+ def _find_similar_bits(self):
+ """Find similar bits tool."""
+ from PySide6.QtWidgets import QMessageBox
+ QMessageBox.information(self, "Find Similar Bits", "Not yet implemented")
+
+ def _find_signal(self):
+ """Find signal tool."""
+ from PySide6.QtWidgets import QMessageBox
+ QMessageBox.information(self, "Find Signal", "Not yet implemented")
+
+ def _show_about(self):
+ """Show the about dialog."""
+ from PySide6.QtWidgets import QMessageBox
+ QMessageBox.about(
+ self,
+ self.tr("About pycabana"),
+ self.tr(
+ "pycabana - PySide6 CAN Bus Analyzer\n\n"
+ + "A Python port of cabana for analyzing CAN bus data\n"
+ + "from openpilot routes.\n\n"
+ + "comma.ai"
+ )
+ )
+
+ def closeEvent(self, event):
+ """Clean up resources on close."""
+ # Stop video/camera threads
+ self.video_widget.camera_view.stop()
+
+ # Stop stream
+ self.stream.stop()
+
+ super().closeEvent(event)
diff --git a/tools/cabana/pycabana/settings.py b/tools/cabana/pycabana/settings.py
new file mode 100644
index 00000000000000..95b5937787a501
--- /dev/null
+++ b/tools/cabana/pycabana/settings.py
@@ -0,0 +1,260 @@
+from typing import Optional
+from enum import IntEnum
+from pathlib import Path
+
+from PySide6.QtCore import QObject, Signal, QSettings, QByteArray, QDir, QStandardPaths
+from PySide6.QtWidgets import (
+ QDialog, QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
+ QGroupBox, QSpinBox, QComboBox, QLineEdit, QPushButton,
+ QDialogButtonBox, QFileDialog
+)
+
+# Theme constants
+LIGHT_THEME = 1
+DARK_THEME = 2
+
+# Cache limits
+MIN_CACHE_MINUTES = 30
+MAX_CACHE_MINUTES = 120
+
+
+class DragDirection(IntEnum):
+ MsbFirst = 0
+ LsbFirst = 1
+ AlwaysLE = 2
+ AlwaysBE = 3
+
+
+class Settings(QObject):
+ changed = Signal()
+
+ _instance: Optional['Settings'] = None
+
+ def __new__(cls):
+ if cls._instance is None:
+ cls._instance = super().__new__(cls)
+ return cls._instance
+
+ def __init__(self):
+ if hasattr(self, '_initialized'):
+ return
+ super().__init__()
+ self._initialized = True
+
+ # General settings
+ self.absolute_time: bool = False
+ self.fps: int = 10
+ self.max_cached_minutes: int = 30
+ self.chart_height: int = 200
+ self.chart_column_count: int = 1
+ self.chart_range: int = 3 * 60 # 3 minutes
+ self.chart_series_type: int = 0
+ self.theme: int = 0
+ self.sparkline_range: int = 15 # 15 seconds
+ self.multiple_lines_hex: bool = False
+ self.log_livestream: bool = True
+ self.suppress_defined_signals: bool = False
+ self.log_path: str = str(Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.HomeLocation)) / "cabana_live_stream")
+ self.last_dir: str = QDir.homePath()
+ self.last_route_dir: str = QDir.homePath()
+ self.geometry: QByteArray = QByteArray()
+ self.video_splitter_state: QByteArray = QByteArray()
+ self.window_state: QByteArray = QByteArray()
+ self.recent_files: list[str] = []
+ self.message_header_state: QByteArray = QByteArray()
+ self.drag_direction: DragDirection = DragDirection.MsbFirst
+
+ # Session data
+ self.recent_dbc_file: str = ""
+ self.active_msg_id: str = ""
+ self.selected_msg_ids: list[str] = []
+ self.active_charts: list[str] = []
+
+ self._load()
+
+ def _load(self):
+ """Load settings from QSettings"""
+ s = QSettings("cabana", "cabana")
+
+ self.absolute_time = s.value("absolute_time", self.absolute_time, type=bool)
+ self.fps = s.value("fps", self.fps, type=int)
+ self.max_cached_minutes = s.value("max_cached_minutes", self.max_cached_minutes, type=int)
+ self.chart_height = s.value("chart_height", self.chart_height, type=int)
+ self.chart_range = s.value("chart_range", self.chart_range, type=int)
+ self.chart_column_count = s.value("chart_column_count", self.chart_column_count, type=int)
+ self.last_dir = s.value("last_dir", self.last_dir, type=str)
+ self.last_route_dir = s.value("last_route_dir", self.last_route_dir, type=str)
+ self.window_state = s.value("window_state", self.window_state, type=QByteArray)
+ self.geometry = s.value("geometry", self.geometry, type=QByteArray)
+ self.video_splitter_state = s.value("video_splitter_state", self.video_splitter_state, type=QByteArray)
+ self.recent_files = s.value("recent_files", self.recent_files, type=list)
+ self.message_header_state = s.value("message_header_state", self.message_header_state, type=QByteArray)
+ self.chart_series_type = s.value("chart_series_type", self.chart_series_type, type=int)
+ self.theme = s.value("theme", self.theme, type=int)
+ self.sparkline_range = s.value("sparkline_range", self.sparkline_range, type=int)
+ self.multiple_lines_hex = s.value("multiple_lines_hex", self.multiple_lines_hex, type=bool)
+ self.log_livestream = s.value("log_livestream", self.log_livestream, type=bool)
+ self.log_path = s.value("log_path", self.log_path, type=str)
+ self.drag_direction = DragDirection(s.value("drag_direction", int(self.drag_direction), type=int))
+ self.suppress_defined_signals = s.value("suppress_defined_signals", self.suppress_defined_signals, type=bool)
+ self.recent_dbc_file = s.value("recent_dbc_file", self.recent_dbc_file, type=str)
+ self.active_msg_id = s.value("active_msg_id", self.active_msg_id, type=str)
+ self.selected_msg_ids = s.value("selected_msg_ids", self.selected_msg_ids, type=list)
+ self.active_charts = s.value("active_charts", self.active_charts, type=list)
+
+ def save(self):
+ """Save settings to QSettings"""
+ s = QSettings("cabana", "cabana")
+
+ s.setValue("absolute_time", self.absolute_time)
+ s.setValue("fps", self.fps)
+ s.setValue("max_cached_minutes", self.max_cached_minutes)
+ s.setValue("chart_height", self.chart_height)
+ s.setValue("chart_range", self.chart_range)
+ s.setValue("chart_column_count", self.chart_column_count)
+ s.setValue("last_dir", self.last_dir)
+ s.setValue("last_route_dir", self.last_route_dir)
+ s.setValue("window_state", self.window_state)
+ s.setValue("geometry", self.geometry)
+ s.setValue("video_splitter_state", self.video_splitter_state)
+ s.setValue("recent_files", self.recent_files)
+ s.setValue("message_header_state", self.message_header_state)
+ s.setValue("chart_series_type", self.chart_series_type)
+ s.setValue("theme", self.theme)
+ s.setValue("sparkline_range", self.sparkline_range)
+ s.setValue("multiple_lines_hex", self.multiple_lines_hex)
+ s.setValue("log_livestream", self.log_livestream)
+ s.setValue("log_path", self.log_path)
+ s.setValue("drag_direction", int(self.drag_direction))
+ s.setValue("suppress_defined_signals", self.suppress_defined_signals)
+ s.setValue("recent_dbc_file", self.recent_dbc_file)
+ s.setValue("active_msg_id", self.active_msg_id)
+ s.setValue("selected_msg_ids", self.selected_msg_ids)
+ s.setValue("active_charts", self.active_charts)
+
+ s.sync()
+
+ def __del__(self):
+ """Save settings on destruction"""
+ self.save()
+
+
+class SettingsDlg(QDialog):
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+ self.setWindowTitle(self.tr("Settings"))
+
+ main_layout = QVBoxLayout(self)
+
+ # General settings group
+ groupbox = QGroupBox("General")
+ form_layout = QFormLayout(groupbox)
+
+ self.theme = QComboBox(self)
+ self.theme.setToolTip(self.tr("You may need to restart cabana after changes theme"))
+ self.theme.addItems([self.tr("Automatic"), self.tr("Light"), self.tr("Dark")])
+ self.theme.setCurrentIndex(settings.theme)
+ form_layout.addRow(self.tr("Color Theme"), self.theme)
+
+ self.fps = QSpinBox(self)
+ self.fps.setRange(10, 100)
+ self.fps.setSingleStep(10)
+ self.fps.setValue(settings.fps)
+ form_layout.addRow("FPS", self.fps)
+
+ self.cached_minutes = QSpinBox(self)
+ self.cached_minutes.setRange(MIN_CACHE_MINUTES, MAX_CACHE_MINUTES)
+ self.cached_minutes.setSingleStep(1)
+ self.cached_minutes.setValue(settings.max_cached_minutes)
+ form_layout.addRow(self.tr("Max Cached Minutes"), self.cached_minutes)
+
+ main_layout.addWidget(groupbox)
+
+ # New Signal Settings group
+ groupbox = QGroupBox("New Signal Settings")
+ form_layout = QFormLayout(groupbox)
+
+ self.drag_direction = QComboBox(self)
+ self.drag_direction.addItems([
+ self.tr("MSB First"),
+ self.tr("LSB First"),
+ self.tr("Always Little Endian"),
+ self.tr("Always Big Endian")
+ ])
+ self.drag_direction.setCurrentIndex(int(settings.drag_direction))
+ form_layout.addRow(self.tr("Drag Direction"), self.drag_direction)
+
+ main_layout.addWidget(groupbox)
+
+ # Chart settings group
+ groupbox = QGroupBox("Chart")
+ form_layout = QFormLayout(groupbox)
+
+ self.chart_height = QSpinBox(self)
+ self.chart_height.setRange(100, 500)
+ self.chart_height.setSingleStep(10)
+ self.chart_height.setValue(settings.chart_height)
+ form_layout.addRow(self.tr("Chart Height"), self.chart_height)
+
+ main_layout.addWidget(groupbox)
+
+ # Live stream logging group
+ self.log_livestream = QGroupBox(self.tr("Enable live stream logging"), self)
+ self.log_livestream.setCheckable(True)
+ self.log_livestream.setChecked(settings.log_livestream)
+ path_layout = QHBoxLayout(self.log_livestream)
+
+ self.log_path = QLineEdit(settings.log_path, self)
+ self.log_path.setReadOnly(True)
+ path_layout.addWidget(self.log_path)
+
+ browse_btn = QPushButton(self.tr("B&rowse..."))
+ browse_btn.clicked.connect(self._browse_log_path)
+ path_layout.addWidget(browse_btn)
+
+ main_layout.addWidget(self.log_livestream)
+
+ # Dialog buttons
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ button_box.accepted.connect(self._save)
+ button_box.rejected.connect(self.reject)
+ main_layout.addWidget(button_box)
+
+ self.setFixedSize(400, self.sizeHint().height())
+
+ def _browse_log_path(self):
+ """Open file dialog to select log path"""
+ fn = QFileDialog.getExistingDirectory(
+ self,
+ self.tr("Log File Location"),
+ QStandardPaths.writableLocation(QStandardPaths.StandardLocation.HomeLocation),
+ QFileDialog.Option.ShowDirsOnly | QFileDialog.Option.DontResolveSymlinks
+ )
+ if fn:
+ self.log_path.setText(fn)
+
+ def _save(self):
+ """Save settings and close dialog"""
+ old_theme = settings.theme
+ settings.theme = self.theme.currentIndex()
+
+ if old_theme != settings.theme:
+ # Set theme before emit changed
+ # Note: In Python, we would need to import and call the theme utility here
+ # For now, we'll just emit the signal
+ pass
+
+ settings.fps = self.fps.value()
+ settings.max_cached_minutes = self.cached_minutes.value()
+ settings.chart_height = self.chart_height.value()
+ settings.log_livestream = self.log_livestream.isChecked()
+ settings.log_path = self.log_path.text()
+ settings.drag_direction = DragDirection(self.drag_direction.currentIndex())
+
+ settings.save()
+ settings.changed.emit()
+ self.accept()
+
+
+# Global singleton instance
+settings = Settings()
diff --git a/tools/cabana/pycabana/streams/__init__.py b/tools/cabana/pycabana/streams/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/tools/cabana/pycabana/streams/abstract.py b/tools/cabana/pycabana/streams/abstract.py
new file mode 100644
index 00000000000000..8d90c139bc726d
--- /dev/null
+++ b/tools/cabana/pycabana/streams/abstract.py
@@ -0,0 +1,88 @@
+"""Abstract base class for CAN data streams."""
+
+from PySide6.QtCore import QObject, Signal
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId, CanEvent, CanData
+
+
+class AbstractStream(QObject):
+ """Base class for all CAN data streams.
+
+ Subclasses must implement start() and populate events via updateEvent().
+ """
+
+ # Emitted when messages are received/updated
+ # Args: (msg_ids: set[MessageId], has_new_ids: bool)
+ msgsReceived = Signal(set, bool)
+
+ # Emitted when stream seeks to a new time (for replay)
+ # Args: (time_seconds: float)
+ seekedTo = Signal(float)
+
+ # Emitted when stream starts
+ streamStarted = Signal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.events: dict[MessageId, list[CanEvent]] = {}
+ self.last_msgs: dict[MessageId, CanData] = {}
+ self.start_ts: int = 0 # first event timestamp (nanoseconds)
+ self._msg_ids: set[MessageId] = set()
+
+ def start(self) -> None:
+ """Start the stream. Subclasses should override."""
+ self.streamStarted.emit()
+
+ def stop(self) -> None:
+ """Stop the stream. Subclasses should override."""
+
+ def lastMessage(self, msg_id: MessageId) -> CanData | None:
+ """Get the last processed data for a message."""
+ return self.last_msgs.get(msg_id)
+
+ def allEvents(self) -> list[CanEvent]:
+ """Get all events across all messages, sorted by time."""
+ all_evts = []
+ for evts in self.events.values():
+ all_evts.extend(evts)
+ all_evts.sort(key=lambda e: e.mono_time)
+ return all_evts
+
+ def updateEvent(self, event: CanEvent) -> None:
+ """Process a single CAN event, update internal state."""
+ msg_id = MessageId(event.src, event.address)
+
+ # Track first timestamp
+ if self.start_ts == 0:
+ self.start_ts = event.mono_time
+
+ # Add to events list
+ if msg_id not in self.events:
+ self.events[msg_id] = []
+ self.events[msg_id].append(event)
+
+ # Update last_msgs
+ if msg_id not in self.last_msgs:
+ self.last_msgs[msg_id] = CanData()
+ self.last_msgs[msg_id].update(event, self.start_ts)
+
+ # Track new message IDs
+ self._msg_ids.add(msg_id)
+
+ def emitMsgsReceived(self, has_new: bool = True) -> None:
+ """Emit the msgsReceived signal with current message IDs."""
+ self.msgsReceived.emit(self._msg_ids.copy(), has_new)
+
+ def toSeconds(self, mono_time: int) -> float:
+ """Convert monotonic time to seconds from start."""
+ return (mono_time - self.start_ts) / 1e9
+
+ @property
+ def routeName(self) -> str:
+ """Return route name if applicable. Subclasses can override."""
+ return ""
+
+ @property
+ def carFingerprint(self) -> str:
+ """Return car fingerprint if known. Subclasses can override."""
+ return ""
diff --git a/tools/cabana/pycabana/streams/replay.py b/tools/cabana/pycabana/streams/replay.py
new file mode 100644
index 00000000000000..b38b24c7351c00
--- /dev/null
+++ b/tools/cabana/pycabana/streams/replay.py
@@ -0,0 +1,239 @@
+"""ReplayStream - loads CAN data from openpilot routes."""
+
+import bisect
+import time
+from dataclasses import dataclass
+
+from PySide6.QtCore import QThread, Signal as QtSignal, Qt
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId, CanData
+from openpilot.tools.cabana.pycabana.streams.abstract import AbstractStream
+
+
+@dataclass
+class CanEvent:
+ """Single CAN event with timestamp."""
+ ts: float # seconds since start
+ msg_id: MessageId
+ dat: bytes
+
+
+class LogLoaderThread(QThread):
+ """Background thread that loads log data and processes it."""
+
+ dataReady = QtSignal(object, object, object) # last_msgs, msg_ids, events
+ progress = QtSignal(int, int)
+ finished = QtSignal(str, float) # fingerprint, duration
+
+ BATCH_SIZE = 10000
+ YIELD_INTERVAL = 1000 # Yield GIL every N messages
+
+ def __init__(self, route: str, parent=None):
+ super().__init__(parent)
+ self.route = route
+ self._stop_requested = False
+
+ def run(self):
+ fingerprint = ""
+ duration = 0.0
+ try:
+ from openpilot.tools.lib.logreader import LogReader
+ lr = LogReader(self.route)
+ time.sleep(0.001) # Yield GIL after LogReader init
+ total_msgs = 0
+ can_msgs = 0
+ last_msgs: dict[MessageId, CanData] = {}
+ all_events: list[CanEvent] = []
+ start_ts = 0
+ last_emit_count = 0
+ last_yield = 0
+
+ for msg in lr:
+ if self._stop_requested:
+ break
+
+ total_msgs += 1
+ which = msg.which()
+
+ if which == 'carParams' and not fingerprint:
+ fingerprint = msg.carParams.carFingerprint
+
+ if which == 'can':
+ for c in msg.can:
+ msg_id = MessageId(c.src, c.address)
+ mono_time = msg.logMonoTime
+
+ if start_ts == 0:
+ start_ts = mono_time
+
+ ts = (mono_time - start_ts) / 1e9
+ duration = max(duration, ts)
+
+ # Store event for seeking
+ all_events.append(CanEvent(ts=ts, msg_id=msg_id, dat=bytes(c.dat)))
+
+ if msg_id not in last_msgs:
+ last_msgs[msg_id] = CanData()
+
+ last_msgs[msg_id].count += 1
+ last_msgs[msg_id].dat = bytes(c.dat)
+ last_msgs[msg_id].ts = ts
+ can_msgs += 1
+
+ if can_msgs - last_emit_count >= self.BATCH_SIZE:
+ snapshot = {k: CanData(v.ts, v.count, v.freq, v.dat) for k, v in last_msgs.items()}
+ self.dataReady.emit(snapshot, set(last_msgs.keys()), None)
+ self.progress.emit(can_msgs, total_msgs)
+ last_emit_count = can_msgs
+ time.sleep(0.01) # Give main thread time to process
+
+ # Periodically yield GIL to allow main thread to process events
+ if can_msgs - last_yield >= self.YIELD_INTERVAL:
+ time.sleep(0)
+ last_yield = can_msgs
+
+ snapshot = {k: CanData(v.ts, v.count, v.freq, v.dat) for k, v in last_msgs.items()}
+ self.dataReady.emit(snapshot, set(last_msgs.keys()), all_events)
+ self.progress.emit(can_msgs, total_msgs)
+ except Exception as e:
+ import traceback
+
+ print(f"Error loading route: {e}")
+ traceback.print_exc()
+ finally:
+ self.finished.emit(fingerprint, duration)
+
+ def stop(self):
+ self._stop_requested = True
+
+
+class ReplayStream(AbstractStream):
+ """Stream that replays CAN data from an openpilot route."""
+
+ loadProgress = QtSignal(int, int)
+ loadFinished = QtSignal()
+ seeked = QtSignal(float) # Emitted when seek completes (time in seconds)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._route: str = ""
+ self._fingerprint: str = ""
+ self._loader_thread: LogLoaderThread | None = None
+ self._loading: bool = False
+ self._duration: float = 0.0
+ self._current_time: float = 0.0
+ self._all_events: list[CanEvent] = []
+ self._event_timestamps: list[float] = [] # For binary search
+
+ def __del__(self):
+ try:
+ self.stop()
+ except RuntimeError:
+ pass
+
+ def loadRoute(self, route: str) -> bool:
+ if self._loading:
+ return False
+
+ self._route = route
+ self._loading = True
+ self._duration = 0.0
+ self._current_time = 0.0
+ self._all_events = []
+ self._event_timestamps = []
+
+ self.events.clear()
+ self.last_msgs.clear()
+ self._msg_ids.clear()
+ self.start_ts = 0
+
+ self._loader_thread = LogLoaderThread(route, self)
+ self._loader_thread.dataReady.connect(self._onDataReady, Qt.ConnectionType.QueuedConnection)
+ self._loader_thread.progress.connect(self._onProgress, Qt.ConnectionType.QueuedConnection)
+ self._loader_thread.finished.connect(self._onLoadFinished, Qt.ConnectionType.QueuedConnection)
+ self._loader_thread.start()
+
+ return True
+
+ def _onDataReady(self, snapshot: dict[MessageId, CanData], msg_ids: set[MessageId], events: list[CanEvent] | None):
+ self.last_msgs = snapshot
+ new_ids = msg_ids - self._msg_ids
+ self._msg_ids = msg_ids
+
+ # Store events when loading completes (final emit has all events)
+ if events is not None:
+ self._all_events = events
+ self._event_timestamps = [e.ts for e in events]
+
+ self.emitMsgsReceived(has_new=bool(new_ids))
+
+ def _onProgress(self, can_msgs: int, total_msgs: int):
+ self.loadProgress.emit(can_msgs, total_msgs)
+
+ def _onLoadFinished(self, fingerprint: str, duration: float):
+ self._loading = False
+ self._fingerprint = fingerprint
+ self._duration = duration
+ self._current_time = duration # Start at end (showing all data)
+ self.loadFinished.emit()
+
+ def seekTo(self, time_sec: float) -> None:
+ """Seek to a specific time, updating last_msgs to state at that time."""
+ if not self._all_events:
+ return
+
+ time_sec = max(0, min(time_sec, self._duration))
+ self._current_time = time_sec
+
+ # Find all events up to this time
+ idx = bisect.bisect_right(self._event_timestamps, time_sec)
+
+ # Rebuild last_msgs from events up to this point
+ new_last_msgs: dict[MessageId, CanData] = {}
+ counts: dict[MessageId, int] = {}
+
+ for i in range(idx):
+ event = self._all_events[i]
+ if event.msg_id not in new_last_msgs:
+ new_last_msgs[event.msg_id] = CanData()
+ counts[event.msg_id] = 0
+
+ counts[event.msg_id] += 1
+ new_last_msgs[event.msg_id].count = counts[event.msg_id]
+ new_last_msgs[event.msg_id].dat = event.dat
+ new_last_msgs[event.msg_id].ts = event.ts
+
+ self.last_msgs = new_last_msgs
+ self._msg_ids = set(new_last_msgs.keys())
+ self.emitMsgsReceived(has_new=False)
+ self.seeked.emit(time_sec)
+
+ def stop(self):
+ if self._loader_thread is not None:
+ self._loader_thread.stop()
+ if self._loader_thread.isRunning():
+ if not self._loader_thread.wait(2000):
+ # Force terminate if thread doesn't stop gracefully
+ self._loader_thread.terminate()
+ self._loader_thread.wait(1000)
+ self._loader_thread = None
+
+ @property
+ def routeName(self) -> str:
+ return self._route
+
+ @property
+ def carFingerprint(self) -> str:
+ return self._fingerprint
+
+ @property
+ def isLoading(self) -> bool:
+ return self._loading
+
+ @property
+ def duration(self) -> float:
+ return self._duration
+
+ @property
+ def currentTime(self) -> float:
+ return self._current_time
diff --git a/tools/cabana/pycabana/test_smoke.py b/tools/cabana/pycabana/test_smoke.py
new file mode 100755
index 00000000000000..c91abbcd7f833c
--- /dev/null
+++ b/tools/cabana/pycabana/test_smoke.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+"""Smoke test for pycabana - runs the app headlessly and checks for errors."""
+
+import os
+import signal
+import subprocess
+import sys
+import time
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
+TEST_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19/0"
+
+ERROR_PATTERNS = [
+ "_pythonToCppCopy",
+ # "QThread: Destroyed while" - known Qt/Python cleanup issue, not a functional problem
+ "Traceback",
+ "Segmentation fault",
+ "Aborted",
+ "core dumped",
+ "UI thread blocked",
+]
+
+
+def main():
+ env = {**os.environ, "QT_QPA_PLATFORM": "offscreen"}
+ cmd = [sys.executable, "-m", "openpilot.tools.cabana.pycabana.cabana", "--strict", TEST_ROUTE]
+
+ print(f"Starting pycabana with route {TEST_ROUTE}")
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, cwd=REPO_ROOT, env=env)
+
+ time.sleep(5)
+
+ print("Sending SIGINT...")
+ os.kill(proc.pid, signal.SIGINT)
+
+ try:
+ stdout, stderr = proc.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ stdout, stderr = proc.communicate()
+ print("FAILED: Process hung after SIGINT")
+ return 1
+
+ output = stdout + stderr
+ errors = [p for p in ERROR_PATTERNS if p in output]
+ if errors:
+ print(f"FAILED: Found error patterns: {errors}")
+ print("--- stdout ---")
+ print(stdout)
+ print("--- stderr ---")
+ print(stderr)
+ return 1
+
+ # Accept 0 (success) or -6 (SIGABRT from Qt thread cleanup warning)
+ # The QThread cleanup warning is a known Qt/Python interop issue
+ if proc.returncode not in (0, -6):
+ print(f"FAILED: Bad exit code {proc.returncode}")
+ print("--- stderr ---")
+ print(stderr)
+ return 1
+
+ print("OK")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/cabana/pycabana/utils/__init__.py b/tools/cabana/pycabana/utils/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/tools/cabana/pycabana/widgets/__init__.py b/tools/cabana/pycabana/widgets/__init__.py
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/tools/cabana/pycabana/widgets/binary.py b/tools/cabana/pycabana/widgets/binary.py
new file mode 100644
index 00000000000000..ded2ff6c829b10
--- /dev/null
+++ b/tools/cabana/pycabana/widgets/binary.py
@@ -0,0 +1,456 @@
+"""BinaryView - displays CAN message bytes as bits with signal coloring."""
+
+from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex, Signal as QtSignal
+from PySide6.QtGui import QColor, QPainter, QFont, QFontDatabase, QMouseEvent
+from PySide6.QtWidgets import (
+ QWidget,
+ QVBoxLayout,
+ QTableView,
+ QHeaderView,
+ QStyledItemDelegate,
+ QStyleOptionViewItem,
+)
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId, CanData
+from openpilot.tools.cabana.pycabana.dbc.dbcmanager import dbc_manager
+from openpilot.tools.cabana.pycabana.settings import Settings, DragDirection
+
+# Signal colors - same palette as C++ cabana
+SIGNAL_COLORS = [
+ QColor(102, 86, 169), # purple
+ QColor(76, 175, 80), # green
+ QColor(255, 152, 0), # orange
+ QColor(33, 150, 243), # blue
+ QColor(233, 30, 99), # pink
+ QColor(0, 188, 212), # cyan
+ QColor(255, 235, 59), # yellow
+ QColor(121, 85, 72), # brown
+]
+
+CELL_HEIGHT = 36
+
+
+def flip_bit_pos(pos: int) -> int:
+ """Flip bit position for big endian calculation."""
+ return (pos // 8) * 8 + 7 - (pos % 8)
+
+
+def get_bit_pos(row: int, col: int) -> int:
+ """Get absolute bit position from row and column."""
+ return row * 8 + (7 - col)
+
+
+class BinaryViewModel(QAbstractTableModel):
+ """Model for the binary view - 8 bit columns + 1 hex column per row."""
+
+ COLUMN_COUNT = 9 # 8 bits + 1 hex byte
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.msg_id: MessageId | None = None
+ self._data: bytes = b''
+ self._row_count = 0
+ # items[row][col] = (bit_value, signal_indices, is_msb, is_lsb)
+ self._items: list[list[dict]] = []
+
+ def setMessage(self, msg_id: MessageId | None, can_data: CanData | None):
+ """Set the message to display."""
+ self.beginResetModel()
+ self.msg_id = msg_id
+ self._data = can_data.dat if can_data else b''
+ self._rebuild()
+ self.endResetModel()
+
+ def updateData(self, can_data: CanData | None):
+ """Update data without rebuilding signal mapping."""
+ new_data = can_data.dat if can_data else b''
+ if new_data == self._data:
+ return
+
+ old_row_count = self._row_count
+ self._data = new_data
+ new_row_count = len(self._data)
+
+ if new_row_count > old_row_count:
+ self.beginInsertRows(QModelIndex(), old_row_count, new_row_count - 1)
+ self._row_count = new_row_count
+ self._extend_items()
+ self.endInsertRows()
+ elif new_row_count < old_row_count:
+ self.beginRemoveRows(QModelIndex(), new_row_count, old_row_count - 1)
+ self._row_count = new_row_count
+ self._items = self._items[:new_row_count]
+ self.endRemoveRows()
+
+ # Emit data changed for all cells
+ if self._row_count > 0:
+ self.dataChanged.emit(
+ self.index(0, 0),
+ self.index(self._row_count - 1, self.COLUMN_COUNT - 1)
+ )
+
+ def _rebuild(self):
+ """Rebuild the signal mapping for each bit."""
+ self._row_count = len(self._data) if self._data else 0
+ if self.msg_id is not None:
+ # Check DBC for message size
+ msg = dbc_manager().msg(self.msg_id)
+ if msg:
+ self._row_count = max(self._row_count, msg.size)
+
+ self._items = []
+ for _ in range(self._row_count):
+ row = []
+ for _ in range(self.COLUMN_COUNT):
+ row.append({'sig_indices': [], 'is_msb': False, 'is_lsb': False})
+ self._items.append(row)
+
+ # Map signals to bits
+ if self.msg_id:
+ msg = dbc_manager().msg(self.msg_id)
+ if msg:
+ for sig_idx, sig in enumerate(msg.sigs.values()):
+ for j in range(sig.size):
+ # Calculate bit position based on endianness
+ if sig.is_little_endian:
+ bit_pos = sig.lsb + j
+ else:
+ # Big endian: start from MSB
+ bit_pos = sig.msb - (j % 8) + (j // 8) * 8
+
+ byte_idx = bit_pos // 8
+ bit_idx = bit_pos % 8
+
+ if byte_idx < self._row_count and bit_idx < 8:
+ item = self._items[byte_idx][bit_idx]
+ item['sig_indices'].append(sig_idx)
+ if j == 0:
+ item['is_lsb' if sig.is_little_endian else 'is_msb'] = True
+ if j == sig.size - 1:
+ item['is_msb' if sig.is_little_endian else 'is_lsb'] = True
+
+ def _extend_items(self):
+ """Extend items list for new rows."""
+ while len(self._items) < self._row_count:
+ row = []
+ for _ in range(self.COLUMN_COUNT):
+ row.append({'sig_indices': [], 'is_msb': False, 'is_lsb': False})
+ self._items.append(row)
+
+ def rowCount(self, parent=None):
+ if parent is None:
+ parent = QModelIndex()
+ if parent.isValid():
+ return 0
+ return self._row_count
+
+ def columnCount(self, parent=None):
+ if parent is None:
+ parent = QModelIndex()
+ if parent.isValid():
+ return 0
+ return self.COLUMN_COUNT
+
+ def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
+ if not index.isValid():
+ return None
+
+ row, col = index.row(), index.column()
+ if row >= self._row_count:
+ return None
+
+ if role == Qt.ItemDataRole.DisplayRole:
+ if row < len(self._data):
+ byte_val = self._data[row]
+ if col == 8: # Hex column
+ return f"{byte_val:02X}"
+ else: # Bit column (0-7, MSB first)
+ bit_val = (byte_val >> (7 - col)) & 1
+ return str(bit_val)
+ return ""
+
+ elif role == Qt.ItemDataRole.UserRole:
+ # Return item data for delegate
+ if row < len(self._items) and col < len(self._items[row]):
+ item = self._items[row][col]
+ byte_val = self._data[row] if row < len(self._data) else 0
+ bit_val = (byte_val >> (7 - col)) & 1 if col < 8 else byte_val
+ return {
+ 'value': bit_val,
+ 'byte_value': byte_val,
+ 'sig_indices': item['sig_indices'],
+ 'is_msb': item['is_msb'],
+ 'is_lsb': item['is_lsb'],
+ 'is_hex': col == 8,
+ 'valid': row < len(self._data),
+ }
+ return None
+
+ return None
+
+ def headerData(self, section, orientation, role: int = Qt.ItemDataRole.DisplayRole):
+ if role == Qt.ItemDataRole.DisplayRole:
+ if orientation == Qt.Orientation.Horizontal:
+ if section == 8:
+ return "Hex"
+ return str(7 - section) # Show bit position (MSB=7 to LSB=0)
+ else:
+ return str(section)
+ return None
+
+
+class BinaryItemDelegate(QStyledItemDelegate):
+ """Custom delegate for painting binary cells with signal colors."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._hex_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
+ self._hex_font.setBold(True)
+ self._small_font = QFont()
+ self._small_font.setPixelSize(8)
+
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex):
+ item = index.data(Qt.ItemDataRole.UserRole)
+ if item is None:
+ return
+
+ painter.save()
+
+ rect = option.rect
+ is_hex = item['is_hex']
+ sig_indices = item['sig_indices']
+ valid = item['valid']
+
+ # Background color
+ if sig_indices:
+ # Use first signal's color
+ color_idx = sig_indices[0] % len(SIGNAL_COLORS)
+ bg_color = QColor(SIGNAL_COLORS[color_idx])
+ bg_color.setAlpha(180 if valid else 80)
+ painter.fillRect(rect, bg_color)
+
+ # Draw border for signal boundaries
+ border_color = SIGNAL_COLORS[color_idx].darker(130)
+ painter.setPen(border_color)
+ painter.drawRect(rect.adjusted(0, 0, -1, -1))
+ elif valid:
+ # No signal - light gray
+ painter.fillRect(rect, QColor(60, 60, 60, 40))
+
+ # Mark overlapping signals
+ if len(sig_indices) > 1:
+ painter.fillRect(rect, QColor(100, 100, 100, 100))
+
+ # Invalid data pattern
+ if not valid:
+ painter.fillRect(rect, QColor(80, 80, 80, 60))
+
+ # Text
+ if valid:
+ if is_hex:
+ painter.setFont(self._hex_font)
+ text = index.data(Qt.ItemDataRole.DisplayRole)
+ if text:
+ painter.setPen(Qt.GlobalColor.white if sig_indices else Qt.GlobalColor.lightGray)
+ painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
+
+ # MSB/LSB markers
+ if item['is_msb'] or item['is_lsb']:
+ painter.setFont(self._small_font)
+ painter.setPen(Qt.GlobalColor.white)
+ marker = "M" if item['is_msb'] else "L"
+ painter.drawText(rect.adjusted(2, 2, -2, -2), Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignBottom, marker)
+
+ painter.restore()
+
+
+class BinaryTableView(QTableView):
+ """Custom table view with mouse handling for signal creation."""
+
+ signalCreated = QtSignal(int, int, bool) # start_bit, size, is_little_endian
+
+ def __init__(self, model: BinaryViewModel, parent=None):
+ super().__init__(parent)
+ self._model = model
+ self._anchor_index: QModelIndex | None = None
+ self._resize_sig = None # Signal being resized (not implemented yet)
+
+ def mousePressEvent(self, event: QMouseEvent):
+ """Handle mouse press - start selection."""
+ if event.button() == Qt.MouseButton.LeftButton:
+ index = self.indexAt(event.pos())
+ if index.isValid() and index.column() != 8: # Not hex column
+ self._anchor_index = index
+ self._resize_sig = None
+ # TODO: Check if clicking on signal edge for resize
+ event.accept()
+ return
+ super().mousePressEvent(event)
+
+ def mouseMoveEvent(self, event: QMouseEvent):
+ """Handle mouse move - update selection."""
+ if self._anchor_index is not None and self._anchor_index.isValid():
+ index = self.indexAt(event.pos())
+ if index.isValid() and index.column() != 8:
+ # Update selection between anchor and current
+ self._updateSelection(index)
+ super().mouseMoveEvent(event)
+
+ def mouseReleaseEvent(self, event: QMouseEvent):
+ """Handle mouse release - create signal from selection."""
+ if event.button() == Qt.MouseButton.LeftButton and self._anchor_index is not None:
+ release_index = self.indexAt(event.pos())
+ if release_index.isValid() and self._anchor_index.isValid():
+ selection = self.selectionModel().selectedIndexes()
+ if selection:
+ # Calculate signal parameters from selection
+ start_bit, size, is_le = self._getSelectionParams(release_index)
+ if size > 0:
+ self.signalCreated.emit(start_bit, size, is_le)
+ self.clearSelection()
+ self._anchor_index = None
+ self._resize_sig = None
+ super().mouseReleaseEvent(event)
+
+ def _updateSelection(self, current_index: QModelIndex):
+ """Update selection between anchor and current index."""
+ if not self._anchor_index or not current_index.isValid():
+ return
+
+ # Clear and set new selection
+ self.clearSelection()
+ selection = self.selectionModel()
+
+ # Get range of cells to select
+ start_row = min(self._anchor_index.row(), current_index.row())
+ end_row = max(self._anchor_index.row(), current_index.row())
+ start_col = min(self._anchor_index.column(), current_index.column())
+ end_col = max(self._anchor_index.column(), current_index.column())
+
+ # For simple rectangular selection
+ for row in range(start_row, end_row + 1):
+ for col in range(start_col, min(end_col + 1, 8)): # Exclude hex column
+ idx = self._model.index(row, col)
+ selection.select(idx, selection.SelectionFlag.Select)
+
+ def _getSelectionParams(self, release_index: QModelIndex) -> tuple[int, int, bool]:
+ """Calculate start_bit, size, and endianness from selection."""
+ settings = Settings()
+
+ # Determine endianness based on drag direction setting
+ is_le = True
+ if settings.drag_direction == DragDirection.MsbFirst:
+ is_le = release_index.row() < self._anchor_index.row() or (
+ release_index.row() == self._anchor_index.row() and release_index.column() > self._anchor_index.column()
+ )
+ elif settings.drag_direction == DragDirection.LsbFirst:
+ is_le = not (release_index.row() < self._anchor_index.row() or (
+ release_index.row() == self._anchor_index.row() and release_index.column() > self._anchor_index.column()
+ ))
+ elif settings.drag_direction == DragDirection.AlwaysLE:
+ is_le = True
+ elif settings.drag_direction == DragDirection.AlwaysBE:
+ is_le = False
+
+ # Get bit positions
+ anchor_bit = get_bit_pos(self._anchor_index.row(), self._anchor_index.column())
+ release_bit = get_bit_pos(release_index.row(), release_index.column())
+
+ if is_le:
+ # Little endian: start_bit is the LSB
+ start_bit = min(anchor_bit, release_bit)
+ size = abs(anchor_bit - release_bit) + 1
+ else:
+ # Big endian: start_bit is MSB, calculate flipped positions
+ anchor_flipped = flip_bit_pos(anchor_bit)
+ release_flipped = flip_bit_pos(release_bit)
+ start_bit = get_bit_pos(
+ min(self._anchor_index.row(), release_index.row()),
+ min(self._anchor_index.column(), release_index.column())
+ )
+ size = abs(anchor_flipped - release_flipped) + 1
+
+ return start_bit, size, is_le
+
+
+class BinaryView(QWidget):
+ """Widget showing CAN message bytes as colored bits."""
+
+ signalCreated = QtSignal(int, int, bool) # start_bit, size, is_little_endian
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._msg_id: MessageId | None = None
+
+ self._setup_ui()
+ self._connect_signals()
+
+ def _setup_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self._model = BinaryViewModel()
+ self._delegate = BinaryItemDelegate()
+
+ self._table = BinaryTableView(self._model)
+ self._table.setModel(self._model)
+ self._table.setItemDelegate(self._delegate)
+
+ # Configure table appearance
+ self._table.setShowGrid(False)
+ self._table.setAlternatingRowColors(False)
+ self._table.setSelectionMode(QTableView.SelectionMode.ContiguousSelection)
+
+ # Horizontal header (bit positions)
+ h_header = self._table.horizontalHeader()
+ h_header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
+ h_header.setMinimumSectionSize(30)
+ h_header.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
+
+ # Vertical header (byte indices)
+ v_header = self._table.verticalHeader()
+ v_header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
+ v_header.setDefaultSectionSize(CELL_HEIGHT)
+ v_header.setMinimumWidth(30)
+
+ self._table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+
+ layout.addWidget(self._table)
+
+ def _connect_signals(self):
+ """Connect internal signals."""
+ self._table.signalCreated.connect(self._on_signal_created)
+
+ def _on_signal_created(self, start_bit: int, size: int, is_le: bool):
+ """Handle signal creation from table view."""
+ if self._msg_id is None:
+ return
+
+ from opendbc.can.dbc import Signal
+ from openpilot.tools.cabana.pycabana.commands import AddSigCommand, UndoStack
+
+ # Create a new signal
+ sig = Signal(
+ name="", # Will be assigned by AddSigCommand
+ start_bit=start_bit,
+ size=size,
+ is_signed=False,
+ factor=1.0,
+ offset=0.0,
+ is_little_endian=is_le,
+ )
+
+ cmd = AddSigCommand(self._msg_id, sig)
+ UndoStack.push(cmd)
+
+ # Re-emit for parent widgets
+ self.signalCreated.emit(start_bit, size, is_le)
+
+ def setMessage(self, msg_id: MessageId | None, can_data: CanData | None):
+ """Set the message to display."""
+ self._msg_id = msg_id
+ self._model.setMessage(msg_id, can_data)
+
+ def updateData(self, can_data: CanData | None):
+ """Update data values without changing message selection."""
+ self._model.updateData(can_data)
diff --git a/tools/cabana/pycabana/widgets/camera.py b/tools/cabana/pycabana/widgets/camera.py
new file mode 100644
index 00000000000000..1468afb2a048c8
--- /dev/null
+++ b/tools/cabana/pycabana/widgets/camera.py
@@ -0,0 +1,188 @@
+"""CameraView - displays video frames from openpilot routes."""
+
+from PySide6.QtCore import Qt, QThread, Signal as QtSignal
+from PySide6.QtGui import QImage, QPixmap
+from PySide6.QtWidgets import QVBoxLayout, QLabel, QFrame
+
+
+class FrameLoaderThread(QThread):
+ """Background thread for loading video frames."""
+
+ frameReady = QtSignal(int, object) # frame_idx, numpy array
+ loadComplete = QtSignal(int) # total frames
+
+ def __init__(self, route: str, camera: str = "fcamera", parent=None):
+ super().__init__(parent)
+ self.route = route
+ self.camera = camera
+ self._frame_reader = None
+ self._stop_requested = False
+ self._fps = 20.0 # Default openpilot camera FPS
+
+ def run(self):
+ try:
+ from openpilot.tools.lib.framereader import FrameReader
+ from openpilot.tools.lib.route import Route
+
+ # Strip segment number if present (e.g., "route_name/0" -> "route_name")
+ route_name = self.route.rsplit('/', 1)[0] if '/' in self.route else self.route
+ route = Route(route_name)
+
+ # Get camera paths based on camera type
+ if self.camera == "fcamera":
+ paths = route.camera_paths()
+ elif self.camera == "ecamera":
+ paths = route.ecamera_paths()
+ elif self.camera == "dcamera":
+ paths = route.dcamera_paths()
+ else:
+ paths = route.camera_paths()
+
+ if not paths:
+ return
+
+ # For now, just load the first segment
+ self._frame_reader = FrameReader(paths[0], pix_fmt="rgb24")
+ total_frames = self._frame_reader.frame_count
+
+ self.loadComplete.emit(total_frames)
+
+ except Exception as e:
+ import traceback
+ print(f"Error initializing FrameReader for route '{self.route}': {e}")
+ traceback.print_exc()
+
+ def getFrame(self, frame_idx: int):
+ """Request a frame to be loaded."""
+ if self._frame_reader is None:
+ return
+
+ try:
+ frame = self._frame_reader.get(frame_idx)
+ if frame is not None:
+ self.frameReady.emit(frame_idx, frame)
+ except Exception as e:
+ print(f"Error getting frame {frame_idx}: {e}")
+
+ @property
+ def fps(self) -> float:
+ return self._fps
+
+ def stop(self):
+ self._stop_requested = True
+
+
+class CameraView(QFrame):
+ """Widget for displaying video frames from openpilot routes."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._route: str = ""
+ self._loader_thread: FrameLoaderThread | None = None
+ self._total_frames: int = 0
+ self._current_frame: int = -1
+ self._fps: float = 20.0
+ self._duration: float = 0.0
+
+ self._setup_ui()
+
+ def __del__(self):
+ self.stop()
+
+ def _setup_ui(self):
+ self.setFrameShape(QFrame.Shape.StyledPanel)
+
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Frame display label
+ self.frame_label = QLabel()
+ self.frame_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.frame_label.setMinimumSize(320, 240)
+ self.frame_label.setStyleSheet("background-color: #1a1a1a;")
+ self.frame_label.setText("No video loaded")
+ layout.addWidget(self.frame_label)
+
+ def loadRoute(self, route: str, camera: str = "fcamera"):
+ """Load video from a route."""
+ self._route = route
+
+ if self._loader_thread is not None:
+ self._loader_thread.stop()
+ self._loader_thread.wait()
+
+ self._loader_thread = FrameLoaderThread(route, camera, self)
+ self._loader_thread.frameReady.connect(self._on_frame_ready)
+ self._loader_thread.loadComplete.connect(self._on_load_complete)
+ self._loader_thread.start()
+
+ def _on_load_complete(self, total_frames: int):
+ self._total_frames = total_frames
+ if self._loader_thread:
+ self._fps = self._loader_thread.fps
+ self._duration = total_frames / self._fps if self._fps > 0 else 0.0
+
+ # Load first frame
+ if total_frames > 0:
+ self.seekToFrame(0)
+
+ def _on_frame_ready(self, frame_idx: int, frame_data):
+ """Handle frame data received from loader thread."""
+ if frame_data is None:
+ return
+
+ self._current_frame = frame_idx
+
+ # Convert numpy array to QImage
+ try:
+ import numpy as np
+ if isinstance(frame_data, np.ndarray):
+ height, width = frame_data.shape[:2]
+ bytes_per_line = 3 * width
+ qimg = QImage(frame_data.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)
+
+ # Scale to fit label while keeping aspect ratio
+ pixmap = QPixmap.fromImage(qimg)
+ scaled = pixmap.scaled(
+ self.frame_label.size(),
+ Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation
+ )
+ self.frame_label.setPixmap(scaled)
+ except Exception as e:
+ print(f"Error displaying frame: {e}")
+
+ def seekToTime(self, time_sec: float):
+ """Seek to a specific time in seconds."""
+ if self._total_frames == 0:
+ return
+
+ frame_idx = int(time_sec * self._fps)
+ frame_idx = max(0, min(frame_idx, self._total_frames - 1))
+ self.seekToFrame(frame_idx)
+
+ def seekToFrame(self, frame_idx: int):
+ """Seek to a specific frame index."""
+ if self._loader_thread is None:
+ return
+
+ if frame_idx == self._current_frame:
+ return
+
+ self._loader_thread.getFrame(frame_idx)
+
+ def stop(self):
+ """Stop the frame loader."""
+ if self._loader_thread is not None:
+ self._loader_thread.stop()
+ if self._loader_thread.isRunning():
+ self._loader_thread.wait(2000) # Wait up to 2 seconds
+ self._loader_thread = None
+
+ @property
+ def duration(self) -> float:
+ return self._duration
+
+ @property
+ def fps(self) -> float:
+ return self._fps
diff --git a/tools/cabana/pycabana/widgets/charts.py b/tools/cabana/pycabana/widgets/charts.py
new file mode 100644
index 00000000000000..a9579ac8caf5e2
--- /dev/null
+++ b/tools/cabana/pycabana/widgets/charts.py
@@ -0,0 +1,1070 @@
+"""ChartsWidget - displays signal values over time with advanced charting features."""
+
+from typing import Optional
+from bisect import bisect_left
+from dataclasses import dataclass
+import math
+
+from PySide6.QtCore import Qt, QRectF, QPointF, QRect, QPoint, QSize, Signal, QTimer
+from PySide6.QtGui import (
+ QPainter,
+ QPen,
+ QColor,
+ QFont,
+ QPainterPath,
+ QBrush,
+ QPalette,
+ QFontMetrics,
+ QPixmap,
+ QMouseEvent,
+)
+from PySide6.QtWidgets import (
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QScrollArea,
+ QFrame,
+ QLabel,
+ QPushButton,
+ QComboBox,
+ QDialog,
+ QGridLayout,
+ QListWidget,
+ QListWidgetItem,
+ QDialogButtonBox,
+ QMenu,
+ QToolButton,
+ QSizePolicy,
+)
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId
+from openpilot.tools.cabana.pycabana.dbc.dbcmanager import dbc_manager
+
+
+# Constants
+CHART_MIN_WIDTH = 300
+CHART_HEIGHT = 200
+CHART_SPACING = 4
+AXIS_X_TOP_MARGIN = 4
+MIN_ZOOM_SECONDS = 0.01
+EPSILON = 0.000001
+
+# Chart colors
+CHART_COLORS = [
+ QColor(102, 86, 169), # purple
+ QColor(76, 175, 80), # green
+ QColor(255, 152, 0), # orange
+ QColor(33, 150, 243), # blue
+ QColor(233, 30, 99), # pink
+ QColor(0, 188, 212), # cyan
+ QColor(255, 193, 7), # amber
+ QColor(121, 85, 72), # brown
+]
+
+
+class SeriesType:
+ """Chart series type enumeration."""
+ LINE = 0
+ STEP_LINE = 1
+ SCATTER = 2
+
+
+@dataclass
+class SignalData:
+ """Container for signal data and metadata."""
+ msg_id: MessageId
+ signal_name: str
+ color: QColor
+ values: list[tuple[float, float]] # (time, value) pairs
+ step_values: list[tuple[float, float]] # for step line rendering
+ min_val: float = 0.0
+ max_val: float = 0.0
+ track_point: Optional[tuple[float, float]] = None
+
+
+class TipLabel(QLabel):
+ """Tooltip label for displaying signal values at hover point."""
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ super().__init__(parent, Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint)
+ self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
+
+ self.setForegroundRole(QPalette.ColorRole.ToolTipText)
+ self.setBackgroundRole(QPalette.ColorRole.ToolTipBase)
+
+ font = QFont()
+ font.setPointSizeF(8.5)
+ self.setFont(font)
+ self.setMargin(3)
+ self.setTextFormat(Qt.TextFormat.RichText)
+
+ def showText(self, pt: QPoint, text: str, w: QWidget, rect: QRect):
+ """Display tooltip at given point within rect."""
+ self.setText(text)
+ if text:
+ self.resize(self.sizeHint() + QSize(2, 2))
+ tip_pos = QPoint(pt.x() + 8, rect.top() + 2)
+ if tip_pos.x() + self.width() >= rect.right():
+ tip_pos.setX(pt.x() - self.width() - 8)
+
+ if rect.contains(QRect(tip_pos, self.size())):
+ self.move(w.mapToGlobal(tip_pos))
+ self.setVisible(True)
+ return
+
+ self.setVisible(False)
+
+
+class Sparkline:
+ """Mini-chart for inline display of signal trends."""
+
+ def __init__(self):
+ self.pixmap: Optional[QPixmap] = None
+ self.min_val: float = 0.0
+ self.max_val: float = 0.0
+ self.freq: float = 0.0
+ self._points: list[tuple[float, float]] = []
+
+ def update(self, values: list[tuple[float, float]], color: QColor, size: QSize, range_sec: int):
+ """Update sparkline with new data."""
+ if not values or size.isEmpty():
+ self.pixmap = None
+ return
+
+ self._points = values
+ self.min_val = min(v[1] for v in values)
+ self.max_val = max(v[1] for v in values)
+
+ if values:
+ time_range = values[-1][0] - values[0][0]
+ self.freq = len(values) / max(time_range, 1.0)
+
+ self._render(color, size, range_sec)
+
+ def _render(self, color: QColor, size: QSize, range_sec: int):
+ """Render sparkline to pixmap."""
+ if not self._points:
+ return
+
+ # Adjust for flat lines
+ is_flat = self.min_val == self.max_val
+ min_val = self.min_val - 1.0 if is_flat else self.min_val
+ max_val = self.max_val + 1.0 if is_flat else self.max_val
+
+ # Calculate scaling
+ xscale = (size.width() - 1) / range_sec
+ yscale = (size.height() - 3) / (max_val - min_val) if max_val != min_val else 1.0
+
+ # Transform points
+ render_points = []
+ for t, v in self._points:
+ x = t * xscale
+ y = 1.0 + (max_val - v) * yscale
+ render_points.append(QPointF(x, y))
+
+ # Render to pixmap
+ self.pixmap = QPixmap(size)
+ self.pixmap.fill(Qt.GlobalColor.transparent)
+ painter = QPainter(self.pixmap)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, len(render_points) <= 500)
+ painter.setPen(QPen(color, 1))
+
+ if len(render_points) > 1:
+ path = QPainterPath()
+ path.moveTo(render_points[0])
+ for pt in render_points[1:]:
+ path.lineTo(pt)
+ painter.drawPath(path)
+
+ # Draw points
+ painter.setPen(QPen(color, 3))
+ if len(render_points) > 0:
+ painter.drawPoint(render_points[-1])
+
+ painter.end()
+
+ def isEmpty(self) -> bool:
+ """Check if sparkline has no data."""
+ return self.pixmap is None
+
+
+class SignalSelector(QDialog):
+ """Dialog for selecting signals to chart."""
+
+ class ListItem(QListWidgetItem):
+ """Custom list item storing signal metadata."""
+
+ def __init__(self, msg_id: MessageId, sig_name: str, sig_color: QColor, parent: QListWidget):
+ super().__init__(parent)
+ self.msg_id = msg_id
+ self.sig_name = sig_name
+ self.sig_color = sig_color
+
+ def __init__(self, title: str, dbc, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+ self.setWindowTitle(title)
+ self.dbc = dbc
+ self._setup_ui()
+
+ def _setup_ui(self):
+ """Setup dialog UI."""
+ layout = QGridLayout(self)
+
+ # Left column - available signals
+ layout.addWidget(QLabel("Available Signals"), 0, 0)
+
+ self.msgs_combo = QComboBox()
+ self.msgs_combo.setEditable(True)
+ self.msgs_combo.lineEdit().setPlaceholderText("Select a message...")
+ self.msgs_combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
+ self.msgs_combo.currentIndexChanged.connect(self._update_available_list)
+ layout.addWidget(self.msgs_combo, 1, 0)
+
+ self.available_list = QListWidget()
+ self.available_list.itemDoubleClicked.connect(self._add_signal)
+ layout.addWidget(self.available_list, 2, 0)
+
+ # Middle column - buttons
+ btn_layout = QVBoxLayout()
+ btn_layout.addStretch()
+
+ self.add_btn = QPushButton("→")
+ self.add_btn.setEnabled(False)
+ self.add_btn.clicked.connect(lambda: self._add_signal(self.available_list.currentItem()))
+ btn_layout.addWidget(self.add_btn)
+
+ self.remove_btn = QPushButton("←")
+ self.remove_btn.setEnabled(False)
+ self.remove_btn.clicked.connect(lambda: self._remove_signal(self.selected_list.currentItem()))
+ btn_layout.addWidget(self.remove_btn)
+
+ btn_layout.addStretch()
+ layout.addLayout(btn_layout, 0, 1, 3, 1)
+
+ # Right column - selected signals
+ layout.addWidget(QLabel("Selected Signals"), 0, 2)
+
+ self.selected_list = QListWidget()
+ self.selected_list.itemDoubleClicked.connect(self._remove_signal)
+ layout.addWidget(self.selected_list, 1, 2, 2, 1)
+
+ # Button box
+ button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+ layout.addWidget(button_box, 3, 2)
+
+ # Connect signals
+ self.available_list.currentRowChanged.connect(lambda row: self.add_btn.setEnabled(row != -1))
+ self.selected_list.currentRowChanged.connect(lambda row: self.remove_btn.setEnabled(row != -1))
+
+ def populate(self, messages: list[MessageId]):
+ """Populate message combo with available messages."""
+ self.msgs_combo.clear()
+ for msg_id in sorted(messages, key=lambda m: (m.source, m.address)):
+ msg = self.dbc.msg(msg_id)
+ if msg and msg.sigs:
+ name = f"{msg.name} (0x{msg_id.address:X})"
+ self.msgs_combo.addItem(name, msg_id)
+
+ def _update_available_list(self, index: int):
+ """Update available signals list based on selected message."""
+ self.available_list.clear()
+ if index < 0:
+ return
+
+ msg_id = self.msgs_combo.itemData(index)
+ if not msg_id:
+ return
+
+ msg = self.dbc.msg(msg_id)
+ if not msg:
+ return
+
+ selected = self.selected_items()
+ for sig_name, sig_info in msg.sigs.items():
+ is_selected = any(item.msg_id == msg_id and item.sig_name == sig_name for item in selected)
+ if not is_selected:
+ self._add_item_to_list(self.available_list, msg_id, sig_name, sig_info.get('color', QColor(100, 100, 100)), False)
+
+ def _add_signal(self, item: Optional[QListWidgetItem]):
+ """Add signal from available to selected."""
+ if not item:
+ return
+
+ list_item = item
+ if isinstance(list_item, self.ListItem):
+ self._add_item_to_list(self.selected_list, list_item.msg_id, list_item.sig_name, list_item.sig_color, True)
+ row = self.available_list.row(item)
+ self.available_list.takeItem(row)
+
+ def _remove_signal(self, item: Optional[QListWidgetItem]):
+ """Remove signal from selected."""
+ if not item:
+ return
+
+ list_item = item
+ if isinstance(list_item, self.ListItem):
+ if list_item.msg_id == self.msgs_combo.currentData():
+ msg = self.dbc.msg(list_item.msg_id)
+ if msg:
+ self._add_item_to_list(self.available_list, list_item.msg_id, list_item.sig_name, list_item.sig_color, False)
+
+ row = self.selected_list.row(item)
+ self.selected_list.takeItem(row)
+
+ def _add_item_to_list(self, parent: QListWidget, msg_id: MessageId, sig_name: str, color: QColor, show_msg_name: bool):
+ """Add item to list widget."""
+ text = f"■ {sig_name}"
+ if show_msg_name:
+ msg = self.dbc.msg(msg_id)
+ msg_name = msg.name if msg else "Unknown"
+ text += f" {msg_name} 0x{msg_id.address:X}"
+
+ label = QLabel(text)
+ label.setContentsMargins(5, 0, 5, 0)
+ item = self.ListItem(msg_id, sig_name, color, parent)
+ item.setSizeHint(label.sizeHint())
+ parent.setItemWidget(item, label)
+
+ def add_selected(self, msg_id: MessageId, sig_name: str, color: QColor):
+ """Pre-populate selected list with signal."""
+ self._add_item_to_list(self.selected_list, msg_id, sig_name, color, True)
+
+ def selected_items(self) -> list[ListItem]:
+ """Get list of selected signals."""
+ items = []
+ for i in range(self.selected_list.count()):
+ item = self.selected_list.item(i)
+ if isinstance(item, self.ListItem):
+ items.append(item)
+ return items
+
+
+class ChartView(QFrame):
+ """Single chart view displaying one or more signals."""
+
+ axisYLabelWidthChanged = Signal(int)
+ removeRequested = Signal(object)
+
+ def __init__(self, x_range: tuple[float, float], dbc, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+ self.dbc = dbc
+ self.signals: list[SignalData] = []
+ self.axis_x_range = x_range
+ self.axis_y_range = (0.0, 1.0)
+ self.current_sec = 0.0
+ self.series_type = SeriesType.LINE
+ self.tooltip_x = -1.0
+ self.y_label_width = 0
+ self.align_to = 0
+ self.is_scrubbing = False
+ self.tip_label = TipLabel(self)
+
+ self.setFrameShape(QFrame.Shape.StyledPanel)
+ self.setMinimumHeight(CHART_HEIGHT)
+ self.setMinimumWidth(CHART_MIN_WIDTH)
+ self.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Fixed)
+ self.setMouseTracking(True)
+
+ self._setup_ui()
+
+ def _setup_ui(self):
+ """Setup chart UI."""
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(2, 2, 2, 2)
+
+ # Header with controls
+ header = QHBoxLayout()
+ header.setContentsMargins(4, 4, 4, 2)
+
+ self.title_label = QLabel()
+ self.title_label.setTextFormat(Qt.TextFormat.RichText)
+ header.addWidget(self.title_label)
+ header.addStretch()
+
+ # Menu button
+ self.menu_btn = QToolButton()
+ self.menu_btn.setText("☰")
+ self.menu_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ menu = QMenu()
+
+ # Series type submenu
+ series_menu = menu.addMenu("Series Type")
+ self.line_action = series_menu.addAction("Line", lambda: self.set_series_type(SeriesType.LINE))
+ self.line_action.setCheckable(True)
+ self.line_action.setChecked(True)
+ self.step_action = series_menu.addAction("Step Line", lambda: self.set_series_type(SeriesType.STEP_LINE))
+ self.step_action.setCheckable(True)
+ self.scatter_action = series_menu.addAction("Scatter", lambda: self.set_series_type(SeriesType.SCATTER))
+ self.scatter_action.setCheckable(True)
+
+ menu.addSeparator()
+ menu.addAction("Manage Signals", self._manage_signals)
+ menu.addSeparator()
+
+ self.close_action = menu.addAction("Remove Chart", lambda: self.removeRequested.emit(self))
+ self.menu_btn.setMenu(menu)
+ header.addWidget(self.menu_btn)
+
+ # Close button
+ self.close_btn = QPushButton("×")
+ self.close_btn.setFixedSize(20, 20)
+ self.close_btn.clicked.connect(lambda: self.removeRequested.emit(self))
+ header.addWidget(self.close_btn)
+
+ layout.addLayout(header)
+
+ # Chart canvas
+ self.canvas = ChartCanvas(self)
+ layout.addWidget(self.canvas, 1)
+
+ def add_signal(self, msg_id: MessageId, sig_name: str, color: QColor):
+ """Add a signal to this chart."""
+ if self.has_signal(msg_id, sig_name):
+ return
+
+ signal_data = SignalData(
+ msg_id=msg_id,
+ signal_name=sig_name,
+ color=color,
+ values=[],
+ step_values=[],
+ )
+ self.signals.append(signal_data)
+ self._update_title()
+
+ def has_signal(self, msg_id: MessageId, sig_name: str) -> bool:
+ """Check if signal is in this chart."""
+ return any(s.msg_id == msg_id and s.signal_name == sig_name for s in self.signals)
+
+ def remove_signal(self, msg_id: MessageId, sig_name: str):
+ """Remove a signal from this chart."""
+ self.signals = [s for s in self.signals if not (s.msg_id == msg_id and s.signal_name == sig_name)]
+ if not self.signals:
+ self.removeRequested.emit(self)
+ else:
+ self._update_title()
+ self._update_axis_y()
+
+ def update_series(self, events: dict[MessageId, list]):
+ """Update signal data from events."""
+ for signal in self.signals:
+ if signal.msg_id not in events:
+ continue
+
+ msg_events = events[signal.msg_id]
+ msg = self.dbc.msg(signal.msg_id)
+ if not msg or signal.signal_name not in msg.sigs:
+ continue
+
+ sig_info = msg.sigs[signal.signal_name]
+
+ # Clear old data
+ signal.values = []
+ signal.step_values = []
+
+ # Extract signal values from events
+ from openpilot.tools.cabana.pycabana.dbc.dbc import decode_signal
+ for event in msg_events:
+ try:
+ value = decode_signal(sig_info, event.dat)
+ signal.values.append((event.ts, value))
+
+ # For step line, add horizontal segments
+ if signal.step_values:
+ signal.step_values.append((event.ts, signal.step_values[-1][1]))
+ signal.step_values.append((event.ts, value))
+ except Exception:
+ pass
+
+ self._update_axis_y()
+ self.canvas.update()
+
+ def update_plot(self, cur_sec: float, min_sec: float, max_sec: float):
+ """Update plot with new time range."""
+ self.current_sec = cur_sec
+ if min_sec != self.axis_x_range[0] or max_sec != self.axis_x_range[1]:
+ self.axis_x_range = (min_sec, max_sec)
+ self._update_axis_y()
+ self.canvas.update()
+
+ def show_tip(self, sec: float):
+ """Show tooltip at given time."""
+ self.tooltip_x = self._map_to_position_x(sec)
+
+ text_list = [f"{sec:.3f}s"]
+
+ for signal in self.signals:
+ if not signal.values:
+ continue
+
+ # Find value at this time (binary search)
+ idx = bisect_left([v[0] for v in signal.values], sec)
+ if idx > 0:
+ idx -= 1
+
+ if idx < len(signal.values) and signal.values[idx][0] >= self.axis_x_range[0]:
+ t, v = signal.values[idx]
+ signal.track_point = (t, v)
+ value_str = f"{v:.2f}"
+ else:
+ signal.track_point = None
+ value_str = "--"
+
+ min_str = f"{signal.min_val:.2f}" if signal.min_val != float('inf') else "--"
+ max_str = f"{signal.max_val:.2f}" if signal.max_val != float('-inf') else "--"
+
+ text_list.append(
+ f"■ "
+ + f"{signal.signal_name}: {value_str} ({min_str}, {max_str})"
+ )
+
+ text = "" + "
".join(text_list) + "
"
+ pt = QPoint(int(self.tooltip_x), self.canvas.geometry().top())
+ visible_rect = self.canvas.geometry()
+ self.tip_label.showText(pt, text, self, visible_rect)
+ self.canvas.update()
+
+ def hide_tip(self):
+ """Hide tooltip."""
+ self.tooltip_x = -1.0
+ for signal in self.signals:
+ signal.track_point = None
+ self.tip_label.hide()
+ self.canvas.update()
+
+ def set_series_type(self, series_type: int):
+ """Change series rendering type."""
+ self.series_type = series_type
+ self.line_action.setChecked(series_type == SeriesType.LINE)
+ self.step_action.setChecked(series_type == SeriesType.STEP_LINE)
+ self.scatter_action.setChecked(series_type == SeriesType.SCATTER)
+ self.canvas.update()
+
+ def update_plot_area(self, left_pos: int, force: bool = False):
+ """Update plot area alignment."""
+ if self.align_to != left_pos or force:
+ self.align_to = left_pos
+ self._update_axis_y()
+ self.canvas.update()
+
+ def _manage_signals(self):
+ """Show signal management dialog."""
+
+ dlg = SignalSelector("Manage Chart", self.dbc, self)
+
+ # Populate with available messages (would need events from parent)
+ # For now, just show current signals
+ for signal in self.signals:
+ dlg.add_selected(signal.msg_id, signal.signal_name, signal.color)
+
+ if dlg.exec() == QDialog.DialogCode.Accepted:
+ selected = dlg.selected_items()
+
+ # Add new signals
+ for item in selected:
+ if not self.has_signal(item.msg_id, item.sig_name):
+ self.add_signal(item.msg_id, item.sig_name, item.sig_color)
+
+ # Remove unselected signals
+ to_remove = []
+ for signal in self.signals:
+ if not any(item.msg_id == signal.msg_id and item.sig_name == signal.signal_name for item in selected):
+ to_remove.append((signal.msg_id, signal.signal_name))
+
+ for msg_id, sig_name in to_remove:
+ self.remove_signal(msg_id, sig_name)
+
+ def _update_title(self):
+ """Update chart title with signal names."""
+ if not self.signals:
+ self.title_label.setText("")
+ return
+
+ parts = []
+ for signal in self.signals:
+ msg = self.dbc.msg(signal.msg_id)
+ msg_name = msg.name if msg else "Unknown"
+ parts.append(
+ f"{signal.signal_name} "
+ + f"{msg_name}"
+ )
+
+ self.title_label.setText(" | ".join(parts))
+
+ def _update_axis_y(self):
+ """Update Y-axis range based on visible data."""
+ if not self.signals:
+ return
+
+ min_val = float('inf')
+ max_val = float('-inf')
+
+ for signal in self.signals:
+ signal.min_val = float('inf')
+ signal.max_val = float('-inf')
+
+ # Find values in current time range
+ for t, v in signal.values:
+ if self.axis_x_range[0] <= t <= self.axis_x_range[1]:
+ signal.min_val = min(signal.min_val, v)
+ signal.max_val = max(signal.max_val, v)
+
+ if signal.min_val != float('inf'):
+ min_val = min(min_val, signal.min_val)
+ if signal.max_val != float('-inf'):
+ max_val = max(max_val, signal.max_val)
+
+ if min_val == float('inf'):
+ min_val = 0.0
+ if max_val == float('-inf'):
+ max_val = 0.0
+
+ # Add padding
+ delta = abs(max_val - min_val) * 0.05 if abs(max_val - min_val) >= 1e-3 else 1.0
+ min_y, max_y, tick_count = self._get_nice_axis_numbers(min_val - delta, max_val + delta, 3)
+ self.axis_y_range = (min_y, max_y)
+
+ # Calculate label width
+ font_metrics = QFontMetrics(QFont("monospace", 8))
+ max_label_width = 0
+ for i in range(tick_count):
+ value = min_y + (i * (max_y - min_y) / (tick_count - 1))
+ label = f"{value:.2f}"
+ max_label_width = max(max_label_width, font_metrics.horizontalAdvance(label))
+
+ new_width = max_label_width + 15
+ if self.y_label_width != new_width:
+ self.y_label_width = new_width
+ self.axisYLabelWidthChanged.emit(new_width)
+
+ def _get_nice_axis_numbers(self, min_val: float, max_val: float, tick_count: int) -> tuple[float, float, int]:
+ """Calculate nice round numbers for axis labels."""
+ def nice_number(x: float, ceiling: bool) -> float:
+ exp = math.floor(math.log10(x)) if x > 0 else 0
+ z = 10 ** exp
+ q = x / z if z != 0 else x
+
+ if ceiling:
+ if q <= 1.0:
+ q = 1
+ elif q <= 2.0:
+ q = 2
+ elif q <= 5.0:
+ q = 5
+ else:
+ q = 10
+ else:
+ if q < 1.5:
+ q = 1
+ elif q < 3.0:
+ q = 2
+ elif q < 7.0:
+ q = 5
+ else:
+ q = 10
+
+ return q * z
+
+ range_val = nice_number(max_val - min_val, True)
+ step = nice_number(range_val / (tick_count - 1), False)
+ min_val = math.floor(min_val / step) if step != 0 else min_val
+ max_val = math.ceil(max_val / step) if step != 0 else max_val
+ tick_count = int(max_val - min_val) + 1 if step != 0 else tick_count
+
+ return (min_val * step, max_val * step, tick_count)
+
+ def _map_to_position_x(self, sec: float) -> float:
+ """Map time value to x pixel position."""
+ if self.axis_x_range[1] == self.axis_x_range[0]:
+ return 0.0
+
+ chart_rect = self.canvas.chart_rect()
+ ratio = (sec - self.axis_x_range[0]) / (self.axis_x_range[1] - self.axis_x_range[0])
+ return chart_rect.left() + ratio * chart_rect.width()
+
+ def _map_from_position_x(self, x: float) -> float:
+ """Map x pixel position to time value."""
+ chart_rect = self.canvas.chart_rect()
+ if chart_rect.width() == 0:
+ return self.axis_x_range[0]
+
+ ratio = (x - chart_rect.left()) / chart_rect.width()
+ return self.axis_x_range[0] + ratio * (self.axis_x_range[1] - self.axis_x_range[0])
+
+ def mouseMoveEvent(self, event: QMouseEvent):
+ """Handle mouse move for tooltip."""
+ chart_rect = self.canvas.chart_rect()
+ if chart_rect.contains(event.pos()):
+ sec = self._map_from_position_x(event.pos().x())
+ self.show_tip(sec)
+ elif self.tip_label.isVisible():
+ self.hide_tip()
+
+ super().mouseMoveEvent(event)
+
+ def leaveEvent(self, event):
+ """Hide tooltip when mouse leaves."""
+ self.hide_tip()
+ super().leaveEvent(event)
+
+
+class ChartCanvas(QWidget):
+ """Canvas widget for rendering chart contents."""
+
+ def __init__(self, chart_view: ChartView):
+ super().__init__()
+ self.chart_view = chart_view
+ self.setMinimumHeight(100)
+
+ def chart_rect(self) -> QRectF:
+ """Get the chart plotting area."""
+ margin_left = max(self.chart_view.align_to, 50)
+ margin_right = 30
+ margin_top = 20
+ margin_bottom = 30
+
+ return QRectF(
+ margin_left,
+ margin_top,
+ self.width() - margin_left - margin_right,
+ self.height() - margin_top - margin_bottom
+ )
+
+ def paintEvent(self, event):
+ """Paint the chart."""
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+
+ rect = self.rect()
+ chart_rect = self.chart_rect()
+
+ # Background
+ painter.fillRect(rect, self.palette().color(QPalette.ColorRole.Base))
+
+ # Draw grid
+ self._draw_grid(painter, chart_rect)
+
+ # Draw axes
+ self._draw_axes(painter, chart_rect)
+
+ # Draw data series
+ self._draw_series(painter, chart_rect)
+
+ # Draw timeline cursor
+ self._draw_timeline(painter, chart_rect)
+
+ # Draw track points
+ self._draw_track_points(painter, chart_rect)
+
+ def _draw_grid(self, painter: QPainter, rect: QRectF):
+ """Draw grid lines."""
+ painter.setPen(QPen(QColor(60, 60, 60), 1))
+
+ # Horizontal grid lines
+ for i in range(5):
+ y = rect.top() + rect.height() * i / 4
+ painter.drawLine(int(rect.left()), int(y), int(rect.right()), int(y))
+
+ def _draw_axes(self, painter: QPainter, rect: QRectF):
+ """Draw axis labels."""
+ painter.setPen(self.palette().color(QPalette.ColorRole.Text))
+ font = QFont("monospace", 8)
+ painter.setFont(font)
+ font_metrics = QFontMetrics(font)
+
+ # Y-axis labels
+ min_y, max_y = self.chart_view.axis_y_range
+ for i in range(5):
+ value = max_y - (max_y - min_y) * i / 4
+ y = rect.top() + rect.height() * i / 4
+ label = f"{value:.2f}"
+ label_width = self.chart_view.y_label_width - 10
+ painter.drawText(
+ int(rect.left() - label_width - 5),
+ int(y - 6),
+ label_width,
+ 12,
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
+ label
+ )
+
+ # X-axis labels
+ min_x, max_x = self.chart_view.axis_x_range
+ for i in range(5):
+ value = min_x + (max_x - min_x) * i / 4
+ x = rect.left() + rect.width() * i / 4
+ label = f"{value:.1f}"
+ label_width = font_metrics.horizontalAdvance(label)
+ painter.drawText(
+ int(x - label_width / 2),
+ int(rect.bottom() + 5),
+ label_width,
+ 15,
+ Qt.AlignmentFlag.AlignCenter,
+ label
+ )
+
+ def _draw_series(self, painter: QPainter, rect: QRectF):
+ """Draw signal data series."""
+ min_x, max_x = self.chart_view.axis_x_range
+ min_y, max_y = self.chart_view.axis_y_range
+
+ if max_x == min_x or max_y == min_y:
+ return
+
+ for signal in self.chart_view.signals:
+ values = signal.step_values if self.chart_view.series_type == SeriesType.STEP_LINE else signal.values
+
+ if not values:
+ continue
+
+ # Filter to visible range
+ visible_values = [(t, v) for t, v in values if min_x <= t <= max_x]
+
+ if not visible_values:
+ continue
+
+ # Map to screen coordinates
+ points = []
+ for t, v in visible_values:
+ x = rect.left() + (t - min_x) / (max_x - min_x) * rect.width()
+ y = rect.bottom() - (v - min_y) / (max_y - min_y) * rect.height()
+ points.append(QPointF(x, y))
+
+ # Draw based on series type
+ if self.chart_view.series_type == SeriesType.SCATTER:
+ painter.setPen(Qt.PenStyle.NoPen)
+ painter.setBrush(QBrush(signal.color))
+ for pt in points:
+ painter.drawEllipse(pt, 4, 4)
+ else:
+ painter.setPen(QPen(signal.color, 2))
+ if len(points) > 1:
+ path = QPainterPath()
+ path.moveTo(points[0])
+ for pt in points[1:]:
+ path.lineTo(pt)
+ painter.drawPath(path)
+
+ def _draw_timeline(self, painter: QPainter, rect: QRectF):
+ """Draw current time cursor."""
+ min_x, max_x = self.chart_view.axis_x_range
+ if max_x == min_x:
+ return
+
+ cur_sec = self.chart_view.current_sec
+ x = rect.left() + (cur_sec - min_x) / (max_x - min_x) * rect.width()
+
+ if rect.left() <= x <= rect.right():
+ painter.setPen(QPen(QColor(255, 255, 255, 200), 1))
+ painter.drawLine(int(x), int(rect.top()), int(x), int(rect.bottom()))
+
+ # Draw time label
+ time_str = f"{cur_sec:.2f}"
+ font_metrics = QFontMetrics(painter.font())
+ time_str_width = font_metrics.horizontalAdvance(time_str) + 8
+ time_rect = QRect(int(x - time_str_width / 2), int(rect.bottom() + 5), time_str_width, 20)
+
+ painter.fillRect(time_rect, QColor(100, 100, 100))
+ painter.setPen(QColor(255, 255, 255))
+ painter.drawText(time_rect, Qt.AlignmentFlag.AlignCenter, time_str)
+
+ def _draw_track_points(self, painter: QPainter, rect: QRectF):
+ """Draw track points at tooltip location."""
+ min_x, max_x = self.chart_view.axis_x_range
+ min_y, max_y = self.chart_view.axis_y_range
+
+ if max_x == min_x or max_y == min_y:
+ return
+
+ painter.setPen(Qt.PenStyle.NoPen)
+
+ track_line_x = -1.0
+ for signal in self.chart_view.signals:
+ if signal.track_point:
+ t, v = signal.track_point
+ x = rect.left() + (t - min_x) / (max_x - min_x) * rect.width()
+ y = rect.bottom() - (v - min_y) / (max_y - min_y) * rect.height()
+
+ painter.setBrush(QBrush(signal.color.darker(125)))
+ painter.drawEllipse(QPointF(x, y), 5, 5)
+ track_line_x = max(track_line_x, x)
+
+ if track_line_x > 0:
+ painter.setPen(QPen(QColor(100, 100, 100), 1, Qt.PenStyle.DashLine))
+ painter.drawLine(int(track_line_x), int(rect.top()), int(track_line_x), int(rect.bottom()))
+
+
+class ChartsWidget(QFrame):
+ """Container for multiple chart views."""
+
+ def __init__(self, dbc=None, parent: Optional[QWidget] = None):
+ super().__init__(parent)
+ self.dbc = dbc if dbc is not None else dbc_manager()
+ self.charts: list[ChartView] = []
+ self.display_range = (0.0, 30.0)
+ self.max_chart_range = 30
+ self.current_sec = 0.0
+ self._align_timer = QTimer()
+ self._align_timer.setSingleShot(True)
+ self._align_timer.timeout.connect(self._align_charts)
+
+ self.setFrameShape(QFrame.Shape.StyledPanel)
+ self._setup_ui()
+
+ def _setup_ui(self):
+ """Setup main widget UI."""
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(4, 4, 4, 4)
+ layout.setSpacing(4)
+
+ # Toolbar
+ toolbar = QHBoxLayout()
+
+ self.new_chart_btn = QPushButton("New Chart")
+ self.new_chart_btn.clicked.connect(self._new_chart)
+ toolbar.addWidget(self.new_chart_btn)
+
+ self.title_label = QLabel("Charts: 0")
+ toolbar.addWidget(self.title_label)
+ toolbar.addStretch()
+
+ self.remove_all_btn = QPushButton("Remove All")
+ self.remove_all_btn.clicked.connect(self.remove_all)
+ self.remove_all_btn.setEnabled(False)
+ toolbar.addWidget(self.remove_all_btn)
+
+ layout.addLayout(toolbar)
+
+ # Scroll area for charts
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
+
+ self.charts_container = QWidget()
+ self.charts_layout = QVBoxLayout(self.charts_container)
+ self.charts_layout.setContentsMargins(0, 0, 0, 0)
+ self.charts_layout.setSpacing(CHART_SPACING)
+ self.charts_layout.addStretch()
+
+ scroll.setWidget(self.charts_container)
+ layout.addWidget(scroll, 1)
+
+ def create_chart(self) -> ChartView:
+ """Create a new chart view."""
+ chart = ChartView(self.display_range, self.dbc, self)
+ chart.removeRequested.connect(self._remove_chart)
+ chart.axisYLabelWidthChanged.connect(lambda: self._align_timer.start(10))
+
+ self.charts.append(chart)
+ self.charts_layout.insertWidget(len(self.charts) - 1, chart)
+
+ self._update_toolbar()
+ return chart
+
+ def show_chart(self, msg_id: MessageId, sig_name: str, color: QColor, merge: bool = False):
+ """Show a chart for the given signal."""
+ # Check if signal already exists
+ for chart in self.charts:
+ if chart.has_signal(msg_id, sig_name):
+ return
+
+ # Create or reuse chart
+ if merge and self.charts:
+ chart = self.charts[0]
+ else:
+ chart = self.create_chart()
+
+ chart.add_signal(msg_id, sig_name, color)
+
+ def _new_chart(self):
+ """Show dialog to create new chart."""
+ dlg = SignalSelector("New Chart", self.dbc, self)
+
+ # Get available messages from events (would need to be passed from parent)
+ # For now, just open empty dialog
+
+ if dlg.exec() == QDialog.DialogCode.Accepted:
+ selected = dlg.selected_items()
+ if selected:
+ chart = self.create_chart()
+ for item in selected:
+ chart.add_signal(item.msg_id, item.sig_name, item.sig_color)
+
+ def _remove_chart(self, chart: ChartView):
+ """Remove a chart."""
+ if chart in self.charts:
+ self.charts.remove(chart)
+ self.charts_layout.removeWidget(chart)
+ chart.deleteLater()
+ self._update_toolbar()
+ self._align_charts()
+
+ def remove_all(self):
+ """Remove all charts."""
+ for chart in list(self.charts):
+ self.charts_layout.removeWidget(chart)
+ chart.deleteLater()
+ self.charts.clear()
+ self._update_toolbar()
+
+ def update_events(self, events: dict[MessageId, list]):
+ """Update all charts with new events."""
+ for chart in self.charts:
+ chart.update_series(events)
+
+ def update_state(self, current_sec: float, min_sec: float, max_sec: float):
+ """Update all charts with new time range."""
+ self.current_sec = current_sec
+ self.display_range = (min_sec, max_sec)
+
+ for chart in self.charts:
+ chart.update_plot(current_sec, min_sec, max_sec)
+
+ def _update_toolbar(self):
+ """Update toolbar state."""
+ self.title_label.setText(f"Charts: {len(self.charts)}")
+ self.remove_all_btn.setEnabled(len(self.charts) > 0)
+
+ def _align_charts(self):
+ """Align all charts' Y-axis labels."""
+ if not self.charts:
+ return
+
+ max_width = max(chart.y_label_width for chart in self.charts)
+ max_width = max((max_width // 10) * 10 + 10, 50)
+
+ for chart in self.charts:
+ chart.update_plot_area(max_width)
+
+ def setEvents(self, events: list):
+ """Set CAN events for all charts.
+
+ This converts the flat event list to a per-message dict for update_events.
+ """
+ if not events:
+ return
+
+ # Group events by message ID
+ events_by_msg: dict[MessageId, list] = {}
+ for event in events:
+ msg_id = event.msg_id
+ if msg_id not in events_by_msg:
+ events_by_msg[msg_id] = []
+ events_by_msg[msg_id].append(event)
+
+ self._events = events_by_msg
+ self.update_events(events_by_msg)
+
+ def setCurrentTime(self, time_sec: float):
+ """Set the current playback time and update chart display range."""
+ # Use a window around the current time
+ half_range = self.max_chart_range / 2
+ min_sec = max(0, time_sec - half_range)
+ max_sec = time_sec + half_range
+
+ self.update_state(time_sec, min_sec, max_sec)
diff --git a/tools/cabana/pycabana/widgets/detail.py b/tools/cabana/pycabana/widgets/detail.py
new file mode 100644
index 00000000000000..7d26aedf38a1fd
--- /dev/null
+++ b/tools/cabana/pycabana/widgets/detail.py
@@ -0,0 +1,523 @@
+"""DetailWidget - displays detailed information about a selected CAN message."""
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QLabel,
+ QTabWidget,
+ QSplitter,
+ QToolBar,
+ QRadioButton,
+ QTabBar,
+ QMenu,
+ QSizePolicy,
+)
+from PySide6.QtGui import QPainter, QAction
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId
+from openpilot.tools.cabana.pycabana.dbc.dbcmanager import dbc_manager
+from openpilot.tools.cabana.pycabana.widgets.binary import BinaryView
+from openpilot.tools.cabana.pycabana.widgets.history import HistoryLogWidget
+from openpilot.tools.cabana.pycabana.widgets.signal import SignalView
+from openpilot.tools.cabana.pycabana.streams.abstract import AbstractStream
+
+
+class ElidedLabel(QLabel):
+ """Label that elides text with ellipsis if too long."""
+
+ def __init__(self, text: str = "", parent: QWidget | None = None):
+ super().__init__(text, parent)
+ self.setTextFormat(Qt.TextFormat.PlainText)
+
+ def paintEvent(self, event):
+ painter = QPainter(self)
+ metrics = painter.fontMetrics()
+ elided = metrics.elidedText(self.text(), Qt.TextElideMode.ElideRight, self.width())
+ painter.drawText(self.rect(), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, elided)
+
+
+class TabBar(QTabBar):
+ """Custom tab bar that can be hidden when empty."""
+
+ def __init__(self, parent: QWidget | None = None):
+ super().__init__(parent)
+ self._auto_hide = False
+ self.setTabsClosable(True)
+
+ def setAutoHide(self, enable: bool):
+ """Enable auto-hide when no tabs."""
+ self._auto_hide = enable
+ self._updateVisibility()
+
+ def tabInserted(self, index: int):
+ super().tabInserted(index)
+ self._updateVisibility()
+
+ def tabRemoved(self, index: int):
+ super().tabRemoved(index)
+ self._updateVisibility()
+
+ def _updateVisibility(self):
+ if self._auto_hide:
+ self.setVisible(self.count() > 0)
+
+
+class DetailWidget(QWidget):
+ """Main detail widget showing message information across multiple tabs."""
+
+ def __init__(self, stream: AbstractStream, parent: QWidget | None = None):
+ super().__init__(parent)
+ self._stream = stream
+ self._msg_id: MessageId | None = None
+
+ self._setup_ui()
+ self._connect_signals()
+
+ def _setup_ui(self):
+ """Set up the user interface."""
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+
+ # Tab bar for multiple messages
+ self._tabbar = TabBar(self)
+ self._tabbar.setUsesScrollButtons(True)
+ self._tabbar.setAutoHide(True)
+ self._tabbar.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
+ main_layout.addWidget(self._tabbar)
+
+ # Toolbar
+ self._create_toolbar()
+ main_layout.addWidget(self._toolbar)
+
+ # Warning widget
+ self._warning_widget = QWidget(self)
+ warning_layout = QHBoxLayout(self._warning_widget)
+ warning_layout.setContentsMargins(8, 4, 8, 4)
+
+ self._warning_icon = QLabel(self._warning_widget)
+ self._warning_label = QLabel(self._warning_widget)
+ self._warning_label.setWordWrap(True)
+
+ warning_layout.addWidget(self._warning_icon, 0, Qt.AlignmentFlag.AlignTop)
+ warning_layout.addWidget(self._warning_label, 1)
+
+ self._warning_widget.hide()
+ self._warning_widget.setStyleSheet("background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px;")
+ main_layout.addWidget(self._warning_widget)
+
+ # Main content splitter
+ splitter = QSplitter(Qt.Orientation.Vertical, self)
+
+ self._binary_view = BinaryView(self)
+ self._signal_view = SignalView(self)
+
+ splitter.addWidget(self._binary_view)
+ splitter.addWidget(self._signal_view)
+
+ self._binary_view.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
+ self._signal_view.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
+
+ splitter.setStretchFactor(0, 0)
+ splitter.setStretchFactor(1, 1)
+
+ # Tab widget for Msg/Logs
+ self._tab_widget = QTabWidget(self)
+ self._tab_widget.setStyleSheet("QTabWidget::pane {border: none; margin-bottom: -2px;}")
+ self._tab_widget.setTabPosition(QTabWidget.TabPosition.South)
+
+ self._tab_widget.addTab(splitter, "&Msg")
+
+ self._history_log = HistoryLogWidget(self._stream, self)
+ self._tab_widget.addTab(self._history_log, "&Logs")
+
+ main_layout.addWidget(self._tab_widget)
+
+ def _create_toolbar(self):
+ """Create the toolbar with message name and controls."""
+ self._toolbar = QToolBar(self)
+
+ # Message name label
+ self._name_label = ElidedLabel("", self)
+ self._name_label.setStyleSheet("QLabel {font-weight: bold; padding: 4px;}")
+ self._toolbar.addWidget(self._name_label)
+
+ # Spacer
+ spacer = QWidget()
+ spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ self._toolbar.addWidget(spacer)
+
+ # Heatmap controls
+ self._toolbar.addWidget(QLabel("Heatmap:", self))
+ self._heatmap_live = QRadioButton("Live", self)
+ self._heatmap_all = QRadioButton("All", self)
+ self._heatmap_live.setChecked(True)
+
+ self._toolbar.addWidget(self._heatmap_live)
+ self._toolbar.addWidget(self._heatmap_all)
+
+ # Edit and remove buttons
+ self._toolbar.addSeparator()
+
+ self._edit_action = QAction("Edit Message", self)
+ self._edit_action.triggered.connect(self._edit_msg)
+ self._toolbar.addAction(self._edit_action)
+
+ self._remove_action = QAction("Remove Message", self)
+ self._remove_action.triggered.connect(self._remove_msg)
+ self._remove_action.setEnabled(False)
+ self._toolbar.addAction(self._remove_action)
+
+ def _connect_signals(self):
+ """Connect internal signals."""
+ # Tab bar signals
+ self._tabbar.currentChanged.connect(self._on_tab_changed)
+ self._tabbar.tabCloseRequested.connect(self._tabbar.removeTab)
+ self._tabbar.customContextMenuRequested.connect(self._show_tabbar_context_menu)
+
+ # Tab widget signals
+ self._tab_widget.currentChanged.connect(self._on_content_tab_changed)
+
+ # Stream signals
+ self._stream.msgsReceived.connect(self._on_msgs_received)
+
+ # DBC manager signals
+ dbc_manager().dbcLoaded.connect(self._on_dbc_loaded)
+
+ def _on_tab_changed(self, index: int):
+ """Handle tab bar selection change."""
+ if index >= 0:
+ msg_id = self._tabbar.tabData(index)
+ if isinstance(msg_id, MessageId):
+ self.setMessage(msg_id)
+
+ def _on_content_tab_changed(self, index: int):
+ """Handle content tab change (Msg/Logs)."""
+ self._update_state()
+
+ def _on_msgs_received(self, msg_ids: set[MessageId], has_new: bool):
+ """Handle new messages from stream."""
+ if self._msg_id and self._msg_id in msg_ids:
+ self._update_state()
+
+ def _on_dbc_loaded(self, dbc_name: str):
+ """Handle DBC file loaded."""
+ self.refresh()
+
+ def _show_tabbar_context_menu(self, pos):
+ """Show context menu on tab bar."""
+ index = self._tabbar.tabAt(pos)
+ if index >= 0:
+ menu = QMenu(self)
+ menu.addAction("Close Other Tabs")
+ if menu.exec(self._tabbar.mapToGlobal(pos)):
+ # Move selected tab to front
+ self._tabbar.moveTab(index, 0)
+ self._tabbar.setCurrentIndex(0)
+ # Remove all other tabs
+ while self._tabbar.count() > 1:
+ self._tabbar.removeTab(1)
+
+ def _find_or_add_tab(self, msg_id: MessageId) -> int:
+ """Find existing tab or add new one for message."""
+ # Search for existing tab
+ for index in range(self._tabbar.count()):
+ tab_msg_id = self._tabbar.tabData(index)
+ if isinstance(tab_msg_id, MessageId) and tab_msg_id == msg_id:
+ return index
+
+ # Add new tab
+ index = self._tabbar.addTab(str(msg_id))
+ self._tabbar.setTabData(index, msg_id)
+ self._tabbar.setTabToolTip(index, self._get_msg_name(msg_id))
+ return index
+
+ def _get_msg_name(self, msg_id: MessageId) -> str:
+ """Get message name from DBC or return default."""
+ msg = dbc_manager().msg(msg_id)
+ if msg:
+ return msg.name
+ return f"0x{msg_id.address:X}"
+
+ def setMessage(self, msg_id: MessageId):
+ """Set the currently displayed message."""
+ if self._msg_id == msg_id:
+ return
+
+ self._msg_id = msg_id
+
+ # Update tab bar
+ self._tabbar.blockSignals(True)
+ index = self._find_or_add_tab(msg_id)
+ self._tabbar.setCurrentIndex(index)
+ self._tabbar.blockSignals(False)
+
+ # Update views
+ self.setUpdatesEnabled(False)
+
+ can_data = self._stream.lastMessage(msg_id)
+ self._binary_view.setMessage(msg_id, can_data)
+ self._signal_view.setMessage(msg_id, can_data)
+ self._history_log.setMessage(msg_id)
+
+ self.refresh()
+ self.setUpdatesEnabled(True)
+
+ def refresh(self):
+ """Refresh the display with current message state."""
+ if not self._msg_id:
+ return
+
+ warnings = []
+ msg = dbc_manager().msg(self._msg_id)
+
+ if msg:
+ can_data = self._stream.lastMessage(self._msg_id)
+ if not can_data:
+ warnings.append("No messages received.")
+ elif msg.size != len(can_data.dat):
+ warnings.append(f"Message size ({msg.size}) is incorrect.")
+
+ # Display message name
+ msg_name = f"{msg.name} (0x{self._msg_id.address:X})"
+ self._name_label.setText(msg_name)
+ self._name_label.setToolTip(msg_name)
+ self._remove_action.setEnabled(True)
+ else:
+ # No DBC definition
+ msg_name = f"0x{self._msg_id.address:X}"
+ self._name_label.setText(msg_name)
+ self._name_label.setToolTip(msg_name)
+ self._remove_action.setEnabled(False)
+ warnings.append("No DBC definition for this message.")
+
+ # Show warnings if any
+ if warnings:
+ self._warning_label.setText("\n".join(warnings))
+ self._warning_icon.setText("⚠")
+ self._warning_widget.show()
+ else:
+ self._warning_widget.hide()
+
+ def updateState(self):
+ """Update the display with latest data from stream."""
+ if not self._msg_id:
+ return
+
+ can_data = self._stream.lastMessage(self._msg_id)
+ self._binary_view.updateData(can_data)
+ self._signal_view.updateValues(can_data)
+
+ def _update_state(self):
+ """Update the current view based on tab selection."""
+ if not self._msg_id:
+ return
+
+ can_data = self._stream.lastMessage(self._msg_id)
+
+ # Update based on which tab is active
+ if self._tab_widget.currentIndex() == 0:
+ # Msg tab
+ self._binary_view.updateData(can_data)
+ self._signal_view.updateValues(can_data)
+ else:
+ # Logs tab
+ self._history_log.updateState()
+
+ def _edit_msg(self):
+ """Open edit message dialog."""
+ if not self._msg_id:
+ return
+ from PySide6.QtWidgets import QInputDialog, QMessageBox
+ msg = dbc_manager().msg(self._msg_id)
+ if not msg:
+ QMessageBox.warning(self, "Edit Message", "Message not found in DBC.")
+ return
+
+ # Simple edit dialog for message name
+ new_name, ok = QInputDialog.getText(
+ self, "Edit Message", "Message name:", text=msg.name
+ )
+ if ok and new_name and new_name != msg.name:
+ from openpilot.tools.cabana.pycabana.commands import EditMsgCommand, UndoStack
+ cmd = EditMsgCommand(
+ self._msg_id, new_name, msg.size,
+ getattr(msg, 'transmitter', ''),
+ getattr(msg, 'comment', '')
+ )
+ UndoStack.push(cmd)
+
+ def _remove_msg(self):
+ """Remove message from DBC."""
+ if not self._msg_id:
+ return
+ from PySide6.QtWidgets import QMessageBox
+ msg = dbc_manager().msg(self._msg_id)
+ msg_name = msg.name if msg else f"0x{self._msg_id.address:X}"
+
+ reply = QMessageBox.question(
+ self, "Remove Message",
+ f"Are you sure you want to remove '{msg_name}'?\n\nThis will also remove all its signals.",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No
+ )
+
+ if reply == QMessageBox.StandardButton.Yes:
+ from openpilot.tools.cabana.pycabana.commands import RemoveMsgCommand, UndoStack
+ cmd = RemoveMsgCommand(self._msg_id)
+ UndoStack.push(cmd)
+
+ def serializeMessageIds(self) -> tuple[str, list[str]]:
+ """Serialize tab state for saving."""
+ msg_ids = []
+ for i in range(self._tabbar.count()):
+ msg_id = self._tabbar.tabData(i)
+ if isinstance(msg_id, MessageId):
+ msg_ids.append(str(msg_id))
+
+ active_id = str(self._msg_id) if self._msg_id else ""
+ return (active_id, msg_ids)
+
+ def restoreTabs(self, active_msg_id: str, msg_ids: list[str]):
+ """Restore tab state from saved data."""
+ self._tabbar.blockSignals(True)
+
+ # Add tabs for each message ID
+ for msg_id_str in msg_ids:
+ try:
+ # Parse "source:address" format
+ parts = msg_id_str.split(':')
+ if len(parts) == 2:
+ source = int(parts[0])
+ address = int(parts[1], 16)
+ msg_id = MessageId(source=source, address=address)
+
+ # Check if message still exists in DBC
+ if dbc_manager().msg(msg_id):
+ self._find_or_add_tab(msg_id)
+ except (ValueError, IndexError):
+ continue
+
+ self._tabbar.blockSignals(False)
+
+ # Set active message
+ if active_msg_id:
+ try:
+ parts = active_msg_id.split(':')
+ if len(parts) == 2:
+ source = int(parts[0])
+ address = int(parts[1], 16)
+ active_id = MessageId(source=source, address=address)
+ if dbc_manager().msg(active_id):
+ self.setMessage(active_id)
+ except (ValueError, IndexError):
+ pass
+
+
+class CenterWidget(QWidget):
+ """Center widget that shows either welcome screen or detail widget."""
+
+ def __init__(self, stream: AbstractStream, parent: QWidget | None = None):
+ super().__init__(parent)
+ self._stream = stream
+ self._detail_widget: DetailWidget | None = None
+ self._welcome_widget: QWidget | None = None
+
+ self._layout = QVBoxLayout(self)
+ self._layout.setContentsMargins(0, 0, 0, 0)
+
+ self._show_welcome()
+
+ def _show_welcome(self):
+ """Show welcome screen."""
+ if self._welcome_widget:
+ return
+
+ self._welcome_widget = self._create_welcome_widget()
+ self._layout.addWidget(self._welcome_widget)
+
+ def _create_welcome_widget(self) -> QWidget:
+ """Create the welcome screen widget."""
+ widget = QWidget(self)
+ layout = QVBoxLayout(widget)
+ layout.addStretch()
+
+ # Logo
+ logo = QLabel("CABANA")
+ logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ logo.setStyleSheet("font-size: 50px; font-weight: bold; color: #404040;")
+ layout.addWidget(logo)
+
+ # Instructions
+ instructions = QLabel("<- Select a message to view details")
+ instructions.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ instructions.setStyleSheet("color: gray; font-size: 14px; margin-top: 20px;")
+ layout.addWidget(instructions)
+
+ # Shortcuts
+ shortcuts_layout = QVBoxLayout()
+ shortcuts_layout.setSpacing(8)
+ shortcuts_layout.setContentsMargins(0, 20, 0, 0)
+
+ def add_shortcut(title: str, key: str):
+ row = QHBoxLayout()
+ row.addStretch()
+
+ label = QLabel(title)
+ label.setStyleSheet("color: gray;")
+ row.addWidget(label)
+
+ key_label = QLabel(key)
+ key_label.setStyleSheet(
+ "background-color: #e0e0e0; padding: 4px 8px; "
+ + "border-radius: 4px; color: #404040; margin-left: 8px;"
+ )
+ row.addWidget(key_label)
+ row.addStretch()
+
+ shortcuts_layout.addLayout(row)
+
+ add_shortcut("Pause", "Space")
+ add_shortcut("Help", "F1")
+ add_shortcut("WhatsThis", "Shift+F1")
+
+ layout.addLayout(shortcuts_layout)
+ layout.addStretch()
+
+ widget.setStyleSheet("background-color: #f5f5f5;")
+ widget.setAutoFillBackground(True)
+
+ return widget
+
+ def setMessage(self, msg_id: MessageId):
+ """Set the message to display."""
+ detail = self.ensureDetailWidget()
+ detail.setMessage(msg_id)
+
+ def ensureDetailWidget(self) -> DetailWidget:
+ """Ensure detail widget exists and return it."""
+ if not self._detail_widget:
+ if self._welcome_widget:
+ self._layout.removeWidget(self._welcome_widget)
+ self._welcome_widget.deleteLater()
+ self._welcome_widget = None
+
+ self._detail_widget = DetailWidget(self._stream, self)
+ self._layout.addWidget(self._detail_widget)
+
+ return self._detail_widget
+
+ def getDetailWidget(self) -> DetailWidget | None:
+ """Get the detail widget if it exists."""
+ return self._detail_widget
+
+ def clear(self):
+ """Clear the detail widget and show welcome screen."""
+ if self._detail_widget:
+ self._layout.removeWidget(self._detail_widget)
+ self._detail_widget.deleteLater()
+ self._detail_widget = None
+
+ self._show_welcome()
diff --git a/tools/cabana/pycabana/widgets/history.py b/tools/cabana/pycabana/widgets/history.py
new file mode 100644
index 00000000000000..ba3afbe2cb0c49
--- /dev/null
+++ b/tools/cabana/pycabana/widgets/history.py
@@ -0,0 +1,519 @@
+"""HistoryLog - displays individual CAN message events over time."""
+
+from collections import deque
+from collections.abc import Callable
+from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex, QSize, QRect
+from PySide6.QtGui import QColor, QPainter, QBrush, QPalette
+from PySide6.QtWidgets import (
+ QWidget,
+ QFrame,
+ QVBoxLayout,
+ QHBoxLayout,
+ QTableView,
+ QHeaderView,
+ QComboBox,
+ QLineEdit,
+ QPushButton,
+ QFileDialog,
+ QStyledItemDelegate,
+ QStyleOptionViewItem,
+)
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId, decode_signal
+from openpilot.tools.cabana.pycabana.dbc.dbcmanager import dbc_manager
+from openpilot.tools.cabana.pycabana.streams.abstract import AbstractStream
+
+
+# Custom roles for byte data display
+ColorsRole = Qt.ItemDataRole.UserRole + 1
+BytesRole = Qt.ItemDataRole.UserRole + 2
+
+
+class Message:
+ """Represents a single CAN message event with decoded signal values."""
+
+ def __init__(self, mono_time: int, sig_values: list[float], data: bytes, colors: list[QColor] | None = None):
+ self.mono_time = mono_time
+ self.sig_values = sig_values
+ self.data = data
+ self.colors = colors if colors else []
+
+
+class HistoryLogModel(QAbstractTableModel):
+ """Model for displaying history of CAN message events."""
+
+ def __init__(self, stream: AbstractStream, parent=None):
+ super().__init__(parent)
+ self.stream = stream
+ self.msg_id: MessageId | None = None
+ self.messages: deque[Message] = deque()
+ self.sigs: list = []
+ self.hex_mode = False
+ self.batch_size = 50
+ self.filter_sig_idx = -1
+ self.filter_value = 0.0
+ self.filter_cmp: Callable[[float, float], bool] | None = None
+
+ def setMessage(self, msg_id: MessageId | None):
+ """Set the message to display."""
+ self.msg_id = msg_id
+ self.reset()
+
+ def reset(self):
+ """Reset the model and rebuild signal list."""
+ self.beginResetModel()
+ self.sigs = []
+ if self.msg_id:
+ msg = dbc_manager().msg(self.msg_id)
+ if msg:
+ # Convert dict values to list
+ self.sigs = list(msg.sigs.values())
+ self.messages.clear()
+ self.endResetModel()
+ self.setFilter(0, "", None)
+
+ def setHexMode(self, hex_mode: bool):
+ """Toggle between signal value mode and hex mode."""
+ self.hex_mode = hex_mode
+ self.reset()
+
+ def isHexMode(self) -> bool:
+ """Check if in hex mode."""
+ return len(self.sigs) == 0 or self.hex_mode
+
+ def setFilter(self, sig_idx: int, value: str, cmp: Callable[[float, float], bool] | None):
+ """Set filter for signal values."""
+ self.filter_sig_idx = sig_idx
+ try:
+ self.filter_value = float(value) if value else 0.0
+ except ValueError:
+ self.filter_value = 0.0
+ self.filter_cmp = cmp if value else None
+ self.updateState(clear=True)
+
+ def updateState(self, clear: bool = False):
+ """Update the model with new events from the stream."""
+ if clear and len(self.messages) > 0:
+ self.beginRemoveRows(QModelIndex(), 0, len(self.messages) - 1)
+ self.messages.clear()
+ self.endRemoveRows()
+
+ if not self.msg_id:
+ return
+
+ # Get current time boundary
+ last_msg = self.stream.lastMessage(self.msg_id)
+ if not last_msg:
+ return
+
+ current_time = int(last_msg.ts * 1e9) + self.stream.start_ts + 1
+ min_time = self.messages[0].mono_time if self.messages else 0
+ self._fetchData(0, current_time, min_time)
+
+ def canFetchMore(self, parent: QModelIndex | QPersistentModelIndex | None = None) -> bool:
+ """Check if more data can be fetched."""
+ if not self.msg_id or len(self.messages) == 0:
+ return False
+ events = self.stream.events.get(self.msg_id, [])
+ if not events:
+ return False
+ return self.messages[-1].mono_time > events[0].mono_time
+
+ def fetchMore(self, parent: QModelIndex | QPersistentModelIndex | None = None):
+ """Fetch more historical data."""
+ if len(self.messages) > 0:
+ self._fetchData(len(self.messages), self.messages[-1].mono_time, 0)
+
+ def _fetchData(self, insert_pos: int, from_time: int, min_time: int):
+ """Fetch and decode events from the stream."""
+ if not self.msg_id:
+ return
+
+ events = self.stream.events.get(self.msg_id, [])
+ if not events:
+ return
+
+ # Find events in time range (reverse chronological order)
+ msgs: list[Message] = []
+ for event in reversed(events):
+ if event.mono_time >= from_time:
+ continue
+ if event.mono_time <= min_time:
+ break
+
+ # Decode signal values
+ sig_values = []
+ for sig in self.sigs:
+ value = decode_signal(sig, event.dat)
+ sig_values.append(value)
+
+ # Apply filter
+ if self.filter_cmp and len(sig_values) > self.filter_sig_idx:
+ if not self.filter_cmp(sig_values[self.filter_sig_idx], self.filter_value):
+ continue
+
+ # Create message entry
+ msgs.append(Message(event.mono_time, sig_values, event.dat))
+
+ # Limit batch size when loading newest data
+ if len(msgs) >= self.batch_size and min_time == 0:
+ break
+
+ # Insert new messages
+ if msgs:
+ self.beginInsertRows(QModelIndex(), insert_pos, insert_pos + len(msgs) - 1)
+ for i, msg in enumerate(msgs):
+ self.messages.insert(insert_pos + i, msg)
+ self.endInsertRows()
+
+ def rowCount(self, parent: QModelIndex | QPersistentModelIndex | None = None) -> int:
+ if parent is not None and parent.isValid():
+ return 0
+ return len(self.messages)
+
+ def columnCount(self, parent: QModelIndex | QPersistentModelIndex | None = None) -> int:
+ if parent is not None and parent.isValid():
+ return 0
+ return 2 if self.isHexMode() else len(self.sigs) + 1
+
+ def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
+ if not index.isValid() or index.row() >= len(self.messages):
+ return None
+
+ msg = self.messages[index.row()]
+ col = index.column()
+
+ if role == Qt.ItemDataRole.DisplayRole:
+ if col == 0:
+ # Time column
+ time_sec = self.stream.toSeconds(msg.mono_time)
+ return f"{time_sec:.3f}"
+ if not self.isHexMode() and col <= len(self.sigs):
+ # Signal value column
+ sig = self.sigs[col - 1]
+ value = msg.sig_values[col - 1]
+ # Format signal value with unit
+ if sig.unit:
+ return f"{value:.6g} {sig.unit}"
+ return f"{value:.6g}"
+ elif role == Qt.ItemDataRole.TextAlignmentRole:
+ return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
+ elif self.isHexMode() and col == 1:
+ # Hex mode - return data for delegate
+ if role == BytesRole:
+ return msg.data
+ if role == ColorsRole:
+ return msg.colors
+
+ return None
+
+ def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole):
+ if orientation == Qt.Orientation.Horizontal:
+ if role == Qt.ItemDataRole.DisplayRole or role == Qt.ItemDataRole.ToolTipRole:
+ if section == 0:
+ return "Time"
+ if self.isHexMode():
+ return "Data"
+ if section <= len(self.sigs):
+ sig = self.sigs[section - 1]
+ if sig.unit:
+ return f"{sig.name} ({sig.unit})"
+ return sig.name
+ elif role == Qt.ItemDataRole.BackgroundRole and section > 0 and not self.isHexMode():
+ # Signal color with alpha for contrast
+ if section <= len(self.sigs):
+ sig = self.sigs[section - 1]
+ color = QColor(sig.color)
+ color.setAlpha(128)
+ return QBrush(color)
+ return None
+
+
+class HeaderView(QHeaderView):
+ """Custom header view with word-wrapped text and custom sizing."""
+
+ def __init__(self, orientation: Qt.Orientation, parent=None):
+ super().__init__(orientation, parent)
+ self.setDefaultAlignment(Qt.AlignmentFlag.AlignRight | Qt.TextFlag.TextWordWrap)
+
+ def sectionSizeFromContents(self, logicalIndex: int) -> QSize:
+ """Calculate section size with word wrapping."""
+ time_col_size = QSize(
+ self.fontMetrics().horizontalAdvance("000000.000") + 10,
+ self.fontMetrics().height() + 6
+ )
+
+ if logicalIndex == 0:
+ return time_col_size
+ else:
+ model = self.model()
+ if not model:
+ return QSize(100, time_col_size.height())
+
+ col_count = model.columnCount()
+ default_size = max(100, (self.rect().width() - time_col_size.width()) // max(1, col_count - 1))
+
+ text = str(model.headerData(logicalIndex, self.orientation(), Qt.ItemDataRole.DisplayRole) or "")
+ text = text.replace('_', ' ')
+
+ rect = self.fontMetrics().boundingRect(
+ QRect(0, 0, default_size, 2000),
+ self.defaultAlignment(),
+ text
+ )
+ size = QSize(rect.width() + 10, rect.height() + 6)
+ return QSize(max(size.width(), default_size), size.height())
+
+ def paintSection(self, painter: QPainter, rect: QRect, logicalIndex: int):
+ """Paint section with custom background color."""
+ model = self.model()
+ if model:
+ bg_role = model.headerData(logicalIndex, Qt.Orientation.Horizontal, Qt.ItemDataRole.BackgroundRole)
+ if bg_role:
+ painter.fillRect(rect, bg_role)
+
+ text = str(model.headerData(logicalIndex, Qt.Orientation.Horizontal, Qt.ItemDataRole.DisplayRole) or "")
+ text = text.replace('_', ' ')
+
+ # Use palette color for text
+ painter.setPen(self.palette().color(QPalette.ColorRole.Text))
+ painter.drawText(rect.adjusted(5, 3, -5, -3), self.defaultAlignment(), text)
+
+
+class MessageBytesDelegate(QStyledItemDelegate):
+ """Delegate for rendering hex bytes with colors."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ # Pre-compute font metrics for fixed-width font
+ from PySide6.QtGui import QFontDatabase
+ self.fixed_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
+ from PySide6.QtGui import QFontMetricsF
+ fm = QFontMetricsF(self.fixed_font)
+ self.byte_width = fm.horizontalAdvance("00 ")
+ self.byte_height = fm.height()
+ self.h_margin = 6
+ self.v_margin = 4
+
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex):
+ """Paint hex bytes with background colors."""
+ data = index.data(BytesRole)
+ colors = index.data(ColorsRole)
+
+ if not isinstance(data, bytes):
+ super().paint(painter, option, index)
+ return
+
+ # Fill background
+ if option.state & QStyleOptionViewItem.StateFlag.State_Selected:
+ painter.fillRect(option.rect, option.palette.highlight())
+
+ painter.setFont(self.fixed_font)
+
+ # Paint each byte with its color
+ x = option.rect.x() + self.h_margin
+ y = option.rect.y() + self.v_margin + self.byte_height
+
+ for i, byte_val in enumerate(data):
+ # Draw background color if available
+ if colors and isinstance(colors, list) and i < len(colors):
+ color = colors[i]
+ if isinstance(color, QColor) and color.isValid():
+ bg_rect = QRect(int(x), option.rect.y(), int(self.byte_width), option.rect.height())
+ painter.fillRect(bg_rect, color)
+
+ # Draw hex text
+ hex_str = f"{byte_val:02X}"
+ painter.setPen(option.palette.color(QPalette.ColorRole.Text))
+ painter.drawText(int(x), int(y), hex_str)
+ x += self.byte_width
+
+ def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex) -> QSize:
+ """Calculate size hint for hex bytes."""
+ data = index.data(BytesRole)
+ if isinstance(data, bytes) and len(data) > 0:
+ width = int(len(data) * self.byte_width + 2 * self.h_margin)
+ height = int(self.byte_height + 2 * self.v_margin)
+ return QSize(width, height)
+ return QSize(100, int(self.byte_height + 2 * self.v_margin))
+
+
+class HistoryLogWidget(QFrame):
+ """Widget for displaying message history log."""
+
+ def __init__(self, stream: AbstractStream, parent=None):
+ super().__init__(parent)
+ self.stream = stream
+ self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Plain)
+
+ self._setup_ui()
+ self._connect_signals()
+
+ def _setup_ui(self):
+ """Set up the UI components."""
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+
+ # Toolbar
+ toolbar = QWidget()
+ toolbar.setAutoFillBackground(True)
+ toolbar_layout = QHBoxLayout(toolbar)
+
+ # Filters widget
+ self.filters_widget = QWidget()
+ filter_layout = QHBoxLayout(self.filters_widget)
+ filter_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.display_type_cb = QComboBox()
+ self.display_type_cb.addItems(["Signal", "Hex"])
+ self.display_type_cb.setToolTip("Display signal value or raw hex value")
+ filter_layout.addWidget(self.display_type_cb)
+
+ self.signals_cb = QComboBox()
+ filter_layout.addWidget(self.signals_cb)
+
+ self.comp_box = QComboBox()
+ self.comp_box.addItems([">", "=", "!=", "<"])
+ filter_layout.addWidget(self.comp_box)
+
+ self.value_edit = QLineEdit()
+ self.value_edit.setClearButtonEnabled(True)
+ from PySide6.QtGui import QDoubleValidator
+ self.value_edit.setValidator(QDoubleValidator())
+ filter_layout.addWidget(self.value_edit)
+
+ toolbar_layout.addWidget(self.filters_widget)
+ toolbar_layout.addStretch()
+
+ self.export_btn = QPushButton("Export to CSV...")
+ self.export_btn.setEnabled(False)
+ toolbar_layout.addWidget(self.export_btn, alignment=Qt.AlignmentFlag.AlignRight)
+
+ main_layout.addWidget(toolbar)
+
+ # Separator line
+ line = QFrame()
+ line.setFrameStyle(QFrame.Shape.HLine | QFrame.Shadow.Sunken)
+ main_layout.addWidget(line)
+
+ # Table view
+ self.logs = QTableView()
+ self.model = HistoryLogModel(self.stream)
+ self.logs.setModel(self.model)
+
+ self.delegate = MessageBytesDelegate()
+ self.logs.setItemDelegate(self.delegate)
+
+ self.logs.setHorizontalHeader(HeaderView(Qt.Orientation.Horizontal))
+ self.logs.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
+ self.logs.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
+ self.logs.verticalHeader().setDefaultSectionSize(int(self.delegate.byte_height + 2 * self.delegate.v_margin))
+ self.logs.setFrameShape(QFrame.Shape.NoFrame)
+
+ main_layout.addWidget(self.logs)
+
+ def _connect_signals(self):
+ """Connect signal handlers."""
+ self.display_type_cb.activated.connect(self._on_display_type_changed)
+ self.signals_cb.activated.connect(self._on_filter_changed)
+ self.comp_box.activated.connect(self._on_filter_changed)
+ self.value_edit.textEdited.connect(self._on_filter_changed)
+ self.export_btn.clicked.connect(self._on_export_csv)
+
+ # Stream signals
+ self.stream.seekedTo.connect(lambda: self.model.reset())
+
+ # DBC signals
+ dbc_manager().dbcLoaded.connect(lambda: self.model.reset())
+
+ # Model signals
+ self.model.modelReset.connect(self._on_model_reset)
+ self.model.rowsInserted.connect(lambda: self.export_btn.setEnabled(True))
+
+ def setMessage(self, msg_id: MessageId | None):
+ """Set the message to display."""
+ self.model.setMessage(msg_id)
+
+ def updateState(self):
+ """Update the model state with latest data."""
+ self.model.updateState()
+
+ def showEvent(self, event):
+ """Handle show event."""
+ super().showEvent(event)
+ self.model.updateState(clear=True)
+
+ def _on_display_type_changed(self, index: int):
+ """Handle display type change."""
+ self.model.setHexMode(index == 1)
+
+ def _on_model_reset(self):
+ """Handle model reset."""
+ self.signals_cb.clear()
+ for sig in self.model.sigs:
+ self.signals_cb.addItem(sig.name)
+ self.export_btn.setEnabled(False)
+ self.value_edit.clear()
+ self.comp_box.setCurrentIndex(0)
+ self.filters_widget.setVisible(len(self.model.sigs) > 0)
+
+ def _on_filter_changed(self):
+ """Handle filter change."""
+ text = self.value_edit.text()
+ if not text and not self.value_edit.isModified():
+ return
+
+ # Map comparison operator
+ cmp_funcs = [
+ lambda l, r: l > r,
+ lambda l, r: l == r,
+ lambda l, r: l != r,
+ lambda l, r: l < r,
+ ]
+ cmp = cmp_funcs[self.comp_box.currentIndex()] if self.comp_box.currentIndex() < len(cmp_funcs) else None
+
+ self.model.setFilter(self.signals_cb.currentIndex(), text, cmp)
+
+ def _on_export_csv(self):
+ """Export history to CSV file."""
+ if not self.model.msg_id:
+ return
+
+ msg_name = dbc_manager().msgName(self.model.msg_id)
+ route_name = self.stream.routeName if hasattr(self.stream, 'routeName') else "route"
+ default_filename = f"{route_name}_{msg_name}.csv"
+
+ filename, _ = QFileDialog.getSaveFileName(
+ self,
+ f"Export {msg_name} to CSV file",
+ default_filename,
+ "CSV files (*.csv)"
+ )
+
+ if filename:
+ self._export_to_csv(filename)
+
+ def _export_to_csv(self, filename: str):
+ """Write data to CSV file."""
+ try:
+ with open(filename, 'w') as f:
+ # Write header
+ if self.model.isHexMode():
+ f.write("Time,Data\n")
+ else:
+ header = ["Time"] + [sig.name for sig in self.model.sigs]
+ f.write(",".join(header) + "\n")
+
+ # Write data rows
+ for msg in self.model.messages:
+ time_sec = self.stream.toSeconds(msg.mono_time)
+ if self.model.isHexMode():
+ hex_str = msg.data.hex(' ').upper()
+ f.write(f"{time_sec:.3f},{hex_str}\n")
+ else:
+ values = [f"{time_sec:.3f}"] + [f"{val:.6g}" for val in msg.sig_values]
+ f.write(",".join(values) + "\n")
+
+ except Exception as e:
+ print(f"Failed to export CSV: {e}")
diff --git a/tools/cabana/pycabana/widgets/messages.py b/tools/cabana/pycabana/widgets/messages.py
new file mode 100644
index 00000000000000..1a8e4032410834
--- /dev/null
+++ b/tools/cabana/pycabana/widgets/messages.py
@@ -0,0 +1,251 @@
+"""MessagesWidget - displays CAN messages in a table."""
+
+from PySide6.QtCore import Qt, QAbstractTableModel, QModelIndex, QPersistentModelIndex, QSortFilterProxyModel, Signal, QTimer
+from PySide6.QtWidgets import (
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QLineEdit,
+ QTableView,
+ QHeaderView,
+ QAbstractItemView,
+ QPushButton,
+)
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId
+from openpilot.tools.cabana.pycabana.dbc.dbcmanager import dbc_manager
+from openpilot.tools.cabana.pycabana.streams.abstract import AbstractStream
+
+
+class MessageListModel(QAbstractTableModel):
+ """Model for the messages table."""
+
+ COLUMNS = ['Bus', 'Address', 'Name', 'Count', 'Freq', 'Data']
+ COL_BUS = 0
+ COL_ADDRESS = 1
+ COL_NAME = 2
+ COL_COUNT = 3
+ COL_FREQ = 4
+ COL_DATA = 5
+
+ def __init__(self, stream: AbstractStream, parent=None):
+ super().__init__(parent)
+ self.stream = stream
+ self.msg_ids: list[MessageId] = []
+ self._data_changed_pending = False
+ self._data_changed_timer = QTimer(self)
+ self._data_changed_timer.setSingleShot(True)
+ self._data_changed_timer.setInterval(50) # 50ms debounce
+ self._data_changed_timer.timeout.connect(self._emitDataChanged)
+
+ def rowCount(self, parent=None):
+ if parent is None:
+ parent = QModelIndex()
+ if parent.isValid():
+ return 0
+ return len(self.msg_ids)
+
+ def columnCount(self, parent=None):
+ if parent is None:
+ parent = QModelIndex()
+ if parent.isValid():
+ return 0
+ return len(self.COLUMNS)
+
+ def data(self, index: QModelIndex | QPersistentModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
+ if not index.isValid() or index.row() >= len(self.msg_ids):
+ return None
+
+ msg_id = self.msg_ids[index.row()]
+ can_data = self.stream.last_msgs.get(msg_id)
+
+ if role == Qt.ItemDataRole.DisplayRole:
+ col = index.column()
+ if col == self.COL_BUS:
+ return str(msg_id.source)
+ elif col == self.COL_ADDRESS:
+ return f"0x{msg_id.address:X}"
+ elif col == self.COL_NAME:
+ return dbc_manager().msgName(msg_id)
+ elif col == self.COL_COUNT:
+ return str(can_data.count) if can_data else "0"
+ elif col == self.COL_FREQ:
+ return f"{can_data.freq:.1f}" if can_data else "0.0"
+ elif col == self.COL_DATA:
+ if can_data and can_data.dat:
+ return can_data.dat.hex(' ').upper()
+ return ""
+
+ elif role == Qt.ItemDataRole.TextAlignmentRole:
+ col = index.column()
+ if col in (self.COL_COUNT, self.COL_FREQ):
+ return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
+ return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
+
+ elif role == Qt.ItemDataRole.UserRole:
+ # Return MessageId for selection handling
+ return msg_id
+
+ return None
+
+ def headerData(self, section, orientation, role: int = Qt.ItemDataRole.DisplayRole):
+ if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
+ if 0 <= section < len(self.COLUMNS):
+ return self.COLUMNS[section]
+ return None
+
+ def updateMessages(self, msg_ids: set[MessageId], has_new: bool):
+ """Update the model with new message IDs."""
+ if has_new:
+ # Add new message IDs
+ new_ids = msg_ids - set(self.msg_ids)
+ if new_ids:
+ start = len(self.msg_ids)
+ end = start + len(new_ids) - 1
+ self.beginInsertRows(QModelIndex(), start, end)
+ self.msg_ids.extend(sorted(new_ids))
+ self.endInsertRows()
+
+ # Schedule debounced dataChanged emission
+ self._data_changed_pending = True
+ if not self._data_changed_timer.isActive():
+ self._data_changed_timer.start()
+
+ def _emitDataChanged(self):
+ """Emit dataChanged signal (debounced)."""
+ if self._data_changed_pending and self.msg_ids:
+ self._data_changed_pending = False
+ top_left = self.index(0, self.COL_COUNT)
+ bottom_right = self.index(len(self.msg_ids) - 1, self.COL_DATA)
+ self.dataChanged.emit(top_left, bottom_right)
+
+ def getMsgId(self, row: int) -> MessageId | None:
+ """Get MessageId for a row."""
+ if 0 <= row < len(self.msg_ids):
+ return self.msg_ids[row]
+ return None
+
+
+class MessageFilterProxyModel(QSortFilterProxyModel):
+ """Proxy model for filtering messages by address or name."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.filter_text = ""
+
+ def setFilterText(self, text: str):
+ self.filter_text = text.lower()
+ self.invalidateFilter()
+
+ def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex | QPersistentModelIndex) -> bool:
+ if not self.filter_text:
+ return True
+
+ source_model = self.sourceModel()
+ # Check address column
+ address_index = source_model.index(source_row, MessageListModel.COL_ADDRESS)
+ address = source_model.data(address_index, Qt.ItemDataRole.DisplayRole)
+ if address and self.filter_text in address.lower():
+ return True
+
+ # Check name column
+ name_index = source_model.index(source_row, MessageListModel.COL_NAME)
+ name = source_model.data(name_index, Qt.ItemDataRole.DisplayRole)
+ if name and self.filter_text in name.lower():
+ return True
+
+ return False
+
+
+class MessagesWidget(QWidget):
+ """Widget displaying the list of CAN messages."""
+
+ msgSelectionChanged = Signal(object) # MessageId or None
+
+ def __init__(self, stream: AbstractStream, parent=None):
+ super().__init__(parent)
+ self.stream = stream
+
+ self._setup_ui()
+ self._connect_signals()
+
+ def _setup_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(4)
+
+ # Filter bar
+ filter_layout = QHBoxLayout()
+ filter_layout.setContentsMargins(4, 4, 4, 0)
+
+ self.filter_input = QLineEdit()
+ self.filter_input.setPlaceholderText("Filter by address or name...")
+ self.filter_input.setClearButtonEnabled(True)
+ filter_layout.addWidget(self.filter_input)
+
+ self.clear_btn = QPushButton("Clear")
+ self.clear_btn.setFixedWidth(60)
+ filter_layout.addWidget(self.clear_btn)
+
+ layout.addLayout(filter_layout)
+
+ # Table view
+ self.model = MessageListModel(self.stream)
+ self.proxy_model = MessageFilterProxyModel()
+ self.proxy_model.setSourceModel(self.model)
+
+ self.table_view = QTableView()
+ self.table_view.setModel(self.proxy_model)
+ self.table_view.setSelectionBehavior(QAbstractItemView.SelectRows)
+ self.table_view.setSelectionMode(QAbstractItemView.SingleSelection)
+ self.table_view.setSortingEnabled(True)
+ self.table_view.setAlternatingRowColors(True)
+ self.table_view.verticalHeader().setVisible(False)
+
+ # Column sizing
+ header = self.table_view.horizontalHeader()
+ header.setSectionResizeMode(MessageListModel.COL_BUS, QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(MessageListModel.COL_ADDRESS, QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(MessageListModel.COL_NAME, QHeaderView.ResizeMode.Interactive)
+ header.setSectionResizeMode(MessageListModel.COL_COUNT, QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(MessageListModel.COL_FREQ, QHeaderView.ResizeMode.ResizeToContents)
+ header.setSectionResizeMode(MessageListModel.COL_DATA, QHeaderView.ResizeMode.Stretch)
+ header.resizeSection(MessageListModel.COL_NAME, 150)
+
+ layout.addWidget(self.table_view)
+
+ def _connect_signals(self):
+ # Filter input
+ self.filter_input.textChanged.connect(self.proxy_model.setFilterText)
+ self.clear_btn.clicked.connect(self.filter_input.clear)
+
+ # Selection
+ self.table_view.selectionModel().selectionChanged.connect(self._on_selection_changed)
+
+ # Stream updates
+ self.stream.msgsReceived.connect(self._on_msgs_received)
+
+ def _on_selection_changed(self, selected, deselected):
+ """Handle selection change in the table."""
+ indexes = self.table_view.selectionModel().selectedRows()
+ if indexes:
+ # Map proxy index to source index
+ proxy_index = indexes[0]
+ source_index = self.proxy_model.mapToSource(proxy_index)
+ msg_id = self.model.getMsgId(source_index.row())
+ self.msgSelectionChanged.emit(msg_id)
+ else:
+ self.msgSelectionChanged.emit(None)
+
+ def _on_msgs_received(self, msg_ids: set[MessageId], has_new: bool):
+ """Handle new messages from stream."""
+ self.model.updateMessages(msg_ids, has_new)
+
+ def selectMessage(self, msg_id: MessageId):
+ """Programmatically select a message."""
+ for row, mid in enumerate(self.model.msg_ids):
+ if mid == msg_id:
+ source_index = self.model.index(row, 0)
+ proxy_index = self.proxy_model.mapFromSource(source_index)
+ self.table_view.selectRow(proxy_index.row())
+ break
diff --git a/tools/cabana/pycabana/widgets/signal.py b/tools/cabana/pycabana/widgets/signal.py
new file mode 100644
index 00000000000000..e84768f7986dfe
--- /dev/null
+++ b/tools/cabana/pycabana/widgets/signal.py
@@ -0,0 +1,108 @@
+"""SignalView - displays decoded signal values for a CAN message."""
+
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import (
+ QWidget,
+ QVBoxLayout,
+ QLabel,
+ QTableWidget,
+ QTableWidgetItem,
+ QHeaderView,
+)
+
+from openpilot.tools.cabana.pycabana.dbc.dbc import MessageId, CanData, decode_signal
+from openpilot.tools.cabana.pycabana.dbc.dbcmanager import dbc_manager
+
+
+class SignalView(QWidget):
+ """Widget showing decoded signal values for a message."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._msg_id: MessageId | None = None
+
+ self._setup_ui()
+
+ def _setup_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(8, 8, 8, 8)
+
+ # Header label
+ self.header_label = QLabel("No message selected")
+ self.header_label.setStyleSheet("font-weight: bold; font-size: 14px;")
+ layout.addWidget(self.header_label)
+
+ # Signals table
+ self.table = QTableWidget()
+ self.table.setColumnCount(2)
+ self.table.setHorizontalHeaderLabels(["Signal", "Value"])
+ self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
+ self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
+ self.table.verticalHeader().setVisible(False)
+ self.table.setAlternatingRowColors(True)
+ self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
+ self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
+ layout.addWidget(self.table)
+
+ def setMessage(self, msg_id: MessageId | None, can_data: CanData | None):
+ """Update to show signals for the given message."""
+ self._msg_id = msg_id
+
+ if msg_id is None:
+ self.header_label.setText("No message selected")
+ self.table.setRowCount(0)
+ return
+
+ msg = dbc_manager().msg(msg_id)
+ if msg is None:
+ self.header_label.setText(f"0x{msg_id.address:X} (no DBC)")
+ self.table.setRowCount(0)
+ return
+
+ self.header_label.setText(f"{msg.name} (0x{msg_id.address:X})")
+
+ # Populate signals table
+ signals = list(msg.sigs.values())
+ self.table.setRowCount(len(signals))
+
+ data = can_data.dat if can_data else b''
+
+ for i, sig in enumerate(signals):
+ # Signal name
+ name_item = QTableWidgetItem(sig.name)
+ name_item.setFlags(name_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ self.table.setItem(i, 0, name_item)
+
+ # Signal value
+ if data:
+ value = decode_signal(sig, data)
+ value_str = f"{value:.2f}" if isinstance(value, float) else str(value)
+ else:
+ value_str = "N/A"
+ value_item = QTableWidgetItem(value_str)
+ value_item.setFlags(value_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
+ value_item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+ self.table.setItem(i, 1, value_item)
+
+ def updateValues(self, can_data: CanData | None):
+ """Update signal values without changing the selected message."""
+ if self._msg_id is None:
+ return
+
+ msg = dbc_manager().msg(self._msg_id)
+ if msg is None:
+ return
+
+ data = can_data.dat if can_data else b''
+ signals = list(msg.sigs.values())
+
+ for i, sig in enumerate(signals):
+ if i < self.table.rowCount():
+ if data:
+ value = decode_signal(sig, data)
+ value_str = f"{value:.2f}" if isinstance(value, float) else str(value)
+ else:
+ value_str = "N/A"
+ item = self.table.item(i, 1)
+ if item:
+ item.setText(value_str)
diff --git a/tools/cabana/pycabana/widgets/video.py b/tools/cabana/pycabana/widgets/video.py
new file mode 100644
index 00000000000000..25c8c9c85762a7
--- /dev/null
+++ b/tools/cabana/pycabana/widgets/video.py
@@ -0,0 +1,255 @@
+"""VideoWidget - timeline slider, playback controls, and camera view."""
+
+from PySide6.QtCore import Qt, Signal, QTimer
+from PySide6.QtWidgets import (
+ QVBoxLayout,
+ QHBoxLayout,
+ QSlider,
+ QLabel,
+ QPushButton,
+ QStyle,
+ QFrame,
+ QComboBox,
+)
+
+from openpilot.tools.cabana.pycabana.widgets.camera import CameraView
+
+
+class TimelineSlider(QSlider):
+ """Timeline slider with millisecond precision."""
+
+ def __init__(self, parent=None):
+ super().__init__(Qt.Orientation.Horizontal, parent)
+ self._factor = 1000.0 # Store time in milliseconds
+ self.setRange(0, 0)
+
+ def currentSecond(self) -> float:
+ return self.value() / self._factor
+
+ def setCurrentSecond(self, sec: float):
+ self.setValue(int(sec * self._factor))
+
+ def setTimeRange(self, min_sec: float, max_sec: float):
+ self.setRange(int(min_sec * self._factor), int(max_sec * self._factor))
+
+
+class VideoWidget(QFrame):
+ """Widget with camera view, timeline slider and playback controls."""
+
+ seeked = Signal(float) # Emitted when user seeks to a time (seconds)
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._duration = 0.0
+ self._current_time = 0.0
+ self._playing = False
+ self._playback_speed = 1.0
+ self._route: str = ""
+
+ self._setup_ui()
+ self._connect_signals()
+
+ # Playback timer
+ self._playback_timer = QTimer(self)
+ self._playback_timer.setInterval(33) # ~30fps
+ self._playback_timer.timeout.connect(self._on_playback_tick)
+
+ def _setup_ui(self):
+ self.setFrameShape(QFrame.Shape.StyledPanel)
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(8, 8, 8, 8)
+ layout.setSpacing(8)
+
+ # Camera selector and view
+ camera_header = QHBoxLayout()
+ camera_header.addWidget(QLabel("Camera:"))
+ self.camera_combo = QComboBox()
+ self.camera_combo.addItems(["Road Camera", "Wide Camera", "Driver Camera"])
+ self.camera_combo.setCurrentIndex(0)
+ camera_header.addWidget(self.camera_combo)
+ camera_header.addStretch()
+ layout.addLayout(camera_header)
+
+ # Camera view
+ self.camera_view = CameraView()
+ self.camera_view.setMinimumHeight(200)
+ layout.addWidget(self.camera_view, 1)
+
+ # Time display
+ time_layout = QHBoxLayout()
+ self.time_label = QLabel("0:00.0 / 0:00.0")
+ self.time_label.setStyleSheet("font-family: monospace; font-size: 12px;")
+ time_layout.addWidget(self.time_label)
+ time_layout.addStretch()
+
+ self.speed_label = QLabel("1.0x")
+ self.speed_label.setStyleSheet("color: gray;")
+ time_layout.addWidget(self.speed_label)
+ layout.addLayout(time_layout)
+
+ # Timeline slider
+ self.slider = TimelineSlider()
+ self.slider.setMinimumHeight(20)
+ layout.addWidget(self.slider)
+
+ # Playback controls
+ controls_layout = QHBoxLayout()
+ controls_layout.setSpacing(4)
+
+ self.play_btn = QPushButton()
+ self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
+ self.play_btn.setFixedSize(32, 32)
+ controls_layout.addWidget(self.play_btn)
+
+ self.stop_btn = QPushButton()
+ self.stop_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop))
+ self.stop_btn.setFixedSize(32, 32)
+ controls_layout.addWidget(self.stop_btn)
+
+ controls_layout.addSpacing(16)
+
+ # Speed controls
+ self.slower_btn = QPushButton("-")
+ self.slower_btn.setFixedSize(24, 24)
+ self.slower_btn.setToolTip("Slower")
+ controls_layout.addWidget(self.slower_btn)
+
+ self.faster_btn = QPushButton("+")
+ self.faster_btn.setFixedSize(24, 24)
+ self.faster_btn.setToolTip("Faster")
+ controls_layout.addWidget(self.faster_btn)
+
+ controls_layout.addStretch()
+ layout.addLayout(controls_layout)
+
+ def _connect_signals(self):
+ self.slider.sliderMoved.connect(self._on_slider_moved)
+ self.slider.sliderPressed.connect(self._on_slider_pressed)
+ self.slider.sliderReleased.connect(self._on_slider_released)
+ self.play_btn.clicked.connect(self._toggle_playback)
+ self.stop_btn.clicked.connect(self._stop_playback)
+ self.slower_btn.clicked.connect(self._decrease_speed)
+ self.faster_btn.clicked.connect(self._increase_speed)
+ self.camera_combo.currentIndexChanged.connect(self._on_camera_changed)
+
+ def loadRoute(self, route: str):
+ """Load video from a route."""
+ self._route = route
+ camera = self._get_camera_name()
+ self.camera_view.loadRoute(route, camera)
+
+ def _get_camera_name(self) -> str:
+ """Get the camera name from combo box selection."""
+ idx = self.camera_combo.currentIndex()
+ return ["fcamera", "ecamera", "dcamera"][idx]
+
+ def _on_camera_changed(self, index: int):
+ """Handle camera selection change."""
+ if self._route:
+ camera = self._get_camera_name()
+ self.camera_view.loadRoute(self._route, camera)
+
+ def setDuration(self, duration: float):
+ """Set the total duration in seconds."""
+ self._duration = duration
+ self.slider.setTimeRange(0, duration)
+ self._update_time_display()
+
+ def setCurrentTime(self, time: float):
+ """Set the current playback time."""
+ self._current_time = time
+ if not self.slider.isSliderDown():
+ self.slider.setCurrentSecond(time)
+ self._update_time_display()
+ # Update camera frame
+ self.camera_view.seekToTime(time)
+
+ def _update_time_display(self):
+ current = self._format_time(self._current_time)
+ total = self._format_time(self._duration)
+ self.time_label.setText(f"{current} / {total}")
+
+ def _format_time(self, seconds: float) -> str:
+ """Format seconds as M:SS.s"""
+ minutes = int(seconds // 60)
+ secs = seconds % 60
+ return f"{minutes}:{secs:04.1f}"
+
+ def _on_slider_moved(self, value):
+ time = value / self.slider._factor
+ self._current_time = time
+ self._update_time_display()
+
+ def _on_slider_pressed(self):
+ self._was_playing = self._playing
+ if self._playing:
+ self._playback_timer.stop()
+
+ def _on_slider_released(self):
+ time = self.slider.currentSecond()
+ self._current_time = time
+ self.camera_view.seekToTime(time)
+ self.seeked.emit(time)
+ if self._was_playing:
+ self._playback_timer.start()
+
+ def _toggle_playback(self):
+ if self._playing:
+ self._pause()
+ else:
+ self._play()
+
+ def _play(self):
+ self._playing = True
+ self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPause))
+ self._playback_timer.start()
+
+ def _pause(self):
+ self._playing = False
+ self.play_btn.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
+ self._playback_timer.stop()
+
+ def _stop_playback(self):
+ self._pause()
+ self._current_time = 0
+ self.slider.setCurrentSecond(0)
+ self._update_time_display()
+ self.camera_view.seekToTime(0)
+ self.seeked.emit(0)
+
+ def _on_playback_tick(self):
+ if self._current_time >= self._duration:
+ self._pause()
+ return
+
+ # Advance time based on playback speed
+ dt = self._playback_timer.interval() / 1000.0 * self._playback_speed
+ self._current_time = min(self._current_time + dt, self._duration)
+ self.slider.setCurrentSecond(self._current_time)
+ self._update_time_display()
+ self.camera_view.seekToTime(self._current_time)
+ self.seeked.emit(self._current_time)
+
+ def _decrease_speed(self):
+ speeds = [0.1, 0.25, 0.5, 1.0, 2.0, 4.0]
+ idx = 0
+ for i, s in enumerate(speeds):
+ if s >= self._playback_speed:
+ idx = max(0, i - 1)
+ break
+ self._playback_speed = speeds[idx]
+ self.speed_label.setText(f"{self._playback_speed}x")
+
+ def _increase_speed(self):
+ speeds = [0.1, 0.25, 0.5, 1.0, 2.0, 4.0]
+ idx = len(speeds) - 1
+ for i, s in enumerate(speeds):
+ if s > self._playback_speed:
+ idx = i
+ break
+ self._playback_speed = speeds[idx]
+ self.speed_label.setText(f"{self._playback_speed}x")
+
+ @property
+ def isPlaying(self) -> bool:
+ return self._playing
diff --git a/uv.lock b/uv.lock
index b179517e0b26d1..83b067e88c770c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1371,6 +1371,7 @@ dev = [
{ name = "pygame" },
{ name = "pyopencl", marker = "platform_machine != 'aarch64'" },
{ name = "pyprof2calltree" },
+ { name = "pyside6" },
{ name = "pytools", marker = "platform_machine != 'aarch64'" },
{ name = "pywinctl" },
{ name = "tabulate" },
@@ -1450,6 +1451,7 @@ requires-dist = [
{ name = "pyopenssl", specifier = "<24.3.0" },
{ name = "pyprof2calltree", marker = "extra == 'dev'" },
{ name = "pyserial" },
+ { name = "pyside6", marker = "extra == 'dev'" },
{ name = "pytest", marker = "extra == 'testing'" },
{ name = "pytest-asyncio", marker = "extra == 'testing'" },
{ name = "pytest-cpp", marker = "extra == 'testing'" },
@@ -4345,6 +4347,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
]
+[[package]]
+name = "pyside6"
+version = "6.10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-addons" },
+ { name = "pyside6-essentials" },
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/22/f82cfcd1158be502c5741fe67c3fa853f3c1edbd3ac2c2250769dd9722d1/pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516", size = 558169, upload-time = "2025-11-20T10:09:08.79Z" },
+ { url = "https://files.pythonhosted.org/packages/66/eb/54afe242a25d1c33b04ecd8321a549d9efb7b89eef7690eed92e98ba1dc9/pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa", size = 557818, upload-time = "2025-11-20T10:09:10.132Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/af/5706b1b33587dc2f3dfa3a5000424befba35e4f2d5889284eebbde37138b/pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c", size = 558358, upload-time = "2025-11-20T10:09:11.288Z" },
+ { url = "https://files.pythonhosted.org/packages/26/41/3f48d724ecc8e42cea8a8442aa9b5a86d394b85093275990038fd1020039/pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e", size = 564424, upload-time = "2025-11-20T10:09:12.677Z" },
+ { url = "https://files.pythonhosted.org/packages/af/30/395411473b433875a82f6b5fdd0cb28f19a0e345bcaac9fbc039400d7072/pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437", size = 548866, upload-time = "2025-11-20T10:09:14.174Z" },
+]
+
+[[package]]
+name = "pyside6-addons"
+version = "6.10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyside6-essentials" },
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2d/f9/b72a2578d7dbef7741bb90b5756b4ef9c99a5b40148ea53ce7f048573fe9/pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82", size = 322639446, upload-time = "2025-11-20T09:54:50.75Z" },
+ { url = "https://files.pythonhosted.org/packages/94/3b/3ed951c570a15570706a89d39bfd4eaaffdf16d5c2dca17e82fc3ec8aaa6/pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4", size = 170678293, upload-time = "2025-11-20T09:56:40.991Z" },
+ { url = "https://files.pythonhosted.org/packages/22/77/4c780b204d0bf3323a75c184e349d063e208db44c993f1214aa4745d6f47/pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c", size = 166365011, upload-time = "2025-11-20T09:57:20.261Z" },
+ { url = "https://files.pythonhosted.org/packages/04/14/58239776499e6b279fa6ca2e0d47209531454b99f6bd2ad7c96f11109416/pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341", size = 164864664, upload-time = "2025-11-20T09:57:54.815Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/cd/1b74108671ba4b1ebb2661330665c4898b089e9c87f7ba69fe2438f3d1b6/pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db", size = 34191225, upload-time = "2025-11-20T09:58:04.184Z" },
+]
+
+[[package]]
+name = "pyside6-essentials"
+version = "6.10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "shiboken6" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/b0/c43209fecef79912e9b1c70a1b5172b1edf76caebcc885c58c60a09613b0/pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe", size = 105461499, upload-time = "2025-11-20T09:59:23.733Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/8e/b69ba7fa0c701f3f4136b50460441697ec49ee6ea35c229eb2a5ee4b5952/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856", size = 76764617, upload-time = "2025-11-20T09:59:38.831Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/83/569d27f4b6c6b9377150fe1a3745d64d02614021bea233636bc936a23423/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2", size = 75850373, upload-time = "2025-11-20T09:59:56.082Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/64/a8df6333de8ccbf3a320e1346ca30d0f314840aff5e3db9b4b66bf38e26c/pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674", size = 74491180, upload-time = "2025-11-20T10:00:11.215Z" },
+ { url = "https://files.pythonhosted.org/packages/67/da/65cc6c6a870d4ea908c59b2f0f9e2cf3bfc6c0710ebf278ed72f69865e4e/pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c", size = 55190458, upload-time = "2025-11-20T10:00:26.226Z" },
+]
+
[[package]]
name = "pytest"
version = "8.4.2"
@@ -4838,6 +4888,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/e9/a4560e12b9338842a1f82c9016d2543eaa084fce30a1ca11991143086b57/shapely-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:13d643256f81d55a50013eff6321142781cf777eb6a9e207c2c9e6315ba6044a", size = 1703479, upload-time = "2025-05-19T11:04:18.497Z" },
]
+[[package]]
+name = "shiboken6"
+version = "6.10.1"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/8b/e5db743d505ceea3efc4cd9634a3bee22a3e2bf6e07cefd28c9b9edabcc6/shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71", size = 478483, upload-time = "2025-11-20T10:08:52.411Z" },
+ { url = "https://files.pythonhosted.org/packages/56/ba/b50c1a44b3c4643f482afbf1a0ea58f393827307100389ce29404f9ad3b0/shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4", size = 271993, upload-time = "2025-11-20T10:08:54.093Z" },
+ { url = "https://files.pythonhosted.org/packages/16/b8/939c24ebd662b0aa5c945443d0973145b3fb7079f0196274ef7bb4b98f73/shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0", size = 268691, upload-time = "2025-11-20T10:08:55.639Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/a6/8c65ee0fa5e172ebcca03246b1bc3bd96cdaf1d60537316648536b7072a5/shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e", size = 1234704, upload-time = "2025-11-20T10:08:57.417Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/6a/c0fea2f2ac7d9d96618c98156500683a4d1f93fea0e8c5a2bc39913d7ef1/shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9", size = 1795567, upload-time = "2025-11-20T10:08:59.184Z" },
+]
+
[[package]]
name = "siphash24"
version = "1.8"