LiuYang преди 5 месеца
ревизия
b3c12c3169

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+.venv/
+.pytest_cache/
+build/
+dist/
+__pycache__/
+*.pyc
+

+ 136 - 0
README.md

@@ -0,0 +1,136 @@
+# Python MLog Tool
+
+Python desktop and CLI tool for parsing Firmament `mlog` binary files, exporting each bus to CSV, and plotting signals in a Qt GUI.
+
+## Features
+
+- Parse binary `mlog` files into structured bus data.
+- Export each bus group to an individual CSV file.
+- Inspect and compare signals in a `PySide6 + PyQtGraph` desktop GUI.
+- Build a platform-native single-file executable with `PyInstaller`.
+
+## Project Layout
+
+```text
+python_mlog_tool/
+├── README.md
+├── pyproject.toml
+├── mlog_tool_qt.spec
+├── scripts/
+│   └── build_qt_exe.sh
+│   └── build_qt_exe.ps1
+├── examples/
+│   └── example_usage.py
+├── src/
+│   ├── qt_gui_entry.py
+│   └── mlog_tool/
+│       ├── __init__.py
+│       ├── __main__.py
+│       ├── cli.py
+│       ├── constants.py
+│       ├── exporters.py
+│       ├── models.py
+│       ├── parser.py
+│       ├── plotting.py
+│       ├── qt_gui.py
+│       ├── qt_plot_widget.py
+│       ├── services.py
+│       └── utils.py
+└── tests/
+    └── test_parser.py
+```
+
+## MATLAB Mapping
+
+- `mlog_parser(logfile)` -> `MLogParser.parse(log_path)`
+- `generate_MAT(...)` -> `CsvExporter.export_all(parsed_log, output_dir)`
+- `get_param_val(...)` -> `ParsedLog.get_parameter(group_name, param_name)`
+
+## Install
+
+Install the base dependencies into the local virtual environment:
+
+```bash
+uv pip install --python ./.venv/bin/python -e .
+```
+
+Install the Qt GUI dependencies:
+
+```bash
+uv pip install --python ./.venv/bin/python ".[qt]"
+```
+
+Install the packaging dependency:
+
+```bash
+uv pip install --python ./.venv/bin/python ".[bundle]"
+```
+
+## Run From Source
+
+CLI:
+
+```bash
+PYTHONPATH=src ./.venv/bin/python -m mlog_tool parse ../mlog16.bin
+PYTHONPATH=src ./.venv/bin/python -m mlog_tool export ../mlog16.bin ./csv_output
+```
+
+Qt GUI:
+
+```bash
+PYTHONPATH=src ./.venv/bin/python -m mlog_tool.qt_gui
+```
+
+## Build Executable
+
+Build the Qt GUI into a single executable:
+
+Linux:
+
+```bash
+./scripts/build_qt_exe.sh
+```
+
+Windows PowerShell:
+
+```powershell
+.\scripts\build_qt_exe.ps1
+```
+
+Output:
+
+```text
+dist/mlog-tool-qt
+dist/mlog-tool-qt-v0.1.0-linux-x86_64
+dist/mlog-tool-qt.exe
+dist/mlog-tool-qt-v0.1.0-windows-x86_64.exe
+```
+
+Run it with:
+
+```bash
+./dist/mlog-tool-qt
+```
+
+Windows:
+
+```powershell
+.\dist\mlog-tool-qt.exe
+```
+
+On first launch, PyInstaller will unpack its runtime files into a temporary directory automatically.
+
+The build script also creates a versioned copy that is convenient for release delivery.
+
+## Notes
+
+- The Qt GUI parses the selected log immediately after opening the file.
+- The generated CSV files are organized one bus per file.
+- `src/qt_gui_entry.py` exists only as a PyInstaller-friendly GUI entry point for the onefile build.
+
+## Release Checklist
+
+1. Update `project.version` in `pyproject.toml`.
+2. Rebuild with `./scripts/build_qt_exe.sh` on Linux or `.\scripts\build_qt_exe.ps1` on Windows.
+3. Deliver the versioned file from `dist/`.
+4. Keep the unversioned `dist/mlog-tool-qt` as the latest local convenience build.

+ 31 - 0
RELEASE.md

@@ -0,0 +1,31 @@
+# Release Notes
+
+## Current Version
+
+- `0.1.0`
+
+## Release Steps
+
+```bash
+cd /home/liuyang/桌面/script/python_mlog_tool
+./scripts/build_qt_exe.sh
+```
+
+Windows PowerShell:
+
+```powershell
+cd path\to\python_mlog_tool
+.\scripts\build_qt_exe.ps1
+```
+
+## Output Files
+
+- `dist/mlog-tool-qt`
+- `dist/mlog-tool-qt-v0.1.0-linux-x86_64`
+- `dist/mlog-tool-qt.exe`
+- `dist/mlog-tool-qt-v0.1.0-windows-x86_64.exe`
+
+## Suggested Delivery
+
+- Send the versioned executable to users.
+- Keep `README.md` with the executable when sharing the tool internally.

+ 24 - 0
examples/example_usage.py

@@ -0,0 +1,24 @@
+"""Example using the current parser and CSV export service."""
+
+from pathlib import Path
+
+from mlog_tool.services import MLogService
+
+
+def main() -> None:
+    service = MLogService()
+    sample_log = Path(__file__).resolve().parents[2] / "mlog16.bin"
+    output_dir = Path(__file__).resolve().parents[1] / "example_csv_output"
+
+    parsed_log = service.parse(sample_log)
+
+    print(f"Parsed: {parsed_log.source_path}")
+    print(f"Data buses: {len(parsed_log.buses)}")
+    print(f"Exporting CSV files to: {output_dir}")
+
+    written_files = service.export_csv(parsed_log, output_dir)
+    print(f"Exported {len(written_files)} CSV files")
+
+
+if __name__ == "__main__":
+    main()

+ 52 - 0
mlog_tool_qt.spec

@@ -0,0 +1,52 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+from pathlib import Path
+
+from PyInstaller.utils.hooks import collect_data_files, copy_metadata
+
+project_root = Path.cwd()
+src_root = project_root / "src"
+
+datas = []
+datas += collect_data_files("pyqtgraph")
+datas += copy_metadata("pyqtgraph")
+datas += copy_metadata("pandas")
+datas += copy_metadata("numpy")
+
+hiddenimports = []
+
+
+a = Analysis(
+    [str(src_root / "qt_gui_entry.py")],
+    pathex=[str(src_root)],
+    binaries=[],
+    datas=datas,
+    hiddenimports=hiddenimports,
+    hookspath=[],
+    hooksconfig={},
+    runtime_hooks=[],
+    excludes=[],
+    noarchive=False,
+    optimize=0,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+    pyz,
+    a.scripts,
+    a.binaries,
+    a.datas,
+    [],
+    exclude_binaries=False,
+    name="mlog-tool-qt",
+    debug=False,
+    bootloader_ignore_signals=False,
+    strip=False,
+    upx=True,
+    console=False,
+    disable_windowed_traceback=False,
+    argv_emulation=False,
+    target_arch=None,
+    codesign_identity=None,
+    entitlements_file=None,
+)

+ 38 - 0
pyproject.toml

@@ -0,0 +1,38 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "python-mlog-tool"
+version = "0.1.0"
+description = "Parse MLog binaries, export bus data to CSV, and plot signals."
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+    "matplotlib>=3.8",
+    "numpy>=1.26",
+    "pandas>=2.2",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=8.0",
+]
+qt = [
+    "PySide6>=6.7",
+    "pyqtgraph>=0.13",
+]
+bundle = [
+    "pyinstaller>=6.0",
+]
+
+[project.scripts]
+mlog-tool = "mlog_tool.cli:main"
+mlog-tool-gui = "mlog_tool.qt_gui:main"
+mlog-tool-qt = "mlog_tool.qt_gui:main"
+
+[tool.setuptools]
+package-dir = {"" = "src"}
+
+[tool.setuptools.packages.find]
+where = ["src"]

+ 47 - 0
scripts/build_qt_exe.ps1

@@ -0,0 +1,47 @@
+$ErrorActionPreference = "Stop"
+
+$RootDir = Split-Path -Parent $PSScriptRoot
+Set-Location $RootDir
+
+$PythonBin = if ($env:PYTHON_BIN) { $env:PYTHON_BIN } else { ".\.venv\Scripts\python.exe" }
+$PyInstallerBin = if ($env:PYINSTALLER_BIN) { $env:PYINSTALLER_BIN } else { ".\.venv\Scripts\pyinstaller.exe" }
+
+if (-not (Test-Path $PythonBin)) {
+    Write-Error "Python executable not found: $PythonBin"
+}
+
+if (-not (Test-Path $PyInstallerBin)) {
+    Write-Error "PyInstaller executable not found: $PyInstallerBin`nInstall it with: uv pip install --python .\.venv\Scripts\python.exe pyinstaller"
+}
+
+$Version = (& $PythonBin -c "from pathlib import Path; import tomllib; print(tomllib.loads(Path('pyproject.toml').read_text(encoding='utf-8'))['project']['version'])").Trim()
+$ArchName = if ([Environment]::Is64BitOperatingSystem) { "x86_64" } else { "x86" }
+$MainOutput = "dist/mlog-tool-qt.exe"
+$VersionedOutput = "dist/mlog-tool-qt-v$Version-windows-$ArchName.exe"
+
+Write-Host "Building Qt executable with $PyInstallerBin"
+
+$ResetQtPlatform = $false
+if (-not $env:QT_QPA_PLATFORM) {
+    $env:QT_QPA_PLATFORM = "offscreen"
+    $ResetQtPlatform = $true
+}
+
+try {
+    & $PyInstallerBin --noconfirm --clean mlog_tool_qt.spec
+    if ($LASTEXITCODE -ne 0) {
+        exit $LASTEXITCODE
+    }
+
+    Copy-Item $MainOutput $VersionedOutput -Force
+}
+finally {
+    if ($ResetQtPlatform) {
+        Remove-Item Env:QT_QPA_PLATFORM -ErrorAction SilentlyContinue
+    }
+}
+
+Write-Host ""
+Write-Host "Build finished:"
+Write-Host "  $MainOutput"
+Write-Host "  $VersionedOutput"

+ 34 - 0
scripts/build_qt_exe.sh

@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT_DIR"
+
+PYTHON_BIN="${PYTHON_BIN:-./.venv/bin/python}"
+PYINSTALLER_BIN="${PYINSTALLER_BIN:-./.venv/bin/pyinstaller}"
+
+if [[ ! -x "$PYTHON_BIN" ]]; then
+  echo "Python executable not found: $PYTHON_BIN" >&2
+  exit 1
+fi
+
+if [[ ! -x "$PYINSTALLER_BIN" ]]; then
+  echo "PyInstaller executable not found: $PYINSTALLER_BIN" >&2
+  echo "Install it with: uv pip install --python ./.venv/bin/python pyinstaller" >&2
+  exit 1
+fi
+
+VERSION="$("$PYTHON_BIN" -c "from pathlib import Path; import tomllib; print(tomllib.loads(Path('pyproject.toml').read_text(encoding='utf-8'))['project']['version'])")"
+OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
+ARCH_NAME="$(uname -m)"
+VERSIONED_OUTPUT="dist/mlog-tool-qt-v${VERSION}-${OS_NAME}-${ARCH_NAME}"
+
+echo "Building Qt executable with $PYINSTALLER_BIN"
+QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" "$PYINSTALLER_BIN" --noconfirm --clean mlog_tool_qt.spec
+
+cp dist/mlog-tool-qt "$VERSIONED_OUTPUT"
+
+echo
+echo "Build finished:"
+echo "  dist/mlog-tool-qt"
+echo "  $VERSIONED_OUTPUT"

+ 14 - 0
src/mlog_tool/__init__.py

@@ -0,0 +1,14 @@
+"""Top-level package for the Python MLog tool."""
+
+from importlib.metadata import PackageNotFoundError, version
+
+from .models import ParsedLog
+from .parser import MLogParser
+from .services import MLogService
+
+try:
+    __version__ = version("python-mlog-tool")
+except PackageNotFoundError:
+    __version__ = "0.1.0"
+
+__all__ = ["MLogParser", "MLogService", "ParsedLog", "__version__"]

+ 7 - 0
src/mlog_tool/__main__.py

@@ -0,0 +1,7 @@
+"""Module entry point for `python -m mlog_tool`."""
+
+from .cli import main
+
+
+if __name__ == "__main__":
+    main()

+ 47 - 0
src/mlog_tool/cli.py

@@ -0,0 +1,47 @@
+"""Command-line interface for the Python MLog tool."""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+from . import __version__
+from .services import MLogService
+
+
+def build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(description="Parse MLog binaries and export CSV files.")
+    parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
+    subparsers = parser.add_subparsers(dest="command", required=True)
+
+    parse_cmd = subparsers.add_parser("parse", help="Parse a log file and print a summary.")
+    parse_cmd.add_argument("log_file", type=Path, help="Path to a .bin or .log file.")
+
+    export_cmd = subparsers.add_parser("export", help="Parse a log file and export all buses to CSV.")
+    export_cmd.add_argument("log_file", type=Path, help="Path to a .bin or .log file.")
+    export_cmd.add_argument("output_dir", type=Path, help="Directory for generated CSV files.")
+
+    return parser
+
+
+def main() -> None:
+    parser = build_parser()
+    args = parser.parse_args()
+    service = MLogService()
+
+    if args.command == "parse":
+        parsed_log = service.parse(args.log_file)
+        print(f"Parsed: {parsed_log.source_path}")
+        print(f"Bus count: {len(parsed_log.header.buses)}")
+        print(f"Parameter groups: {len(parsed_log.header.parameter_groups)}")
+        print(f"Data buses: {len(parsed_log.buses)}")
+        return
+
+    if args.command == "export":
+        parsed_log = service.parse(args.log_file)
+        written_files = service.export_csv(parsed_log, args.output_dir)
+        print(f"Exported {len(written_files)} CSV files to {args.output_dir}")
+
+
+if __name__ == "__main__":
+    main()

+ 42 - 0
src/mlog_tool/constants.py

@@ -0,0 +1,42 @@
+"""Shared constants for the MLog binary format."""
+
+from __future__ import annotations
+
+import struct
+
+MLOG_BEGIN_MSG1 = 0x92
+MLOG_BEGIN_MSG2 = 0x05
+MLOG_END_MSG = 0x26
+
+# MATLAB uses an indexed list. Python uses the numeric type id as the key.
+TYPE_STRUCT_FORMATS: dict[int, str] = {
+    0: "b",   # int8
+    1: "B",   # uint8
+    2: "h",   # int16
+    3: "H",   # uint16
+    4: "i",   # int32
+    5: "I",   # uint32
+    6: "f",   # float
+    7: "d",   # double
+    8: "q",   # int64
+    9: "Q",   # uint64
+}
+
+TYPE_DISPLAY_NAMES: dict[int, str] = {
+    0: "int8",
+    1: "uint8",
+    2: "int16",
+    3: "uint16",
+    4: "int32",
+    5: "uint32",
+    6: "float",
+    7: "double",
+    8: "int64",
+    9: "uint64",
+}
+
+
+def type_size(type_id: int) -> int:
+    """Return the byte size for a binary scalar type id."""
+
+    return struct.calcsize("<" + TYPE_STRUCT_FORMATS[type_id])

+ 22 - 0
src/mlog_tool/exporters.py

@@ -0,0 +1,22 @@
+"""CSV export helpers for parsed bus data."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from .models import ParsedLog
+
+
+class CsvExporter:
+    """Export each bus table in a parsed log to an individual CSV file."""
+
+    def export_all(self, parsed_log: ParsedLog, output_dir: str | Path) -> list[Path]:
+        output_path = Path(output_dir)
+        output_path.mkdir(parents=True, exist_ok=True)
+
+        written_files: list[Path] = []
+        for bus_name, bus_frame in parsed_log.buses.items():
+            csv_path = output_path / f"{bus_name}.csv"
+            bus_frame.frame.to_csv(csv_path, index=False)
+            written_files.append(csv_path)
+        return written_files

+ 95 - 0
src/mlog_tool/models.py

@@ -0,0 +1,95 @@
+"""Dataclasses shared across parsing, export, and plotting layers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+
+@dataclass(slots=True)
+class ElementDefinition:
+    name: str
+    type_id: int
+    count: int
+
+
+@dataclass(slots=True)
+class BusDefinition:
+    name: str
+    msg_id: int
+    elements: list[ElementDefinition] = field(default_factory=list)
+
+
+@dataclass(slots=True)
+class ParameterDefinition:
+    name: str
+    type_id: int
+    value: Any
+
+
+@dataclass(slots=True)
+class ParameterGroup:
+    name: str
+    parameters: list[ParameterDefinition] = field(default_factory=list)
+
+
+@dataclass(slots=True)
+class LogHeader:
+    version: int
+    timestamp: int
+    max_name_len: int
+    max_desc_len: int
+    max_model_info_len: int
+    description: str
+    model_info: str
+    buses: list[BusDefinition] = field(default_factory=list)
+    parameter_groups: list[ParameterGroup] = field(default_factory=list)
+
+
+@dataclass(slots=True)
+class BusFrame:
+    name: str
+    frame: pd.DataFrame
+    timestamp_field: str | None = None
+
+
+@dataclass(slots=True)
+class PlotTraceSpec:
+    bus_name: str
+    field_name: str
+    subplot_index: int = 0
+    label: str | None = None
+
+
+@dataclass(slots=True)
+class ParsedLog:
+    source_path: Path
+    header: LogHeader
+    buses: dict[str, BusFrame] = field(default_factory=dict)
+
+    def get_bus(self, bus_name: str) -> BusFrame | None:
+        """Return a bus frame by name."""
+
+        return self.buses.get(bus_name)
+
+    def get_parameter(self, group_name: str, param_name: str) -> Any | None:
+        """Find a parameter value by group and parameter name."""
+
+        for group in self.header.parameter_groups:
+            if group.name != group_name:
+                continue
+            for parameter in group.parameters:
+                if parameter.name == param_name:
+                    return parameter.value
+        return None
+
+    def get_parameter_group(self, group_name: str) -> ParameterGroup | None:
+        """Return a parameter group by name."""
+
+        for group in self.header.parameter_groups:
+            if group.name == group_name:
+                return group
+        return None

+ 245 - 0
src/mlog_tool/parser.py

@@ -0,0 +1,245 @@
+"""Binary parser for Firmament MLog files."""
+
+from __future__ import annotations
+
+import struct
+from pathlib import Path
+from typing import Any, BinaryIO
+
+import pandas as pd
+
+from .constants import MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, MLOG_END_MSG, TYPE_STRUCT_FORMATS
+from .models import (
+    BusDefinition,
+    BusFrame,
+    ElementDefinition,
+    LogHeader,
+    ParameterDefinition,
+    ParameterGroup,
+    ParsedLog,
+)
+from .utils import clean_c_string, normalize_field_name
+
+
+class MLogParser:
+    """
+    Parse an MLog file into structured Python objects.
+
+    This mirrors the MATLAB flow:
+    - read file header
+    - read bus definitions
+    - read parameter groups
+    - scan payload messages
+    - aggregate each bus into a DataFrame
+    """
+
+    def parse(self, log_path: str | Path) -> ParsedLog:
+        """Parse a binary log file."""
+
+        path = Path(log_path)
+        with path.open("rb") as stream:
+            header = self._read_header(stream)
+            header.buses = self._read_bus_definitions(stream, header.max_name_len)
+            header.parameter_groups = self._read_parameter_groups(stream, header.max_name_len)
+            buses = self._read_messages(stream, header)
+
+        return ParsedLog(source_path=path, header=header, buses=buses)
+
+    def _read_header(self, stream: BinaryIO) -> LogHeader:
+        """Read the fixed header section from the log file."""
+
+        version = self._read_scalar(stream, "H")
+        timestamp = self._read_scalar(stream, "I")
+        max_name_len = self._read_scalar(stream, "H")
+        max_desc_len = self._read_scalar(stream, "H")
+        max_model_info_len = self._read_scalar(stream, "H")
+        description = self._read_fixed_string(stream, max_desc_len)
+        model_info = self._read_fixed_string(stream, max_model_info_len)
+
+        return LogHeader(
+            version=version,
+            timestamp=timestamp,
+            max_name_len=max_name_len,
+            max_desc_len=max_desc_len,
+            max_model_info_len=max_model_info_len,
+            description=description,
+            model_info=model_info,
+        )
+
+    def _read_bus_definitions(self, stream: BinaryIO, max_name_len: int) -> list[BusDefinition]:
+        """Read the bus schema definitions."""
+
+        bus_count = self._read_scalar(stream, "B")
+        buses: list[BusDefinition] = []
+
+        for bus_index in range(bus_count):
+            bus_name = self._read_fixed_string(stream, max_name_len) or f"bus_{bus_index}"
+            msg_id = self._read_scalar(stream, "B")
+            element_count = self._read_scalar(stream, "B")
+            elements: list[ElementDefinition] = []
+
+            for element_index in range(element_count):
+                element_name = self._read_fixed_string(stream, max_name_len) or f"field_{element_index}"
+                type_id = self._read_scalar(stream, "H")
+                count = self._read_scalar(stream, "H")
+                elements.append(ElementDefinition(name=element_name, type_id=type_id, count=count))
+
+            buses.append(BusDefinition(name=bus_name, msg_id=msg_id, elements=elements))
+
+        return buses
+
+    def _read_parameter_groups(self, stream: BinaryIO, max_name_len: int) -> list[ParameterGroup]:
+        """Read parameter groups from the header area."""
+
+        group_count = self._read_scalar(stream, "B")
+        groups: list[ParameterGroup] = []
+
+        for group_index in range(group_count):
+            group_name = self._read_fixed_string(stream, max_name_len) or f"group_{group_index}"
+            param_count = self._read_scalar(stream, "I")
+            parameters: list[ParameterDefinition] = []
+
+            for param_index in range(param_count):
+                param_name = self._read_fixed_string(stream, max_name_len) or f"param_{param_index}"
+                type_id = self._read_scalar(stream, "B")
+                value = self._read_typed_scalar(stream, type_id)
+                parameters.append(ParameterDefinition(name=param_name, type_id=type_id, value=value))
+
+            groups.append(ParameterGroup(name=group_name, parameters=parameters))
+
+        return groups
+
+    def _read_messages(self, stream: BinaryIO, header: LogHeader) -> dict[str, BusFrame]:
+        """Scan the payload message area and aggregate valid messages by bus."""
+
+        buses_by_id = {bus.msg_id: bus for bus in header.buses}
+        records_by_bus: dict[str, list[dict[str, Any]]] = {bus.name: [] for bus in header.buses}
+
+        while self._seek_next_message(stream):
+            msg_id_raw = stream.read(1)
+            if not msg_id_raw:
+                break
+
+            bus = buses_by_id.get(msg_id_raw[0])
+            if bus is None:
+                continue
+
+            row: dict[str, Any] = {}
+            try:
+                for element_index, element in enumerate(bus.elements):
+                    values = self._read_typed_values(stream, element.type_id, element.count)
+                    column_name = normalize_field_name(element.name) or f"field_{element_index}"
+
+                    if element.count == 1:
+                        row[column_name] = values[0]
+                    else:
+                        for value_index, value in enumerate(values):
+                            row[f"{column_name}_{value_index}"] = value
+            except EOFError:
+                break
+
+            msg_end_raw = stream.read(1)
+            if not msg_end_raw:
+                break
+            if msg_end_raw[0] != MLOG_END_MSG:
+                continue
+
+            records_by_bus[bus.name].append(row)
+
+        return self._build_bus_frames(header.buses, records_by_bus)
+
+    def _build_bus_frames(
+        self,
+        bus_definitions: list[BusDefinition],
+        records_by_bus: dict[str, list[dict[str, Any]]],
+    ) -> dict[str, BusFrame]:
+        """Convert aggregated message rows into DataFrames."""
+
+        buses: dict[str, BusFrame] = {}
+
+        for bus in bus_definitions:
+            rows = records_by_bus.get(bus.name, [])
+            if not rows:
+                continue
+
+            frame = pd.DataFrame(rows)
+            timestamp_field = self._find_timestamp_field(bus)
+
+            if timestamp_field and timestamp_field in frame.columns:
+                relative_time_name = "time_s"
+                if relative_time_name in frame.columns:
+                    relative_time_name = "relative_time_s"
+
+                timestamp_series = pd.to_numeric(frame[timestamp_field], errors="coerce")
+                relative_time = (timestamp_series - timestamp_series.iloc[0]) * 0.001
+                insert_at = list(frame.columns).index(timestamp_field) + 1
+                frame.insert(insert_at, relative_time_name, relative_time)
+                buses[bus.name] = BusFrame(name=bus.name, frame=frame, timestamp_field=relative_time_name)
+                continue
+
+            buses[bus.name] = BusFrame(name=bus.name, frame=frame, timestamp_field=None)
+
+        return buses
+
+    def _find_timestamp_field(self, bus: BusDefinition) -> str | None:
+        """Return the normalized timestamp field name if the bus exposes one."""
+
+        for element in bus.elements:
+            element_name = element.name.strip()
+            if element_name in {"timestamp_ms", "timestamp"} and element.count == 1:
+                return normalize_field_name(element_name)
+        return None
+
+    def _seek_next_message(self, stream: BinaryIO) -> bool:
+        """Scan until the next message start marker is found."""
+
+        begin_state = 0
+
+        while True:
+            current = stream.read(1)
+            if not current:
+                return False
+
+            byte = current[0]
+            if begin_state == 0:
+                begin_state = 1 if byte == MLOG_BEGIN_MSG1 else 0
+                continue
+
+            if byte == MLOG_BEGIN_MSG2:
+                return True
+
+            begin_state = 1 if byte == MLOG_BEGIN_MSG1 else 0
+
+    def _read_fixed_string(self, stream: BinaryIO, length: int) -> str:
+        """Read a fixed-length string field."""
+
+        return clean_c_string(self._read_exact(stream, length))
+
+    def _read_typed_scalar(self, stream: BinaryIO, type_id: int) -> Any:
+        """Read a scalar value using a format id defined by the log file."""
+
+        return self._read_typed_values(stream, type_id, 1)[0]
+
+    def _read_typed_values(self, stream: BinaryIO, type_id: int, count: int) -> tuple[Any, ...]:
+        """Read one or more values using a log-defined numeric type."""
+
+        if type_id not in TYPE_STRUCT_FORMATS:
+            raise ValueError(f"Unsupported MLog type id: {type_id}")
+
+        fmt = "<" + TYPE_STRUCT_FORMATS[type_id] * count
+        payload = self._read_exact(stream, struct.calcsize(fmt))
+        return struct.unpack(fmt, payload)
+
+    def _read_scalar(self, stream: BinaryIO, fmt: str) -> int:
+        """Read a little-endian scalar with a raw struct format code."""
+
+        payload = self._read_exact(stream, struct.calcsize("<" + fmt))
+        return struct.unpack("<" + fmt, payload)[0]
+
+    def _read_exact(self, stream: BinaryIO, size: int) -> bytes:
+        """Read an exact number of bytes or raise EOFError."""
+
+        payload = stream.read(size)
+        if len(payload) != size:
+            raise EOFError("Unexpected end of MLog file.")
+        return payload

+ 129 - 0
src/mlog_tool/plotting.py

@@ -0,0 +1,129 @@
+"""Plotting helpers built on top of matplotlib."""
+
+from __future__ import annotations
+
+import matplotlib.pyplot as plt
+import numpy as np
+from matplotlib.figure import Figure
+
+from .models import ParsedLog, PlotTraceSpec
+
+
+class LogPlotter:
+    """Create quick-look plots for selected bus fields."""
+
+    def _resolve_trace(self, parsed_log: ParsedLog, trace: PlotTraceSpec) -> tuple[object, str, object, str]:
+        """Resolve one trace spec into x data, x label, y data, and legend label."""
+
+        bus = parsed_log.get_bus(trace.bus_name)
+        if bus is None:
+            raise ValueError(f"Unknown bus: {trace.bus_name}")
+        if trace.field_name not in bus.frame.columns:
+            raise ValueError(f"Unknown field '{trace.field_name}' in bus '{trace.bus_name}'")
+
+        frame = bus.frame
+        if bus.timestamp_field and bus.timestamp_field in frame.columns:
+            x_values = frame[bus.timestamp_field]
+            x_label = bus.timestamp_field
+        else:
+            x_values = frame.index
+            x_label = "index"
+
+        y_values = frame[trace.field_name]
+        legend_label = trace.label or f"{trace.bus_name}.{trace.field_name}"
+        return x_values, x_label, y_values, legend_label
+
+    def build_figure(self, parsed_log: ParsedLog, bus_name: str, fields: list[str]) -> Figure:
+        """Build a single-axis figure for selected fields from one bus."""
+
+        traces = [
+            PlotTraceSpec(bus_name=bus_name, field_name=field_name, subplot_index=0)
+            for field_name in fields
+        ]
+        return self.build_comparison_figure(parsed_log, [traces], subplot_count=1)
+
+    def build_comparison_figure(
+        self,
+        parsed_log: ParsedLog,
+        subplot_traces: list[list[PlotTraceSpec]],
+        subplot_count: int = 2,
+    ) -> Figure:
+        """Build a figure with one or two subplots that may mix different buses."""
+
+        plot_count = 1 if subplot_count <= 1 else 2
+        figure = Figure(figsize=(9, 4.6) if plot_count == 1 else (12, 4.8), dpi=100)
+        axes = figure.subplots(1, plot_count, sharex=True, squeeze=False)
+        axis_list = list(axes[0, :])
+        cursor_payload: list[dict[str, object]] = []
+
+        for subplot_index in range(plot_count):
+            axis = axis_list[subplot_index]
+            traces = subplot_traces[subplot_index] if subplot_index < len(subplot_traces) else []
+            axis_payload: dict[str, object] = {"axis_index": subplot_index, "traces": [], "x_label": "index"}
+
+            if not traces:
+                axis.text(
+                    0.5,
+                    0.5,
+                    f"Subplot {subplot_index + 1}\nNo traces selected",
+                    ha="center",
+                    va="center",
+                    wrap=True,
+                )
+                axis.set_xticks([])
+                axis.set_yticks([])
+                for spine in axis.spines.values():
+                    spine.set_visible(False)
+                cursor_payload.append(axis_payload)
+                continue
+
+            x_labels: list[str] = []
+            for trace in traces:
+                x_values, x_label, y_values, legend_label = self._resolve_trace(parsed_log, trace)
+                axis.plot(x_values, y_values, label=legend_label)
+                x_labels.append(x_label)
+                axis_payload["traces"].append(
+                    {
+                        "label": legend_label,
+                        "x": np.asarray(x_values, dtype=float),
+                        "y": np.asarray(y_values, dtype=float),
+                    }
+                )
+
+            xlabel = x_labels[0] if len(set(x_labels)) == 1 else "time / index"
+            axis_payload["x_label"] = xlabel
+            axis.set_title(f"Subplot {subplot_index + 1}")
+            axis.set_xlabel(xlabel)
+            axis.grid(True, alpha=0.3)
+            axis.legend(loc="best")
+            cursor_payload.append(axis_payload)
+
+        figure.tight_layout()
+        figure._mlog_cursor_payload = cursor_payload
+        return figure
+
+    def plot_fields(self, parsed_log: ParsedLog, bus_name: str, fields: list[str]) -> None:
+        """Show a standalone interactive plot window."""
+
+        traces = [
+            PlotTraceSpec(bus_name=bus_name, field_name=field_name, subplot_index=0)
+            for field_name in fields
+        ]
+        if not traces:
+            raise ValueError("No plottable fields selected.")
+
+        plot_count = 1
+        figure, axes = plt.subplots(plot_count, 1, figsize=(9, 4.6), squeeze=False)
+        axis = axes[0, 0]
+
+        x_label = "index"
+        for trace in traces:
+            x_values, x_label, y_values, legend_label = self._resolve_trace(parsed_log, trace)
+            axis.plot(x_values, y_values, label=legend_label)
+
+        axis.set_title(bus_name)
+        axis.set_xlabel(x_label)
+        axis.grid(True, alpha=0.3)
+        axis.legend(loc="best")
+        figure.tight_layout()
+        plt.show()

+ 690 - 0
src/mlog_tool/qt_gui.py

@@ -0,0 +1,690 @@
+"""PySide6 + PyQtGraph desktop GUI."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+from . import __version__
+from .constants import TYPE_DISPLAY_NAMES
+from .models import ParsedLog, PlotTraceSpec
+from .services import MLogService
+
+QT_IMPORT_ERROR: Exception | None = None
+
+try:
+    from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
+    from PySide6.QtGui import QAction, QColor, QBrush, QFont
+    from PySide6.QtWidgets import (
+        QApplication,
+        QFileDialog,
+        QHeaderView,
+        QLabel,
+        QMainWindow,
+        QMenu,
+        QMessageBox,
+        QPlainTextEdit,
+        QSplitter,
+        QTableView,
+        QToolBar,
+        QToolButton,
+        QTreeWidget,
+        QTreeWidgetItem,
+        QVBoxLayout,
+        QWidget,
+    )
+
+    from .qt_plot_widget import ComparisonPlotWidget
+except Exception as exc:  # pragma: no cover - depends on local Qt install
+    QT_IMPORT_ERROR = exc
+
+
+if QT_IMPORT_ERROR is None:
+    ITEM_KIND_ROLE = Qt.ItemDataRole.UserRole + 20
+    ITEM_VALUE_ROLE = Qt.ItemDataRole.UserRole + 21
+    ITEM_PARENT_ROLE = Qt.ItemDataRole.UserRole + 22
+
+    class DataFrameTableModel(QAbstractTableModel):
+        """Lightweight table model for large pandas DataFrames."""
+
+        def __init__(self, frame: pd.DataFrame | None = None) -> None:
+            super().__init__()
+            self._frame = frame if frame is not None else pd.DataFrame()
+
+        def set_frame(self, frame: pd.DataFrame) -> None:
+            self.beginResetModel()
+            self._frame = frame
+            self.endResetModel()
+
+        def clear(self) -> None:
+            self.set_frame(pd.DataFrame())
+
+        def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:  # noqa: N802
+            if parent.isValid():
+                return 0
+            return len(self._frame.index)
+
+        def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:  # noqa: N802
+            if parent.isValid():
+                return 0
+            return len(self._frame.columns)
+
+        def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
+            if not index.isValid():
+                return None
+
+            value = self._frame.iat[index.row(), index.column()]
+
+            if role == Qt.ItemDataRole.DisplayRole:
+                if pd.isna(value):
+                    return ""
+                return str(value)
+
+            if role == Qt.ItemDataRole.TextAlignmentRole:
+                if pd.api.types.is_number(value) and not pd.isna(value):
+                    return int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
+                return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
+
+            return None
+
+        def headerData(
+            self,
+            section: int,
+            orientation: Qt.Orientation,
+            role: int = Qt.ItemDataRole.DisplayRole,
+        ) -> Any:  # noqa: N802
+            if role != Qt.ItemDataRole.DisplayRole:
+                return None
+
+            if orientation == Qt.Orientation.Horizontal:
+                if 0 <= section < len(self._frame.columns):
+                    return str(self._frame.columns[section])
+                return None
+
+            if 0 <= section < len(self._frame.index):
+                return str(self._frame.index[section])
+            return None
+
+    class QtMLogMainWindow(QMainWindow):
+        """Qt main window for the MLog parser and plot workflow."""
+
+        def __init__(self) -> None:
+            super().__init__()
+            self.setWindowTitle(f"MLog Tool Qt v{__version__}")
+            self.resize(1440, 860)
+
+            self.service = MLogService()
+            self.parsed_log: ParsedLog | None = None
+            self.selected_log: Path | None = None
+            self.subplot_count = 2
+            self.subplot_traces: list[list[PlotTraceSpec]] = [[], []]
+            self._updating_tree = False
+            self.preview_model = DataFrameTableModel()
+
+            self._build_ui()
+
+        def _build_ui(self) -> None:
+            toolbar = QToolBar("Main")
+            toolbar.setMovable(False)
+            self.addToolBar(toolbar)
+
+            open_action = QAction("Open Log", self)
+            open_action.triggered.connect(self.open_log_file)
+            toolbar.addAction(open_action)
+
+            export_action = QAction("Export CSV", self)
+            export_action.triggered.connect(self.export_csv)
+            toolbar.addAction(export_action)
+
+            refresh_action = QAction("Refresh Plot", self)
+            refresh_action.triggered.connect(self.refresh_plot)
+            toolbar.addAction(refresh_action)
+
+            cursor_action = QAction("Cursor", self)
+            cursor_action.setCheckable(True)
+            cursor_action.setShortcut("C")
+            cursor_action.toggled.connect(self._toggle_cursor)
+            toolbar.addAction(cursor_action)
+
+            toolbar.addSeparator()
+
+            layout_menu = QMenu(self)
+            one_subplot_action = QAction("1 Subplot", self)
+            one_subplot_action.triggered.connect(lambda: self.set_subplot_count(1))
+            two_subplot_action = QAction("2 Subplots", self)
+            two_subplot_action.triggered.connect(lambda: self.set_subplot_count(2))
+            layout_menu.addAction(one_subplot_action)
+            layout_menu.addAction(two_subplot_action)
+
+            layout_button = QToolButton()
+            layout_button.setText("Layout")
+            layout_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+            layout_button.setMenu(layout_menu)
+            toolbar.addWidget(layout_button)
+
+            toolbar.addSeparator()
+
+            clear_menu = QMenu(self)
+            clear_s1_action = QAction("Clear S1", self)
+            clear_s1_action.triggered.connect(lambda: self.clear_target_subplot(0))
+            clear_s2_action = QAction("Clear S2", self)
+            clear_s2_action.triggered.connect(lambda: self.clear_target_subplot(1))
+            clear_all_action = QAction("Clear All", self)
+            clear_all_action.triggered.connect(self.clear_all_traces)
+            clear_menu.addAction(clear_s1_action)
+            clear_menu.addAction(clear_s2_action)
+            clear_menu.addSeparator()
+            clear_menu.addAction(clear_all_action)
+
+            clear_button = QToolButton()
+            clear_button.setText("Clear")
+            clear_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+            clear_button.setMenu(clear_menu)
+            toolbar.addWidget(clear_button)
+
+            central = QWidget()
+            self.setCentralWidget(central)
+            root_layout = QVBoxLayout(central)
+            root_layout.setContentsMargins(10, 10, 10, 10)
+
+            self.path_label = QLabel("No log selected.")
+            self.summary_label = QLabel("Select a log file to parse it automatically.")
+            root_layout.addWidget(self.path_label)
+            root_layout.addWidget(self.summary_label)
+
+            splitter = QSplitter(Qt.Orientation.Horizontal)
+            root_layout.addWidget(splitter, 1)
+
+            self.signal_tree = QTreeWidget()
+            self.signal_tree.setColumnCount(3)
+            self.signal_tree.setHeaderLabels(["Items", "S1", "S2"])
+            self.signal_tree.setAlternatingRowColors(True)
+            self.signal_tree.setColumnWidth(0, 380)
+            self.signal_tree.setColumnWidth(1, 48)
+            self.signal_tree.setColumnWidth(2, 48)
+            self.signal_tree.itemSelectionChanged.connect(self.on_tree_selection_changed)
+            self.signal_tree.itemChanged.connect(self.on_tree_item_changed)
+            splitter.addWidget(self.signal_tree)
+
+            right_splitter = QSplitter(Qt.Orientation.Vertical)
+            splitter.addWidget(right_splitter)
+
+            self.plot_widget = ComparisonPlotWidget()
+            right_splitter.addWidget(self.plot_widget)
+
+            self.preview = QPlainTextEdit()
+            self.preview.setReadOnly(True)
+            self.preview.setMaximumHeight(96)
+
+            preview_container = QWidget()
+            preview_layout = QVBoxLayout(preview_container)
+            preview_layout.setContentsMargins(0, 0, 0, 0)
+            preview_layout.addWidget(self.preview)
+
+            self.preview_table = QTableView()
+            self.preview_table.setModel(self.preview_model)
+            self.preview_table.setAlternatingRowColors(True)
+            self.preview_table.setShowGrid(True)
+            self.preview_table.setWordWrap(False)
+            self.preview_table.verticalHeader().setVisible(False)
+            self.preview_table.horizontalHeader().setStretchLastSection(False)
+            self.preview_table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
+            self.preview_table.setSortingEnabled(False)
+            preview_layout.addWidget(self.preview_table, 1)
+
+            right_splitter.addWidget(preview_container)
+
+            splitter.setSizes([420, 980])
+            right_splitter.setSizes([620, 220])
+
+        def open_log_file(self) -> None:
+            selected, _ = QFileDialog.getOpenFileName(
+                self,
+                "Select MLog file",
+                "",
+                "MLog files (*.bin *.log);;All files (*.*)",
+            )
+            if not selected:
+                return
+
+            self.selected_log = Path(selected)
+            self.path_label.setText(str(self.selected_log))
+            self.summary_label.setText("Log selected. Parsing...")
+            self.parse_log()
+
+        def parse_log(self) -> None:
+            if self.selected_log is None:
+                QMessageBox.information(self, "No file", "Select a log file first.")
+                return
+
+            try:
+                self.parsed_log = self.service.parse(self.selected_log)
+            except Exception as exc:
+                QMessageBox.critical(self, "Parse failed", str(exc))
+                return
+
+            self.subplot_traces = [[], []]
+            self._populate_tree()
+            self.summary_label.setText(
+                f"Parsed {len(self.parsed_log.header.buses)} bus definitions, "
+                f"{len(self.parsed_log.header.parameter_groups)} parameter groups, "
+                f"{len(self.parsed_log.buses)} buses with data."
+            )
+            self.refresh_plot()
+
+            first_bus = self._first_bus_item()
+            if first_bus is not None:
+                first_bus.setExpanded(True)
+                self.signal_tree.setCurrentItem(first_bus)
+                self.on_tree_selection_changed()
+            elif self.signal_tree.topLevelItemCount() > 0:
+                first_item = self.signal_tree.topLevelItem(0)
+                first_item.setExpanded(True)
+                self.signal_tree.setCurrentItem(first_item)
+                self.on_tree_selection_changed()
+
+        def export_csv(self) -> None:
+            if self.parsed_log is None:
+                QMessageBox.information(self, "No data", "Parse a log file first.")
+                return
+
+            output_dir = QFileDialog.getExistingDirectory(self, "Select output folder")
+            if not output_dir:
+                return
+
+            try:
+                written = self.service.export_csv(self.parsed_log, output_dir)
+            except Exception as exc:
+                QMessageBox.critical(self, "Export failed", str(exc))
+                return
+
+            QMessageBox.information(self, "Export complete", f"Exported {len(written)} CSV files.")
+
+        def refresh_plot(self) -> None:
+            self.plot_widget.set_plot_data(self.parsed_log, self.subplot_traces, self.subplot_count)
+
+        def set_subplot_count(self, count: int) -> None:
+            self.subplot_count = 1 if count <= 1 else 2
+            self.refresh_plot()
+
+        def clear_target_subplot(self, subplot_index: int) -> None:
+            if subplot_index not in (0, 1):
+                return
+            self.subplot_traces[subplot_index] = []
+            self._refresh_tree_state()
+            self.refresh_plot()
+
+        def clear_all_traces(self) -> None:
+            self.subplot_traces = [[], []]
+            self._refresh_tree_state()
+            self.refresh_plot()
+
+        def _toggle_cursor(self, enabled: bool) -> None:
+            self.plot_widget.set_cursor_enabled(enabled)
+
+        def on_tree_selection_changed(self) -> None:
+            item = self.signal_tree.currentItem()
+            if item is None or self.parsed_log is None:
+                self.preview.setPlainText("")
+                self._clear_preview_table()
+                return
+
+            item_kind = item.data(0, ITEM_KIND_ROLE)
+            if item_kind in {"bus", "field"}:
+                bus_name = item.data(0, ITEM_VALUE_ROLE)
+                self._show_bus_preview(str(bus_name))
+                return
+
+            if item_kind == "param_root":
+                self._show_parameter_root_preview()
+                return
+
+            if item_kind == "bus_root":
+                self._show_bus_root_preview()
+                return
+
+            if item_kind in {"param_group", "param"}:
+                group_name = item.data(0, ITEM_VALUE_ROLE)
+                if item_kind == "param":
+                    group_name = item.data(0, ITEM_PARENT_ROLE)
+                if group_name:
+                    self._show_parameter_group_preview(str(group_name))
+                    return
+
+            self.preview.setPlainText("")
+            self._clear_preview_table()
+
+        def on_tree_item_changed(self, item: QTreeWidgetItem, column: int) -> None:
+            if self._updating_tree or self.parsed_log is None:
+                return
+            if column not in (1, 2):
+                return
+            if item.childCount() > 0:
+                return
+
+            if item.data(0, ITEM_KIND_ROLE) != "field":
+                return
+
+            bus_name = item.data(0, ITEM_VALUE_ROLE)
+            field_name = item.data(0, ITEM_PARENT_ROLE)
+            if not bus_name or not field_name:
+                return
+
+            subplot_index = column - 1
+            if subplot_index == 1 and self.subplot_count == 1 and item.checkState(column) == Qt.CheckState.Checked:
+                self._updating_tree = True
+                item.setCheckState(column, Qt.CheckState.Unchecked)
+                self._updating_tree = False
+                QMessageBox.information(self, "Single subplot mode", "Switch to 2 Subplots before using S2.")
+                return
+
+            enabled = item.checkState(column) == Qt.CheckState.Checked
+            self._set_trace_enabled(str(bus_name), str(field_name), subplot_index, enabled)
+            self._refresh_tree_state()
+            self.refresh_plot()
+
+        def _populate_tree(self) -> None:
+            assert self.parsed_log is not None
+            self._updating_tree = True
+            self.signal_tree.clear()
+
+            parameter_root = QTreeWidgetItem(
+                [f"Parameters ({len(self.parsed_log.header.parameter_groups)} groups)", "", ""]
+            )
+            parameter_root.setData(0, ITEM_KIND_ROLE, "param_root")
+            parameter_root.setData(0, ITEM_VALUE_ROLE, "__parameters__")
+            self.signal_tree.addTopLevelItem(parameter_root)
+
+            for group in self.parsed_log.header.parameter_groups:
+                group_item = QTreeWidgetItem([self._parameter_group_display_text(group.name), "", ""])
+                group_item.setData(0, ITEM_KIND_ROLE, "param_group")
+                group_item.setData(0, ITEM_VALUE_ROLE, group.name)
+                parameter_root.addChild(group_item)
+
+            bus_root = QTreeWidgetItem([f"Buses ({len(self.parsed_log.buses)} with data)", "", ""])
+            bus_root.setData(0, ITEM_KIND_ROLE, "bus_root")
+            bus_root.setData(0, ITEM_VALUE_ROLE, "__buses__")
+            self.signal_tree.addTopLevelItem(bus_root)
+
+            for bus_name in self.parsed_log.buses:
+                bus_item = QTreeWidgetItem([self._bus_display_text(bus_name), "", ""])
+                bus_item.setData(0, ITEM_KIND_ROLE, "bus")
+                bus_item.setData(0, ITEM_VALUE_ROLE, bus_name)
+                bus_root.addChild(bus_item)
+
+                for field_name in self._plottable_columns(bus_name):
+                    field_item = QTreeWidgetItem([field_name, "", ""])
+                    field_item.setFlags(
+                        field_item.flags()
+                        | Qt.ItemFlag.ItemIsUserCheckable
+                        | Qt.ItemFlag.ItemIsSelectable
+                        | Qt.ItemFlag.ItemIsEnabled
+                    )
+                    field_item.setData(0, ITEM_KIND_ROLE, "field")
+                    field_item.setData(0, ITEM_VALUE_ROLE, bus_name)
+                    field_item.setData(0, ITEM_PARENT_ROLE, field_name)
+                    field_item.setCheckState(1, Qt.CheckState.Unchecked)
+                    field_item.setCheckState(2, Qt.CheckState.Unchecked)
+                    bus_item.addChild(field_item)
+
+            parameter_root.setExpanded(True)
+            bus_root.setExpanded(True)
+            self._updating_tree = False
+            self._refresh_tree_state()
+
+        def _refresh_tree_state(self) -> None:
+            self._updating_tree = True
+
+            for index in range(self.signal_tree.topLevelItemCount()):
+                root_item = self.signal_tree.topLevelItem(index)
+                self._refresh_bus_item_state(root_item)
+
+            self._updating_tree = False
+
+        def _refresh_bus_item_state(self, item: QTreeWidgetItem) -> None:
+            item_kind = item.data(0, ITEM_KIND_ROLE)
+            if item_kind == "bus":
+                bus_name = str(item.data(0, ITEM_VALUE_ROLE))
+                item.setText(0, self._bus_display_text(bus_name))
+
+                bus_font = item.font(0)
+                bus_font.setBold(self._bus_has_active_traces(bus_name))
+                item.setFont(0, bus_font)
+
+                for child_index in range(item.childCount()):
+                    field_item = item.child(child_index)
+                    field_name = str(field_item.data(0, ITEM_PARENT_ROLE))
+                    in_s1 = self._trace_exists(bus_name, field_name, 0)
+                    in_s2 = self._trace_exists(bus_name, field_name, 1)
+                    field_item.setCheckState(1, Qt.CheckState.Checked if in_s1 else Qt.CheckState.Unchecked)
+                    field_item.setCheckState(2, Qt.CheckState.Checked if in_s2 else Qt.CheckState.Unchecked)
+
+                    color = None
+                    if in_s1 and in_s2:
+                        color = QColor("#f6ebff")
+                    elif in_s1:
+                        color = QColor("#e8f2ff")
+                    elif in_s2:
+                        color = QColor("#eef8e8")
+
+                    for column in range(3):
+                        field_item.setBackground(column, QBrush(color) if color else QBrush())
+                return
+
+            for child_index in range(item.childCount()):
+                self._refresh_bus_item_state(item.child(child_index))
+
+        def _first_bus_item(self) -> QTreeWidgetItem | None:
+            for index in range(self.signal_tree.topLevelItemCount()):
+                item = self.signal_tree.topLevelItem(index)
+                result = self._first_bus_item_from(item)
+                if result is not None:
+                    return result
+            return None
+
+        def _first_bus_item_from(self, item: QTreeWidgetItem) -> QTreeWidgetItem | None:
+            if item.data(0, ITEM_KIND_ROLE) == "bus":
+                return item
+            for child_index in range(item.childCount()):
+                result = self._first_bus_item_from(item.child(child_index))
+                if result is not None:
+                    return result
+            return None
+
+        def _plottable_columns(self, bus_name: str) -> list[str]:
+            assert self.parsed_log is not None
+            bus = self.parsed_log.get_bus(bus_name)
+            if bus is None:
+                return []
+            excluded = {bus.timestamp_field, "timestamp", "timestamp_ms"}
+            return [column for column in bus.frame.columns if column not in excluded]
+
+        def _trace_exists(self, bus_name: str, field_name: str, subplot_index: int) -> bool:
+            return any(
+                trace.bus_name == bus_name and trace.field_name == field_name
+                for trace in self.subplot_traces[subplot_index]
+            )
+
+        def _set_trace_enabled(self, bus_name: str, field_name: str, subplot_index: int, enabled: bool) -> None:
+            traces = self.subplot_traces[subplot_index]
+            exists = self._trace_exists(bus_name, field_name, subplot_index)
+
+            if enabled and not exists:
+                traces.append(
+                    PlotTraceSpec(
+                        bus_name=bus_name,
+                        field_name=field_name,
+                        subplot_index=subplot_index,
+                        label=f"{bus_name}.{field_name}",
+                    )
+                )
+            elif not enabled and exists:
+                self.subplot_traces[subplot_index] = [
+                    trace
+                    for trace in traces
+                    if not (trace.bus_name == bus_name and trace.field_name == field_name)
+                ]
+
+        def _bus_has_active_traces(self, bus_name: str) -> bool:
+            return any(trace.bus_name == bus_name for traces in self.subplot_traces for trace in traces)
+
+        def _bus_display_text(self, bus_name: str) -> str:
+            field_count = len(self._plottable_columns(bus_name)) if self.parsed_log is not None else 0
+            s1_count = sum(1 for trace in self.subplot_traces[0] if trace.bus_name == bus_name)
+            s2_count = sum(1 for trace in self.subplot_traces[1] if trace.bus_name == bus_name)
+            return f"{bus_name} ({field_count} fields | S1:{s1_count} S2:{s2_count})"
+
+        def _parameter_group_display_text(self, group_name: str) -> str:
+            assert self.parsed_log is not None
+            group = self.parsed_log.get_parameter_group(group_name)
+            parameter_count = len(group.parameters) if group is not None else 0
+            return f"{group_name} ({parameter_count} params)"
+
+        def _format_bus_preview(self, bus_name: str) -> str:
+            assert self.parsed_log is not None
+            bus = self.parsed_log.get_bus(bus_name)
+            if bus is None:
+                return ""
+
+            return "\n".join(
+                [
+                    f"Bus: {bus_name}",
+                    f"Rows: {len(bus.frame)}",
+                    f"Columns: {len(bus.frame.columns)}",
+                    f"Plot x-axis: {bus.timestamp_field or 'index'}",
+                    "Preview: full table",
+                ]
+            )
+
+        def _format_parameter_group_preview(self, group_name: str) -> str:
+            assert self.parsed_log is not None
+            group = self.parsed_log.get_parameter_group(group_name)
+            if group is None:
+                return ""
+
+            return "\n".join(
+                [
+                    f"Parameter Group: {group.name}",
+                    f"Parameters: {len(group.parameters)}",
+                    "Preview: full table",
+                ]
+            )
+
+        def _format_parameter_root_preview(self) -> str:
+            assert self.parsed_log is not None
+            group_count = len(self.parsed_log.header.parameter_groups)
+            parameter_count = sum(len(group.parameters) for group in self.parsed_log.header.parameter_groups)
+            return "\n".join(
+                [
+                    "Parameter Overview",
+                    f"Groups: {group_count}",
+                    f"Parameters: {parameter_count}",
+                    "Preview: full table",
+                ]
+            )
+
+        def _format_bus_root_preview(self) -> str:
+            assert self.parsed_log is not None
+            field_count = sum(len(bus.frame.columns) for bus in self.parsed_log.buses.values())
+            row_count = sum(len(bus.frame) for bus in self.parsed_log.buses.values())
+            return "\n".join(
+                [
+                    "Bus Overview",
+                    f"Buses with data: {len(self.parsed_log.buses)}",
+                    f"Total rows: {row_count}",
+                    f"Total columns: {field_count}",
+                    "Preview: full table",
+                ]
+            )
+
+        def _show_bus_preview(self, bus_name: str) -> None:
+            assert self.parsed_log is not None
+            bus = self.parsed_log.get_bus(bus_name)
+            if bus is None:
+                self.preview.setPlainText("")
+                self._clear_preview_table()
+                return
+
+            self.preview.setPlainText(self._format_bus_preview(bus_name))
+            self._set_preview_table(bus.frame)
+
+        def _show_parameter_group_preview(self, group_name: str) -> None:
+            assert self.parsed_log is not None
+            group = self.parsed_log.get_parameter_group(group_name)
+            if group is None:
+                self.preview.setPlainText("")
+                self._clear_preview_table()
+                return
+
+            rows = [
+                {
+                    "name": parameter.name,
+                    "type": TYPE_DISPLAY_NAMES.get(parameter.type_id, str(parameter.type_id)),
+                    "value": parameter.value,
+                }
+                for parameter in group.parameters
+            ]
+            preview_frame = pd.DataFrame(rows)
+            self.preview.setPlainText(self._format_parameter_group_preview(group_name))
+            self._set_preview_table(preview_frame)
+
+        def _show_parameter_root_preview(self) -> None:
+            assert self.parsed_log is not None
+            rows = [
+                {"group": group.name, "parameters": len(group.parameters)}
+                for group in self.parsed_log.header.parameter_groups
+            ]
+            preview_frame = pd.DataFrame(rows)
+            self.preview.setPlainText(self._format_parameter_root_preview())
+            self._set_preview_table(preview_frame)
+
+        def _show_bus_root_preview(self) -> None:
+            assert self.parsed_log is not None
+            rows = [
+                {
+                    "bus": bus_name,
+                    "rows": len(bus.frame),
+                    "columns": len(bus.frame.columns),
+                    "x_axis": bus.timestamp_field or "index",
+                }
+                for bus_name, bus in self.parsed_log.buses.items()
+            ]
+            preview_frame = pd.DataFrame(rows)
+            self.preview.setPlainText(self._format_bus_root_preview())
+            self._set_preview_table(preview_frame)
+
+        def _clear_preview_table(self) -> None:
+            self.preview_model.clear()
+
+        def _set_preview_table(self, frame: pd.DataFrame) -> None:
+            self.preview_model.set_frame(frame)
+            header = self.preview_table.horizontalHeader()
+            if len(frame.columns) <= 12:
+                self.preview_table.resizeColumnsToContents()
+            else:
+                for column_index in range(min(len(frame.columns), 6)):
+                    header.setSectionResizeMode(column_index, QHeaderView.ResizeMode.ResizeToContents)
+                    self.preview_table.resizeColumnToContents(column_index)
+                    header.setSectionResizeMode(column_index, QHeaderView.ResizeMode.Interactive)
+
+
+def main() -> None:
+    """Run the Qt GUI entry point."""
+
+    if QT_IMPORT_ERROR is not None:
+        raise SystemExit(
+            "PySide6 + PyQtGraph are required for the Qt GUI.\n"
+            "Install them with: uv pip install --python ./.venv/bin/python '.[qt]'\n"
+            f"Original import error: {QT_IMPORT_ERROR}"
+        )
+
+    app = QApplication.instance() or QApplication([])
+    window = QtMLogMainWindow()
+    window.show()
+    app.exec()
+
+
+if __name__ == "__main__":
+    main()

+ 402 - 0
src/mlog_tool/qt_plot_widget.py

@@ -0,0 +1,402 @@
+"""PyQtGraph plotting widget for the Qt GUI."""
+
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+import pyqtgraph as pg
+from PySide6.QtCore import Qt
+from PySide6.QtWidgets import QVBoxLayout, QWidget
+
+from .models import ParsedLog, PlotTraceSpec
+
+
+class ComparisonPlotWidget(QWidget):
+    """A simple one- or two-subplot plotting area built on PyQtGraph."""
+
+    COLORS = [
+        "#1f77b4",
+        "#d62728",
+        "#2ca02c",
+        "#ff7f0e",
+        "#9467bd",
+        "#17becf",
+        "#8c564b",
+        "#e377c2",
+    ]
+
+    def __init__(self, parent: QWidget | None = None) -> None:
+        super().__init__(parent)
+        self.graphics = pg.GraphicsLayoutWidget()
+        self.graphics.setBackground("#fcfcfc")
+        self.plots: list[pg.PlotItem] = []
+        self.cursor_enabled = False
+        self.cursor_frozen = False
+        self.last_cursor_x: float | None = None
+        self.cursor_lines: list[pg.InfiniteLine] = []
+        self.cursor_traces: list[list[dict[str, Any]]] = []
+
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.addWidget(self.graphics)
+
+        self.graphics.scene().sigMouseMoved.connect(self._on_mouse_moved)
+        self.graphics.scene().sigMouseClicked.connect(self._on_mouse_clicked)
+
+    def set_plot_data(
+        self,
+        parsed_log: ParsedLog | None,
+        subplot_traces: list[list[PlotTraceSpec]],
+        subplot_count: int,
+    ) -> None:
+        """Render one or two subplots from the current trace selection."""
+
+        self.graphics.clear()
+        self.plots = []
+        self.cursor_lines = []
+        self.cursor_traces = []
+        if parsed_log is None:
+            return
+
+        visible_count = 1 if subplot_count <= 1 else 2
+        self.cursor_traces = [[] for _ in range(visible_count)]
+        if visible_count == 1:
+            self.graphics.ci.layout.setColumnStretchFactor(0, 1)
+            self.graphics.ci.layout.setColumnStretchFactor(1, 0)
+        else:
+            self.graphics.ci.layout.setColumnStretchFactor(0, 1)
+            self.graphics.ci.layout.setColumnStretchFactor(1, 1)
+
+        for subplot_index in range(visible_count):
+            column_span = 2 if visible_count == 1 else 1
+            plot = self.graphics.addPlot(
+                row=0,
+                col=subplot_index,
+                colspan=column_span,
+                title=f"Subplot {subplot_index + 1}",
+            )
+            plot.showGrid(x=True, y=True, alpha=0.2)
+            legend = plot.addLegend(offset=(10, 10))
+            self._configure_legend(legend)
+            plot.setLabel("bottom", "time / index")
+            plot.getAxis("left").setWidth(64)
+            self.plots.append(plot)
+
+        if len(self.plots) == 2:
+            self.plots[1].setXLink(self.plots[0])
+
+        for subplot_index, plot in enumerate(self.plots):
+            traces = subplot_traces[subplot_index] if subplot_index < len(subplot_traces) else []
+            self._render_subplot(plot, subplot_index, parsed_log, traces)
+
+        self._set_cursor_enabled(self.cursor_enabled)
+
+    def set_cursor_enabled(self, enabled: bool) -> None:
+        """Toggle the shared vertical cursor."""
+
+        self.cursor_enabled = enabled
+        if not enabled:
+            if self.cursor_frozen:
+                self.cursor_frozen = False
+            self.last_cursor_x = None
+        self._set_cursor_enabled(enabled)
+
+    def set_cursor_frozen(self, enabled: bool) -> None:
+        """Freeze or unfreeze the current cursor position."""
+
+        if not self.cursor_enabled:
+            if self.cursor_frozen:
+                self.cursor_frozen = False
+            return
+
+        if self.cursor_frozen == enabled:
+            return
+
+        self.cursor_frozen = enabled
+        if enabled and self.last_cursor_x is not None:
+            self._set_cursor_position(self.last_cursor_x)
+
+    def _set_cursor_enabled(self, enabled: bool) -> None:
+        for line in self.cursor_lines:
+            line.hide()
+        self._hide_cursor_readouts()
+
+        self.cursor_lines = []
+        if not enabled:
+            return
+
+        for plot in self.plots:
+            line = pg.InfiniteLine(
+                angle=90,
+                movable=False,
+                pen=pg.mkPen(color="#444444", width=1, style=pg.QtCore.Qt.PenStyle.DashLine),
+            )
+            line.hide()
+            plot.addItem(line, ignoreBounds=True)
+            self.cursor_lines.append(line)
+
+        for subplot_index, plot in enumerate(self.plots):
+            for trace in self.cursor_traces[subplot_index]:
+                color = trace["color"]
+                marker = pg.ScatterPlotItem(
+                    size=8,
+                    pen=pg.mkPen(color=color, width=1),
+                    brush=pg.mkBrush(color),
+                )
+                label = pg.TextItem(
+                    text="",
+                    color=color,
+                    anchor=(-0.12, 1.15),
+                    fill=pg.mkBrush(255, 255, 255, 210),
+                    border=pg.mkPen(color=color, width=1),
+                )
+                marker.hide()
+                label.hide()
+                plot.addItem(marker, ignoreBounds=True)
+                plot.addItem(label, ignoreBounds=True)
+                trace["marker"] = marker
+                trace["label"] = label
+
+    def _on_mouse_moved(self, pos: Any) -> None:
+        if not self.cursor_enabled or not self.plots or not self.cursor_lines:
+            return
+        if self.cursor_frozen:
+            return
+
+        for plot in self.plots:
+            view_box = plot.getViewBox()
+            if view_box.sceneBoundingRect().contains(pos):
+                mouse_point = view_box.mapSceneToView(pos)
+                x_value = mouse_point.x()
+                self._set_cursor_position(x_value)
+                return
+
+        for line in self.cursor_lines:
+            line.hide()
+        self._hide_cursor_readouts()
+        self.last_cursor_x = None
+
+    def _on_mouse_clicked(self, event: Any) -> None:
+        if not self.cursor_enabled or not self.plots:
+            return
+
+        pos = event.scenePos()
+        for plot in self.plots:
+            view_box = plot.getViewBox()
+            if not view_box.sceneBoundingRect().contains(pos):
+                continue
+
+            mouse_point = view_box.mapSceneToView(pos)
+            x_value = mouse_point.x()
+
+            if event.button() == Qt.MouseButton.LeftButton:
+                self._set_cursor_position(x_value)
+                self.set_cursor_frozen(True)
+                event.accept()
+                return
+
+            if event.button() == Qt.MouseButton.RightButton:
+                self.set_cursor_frozen(False)
+                self._set_cursor_position(x_value)
+                event.accept()
+                return
+
+    def _set_cursor_position(self, x_value: float) -> None:
+        self.last_cursor_x = x_value
+        for line in self.cursor_lines:
+            line.setPos(x_value)
+            line.show()
+        self._update_cursor_readouts(x_value)
+
+    def _update_cursor_readouts(self, x_value: float) -> None:
+        for subplot_index, subplot_traces in enumerate(self.cursor_traces):
+            if subplot_index >= len(self.plots):
+                continue
+
+            plot = self.plots[subplot_index]
+            view_box = plot.getViewBox()
+            readout_entries: list[dict[str, Any]] = []
+
+            for trace in subplot_traces:
+                point = self._nearest_point(trace["x"], trace["y"], x_value, trace["monotonic"])
+                marker = trace.get("marker")
+                label = trace.get("label")
+                if point is None or marker is None or label is None:
+                    continue
+
+                nearest_x, nearest_y = point
+                marker.setData([nearest_x], [nearest_y])
+                marker.show()
+                label.setText(self._format_cursor_value(nearest_y))
+                label.show()
+                scene_point = view_box.mapViewToScene(pg.Point(nearest_x, nearest_y))
+                readout_entries.append(
+                    {
+                        "label": label,
+                        "x": nearest_x,
+                        "y": nearest_y,
+                        "scene_x": scene_point.x(),
+                        "scene_y": scene_point.y(),
+                    }
+                )
+
+            self._layout_readout_labels(view_box, readout_entries)
+
+    def _hide_cursor_readouts(self) -> None:
+        for subplot_traces in self.cursor_traces:
+            for trace in subplot_traces:
+                marker = trace.get("marker")
+                label = trace.get("label")
+                if marker is not None:
+                    marker.hide()
+                if label is not None:
+                    label.hide()
+
+    def _nearest_point(
+        self,
+        x_values: np.ndarray,
+        y_values: np.ndarray,
+        x_value: float,
+        monotonic: bool,
+    ) -> tuple[float, float] | None:
+        if len(x_values) == 0:
+            return None
+
+        if monotonic:
+            insert_at = int(np.searchsorted(x_values, x_value))
+            if insert_at <= 0:
+                index = 0
+            elif insert_at >= len(x_values):
+                index = len(x_values) - 1
+            else:
+                left_index = insert_at - 1
+                right_index = insert_at
+                index = (
+                    left_index
+                    if abs(x_value - x_values[left_index]) <= abs(x_values[right_index] - x_value)
+                    else right_index
+                )
+        else:
+            index = int(np.argmin(np.abs(x_values - x_value)))
+
+        return float(x_values[index]), float(y_values[index])
+
+    def _format_cursor_value(self, value: float) -> str:
+        return f"{value:.6g}"
+
+    def _configure_legend(self, legend: Any) -> None:
+        """Style the legend so it stays readable above dense curves."""
+
+        legend.setZValue(1000)
+        if hasattr(legend, "setBrush"):
+            legend.setBrush(pg.mkBrush(252, 252, 252, 225))
+        if hasattr(legend, "setPen"):
+            legend.setPen(pg.mkPen("#b9c0c8", width=1))
+        if hasattr(legend, "anchor"):
+            legend.anchor(itemPos=(0, 0), parentPos=(0, 0), offset=(10, 10))
+
+        layout = getattr(legend, "layout", None)
+        if layout is None:
+            return
+
+        if hasattr(layout, "setVerticalSpacing"):
+            layout.setVerticalSpacing(0)
+        if hasattr(layout, "setHorizontalSpacing"):
+            layout.setHorizontalSpacing(8)
+
+    def _layout_readout_labels(self, view_box: Any, entries: list[dict[str, Any]]) -> None:
+        if not entries:
+            return
+
+        bounds = view_box.sceneBoundingRect()
+        top_limit = bounds.top() + 12
+        bottom_limit = bounds.bottom() - 12
+        min_gap = 18
+        x_offset = 10
+
+        entries.sort(key=lambda entry: entry["scene_y"])
+
+        for index, entry in enumerate(entries):
+            target_y = min(max(entry["scene_y"], top_limit), bottom_limit)
+            if index > 0:
+                previous_y = entries[index - 1]["adjusted_scene_y"]
+                target_y = max(target_y, previous_y + min_gap)
+            entry["adjusted_scene_y"] = target_y
+
+        overflow = entries[-1]["adjusted_scene_y"] - bottom_limit
+        if overflow > 0:
+            for entry in entries:
+                entry["adjusted_scene_y"] -= overflow
+
+        underflow = top_limit - entries[0]["adjusted_scene_y"]
+        if underflow > 0:
+            for entry in entries:
+                entry["adjusted_scene_y"] += underflow
+
+        previous_y = top_limit - min_gap
+        for entry in entries:
+            entry["adjusted_scene_y"] = max(entry["adjusted_scene_y"], previous_y + min_gap)
+            entry["adjusted_scene_y"] = min(entry["adjusted_scene_y"], bottom_limit)
+            previous_y = entry["adjusted_scene_y"]
+
+        for entry in entries:
+            label_scene_x = min(entry["scene_x"] + x_offset, bounds.right() - 8)
+            label_view_point = view_box.mapSceneToView(pg.Point(label_scene_x, entry["adjusted_scene_y"]))
+            entry["label"].setPos(float(label_view_point.x()), float(label_view_point.y()))
+
+    def _render_subplot(
+        self,
+        plot: pg.PlotItem,
+        subplot_index: int,
+        parsed_log: ParsedLog,
+        traces: list[PlotTraceSpec],
+    ) -> None:
+        """Render all traces assigned to one subplot."""
+
+        if not traces:
+            empty = pg.TextItem("No traces selected", color="#666666", anchor=(0.5, 0.5))
+            plot.addItem(empty)
+            empty.setPos(0.5, 0.5)
+            return
+
+        x_label = "index"
+        y_names: list[str] = []
+
+        for index, trace in enumerate(traces):
+            bus = parsed_log.get_bus(trace.bus_name)
+            if bus is None or trace.field_name not in bus.frame.columns:
+                continue
+
+            frame = bus.frame
+            if bus.timestamp_field and bus.timestamp_field in frame.columns:
+                x_values = frame[bus.timestamp_field].to_numpy(dtype=float)
+                x_label = bus.timestamp_field
+            else:
+                x_values = frame.index.to_numpy(dtype=float)
+                x_label = "index"
+
+            y_values = frame[trace.field_name].to_numpy(dtype=float)
+            label = trace.label or f"{trace.bus_name}.{trace.field_name}"
+            color = self.COLORS[index % len(self.COLORS)]
+            pen = pg.mkPen(color, width=2)
+            plot.plot(x_values, y_values, pen=pen, name=label)
+            y_names.append(trace.field_name)
+
+            finite_mask = np.isfinite(x_values) & np.isfinite(y_values)
+            finite_x = x_values[finite_mask]
+            finite_y = y_values[finite_mask]
+            if len(finite_x) > 0:
+                monotonic = bool(np.all(np.diff(finite_x) >= 0))
+                self.cursor_traces[subplot_index].append(
+                    {
+                        "x": finite_x,
+                        "y": finite_y,
+                        "color": color,
+                        "monotonic": monotonic,
+                    }
+                )
+
+        plot.setLabel("bottom", x_label)
+        if y_names:
+            plot.setLabel("left", y_names[0] if len(y_names) == 1 else "value")

+ 46 - 0
src/mlog_tool/services.py

@@ -0,0 +1,46 @@
+"""Application service layer used by CLI and GUI entry points."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from matplotlib.figure import Figure
+
+from .exporters import CsvExporter
+from .models import ParsedLog, PlotTraceSpec
+from .parser import MLogParser
+from .plotting import LogPlotter
+
+
+class MLogService:
+    """High-level workflow facade for parse, export, and plot actions."""
+
+    def __init__(
+        self,
+        parser: MLogParser | None = None,
+        exporter: CsvExporter | None = None,
+        plotter: LogPlotter | None = None,
+    ) -> None:
+        self.parser = parser or MLogParser()
+        self.exporter = exporter or CsvExporter()
+        self.plotter = plotter or LogPlotter()
+
+    def parse(self, log_path: str | Path) -> ParsedLog:
+        return self.parser.parse(log_path)
+
+    def export_csv(self, parsed_log: ParsedLog, output_dir: str | Path) -> list[Path]:
+        return self.exporter.export_all(parsed_log, output_dir)
+
+    def build_plot_figure(self, parsed_log: ParsedLog, bus_name: str, fields: list[str]) -> Figure:
+        return self.plotter.build_figure(parsed_log, bus_name, fields)
+
+    def build_comparison_figure(
+        self,
+        parsed_log: ParsedLog,
+        subplot_traces: list[list[PlotTraceSpec]],
+        subplot_count: int = 2,
+    ) -> Figure:
+        return self.plotter.build_comparison_figure(parsed_log, subplot_traces, subplot_count)
+
+    def plot(self, parsed_log: ParsedLog, bus_name: str, fields: list[str]) -> None:
+        self.plotter.plot_fields(parsed_log, bus_name, fields)

+ 24 - 0
src/mlog_tool/utils.py

@@ -0,0 +1,24 @@
+"""Small utility helpers shared across the project."""
+
+from __future__ import annotations
+
+import re
+
+
+def clean_c_string(raw: bytes | str) -> str:
+    """Trim trailing nulls and whitespace from fixed-length string fields."""
+
+    if isinstance(raw, bytes):
+        text = raw.decode("utf-8", errors="ignore")
+    else:
+        text = raw
+    return text.rstrip("\x00").strip()
+
+
+def normalize_field_name(name: str) -> str:
+    """Make element names safe and stable for DataFrame column names."""
+
+    normalized = re.sub(r"\s+", "_", name.strip())
+    normalized = re.sub(r"[^0-9A-Za-z_]+", "_", normalized)
+    normalized = re.sub(r"_+", "_", normalized)
+    return normalized.strip("_")

+ 7 - 0
src/qt_gui_entry.py

@@ -0,0 +1,7 @@
+"""PyInstaller-friendly Qt GUI entry point."""
+
+from mlog_tool.qt_gui import main
+
+
+if __name__ == "__main__":
+    main()

+ 104 - 0
tests/test_parser.py

@@ -0,0 +1,104 @@
+from pathlib import Path
+
+import pandas as pd
+
+from mlog_tool.constants import MLOG_BEGIN_MSG1, MLOG_BEGIN_MSG2, MLOG_END_MSG
+from mlog_tool.models import BusFrame, LogHeader, ParsedLog, PlotTraceSpec
+from mlog_tool.parser import MLogParser
+from mlog_tool.plotting import LogPlotter
+from mlog_tool.utils import normalize_field_name
+
+
+def test_parser_type_exists() -> None:
+    parser = MLogParser()
+    assert parser is not None
+
+
+def test_message_markers_match_matlab_definition() -> None:
+    assert MLOG_BEGIN_MSG1 == 0x92
+    assert MLOG_BEGIN_MSG2 == 0x05
+    assert MLOG_END_MSG == 0x26
+
+
+def test_normalize_field_name() -> None:
+    assert normalize_field_name("timestamp_ms") == "timestamp_ms"
+    assert normalize_field_name("gyro[0]") == "gyro_0"
+    assert normalize_field_name("  att roll ") == "att_roll"
+
+
+def test_parsed_log_parameter_lookup_returns_none_when_missing() -> None:
+    header = LogHeader(
+        version=1,
+        timestamp=0,
+        max_name_len=16,
+        max_desc_len=16,
+        max_model_info_len=16,
+        description="demo",
+        model_info="demo",
+    )
+    parsed_log = ParsedLog(source_path=Path("demo.bin"), header=header)
+    assert parsed_log.get_parameter("INS", "acc_bias") is None
+
+
+def test_parser_can_parse_sample_log() -> None:
+    parser = MLogParser()
+    sample_log = Path(__file__).resolve().parents[2] / "mlog16.bin"
+
+    parsed_log = parser.parse(sample_log)
+
+    assert parsed_log.header.version >= 1
+    assert len(parsed_log.header.buses) == 48
+    assert len(parsed_log.header.parameter_groups) == 9
+    assert len(parsed_log.buses) == 19
+    assert "IMU0" in parsed_log.buses
+    assert "GPS" in parsed_log.buses
+    assert "time_s" in parsed_log.buses["IMU0"].frame.columns
+    assert parsed_log.get_parameter_group("SYSTEM") is not None
+    assert parsed_log.get_parameter("SYSTEM", "MAV_SYS_ID") == 2
+
+
+def test_plotter_can_compare_traces_across_subplots() -> None:
+    header = LogHeader(
+        version=1,
+        timestamp=0,
+        max_name_len=16,
+        max_desc_len=16,
+        max_model_info_len=16,
+        description="demo",
+        model_info="demo",
+    )
+    frame = pd.DataFrame(
+        {
+            "time_s": [0.0, 0.1, 0.2],
+            "ax": [1.0, 2.0, 3.0],
+            "ay": [4.0, 5.0, 6.0],
+        }
+    )
+    parsed_log = ParsedLog(
+        source_path=Path("demo.bin"),
+        header=header,
+        buses={
+            "IMU0": BusFrame(name="IMU0", frame=frame, timestamp_field="time_s"),
+            "GPS": BusFrame(name="GPS", frame=frame.rename(columns={"ax": "ve", "ay": "vn"}), timestamp_field="time_s"),
+        },
+    )
+
+    figure = LogPlotter().build_comparison_figure(
+        parsed_log,
+        [
+            [
+                PlotTraceSpec(bus_name="IMU0", field_name="ax", subplot_index=0, label="IMU0.ax"),
+                PlotTraceSpec(bus_name="GPS", field_name="ve", subplot_index=0, label="GPS.ve"),
+            ],
+            [PlotTraceSpec(bus_name="IMU0", field_name="ay", subplot_index=1, label="IMU0.ay")],
+        ],
+        subplot_count=2,
+    )
+
+    assert len(figure.axes) == 2
+    assert figure.axes[0].get_title() == "Subplot 1"
+    assert figure.axes[1].get_title() == "Subplot 2"
+    assert figure.axes[0].get_shared_x_axes().joined(figure.axes[0], figure.axes[1])
+    assert len(figure._mlog_cursor_payload) == 2
+    assert len(figure._mlog_cursor_payload[0]["traces"]) == 2
+    assert len(figure._mlog_cursor_payload[1]["traces"]) == 1