wip
This commit is contained in:
20
tools/CTF.md
Executable file
20
tools/CTF.md
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
## CTF
|
||||||
|
Welcome to the first part of the comma CTF!
|
||||||
|
|
||||||
|
* all the flags are contained in this route: `0c7f0c7f0c7f0c7f|2021-10-13--13-00-00`
|
||||||
|
* there's 2 flags in each segment, with roughly increasing difficulty
|
||||||
|
* everything you'll need to find the flags is in the openpilot repo
|
||||||
|
* grep is also your friend
|
||||||
|
* first, [setup](https://github.com/commaai/openpilot/tree/master/tools#setup-your-pc) your PC
|
||||||
|
* read the docs & checkout out the tools in tools/ and selfdrive/debug/
|
||||||
|
* tip: once you get the replay and UI up, start by familiarizing yourself with seeking in replay
|
||||||
|
|
||||||
|
getting started
|
||||||
|
```bash
|
||||||
|
# start the route reply
|
||||||
|
cd tools/replay
|
||||||
|
./replay '0c7f0c7f0c7f0c7f|2021-10-13--13-00-00' --dcam --ecam
|
||||||
|
|
||||||
|
# start the UI in another terminal
|
||||||
|
selfdrive/ui/ui
|
||||||
|
```
|
||||||
80
tools/README.md
Executable file
80
tools/README.md
Executable file
@@ -0,0 +1,80 @@
|
|||||||
|
# openpilot tools
|
||||||
|
|
||||||
|
## System Requirements
|
||||||
|
|
||||||
|
openpilot is developed and tested on **Ubuntu 20.04**, which is the primary development target aside from the [supported embedded hardware](https://github.com/commaai/openpilot#running-on-a-dedicated-device-in-a-car).
|
||||||
|
|
||||||
|
Running natively on any other system is not recommended and will require modifications. On Windows you can use WSL, and on macOS or incompatible Linux systems, it is recommended to use the dev containers.
|
||||||
|
|
||||||
|
## Native setup on Ubuntu 20.04
|
||||||
|
|
||||||
|
**1. Clone openpilot**
|
||||||
|
|
||||||
|
NOTE: This repository uses Git LFS for large files. Ensure you have [Git LFS](https://git-lfs.com/) installed and set up before cloning or working with it.
|
||||||
|
|
||||||
|
Either do a partial clone for faster download:
|
||||||
|
``` bash
|
||||||
|
git clone --filter=blob:none --recurse-submodules --also-filter-submodules https://github.com/commaai/openpilot.git
|
||||||
|
```
|
||||||
|
|
||||||
|
or do a full clone:
|
||||||
|
``` bash
|
||||||
|
git clone --recurse-submodules https://github.com/commaai/openpilot.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Run the setup script**
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
cd openpilot
|
||||||
|
git lfs pull
|
||||||
|
tools/ubuntu_setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Activate a shell with the Python dependencies installed:
|
||||||
|
``` bash
|
||||||
|
poetry shell
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Build openpilot**
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
scons -u -j$(nproc)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dev Container on any Linux or macOS
|
||||||
|
|
||||||
|
openpilot supports [Dev Containers](https://containers.dev/). Dev containers provide customizable and consistent development environment wrapped inside a container. This means you can develop in a designated environment matching our primary development target, regardless of your local setup.
|
||||||
|
|
||||||
|
Dev containers are supported in [multiple editors and IDEs](https://containers.dev/supporting), including Visual Studio Code. Use the following [guide](https://code.visualstudio.com/docs/devcontainers/containers) to start using them with VSCode.
|
||||||
|
|
||||||
|
#### X11 forwarding on macOS
|
||||||
|
|
||||||
|
GUI apps like `ui` or `cabana` can also run inside the container by leveraging X11 forwarding. To make use of it on macOS, additional configuration steps must be taken. Follow [these](https://gist.github.com/sorny/969fe55d85c9b0035b0109a31cbcb088) steps to setup X11 forwarding on macOS.
|
||||||
|
|
||||||
|
## WSL on Windows
|
||||||
|
|
||||||
|
[Windows Subsystem for Linux (WSL)](https://docs.microsoft.com/en-us/windows/wsl/about) should provide a similar experience to native Ubuntu. [WSL 2](https://docs.microsoft.com/en-us/windows/wsl/compare-versions) specifically has been reported by several users to be a seamless experience.
|
||||||
|
|
||||||
|
Follow [these instructions](https://docs.microsoft.com/en-us/windows/wsl/install) to setup the WSL and install the `Ubuntu-20.04` distribution. Once your Ubuntu WSL environment is setup, follow the Linux setup instructions to finish setting up your environment. See [these instructions](https://learn.microsoft.com/en-us/windows/wsl/tutorials/gui-apps) for running GUI apps.
|
||||||
|
|
||||||
|
**NOTE**: If you are running WSL and any GUIs are failing (segfaulting or other strange issues) even after following the steps above, you may need to enable software rendering with `LIBGL_ALWAYS_SOFTWARE=1`, e.g. `LIBGL_ALWAYS_SOFTWARE=1 selfdrive/ui/ui`.
|
||||||
|
|
||||||
|
## CTF
|
||||||
|
Learn about the openpilot ecosystem and tools by playing our [CTF](/tools/CTF.md).
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
├── ubuntu_setup.sh # Setup script for Ubuntu
|
||||||
|
├── mac_setup.sh # Setup script for macOS
|
||||||
|
├── cabana/ # View and plot CAN messages from drives or in realtime
|
||||||
|
├── joystick/ # Control your car with a joystick
|
||||||
|
├── lib/ # Libraries to support the tools and reading openpilot logs
|
||||||
|
├── plotjuggler/ # A tool to plot openpilot logs
|
||||||
|
├── replay/ # Replay drives and mock openpilot services
|
||||||
|
├── scripts/ # Miscellaneous scripts
|
||||||
|
├── serial/ # Tools for using the comma serial
|
||||||
|
├── sim/ # Run openpilot in a simulator
|
||||||
|
├── ssh/ # SSH into a comma device
|
||||||
|
└── webcam/ # Run openpilot on a PC with webcams
|
||||||
|
```
|
||||||
4
tools/bodyteleop/.gitignore
vendored
Executable file
4
tools/bodyteleop/.gitignore
vendored
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
av
|
||||||
|
av-10.0.0/*
|
||||||
|
key.pem
|
||||||
|
cert.pem
|
||||||
@@ -6,10 +6,9 @@ import os
|
|||||||
import ssl
|
import ssl
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from aiohttp import web, ClientSession
|
||||||
import pyaudio
|
import pyaudio
|
||||||
import wave
|
import wave
|
||||||
from aiohttp import web
|
|
||||||
from aiohttp import ClientSession
|
|
||||||
|
|
||||||
from openpilot.common.basedir import BASEDIR
|
from openpilot.common.basedir import BASEDIR
|
||||||
from openpilot.system.webrtc.webrtcd import StreamRequestBody
|
from openpilot.system.webrtc.webrtcd import StreamRequestBody
|
||||||
@@ -23,7 +22,7 @@ WEBRTCD_HOST, WEBRTCD_PORT = "localhost", 5001
|
|||||||
|
|
||||||
|
|
||||||
## UTILS
|
## UTILS
|
||||||
async def play_sound(sound: str):
|
async def play_sound(sound):
|
||||||
SOUNDS = {
|
SOUNDS = {
|
||||||
"engage": "selfdrive/assets/sounds/engage.wav",
|
"engage": "selfdrive/assets/sounds/engage.wav",
|
||||||
"disengage": "selfdrive/assets/sounds/disengage.wav",
|
"disengage": "selfdrive/assets/sounds/disengage.wav",
|
||||||
@@ -52,7 +51,7 @@ async def play_sound(sound: str):
|
|||||||
p.terminate()
|
p.terminate()
|
||||||
|
|
||||||
## SSL
|
## SSL
|
||||||
def create_ssl_cert(cert_path: str, key_path: str):
|
def create_ssl_cert(cert_path, key_path):
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(f'openssl req -x509 -newkey rsa:4096 -nodes -out {cert_path} -keyout {key_path} \
|
proc = subprocess.run(f'openssl req -x509 -newkey rsa:4096 -nodes -out {cert_path} -keyout {key_path} \
|
||||||
-days 365 -subj "/C=US/ST=California/O=commaai/OU=comma body"',
|
-days 365 -subj "/C=US/ST=California/O=commaai/OU=comma body"',
|
||||||
@@ -76,17 +75,17 @@ def create_ssl_context():
|
|||||||
return ssl_context
|
return ssl_context
|
||||||
|
|
||||||
## ENDPOINTS
|
## ENDPOINTS
|
||||||
async def index(request: 'web.Request'):
|
async def index(request):
|
||||||
with open(os.path.join(TELEOPDIR, "static", "index.html"), "r") as f:
|
with open(os.path.join(TELEOPDIR, "static", "index.html"), "r") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
return web.Response(content_type="text/html", text=content)
|
return web.Response(content_type="text/html", text=content)
|
||||||
|
|
||||||
|
|
||||||
async def ping(request: 'web.Request'):
|
async def ping(request):
|
||||||
return web.Response(text="pong")
|
return web.Response(text="pong")
|
||||||
|
|
||||||
|
|
||||||
async def sound(request: 'web.Request'):
|
async def sound(request):
|
||||||
params = await request.json()
|
params = await request.json()
|
||||||
sound_to_play = params["sound"]
|
sound_to_play = params["sound"]
|
||||||
|
|
||||||
@@ -94,7 +93,7 @@ async def sound(request: 'web.Request'):
|
|||||||
return web.json_response({"status": "ok"})
|
return web.json_response({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
async def offer(request: 'web.Request'):
|
async def offer(request):
|
||||||
params = await request.json()
|
params = await request.json()
|
||||||
body = StreamRequestBody(params["sdp"], ["driver"], ["testJoystick"], ["carState"])
|
body = StreamRequestBody(params["sdp"], ["driver"], ["testJoystick"], ["carState"])
|
||||||
body_json = json.dumps(dataclasses.asdict(body))
|
body_json = json.dumps(dataclasses.asdict(body))
|
||||||
|
|||||||
6
tools/cabana/.gitignore
vendored
Executable file
6
tools/cabana/.gitignore
vendored
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
moc_*
|
||||||
|
*.moc
|
||||||
|
|
||||||
|
cabana
|
||||||
|
dbc/car_fingerprint_to_dbc.json
|
||||||
|
tests/test_cabana
|
||||||
32
tools/cabana/README.md
Executable file
32
tools/cabana/README.md
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
# Cabana
|
||||||
|
|
||||||
|
Cabana is a tool developed to view raw CAN data. One use for this is creating and editing [CAN Dictionaries](http://socialledge.com/sjsu/index.php/DBC_Format) (DBC files), and the tool provides direct integration with [commaai/opendbc](https://github.com/commaai/opendbc) (a collection of DBC files), allowing you to load the DBC files direct from source, and save to your fork. In addition, you can load routes from [comma connect](https://connect.comma.ai).
|
||||||
|
|
||||||
|
## Usage Instructions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./cabana -h
|
||||||
|
Usage: ./cabana [options] route
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Displays help on commandline options.
|
||||||
|
--help-all Displays help including Qt specific options.
|
||||||
|
--demo use a demo route instead of providing your own
|
||||||
|
--qcam load qcamera
|
||||||
|
--ecam load wide road camera
|
||||||
|
--stream read can messages from live streaming
|
||||||
|
--panda read can messages from panda
|
||||||
|
--panda-serial <panda-serial> read can messages from panda with given serial
|
||||||
|
--socketcan <socketcan> read can messages from given SocketCAN device
|
||||||
|
--zmq <zmq> the ip address on which to receive zmq
|
||||||
|
messages
|
||||||
|
--data_dir <data_dir> local directory with routes
|
||||||
|
--no-vipc do not output video
|
||||||
|
--dbc <dbc> dbc file to open
|
||||||
|
|
||||||
|
Arguments:
|
||||||
|
route the drive to replay. find your drives at
|
||||||
|
connect.comma.ai
|
||||||
|
```
|
||||||
|
|
||||||
|
See [openpilot wiki](https://github.com/commaai/openpilot/wiki/Cabana)
|
||||||
41
tools/cabana/SConscript
Executable file
41
tools/cabana/SConscript
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
Import('qt_env', 'arch', 'common', 'messaging', 'visionipc', 'replay_lib', 'cereal', 'widgets')
|
||||||
|
|
||||||
|
base_frameworks = qt_env['FRAMEWORKS']
|
||||||
|
base_libs = [common, messaging, cereal, visionipc, 'qt_util', 'zmq', 'capnp', 'kj', 'm', 'ssl', 'crypto', 'pthread'] + qt_env["LIBS"]
|
||||||
|
|
||||||
|
if arch == "Darwin":
|
||||||
|
base_frameworks.append('OpenCL')
|
||||||
|
base_frameworks.append('QtCharts')
|
||||||
|
base_frameworks.append('QtSerialBus')
|
||||||
|
else:
|
||||||
|
base_libs.append('OpenCL')
|
||||||
|
base_libs.append('Qt5Charts')
|
||||||
|
base_libs.append('Qt5SerialBus')
|
||||||
|
|
||||||
|
qt_libs = ['qt_util'] + base_libs
|
||||||
|
|
||||||
|
cabana_env = qt_env.Clone()
|
||||||
|
cabana_libs = [widgets, cereal, messaging, visionipc, replay_lib, 'panda', 'avutil', 'avcodec', 'avformat', 'bz2', 'curl', 'yuv', 'usb-1.0'] + qt_libs
|
||||||
|
opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../opendbc").abspath)
|
||||||
|
cabana_env['CXXFLAGS'] += [opendbc_path]
|
||||||
|
|
||||||
|
# build assets
|
||||||
|
assets = "assets/assets.cc"
|
||||||
|
assets_src = "assets/assets.qrc"
|
||||||
|
cabana_env.Command(assets, assets_src, f"rcc $SOURCES -o $TARGET")
|
||||||
|
cabana_env.Depends(assets, Glob('/assets/*', exclude=[assets, assets_src, "assets/assets.o"]))
|
||||||
|
|
||||||
|
cabana_lib = cabana_env.Library("cabana_lib", ['mainwin.cc', 'streams/socketcanstream.cc', 'streams/pandastream.cc', 'streams/devicestream.cc', 'streams/livestream.cc', 'streams/abstractstream.cc', 'streams/replaystream.cc', 'binaryview.cc', 'historylog.cc', 'videowidget.cc', 'signalview.cc',
|
||||||
|
'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc',
|
||||||
|
'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc',
|
||||||
|
'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'util.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc'], LIBS=cabana_libs, FRAMEWORKS=base_frameworks)
|
||||||
|
cabana_env.Program('cabana', ['cabana.cc', cabana_lib, assets], LIBS=cabana_libs, FRAMEWORKS=base_frameworks)
|
||||||
|
|
||||||
|
if GetOption('extras'):
|
||||||
|
cabana_env.Program('tests/test_cabana', ['tests/test_runner.cc', 'tests/test_cabana.cc', cabana_lib], LIBS=[cabana_libs])
|
||||||
|
|
||||||
|
output_json_file = 'tools/cabana/dbc/car_fingerprint_to_dbc.json'
|
||||||
|
generate_dbc = cabana_env.Command('#' + output_json_file,
|
||||||
|
['dbc/generate_dbc_json.py'],
|
||||||
|
"python3 tools/cabana/dbc/generate_dbc_json.py --out " + output_json_file)
|
||||||
|
cabana_env.Depends(generate_dbc, ["#common", "#selfdrive/boardd", '#opendbc', "#cereal", Glob("#opendbc/*.dbc")])
|
||||||
1
tools/cabana/assets/.gitignore
vendored
Executable file
1
tools/cabana/assets/.gitignore
vendored
Executable file
@@ -0,0 +1 @@
|
|||||||
|
*.cc
|
||||||
6
tools/cabana/assets/assets.qrc
Executable file
6
tools/cabana/assets/assets.qrc
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
<!DOCTYPE RCC><RCC version="1.0">
|
||||||
|
<qresource>
|
||||||
|
<file alias="bootstrap-icons.svg">../../../third_party/bootstrap/bootstrap-icons.svg</file>
|
||||||
|
<file>cabana-icon.png</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
BIN
tools/cabana/assets/cabana-icon.png
Executable file
BIN
tools/cabana/assets/cabana-icon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
458
tools/cabana/binaryview.cc
Executable file
458
tools/cabana/binaryview.cc
Executable file
@@ -0,0 +1,458 @@
|
|||||||
|
#include "tools/cabana/binaryview.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QFontDatabase>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QScrollBar>
|
||||||
|
#include <QShortcut>
|
||||||
|
#include <QToolTip>
|
||||||
|
|
||||||
|
#include "tools/cabana/commands.h"
|
||||||
|
|
||||||
|
// BinaryView
|
||||||
|
|
||||||
|
const int CELL_HEIGHT = 36;
|
||||||
|
const int VERTICAL_HEADER_WIDTH = 30;
|
||||||
|
inline int get_bit_pos(const QModelIndex &index) { return flipBitPos(index.row() * 8 + index.column()); }
|
||||||
|
|
||||||
|
BinaryView::BinaryView(QWidget *parent) : QTableView(parent) {
|
||||||
|
model = new BinaryViewModel(this);
|
||||||
|
setModel(model);
|
||||||
|
delegate = new BinaryItemDelegate(this);
|
||||||
|
setItemDelegate(delegate);
|
||||||
|
horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
|
||||||
|
horizontalHeader()->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont));
|
||||||
|
verticalHeader()->setSectionsClickable(false);
|
||||||
|
verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
|
||||||
|
verticalHeader()->setDefaultSectionSize(CELL_HEIGHT);
|
||||||
|
horizontalHeader()->hide();
|
||||||
|
setShowGrid(false);
|
||||||
|
setMouseTracking(true);
|
||||||
|
setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
|
||||||
|
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &BinaryView::refresh);
|
||||||
|
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &BinaryView::refresh);
|
||||||
|
|
||||||
|
addShortcuts();
|
||||||
|
setWhatsThis(R"(
|
||||||
|
<b>Binary View</b><br/>
|
||||||
|
<!-- TODO: add descprition here -->
|
||||||
|
<span style="color:gray">Shortcuts</span><br />
|
||||||
|
Delete Signal:
|
||||||
|
<span style="background-color:lightGray;color:gray"> x </span>,
|
||||||
|
<span style="background-color:lightGray;color:gray"> Backspace </span>,
|
||||||
|
<span style="background-color:lightGray;color:gray"> Delete </span><br />
|
||||||
|
Change endianness: <span style="background-color:lightGray;color:gray"> e </span><br />
|
||||||
|
Change singedness: <span style="background-color:lightGray;color:gray"> s </span><br />
|
||||||
|
Open chart:
|
||||||
|
<span style="background-color:lightGray;color:gray"> c </span>,
|
||||||
|
<span style="background-color:lightGray;color:gray"> p </span>,
|
||||||
|
<span style="background-color:lightGray;color:gray"> g </span>
|
||||||
|
)");
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::addShortcuts() {
|
||||||
|
// Delete (x, backspace, delete)
|
||||||
|
QShortcut *shortcut_delete_x = new QShortcut(QKeySequence(Qt::Key_X), this);
|
||||||
|
QShortcut *shortcut_delete_backspace = new QShortcut(QKeySequence(Qt::Key_Backspace), this);
|
||||||
|
QShortcut *shortcut_delete_delete = new QShortcut(QKeySequence(Qt::Key_Delete), this);
|
||||||
|
QObject::connect(shortcut_delete_delete, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated);
|
||||||
|
QObject::connect(shortcut_delete_backspace, &QShortcut::activated, shortcut_delete_x, &QShortcut::activated);
|
||||||
|
QObject::connect(shortcut_delete_x, &QShortcut::activated, [=]{
|
||||||
|
if (hovered_sig != nullptr) {
|
||||||
|
UndoStack::push(new RemoveSigCommand(model->msg_id, hovered_sig));
|
||||||
|
hovered_sig = nullptr;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change endianness (e)
|
||||||
|
QShortcut *shortcut_endian = new QShortcut(QKeySequence(Qt::Key_E), this);
|
||||||
|
QObject::connect(shortcut_endian, &QShortcut::activated, [=]{
|
||||||
|
if (hovered_sig != nullptr) {
|
||||||
|
cabana::Signal s = *hovered_sig;
|
||||||
|
s.is_little_endian = !s.is_little_endian;
|
||||||
|
emit editSignal(hovered_sig, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change signedness (s)
|
||||||
|
QShortcut *shortcut_sign = new QShortcut(QKeySequence(Qt::Key_S), this);
|
||||||
|
QObject::connect(shortcut_sign, &QShortcut::activated, [=]{
|
||||||
|
if (hovered_sig != nullptr) {
|
||||||
|
cabana::Signal s = *hovered_sig;
|
||||||
|
s.is_signed = !s.is_signed;
|
||||||
|
emit editSignal(hovered_sig, s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Open chart (c, p, g)
|
||||||
|
QShortcut *shortcut_plot = new QShortcut(QKeySequence(Qt::Key_P), this);
|
||||||
|
QShortcut *shortcut_plot_g = new QShortcut(QKeySequence(Qt::Key_G), this);
|
||||||
|
QShortcut *shortcut_plot_c = new QShortcut(QKeySequence(Qt::Key_C), this);
|
||||||
|
QObject::connect(shortcut_plot_g, &QShortcut::activated, shortcut_plot, &QShortcut::activated);
|
||||||
|
QObject::connect(shortcut_plot_c, &QShortcut::activated, shortcut_plot, &QShortcut::activated);
|
||||||
|
QObject::connect(shortcut_plot, &QShortcut::activated, [=]{
|
||||||
|
if (hovered_sig != nullptr) {
|
||||||
|
emit showChart(model->msg_id, hovered_sig, true, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize BinaryView::minimumSizeHint() const {
|
||||||
|
return {(horizontalHeader()->minimumSectionSize() + 1) * 9 + VERTICAL_HEADER_WIDTH + 2,
|
||||||
|
CELL_HEIGHT * std::min(model->rowCount(), 10) + 2};
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::highlight(const cabana::Signal *sig) {
|
||||||
|
if (sig != hovered_sig) {
|
||||||
|
for (int i = 0; i < model->items.size(); ++i) {
|
||||||
|
auto &item_sigs = model->items[i].sigs;
|
||||||
|
if ((sig && item_sigs.contains(sig)) || (hovered_sig && item_sigs.contains(hovered_sig))) {
|
||||||
|
auto index = model->index(i / model->columnCount(), i % model->columnCount());
|
||||||
|
emit model->dataChanged(index, index, {Qt::DisplayRole});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hovered_sig = sig;
|
||||||
|
emit signalHovered(hovered_sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) {
|
||||||
|
auto index = indexAt(viewport()->mapFromGlobal(QCursor::pos()));
|
||||||
|
if (!anchor_index.isValid() || !index.isValid())
|
||||||
|
return;
|
||||||
|
|
||||||
|
QItemSelection selection;
|
||||||
|
auto [start, size, is_lb] = getSelection(index);
|
||||||
|
for (int i = 0; i < size; ++i) {
|
||||||
|
int pos = is_lb ? flipBitPos(start + i) : flipBitPos(start) + i;
|
||||||
|
selection << QItemSelectionRange{model->index(pos / 8, pos % 8)};
|
||||||
|
}
|
||||||
|
selectionModel()->select(selection, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::mousePressEvent(QMouseEvent *event) {
|
||||||
|
resize_sig = nullptr;
|
||||||
|
if (auto index = indexAt(event->pos()); index.isValid() && index.column() != 8) {
|
||||||
|
anchor_index = index;
|
||||||
|
auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer();
|
||||||
|
int bit_pos = get_bit_pos(anchor_index);
|
||||||
|
for (auto s : item->sigs) {
|
||||||
|
if (bit_pos == s->lsb || bit_pos == s->msb) {
|
||||||
|
int idx = flipBitPos(bit_pos == s->lsb ? s->msb : s->lsb);
|
||||||
|
anchor_index = model->index(idx / 8, idx % 8);
|
||||||
|
resize_sig = s;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::highlightPosition(const QPoint &pos) {
|
||||||
|
if (auto index = indexAt(viewport()->mapFromGlobal(pos)); index.isValid()) {
|
||||||
|
auto item = (BinaryViewModel::Item *)index.internalPointer();
|
||||||
|
const cabana::Signal *sig = item->sigs.isEmpty() ? nullptr : item->sigs.back();
|
||||||
|
highlight(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::mouseMoveEvent(QMouseEvent *event) {
|
||||||
|
highlightPosition(event->globalPos());
|
||||||
|
QTableView::mouseMoveEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::mouseReleaseEvent(QMouseEvent *event) {
|
||||||
|
QTableView::mouseReleaseEvent(event);
|
||||||
|
|
||||||
|
auto release_index = indexAt(event->pos());
|
||||||
|
if (release_index.isValid() && anchor_index.isValid()) {
|
||||||
|
if (selectionModel()->hasSelection()) {
|
||||||
|
auto sig = resize_sig ? *resize_sig : cabana::Signal{};
|
||||||
|
std::tie(sig.start_bit, sig.size, sig.is_little_endian) = getSelection(release_index);
|
||||||
|
resize_sig ? emit editSignal(resize_sig, sig)
|
||||||
|
: UndoStack::push(new AddSigCommand(model->msg_id, sig));
|
||||||
|
} else {
|
||||||
|
auto item = (const BinaryViewModel::Item *)anchor_index.internalPointer();
|
||||||
|
if (item && item->sigs.size() > 0)
|
||||||
|
emit signalClicked(item->sigs.back());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearSelection();
|
||||||
|
anchor_index = QModelIndex();
|
||||||
|
resize_sig = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::leaveEvent(QEvent *event) {
|
||||||
|
highlight(nullptr);
|
||||||
|
QTableView::leaveEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::setMessage(const MessageId &message_id) {
|
||||||
|
model->msg_id = message_id;
|
||||||
|
verticalScrollBar()->setValue(0);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryView::refresh() {
|
||||||
|
clearSelection();
|
||||||
|
anchor_index = QModelIndex();
|
||||||
|
resize_sig = nullptr;
|
||||||
|
hovered_sig = nullptr;
|
||||||
|
model->refresh();
|
||||||
|
highlightPosition(QCursor::pos());
|
||||||
|
}
|
||||||
|
|
||||||
|
QSet<const cabana::Signal *> BinaryView::getOverlappingSignals() const {
|
||||||
|
QSet<const cabana::Signal *> overlapping;
|
||||||
|
for (const auto &item : model->items) {
|
||||||
|
if (item.sigs.size() > 1) {
|
||||||
|
for (auto s : item.sigs) {
|
||||||
|
if (s->type == cabana::Signal::Type::Normal) overlapping += s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overlapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::tuple<int, int, bool> BinaryView::getSelection(QModelIndex index) {
|
||||||
|
if (index.column() == 8) {
|
||||||
|
index = model->index(index.row(), 7);
|
||||||
|
}
|
||||||
|
bool is_lb = true;
|
||||||
|
if (resize_sig) {
|
||||||
|
is_lb = resize_sig->is_little_endian;
|
||||||
|
} else if (settings.drag_direction == Settings::DragDirection::MsbFirst) {
|
||||||
|
is_lb = index < anchor_index;
|
||||||
|
} else if (settings.drag_direction == Settings::DragDirection::LsbFirst) {
|
||||||
|
is_lb = !(index < anchor_index);
|
||||||
|
} else if (settings.drag_direction == Settings::DragDirection::AlwaysLE) {
|
||||||
|
is_lb = true;
|
||||||
|
} else if (settings.drag_direction == Settings::DragDirection::AlwaysBE) {
|
||||||
|
is_lb = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cur_bit_pos = get_bit_pos(index);
|
||||||
|
int anchor_bit_pos = get_bit_pos(anchor_index);
|
||||||
|
int start_bit = is_lb ? std::min(cur_bit_pos, anchor_bit_pos) : get_bit_pos(std::min(index, anchor_index));
|
||||||
|
int size = is_lb ? std::abs(cur_bit_pos - anchor_bit_pos) + 1 : std::abs(flipBitPos(cur_bit_pos) - flipBitPos(anchor_bit_pos)) + 1;
|
||||||
|
return {start_bit, size, is_lb};
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinaryViewModel
|
||||||
|
|
||||||
|
void BinaryViewModel::refresh() {
|
||||||
|
beginResetModel();
|
||||||
|
items.clear();
|
||||||
|
if (auto dbc_msg = dbc()->msg(msg_id)) {
|
||||||
|
row_count = dbc_msg->size;
|
||||||
|
items.resize(row_count * column_count);
|
||||||
|
for (auto sig : dbc_msg->getSignals()) {
|
||||||
|
for (int j = 0; j < sig->size; ++j) {
|
||||||
|
int pos = sig->is_little_endian ? flipBitPos(sig->start_bit + j) : flipBitPos(sig->start_bit) + j;
|
||||||
|
int idx = column_count * (pos / 8) + pos % 8;
|
||||||
|
if (idx >= items.size()) {
|
||||||
|
qWarning() << "signal " << sig->name << "out of bounds.start_bit:" << sig->start_bit << "size:" << sig->size;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (j == 0) sig->is_little_endian ? items[idx].is_lsb = true : items[idx].is_msb = true;
|
||||||
|
if (j == sig->size - 1) sig->is_little_endian ? items[idx].is_msb = true : items[idx].is_lsb = true;
|
||||||
|
|
||||||
|
auto &sigs = items[idx].sigs;
|
||||||
|
sigs.push_back(sig);
|
||||||
|
if (sigs.size() > 1) {
|
||||||
|
std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) { return l->size > r->size; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row_count = can->lastMessage(msg_id).dat.size();
|
||||||
|
items.resize(row_count * column_count);
|
||||||
|
}
|
||||||
|
int valid_rows = std::min<int>(can->lastMessage(msg_id).dat.size(), row_count);
|
||||||
|
for (int i = 0; i < valid_rows * column_count; ++i) {
|
||||||
|
items[i].valid = true;
|
||||||
|
}
|
||||||
|
endResetModel();
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryViewModel::updateItem(int row, int col, uint8_t val, const QColor &color) {
|
||||||
|
auto &item = items[row * column_count + col];
|
||||||
|
if (item.val != val || item.bg_color != color) {
|
||||||
|
item.val = val;
|
||||||
|
item.bg_color = color;
|
||||||
|
auto idx = index(row, col);
|
||||||
|
emit dataChanged(idx, idx, {Qt::DisplayRole});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryViewModel::updateState() {
|
||||||
|
const auto &last_msg = can->lastMessage(msg_id);
|
||||||
|
const auto &binary = last_msg.dat;
|
||||||
|
// data size may changed.
|
||||||
|
if (binary.size() > row_count) {
|
||||||
|
beginInsertRows({}, row_count, binary.size() - 1);
|
||||||
|
row_count = binary.size();
|
||||||
|
items.resize(row_count * column_count);
|
||||||
|
endInsertRows();
|
||||||
|
}
|
||||||
|
|
||||||
|
const double max_f = 255.0;
|
||||||
|
const double factor = 0.25;
|
||||||
|
const double scaler = max_f / log2(1.0 + factor);
|
||||||
|
for (int i = 0; i < binary.size(); ++i) {
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
auto &item = items[i * column_count + j];
|
||||||
|
int val = ((binary[i] >> (7 - j)) & 1) != 0 ? 1 : 0;
|
||||||
|
// Bit update frequency based highlighting
|
||||||
|
double offset = !item.sigs.empty() ? 50 : 0;
|
||||||
|
auto n = last_msg.last_changes[i].bit_change_counts[j];
|
||||||
|
double min_f = n == 0 ? offset : offset + 25;
|
||||||
|
double alpha = std::clamp(offset + log2(1.0 + factor * (double)n / (double)last_msg.count) * scaler, min_f, max_f);
|
||||||
|
auto color = item.bg_color;
|
||||||
|
color.setAlpha(alpha);
|
||||||
|
updateItem(i, j, val, color);
|
||||||
|
}
|
||||||
|
updateItem(i, 8, binary[i], last_msg.colors[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant BinaryViewModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||||
|
if (orientation == Qt::Vertical) {
|
||||||
|
switch (role) {
|
||||||
|
case Qt::DisplayRole: return section;
|
||||||
|
case Qt::SizeHintRole: return QSize(VERTICAL_HEADER_WIDTH, 0);
|
||||||
|
case Qt::TextAlignmentRole: return Qt::AlignCenter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant BinaryViewModel::data(const QModelIndex &index, int role) const {
|
||||||
|
auto item = (const BinaryViewModel::Item *)index.internalPointer();
|
||||||
|
return role == Qt::ToolTipRole && item && !item->sigs.empty() ? signalToolTip(item->sigs.back()) : QVariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinaryItemDelegate
|
||||||
|
|
||||||
|
BinaryItemDelegate::BinaryItemDelegate(QObject *parent) : QStyledItemDelegate(parent) {
|
||||||
|
small_font.setPixelSize(8);
|
||||||
|
hex_font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||||
|
hex_font.setBold(true);
|
||||||
|
|
||||||
|
bin_text_table[0].setText("0");
|
||||||
|
bin_text_table[1].setText("1");
|
||||||
|
for (int i = 0; i < 256; ++i) {
|
||||||
|
hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper());
|
||||||
|
hex_text_table[i].prepare({}, hex_font);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool BinaryItemDelegate::hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const {
|
||||||
|
if (!index.isValid()) return false;
|
||||||
|
auto model = (const BinaryViewModel*)(index.model());
|
||||||
|
int idx = (index.row() + dy) * model->columnCount() + index.column() + dx;
|
||||||
|
return (idx >=0 && idx < model->items.size()) ? model->items[idx].sigs.contains(sig) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BinaryItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||||
|
auto item = (const BinaryViewModel::Item *)index.internalPointer();
|
||||||
|
BinaryView *bin_view = (BinaryView *)parent();
|
||||||
|
painter->save();
|
||||||
|
|
||||||
|
if (index.column() == 8) {
|
||||||
|
if (item->valid) {
|
||||||
|
painter->setFont(hex_font);
|
||||||
|
painter->fillRect(option.rect, item->bg_color);
|
||||||
|
}
|
||||||
|
} else if (option.state & QStyle::State_Selected) {
|
||||||
|
auto color = bin_view->resize_sig ? bin_view->resize_sig->color : option.palette.color(QPalette::Active, QPalette::Highlight);
|
||||||
|
painter->fillRect(option.rect, color);
|
||||||
|
painter->setPen(option.palette.color(QPalette::BrightText));
|
||||||
|
} else if (!bin_view->selectionModel()->hasSelection() || !item->sigs.contains(bin_view->resize_sig)) { // not resizing
|
||||||
|
if (item->sigs.size() > 0) {
|
||||||
|
for (auto &s : item->sigs) {
|
||||||
|
if (s == bin_view->hovered_sig) {
|
||||||
|
painter->fillRect(option.rect, s->color.darker(125)); // 4/5x brightness
|
||||||
|
} else {
|
||||||
|
drawSignalCell(painter, option, index, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (item->valid && item->bg_color.alpha() > 0) {
|
||||||
|
painter->fillRect(option.rect, item->bg_color);
|
||||||
|
}
|
||||||
|
auto color_role = item->sigs.contains(bin_view->hovered_sig) ? QPalette::BrightText : QPalette::Text;
|
||||||
|
painter->setPen(option.palette.color(color_role));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item->sigs.size() > 1) {
|
||||||
|
painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::Dense7Pattern));
|
||||||
|
} else if (!item->valid) {
|
||||||
|
painter->fillRect(option.rect, QBrush(Qt::darkGray, Qt::BDiagPattern));
|
||||||
|
}
|
||||||
|
if (item->valid) {
|
||||||
|
utils::drawStaticText(painter, option.rect, index.column() == 8 ? hex_text_table[item->val] : bin_text_table[item->val]);
|
||||||
|
}
|
||||||
|
if (item->is_msb || item->is_lsb) {
|
||||||
|
painter->setFont(small_font);
|
||||||
|
painter->drawText(option.rect.adjusted(8, 0, -8, -3), Qt::AlignRight | Qt::AlignBottom, item->is_msb ? "M" : "L");
|
||||||
|
}
|
||||||
|
painter->restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw border on edge of signal
|
||||||
|
void BinaryItemDelegate::drawSignalCell(QPainter *painter, const QStyleOptionViewItem &option,
|
||||||
|
const QModelIndex &index, const cabana::Signal *sig) const {
|
||||||
|
bool draw_left = !hasSignal(index, -1, 0, sig);
|
||||||
|
bool draw_top = !hasSignal(index, 0, -1, sig);
|
||||||
|
bool draw_right = !hasSignal(index, 1, 0, sig);
|
||||||
|
bool draw_bottom = !hasSignal(index, 0, 1, sig);
|
||||||
|
|
||||||
|
const int spacing = 2;
|
||||||
|
QRect rc = option.rect.adjusted(draw_left * 3, draw_top * spacing, draw_right * -3, draw_bottom * -spacing);
|
||||||
|
QRegion subtract;
|
||||||
|
if (!draw_top) {
|
||||||
|
if (!draw_left && !hasSignal(index, -1, -1, sig)) {
|
||||||
|
subtract += QRect{rc.left(), rc.top(), 3, spacing};
|
||||||
|
} else if (!draw_right && !hasSignal(index, 1, -1, sig)) {
|
||||||
|
subtract += QRect{rc.right() - 2, rc.top(), 3, spacing};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!draw_bottom) {
|
||||||
|
if (!draw_left && !hasSignal(index, -1, 1, sig)) {
|
||||||
|
subtract += QRect{rc.left(), rc.bottom() - (spacing - 1), 3, spacing};
|
||||||
|
} else if (!draw_right && !hasSignal(index, 1, 1, sig)) {
|
||||||
|
subtract += QRect{rc.right() - 2, rc.bottom() - (spacing - 1), 3, spacing};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
painter->setClipRegion(QRegion(rc).subtracted(subtract));
|
||||||
|
|
||||||
|
auto item = (const BinaryViewModel::Item *)index.internalPointer();
|
||||||
|
QColor color = sig->color;
|
||||||
|
color.setAlpha(item->bg_color.alpha());
|
||||||
|
// Mixing the signal colour with the Base background color to fade it
|
||||||
|
painter->fillRect(rc, option.palette.color(QPalette::Base));
|
||||||
|
painter->fillRect(rc, color);
|
||||||
|
|
||||||
|
// Draw edges
|
||||||
|
color = sig->color.darker(125);
|
||||||
|
painter->setPen(QPen(color, 1));
|
||||||
|
if (draw_left) painter->drawLine(rc.topLeft(), rc.bottomLeft());
|
||||||
|
if (draw_right) painter->drawLine(rc.topRight(), rc.bottomRight());
|
||||||
|
if (draw_bottom) painter->drawLine(rc.bottomLeft(), rc.bottomRight());
|
||||||
|
if (draw_top) painter->drawLine(rc.topLeft(), rc.topRight());
|
||||||
|
|
||||||
|
if (!subtract.isEmpty()) {
|
||||||
|
// fill gaps inside corners.
|
||||||
|
painter->setPen(QPen(color, 2, Qt::SolidLine, Qt::SquareCap, Qt::MiterJoin));
|
||||||
|
for (auto &r : subtract) {
|
||||||
|
painter->drawRect(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
92
tools/cabana/binaryview.h
Executable file
92
tools/cabana/binaryview.h
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <tuple>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QStyledItemDelegate>
|
||||||
|
#include <QTableView>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
class BinaryItemDelegate : public QStyledItemDelegate {
|
||||||
|
public:
|
||||||
|
BinaryItemDelegate(QObject *parent);
|
||||||
|
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
bool hasSignal(const QModelIndex &index, int dx, int dy, const cabana::Signal *sig) const;
|
||||||
|
void drawSignalCell(QPainter* painter, const QStyleOptionViewItem &option, const QModelIndex &index, const cabana::Signal *sig) const;
|
||||||
|
|
||||||
|
QFont small_font, hex_font;
|
||||||
|
std::array<QStaticText, 256> hex_text_table;
|
||||||
|
std::array<QStaticText, 2> bin_text_table;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BinaryViewModel : public QAbstractTableModel {
|
||||||
|
public:
|
||||||
|
BinaryViewModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||||
|
void refresh();
|
||||||
|
void updateState();
|
||||||
|
void updateItem(int row, int col, uint8_t val, const QColor &color);
|
||||||
|
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return row_count; }
|
||||||
|
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return column_count; }
|
||||||
|
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override {
|
||||||
|
return createIndex(row, column, (void *)&items[row * column_count + column]);
|
||||||
|
}
|
||||||
|
Qt::ItemFlags flags(const QModelIndex &index) const override {
|
||||||
|
return (index.column() == column_count - 1) ? Qt::ItemIsEnabled : Qt::ItemIsEnabled | Qt::ItemIsSelectable;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Item {
|
||||||
|
QColor bg_color = QColor(102, 86, 169, 255);
|
||||||
|
bool is_msb = false;
|
||||||
|
bool is_lsb = false;
|
||||||
|
uint8_t val;
|
||||||
|
QList<const cabana::Signal *> sigs;
|
||||||
|
bool valid = false;
|
||||||
|
};
|
||||||
|
std::vector<Item> items;
|
||||||
|
|
||||||
|
MessageId msg_id;
|
||||||
|
int row_count = 0;
|
||||||
|
const int column_count = 9;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BinaryView : public QTableView {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
BinaryView(QWidget *parent = nullptr);
|
||||||
|
void setMessage(const MessageId &message_id);
|
||||||
|
void highlight(const cabana::Signal *sig);
|
||||||
|
QSet<const cabana::Signal*> getOverlappingSignals() const;
|
||||||
|
inline void updateState() { model->updateState(); }
|
||||||
|
QSize minimumSizeHint() const override;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void signalClicked(const cabana::Signal *sig);
|
||||||
|
void signalHovered(const cabana::Signal *sig);
|
||||||
|
void editSignal(const cabana::Signal *origin_s, cabana::Signal &s);
|
||||||
|
void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void addShortcuts();
|
||||||
|
void refresh();
|
||||||
|
std::tuple<int, int, bool> getSelection(QModelIndex index);
|
||||||
|
void setSelection(const QRect &rect, QItemSelectionModel::SelectionFlags flags) override;
|
||||||
|
void mousePressEvent(QMouseEvent *event) override;
|
||||||
|
void mouseMoveEvent(QMouseEvent *event) override;
|
||||||
|
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||||
|
void leaveEvent(QEvent *event) override;
|
||||||
|
void highlightPosition(const QPoint &pt);
|
||||||
|
|
||||||
|
QModelIndex anchor_index;
|
||||||
|
BinaryViewModel *model;
|
||||||
|
BinaryItemDelegate *delegate;
|
||||||
|
const cabana::Signal *resize_sig = nullptr;
|
||||||
|
const cabana::Signal *hovered_sig = nullptr;
|
||||||
|
friend class BinaryItemDelegate;
|
||||||
|
};
|
||||||
109
tools/cabana/cabana.cc
Executable file
109
tools/cabana/cabana.cc
Executable file
@@ -0,0 +1,109 @@
|
|||||||
|
#include <QApplication>
|
||||||
|
#include <QCommandLineParser>
|
||||||
|
|
||||||
|
#include "selfdrive/ui/qt/util.h"
|
||||||
|
#include "tools/cabana/mainwin.h"
|
||||||
|
#include "tools/cabana/streamselector.h"
|
||||||
|
#include "tools/cabana/streams/devicestream.h"
|
||||||
|
#include "tools/cabana/streams/pandastream.h"
|
||||||
|
#include "tools/cabana/streams/replaystream.h"
|
||||||
|
#include "tools/cabana/streams/socketcanstream.h"
|
||||||
|
|
||||||
|
int main(int argc, char *argv[]) {
|
||||||
|
QCoreApplication::setApplicationName("Cabana");
|
||||||
|
QCoreApplication::setAttribute(Qt::AA_ShareOpenGLContexts);
|
||||||
|
initApp(argc, argv, false);
|
||||||
|
QApplication app(argc, argv);
|
||||||
|
app.setApplicationDisplayName("Cabana");
|
||||||
|
app.setWindowIcon(QIcon(":cabana-icon.png"));
|
||||||
|
|
||||||
|
UnixSignalHandler signalHandler;
|
||||||
|
utils::setTheme(settings.theme);
|
||||||
|
|
||||||
|
QCommandLineParser cmd_parser;
|
||||||
|
cmd_parser.addHelpOption();
|
||||||
|
cmd_parser.addPositionalArgument("route", "the drive to replay. find your drives at connect.comma.ai");
|
||||||
|
cmd_parser.addOption({"demo", "use a demo route instead of providing your own"});
|
||||||
|
cmd_parser.addOption({"qcam", "load qcamera"});
|
||||||
|
cmd_parser.addOption({"ecam", "load wide road camera"});
|
||||||
|
cmd_parser.addOption({"dcam", "load driver camera"});
|
||||||
|
cmd_parser.addOption({"stream", "read can messages from live streaming"});
|
||||||
|
cmd_parser.addOption({"panda", "read can messages from panda"});
|
||||||
|
cmd_parser.addOption({"panda-serial", "read can messages from panda with given serial", "panda-serial"});
|
||||||
|
if (SocketCanStream::available()) {
|
||||||
|
cmd_parser.addOption({"socketcan", "read can messages from given SocketCAN device", "socketcan"});
|
||||||
|
}
|
||||||
|
cmd_parser.addOption({"zmq", "the ip address on which to receive zmq messages", "zmq"});
|
||||||
|
cmd_parser.addOption({"data_dir", "local directory with routes", "data_dir"});
|
||||||
|
cmd_parser.addOption({"no-vipc", "do not output video"});
|
||||||
|
cmd_parser.addOption({"dbc", "dbc file to open", "dbc"});
|
||||||
|
cmd_parser.process(app);
|
||||||
|
|
||||||
|
QString dbc_file = cmd_parser.isSet("dbc") ? cmd_parser.value("dbc") : "";
|
||||||
|
|
||||||
|
AbstractStream *stream = nullptr;
|
||||||
|
if (cmd_parser.isSet("stream")) {
|
||||||
|
stream = new DeviceStream(&app, cmd_parser.value("zmq"));
|
||||||
|
} else if (cmd_parser.isSet("panda") || cmd_parser.isSet("panda-serial")) {
|
||||||
|
PandaStreamConfig config = {};
|
||||||
|
if (cmd_parser.isSet("panda-serial")) {
|
||||||
|
config.serial = cmd_parser.value("panda-serial");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stream = new PandaStream(&app, config);
|
||||||
|
} catch (std::exception &e) {
|
||||||
|
qWarning() << e.what();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
} else if (cmd_parser.isSet("socketcan")) {
|
||||||
|
SocketCanStreamConfig config = {};
|
||||||
|
config.device = cmd_parser.value("socketcan");
|
||||||
|
stream = new SocketCanStream(&app, config);
|
||||||
|
} else {
|
||||||
|
uint32_t replay_flags = REPLAY_FLAG_NONE;
|
||||||
|
if (cmd_parser.isSet("ecam")) replay_flags |= REPLAY_FLAG_ECAM;
|
||||||
|
if (cmd_parser.isSet("qcam")) replay_flags |= REPLAY_FLAG_QCAMERA;
|
||||||
|
if (cmd_parser.isSet("dcam")) replay_flags |= REPLAY_FLAG_DCAM;
|
||||||
|
if (cmd_parser.isSet("no-vipc")) replay_flags |= REPLAY_FLAG_NO_VIPC;
|
||||||
|
|
||||||
|
const QStringList args = cmd_parser.positionalArguments();
|
||||||
|
QString route;
|
||||||
|
if (args.size() > 0) {
|
||||||
|
route = args.first();
|
||||||
|
} else if (cmd_parser.isSet("demo")) {
|
||||||
|
route = DEMO_ROUTE;
|
||||||
|
}
|
||||||
|
if (!route.isEmpty()) {
|
||||||
|
auto replay_stream = new ReplayStream(&app);
|
||||||
|
if (!replay_stream->loadRoute(route, cmd_parser.value("data_dir"), replay_flags)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
stream = replay_stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int ret = 0;
|
||||||
|
{
|
||||||
|
MainWindow w;
|
||||||
|
QTimer::singleShot(0, [&]() {
|
||||||
|
if (!stream) {
|
||||||
|
StreamSelector dlg(&stream);
|
||||||
|
dlg.exec();
|
||||||
|
dbc_file = dlg.dbcFile();
|
||||||
|
}
|
||||||
|
if (!stream) {
|
||||||
|
stream = new DummyStream(&app);
|
||||||
|
}
|
||||||
|
stream->start();
|
||||||
|
if (!dbc_file.isEmpty()) {
|
||||||
|
w.loadFile(dbc_file);
|
||||||
|
}
|
||||||
|
w.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
ret = app.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
delete can;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
865
tools/cabana/chart/chart.cc
Executable file
865
tools/cabana/chart/chart.cc
Executable file
@@ -0,0 +1,865 @@
|
|||||||
|
#include "tools/cabana/chart/chart.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
#include <QActionGroup>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QDrag>
|
||||||
|
#include <QGraphicsLayout>
|
||||||
|
#include <QGraphicsDropShadowEffect>
|
||||||
|
#include <QGraphicsItemGroup>
|
||||||
|
#include <QGraphicsOpacityEffect>
|
||||||
|
#include <QMimeData>
|
||||||
|
#include <QOpenGLWidget>
|
||||||
|
#include <QPropertyAnimation>
|
||||||
|
#include <QRandomGenerator>
|
||||||
|
#include <QRubberBand>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QWindow>
|
||||||
|
|
||||||
|
#include "tools/cabana/chart/chartswidget.h"
|
||||||
|
|
||||||
|
// ChartAxisElement's padding is 4 (https://codebrowser.dev/qt5/qtcharts/src/charts/axis/chartaxiselement_p.h.html)
|
||||||
|
const int AXIS_X_TOP_MARGIN = 4;
|
||||||
|
// Define a small value of epsilon to compare double values
|
||||||
|
const float EPSILON = 0.000001;
|
||||||
|
static inline bool xLessThan(const QPointF &p, float x) { return p.x() < (x - EPSILON); }
|
||||||
|
|
||||||
|
ChartView::ChartView(const std::pair<double, double> &x_range, ChartsWidget *parent)
|
||||||
|
: charts_widget(parent), QChartView(parent) {
|
||||||
|
series_type = (SeriesType)settings.chart_series_type;
|
||||||
|
chart()->setBackgroundVisible(false);
|
||||||
|
axis_x = new QValueAxis(this);
|
||||||
|
axis_y = new QValueAxis(this);
|
||||||
|
chart()->addAxis(axis_x, Qt::AlignBottom);
|
||||||
|
chart()->addAxis(axis_y, Qt::AlignLeft);
|
||||||
|
chart()->legend()->layout()->setContentsMargins(0, 0, 0, 0);
|
||||||
|
chart()->legend()->setShowToolTips(true);
|
||||||
|
chart()->setMargins({0, 0, 0, 0});
|
||||||
|
|
||||||
|
axis_x->setRange(x_range.first, x_range.second);
|
||||||
|
|
||||||
|
tip_label = new TipLabel(this);
|
||||||
|
createToolButtons();
|
||||||
|
setRubberBand(QChartView::HorizontalRubberBand);
|
||||||
|
setMouseTracking(true);
|
||||||
|
setTheme(settings.theme == DARK_THEME ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight);
|
||||||
|
signal_value_font.setPointSize(9);
|
||||||
|
|
||||||
|
QObject::connect(axis_y, &QValueAxis::rangeChanged, this, &ChartView::resetChartCache);
|
||||||
|
QObject::connect(axis_y, &QAbstractAxis::titleTextChanged, this, &ChartView::resetChartCache);
|
||||||
|
QObject::connect(window()->windowHandle(), &QWindow::screenChanged, this, &ChartView::resetChartCache);
|
||||||
|
|
||||||
|
QObject::connect(dbc(), &DBCManager::signalRemoved, this, &ChartView::signalRemoved);
|
||||||
|
QObject::connect(dbc(), &DBCManager::signalUpdated, this, &ChartView::signalUpdated);
|
||||||
|
QObject::connect(dbc(), &DBCManager::msgRemoved, this, &ChartView::msgRemoved);
|
||||||
|
QObject::connect(dbc(), &DBCManager::msgUpdated, this, &ChartView::msgUpdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::createToolButtons() {
|
||||||
|
move_icon = new QGraphicsPixmapItem(utils::icon("grip-horizontal"), chart());
|
||||||
|
move_icon->setToolTip(tr("Drag and drop to move chart"));
|
||||||
|
|
||||||
|
QToolButton *remove_btn = new ToolButton("x", tr("Remove Chart"));
|
||||||
|
close_btn_proxy = new QGraphicsProxyWidget(chart());
|
||||||
|
close_btn_proxy->setWidget(remove_btn);
|
||||||
|
close_btn_proxy->setZValue(chart()->zValue() + 11);
|
||||||
|
|
||||||
|
menu = new QMenu(this);
|
||||||
|
// series types
|
||||||
|
auto change_series_group = new QActionGroup(menu);
|
||||||
|
change_series_group->setExclusive(true);
|
||||||
|
QStringList types{tr("Line"), tr("Step Line"), tr("Scatter")};
|
||||||
|
for (int i = 0; i < types.size(); ++i) {
|
||||||
|
QAction *act = new QAction(types[i], change_series_group);
|
||||||
|
act->setData(i);
|
||||||
|
act->setCheckable(true);
|
||||||
|
act->setChecked(i == (int)series_type);
|
||||||
|
menu->addAction(act);
|
||||||
|
}
|
||||||
|
menu->addSeparator();
|
||||||
|
menu->addAction(tr("Manage Signals"), this, &ChartView::manageSignals);
|
||||||
|
split_chart_act = menu->addAction(tr("Split Chart"), [this]() { charts_widget->splitChart(this); });
|
||||||
|
|
||||||
|
QToolButton *manage_btn = new ToolButton("list", "");
|
||||||
|
manage_btn->setMenu(menu);
|
||||||
|
manage_btn->setPopupMode(QToolButton::InstantPopup);
|
||||||
|
manage_btn->setStyleSheet("QToolButton::menu-indicator { image: none; }");
|
||||||
|
manage_btn_proxy = new QGraphicsProxyWidget(chart());
|
||||||
|
manage_btn_proxy->setWidget(manage_btn);
|
||||||
|
manage_btn_proxy->setZValue(chart()->zValue() + 11);
|
||||||
|
|
||||||
|
close_act = new QAction(tr("Close"), this);
|
||||||
|
QObject::connect(close_act, &QAction::triggered, [this] () { charts_widget->removeChart(this); });
|
||||||
|
QObject::connect(remove_btn, &QToolButton::clicked, close_act, &QAction::triggered);
|
||||||
|
QObject::connect(change_series_group, &QActionGroup::triggered, [this](QAction *action) {
|
||||||
|
setSeriesType((SeriesType)action->data().toInt());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize ChartView::sizeHint() const {
|
||||||
|
return {CHART_MIN_WIDTH, settings.chart_height};
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::setTheme(QChart::ChartTheme theme) {
|
||||||
|
chart()->setTheme(theme);
|
||||||
|
if (theme == QChart::ChartThemeDark) {
|
||||||
|
axis_x->setTitleBrush(palette().text());
|
||||||
|
axis_x->setLabelsBrush(palette().text());
|
||||||
|
axis_y->setTitleBrush(palette().text());
|
||||||
|
axis_y->setLabelsBrush(palette().text());
|
||||||
|
chart()->legend()->setLabelColor(palette().color(QPalette::Text));
|
||||||
|
}
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
s.series->setColor(s.sig->color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::addSignal(const MessageId &msg_id, const cabana::Signal *sig) {
|
||||||
|
if (hasSignal(msg_id, sig)) return;
|
||||||
|
|
||||||
|
QXYSeries *series = createSeries(series_type, sig->color);
|
||||||
|
sigs.push_back({.msg_id = msg_id, .sig = sig, .series = series});
|
||||||
|
updateSeries(sig);
|
||||||
|
updateSeriesPoints();
|
||||||
|
updateTitle();
|
||||||
|
emit charts_widget->seriesChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChartView::hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const {
|
||||||
|
return std::any_of(sigs.cbegin(), sigs.cend(), [&](auto &s) { return s.msg_id == msg_id && s.sig == sig; });
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::removeIf(std::function<bool(const SigItem &s)> predicate) {
|
||||||
|
int prev_size = sigs.size();
|
||||||
|
for (auto it = sigs.begin(); it != sigs.end(); /**/) {
|
||||||
|
if (predicate(*it)) {
|
||||||
|
chart()->removeSeries(it->series);
|
||||||
|
it->series->deleteLater();
|
||||||
|
it = sigs.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sigs.empty()) {
|
||||||
|
charts_widget->removeChart(this);
|
||||||
|
} else if (sigs.size() != prev_size) {
|
||||||
|
emit charts_widget->seriesChanged();
|
||||||
|
updateAxisY();
|
||||||
|
resetChartCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::signalUpdated(const cabana::Signal *sig) {
|
||||||
|
if (std::any_of(sigs.cbegin(), sigs.cend(), [=](auto &s) { return s.sig == sig; })) {
|
||||||
|
for (const auto &s : sigs) {
|
||||||
|
if (s.sig == sig && s.series->color() != sig->color) {
|
||||||
|
setSeriesColor(s.series, sig->color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateTitle();
|
||||||
|
updateSeries(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::msgUpdated(MessageId id) {
|
||||||
|
if (std::any_of(sigs.cbegin(), sigs.cend(), [=](auto &s) { return s.msg_id.address == id.address; })) {
|
||||||
|
updateTitle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::manageSignals() {
|
||||||
|
SignalSelector dlg(tr("Manage Chart"), this);
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
dlg.addSelected(s.msg_id, s.sig);
|
||||||
|
}
|
||||||
|
if (dlg.exec() == QDialog::Accepted) {
|
||||||
|
auto items = dlg.seletedItems();
|
||||||
|
for (auto s : items) {
|
||||||
|
addSignal(s->msg_id, s->sig);
|
||||||
|
}
|
||||||
|
removeIf([&](auto &s) {
|
||||||
|
return std::none_of(items.cbegin(), items.cend(), [&](auto &it) { return s.msg_id == it->msg_id && s.sig == it->sig; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::resizeEvent(QResizeEvent *event) {
|
||||||
|
qreal left, top, right, bottom;
|
||||||
|
chart()->layout()->getContentsMargins(&left, &top, &right, &bottom);
|
||||||
|
move_icon->setPos(left, top);
|
||||||
|
close_btn_proxy->setPos(rect().right() - right - close_btn_proxy->size().width(), top);
|
||||||
|
int x = close_btn_proxy->pos().x() - manage_btn_proxy->size().width() - style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing);
|
||||||
|
manage_btn_proxy->setPos(x, top);
|
||||||
|
if (align_to > 0) {
|
||||||
|
updatePlotArea(align_to, true);
|
||||||
|
}
|
||||||
|
QChartView::resizeEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::updatePlotArea(int left_pos, bool force) {
|
||||||
|
if (align_to != left_pos || force) {
|
||||||
|
align_to = left_pos;
|
||||||
|
|
||||||
|
qreal left, top, right, bottom;
|
||||||
|
chart()->layout()->getContentsMargins(&left, &top, &right, &bottom);
|
||||||
|
QSizeF legend_size = chart()->legend()->layout()->minimumSize();
|
||||||
|
legend_size.setWidth(manage_btn_proxy->sceneBoundingRect().left() - move_icon->sceneBoundingRect().right());
|
||||||
|
chart()->legend()->setGeometry({move_icon->sceneBoundingRect().topRight(), legend_size});
|
||||||
|
|
||||||
|
// add top space for signal value
|
||||||
|
int adjust_top = chart()->legend()->geometry().height() + QFontMetrics(signal_value_font).height() + 3;
|
||||||
|
adjust_top = std::max<int>(adjust_top, manage_btn_proxy->sceneBoundingRect().height() + style()->pixelMetric(QStyle::PM_LayoutTopMargin));
|
||||||
|
// add right space for x-axis label
|
||||||
|
QSizeF x_label_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, QString::number(axis_x->max(), 'f', 2));
|
||||||
|
x_label_size += QSizeF{5, 5};
|
||||||
|
chart()->setPlotArea(rect().adjusted(align_to + left, adjust_top + top, -x_label_size.width() / 2 - right, -x_label_size.height() - bottom));
|
||||||
|
chart()->layout()->invalidate();
|
||||||
|
resetChartCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::updateTitle() {
|
||||||
|
for (QLegendMarker *marker : chart()->legend()->markers()) {
|
||||||
|
QObject::connect(marker, &QLegendMarker::clicked, this, &ChartView::handleMarkerClicked, Qt::UniqueConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use CSS to draw titles in the WindowText color
|
||||||
|
auto tmp = palette().color(QPalette::WindowText);
|
||||||
|
auto titleColorCss = tmp.name(QColor::HexArgb);
|
||||||
|
// Draw message details in similar color, but slightly fade it to the background
|
||||||
|
tmp.setAlpha(180);
|
||||||
|
auto msgColorCss = tmp.name(QColor::HexArgb);
|
||||||
|
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
auto decoration = s.series->isVisible() ? "none" : "line-through";
|
||||||
|
s.series->setName(QString("<span style=\"text-decoration:%1; color:%2\"><b>%3</b> <font color=\"%4\">%5 %6</font></span>")
|
||||||
|
.arg(decoration, titleColorCss, s.sig->name,
|
||||||
|
msgColorCss, msgName(s.msg_id), s.msg_id.toString()));
|
||||||
|
}
|
||||||
|
split_chart_act->setEnabled(sigs.size() > 1);
|
||||||
|
resetChartCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::updatePlot(double cur, double min, double max) {
|
||||||
|
cur_sec = cur;
|
||||||
|
if (min != axis_x->min() || max != axis_x->max()) {
|
||||||
|
axis_x->setRange(min, max);
|
||||||
|
updateAxisY();
|
||||||
|
updateSeriesPoints();
|
||||||
|
// update tooltip
|
||||||
|
if (tooltip_x >= 0) {
|
||||||
|
showTip(chart()->mapToValue({tooltip_x, 0}).x());
|
||||||
|
}
|
||||||
|
resetChartCache();
|
||||||
|
}
|
||||||
|
viewport()->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::updateSeriesPoints() {
|
||||||
|
// Show points when zoomed in enough
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
auto begin = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan);
|
||||||
|
auto end = std::lower_bound(begin, s.vals.cend(), axis_x->max(), xLessThan);
|
||||||
|
if (begin != end) {
|
||||||
|
int num_points = std::max<int>((end - begin), 1);
|
||||||
|
QPointF right_pt = end == s.vals.cend() ? s.vals.back() : *end;
|
||||||
|
double pixels_per_point = (chart()->mapToPosition(right_pt).x() - chart()->mapToPosition(*begin).x()) / num_points;
|
||||||
|
|
||||||
|
if (series_type == SeriesType::Scatter) {
|
||||||
|
qreal size = std::clamp(pixels_per_point / 2.0, 2.0, 8.0);
|
||||||
|
if (s.series->useOpenGL()) {
|
||||||
|
size *= devicePixelRatioF();
|
||||||
|
}
|
||||||
|
((QScatterSeries *)s.series)->setMarkerSize(size);
|
||||||
|
} else {
|
||||||
|
s.series->setPointsVisible(pixels_per_point > 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::appendCanEvents(const cabana::Signal *sig, const std::vector<const CanEvent *> &events,
|
||||||
|
std::vector<QPointF> &vals, std::vector<QPointF> &step_vals) {
|
||||||
|
vals.reserve(vals.size() + events.capacity());
|
||||||
|
step_vals.reserve(step_vals.size() + events.capacity() * 2);
|
||||||
|
|
||||||
|
double value = 0;
|
||||||
|
const uint64_t begin_mono_time = can->routeStartTime() * 1e9;
|
||||||
|
for (const CanEvent *e : events) {
|
||||||
|
if (sig->getValue(e->dat, e->size, &value)) {
|
||||||
|
const double ts = (e->mono_time - std::min(e->mono_time, begin_mono_time)) / 1e9;
|
||||||
|
vals.emplace_back(ts, value);
|
||||||
|
if (!step_vals.empty())
|
||||||
|
step_vals.emplace_back(ts, step_vals.back().y());
|
||||||
|
step_vals.emplace_back(ts, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::updateSeries(const cabana::Signal *sig, const MessageEventsMap *msg_new_events) {
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
if (!sig || s.sig == sig) {
|
||||||
|
if (!msg_new_events) {
|
||||||
|
s.vals.clear();
|
||||||
|
s.step_vals.clear();
|
||||||
|
}
|
||||||
|
auto events = msg_new_events ? msg_new_events : &can->eventsMap();
|
||||||
|
auto it = events->find(s.msg_id);
|
||||||
|
if (it == events->end() || it->second.empty()) continue;
|
||||||
|
|
||||||
|
if (s.vals.empty() || (it->second.back()->mono_time / 1e9 - can->routeStartTime()) > s.vals.back().x()) {
|
||||||
|
appendCanEvents(s.sig, it->second, s.vals, s.step_vals);
|
||||||
|
} else {
|
||||||
|
std::vector<QPointF> vals, step_vals;
|
||||||
|
appendCanEvents(s.sig, it->second, vals, step_vals);
|
||||||
|
s.vals.insert(std::lower_bound(s.vals.begin(), s.vals.end(), vals.front().x(), xLessThan),
|
||||||
|
vals.begin(), vals.end());
|
||||||
|
s.step_vals.insert(std::lower_bound(s.step_vals.begin(), s.step_vals.end(), step_vals.front().x(), xLessThan),
|
||||||
|
step_vals.begin(), step_vals.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!can->liveStreaming()) {
|
||||||
|
s.segment_tree.build(s.vals);
|
||||||
|
}
|
||||||
|
s.series->replace(QVector<QPointF>::fromStdVector(series_type == SeriesType::StepLine ? s.step_vals : s.vals));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateAxisY();
|
||||||
|
// invoke resetChartCache in ui thread
|
||||||
|
QMetaObject::invokeMethod(this, &ChartView::resetChartCache, Qt::QueuedConnection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// auto zoom on yaxis
|
||||||
|
void ChartView::updateAxisY() {
|
||||||
|
if (sigs.empty()) return;
|
||||||
|
|
||||||
|
double min = std::numeric_limits<double>::max();
|
||||||
|
double max = std::numeric_limits<double>::lowest();
|
||||||
|
QString unit = sigs[0].sig->unit;
|
||||||
|
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
if (!s.series->isVisible()) continue;
|
||||||
|
|
||||||
|
// Only show unit when all signals have the same unit
|
||||||
|
if (unit != s.sig->unit) {
|
||||||
|
unit.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan);
|
||||||
|
auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan);
|
||||||
|
s.min = std::numeric_limits<double>::max();
|
||||||
|
s.max = std::numeric_limits<double>::lowest();
|
||||||
|
if (can->liveStreaming()) {
|
||||||
|
for (auto it = first; it != last; ++it) {
|
||||||
|
if (it->y() < s.min) s.min = it->y();
|
||||||
|
if (it->y() > s.max) s.max = it->y();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
std::tie(s.min, s.max) = s.segment_tree.minmax(std::distance(s.vals.cbegin(), first), std::distance(s.vals.cbegin(), last));
|
||||||
|
}
|
||||||
|
min = std::min(min, s.min);
|
||||||
|
max = std::max(max, s.max);
|
||||||
|
}
|
||||||
|
if (min == std::numeric_limits<double>::max()) min = 0;
|
||||||
|
if (max == std::numeric_limits<double>::lowest()) max = 0;
|
||||||
|
|
||||||
|
if (axis_y->titleText() != unit) {
|
||||||
|
axis_y->setTitleText(unit);
|
||||||
|
y_label_width = 0; // recalc width
|
||||||
|
}
|
||||||
|
|
||||||
|
double delta = std::abs(max - min) < 1e-3 ? 1 : (max - min) * 0.05;
|
||||||
|
auto [min_y, max_y, tick_count] = getNiceAxisNumbers(min - delta, max + delta, 3);
|
||||||
|
if (min_y != axis_y->min() || max_y != axis_y->max() || y_label_width == 0) {
|
||||||
|
axis_y->setRange(min_y, max_y);
|
||||||
|
axis_y->setTickCount(tick_count);
|
||||||
|
|
||||||
|
int n = std::max(int(-std::floor(std::log10((max_y - min_y) / (tick_count - 1)))), 0);
|
||||||
|
int max_label_width = 0;
|
||||||
|
QFontMetrics fm(axis_y->labelsFont());
|
||||||
|
for (int i = 0; i < tick_count; i++) {
|
||||||
|
qreal value = min_y + (i * (max_y - min_y) / (tick_count - 1));
|
||||||
|
max_label_width = std::max(max_label_width, fm.width(QString::number(value, 'f', n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
int title_spacing = unit.isEmpty() ? 0 : QFontMetrics(axis_y->titleFont()).size(Qt::TextSingleLine, unit).height();
|
||||||
|
y_label_width = title_spacing + max_label_width + 15;
|
||||||
|
axis_y->setLabelFormat(QString("%.%1f").arg(n));
|
||||||
|
emit axisYLabelWidthChanged(y_label_width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::tuple<double, double, int> ChartView::getNiceAxisNumbers(qreal min, qreal max, int tick_count) {
|
||||||
|
qreal range = niceNumber((max - min), true); // range with ceiling
|
||||||
|
qreal step = niceNumber(range / (tick_count - 1), false);
|
||||||
|
min = std::floor(min / step);
|
||||||
|
max = std::ceil(max / step);
|
||||||
|
tick_count = int(max - min) + 1;
|
||||||
|
return {min * step, max * step, tick_count};
|
||||||
|
}
|
||||||
|
|
||||||
|
// nice numbers can be expressed as form of 1*10^n, 2* 10^n or 5*10^n
|
||||||
|
qreal ChartView::niceNumber(qreal x, bool ceiling) {
|
||||||
|
qreal z = std::pow(10, std::floor(std::log10(x))); //find corresponding number of the form of 10^n than is smaller than x
|
||||||
|
qreal q = x / z; //q<10 && q>=1;
|
||||||
|
if (ceiling) {
|
||||||
|
if (q <= 1.0) q = 1;
|
||||||
|
else if (q <= 2.0) q = 2;
|
||||||
|
else if (q <= 5.0) q = 5;
|
||||||
|
else q = 10;
|
||||||
|
} else {
|
||||||
|
if (q < 1.5) q = 1;
|
||||||
|
else if (q < 3.0) q = 2;
|
||||||
|
else if (q < 7.0) q = 5;
|
||||||
|
else q = 10;
|
||||||
|
}
|
||||||
|
return q * z;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::leaveEvent(QEvent *event) {
|
||||||
|
if (tip_label->isVisible()) {
|
||||||
|
charts_widget->showValueTip(-1);
|
||||||
|
}
|
||||||
|
QChartView::leaveEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap getBlankShadowPixmap(const QPixmap &px, int radius) {
|
||||||
|
QGraphicsDropShadowEffect *e = new QGraphicsDropShadowEffect;
|
||||||
|
e->setColor(QColor(40, 40, 40, 245));
|
||||||
|
e->setOffset(0, 0);
|
||||||
|
e->setBlurRadius(radius);
|
||||||
|
|
||||||
|
qreal dpr = px.devicePixelRatio();
|
||||||
|
QPixmap blank(px.size());
|
||||||
|
blank.setDevicePixelRatio(dpr);
|
||||||
|
blank.fill(Qt::white);
|
||||||
|
|
||||||
|
QGraphicsScene scene;
|
||||||
|
QGraphicsPixmapItem item(blank);
|
||||||
|
item.setGraphicsEffect(e);
|
||||||
|
scene.addItem(&item);
|
||||||
|
|
||||||
|
QPixmap shadow(px.size() + QSize(radius * dpr * 2, radius * dpr * 2));
|
||||||
|
shadow.setDevicePixelRatio(dpr);
|
||||||
|
shadow.fill(Qt::transparent);
|
||||||
|
QPainter p(&shadow);
|
||||||
|
scene.render(&p, {QPoint(), shadow.size() / dpr}, item.boundingRect().adjusted(-radius, -radius, radius, radius));
|
||||||
|
return shadow;
|
||||||
|
}
|
||||||
|
|
||||||
|
static QPixmap getDropPixmap(const QPixmap &src) {
|
||||||
|
static QPixmap shadow_px;
|
||||||
|
const int radius = 10;
|
||||||
|
if (shadow_px.size() != src.size() + QSize(radius * 2, radius * 2)) {
|
||||||
|
shadow_px = getBlankShadowPixmap(src, radius);
|
||||||
|
}
|
||||||
|
QPixmap px = shadow_px;
|
||||||
|
QPainter p(&px);
|
||||||
|
QRectF target_rect(QPointF(radius, radius), src.size() / src.devicePixelRatio());
|
||||||
|
p.drawPixmap(target_rect.topLeft(), src);
|
||||||
|
p.setCompositionMode(QPainter::CompositionMode_DestinationIn);
|
||||||
|
p.fillRect(target_rect, QColor(0, 0, 0, 200));
|
||||||
|
return px;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::contextMenuEvent(QContextMenuEvent *event) {
|
||||||
|
QMenu context_menu(this);
|
||||||
|
context_menu.addActions(menu->actions());
|
||||||
|
context_menu.addSeparator();
|
||||||
|
context_menu.addAction(charts_widget->undo_zoom_action);
|
||||||
|
context_menu.addAction(charts_widget->redo_zoom_action);
|
||||||
|
context_menu.addSeparator();
|
||||||
|
context_menu.addAction(close_act);
|
||||||
|
context_menu.exec(event->globalPos());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::mousePressEvent(QMouseEvent *event) {
|
||||||
|
if (event->button() == Qt::LeftButton && move_icon->sceneBoundingRect().contains(event->pos())) {
|
||||||
|
QMimeData *mimeData = new QMimeData;
|
||||||
|
mimeData->setData(CHART_MIME_TYPE, QByteArray::number((qulonglong)this));
|
||||||
|
QPixmap px = grab().scaledToWidth(CHART_MIN_WIDTH * viewport()->devicePixelRatio(), Qt::SmoothTransformation);
|
||||||
|
charts_widget->stopAutoScroll();
|
||||||
|
QDrag *drag = new QDrag(this);
|
||||||
|
drag->setMimeData(mimeData);
|
||||||
|
drag->setPixmap(getDropPixmap(px));
|
||||||
|
drag->setHotSpot(-QPoint(5, 5));
|
||||||
|
drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::MoveAction);
|
||||||
|
} else if (event->button() == Qt::LeftButton && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) {
|
||||||
|
// Save current playback state when scrubbing
|
||||||
|
resume_after_scrub = !can->isPaused();
|
||||||
|
if (resume_after_scrub) {
|
||||||
|
can->pause(true);
|
||||||
|
}
|
||||||
|
is_scrubbing = true;
|
||||||
|
} else {
|
||||||
|
QChartView::mousePressEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::mouseReleaseEvent(QMouseEvent *event) {
|
||||||
|
auto rubber = findChild<QRubberBand *>();
|
||||||
|
if (event->button() == Qt::LeftButton && rubber && rubber->isVisible()) {
|
||||||
|
rubber->hide();
|
||||||
|
auto rect = rubber->geometry().normalized();
|
||||||
|
// Prevent zooming/seeking past the end of the route
|
||||||
|
double min = std::clamp(chart()->mapToValue(rect.topLeft()).x(), 0., can->totalSeconds());
|
||||||
|
double max = std::clamp(chart()->mapToValue(rect.bottomRight()).x(), 0., can->totalSeconds());
|
||||||
|
if (rubber->width() <= 0) {
|
||||||
|
// no rubber dragged, seek to mouse position
|
||||||
|
can->seekTo(min);
|
||||||
|
} else if (rubber->width() > 10 && (max - min) > 0.01) { // Minimum range is 10 milliseconds.
|
||||||
|
charts_widget->zoom_undo_stack->push(new ZoomCommand(charts_widget, {min, max}));
|
||||||
|
} else {
|
||||||
|
viewport()->update();
|
||||||
|
}
|
||||||
|
event->accept();
|
||||||
|
} else if (event->button() == Qt::RightButton) {
|
||||||
|
charts_widget->zoom_undo_stack->undo();
|
||||||
|
event->accept();
|
||||||
|
} else {
|
||||||
|
QGraphicsView::mouseReleaseEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume playback if we were scrubbing
|
||||||
|
is_scrubbing = false;
|
||||||
|
if (resume_after_scrub) {
|
||||||
|
can->pause(false);
|
||||||
|
resume_after_scrub = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::mouseMoveEvent(QMouseEvent *ev) {
|
||||||
|
const auto plot_area = chart()->plotArea();
|
||||||
|
// Scrubbing
|
||||||
|
if (is_scrubbing && QApplication::keyboardModifiers().testFlag(Qt::ShiftModifier)) {
|
||||||
|
if (plot_area.contains(ev->pos())) {
|
||||||
|
can->seekTo(std::clamp(chart()->mapToValue(ev->pos()).x(), 0., can->totalSeconds()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto rubber = findChild<QRubberBand *>();
|
||||||
|
bool is_zooming = rubber && rubber->isVisible();
|
||||||
|
clearTrackPoints();
|
||||||
|
|
||||||
|
if (!is_zooming && plot_area.contains(ev->pos())) {
|
||||||
|
const double sec = chart()->mapToValue(ev->pos()).x();
|
||||||
|
charts_widget->showValueTip(sec);
|
||||||
|
} else if (tip_label->isVisible()) {
|
||||||
|
charts_widget->showValueTip(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
QChartView::mouseMoveEvent(ev);
|
||||||
|
if (is_zooming) {
|
||||||
|
QRect rubber_rect = rubber->geometry();
|
||||||
|
rubber_rect.setLeft(std::max(rubber_rect.left(), (int)plot_area.left()));
|
||||||
|
rubber_rect.setRight(std::min(rubber_rect.right(), (int)plot_area.right()));
|
||||||
|
if (rubber_rect != rubber->geometry()) {
|
||||||
|
rubber->setGeometry(rubber_rect);
|
||||||
|
}
|
||||||
|
viewport()->update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::showTip(double sec) {
|
||||||
|
QRect tip_area(0, chart()->plotArea().top(), rect().width(), chart()->plotArea().height());
|
||||||
|
QRect visible_rect = charts_widget->chartVisibleRect(this).intersected(tip_area);
|
||||||
|
if (visible_rect.isEmpty()) {
|
||||||
|
tip_label->hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip_x = chart()->mapToPosition({sec, 0}).x();
|
||||||
|
qreal x = -1;
|
||||||
|
QStringList text_list;
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
if (s.series->isVisible()) {
|
||||||
|
QString value = "--";
|
||||||
|
// use reverse iterator to find last item <= sec.
|
||||||
|
auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), sec, [](auto &p, double x) { return p.x() > x; });
|
||||||
|
if (it != s.vals.crend() && it->x() >= axis_x->min()) {
|
||||||
|
value = QString::number(it->y());
|
||||||
|
s.track_pt = *it;
|
||||||
|
x = std::max(x, chart()->mapToPosition(*it).x());
|
||||||
|
}
|
||||||
|
QString name = sigs.size() > 1 ? s.sig->name + ": " : "";
|
||||||
|
QString min = s.min == std::numeric_limits<double>::max() ? "--" : QString::number(s.min);
|
||||||
|
QString max = s.max == std::numeric_limits<double>::lowest() ? "--" : QString::number(s.max);
|
||||||
|
text_list << QString("<span style=\"color:%1;\">■ </span>%2<b>%3</b> (%4, %5)")
|
||||||
|
.arg(s.series->color().name(), name, value, min, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (x < 0) {
|
||||||
|
x = tooltip_x;
|
||||||
|
}
|
||||||
|
QPoint pt(x, chart()->plotArea().top());
|
||||||
|
text_list.push_front(QString::number(chart()->mapToValue({x, 0}).x(), 'f', 3));
|
||||||
|
QString text = "<p style='white-space:pre'>" % text_list.join("<br />") % "</p>";
|
||||||
|
tip_label->showText(pt, text, this, visible_rect);
|
||||||
|
viewport()->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::hideTip() {
|
||||||
|
clearTrackPoints();
|
||||||
|
tooltip_x = -1;
|
||||||
|
tip_label->hide();
|
||||||
|
viewport()->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::dragEnterEvent(QDragEnterEvent *event) {
|
||||||
|
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||||
|
drawDropIndicator(event->source() != this);
|
||||||
|
event->acceptProposedAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::dragMoveEvent(QDragMoveEvent *event) {
|
||||||
|
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||||
|
event->setDropAction(event->source() == this ? Qt::MoveAction : Qt::CopyAction);
|
||||||
|
event->accept();
|
||||||
|
}
|
||||||
|
charts_widget->startAutoScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::dropEvent(QDropEvent *event) {
|
||||||
|
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||||
|
if (event->source() != this) {
|
||||||
|
ChartView *source_chart = (ChartView *)event->source();
|
||||||
|
for (auto &s : source_chart->sigs) {
|
||||||
|
source_chart->chart()->removeSeries(s.series);
|
||||||
|
addSeries(s.series);
|
||||||
|
}
|
||||||
|
sigs.insert(sigs.end(), std::move_iterator(source_chart->sigs.begin()), std::move_iterator(source_chart->sigs.end()));
|
||||||
|
updateAxisY();
|
||||||
|
updateTitle();
|
||||||
|
startAnimation();
|
||||||
|
|
||||||
|
source_chart->sigs.clear();
|
||||||
|
charts_widget->removeChart(source_chart);
|
||||||
|
event->acceptProposedAction();
|
||||||
|
}
|
||||||
|
can_drop = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::resetChartCache() {
|
||||||
|
chart_pixmap = QPixmap();
|
||||||
|
viewport()->update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::startAnimation() {
|
||||||
|
QGraphicsOpacityEffect *eff = new QGraphicsOpacityEffect(this);
|
||||||
|
viewport()->setGraphicsEffect(eff);
|
||||||
|
QPropertyAnimation *a = new QPropertyAnimation(eff, "opacity");
|
||||||
|
a->setDuration(250);
|
||||||
|
a->setStartValue(0.3);
|
||||||
|
a->setEndValue(1);
|
||||||
|
a->setEasingCurve(QEasingCurve::InBack);
|
||||||
|
a->start(QPropertyAnimation::DeleteWhenStopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::paintEvent(QPaintEvent *event) {
|
||||||
|
if (!can->liveStreaming()) {
|
||||||
|
if (chart_pixmap.isNull()) {
|
||||||
|
const qreal dpr = viewport()->devicePixelRatioF();
|
||||||
|
chart_pixmap = QPixmap(viewport()->size() * dpr);
|
||||||
|
chart_pixmap.setDevicePixelRatio(dpr);
|
||||||
|
QPainter p(&chart_pixmap);
|
||||||
|
p.setRenderHints(QPainter::Antialiasing);
|
||||||
|
drawBackground(&p, viewport()->rect());
|
||||||
|
scene()->setSceneRect(viewport()->rect());
|
||||||
|
scene()->render(&p, viewport()->rect());
|
||||||
|
}
|
||||||
|
|
||||||
|
QPainter painter(viewport());
|
||||||
|
painter.setRenderHints(QPainter::Antialiasing);
|
||||||
|
painter.drawPixmap(QPoint(), chart_pixmap);
|
||||||
|
if (can_drop) {
|
||||||
|
painter.setPen(QPen(palette().color(QPalette::Highlight), 4));
|
||||||
|
painter.drawRect(viewport()->rect());
|
||||||
|
}
|
||||||
|
QRectF exposed_rect = mapToScene(event->region().boundingRect()).boundingRect();
|
||||||
|
drawForeground(&painter, exposed_rect);
|
||||||
|
} else {
|
||||||
|
QChartView::paintEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::drawBackground(QPainter *painter, const QRectF &rect) {
|
||||||
|
painter->fillRect(rect, palette().color(QPalette::Base));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::drawForeground(QPainter *painter, const QRectF &rect) {
|
||||||
|
drawTimeline(painter);
|
||||||
|
drawSignalValue(painter);
|
||||||
|
// draw track points
|
||||||
|
painter->setPen(Qt::NoPen);
|
||||||
|
qreal track_line_x = -1;
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
if (!s.track_pt.isNull() && s.series->isVisible()) {
|
||||||
|
painter->setBrush(s.series->color().darker(125));
|
||||||
|
QPointF pos = chart()->mapToPosition(s.track_pt);
|
||||||
|
painter->drawEllipse(pos, 5.5, 5.5);
|
||||||
|
track_line_x = std::max(track_line_x, pos.x());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (track_line_x > 0) {
|
||||||
|
auto plot_area = chart()->plotArea();
|
||||||
|
painter->setPen(QPen(Qt::darkGray, 1, Qt::DashLine));
|
||||||
|
painter->drawLine(QPointF{track_line_x, plot_area.top()}, QPointF{track_line_x, plot_area.bottom()});
|
||||||
|
}
|
||||||
|
|
||||||
|
// paint points. OpenGL mode lacks certain features (such as showing points)
|
||||||
|
painter->setPen(Qt::NoPen);
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
if (s.series->useOpenGL() && s.series->isVisible() && s.series->pointsVisible()) {
|
||||||
|
auto first = std::lower_bound(s.vals.cbegin(), s.vals.cend(), axis_x->min(), xLessThan);
|
||||||
|
auto last = std::lower_bound(first, s.vals.cend(), axis_x->max(), xLessThan);
|
||||||
|
painter->setBrush(s.series->color());
|
||||||
|
for (auto it = first; it != last; ++it) {
|
||||||
|
painter->drawEllipse(chart()->mapToPosition(*it), 4, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRubberBandTimeRange(painter);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::drawRubberBandTimeRange(QPainter *painter) {
|
||||||
|
auto rubber = findChild<QRubberBand *>();
|
||||||
|
if (rubber && rubber->isVisible() && rubber->width() > 1) {
|
||||||
|
painter->setPen(Qt::white);
|
||||||
|
auto rubber_rect = rubber->geometry().normalized();
|
||||||
|
for (const auto &pt : {rubber_rect.bottomLeft(), rubber_rect.bottomRight()}) {
|
||||||
|
QString sec = QString::number(chart()->mapToValue(pt).x(), 'f', 2);
|
||||||
|
auto r = painter->fontMetrics().boundingRect(sec).adjusted(-6, -AXIS_X_TOP_MARGIN, 6, AXIS_X_TOP_MARGIN);
|
||||||
|
pt == rubber_rect.bottomLeft() ? r.moveTopRight(pt + QPoint{0, 2}) : r.moveTopLeft(pt + QPoint{0, 2});
|
||||||
|
painter->fillRect(r, Qt::gray);
|
||||||
|
painter->drawText(r, Qt::AlignCenter, sec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::drawTimeline(QPainter *painter) {
|
||||||
|
const auto plot_area = chart()->plotArea();
|
||||||
|
// draw vertical time line
|
||||||
|
qreal x = std::clamp(chart()->mapToPosition(QPointF{cur_sec, 0}).x(), plot_area.left(), plot_area.right());
|
||||||
|
painter->setPen(QPen(chart()->titleBrush().color(), 2));
|
||||||
|
painter->drawLine(QPointF{x, plot_area.top()}, QPointF{x, plot_area.bottom() + 1});
|
||||||
|
|
||||||
|
// draw current time under the axis-x
|
||||||
|
QString time_str = QString::number(cur_sec, 'f', 2);
|
||||||
|
QSize time_str_size = QFontMetrics(axis_x->labelsFont()).size(Qt::TextSingleLine, time_str) + QSize(8, 2);
|
||||||
|
QRectF time_str_rect(QPointF(x - time_str_size.width() / 2.0, plot_area.bottom() + AXIS_X_TOP_MARGIN), time_str_size);
|
||||||
|
QPainterPath path;
|
||||||
|
path.addRoundedRect(time_str_rect, 3, 3);
|
||||||
|
painter->fillPath(path, settings.theme == DARK_THEME ? Qt::darkGray : Qt::gray);
|
||||||
|
painter->setPen(palette().color(QPalette::BrightText));
|
||||||
|
painter->setFont(axis_x->labelsFont());
|
||||||
|
painter->drawText(time_str_rect, Qt::AlignCenter, time_str);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::drawSignalValue(QPainter *painter) {
|
||||||
|
auto item_group = qgraphicsitem_cast<QGraphicsItemGroup *>(chart()->legend()->childItems()[0]);
|
||||||
|
assert(item_group != nullptr);
|
||||||
|
auto legend_markers = item_group->childItems();
|
||||||
|
assert(legend_markers.size() == sigs.size());
|
||||||
|
|
||||||
|
painter->setFont(signal_value_font);
|
||||||
|
painter->setPen(chart()->legend()->labelColor());
|
||||||
|
int i = 0;
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
auto it = std::lower_bound(s.vals.crbegin(), s.vals.crend(), cur_sec,
|
||||||
|
[](auto &p, double x) { return p.x() > x + EPSILON; });
|
||||||
|
QString value = (it != s.vals.crend() && it->x() >= axis_x->min()) ? s.sig->formatValue(it->y()) : "--";
|
||||||
|
QRectF marker_rect = legend_markers[i++]->sceneBoundingRect();
|
||||||
|
QRectF value_rect(marker_rect.bottomLeft() - QPoint(0, 1), marker_rect.size());
|
||||||
|
QString elided_val = painter->fontMetrics().elidedText(value, Qt::ElideRight, value_rect.width());
|
||||||
|
painter->drawText(value_rect, Qt::AlignHCenter | Qt::AlignTop, elided_val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QXYSeries *ChartView::createSeries(SeriesType type, QColor color) {
|
||||||
|
QXYSeries *series = nullptr;
|
||||||
|
if (type == SeriesType::Line) {
|
||||||
|
series = new QLineSeries(this);
|
||||||
|
chart()->legend()->setMarkerShape(QLegend::MarkerShapeRectangle);
|
||||||
|
} else if (type == SeriesType::StepLine) {
|
||||||
|
series = new QLineSeries(this);
|
||||||
|
chart()->legend()->setMarkerShape(QLegend::MarkerShapeFromSeries);
|
||||||
|
} else {
|
||||||
|
series = new QScatterSeries(this);
|
||||||
|
static_cast<QScatterSeries*>(series)->setBorderColor(color);
|
||||||
|
chart()->legend()->setMarkerShape(QLegend::MarkerShapeCircle);
|
||||||
|
}
|
||||||
|
series->setColor(color);
|
||||||
|
// TODO: Due to a bug in CameraWidget the camera frames
|
||||||
|
// are drawn instead of the graphs on MacOS. Re-enable OpenGL when fixed
|
||||||
|
#ifndef __APPLE__
|
||||||
|
series->setUseOpenGL(true);
|
||||||
|
// Qt doesn't properly apply device pixel ratio in OpenGL mode
|
||||||
|
QPen pen = series->pen();
|
||||||
|
pen.setWidthF(2.0 * devicePixelRatioF());
|
||||||
|
series->setPen(pen);
|
||||||
|
#endif
|
||||||
|
addSeries(series);
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::addSeries(QXYSeries *series) {
|
||||||
|
setSeriesColor(series, series->color());
|
||||||
|
chart()->addSeries(series);
|
||||||
|
series->attachAxis(axis_x);
|
||||||
|
series->attachAxis(axis_y);
|
||||||
|
|
||||||
|
// disables the delivery of mouse events to the opengl widget.
|
||||||
|
// this enables the user to select the zoom area when the mouse press on the data point.
|
||||||
|
auto glwidget = findChild<QOpenGLWidget *>();
|
||||||
|
if (glwidget && !glwidget->testAttribute(Qt::WA_TransparentForMouseEvents)) {
|
||||||
|
glwidget->setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::setSeriesColor(QXYSeries *series, QColor color) {
|
||||||
|
auto existing_series = chart()->series();
|
||||||
|
for (auto s : existing_series) {
|
||||||
|
if (s != series && std::abs(color.hueF() - qobject_cast<QXYSeries *>(s)->color().hueF()) < 0.1) {
|
||||||
|
// use different color to distinguish it from others.
|
||||||
|
auto last_color = qobject_cast<QXYSeries *>(existing_series.back())->color();
|
||||||
|
color.setHsvF(std::fmod(last_color.hueF() + 60 / 360.0, 1.0),
|
||||||
|
QRandomGenerator::global()->bounded(35, 100) / 100.0,
|
||||||
|
QRandomGenerator::global()->bounded(85, 100) / 100.0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
series->setColor(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::setSeriesType(SeriesType type) {
|
||||||
|
if (type != series_type) {
|
||||||
|
series_type = type;
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
chart()->removeSeries(s.series);
|
||||||
|
s.series->deleteLater();
|
||||||
|
}
|
||||||
|
for (auto &s : sigs) {
|
||||||
|
s.series = createSeries(series_type, s.sig->color);
|
||||||
|
s.series->replace(QVector<QPointF>::fromStdVector(series_type == SeriesType::StepLine ? s.step_vals : s.vals));
|
||||||
|
}
|
||||||
|
updateSeriesPoints();
|
||||||
|
updateTitle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartView::handleMarkerClicked() {
|
||||||
|
auto marker = qobject_cast<QLegendMarker *>(sender());
|
||||||
|
Q_ASSERT(marker);
|
||||||
|
if (sigs.size() > 1) {
|
||||||
|
auto series = marker->series();
|
||||||
|
series->setVisible(!series->isVisible());
|
||||||
|
marker->setVisible(true);
|
||||||
|
updateAxisY();
|
||||||
|
updateTitle();
|
||||||
|
}
|
||||||
|
}
|
||||||
123
tools/cabana/chart/chart.h
Executable file
123
tools/cabana/chart/chart.h
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <tuple>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QGraphicsPixmapItem>
|
||||||
|
#include <QGraphicsProxyWidget>
|
||||||
|
#include <QtCharts/QChartView>
|
||||||
|
#include <QtCharts/QLegendMarker>
|
||||||
|
#include <QtCharts/QLineSeries>
|
||||||
|
#include <QtCharts/QScatterSeries>
|
||||||
|
#include <QtCharts/QValueAxis>
|
||||||
|
using namespace QtCharts;
|
||||||
|
|
||||||
|
#include "tools/cabana/chart/tiplabel.h"
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
enum class SeriesType {
|
||||||
|
Line = 0,
|
||||||
|
StepLine,
|
||||||
|
Scatter
|
||||||
|
};
|
||||||
|
|
||||||
|
class ChartsWidget;
|
||||||
|
class ChartView : public QChartView {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
ChartView(const std::pair<double, double> &x_range, ChartsWidget *parent = nullptr);
|
||||||
|
void addSignal(const MessageId &msg_id, const cabana::Signal *sig);
|
||||||
|
bool hasSignal(const MessageId &msg_id, const cabana::Signal *sig) const;
|
||||||
|
void updateSeries(const cabana::Signal *sig = nullptr, const MessageEventsMap *msg_new_events = nullptr);
|
||||||
|
void updatePlot(double cur, double min, double max);
|
||||||
|
void setSeriesType(SeriesType type);
|
||||||
|
void updatePlotArea(int left, bool force = false);
|
||||||
|
void showTip(double sec);
|
||||||
|
void hideTip();
|
||||||
|
void startAnimation();
|
||||||
|
|
||||||
|
struct SigItem {
|
||||||
|
MessageId msg_id;
|
||||||
|
const cabana::Signal *sig = nullptr;
|
||||||
|
QXYSeries *series = nullptr;
|
||||||
|
std::vector<QPointF> vals;
|
||||||
|
std::vector<QPointF> step_vals;
|
||||||
|
QPointF track_pt{};
|
||||||
|
SegmentTree segment_tree;
|
||||||
|
double min = 0;
|
||||||
|
double max = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void axisYLabelWidthChanged(int w);
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void signalUpdated(const cabana::Signal *sig);
|
||||||
|
void manageSignals();
|
||||||
|
void handleMarkerClicked();
|
||||||
|
void msgUpdated(MessageId id);
|
||||||
|
void msgRemoved(MessageId id) { removeIf([=](auto &s) { return s.msg_id.address == id.address && !dbc()->msg(id); }); }
|
||||||
|
void signalRemoved(const cabana::Signal *sig) { removeIf([=](auto &s) { return s.sig == sig; }); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void appendCanEvents(const cabana::Signal *sig, const std::vector<const CanEvent *> &events,
|
||||||
|
std::vector<QPointF> &vals, std::vector<QPointF> &step_vals);
|
||||||
|
void createToolButtons();
|
||||||
|
void addSeries(QXYSeries *series);
|
||||||
|
void contextMenuEvent(QContextMenuEvent *event) override;
|
||||||
|
void mousePressEvent(QMouseEvent *event) override;
|
||||||
|
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||||
|
void mouseMoveEvent(QMouseEvent *ev) override;
|
||||||
|
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||||
|
void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator(false); }
|
||||||
|
void dragMoveEvent(QDragMoveEvent *event) override;
|
||||||
|
void dropEvent(QDropEvent *event) override;
|
||||||
|
void leaveEvent(QEvent *event) override;
|
||||||
|
void resizeEvent(QResizeEvent *event) override;
|
||||||
|
QSize sizeHint() const override;
|
||||||
|
void updateAxisY();
|
||||||
|
void updateTitle();
|
||||||
|
void resetChartCache();
|
||||||
|
void setTheme(QChart::ChartTheme theme);
|
||||||
|
void paintEvent(QPaintEvent *event) override;
|
||||||
|
void drawForeground(QPainter *painter, const QRectF &rect) override;
|
||||||
|
void drawBackground(QPainter *painter, const QRectF &rect) override;
|
||||||
|
void drawDropIndicator(bool draw) { if (std::exchange(can_drop, draw) != can_drop) viewport()->update(); }
|
||||||
|
void drawSignalValue(QPainter *painter);
|
||||||
|
void drawTimeline(QPainter *painter);
|
||||||
|
void drawRubberBandTimeRange(QPainter *painter);
|
||||||
|
std::tuple<double, double, int> getNiceAxisNumbers(qreal min, qreal max, int tick_count);
|
||||||
|
qreal niceNumber(qreal x, bool ceiling);
|
||||||
|
QXYSeries *createSeries(SeriesType type, QColor color);
|
||||||
|
void setSeriesColor(QXYSeries *, QColor color);
|
||||||
|
void updateSeriesPoints();
|
||||||
|
void removeIf(std::function<bool(const SigItem &)> predicate);
|
||||||
|
inline void clearTrackPoints() { for (auto &s : sigs) s.track_pt = {}; }
|
||||||
|
|
||||||
|
int y_label_width = 0;
|
||||||
|
int align_to = 0;
|
||||||
|
QValueAxis *axis_x;
|
||||||
|
QValueAxis *axis_y;
|
||||||
|
QMenu *menu;
|
||||||
|
QAction *split_chart_act;
|
||||||
|
QAction *close_act;
|
||||||
|
QGraphicsPixmapItem *move_icon;
|
||||||
|
QGraphicsProxyWidget *close_btn_proxy;
|
||||||
|
QGraphicsProxyWidget *manage_btn_proxy;
|
||||||
|
TipLabel *tip_label;
|
||||||
|
std::vector<SigItem> sigs;
|
||||||
|
double cur_sec = 0;
|
||||||
|
SeriesType series_type = SeriesType::Line;
|
||||||
|
bool is_scrubbing = false;
|
||||||
|
bool resume_after_scrub = false;
|
||||||
|
QPixmap chart_pixmap;
|
||||||
|
bool can_drop = false;
|
||||||
|
double tooltip_x = -1;
|
||||||
|
QFont signal_value_font;
|
||||||
|
ChartsWidget *charts_widget;
|
||||||
|
friend class ChartsWidget;
|
||||||
|
};
|
||||||
539
tools/cabana/chart/chartswidget.cc
Executable file
539
tools/cabana/chart/chartswidget.cc
Executable file
@@ -0,0 +1,539 @@
|
|||||||
|
#include "tools/cabana/chart/chartswidget.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QFutureSynchronizer>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QScrollBar>
|
||||||
|
#include <QToolBar>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
|
||||||
|
#include "tools/cabana/chart/chart.h"
|
||||||
|
|
||||||
|
const int MAX_COLUMN_COUNT = 4;
|
||||||
|
const int CHART_SPACING = 4;
|
||||||
|
|
||||||
|
ChartsWidget::ChartsWidget(QWidget *parent) : QFrame(parent) {
|
||||||
|
align_timer = new QTimer(this);
|
||||||
|
auto_scroll_timer = new QTimer(this);
|
||||||
|
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
main_layout->setSpacing(0);
|
||||||
|
|
||||||
|
// toolbar
|
||||||
|
QToolBar *toolbar = new QToolBar(tr("Charts"), this);
|
||||||
|
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
|
||||||
|
toolbar->setIconSize({icon_size, icon_size});
|
||||||
|
|
||||||
|
auto new_plot_btn = new ToolButton("file-plus", tr("New Chart"));
|
||||||
|
auto new_tab_btn = new ToolButton("window-stack", tr("New Tab"));
|
||||||
|
toolbar->addWidget(new_plot_btn);
|
||||||
|
toolbar->addWidget(new_tab_btn);
|
||||||
|
toolbar->addWidget(title_label = new QLabel());
|
||||||
|
title_label->setContentsMargins(0, 0, style()->pixelMetric(QStyle::PM_LayoutHorizontalSpacing), 0);
|
||||||
|
|
||||||
|
QMenu *menu = new QMenu(this);
|
||||||
|
for (int i = 0; i < MAX_COLUMN_COUNT; ++i) {
|
||||||
|
menu->addAction(tr("%1").arg(i + 1), [=]() { setColumnCount(i + 1); });
|
||||||
|
}
|
||||||
|
columns_action = toolbar->addAction("");
|
||||||
|
columns_action->setMenu(menu);
|
||||||
|
qobject_cast<QToolButton*>(toolbar->widgetForAction(columns_action))->setPopupMode(QToolButton::InstantPopup);
|
||||||
|
|
||||||
|
QLabel *stretch_label = new QLabel(this);
|
||||||
|
stretch_label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||||||
|
toolbar->addWidget(stretch_label);
|
||||||
|
|
||||||
|
range_lb_action = toolbar->addWidget(range_lb = new QLabel(this));
|
||||||
|
range_slider = new LogSlider(1000, Qt::Horizontal, this);
|
||||||
|
range_slider->setMaximumWidth(200);
|
||||||
|
range_slider->setToolTip(tr("Set the chart range"));
|
||||||
|
range_slider->setRange(1, settings.max_cached_minutes * 60);
|
||||||
|
range_slider->setSingleStep(1);
|
||||||
|
range_slider->setPageStep(60); // 1 min
|
||||||
|
range_slider_action = toolbar->addWidget(range_slider);
|
||||||
|
|
||||||
|
// zoom controls
|
||||||
|
zoom_undo_stack = new QUndoStack(this);
|
||||||
|
toolbar->addAction(undo_zoom_action = zoom_undo_stack->createUndoAction(this));
|
||||||
|
undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise"));
|
||||||
|
toolbar->addAction(redo_zoom_action = zoom_undo_stack->createRedoAction(this));
|
||||||
|
redo_zoom_action->setIcon(utils::icon("arrow-clockwise"));
|
||||||
|
reset_zoom_action = toolbar->addWidget(reset_zoom_btn = new ToolButton("zoom-out", tr("Reset Zoom")));
|
||||||
|
reset_zoom_btn->setToolButtonStyle(Qt::ToolButtonTextBesideIcon);
|
||||||
|
|
||||||
|
toolbar->addWidget(remove_all_btn = new ToolButton("x-square", tr("Remove all charts")));
|
||||||
|
toolbar->addWidget(dock_btn = new ToolButton(""));
|
||||||
|
main_layout->addWidget(toolbar);
|
||||||
|
|
||||||
|
// tabbar
|
||||||
|
tabbar = new TabBar(this);
|
||||||
|
tabbar->setAutoHide(true);
|
||||||
|
tabbar->setExpanding(false);
|
||||||
|
tabbar->setDrawBase(true);
|
||||||
|
tabbar->setAcceptDrops(true);
|
||||||
|
tabbar->setChangeCurrentOnDrag(true);
|
||||||
|
tabbar->setUsesScrollButtons(true);
|
||||||
|
main_layout->addWidget(tabbar);
|
||||||
|
|
||||||
|
// charts
|
||||||
|
charts_container = new ChartsContainer(this);
|
||||||
|
charts_container->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
|
||||||
|
charts_scroll = new QScrollArea(this);
|
||||||
|
charts_scroll->viewport()->setBackgroundRole(QPalette::Base);
|
||||||
|
charts_scroll->setFrameStyle(QFrame::NoFrame);
|
||||||
|
charts_scroll->setWidgetResizable(true);
|
||||||
|
charts_scroll->setWidget(charts_container);
|
||||||
|
charts_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
main_layout->addWidget(charts_scroll);
|
||||||
|
|
||||||
|
// init settings
|
||||||
|
current_theme = settings.theme;
|
||||||
|
column_count = std::clamp(settings.chart_column_count, 1, MAX_COLUMN_COUNT);
|
||||||
|
max_chart_range = std::clamp(settings.chart_range, 1, settings.max_cached_minutes * 60);
|
||||||
|
display_range = {0, max_chart_range};
|
||||||
|
range_slider->setValue(max_chart_range);
|
||||||
|
updateToolBar();
|
||||||
|
|
||||||
|
align_timer->setSingleShot(true);
|
||||||
|
QObject::connect(align_timer, &QTimer::timeout, this, &ChartsWidget::alignCharts);
|
||||||
|
QObject::connect(auto_scroll_timer, &QTimer::timeout, this, &ChartsWidget::doAutoScroll);
|
||||||
|
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &ChartsWidget::removeAll);
|
||||||
|
QObject::connect(can, &AbstractStream::eventsMerged, this, &ChartsWidget::eventsMerged);
|
||||||
|
QObject::connect(can, &AbstractStream::msgsReceived, this, &ChartsWidget::updateState);
|
||||||
|
QObject::connect(range_slider, &QSlider::valueChanged, this, &ChartsWidget::setMaxChartRange);
|
||||||
|
QObject::connect(new_plot_btn, &QToolButton::clicked, this, &ChartsWidget::newChart);
|
||||||
|
QObject::connect(remove_all_btn, &QToolButton::clicked, this, &ChartsWidget::removeAll);
|
||||||
|
QObject::connect(reset_zoom_btn, &QToolButton::clicked, this, &ChartsWidget::zoomReset);
|
||||||
|
QObject::connect(&settings, &Settings::changed, this, &ChartsWidget::settingChanged);
|
||||||
|
QObject::connect(new_tab_btn, &QToolButton::clicked, this, &ChartsWidget::newTab);
|
||||||
|
QObject::connect(this, &ChartsWidget::seriesChanged, this, &ChartsWidget::updateTabBar);
|
||||||
|
QObject::connect(tabbar, &QTabBar::tabCloseRequested, this, &ChartsWidget::removeTab);
|
||||||
|
QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) {
|
||||||
|
if (index != -1) updateLayout(true);
|
||||||
|
});
|
||||||
|
QObject::connect(dock_btn, &QToolButton::clicked, [this]() {
|
||||||
|
emit dock(!docking);
|
||||||
|
docking = !docking;
|
||||||
|
updateToolBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
newTab();
|
||||||
|
setWhatsThis(tr(R"(
|
||||||
|
<b>Chart view</b><br />
|
||||||
|
<!-- TODO: add descprition here -->
|
||||||
|
)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::newTab() {
|
||||||
|
static int tab_unique_id = 0;
|
||||||
|
int idx = tabbar->addTab("");
|
||||||
|
tabbar->setTabData(idx, tab_unique_id++);
|
||||||
|
tabbar->setCurrentIndex(idx);
|
||||||
|
updateTabBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::removeTab(int index) {
|
||||||
|
int id = tabbar->tabData(index).toInt();
|
||||||
|
for (auto &c : tab_charts[id]) {
|
||||||
|
removeChart(c);
|
||||||
|
}
|
||||||
|
tab_charts.erase(id);
|
||||||
|
tabbar->removeTab(index);
|
||||||
|
updateTabBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::updateTabBar() {
|
||||||
|
for (int i = 0; i < tabbar->count(); ++i) {
|
||||||
|
const auto &charts_in_tab = tab_charts[tabbar->tabData(i).toInt()];
|
||||||
|
tabbar->setTabText(i, QString("Tab %1 (%2)").arg(i + 1).arg(charts_in_tab.count()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::eventsMerged(const MessageEventsMap &new_events) {
|
||||||
|
QFutureSynchronizer<void> future_synchronizer;
|
||||||
|
for (auto c : charts) {
|
||||||
|
future_synchronizer.addFuture(QtConcurrent::run(c, &ChartView::updateSeries, nullptr, &new_events));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::setZoom(double min, double max) {
|
||||||
|
zoomed_range = {min, max};
|
||||||
|
is_zoomed = zoomed_range != display_range;
|
||||||
|
updateToolBar();
|
||||||
|
updateState();
|
||||||
|
emit rangeChanged(min, max, is_zoomed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::zoomReset() {
|
||||||
|
setZoom(display_range.first, display_range.second);
|
||||||
|
zoom_undo_stack->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
QRect ChartsWidget::chartVisibleRect(ChartView *chart) {
|
||||||
|
const QRect visible_rect(-charts_container->pos(), charts_scroll->viewport()->size());
|
||||||
|
return chart->rect().intersected(QRect(chart->mapFrom(charts_container, visible_rect.topLeft()), visible_rect.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::showValueTip(double sec) {
|
||||||
|
for (auto c : currentCharts()) {
|
||||||
|
sec >= 0 ? c->showTip(sec) : c->hideTip();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::updateState() {
|
||||||
|
if (charts.isEmpty()) return;
|
||||||
|
|
||||||
|
const double cur_sec = can->currentSec();
|
||||||
|
if (!is_zoomed) {
|
||||||
|
double pos = (cur_sec - display_range.first) / std::max<float>(1.0, max_chart_range);
|
||||||
|
if (pos < 0 || pos > 0.8) {
|
||||||
|
display_range.first = std::max(0.0, cur_sec - max_chart_range * 0.1);
|
||||||
|
}
|
||||||
|
double max_sec = std::min(display_range.first + max_chart_range, can->totalSeconds());
|
||||||
|
display_range.first = std::max(0.0, max_sec - max_chart_range);
|
||||||
|
display_range.second = display_range.first + max_chart_range;
|
||||||
|
} else if (cur_sec < (zoomed_range.first - 0.1) || cur_sec >= zoomed_range.second) {
|
||||||
|
// loop in zoomed range
|
||||||
|
can->seekTo(zoomed_range.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto &range = is_zoomed ? zoomed_range : display_range;
|
||||||
|
for (auto c : charts) {
|
||||||
|
c->updatePlot(cur_sec, range.first, range.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::setMaxChartRange(int value) {
|
||||||
|
max_chart_range = settings.chart_range = range_slider->value();
|
||||||
|
updateToolBar();
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::updateToolBar() {
|
||||||
|
title_label->setText(tr("Charts: %1").arg(charts.size()));
|
||||||
|
columns_action->setText(tr("Column: %1").arg(column_count));
|
||||||
|
range_lb->setText(utils::formatSeconds(max_chart_range));
|
||||||
|
range_lb_action->setVisible(!is_zoomed);
|
||||||
|
range_slider_action->setVisible(!is_zoomed);
|
||||||
|
undo_zoom_action->setVisible(is_zoomed);
|
||||||
|
redo_zoom_action->setVisible(is_zoomed);
|
||||||
|
reset_zoom_action->setVisible(is_zoomed);
|
||||||
|
reset_zoom_btn->setText(is_zoomed ? tr("%1-%2").arg(zoomed_range.first, 0, 'f', 2).arg(zoomed_range.second, 0, 'f', 2) : "");
|
||||||
|
remove_all_btn->setEnabled(!charts.isEmpty());
|
||||||
|
dock_btn->setIcon(docking ? "arrow-up-right-square" : "arrow-down-left-square");
|
||||||
|
dock_btn->setToolTip(docking ? tr("Undock charts") : tr("Dock charts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::settingChanged() {
|
||||||
|
if (std::exchange(current_theme, settings.theme) != current_theme) {
|
||||||
|
undo_zoom_action->setIcon(utils::icon("arrow-counterclockwise"));
|
||||||
|
redo_zoom_action->setIcon(utils::icon("arrow-clockwise"));
|
||||||
|
auto theme = settings.theme == DARK_THEME ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight;
|
||||||
|
for (auto c : charts) {
|
||||||
|
c->setTheme(theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
range_slider->setRange(1, settings.max_cached_minutes * 60);
|
||||||
|
for (auto c : charts) {
|
||||||
|
c->setFixedHeight(settings.chart_height);
|
||||||
|
c->setSeriesType((SeriesType)settings.chart_series_type);
|
||||||
|
c->resetChartCache();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChartView *ChartsWidget::findChart(const MessageId &id, const cabana::Signal *sig) {
|
||||||
|
for (auto c : charts)
|
||||||
|
if (c->hasSignal(id, sig)) return c;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChartView *ChartsWidget::createChart() {
|
||||||
|
auto chart = new ChartView(is_zoomed ? zoomed_range : display_range, this);
|
||||||
|
chart->setFixedHeight(settings.chart_height);
|
||||||
|
chart->setMinimumWidth(CHART_MIN_WIDTH);
|
||||||
|
chart->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Fixed);
|
||||||
|
QObject::connect(chart, &ChartView::axisYLabelWidthChanged, align_timer, qOverload<>(&QTimer::start));
|
||||||
|
charts.push_front(chart);
|
||||||
|
currentCharts().push_front(chart);
|
||||||
|
updateLayout(true);
|
||||||
|
updateToolBar();
|
||||||
|
return chart;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge) {
|
||||||
|
ChartView *chart = findChart(id, sig);
|
||||||
|
if (show && !chart) {
|
||||||
|
chart = merge && currentCharts().size() > 0 ? currentCharts().front() : createChart();
|
||||||
|
chart->addSignal(id, sig);
|
||||||
|
updateState();
|
||||||
|
} else if (!show && chart) {
|
||||||
|
chart->removeIf([&](auto &s) { return s.msg_id == id && s.sig == sig; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::splitChart(ChartView *src_chart) {
|
||||||
|
if (src_chart->sigs.size() > 1) {
|
||||||
|
for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) {
|
||||||
|
auto c = createChart();
|
||||||
|
src_chart->chart()->removeSeries(it->series);
|
||||||
|
|
||||||
|
// Restore to the original color
|
||||||
|
it->series->setColor(it->sig->color);
|
||||||
|
|
||||||
|
c->addSeries(it->series);
|
||||||
|
c->sigs.emplace_back(std::move(*it));
|
||||||
|
c->updateAxisY();
|
||||||
|
c->updateTitle();
|
||||||
|
it = src_chart->sigs.erase(it);
|
||||||
|
}
|
||||||
|
src_chart->updateAxisY();
|
||||||
|
src_chart->updateTitle();
|
||||||
|
QTimer::singleShot(0, src_chart, &ChartView::resetChartCache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::setColumnCount(int n) {
|
||||||
|
n = std::clamp(n, 1, MAX_COLUMN_COUNT);
|
||||||
|
if (column_count != n) {
|
||||||
|
column_count = settings.chart_column_count = n;
|
||||||
|
updateToolBar();
|
||||||
|
updateLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::updateLayout(bool force) {
|
||||||
|
auto charts_layout = charts_container->charts_layout;
|
||||||
|
int n = MAX_COLUMN_COUNT;
|
||||||
|
for (; n > 1; --n) {
|
||||||
|
if ((n * CHART_MIN_WIDTH + (n - 1) * charts_layout->horizontalSpacing()) < charts_layout->geometry().width()) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool show_column_cb = n > 1;
|
||||||
|
columns_action->setVisible(show_column_cb);
|
||||||
|
|
||||||
|
n = std::min(column_count, n);
|
||||||
|
auto ¤t_charts = currentCharts();
|
||||||
|
if ((current_charts.size() != charts_layout->count() || n != current_column_count) || force) {
|
||||||
|
current_column_count = n;
|
||||||
|
charts_container->setUpdatesEnabled(false);
|
||||||
|
for (auto c : charts) {
|
||||||
|
c->setVisible(false);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < current_charts.size(); ++i) {
|
||||||
|
charts_layout->addWidget(current_charts[i], i / n, i % n);
|
||||||
|
if (current_charts[i]->sigs.empty()) {
|
||||||
|
// the chart will be resized after add signal. delay setVisible to reduce flicker.
|
||||||
|
QTimer::singleShot(0, current_charts[i], [c = current_charts[i]]() { c->setVisible(true); });
|
||||||
|
} else {
|
||||||
|
current_charts[i]->setVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
charts_container->setUpdatesEnabled(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::startAutoScroll() {
|
||||||
|
auto_scroll_timer->start(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::stopAutoScroll() {
|
||||||
|
auto_scroll_timer->stop();
|
||||||
|
auto_scroll_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::doAutoScroll() {
|
||||||
|
QScrollBar *scroll = charts_scroll->verticalScrollBar();
|
||||||
|
if (auto_scroll_count < scroll->pageStep()) {
|
||||||
|
++auto_scroll_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
int value = scroll->value();
|
||||||
|
QPoint pos = charts_scroll->viewport()->mapFromGlobal(QCursor::pos());
|
||||||
|
QRect area = charts_scroll->viewport()->rect();
|
||||||
|
|
||||||
|
if (pos.y() - area.top() < settings.chart_height / 2) {
|
||||||
|
scroll->setValue(value - auto_scroll_count);
|
||||||
|
} else if (area.bottom() - pos.y() < settings.chart_height / 2) {
|
||||||
|
scroll->setValue(value + auto_scroll_count);
|
||||||
|
}
|
||||||
|
bool vertical_unchanged = value == scroll->value();
|
||||||
|
if (vertical_unchanged) {
|
||||||
|
stopAutoScroll();
|
||||||
|
} else {
|
||||||
|
// mouseMoveEvent to updates the drag-selection rectangle
|
||||||
|
const QPoint globalPos = charts_scroll->viewport()->mapToGlobal(pos);
|
||||||
|
const QPoint windowPos = charts_scroll->window()->mapFromGlobal(globalPos);
|
||||||
|
QMouseEvent mm(QEvent::MouseMove, pos, windowPos, globalPos,
|
||||||
|
Qt::NoButton, Qt::LeftButton, Qt::NoModifier, Qt::MouseEventSynthesizedByQt);
|
||||||
|
QApplication::sendEvent(charts_scroll->viewport(), &mm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize ChartsWidget::minimumSizeHint() const {
|
||||||
|
return QSize(CHART_MIN_WIDTH, QWidget::minimumSizeHint().height());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::resizeEvent(QResizeEvent *event) {
|
||||||
|
QWidget::resizeEvent(event);
|
||||||
|
updateLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::newChart() {
|
||||||
|
SignalSelector dlg(tr("New Chart"), this);
|
||||||
|
if (dlg.exec() == QDialog::Accepted) {
|
||||||
|
auto items = dlg.seletedItems();
|
||||||
|
if (!items.isEmpty()) {
|
||||||
|
auto c = createChart();
|
||||||
|
for (auto it : items) {
|
||||||
|
c->addSignal(it->msg_id, it->sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::removeChart(ChartView *chart) {
|
||||||
|
charts.removeOne(chart);
|
||||||
|
chart->deleteLater();
|
||||||
|
for (auto &[_, list] : tab_charts) {
|
||||||
|
list.removeOne(chart);
|
||||||
|
}
|
||||||
|
updateToolBar();
|
||||||
|
updateLayout(true);
|
||||||
|
alignCharts();
|
||||||
|
emit seriesChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::removeAll() {
|
||||||
|
while (tabbar->count() > 1) {
|
||||||
|
tabbar->removeTab(1);
|
||||||
|
}
|
||||||
|
tab_charts.clear();
|
||||||
|
|
||||||
|
if (!charts.isEmpty()) {
|
||||||
|
for (auto c : charts) {
|
||||||
|
delete c;
|
||||||
|
}
|
||||||
|
charts.clear();
|
||||||
|
emit seriesChanged();
|
||||||
|
}
|
||||||
|
zoomReset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsWidget::alignCharts() {
|
||||||
|
int plot_left = 0;
|
||||||
|
for (auto c : charts) {
|
||||||
|
plot_left = std::max(plot_left, c->y_label_width);
|
||||||
|
}
|
||||||
|
plot_left = std::max((plot_left / 10) * 10 + 10, 50);
|
||||||
|
for (auto c : charts) {
|
||||||
|
c->updatePlotArea(plot_left);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChartsWidget::eventFilter(QObject *obj, QEvent *event) {
|
||||||
|
if (obj != this && event->type() == QEvent::Close) {
|
||||||
|
emit dock_btn->clicked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChartsWidget::event(QEvent *event) {
|
||||||
|
bool back_button = false;
|
||||||
|
switch (event->type()) {
|
||||||
|
case QEvent::MouseButtonPress: {
|
||||||
|
QMouseEvent *ev = static_cast<QMouseEvent *>(event);
|
||||||
|
back_button = ev->button() == Qt::BackButton;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case QEvent::NativeGesture: {
|
||||||
|
QNativeGestureEvent *ev = static_cast<QNativeGestureEvent *>(event);
|
||||||
|
back_button = (ev->value() == 180);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case QEvent::WindowActivate:
|
||||||
|
case QEvent::WindowDeactivate:
|
||||||
|
case QEvent::FocusIn:
|
||||||
|
case QEvent::FocusOut:
|
||||||
|
case QEvent::Leave:
|
||||||
|
showValueTip(-1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (back_button) {
|
||||||
|
zoom_undo_stack->undo();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return QFrame::event(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChartsContainer
|
||||||
|
|
||||||
|
ChartsContainer::ChartsContainer(ChartsWidget *parent) : charts_widget(parent), QWidget(parent) {
|
||||||
|
setAcceptDrops(true);
|
||||||
|
setBackgroundRole(QPalette::Window);
|
||||||
|
QVBoxLayout *charts_main_layout = new QVBoxLayout(this);
|
||||||
|
charts_main_layout->setContentsMargins(0, CHART_SPACING, 0, CHART_SPACING);
|
||||||
|
charts_layout = new QGridLayout();
|
||||||
|
charts_layout->setSpacing(CHART_SPACING);
|
||||||
|
charts_main_layout->addLayout(charts_layout);
|
||||||
|
charts_main_layout->addStretch(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsContainer::dragEnterEvent(QDragEnterEvent *event) {
|
||||||
|
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||||
|
event->acceptProposedAction();
|
||||||
|
drawDropIndicator(event->pos());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsContainer::dropEvent(QDropEvent *event) {
|
||||||
|
if (event->mimeData()->hasFormat(CHART_MIME_TYPE)) {
|
||||||
|
auto w = getDropAfter(event->pos());
|
||||||
|
auto chart = qobject_cast<ChartView *>(event->source());
|
||||||
|
if (w != chart) {
|
||||||
|
for (auto &[_, list] : charts_widget->tab_charts) {
|
||||||
|
list.removeOne(chart);
|
||||||
|
}
|
||||||
|
int to = w ? charts_widget->currentCharts().indexOf(w) + 1 : 0;
|
||||||
|
charts_widget->currentCharts().insert(to, chart);
|
||||||
|
charts_widget->updateLayout(true);
|
||||||
|
charts_widget->updateTabBar();
|
||||||
|
event->acceptProposedAction();
|
||||||
|
chart->startAnimation();
|
||||||
|
}
|
||||||
|
drawDropIndicator({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChartsContainer::paintEvent(QPaintEvent *ev) {
|
||||||
|
if (!drop_indictor_pos.isNull() && !childAt(drop_indictor_pos)) {
|
||||||
|
QRect r;
|
||||||
|
if (auto insert_after = getDropAfter(drop_indictor_pos)) {
|
||||||
|
QRect area = insert_after->geometry();
|
||||||
|
r = QRect(area.left(), area.bottom() + 1, area.width(), CHART_SPACING);
|
||||||
|
} else {
|
||||||
|
r = geometry();
|
||||||
|
r.setHeight(CHART_SPACING);
|
||||||
|
}
|
||||||
|
|
||||||
|
QPainter p(this);
|
||||||
|
p.setPen(QPen(palette().highlight(), 2));
|
||||||
|
p.drawLine(r.topLeft() + QPoint(1, 0), r.bottomLeft() + QPoint(1, 0));
|
||||||
|
p.drawLine(r.topLeft() + QPoint(0, r.height() / 2), r.topRight() + QPoint(0, r.height() / 2));
|
||||||
|
p.drawLine(r.topRight(), r.bottomRight());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChartView *ChartsContainer::getDropAfter(const QPoint &pos) const {
|
||||||
|
auto it = std::find_if(charts_widget->currentCharts().crbegin(), charts_widget->currentCharts().crend(), [&pos](auto c) {
|
||||||
|
auto area = c->geometry();
|
||||||
|
return pos.x() >= area.left() && pos.x() <= area.right() && pos.y() >= area.bottom();
|
||||||
|
});
|
||||||
|
return it == charts_widget->currentCharts().crend() ? nullptr : *it;
|
||||||
|
}
|
||||||
130
tools/cabana/chart/chartswidget.h
Executable file
130
tools/cabana/chart/chartswidget.h
Executable file
@@ -0,0 +1,130 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUndoCommand>
|
||||||
|
#include <QUndoStack>
|
||||||
|
|
||||||
|
#include "tools/cabana/chart/signalselector.h"
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
const int CHART_MIN_WIDTH = 300;
|
||||||
|
const QString CHART_MIME_TYPE = "application/x-cabanachartview";
|
||||||
|
|
||||||
|
class ChartView;
|
||||||
|
class ChartsWidget;
|
||||||
|
|
||||||
|
class ChartsContainer : public QWidget {
|
||||||
|
public:
|
||||||
|
ChartsContainer(ChartsWidget *parent);
|
||||||
|
void dragEnterEvent(QDragEnterEvent *event) override;
|
||||||
|
void dropEvent(QDropEvent *event) override;
|
||||||
|
void dragLeaveEvent(QDragLeaveEvent *event) override { drawDropIndicator({}); }
|
||||||
|
void drawDropIndicator(const QPoint &pt) { drop_indictor_pos = pt; update(); }
|
||||||
|
void paintEvent(QPaintEvent *ev) override;
|
||||||
|
ChartView *getDropAfter(const QPoint &pos) const;
|
||||||
|
|
||||||
|
QGridLayout *charts_layout;
|
||||||
|
ChartsWidget *charts_widget;
|
||||||
|
QPoint drop_indictor_pos;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ChartsWidget : public QFrame {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
ChartsWidget(QWidget *parent = nullptr);
|
||||||
|
void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge);
|
||||||
|
inline bool hasSignal(const MessageId &id, const cabana::Signal *sig) { return findChart(id, sig) != nullptr; }
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void setColumnCount(int n);
|
||||||
|
void removeAll();
|
||||||
|
void setZoom(double min, double max);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void dock(bool floating);
|
||||||
|
void rangeChanged(double min, double max, bool is_zommed);
|
||||||
|
void seriesChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QSize minimumSizeHint() const override;
|
||||||
|
void resizeEvent(QResizeEvent *event) override;
|
||||||
|
bool event(QEvent *event) override;
|
||||||
|
void alignCharts();
|
||||||
|
void newChart();
|
||||||
|
ChartView *createChart();
|
||||||
|
void removeChart(ChartView *chart);
|
||||||
|
void splitChart(ChartView *chart);
|
||||||
|
QRect chartVisibleRect(ChartView *chart);
|
||||||
|
void eventsMerged(const MessageEventsMap &new_events);
|
||||||
|
void updateState();
|
||||||
|
void zoomReset();
|
||||||
|
void startAutoScroll();
|
||||||
|
void stopAutoScroll();
|
||||||
|
void doAutoScroll();
|
||||||
|
void updateToolBar();
|
||||||
|
void updateTabBar();
|
||||||
|
void setMaxChartRange(int value);
|
||||||
|
void updateLayout(bool force = false);
|
||||||
|
void settingChanged();
|
||||||
|
void showValueTip(double sec);
|
||||||
|
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||||
|
void newTab();
|
||||||
|
void removeTab(int index);
|
||||||
|
inline QList<ChartView *> ¤tCharts() { return tab_charts[tabbar->tabData(tabbar->currentIndex()).toInt()]; }
|
||||||
|
ChartView *findChart(const MessageId &id, const cabana::Signal *sig);
|
||||||
|
|
||||||
|
QLabel *title_label;
|
||||||
|
QLabel *range_lb;
|
||||||
|
LogSlider *range_slider;
|
||||||
|
QAction *range_lb_action;
|
||||||
|
QAction *range_slider_action;
|
||||||
|
bool docking = true;
|
||||||
|
ToolButton *dock_btn;
|
||||||
|
|
||||||
|
QAction *undo_zoom_action;
|
||||||
|
QAction *redo_zoom_action;
|
||||||
|
QAction *reset_zoom_action;
|
||||||
|
ToolButton *reset_zoom_btn;
|
||||||
|
QUndoStack *zoom_undo_stack;
|
||||||
|
|
||||||
|
ToolButton *remove_all_btn;
|
||||||
|
QList<ChartView *> charts;
|
||||||
|
std::unordered_map<int, QList<ChartView *>> tab_charts;
|
||||||
|
TabBar *tabbar;
|
||||||
|
ChartsContainer *charts_container;
|
||||||
|
QScrollArea *charts_scroll;
|
||||||
|
uint32_t max_chart_range = 0;
|
||||||
|
bool is_zoomed = false;
|
||||||
|
std::pair<double, double> display_range;
|
||||||
|
std::pair<double, double> zoomed_range;
|
||||||
|
QAction *columns_action;
|
||||||
|
int column_count = 1;
|
||||||
|
int current_column_count = 0;
|
||||||
|
int auto_scroll_count = 0;
|
||||||
|
QTimer *auto_scroll_timer;
|
||||||
|
QTimer *align_timer;
|
||||||
|
int current_theme = 0;
|
||||||
|
friend class ZoomCommand;
|
||||||
|
friend class ChartView;
|
||||||
|
friend class ChartsContainer;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ZoomCommand : public QUndoCommand {
|
||||||
|
public:
|
||||||
|
ZoomCommand(ChartsWidget *charts, std::pair<double, double> range) : charts(charts), range(range), QUndoCommand() {
|
||||||
|
prev_range = charts->is_zoomed ? charts->zoomed_range : charts->display_range;
|
||||||
|
setText(QObject::tr("Zoom to %1-%2").arg(range.first, 0, 'f', 2).arg(range.second, 0, 'f', 2));
|
||||||
|
}
|
||||||
|
void undo() override { charts->setZoom(prev_range.first, prev_range.second); }
|
||||||
|
void redo() override { charts->setZoom(range.first, range.second); }
|
||||||
|
ChartsWidget *charts;
|
||||||
|
std::pair<double, double> prev_range, range;
|
||||||
|
};
|
||||||
108
tools/cabana/chart/signalselector.cc
Executable file
108
tools/cabana/chart/signalselector.cc
Executable file
@@ -0,0 +1,108 @@
|
|||||||
|
#include "tools/cabana/chart/signalselector.h"
|
||||||
|
|
||||||
|
#include <QCompleter>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
SignalSelector::SignalSelector(QString title, QWidget *parent) : QDialog(parent) {
|
||||||
|
setWindowTitle(title);
|
||||||
|
QGridLayout *main_layout = new QGridLayout(this);
|
||||||
|
|
||||||
|
// left column
|
||||||
|
main_layout->addWidget(new QLabel(tr("Available Signals")), 0, 0);
|
||||||
|
main_layout->addWidget(msgs_combo = new QComboBox(this), 1, 0);
|
||||||
|
msgs_combo->setEditable(true);
|
||||||
|
msgs_combo->lineEdit()->setPlaceholderText(tr("Select a msg..."));
|
||||||
|
msgs_combo->setInsertPolicy(QComboBox::NoInsert);
|
||||||
|
msgs_combo->completer()->setCompletionMode(QCompleter::PopupCompletion);
|
||||||
|
msgs_combo->completer()->setFilterMode(Qt::MatchContains);
|
||||||
|
|
||||||
|
main_layout->addWidget(available_list = new QListWidget(this), 2, 0);
|
||||||
|
|
||||||
|
// buttons
|
||||||
|
QVBoxLayout *btn_layout = new QVBoxLayout();
|
||||||
|
QPushButton *add_btn = new QPushButton(utils::icon("chevron-right"), "", this);
|
||||||
|
add_btn->setEnabled(false);
|
||||||
|
QPushButton *remove_btn = new QPushButton(utils::icon("chevron-left"), "", this);
|
||||||
|
remove_btn->setEnabled(false);
|
||||||
|
btn_layout->addStretch(0);
|
||||||
|
btn_layout->addWidget(add_btn);
|
||||||
|
btn_layout->addWidget(remove_btn);
|
||||||
|
btn_layout->addStretch(0);
|
||||||
|
main_layout->addLayout(btn_layout, 0, 1, 3, 1);
|
||||||
|
|
||||||
|
// right column
|
||||||
|
main_layout->addWidget(new QLabel(tr("Selected Signals")), 0, 2);
|
||||||
|
main_layout->addWidget(selected_list = new QListWidget(this), 1, 2, 2, 1);
|
||||||
|
|
||||||
|
auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||||
|
main_layout->addWidget(buttonBox, 3, 2);
|
||||||
|
|
||||||
|
for (const auto &[id, _] : can->lastMessages()) {
|
||||||
|
if (auto m = dbc()->msg(id)) {
|
||||||
|
msgs_combo->addItem(QString("%1 (%2)").arg(m->name).arg(id.toString()), QVariant::fromValue(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msgs_combo->model()->sort(0);
|
||||||
|
msgs_combo->setCurrentIndex(-1);
|
||||||
|
|
||||||
|
QObject::connect(msgs_combo, qOverload<int>(&QComboBox::currentIndexChanged), this, &SignalSelector::updateAvailableList);
|
||||||
|
QObject::connect(available_list, &QListWidget::currentRowChanged, [=](int row) { add_btn->setEnabled(row != -1); });
|
||||||
|
QObject::connect(selected_list, &QListWidget::currentRowChanged, [=](int row) { remove_btn->setEnabled(row != -1); });
|
||||||
|
QObject::connect(available_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::add);
|
||||||
|
QObject::connect(selected_list, &QListWidget::itemDoubleClicked, this, &SignalSelector::remove);
|
||||||
|
QObject::connect(add_btn, &QPushButton::clicked, [this]() { if (auto item = available_list->currentItem()) add(item); });
|
||||||
|
QObject::connect(remove_btn, &QPushButton::clicked, [this]() { if (auto item = selected_list->currentItem()) remove(item); });
|
||||||
|
QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||||
|
QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalSelector::add(QListWidgetItem *item) {
|
||||||
|
auto it = (ListItem *)item;
|
||||||
|
addItemToList(selected_list, it->msg_id, it->sig, true);
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalSelector::remove(QListWidgetItem *item) {
|
||||||
|
auto it = (ListItem *)item;
|
||||||
|
if (it->msg_id == msgs_combo->currentData().value<MessageId>()) {
|
||||||
|
addItemToList(available_list, it->msg_id, it->sig);
|
||||||
|
}
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalSelector::updateAvailableList(int index) {
|
||||||
|
if (index == -1) return;
|
||||||
|
available_list->clear();
|
||||||
|
MessageId msg_id = msgs_combo->itemData(index).value<MessageId>();
|
||||||
|
auto selected_items = seletedItems();
|
||||||
|
for (auto s : dbc()->msg(msg_id)->getSignals()) {
|
||||||
|
bool is_selected = std::any_of(selected_items.begin(), selected_items.end(), [=, sig = s](auto it) { return it->msg_id == msg_id && it->sig == sig; });
|
||||||
|
if (!is_selected) {
|
||||||
|
addItemToList(available_list, msg_id, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalSelector::addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name) {
|
||||||
|
QString text = QString("<span style=\"color:%0;\">■ </span> %1").arg(sig->color.name(), sig->name);
|
||||||
|
if (show_msg_name) text += QString(" <font color=\"gray\">%0 %1</font>").arg(msgName(id), id.toString());
|
||||||
|
|
||||||
|
QLabel *label = new QLabel(text);
|
||||||
|
label->setContentsMargins(5, 0, 5, 0);
|
||||||
|
auto new_item = new ListItem(id, sig, parent);
|
||||||
|
new_item->setSizeHint(label->sizeHint());
|
||||||
|
parent->setItemWidget(new_item, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<SignalSelector::ListItem *> SignalSelector::seletedItems() {
|
||||||
|
QList<SignalSelector::ListItem *> ret;
|
||||||
|
for (int i = 0; i < selected_list->count(); ++i) ret.push_back((ListItem *)selected_list->item(i));
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
30
tools/cabana/chart/signalselector.h
Executable file
30
tools/cabana/chart/signalselector.h
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QListWidget>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
|
||||||
|
class SignalSelector : public QDialog {
|
||||||
|
public:
|
||||||
|
struct ListItem : public QListWidgetItem {
|
||||||
|
ListItem(const MessageId &msg_id, const cabana::Signal *sig, QListWidget *parent) : msg_id(msg_id), sig(sig), QListWidgetItem(parent) {}
|
||||||
|
MessageId msg_id;
|
||||||
|
const cabana::Signal *sig;
|
||||||
|
};
|
||||||
|
|
||||||
|
SignalSelector(QString title, QWidget *parent);
|
||||||
|
QList<ListItem *> seletedItems();
|
||||||
|
inline void addSelected(const MessageId &id, const cabana::Signal *sig) { addItemToList(selected_list, id, sig, true); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void updateAvailableList(int index);
|
||||||
|
void addItemToList(QListWidget *parent, const MessageId id, const cabana::Signal *sig, bool show_msg_name = false);
|
||||||
|
void add(QListWidgetItem *item);
|
||||||
|
void remove(QListWidgetItem *item);
|
||||||
|
|
||||||
|
QComboBox *msgs_combo;
|
||||||
|
QListWidget *available_list;
|
||||||
|
QListWidget *selected_list;
|
||||||
|
};
|
||||||
59
tools/cabana/chart/sparkline.cc
Executable file
59
tools/cabana/chart/sparkline.cc
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
#include "tools/cabana/chart/sparkline.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <limits>
|
||||||
|
#include <QPainter>
|
||||||
|
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
void Sparkline::update(const MessageId &msg_id, const cabana::Signal *sig, double last_msg_ts, int range, QSize size) {
|
||||||
|
const auto &msgs = can->events(msg_id);
|
||||||
|
uint64_t ts = (last_msg_ts + can->routeStartTime()) * 1e9;
|
||||||
|
uint64_t first_ts = (ts > range * 1e9) ? ts - range * 1e9 : 0;
|
||||||
|
auto first = std::lower_bound(msgs.cbegin(), msgs.cend(), first_ts, CompareCanEvent());
|
||||||
|
auto last = std::upper_bound(first, msgs.cend(), ts, CompareCanEvent());
|
||||||
|
|
||||||
|
if (first != last && !size.isEmpty()) {
|
||||||
|
points.clear();
|
||||||
|
double value = 0;
|
||||||
|
for (auto it = first; it != last; ++it) {
|
||||||
|
if (sig->getValue((*it)->dat, (*it)->size, &value)) {
|
||||||
|
points.emplace_back(((*it)->mono_time - (*first)->mono_time) / 1e9, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const auto [min, max] = std::minmax_element(points.begin(), points.end(),
|
||||||
|
[](auto &l, auto &r) { return l.y() < r.y(); });
|
||||||
|
min_val = min->y() == max->y() ? min->y() - 1 : min->y();
|
||||||
|
max_val = min->y() == max->y() ? max->y() + 1 : max->y();
|
||||||
|
freq_ = points.size() / std::max(points.back().x() - points.front().x(), 1.0);
|
||||||
|
render(sig->color, range, size);
|
||||||
|
} else {
|
||||||
|
pixmap = QPixmap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Sparkline::render(const QColor &color, int range, QSize size) {
|
||||||
|
const double xscale = (size.width() - 1) / (double)range;
|
||||||
|
const double yscale = (size.height() - 3) / (max_val - min_val);
|
||||||
|
for (auto &v : points) {
|
||||||
|
v = QPoint(v.x() * xscale, 1 + std::abs(v.y() - max_val) * yscale);
|
||||||
|
}
|
||||||
|
|
||||||
|
qreal dpr = qApp->devicePixelRatio();
|
||||||
|
size *= dpr;
|
||||||
|
if (size != pixmap.size()) {
|
||||||
|
pixmap = QPixmap(size);
|
||||||
|
}
|
||||||
|
pixmap.setDevicePixelRatio(dpr);
|
||||||
|
pixmap.fill(Qt::transparent);
|
||||||
|
QPainter painter(&pixmap);
|
||||||
|
painter.setRenderHint(QPainter::Antialiasing, points.size() < 500);
|
||||||
|
painter.setPen(color);
|
||||||
|
painter.drawPolyline(points.data(), points.size());
|
||||||
|
painter.setPen(QPen(color, 3));
|
||||||
|
if ((points.back().x() - points.front().x()) / points.size() > 8) {
|
||||||
|
painter.drawPoints(points.data(), points.size());
|
||||||
|
} else {
|
||||||
|
painter.drawPoint(points.back());
|
||||||
|
}
|
||||||
|
}
|
||||||
24
tools/cabana/chart/sparkline.h
Executable file
24
tools/cabana/chart/sparkline.h
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbc.h"
|
||||||
|
|
||||||
|
class Sparkline {
|
||||||
|
public:
|
||||||
|
void update(const MessageId &msg_id, const cabana::Signal *sig, double last_msg_ts, int range, QSize size);
|
||||||
|
inline double freq() const { return freq_; }
|
||||||
|
bool isEmpty() const { return pixmap.isNull(); }
|
||||||
|
|
||||||
|
QPixmap pixmap;
|
||||||
|
double min_val = 0;
|
||||||
|
double max_val = 0;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void render(const QColor &color, int range, QSize size);
|
||||||
|
|
||||||
|
std::vector<QPointF> points;
|
||||||
|
double freq_ = 0;
|
||||||
|
};
|
||||||
55
tools/cabana/chart/tiplabel.cc
Executable file
55
tools/cabana/chart/tiplabel.cc
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#include "tools/cabana/chart/tiplabel.h"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QStylePainter>
|
||||||
|
#include <QToolTip>
|
||||||
|
|
||||||
|
#include "tools/cabana/settings.h"
|
||||||
|
|
||||||
|
TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) {
|
||||||
|
setForegroundRole(QPalette::ToolTipText);
|
||||||
|
setBackgroundRole(QPalette::ToolTipBase);
|
||||||
|
QFont font;
|
||||||
|
font.setPointSizeF(8.34563465);
|
||||||
|
setFont(font);
|
||||||
|
auto palette = QToolTip::palette();
|
||||||
|
if (settings.theme != DARK_THEME) {
|
||||||
|
palette.setColor(QPalette::ToolTipBase, QApplication::palette().color(QPalette::Base));
|
||||||
|
palette.setColor(QPalette::ToolTipText, QRgb(0x404044)); // same color as chart label brush
|
||||||
|
}
|
||||||
|
setPalette(palette);
|
||||||
|
ensurePolished();
|
||||||
|
setMargin(1 + style()->pixelMetric(QStyle::PM_ToolTipLabelFrameWidth, nullptr, this));
|
||||||
|
setAttribute(Qt::WA_ShowWithoutActivating);
|
||||||
|
setTextFormat(Qt::RichText);
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TipLabel::showText(const QPoint &pt, const QString &text, QWidget *w, const QRect &rect) {
|
||||||
|
setText(text);
|
||||||
|
if (!text.isEmpty()) {
|
||||||
|
QSize extra(1, 1);
|
||||||
|
resize(sizeHint() + extra);
|
||||||
|
QPoint tip_pos(pt.x() + 8, rect.top() + 2);
|
||||||
|
if (tip_pos.x() + size().width() >= rect.right()) {
|
||||||
|
tip_pos.rx() = pt.x() - size().width() - 8;
|
||||||
|
}
|
||||||
|
if (rect.contains({tip_pos, size()})) {
|
||||||
|
move(w->mapToGlobal(tip_pos));
|
||||||
|
setVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TipLabel::paintEvent(QPaintEvent *ev) {
|
||||||
|
QStylePainter p(this);
|
||||||
|
QStyleOptionFrame opt;
|
||||||
|
opt.init(this);
|
||||||
|
p.drawPrimitive(QStyle::PE_PanelTipLabel, opt);
|
||||||
|
p.end();
|
||||||
|
QLabel::paintEvent(ev);
|
||||||
|
}
|
||||||
10
tools/cabana/chart/tiplabel.h
Executable file
10
tools/cabana/chart/tiplabel.h
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QLabel>
|
||||||
|
|
||||||
|
class TipLabel : public QLabel {
|
||||||
|
public:
|
||||||
|
TipLabel(QWidget *parent = nullptr);
|
||||||
|
void showText(const QPoint &pt, const QString &sec, QWidget *w, const QRect &rect);
|
||||||
|
void paintEvent(QPaintEvent *ev) override;
|
||||||
|
};
|
||||||
124
tools/cabana/commands.cc
Executable file
124
tools/cabana/commands.cc
Executable file
@@ -0,0 +1,124 @@
|
|||||||
|
#include <QApplication>
|
||||||
|
|
||||||
|
#include "tools/cabana/commands.h"
|
||||||
|
|
||||||
|
// EditMsgCommand
|
||||||
|
|
||||||
|
EditMsgCommand::EditMsgCommand(const MessageId &id, const QString &name, int size,
|
||||||
|
const QString &node, const QString &comment, QUndoCommand *parent)
|
||||||
|
: id(id), new_name(name), new_size(size), new_node(node), new_comment(comment), QUndoCommand(parent) {
|
||||||
|
if (auto msg = dbc()->msg(id)) {
|
||||||
|
old_name = msg->name;
|
||||||
|
old_size = msg->size;
|
||||||
|
old_node = msg->transmitter;
|
||||||
|
old_comment = msg->comment;
|
||||||
|
setText(QObject::tr("edit message %1:%2").arg(name).arg(id.address));
|
||||||
|
} else {
|
||||||
|
setText(QObject::tr("new message %1:%2").arg(name).arg(id.address));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EditMsgCommand::undo() {
|
||||||
|
if (old_name.isEmpty())
|
||||||
|
dbc()->removeMsg(id);
|
||||||
|
else
|
||||||
|
dbc()->updateMsg(id, old_name, old_size, old_node, old_comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EditMsgCommand::redo() {
|
||||||
|
dbc()->updateMsg(id, new_name, new_size, new_node, new_comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveMsgCommand
|
||||||
|
|
||||||
|
RemoveMsgCommand::RemoveMsgCommand(const MessageId &id, QUndoCommand *parent) : id(id), QUndoCommand(parent) {
|
||||||
|
if (auto msg = dbc()->msg(id)) {
|
||||||
|
message = *msg;
|
||||||
|
setText(QObject::tr("remove message %1:%2").arg(message.name).arg(id.address));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveMsgCommand::undo() {
|
||||||
|
if (!message.name.isEmpty()) {
|
||||||
|
dbc()->updateMsg(id, message.name, message.size, message.transmitter, message.comment);
|
||||||
|
for (auto s : message.getSignals())
|
||||||
|
dbc()->addSignal(id, *s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveMsgCommand::redo() {
|
||||||
|
if (!message.name.isEmpty())
|
||||||
|
dbc()->removeMsg(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSigCommand
|
||||||
|
|
||||||
|
AddSigCommand::AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent)
|
||||||
|
: id(id), signal(sig), QUndoCommand(parent) {
|
||||||
|
setText(QObject::tr("add signal %1 to %2:%3").arg(sig.name).arg(msgName(id)).arg(id.address));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddSigCommand::undo() {
|
||||||
|
dbc()->removeSignal(id, signal.name);
|
||||||
|
if (msg_created) dbc()->removeMsg(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AddSigCommand::redo() {
|
||||||
|
if (auto msg = dbc()->msg(id); !msg) {
|
||||||
|
msg_created = true;
|
||||||
|
dbc()->updateMsg(id, dbc()->newMsgName(id), can->lastMessage(id).dat.size(), "", "");
|
||||||
|
}
|
||||||
|
signal.name = dbc()->newSignalName(id);
|
||||||
|
signal.max = std::pow(2, signal.size) - 1;
|
||||||
|
dbc()->addSignal(id, signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSigCommand
|
||||||
|
|
||||||
|
RemoveSigCommand::RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent)
|
||||||
|
: id(id), QUndoCommand(parent) {
|
||||||
|
sigs.push_back(*sig);
|
||||||
|
if (sig->type == cabana::Signal::Type::Multiplexor) {
|
||||||
|
for (const auto &s : dbc()->msg(id)->sigs) {
|
||||||
|
if (s->type == cabana::Signal::Type::Multiplexed) {
|
||||||
|
sigs.push_back(*s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setText(QObject::tr("remove signal %1 from %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address));
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveSigCommand::undo() { for (const auto &s : sigs) dbc()->addSignal(id, s); }
|
||||||
|
void RemoveSigCommand::redo() { for (const auto &s : sigs) dbc()->removeSignal(id, s.name); }
|
||||||
|
|
||||||
|
// EditSignalCommand
|
||||||
|
|
||||||
|
EditSignalCommand::EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent)
|
||||||
|
: id(id), QUndoCommand(parent) {
|
||||||
|
sigs.push_back({*sig, new_sig});
|
||||||
|
if (sig->type == cabana::Signal::Type::Multiplexor && new_sig.type == cabana::Signal::Type::Normal) {
|
||||||
|
// convert all multiplexed signals to normal signals
|
||||||
|
auto msg = dbc()->msg(id);
|
||||||
|
assert(msg);
|
||||||
|
for (const auto &s : msg->sigs) {
|
||||||
|
if (s->type == cabana::Signal::Type::Multiplexed) {
|
||||||
|
auto new_s = *s;
|
||||||
|
new_s.type = cabana::Signal::Type::Normal;
|
||||||
|
sigs.push_back({*s, new_s});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setText(QObject::tr("edit signal %1 in %2:%3").arg(sig->name).arg(msgName(id)).arg(id.address));
|
||||||
|
}
|
||||||
|
|
||||||
|
void EditSignalCommand::undo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.second.name, s.first); }
|
||||||
|
void EditSignalCommand::redo() { for (const auto &s : sigs) dbc()->updateSignal(id, s.first.name, s.second); }
|
||||||
|
|
||||||
|
namespace UndoStack {
|
||||||
|
|
||||||
|
QUndoStack *instance() {
|
||||||
|
static QUndoStack *undo_stack = new QUndoStack(qApp);
|
||||||
|
return undo_stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace UndoStack
|
||||||
72
tools/cabana/commands.h
Executable file
72
tools/cabana/commands.h
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QUndoCommand>
|
||||||
|
#include <QUndoStack>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
class EditMsgCommand : public QUndoCommand {
|
||||||
|
public:
|
||||||
|
EditMsgCommand(const MessageId &id, const QString &name, int size, const QString &node,
|
||||||
|
const QString &comment, QUndoCommand *parent = nullptr);
|
||||||
|
void undo() override;
|
||||||
|
void redo() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const MessageId id;
|
||||||
|
QString old_name, new_name, old_comment, new_comment, old_node, new_node;
|
||||||
|
int old_size = 0, new_size = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class RemoveMsgCommand : public QUndoCommand {
|
||||||
|
public:
|
||||||
|
RemoveMsgCommand(const MessageId &id, QUndoCommand *parent = nullptr);
|
||||||
|
void undo() override;
|
||||||
|
void redo() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const MessageId id;
|
||||||
|
cabana::Msg message;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AddSigCommand : public QUndoCommand {
|
||||||
|
public:
|
||||||
|
AddSigCommand(const MessageId &id, const cabana::Signal &sig, QUndoCommand *parent = nullptr);
|
||||||
|
void undo() override;
|
||||||
|
void redo() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const MessageId id;
|
||||||
|
bool msg_created = false;
|
||||||
|
cabana::Signal signal = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
class RemoveSigCommand : public QUndoCommand {
|
||||||
|
public:
|
||||||
|
RemoveSigCommand(const MessageId &id, const cabana::Signal *sig, QUndoCommand *parent = nullptr);
|
||||||
|
void undo() override;
|
||||||
|
void redo() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const MessageId id;
|
||||||
|
QList<cabana::Signal> sigs;
|
||||||
|
};
|
||||||
|
|
||||||
|
class EditSignalCommand : public QUndoCommand {
|
||||||
|
public:
|
||||||
|
EditSignalCommand(const MessageId &id, const cabana::Signal *sig, const cabana::Signal &new_sig, QUndoCommand *parent = nullptr);
|
||||||
|
void undo() override;
|
||||||
|
void redo() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
const MessageId id;
|
||||||
|
QList<std::pair<cabana::Signal, cabana::Signal>> sigs; // QList<{old_sig, new_sig}>
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace UndoStack {
|
||||||
|
QUndoStack *instance();
|
||||||
|
inline void push(QUndoCommand *cmd) { instance()->push(cmd); }
|
||||||
|
};
|
||||||
211
tools/cabana/dbc/dbc.cc
Executable file
211
tools/cabana/dbc/dbc.cc
Executable file
@@ -0,0 +1,211 @@
|
|||||||
|
#include "tools/cabana/dbc/dbc.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "tools/cabana/util.h"
|
||||||
|
|
||||||
|
uint qHash(const MessageId &item) {
|
||||||
|
return qHash(item.source) ^ qHash(item.address);
|
||||||
|
}
|
||||||
|
|
||||||
|
// cabana::Msg
|
||||||
|
|
||||||
|
cabana::Msg::~Msg() {
|
||||||
|
for (auto s : sigs) {
|
||||||
|
delete s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cabana::Signal *cabana::Msg::addSignal(const cabana::Signal &sig) {
|
||||||
|
auto s = sigs.emplace_back(new cabana::Signal(sig));
|
||||||
|
update();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
cabana::Signal *cabana::Msg::updateSignal(const QString &sig_name, const cabana::Signal &new_sig) {
|
||||||
|
auto s = sig(sig_name);
|
||||||
|
if (s) {
|
||||||
|
*s = new_sig;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cabana::Msg::removeSignal(const QString &sig_name) {
|
||||||
|
auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; });
|
||||||
|
if (it != sigs.end()) {
|
||||||
|
delete *it;
|
||||||
|
sigs.erase(it);
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cabana::Msg &cabana::Msg::operator=(const cabana::Msg &other) {
|
||||||
|
address = other.address;
|
||||||
|
name = other.name;
|
||||||
|
size = other.size;
|
||||||
|
comment = other.comment;
|
||||||
|
|
||||||
|
for (auto s : sigs) delete s;
|
||||||
|
sigs.clear();
|
||||||
|
for (auto s : other.sigs) {
|
||||||
|
sigs.push_back(new cabana::Signal(*s));
|
||||||
|
}
|
||||||
|
|
||||||
|
update();
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
|
|
||||||
|
cabana::Signal *cabana::Msg::sig(const QString &sig_name) const {
|
||||||
|
auto it = std::find_if(sigs.begin(), sigs.end(), [&](auto &s) { return s->name == sig_name; });
|
||||||
|
return it != sigs.end() ? *it : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int cabana::Msg::indexOf(const cabana::Signal *sig) const {
|
||||||
|
for (int i = 0; i < sigs.size(); ++i) {
|
||||||
|
if (sigs[i] == sig) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString cabana::Msg::newSignalName() {
|
||||||
|
QString new_name;
|
||||||
|
for (int i = 1; /**/; ++i) {
|
||||||
|
new_name = QString("NEW_SIGNAL_%1").arg(i);
|
||||||
|
if (sig(new_name) == nullptr) break;
|
||||||
|
}
|
||||||
|
return new_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cabana::Msg::update() {
|
||||||
|
if (transmitter.isEmpty()) {
|
||||||
|
transmitter = DEFAULT_NODE_NAME;
|
||||||
|
}
|
||||||
|
mask.assign(size, 0x00);
|
||||||
|
multiplexor = nullptr;
|
||||||
|
|
||||||
|
// sort signals
|
||||||
|
std::sort(sigs.begin(), sigs.end(), [](auto l, auto r) {
|
||||||
|
return std::tie(r->type, l->multiplex_value, l->start_bit, l->name) <
|
||||||
|
std::tie(l->type, r->multiplex_value, r->start_bit, r->name);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (auto sig : sigs) {
|
||||||
|
if (sig->type == cabana::Signal::Type::Multiplexor) {
|
||||||
|
multiplexor = sig;
|
||||||
|
}
|
||||||
|
sig->update();
|
||||||
|
|
||||||
|
// update mask
|
||||||
|
int i = sig->msb / 8;
|
||||||
|
int bits = sig->size;
|
||||||
|
while (i >= 0 && i < size && bits > 0) {
|
||||||
|
int lsb = (int)(sig->lsb / 8) == i ? sig->lsb : i * 8;
|
||||||
|
int msb = (int)(sig->msb / 8) == i ? sig->msb : (i + 1) * 8 - 1;
|
||||||
|
|
||||||
|
int sz = msb - lsb + 1;
|
||||||
|
int shift = (lsb - (i * 8));
|
||||||
|
|
||||||
|
mask[i] |= ((1ULL << sz) - 1) << shift;
|
||||||
|
|
||||||
|
bits -= size;
|
||||||
|
i = sig->is_little_endian ? i - 1 : i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto sig : sigs) {
|
||||||
|
sig->multiplexor = sig->type == cabana::Signal::Type::Multiplexed ? multiplexor : nullptr;
|
||||||
|
if (!sig->multiplexor) {
|
||||||
|
if (sig->type == cabana::Signal::Type::Multiplexed) {
|
||||||
|
sig->type = cabana::Signal::Type::Normal;
|
||||||
|
}
|
||||||
|
sig->multiplex_value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cabana::Signal
|
||||||
|
|
||||||
|
void cabana::Signal::update() {
|
||||||
|
updateMsbLsb(*this);
|
||||||
|
if (receiver_name.isEmpty()) {
|
||||||
|
receiver_name = DEFAULT_NODE_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
float h = 19 * (float)lsb / 64.0;
|
||||||
|
h = fmod(h, 1.0);
|
||||||
|
size_t hash = qHash(name);
|
||||||
|
float s = 0.25 + 0.25 * (float)(hash & 0xff) / 255.0;
|
||||||
|
float v = 0.75 + 0.25 * (float)((hash >> 8) & 0xff) / 255.0;
|
||||||
|
|
||||||
|
color = QColor::fromHsvF(h, s, v);
|
||||||
|
precision = std::max(num_decimals(factor), num_decimals(offset));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString cabana::Signal::formatValue(double value) const {
|
||||||
|
// Show enum string
|
||||||
|
int64_t raw_value = round((value - offset) / factor);
|
||||||
|
for (const auto &[val, desc] : val_desc) {
|
||||||
|
if (std::abs(raw_value - val) < 1e-6) {
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString val_str = QString::number(value, 'f', precision);
|
||||||
|
if (!unit.isEmpty()) {
|
||||||
|
val_str += " " + unit;
|
||||||
|
}
|
||||||
|
return val_str;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool cabana::Signal::getValue(const uint8_t *data, size_t data_size, double *val) const {
|
||||||
|
if (multiplexor && get_raw_value(data, data_size, *multiplexor) != multiplex_value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
*val = get_raw_value(data, data_size, *this);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool cabana::Signal::operator==(const cabana::Signal &other) const {
|
||||||
|
return name == other.name && size == other.size &&
|
||||||
|
start_bit == other.start_bit &&
|
||||||
|
msb == other.msb && lsb == other.lsb &&
|
||||||
|
is_signed == other.is_signed && is_little_endian == other.is_little_endian &&
|
||||||
|
factor == other.factor && offset == other.offset &&
|
||||||
|
min == other.min && max == other.max && comment == other.comment && unit == other.unit && val_desc == other.val_desc &&
|
||||||
|
multiplex_value == other.multiplex_value && type == other.type && receiver_name == other.receiver_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper functions
|
||||||
|
|
||||||
|
double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig) {
|
||||||
|
int64_t val = 0;
|
||||||
|
|
||||||
|
int i = sig.msb / 8;
|
||||||
|
int bits = sig.size;
|
||||||
|
while (i >= 0 && i < data_size && bits > 0) {
|
||||||
|
int lsb = (int)(sig.lsb / 8) == i ? sig.lsb : i * 8;
|
||||||
|
int msb = (int)(sig.msb / 8) == i ? sig.msb : (i + 1) * 8 - 1;
|
||||||
|
int size = msb - lsb + 1;
|
||||||
|
|
||||||
|
uint64_t d = (data[i] >> (lsb - (i * 8))) & ((1ULL << size) - 1);
|
||||||
|
val |= d << (bits - size);
|
||||||
|
|
||||||
|
bits -= size;
|
||||||
|
i = sig.is_little_endian ? i - 1 : i + 1;
|
||||||
|
}
|
||||||
|
if (sig.is_signed) {
|
||||||
|
val -= ((val >> (sig.size - 1)) & 0x1) ? (1ULL << sig.size) : 0;
|
||||||
|
}
|
||||||
|
return val * sig.factor + sig.offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateMsbLsb(cabana::Signal &s) {
|
||||||
|
if (s.is_little_endian) {
|
||||||
|
s.lsb = s.start_bit;
|
||||||
|
s.msb = s.start_bit + s.size - 1;
|
||||||
|
} else {
|
||||||
|
s.lsb = flipBitPos(flipBitPos(s.start_bit) + s.size - 1);
|
||||||
|
s.msb = s.start_bit;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
tools/cabana/dbc/dbc.h
Executable file
122
tools/cabana/dbc/dbc.h
Executable file
@@ -0,0 +1,122 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QList>
|
||||||
|
#include <QMetaType>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
|
||||||
|
const QString UNTITLED = "untitled";
|
||||||
|
const QString DEFAULT_NODE_NAME = "XXX";
|
||||||
|
|
||||||
|
struct MessageId {
|
||||||
|
uint8_t source = 0;
|
||||||
|
uint32_t address = 0;
|
||||||
|
|
||||||
|
QString toString() const {
|
||||||
|
return QString("%1:%2").arg(source).arg(address, 1, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator==(const MessageId &other) const {
|
||||||
|
return source == other.source && address == other.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator!=(const MessageId &other) const {
|
||||||
|
return !(*this == other);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator<(const MessageId &other) const {
|
||||||
|
return std::pair{source, address} < std::pair{other.source, other.address};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool operator>(const MessageId &other) const {
|
||||||
|
return std::pair{source, address} > std::pair{other.source, other.address};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
uint qHash(const MessageId &item);
|
||||||
|
Q_DECLARE_METATYPE(MessageId);
|
||||||
|
|
||||||
|
template <>
|
||||||
|
struct std::hash<MessageId> {
|
||||||
|
std::size_t operator()(const MessageId &k) const noexcept { return qHash(k); }
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef QList<std::pair<double, QString>> ValueDescription;
|
||||||
|
|
||||||
|
namespace cabana {
|
||||||
|
|
||||||
|
class Signal {
|
||||||
|
public:
|
||||||
|
Signal() = default;
|
||||||
|
Signal(const Signal &other) = default;
|
||||||
|
void update();
|
||||||
|
bool getValue(const uint8_t *data, size_t data_size, double *val) const;
|
||||||
|
QString formatValue(double value) const;
|
||||||
|
bool operator==(const cabana::Signal &other) const;
|
||||||
|
inline bool operator!=(const cabana::Signal &other) const { return !(*this == other); }
|
||||||
|
|
||||||
|
enum class Type {
|
||||||
|
Normal = 0,
|
||||||
|
Multiplexed,
|
||||||
|
Multiplexor
|
||||||
|
};
|
||||||
|
|
||||||
|
Type type = Type::Normal;
|
||||||
|
QString name;
|
||||||
|
int start_bit, msb, lsb, size;
|
||||||
|
double factor = 1.0;
|
||||||
|
double offset = 0;
|
||||||
|
bool is_signed;
|
||||||
|
bool is_little_endian;
|
||||||
|
double min, max;
|
||||||
|
QString unit;
|
||||||
|
QString comment;
|
||||||
|
QString receiver_name;
|
||||||
|
ValueDescription val_desc;
|
||||||
|
int precision = 0;
|
||||||
|
QColor color;
|
||||||
|
|
||||||
|
// Multiplexed
|
||||||
|
int multiplex_value = 0;
|
||||||
|
Signal *multiplexor = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Msg {
|
||||||
|
public:
|
||||||
|
Msg() = default;
|
||||||
|
Msg(const Msg &other) { *this = other; }
|
||||||
|
~Msg();
|
||||||
|
cabana::Signal *addSignal(const cabana::Signal &sig);
|
||||||
|
cabana::Signal *updateSignal(const QString &sig_name, const cabana::Signal &sig);
|
||||||
|
void removeSignal(const QString &sig_name);
|
||||||
|
Msg &operator=(const Msg &other);
|
||||||
|
int indexOf(const cabana::Signal *sig) const;
|
||||||
|
cabana::Signal *sig(const QString &sig_name) const;
|
||||||
|
QString newSignalName();
|
||||||
|
void update();
|
||||||
|
inline const std::vector<cabana::Signal *> &getSignals() const { return sigs; }
|
||||||
|
|
||||||
|
uint32_t address;
|
||||||
|
QString name;
|
||||||
|
uint32_t size;
|
||||||
|
QString comment;
|
||||||
|
QString transmitter;
|
||||||
|
std::vector<cabana::Signal *> sigs;
|
||||||
|
|
||||||
|
std::vector<uint8_t> mask;
|
||||||
|
cabana::Signal *multiplexor = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace cabana
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
double get_raw_value(const uint8_t *data, size_t data_size, const cabana::Signal &sig);
|
||||||
|
void updateMsbLsb(cabana::Signal &s);
|
||||||
|
inline int flipBitPos(int start_bit) { return 8 * (start_bit / 8) + 7 - start_bit % 8; }
|
||||||
|
inline QString doubleToString(double value) { return QString::number(value, 'g', std::numeric_limits<double>::digits10); }
|
||||||
240
tools/cabana/dbc/dbcfile.cc
Executable file
240
tools/cabana/dbc/dbcfile.cc
Executable file
@@ -0,0 +1,240 @@
|
|||||||
|
#include "tools/cabana/dbc/dbcfile.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QTextStream>
|
||||||
|
|
||||||
|
DBCFile::DBCFile(const QString &dbc_file_name) {
|
||||||
|
QFile file(dbc_file_name);
|
||||||
|
if (file.open(QIODevice::ReadOnly)) {
|
||||||
|
name_ = QFileInfo(dbc_file_name).baseName();
|
||||||
|
filename = dbc_file_name;
|
||||||
|
// Remove auto save file extension
|
||||||
|
if (dbc_file_name.endsWith(AUTO_SAVE_EXTENSION)) {
|
||||||
|
filename.chop(AUTO_SAVE_EXTENSION.length());
|
||||||
|
}
|
||||||
|
parse(file.readAll());
|
||||||
|
} else {
|
||||||
|
throw std::runtime_error("Failed to open file.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DBCFile::DBCFile(const QString &name, const QString &content) : name_(name), filename("") {
|
||||||
|
// Open from clipboard
|
||||||
|
parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DBCFile::save() {
|
||||||
|
assert(!filename.isEmpty());
|
||||||
|
if (writeContents(filename)) {
|
||||||
|
cleanupAutoSaveFile();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DBCFile::saveAs(const QString &new_filename) {
|
||||||
|
filename = new_filename;
|
||||||
|
return save();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DBCFile::autoSave() {
|
||||||
|
return !filename.isEmpty() && writeContents(filename + AUTO_SAVE_EXTENSION);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCFile::cleanupAutoSaveFile() {
|
||||||
|
if (!filename.isEmpty()) {
|
||||||
|
QFile::remove(filename + AUTO_SAVE_EXTENSION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DBCFile::writeContents(const QString &fn) {
|
||||||
|
QFile file(fn);
|
||||||
|
if (file.open(QIODevice::WriteOnly)) {
|
||||||
|
file.write(generateDBC().toUtf8());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCFile::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) {
|
||||||
|
auto &m = msgs[id.address];
|
||||||
|
m.address = id.address;
|
||||||
|
m.name = name;
|
||||||
|
m.size = size;
|
||||||
|
m.transmitter = node.isEmpty() ? DEFAULT_NODE_NAME : node;
|
||||||
|
m.comment = comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
cabana::Msg *DBCFile::msg(uint32_t address) {
|
||||||
|
auto it = msgs.find(address);
|
||||||
|
return it != msgs.end() ? &it->second : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
cabana::Msg *DBCFile::msg(const QString &name) {
|
||||||
|
auto it = std::find_if(msgs.begin(), msgs.end(), [&name](auto &m) { return m.second.name == name; });
|
||||||
|
return it != msgs.end() ? &(it->second) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
int DBCFile::signalCount() {
|
||||||
|
return std::accumulate(msgs.cbegin(), msgs.cend(), 0, [](int &n, const auto &m) { return n + m.second.sigs.size(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCFile::parse(const QString &content) {
|
||||||
|
static QRegularExpression bo_regexp(R"(^BO_ (\w+) (\w+) *: (\w+) (\w+))");
|
||||||
|
static QRegularExpression sg_regexp(R"(^SG_ (\w+) : (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))");
|
||||||
|
static QRegularExpression sgm_regexp(R"(^SG_ (\w+) (\w+) *: (\d+)\|(\d+)@(\d+)([\+|\-]) \(([0-9.+\-eE]+),([0-9.+\-eE]+)\) \[([0-9.+\-eE]+)\|([0-9.+\-eE]+)\] \"(.*)\" (.*))");
|
||||||
|
static QRegularExpression msg_comment_regexp(R"(^CM_ BO_ *(\w+) *\"([^"]*)\"\s*;)");
|
||||||
|
static QRegularExpression sg_comment_regexp(R"(^CM_ SG_ *(\w+) *(\w+) *\"([^"]*)\"\s*;)");
|
||||||
|
static QRegularExpression val_regexp(R"(VAL_ (\w+) (\w+) (\s*[-+]?[0-9]+\s+\".+?\"[^;]*))");
|
||||||
|
|
||||||
|
int line_num = 0;
|
||||||
|
QString line;
|
||||||
|
auto dbc_assert = [&line_num, &line, this](bool condition, const QString &msg = "") {
|
||||||
|
if (!condition) throw std::runtime_error(QString("[%1:%2]%3: %4").arg(filename).arg(line_num).arg(msg).arg(line).toStdString());
|
||||||
|
};
|
||||||
|
auto get_sig = [this](uint32_t address, const QString &name) -> cabana::Signal * {
|
||||||
|
auto m = (cabana::Msg *)msg(address);
|
||||||
|
return m ? (cabana::Signal *)m->sig(name) : nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
msgs.clear();
|
||||||
|
QTextStream stream((QString *)&content);
|
||||||
|
cabana::Msg *current_msg = nullptr;
|
||||||
|
int multiplexor_cnt = 0;
|
||||||
|
while (!stream.atEnd()) {
|
||||||
|
++line_num;
|
||||||
|
QString raw_line = stream.readLine();
|
||||||
|
line = raw_line.trimmed();
|
||||||
|
if (line.startsWith("BO_ ")) {
|
||||||
|
multiplexor_cnt = 0;
|
||||||
|
auto match = bo_regexp.match(line);
|
||||||
|
dbc_assert(match.hasMatch());
|
||||||
|
auto address = match.captured(1).toUInt();
|
||||||
|
dbc_assert(msgs.count(address) == 0, QString("Duplicate message address: %1").arg(address));
|
||||||
|
current_msg = &msgs[address];
|
||||||
|
current_msg->address = address;
|
||||||
|
current_msg->name = match.captured(2);
|
||||||
|
current_msg->size = match.captured(3).toULong();
|
||||||
|
current_msg->transmitter = match.captured(4).trimmed();
|
||||||
|
} else if (line.startsWith("SG_ ")) {
|
||||||
|
int offset = 0;
|
||||||
|
auto match = sg_regexp.match(line);
|
||||||
|
if (!match.hasMatch()) {
|
||||||
|
match = sgm_regexp.match(line);
|
||||||
|
offset = 1;
|
||||||
|
}
|
||||||
|
dbc_assert(match.hasMatch());
|
||||||
|
dbc_assert(current_msg, "No Message");
|
||||||
|
auto name = match.captured(1);
|
||||||
|
dbc_assert(current_msg->sig(name) == nullptr, "Duplicate signal name");
|
||||||
|
cabana::Signal s{};
|
||||||
|
if (offset == 1) {
|
||||||
|
auto indicator = match.captured(2);
|
||||||
|
if (indicator == "M") {
|
||||||
|
// Only one signal within a single message can be the multiplexer switch.
|
||||||
|
dbc_assert(++multiplexor_cnt < 2, "Multiple multiplexor");
|
||||||
|
s.type = cabana::Signal::Type::Multiplexor;
|
||||||
|
} else {
|
||||||
|
s.type = cabana::Signal::Type::Multiplexed;
|
||||||
|
s.multiplex_value = indicator.mid(1).toInt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.name = name;
|
||||||
|
s.start_bit = match.captured(offset + 2).toInt();
|
||||||
|
s.size = match.captured(offset + 3).toInt();
|
||||||
|
s.is_little_endian = match.captured(offset + 4).toInt() == 1;
|
||||||
|
s.is_signed = match.captured(offset + 5) == "-";
|
||||||
|
s.factor = match.captured(offset + 6).toDouble();
|
||||||
|
s.offset = match.captured(offset + 7).toDouble();
|
||||||
|
s.min = match.captured(8 + offset).toDouble();
|
||||||
|
s.max = match.captured(9 + offset).toDouble();
|
||||||
|
s.unit = match.captured(10 + offset);
|
||||||
|
s.receiver_name = match.captured(11 + offset).trimmed();
|
||||||
|
|
||||||
|
current_msg->sigs.push_back(new cabana::Signal(s));
|
||||||
|
} else if (line.startsWith("VAL_ ")) {
|
||||||
|
auto match = val_regexp.match(line);
|
||||||
|
dbc_assert(match.hasMatch());
|
||||||
|
if (auto s = get_sig(match.captured(1).toUInt(), match.captured(2))) {
|
||||||
|
QStringList desc_list = match.captured(3).trimmed().split('"');
|
||||||
|
for (int i = 0; i < desc_list.size(); i += 2) {
|
||||||
|
auto val = desc_list[i].trimmed();
|
||||||
|
if (!val.isEmpty() && (i + 1) < desc_list.size()) {
|
||||||
|
auto desc = desc_list[i + 1].trimmed();
|
||||||
|
s->val_desc.push_back({val.toDouble(), desc});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (line.startsWith("CM_ BO_")) {
|
||||||
|
if (!line.endsWith("\";")) {
|
||||||
|
int pos = stream.pos() - raw_line.length() - 1;
|
||||||
|
line = content.mid(pos, content.indexOf("\";", pos));
|
||||||
|
}
|
||||||
|
auto match = msg_comment_regexp.match(line);
|
||||||
|
dbc_assert(match.hasMatch());
|
||||||
|
if (auto m = (cabana::Msg *)msg(match.captured(1).toUInt())) {
|
||||||
|
m->comment = match.captured(2).trimmed();
|
||||||
|
}
|
||||||
|
} else if (line.startsWith("CM_ SG_ ")) {
|
||||||
|
if (!line.endsWith("\";")) {
|
||||||
|
int pos = stream.pos() - raw_line.length() - 1;
|
||||||
|
line = content.mid(pos, content.indexOf("\";", pos));
|
||||||
|
}
|
||||||
|
auto match = sg_comment_regexp.match(line);
|
||||||
|
dbc_assert(match.hasMatch());
|
||||||
|
if (auto s = get_sig(match.captured(1).toUInt(), match.captured(2))) {
|
||||||
|
s->comment = match.captured(3).trimmed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto &[_, m] : msgs) {
|
||||||
|
m.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DBCFile::generateDBC() {
|
||||||
|
QString dbc_string, signal_comment, message_comment, val_desc;
|
||||||
|
for (const auto &[address, m] : msgs) {
|
||||||
|
const QString transmitter = m.transmitter.isEmpty() ? DEFAULT_NODE_NAME : m.transmitter;
|
||||||
|
dbc_string += QString("BO_ %1 %2: %3 %4\n").arg(address).arg(m.name).arg(m.size).arg(transmitter);
|
||||||
|
if (!m.comment.isEmpty()) {
|
||||||
|
message_comment += QString("CM_ BO_ %1 \"%2\";\n").arg(address).arg(m.comment);
|
||||||
|
}
|
||||||
|
for (auto sig : m.getSignals()) {
|
||||||
|
QString multiplexer_indicator;
|
||||||
|
if (sig->type == cabana::Signal::Type::Multiplexor) {
|
||||||
|
multiplexer_indicator = "M ";
|
||||||
|
} else if (sig->type == cabana::Signal::Type::Multiplexed) {
|
||||||
|
multiplexer_indicator = QString("m%1 ").arg(sig->multiplex_value);
|
||||||
|
}
|
||||||
|
dbc_string += QString(" SG_ %1 %2: %3|%4@%5%6 (%7,%8) [%9|%10] \"%11\" %12\n")
|
||||||
|
.arg(sig->name)
|
||||||
|
.arg(multiplexer_indicator)
|
||||||
|
.arg(sig->start_bit)
|
||||||
|
.arg(sig->size)
|
||||||
|
.arg(sig->is_little_endian ? '1' : '0')
|
||||||
|
.arg(sig->is_signed ? '-' : '+')
|
||||||
|
.arg(doubleToString(sig->factor))
|
||||||
|
.arg(doubleToString(sig->offset))
|
||||||
|
.arg(doubleToString(sig->min))
|
||||||
|
.arg(doubleToString(sig->max))
|
||||||
|
.arg(sig->unit)
|
||||||
|
.arg(sig->receiver_name.isEmpty() ? DEFAULT_NODE_NAME : sig->receiver_name);
|
||||||
|
if (!sig->comment.isEmpty()) {
|
||||||
|
signal_comment += QString("CM_ SG_ %1 %2 \"%3\";\n").arg(address).arg(sig->name).arg(sig->comment);
|
||||||
|
}
|
||||||
|
if (!sig->val_desc.isEmpty()) {
|
||||||
|
QStringList text;
|
||||||
|
for (auto &[val, desc] : sig->val_desc) {
|
||||||
|
text << QString("%1 \"%2\"").arg(val).arg(desc);
|
||||||
|
}
|
||||||
|
val_desc += QString("VAL_ %1 %2 %3;\n").arg(address).arg(sig->name).arg(text.join(" "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dbc_string += "\n";
|
||||||
|
}
|
||||||
|
return dbc_string + message_comment + signal_comment + val_desc;
|
||||||
|
}
|
||||||
41
tools/cabana/dbc/dbcfile.h
Executable file
41
tools/cabana/dbc/dbcfile.h
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbc.h"
|
||||||
|
|
||||||
|
const QString AUTO_SAVE_EXTENSION = ".tmp";
|
||||||
|
|
||||||
|
class DBCFile {
|
||||||
|
public:
|
||||||
|
DBCFile(const QString &dbc_file_name);
|
||||||
|
DBCFile(const QString &name, const QString &content);
|
||||||
|
~DBCFile() {}
|
||||||
|
|
||||||
|
bool save();
|
||||||
|
bool saveAs(const QString &new_filename);
|
||||||
|
bool autoSave();
|
||||||
|
bool writeContents(const QString &fn);
|
||||||
|
void cleanupAutoSaveFile();
|
||||||
|
QString generateDBC();
|
||||||
|
|
||||||
|
void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment);
|
||||||
|
inline void removeMsg(const MessageId &id) { msgs.erase(id.address); }
|
||||||
|
|
||||||
|
inline const std::map<uint32_t, cabana::Msg> &getMessages() const { return msgs; }
|
||||||
|
cabana::Msg *msg(uint32_t address);
|
||||||
|
cabana::Msg *msg(const QString &name);
|
||||||
|
inline cabana::Msg *msg(const MessageId &id) { return msg(id.address); }
|
||||||
|
|
||||||
|
int signalCount();
|
||||||
|
inline int msgCount() { return msgs.size(); }
|
||||||
|
inline QString name() { return name_.isEmpty() ? "untitled" : name_; }
|
||||||
|
inline bool isEmpty() { return (signalCount() == 0) && name_.isEmpty(); }
|
||||||
|
|
||||||
|
QString filename;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void parse(const QString &content);
|
||||||
|
std::map<uint32_t, cabana::Msg> msgs;
|
||||||
|
QString name_;
|
||||||
|
};
|
||||||
202
tools/cabana/dbc/dbcmanager.cc
Executable file
202
tools/cabana/dbc/dbcmanager.cc
Executable file
@@ -0,0 +1,202 @@
|
|||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <numeric>
|
||||||
|
|
||||||
|
bool DBCManager::open(const SourceSet &sources, const QString &dbc_file_name, QString *error) {
|
||||||
|
try {
|
||||||
|
auto it = std::find_if(dbc_files.begin(), dbc_files.end(),
|
||||||
|
[&](auto &f) { return f.second && f.second->filename == dbc_file_name; });
|
||||||
|
auto file = (it != dbc_files.end()) ? it->second : std::make_shared<DBCFile>(dbc_file_name);
|
||||||
|
for (auto s : sources) {
|
||||||
|
dbc_files[s] = file;
|
||||||
|
}
|
||||||
|
} catch (std::exception &e) {
|
||||||
|
if (error) *error = e.what();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit DBCFileChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DBCManager::open(const SourceSet &sources, const QString &name, const QString &content, QString *error) {
|
||||||
|
try {
|
||||||
|
auto file = std::make_shared<DBCFile>(name, content);
|
||||||
|
for (auto s : sources) {
|
||||||
|
dbc_files[s] = file;
|
||||||
|
}
|
||||||
|
} catch (std::exception &e) {
|
||||||
|
if (error) *error = e.what();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit DBCFileChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCManager::close(const SourceSet &sources) {
|
||||||
|
for (auto s : sources) {
|
||||||
|
dbc_files[s] = nullptr;
|
||||||
|
}
|
||||||
|
emit DBCFileChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCManager::close(DBCFile *dbc_file) {
|
||||||
|
for (auto &[_, f] : dbc_files) {
|
||||||
|
if (f.get() == dbc_file) f = nullptr;
|
||||||
|
}
|
||||||
|
emit DBCFileChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCManager::closeAll() {
|
||||||
|
dbc_files.clear();
|
||||||
|
emit DBCFileChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCManager::addSignal(const MessageId &id, const cabana::Signal &sig) {
|
||||||
|
if (auto m = msg(id)) {
|
||||||
|
if (auto s = m->addSignal(sig)) {
|
||||||
|
emit signalAdded(id, s);
|
||||||
|
emit maskUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCManager::updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig) {
|
||||||
|
if (auto m = msg(id)) {
|
||||||
|
if (auto s = m->updateSignal(sig_name, sig)) {
|
||||||
|
emit signalUpdated(s);
|
||||||
|
emit maskUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCManager::removeSignal(const MessageId &id, const QString &sig_name) {
|
||||||
|
if (auto m = msg(id)) {
|
||||||
|
if (auto s = m->sig(sig_name)) {
|
||||||
|
emit signalRemoved(s);
|
||||||
|
m->removeSignal(sig_name);
|
||||||
|
emit maskUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCManager::updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment) {
|
||||||
|
auto dbc_file = findDBCFile(id);
|
||||||
|
assert(dbc_file); // This should be impossible
|
||||||
|
dbc_file->updateMsg(id, name, size, node, comment);
|
||||||
|
emit msgUpdated(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DBCManager::removeMsg(const MessageId &id) {
|
||||||
|
auto dbc_file = findDBCFile(id);
|
||||||
|
assert(dbc_file); // This should be impossible
|
||||||
|
dbc_file->removeMsg(id);
|
||||||
|
emit msgRemoved(id);
|
||||||
|
emit maskUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DBCManager::newMsgName(const MessageId &id) {
|
||||||
|
return QString("NEW_MSG_") + QString::number(id.address, 16).toUpper();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString DBCManager::newSignalName(const MessageId &id) {
|
||||||
|
auto m = msg(id);
|
||||||
|
return m ? m->newSignalName() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<uint8_t> &DBCManager::mask(const MessageId &id) {
|
||||||
|
static std::vector<uint8_t> empty_mask;
|
||||||
|
auto m = msg(id);
|
||||||
|
return m ? m->mask : empty_mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::map<uint32_t, cabana::Msg> &DBCManager::getMessages(uint8_t source) {
|
||||||
|
static std::map<uint32_t, cabana::Msg> empty_msgs;
|
||||||
|
auto dbc_file = findDBCFile(source);
|
||||||
|
return dbc_file ? dbc_file->getMessages() : empty_msgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
cabana::Msg *DBCManager::msg(const MessageId &id) {
|
||||||
|
auto dbc_file = findDBCFile(id);
|
||||||
|
return dbc_file ? dbc_file->msg(id) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
cabana::Msg *DBCManager::msg(uint8_t source, const QString &name) {
|
||||||
|
auto dbc_file = findDBCFile(source);
|
||||||
|
return dbc_file ? dbc_file->msg(name) : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList DBCManager::signalNames() {
|
||||||
|
// Used for autocompletion
|
||||||
|
QStringList ret;
|
||||||
|
for (auto &f : allDBCFiles()) {
|
||||||
|
for (auto &[_, m] : f->getMessages()) {
|
||||||
|
for (auto sig : m.getSignals()) {
|
||||||
|
ret << sig->name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ret.sort();
|
||||||
|
ret.removeDuplicates();
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
int DBCManager::signalCount(const MessageId &id) {
|
||||||
|
auto m = msg(id);
|
||||||
|
return m ? m->sigs.size() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int DBCManager::signalCount() {
|
||||||
|
auto files = allDBCFiles();
|
||||||
|
return std::accumulate(files.cbegin(), files.cend(), 0, [](int &n, auto &f) { return n + f->signalCount(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
int DBCManager::msgCount() {
|
||||||
|
auto files = allDBCFiles();
|
||||||
|
return std::accumulate(files.cbegin(), files.cend(), 0, [](int &n, auto &f) { return n + f->msgCount(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
int DBCManager::dbcCount() {
|
||||||
|
return allDBCFiles().size();
|
||||||
|
}
|
||||||
|
|
||||||
|
int DBCManager::nonEmptyDBCCount() {
|
||||||
|
auto files = allDBCFiles();
|
||||||
|
return std::count_if(files.cbegin(), files.cend(), [](auto &f) { return !f->isEmpty(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
DBCFile *DBCManager::findDBCFile(const uint8_t source) {
|
||||||
|
// Find DBC file that matches id.source, fall back to SOURCE_ALL if no specific DBC is found
|
||||||
|
auto it = dbc_files.count(source) ? dbc_files.find(source) : dbc_files.find(-1);
|
||||||
|
return it != dbc_files.end() ? it->second.get() : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::set<DBCFile *> DBCManager::allDBCFiles() {
|
||||||
|
std::set<DBCFile *> files;
|
||||||
|
for (const auto &[_, f] : dbc_files) {
|
||||||
|
if (f) files.insert(f.get());
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SourceSet DBCManager::sources(const DBCFile *dbc_file) const {
|
||||||
|
SourceSet sources;
|
||||||
|
for (auto &[s, f] : dbc_files) {
|
||||||
|
if (f.get() == dbc_file) sources.insert(s);
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString toString(const SourceSet &ss) {
|
||||||
|
return std::accumulate(ss.cbegin(), ss.cend(), QString(), [](QString str, int source) {
|
||||||
|
if (!str.isEmpty()) str += ", ";
|
||||||
|
return str + (source == -1 ? QStringLiteral("all") : QString::number(source));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DBCManager *dbc() {
|
||||||
|
static DBCManager dbc_manager(nullptr);
|
||||||
|
return &dbc_manager;
|
||||||
|
}
|
||||||
74
tools/cabana/dbc/dbcmanager.h
Executable file
74
tools/cabana/dbc/dbcmanager.h
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <memory>
|
||||||
|
#include <map>
|
||||||
|
#include <set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbcfile.h"
|
||||||
|
|
||||||
|
typedef std::set<int> SourceSet;
|
||||||
|
const SourceSet SOURCE_ALL = {-1};
|
||||||
|
const int INVALID_SOURCE = 0xff;
|
||||||
|
inline bool operator<(const std::shared_ptr<DBCFile> &l, const std::shared_ptr<DBCFile> &r) { return l.get() < r.get(); }
|
||||||
|
|
||||||
|
class DBCManager : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
DBCManager(QObject *parent) : QObject(parent) {}
|
||||||
|
~DBCManager() {}
|
||||||
|
bool open(const SourceSet &sources, const QString &dbc_file_name, QString *error = nullptr);
|
||||||
|
bool open(const SourceSet &sources, const QString &name, const QString &content, QString *error = nullptr);
|
||||||
|
void close(const SourceSet &sources);
|
||||||
|
void close(DBCFile *dbc_file);
|
||||||
|
void closeAll();
|
||||||
|
|
||||||
|
void addSignal(const MessageId &id, const cabana::Signal &sig);
|
||||||
|
void updateSignal(const MessageId &id, const QString &sig_name, const cabana::Signal &sig);
|
||||||
|
void removeSignal(const MessageId &id, const QString &sig_name);
|
||||||
|
|
||||||
|
void updateMsg(const MessageId &id, const QString &name, uint32_t size, const QString &node, const QString &comment);
|
||||||
|
void removeMsg(const MessageId &id);
|
||||||
|
|
||||||
|
QString newMsgName(const MessageId &id);
|
||||||
|
QString newSignalName(const MessageId &id);
|
||||||
|
const std::vector<uint8_t>& mask(const MessageId &id);
|
||||||
|
|
||||||
|
const std::map<uint32_t, cabana::Msg> &getMessages(uint8_t source);
|
||||||
|
cabana::Msg *msg(const MessageId &id);
|
||||||
|
cabana::Msg* msg(uint8_t source, const QString &name);
|
||||||
|
|
||||||
|
QStringList signalNames();
|
||||||
|
int signalCount(const MessageId &id);
|
||||||
|
int signalCount();
|
||||||
|
int msgCount();
|
||||||
|
int dbcCount();
|
||||||
|
int nonEmptyDBCCount();
|
||||||
|
|
||||||
|
const SourceSet sources(const DBCFile *dbc_file) const;
|
||||||
|
DBCFile *findDBCFile(const uint8_t source);
|
||||||
|
inline DBCFile *findDBCFile(const MessageId &id) { return findDBCFile(id.source); }
|
||||||
|
std::set<DBCFile *> allDBCFiles();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void signalAdded(MessageId id, const cabana::Signal *sig);
|
||||||
|
void signalRemoved(const cabana::Signal *sig);
|
||||||
|
void signalUpdated(const cabana::Signal *sig);
|
||||||
|
void msgUpdated(MessageId id);
|
||||||
|
void msgRemoved(MessageId id);
|
||||||
|
void DBCFileChanged();
|
||||||
|
void maskUpdated();
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::map<int, std::shared_ptr<DBCFile>> dbc_files;
|
||||||
|
};
|
||||||
|
|
||||||
|
DBCManager *dbc();
|
||||||
|
|
||||||
|
QString toString(const SourceSet &ss);
|
||||||
|
inline QString msgName(const MessageId &id) {
|
||||||
|
auto msg = dbc()->msg(id);
|
||||||
|
return msg ? msg->name : UNTITLED;
|
||||||
|
}
|
||||||
24
tools/cabana/dbc/generate_dbc_json.py
Executable file
24
tools/cabana/dbc/generate_dbc_json.py
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
from openpilot.selfdrive.car.car_helpers import get_interface_attr
|
||||||
|
|
||||||
|
|
||||||
|
def generate_dbc_json() -> str:
|
||||||
|
all_cars_by_brand = get_interface_attr("CAR_INFO")
|
||||||
|
all_dbcs_by_brand = get_interface_attr("DBC")
|
||||||
|
dbc_map = {car: all_dbcs_by_brand[brand][car]['pt'] for brand, cars in all_cars_by_brand.items() for car in cars if car != 'mock'}
|
||||||
|
return json.dumps(dict(sorted(dbc_map.items())), indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Generate mapping for all car fingerprints to DBC names and outputs json file",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||||
|
|
||||||
|
parser.add_argument("--out", required=True, help="Generated json filepath")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
with open(args.out, 'w') as f:
|
||||||
|
f.write(generate_dbc_json())
|
||||||
|
print(f"Generated and written to {args.out}")
|
||||||
276
tools/cabana/detailwidget.cc
Executable file
276
tools/cabana/detailwidget.cc
Executable file
@@ -0,0 +1,276 @@
|
|||||||
|
#include "tools/cabana/detailwidget.h"
|
||||||
|
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QSpacerItem>
|
||||||
|
|
||||||
|
#include "tools/cabana/commands.h"
|
||||||
|
#include "tools/cabana/mainwin.h"
|
||||||
|
|
||||||
|
// DetailWidget
|
||||||
|
|
||||||
|
DetailWidget::DetailWidget(ChartsWidget *charts, QWidget *parent) : charts(charts), QWidget(parent) {
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// tabbar
|
||||||
|
tabbar = new TabBar(this);
|
||||||
|
tabbar->setUsesScrollButtons(true);
|
||||||
|
tabbar->setAutoHide(true);
|
||||||
|
tabbar->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
|
main_layout->addWidget(tabbar);
|
||||||
|
|
||||||
|
// message title
|
||||||
|
QHBoxLayout *title_layout = new QHBoxLayout();
|
||||||
|
title_layout->setContentsMargins(3, 6, 3, 0);
|
||||||
|
auto spacer = new QSpacerItem(0, 1);
|
||||||
|
title_layout->addItem(spacer);
|
||||||
|
title_layout->addWidget(name_label = new ElidedLabel(this), 1);
|
||||||
|
name_label->setStyleSheet("QLabel{font-weight:bold;}");
|
||||||
|
name_label->setAlignment(Qt::AlignCenter);
|
||||||
|
auto edit_btn = new ToolButton("pencil", tr("Edit Message"));
|
||||||
|
title_layout->addWidget(edit_btn);
|
||||||
|
title_layout->addWidget(remove_btn = new ToolButton("x-lg", tr("Remove Message")));
|
||||||
|
spacer->changeSize(edit_btn->sizeHint().width() * 2 + 9, 1);
|
||||||
|
main_layout->addLayout(title_layout);
|
||||||
|
|
||||||
|
// warning
|
||||||
|
warning_widget = new QWidget(this);
|
||||||
|
QHBoxLayout *warning_hlayout = new QHBoxLayout(warning_widget);
|
||||||
|
warning_hlayout->addWidget(warning_icon = new QLabel(this), 0, Qt::AlignTop);
|
||||||
|
warning_hlayout->addWidget(warning_label = new QLabel(this), 1, Qt::AlignLeft);
|
||||||
|
warning_widget->hide();
|
||||||
|
main_layout->addWidget(warning_widget);
|
||||||
|
|
||||||
|
// msg widget
|
||||||
|
splitter = new QSplitter(Qt::Vertical, this);
|
||||||
|
splitter->addWidget(binary_view = new BinaryView(this));
|
||||||
|
splitter->addWidget(signal_view = new SignalView(charts, this));
|
||||||
|
binary_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum);
|
||||||
|
signal_view->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
|
||||||
|
splitter->setStretchFactor(0, 0);
|
||||||
|
splitter->setStretchFactor(1, 1);
|
||||||
|
|
||||||
|
tab_widget = new QTabWidget(this);
|
||||||
|
tab_widget->setStyleSheet("QTabWidget::pane {border: none; margin-bottom: -2px;}");
|
||||||
|
tab_widget->setTabPosition(QTabWidget::South);
|
||||||
|
tab_widget->addTab(splitter, utils::icon("file-earmark-ruled"), "&Msg");
|
||||||
|
tab_widget->addTab(history_log = new LogsWidget(this), utils::icon("stopwatch"), "&Logs");
|
||||||
|
main_layout->addWidget(tab_widget);
|
||||||
|
|
||||||
|
QObject::connect(edit_btn, &QToolButton::clicked, this, &DetailWidget::editMsg);
|
||||||
|
QObject::connect(remove_btn, &QToolButton::clicked, this, &DetailWidget::removeMsg);
|
||||||
|
QObject::connect(binary_view, &BinaryView::signalHovered, signal_view, &SignalView::signalHovered);
|
||||||
|
QObject::connect(binary_view, &BinaryView::signalClicked, [this](const cabana::Signal *s) { signal_view->selectSignal(s, true); });
|
||||||
|
QObject::connect(binary_view, &BinaryView::editSignal, signal_view->model, &SignalModel::saveSignal);
|
||||||
|
QObject::connect(binary_view, &BinaryView::showChart, charts, &ChartsWidget::showChart);
|
||||||
|
QObject::connect(signal_view, &SignalView::showChart, charts, &ChartsWidget::showChart);
|
||||||
|
QObject::connect(signal_view, &SignalView::highlight, binary_view, &BinaryView::highlight);
|
||||||
|
QObject::connect(tab_widget, &QTabWidget::currentChanged, [this]() { updateState(); });
|
||||||
|
QObject::connect(can, &AbstractStream::msgsReceived, this, &DetailWidget::updateState);
|
||||||
|
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &DetailWidget::refresh);
|
||||||
|
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &DetailWidget::refresh);
|
||||||
|
QObject::connect(tabbar, &QTabBar::customContextMenuRequested, this, &DetailWidget::showTabBarContextMenu);
|
||||||
|
QObject::connect(tabbar, &QTabBar::currentChanged, [this](int index) {
|
||||||
|
if (index != -1) {
|
||||||
|
setMessage(tabbar->tabData(index).value<MessageId>());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
QObject::connect(tabbar, &QTabBar::tabCloseRequested, tabbar, &QTabBar::removeTab);
|
||||||
|
QObject::connect(charts, &ChartsWidget::seriesChanged, signal_view, &SignalView::updateChartState);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailWidget::showTabBarContextMenu(const QPoint &pt) {
|
||||||
|
int index = tabbar->tabAt(pt);
|
||||||
|
if (index >= 0) {
|
||||||
|
QMenu menu(this);
|
||||||
|
menu.addAction(tr("Close Other Tabs"));
|
||||||
|
if (menu.exec(tabbar->mapToGlobal(pt))) {
|
||||||
|
tabbar->moveTab(index, 0);
|
||||||
|
tabbar->setCurrentIndex(0);
|
||||||
|
while (tabbar->count() > 1) {
|
||||||
|
tabbar->removeTab(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailWidget::setMessage(const MessageId &message_id) {
|
||||||
|
if (std::exchange(msg_id, message_id) == message_id) return;
|
||||||
|
|
||||||
|
tabbar->blockSignals(true);
|
||||||
|
int index = tabbar->count() - 1;
|
||||||
|
for (/**/; index >= 0; --index) {
|
||||||
|
if (tabbar->tabData(index).value<MessageId>() == message_id) break;
|
||||||
|
}
|
||||||
|
if (index == -1) {
|
||||||
|
index = tabbar->addTab(message_id.toString());
|
||||||
|
tabbar->setTabData(index, QVariant::fromValue(message_id));
|
||||||
|
tabbar->setTabToolTip(index, msgName(message_id));
|
||||||
|
}
|
||||||
|
tabbar->setCurrentIndex(index);
|
||||||
|
tabbar->blockSignals(false);
|
||||||
|
|
||||||
|
setUpdatesEnabled(false);
|
||||||
|
signal_view->setMessage(msg_id);
|
||||||
|
binary_view->setMessage(msg_id);
|
||||||
|
history_log->setMessage(msg_id);
|
||||||
|
refresh();
|
||||||
|
setUpdatesEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailWidget::refresh() {
|
||||||
|
QStringList warnings;
|
||||||
|
auto msg = dbc()->msg(msg_id);
|
||||||
|
if (msg) {
|
||||||
|
if (msg_id.source == INVALID_SOURCE) {
|
||||||
|
warnings.push_back(tr("No messages received."));
|
||||||
|
} else if (msg->size != can->lastMessage(msg_id).dat.size()) {
|
||||||
|
warnings.push_back(tr("Message size (%1) is incorrect.").arg(msg->size));
|
||||||
|
}
|
||||||
|
for (auto s : binary_view->getOverlappingSignals()) {
|
||||||
|
warnings.push_back(tr("%1 has overlapping bits.").arg(s->name));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warnings.push_back(tr("Drag-Select in binary view to create new signal."));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString msg_name = msg ? QString("%1 (%2)").arg(msg->name, msg->transmitter) : msgName(msg_id);
|
||||||
|
name_label->setText(msg_name);
|
||||||
|
name_label->setToolTip(msg_name);
|
||||||
|
remove_btn->setEnabled(msg != nullptr);
|
||||||
|
|
||||||
|
if (!warnings.isEmpty()) {
|
||||||
|
warning_label->setText(warnings.join('\n'));
|
||||||
|
warning_icon->setPixmap(utils::icon(msg ? "exclamation-triangle" : "info-circle"));
|
||||||
|
}
|
||||||
|
warning_widget->setVisible(!warnings.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailWidget::updateState(const std::set<MessageId> *msgs) {
|
||||||
|
if ((msgs && !msgs->count(msg_id)))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (tab_widget->currentIndex() == 0)
|
||||||
|
binary_view->updateState();
|
||||||
|
else
|
||||||
|
history_log->updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailWidget::editMsg() {
|
||||||
|
auto msg = dbc()->msg(msg_id);
|
||||||
|
int size = msg ? msg->size : can->lastMessage(msg_id).dat.size();
|
||||||
|
EditMessageDialog dlg(msg_id, msgName(msg_id), size, this);
|
||||||
|
if (dlg.exec()) {
|
||||||
|
UndoStack::push(new EditMsgCommand(msg_id, dlg.name_edit->text().trimmed(), dlg.size_spin->value(),
|
||||||
|
dlg.node->text().trimmed(), dlg.comment_edit->toPlainText().trimmed()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetailWidget::removeMsg() {
|
||||||
|
UndoStack::push(new RemoveMsgCommand(msg_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditMessageDialog
|
||||||
|
|
||||||
|
EditMessageDialog::EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent)
|
||||||
|
: original_name(title), msg_id(msg_id), QDialog(parent) {
|
||||||
|
setWindowTitle(tr("Edit message: %1").arg(msg_id.toString()));
|
||||||
|
QFormLayout *form_layout = new QFormLayout(this);
|
||||||
|
|
||||||
|
form_layout->addRow("", error_label = new QLabel);
|
||||||
|
error_label->setVisible(false);
|
||||||
|
form_layout->addRow(tr("Name"), name_edit = new QLineEdit(title, this));
|
||||||
|
name_edit->setValidator(new NameValidator(name_edit));
|
||||||
|
|
||||||
|
form_layout->addRow(tr("Size"), size_spin = new QSpinBox(this));
|
||||||
|
// TODO: limit the maximum?
|
||||||
|
size_spin->setMinimum(1);
|
||||||
|
size_spin->setValue(size);
|
||||||
|
|
||||||
|
form_layout->addRow(tr("Node"), node = new QLineEdit(this));
|
||||||
|
node->setValidator(new NameValidator(name_edit));
|
||||||
|
form_layout->addRow(tr("Comment"), comment_edit = new QTextEdit(this));
|
||||||
|
form_layout->addRow(btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel));
|
||||||
|
|
||||||
|
if (auto msg = dbc()->msg(msg_id)) {
|
||||||
|
node->setText(msg->transmitter);
|
||||||
|
comment_edit->setText(msg->comment);
|
||||||
|
}
|
||||||
|
validateName(name_edit->text());
|
||||||
|
setFixedWidth(parent->width() * 0.9);
|
||||||
|
connect(name_edit, &QLineEdit::textEdited, this, &EditMessageDialog::validateName);
|
||||||
|
connect(btn_box, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||||
|
connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EditMessageDialog::validateName(const QString &text) {
|
||||||
|
bool valid = text.compare(UNTITLED, Qt::CaseInsensitive) != 0;
|
||||||
|
error_label->setVisible(false);
|
||||||
|
if (!text.isEmpty() && valid && text != original_name) {
|
||||||
|
valid = dbc()->msg(msg_id.source, text) == nullptr;
|
||||||
|
if (!valid) {
|
||||||
|
error_label->setText(tr("Name already exists"));
|
||||||
|
error_label->setVisible(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
btn_box->button(QDialogButtonBox::Ok)->setEnabled(valid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CenterWidget
|
||||||
|
|
||||||
|
CenterWidget::CenterWidget(QWidget *parent) : QWidget(parent) {
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
main_layout->addWidget(welcome_widget = createWelcomeWidget());
|
||||||
|
}
|
||||||
|
|
||||||
|
void CenterWidget::setMessage(const MessageId &msg_id) {
|
||||||
|
if (!detail_widget) {
|
||||||
|
delete welcome_widget;
|
||||||
|
welcome_widget = nullptr;
|
||||||
|
layout()->addWidget(detail_widget = new DetailWidget(((MainWindow*)parentWidget())->charts_widget, this));
|
||||||
|
}
|
||||||
|
detail_widget->setMessage(msg_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CenterWidget::clear() {
|
||||||
|
delete detail_widget;
|
||||||
|
detail_widget = nullptr;
|
||||||
|
if (!welcome_widget) {
|
||||||
|
layout()->addWidget(welcome_widget = createWelcomeWidget());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget *CenterWidget::createWelcomeWidget() {
|
||||||
|
QWidget *w = new QWidget(this);
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(w);
|
||||||
|
main_layout->addStretch(0);
|
||||||
|
QLabel *logo = new QLabel("CABANA");
|
||||||
|
logo->setAlignment(Qt::AlignCenter);
|
||||||
|
logo->setStyleSheet("font-size:50px;font-weight:bold;");
|
||||||
|
main_layout->addWidget(logo);
|
||||||
|
|
||||||
|
auto newShortcutRow = [](const QString &title, const QString &key) {
|
||||||
|
QHBoxLayout *hlayout = new QHBoxLayout();
|
||||||
|
auto btn = new QToolButton();
|
||||||
|
btn->setText(key);
|
||||||
|
btn->setEnabled(false);
|
||||||
|
hlayout->addWidget(new QLabel(title), 0, Qt::AlignRight);
|
||||||
|
hlayout->addWidget(btn, 0, Qt::AlignLeft);
|
||||||
|
return hlayout;
|
||||||
|
};
|
||||||
|
|
||||||
|
auto lb = new QLabel(tr("<-Select a message to view details"));
|
||||||
|
lb->setAlignment(Qt::AlignHCenter);
|
||||||
|
main_layout->addWidget(lb);
|
||||||
|
main_layout->addLayout(newShortcutRow("Pause", "Space"));
|
||||||
|
main_layout->addLayout(newShortcutRow("Help", "F1"));
|
||||||
|
main_layout->addLayout(newShortcutRow("WhatsThis", "Shift+F1"));
|
||||||
|
main_layout->addStretch(0);
|
||||||
|
|
||||||
|
w->setStyleSheet("QLabel{color:darkGray;}");
|
||||||
|
w->setBackgroundRole(QPalette::Base);
|
||||||
|
w->setAutoFillBackground(true);
|
||||||
|
return w;
|
||||||
|
}
|
||||||
69
tools/cabana/detailwidget.h
Executable file
69
tools/cabana/detailwidget.h
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QTabWidget>
|
||||||
|
#include <QTextEdit>
|
||||||
|
#include <set>
|
||||||
|
|
||||||
|
#include "selfdrive/ui/qt/widgets/controls.h"
|
||||||
|
#include "tools/cabana/binaryview.h"
|
||||||
|
#include "tools/cabana/chart/chartswidget.h"
|
||||||
|
#include "tools/cabana/historylog.h"
|
||||||
|
#include "tools/cabana/signalview.h"
|
||||||
|
|
||||||
|
class EditMessageDialog : public QDialog {
|
||||||
|
public:
|
||||||
|
EditMessageDialog(const MessageId &msg_id, const QString &title, int size, QWidget *parent);
|
||||||
|
void validateName(const QString &text);
|
||||||
|
|
||||||
|
MessageId msg_id;
|
||||||
|
QString original_name;
|
||||||
|
QDialogButtonBox *btn_box;
|
||||||
|
QLineEdit *name_edit;
|
||||||
|
QLineEdit *node;
|
||||||
|
QTextEdit *comment_edit;
|
||||||
|
QLabel *error_label;
|
||||||
|
QSpinBox *size_spin;
|
||||||
|
};
|
||||||
|
|
||||||
|
class DetailWidget : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
DetailWidget(ChartsWidget *charts, QWidget *parent);
|
||||||
|
void setMessage(const MessageId &message_id);
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void showTabBarContextMenu(const QPoint &pt);
|
||||||
|
void editMsg();
|
||||||
|
void removeMsg();
|
||||||
|
void updateState(const std::set<MessageId> *msgs = nullptr);
|
||||||
|
|
||||||
|
MessageId msg_id;
|
||||||
|
QLabel *warning_icon, *warning_label;
|
||||||
|
ElidedLabel *name_label;
|
||||||
|
QWidget *warning_widget;
|
||||||
|
TabBar *tabbar;
|
||||||
|
QTabWidget *tab_widget;
|
||||||
|
QToolButton *remove_btn;
|
||||||
|
LogsWidget *history_log;
|
||||||
|
BinaryView *binary_view;
|
||||||
|
SignalView *signal_view;
|
||||||
|
ChartsWidget *charts;
|
||||||
|
QSplitter *splitter;
|
||||||
|
};
|
||||||
|
|
||||||
|
class CenterWidget : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
CenterWidget(QWidget *parent);
|
||||||
|
void setMessage(const MessageId &msg_id);
|
||||||
|
void clear();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QWidget *createWelcomeWidget();
|
||||||
|
DetailWidget *detail_widget = nullptr;
|
||||||
|
QWidget *welcome_widget = nullptr;
|
||||||
|
};
|
||||||
306
tools/cabana/historylog.cc
Executable file
306
tools/cabana/historylog.cc
Executable file
@@ -0,0 +1,306 @@
|
|||||||
|
#include "tools/cabana/historylog.h"
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "tools/cabana/commands.h"
|
||||||
|
|
||||||
|
QVariant HistoryLogModel::data(const QModelIndex &index, int role) const {
|
||||||
|
const bool show_signals = display_signals_mode && sigs.size() > 0;
|
||||||
|
const auto &m = messages[index.row()];
|
||||||
|
if (role == Qt::DisplayRole) {
|
||||||
|
if (index.column() == 0) {
|
||||||
|
return QString::number((m.mono_time / (double)1e9) - can->routeStartTime(), 'f', 2);
|
||||||
|
}
|
||||||
|
int i = index.column() - 1;
|
||||||
|
return show_signals ? QString::number(m.sig_values[i], 'f', sigs[i]->precision) : QString();
|
||||||
|
} else if (role == ColorsRole) {
|
||||||
|
return QVariant::fromValue((void *)(&m.colors));
|
||||||
|
} else if (role == BytesRole) {
|
||||||
|
return QVariant::fromValue((void *)(&m.data));
|
||||||
|
} else if (role == Qt::TextAlignmentRole) {
|
||||||
|
return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryLogModel::setMessage(const MessageId &message_id) {
|
||||||
|
msg_id = message_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryLogModel::refresh(bool fetch_message) {
|
||||||
|
beginResetModel();
|
||||||
|
sigs.clear();
|
||||||
|
if (auto dbc_msg = dbc()->msg(msg_id)) {
|
||||||
|
sigs = dbc_msg->getSignals();
|
||||||
|
}
|
||||||
|
last_fetch_time = 0;
|
||||||
|
has_more_data = true;
|
||||||
|
messages.clear();
|
||||||
|
hex_colors = {};
|
||||||
|
if (fetch_message) {
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||||
|
if (orientation == Qt::Horizontal) {
|
||||||
|
const bool show_signals = display_signals_mode && !sigs.empty();
|
||||||
|
if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
|
||||||
|
if (section == 0) {
|
||||||
|
return "Time";
|
||||||
|
}
|
||||||
|
if (show_signals) {
|
||||||
|
QString name = sigs[section - 1]->name;
|
||||||
|
if (!sigs[section - 1]->unit.isEmpty()) {
|
||||||
|
name += QString(" (%1)").arg(sigs[section - 1]->unit);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
} else {
|
||||||
|
return "Data";
|
||||||
|
}
|
||||||
|
} else if (role == Qt::BackgroundRole && section > 0 && show_signals) {
|
||||||
|
// Alpha-blend the signal color with the background to ensure contrast
|
||||||
|
QColor sigColor = sigs[section - 1]->color;
|
||||||
|
sigColor.setAlpha(128);
|
||||||
|
return QBrush(sigColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryLogModel::setDynamicMode(int state) {
|
||||||
|
dynamic_mode = state != 0;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryLogModel::setDisplayType(int type) {
|
||||||
|
display_signals_mode = type == 0;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryLogModel::segmentsMerged() {
|
||||||
|
if (!dynamic_mode) {
|
||||||
|
has_more_data = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryLogModel::setFilter(int sig_idx, const QString &value, std::function<bool(double, double)> cmp) {
|
||||||
|
filter_sig_idx = sig_idx;
|
||||||
|
filter_value = value.toDouble();
|
||||||
|
filter_cmp = value.isEmpty() ? nullptr : cmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryLogModel::updateState() {
|
||||||
|
uint64_t current_time = (can->lastMessage(msg_id).ts + can->routeStartTime()) * 1e9 + 1;
|
||||||
|
auto new_msgs = dynamic_mode ? fetchData(current_time, last_fetch_time) : fetchData(0);
|
||||||
|
if (!new_msgs.empty()) {
|
||||||
|
beginInsertRows({}, 0, new_msgs.size() - 1);
|
||||||
|
messages.insert(messages.begin(), std::move_iterator(new_msgs.begin()), std::move_iterator(new_msgs.end()));
|
||||||
|
endInsertRows();
|
||||||
|
}
|
||||||
|
has_more_data = new_msgs.size() >= batch_size;
|
||||||
|
last_fetch_time = current_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HistoryLogModel::fetchMore(const QModelIndex &parent) {
|
||||||
|
if (!messages.empty()) {
|
||||||
|
auto new_msgs = fetchData(messages.back().mono_time);
|
||||||
|
if (!new_msgs.empty()) {
|
||||||
|
beginInsertRows({}, messages.size(), messages.size() + new_msgs.size() - 1);
|
||||||
|
messages.insert(messages.end(), std::move_iterator(new_msgs.begin()), std::move_iterator(new_msgs.end()));
|
||||||
|
endInsertRows();
|
||||||
|
}
|
||||||
|
has_more_data = new_msgs.size() >= batch_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template <class InputIt>
|
||||||
|
std::deque<HistoryLogModel::Message> HistoryLogModel::fetchData(InputIt first, InputIt last, uint64_t min_time) {
|
||||||
|
std::deque<HistoryLogModel::Message> msgs;
|
||||||
|
std::vector<double> values(sigs.size());
|
||||||
|
for (; first != last && (*first)->mono_time > min_time; ++first) {
|
||||||
|
const CanEvent *e = *first;
|
||||||
|
for (int i = 0; i < sigs.size(); ++i) {
|
||||||
|
sigs[i]->getValue(e->dat, e->size, &values[i]);
|
||||||
|
}
|
||||||
|
if (!filter_cmp || filter_cmp(values[filter_sig_idx], filter_value)) {
|
||||||
|
auto &m = msgs.emplace_back();
|
||||||
|
m.mono_time = e->mono_time;
|
||||||
|
m.data.assign(e->dat, e->dat + e->size);
|
||||||
|
m.sig_values = values;
|
||||||
|
if (msgs.size() >= batch_size && min_time == 0) {
|
||||||
|
return msgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::deque<HistoryLogModel::Message> HistoryLogModel::fetchData(uint64_t from_time, uint64_t min_time) {
|
||||||
|
const auto &events = can->events(msg_id);
|
||||||
|
const auto freq = can->lastMessage(msg_id).freq;
|
||||||
|
const bool update_colors = !display_signals_mode || sigs.empty();
|
||||||
|
const std::vector<uint8_t> no_mask;
|
||||||
|
const auto speed = can->getSpeed();
|
||||||
|
if (dynamic_mode) {
|
||||||
|
auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) {
|
||||||
|
return ts > e->mono_time;
|
||||||
|
});
|
||||||
|
auto msgs = fetchData(first, events.rend(), min_time);
|
||||||
|
if (update_colors && (min_time > 0 || messages.empty())) {
|
||||||
|
for (auto it = msgs.rbegin(); it != msgs.rend(); ++it) {
|
||||||
|
hex_colors.compute(msg_id, it->data.data(), it->data.size(), it->mono_time / (double)1e9, speed, no_mask, freq);
|
||||||
|
it->colors = hex_colors.colors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msgs;
|
||||||
|
} else {
|
||||||
|
assert(min_time == 0);
|
||||||
|
auto first = std::upper_bound(events.cbegin(), events.cend(), from_time, CompareCanEvent());
|
||||||
|
auto msgs = fetchData(first, events.cend(), 0);
|
||||||
|
if (update_colors) {
|
||||||
|
for (auto it = msgs.begin(); it != msgs.end(); ++it) {
|
||||||
|
hex_colors.compute(msg_id, it->data.data(), it->data.size(), it->mono_time / (double)1e9, speed, no_mask, freq);
|
||||||
|
it->colors = hex_colors.colors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return msgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeaderView
|
||||||
|
|
||||||
|
QSize HeaderView::sectionSizeFromContents(int logicalIndex) const {
|
||||||
|
static const QSize time_col_size = fontMetrics().boundingRect({0, 0, 200, 200}, defaultAlignment(), "000000.000").size() + QSize(10, 6);
|
||||||
|
if (logicalIndex == 0) {
|
||||||
|
return time_col_size;
|
||||||
|
} else {
|
||||||
|
int default_size = qMax(100, (rect().width() - time_col_size.width()) / (model()->columnCount() - 1));
|
||||||
|
QString text = model()->headerData(logicalIndex, this->orientation(), Qt::DisplayRole).toString();
|
||||||
|
const QRect rect = fontMetrics().boundingRect({0, 0, default_size, 2000}, defaultAlignment(), text.replace(QChar('_'), ' '));
|
||||||
|
QSize size = rect.size() + QSize{10, 6};
|
||||||
|
return QSize{qMax(size.width(), default_size), size.height()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void HeaderView::paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const {
|
||||||
|
auto bg_role = model()->headerData(logicalIndex, Qt::Horizontal, Qt::BackgroundRole);
|
||||||
|
if (bg_role.isValid()) {
|
||||||
|
painter->fillRect(rect, bg_role.value<QBrush>());
|
||||||
|
}
|
||||||
|
QString text = model()->headerData(logicalIndex, Qt::Horizontal, Qt::DisplayRole).toString();
|
||||||
|
painter->setPen(palette().color(settings.theme == DARK_THEME ? QPalette::BrightText : QPalette::Text));
|
||||||
|
painter->drawText(rect.adjusted(5, 3, -5, -3), defaultAlignment(), text.replace(QChar('_'), ' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogsWidget
|
||||||
|
|
||||||
|
LogsWidget::LogsWidget(QWidget *parent) : QFrame(parent) {
|
||||||
|
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
main_layout->setSpacing(0);
|
||||||
|
|
||||||
|
QWidget *toolbar = new QWidget(this);
|
||||||
|
toolbar->setAutoFillBackground(true);
|
||||||
|
QHBoxLayout *h = new QHBoxLayout(toolbar);
|
||||||
|
|
||||||
|
filters_widget = new QWidget(this);
|
||||||
|
QHBoxLayout *filter_layout = new QHBoxLayout(filters_widget);
|
||||||
|
filter_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
filter_layout->addWidget(display_type_cb = new QComboBox(this));
|
||||||
|
filter_layout->addWidget(signals_cb = new QComboBox(this));
|
||||||
|
filter_layout->addWidget(comp_box = new QComboBox(this));
|
||||||
|
filter_layout->addWidget(value_edit = new QLineEdit(this));
|
||||||
|
h->addWidget(filters_widget);
|
||||||
|
h->addStretch(0);
|
||||||
|
h->addWidget(dynamic_mode = new QCheckBox(tr("Dynamic")), 0, Qt::AlignRight);
|
||||||
|
|
||||||
|
display_type_cb->addItems({"Signal", "Hex"});
|
||||||
|
display_type_cb->setToolTip(tr("Display signal value or raw hex value"));
|
||||||
|
comp_box->addItems({">", "=", "!=", "<"});
|
||||||
|
value_edit->setClearButtonEnabled(true);
|
||||||
|
value_edit->setValidator(new DoubleValidator(this));
|
||||||
|
dynamic_mode->setChecked(true);
|
||||||
|
dynamic_mode->setEnabled(!can->liveStreaming());
|
||||||
|
|
||||||
|
main_layout->addWidget(toolbar);
|
||||||
|
QFrame *line = new QFrame(this);
|
||||||
|
line->setFrameStyle(QFrame::HLine | QFrame::Sunken);
|
||||||
|
main_layout->addWidget(line);
|
||||||
|
main_layout->addWidget(logs = new QTableView(this));
|
||||||
|
logs->setModel(model = new HistoryLogModel(this));
|
||||||
|
delegate = new MessageBytesDelegate(this);
|
||||||
|
logs->setHorizontalHeader(new HeaderView(Qt::Horizontal, this));
|
||||||
|
logs->horizontalHeader()->setDefaultAlignment(Qt::AlignRight | (Qt::Alignment)Qt::TextWordWrap);
|
||||||
|
logs->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
|
||||||
|
logs->verticalHeader()->setSectionResizeMode(QHeaderView::Fixed);
|
||||||
|
logs->verticalHeader()->setDefaultSectionSize(delegate->sizeForBytes(8).height());
|
||||||
|
logs->verticalHeader()->setVisible(false);
|
||||||
|
logs->setFrameShape(QFrame::NoFrame);
|
||||||
|
|
||||||
|
QObject::connect(display_type_cb, qOverload<int>(&QComboBox::activated), [this](int index) {
|
||||||
|
logs->setItemDelegateForColumn(1, index == 1 ? delegate : nullptr);
|
||||||
|
model->setDisplayType(index);
|
||||||
|
});
|
||||||
|
QObject::connect(dynamic_mode, &QCheckBox::stateChanged, model, &HistoryLogModel::setDynamicMode);
|
||||||
|
QObject::connect(signals_cb, SIGNAL(activated(int)), this, SLOT(setFilter()));
|
||||||
|
QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(setFilter()));
|
||||||
|
QObject::connect(value_edit, &QLineEdit::textChanged, this, &LogsWidget::setFilter);
|
||||||
|
QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::refresh);
|
||||||
|
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &LogsWidget::refresh);
|
||||||
|
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &LogsWidget::refresh);
|
||||||
|
QObject::connect(can, &AbstractStream::eventsMerged, model, &HistoryLogModel::segmentsMerged);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogsWidget::setMessage(const MessageId &message_id) {
|
||||||
|
model->setMessage(message_id);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogsWidget::refresh() {
|
||||||
|
model->setFilter(0, "", nullptr);
|
||||||
|
model->refresh(isVisible());
|
||||||
|
bool has_signal = model->sigs.size();
|
||||||
|
if (has_signal) {
|
||||||
|
signals_cb->clear();
|
||||||
|
for (auto s : model->sigs) {
|
||||||
|
signals_cb->addItem(s->name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logs->setItemDelegateForColumn(1, !has_signal || display_type_cb->currentIndex() == 1 ? delegate : nullptr);
|
||||||
|
value_edit->clear();
|
||||||
|
comp_box->setCurrentIndex(0);
|
||||||
|
filters_widget->setVisible(has_signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogsWidget::setFilter() {
|
||||||
|
if (value_edit->text().isEmpty() && !value_edit->isModified()) return;
|
||||||
|
|
||||||
|
std::function<bool(double, double)> cmp = nullptr;
|
||||||
|
switch (comp_box->currentIndex()) {
|
||||||
|
case 0: cmp = std::greater<double>{}; break;
|
||||||
|
case 1: cmp = std::equal_to<double>{}; break;
|
||||||
|
case 2: cmp = [](double l, double r) { return l != r; }; break; // not equal
|
||||||
|
case 3: cmp = std::less<double>{}; break;
|
||||||
|
}
|
||||||
|
model->setFilter(signals_cb->currentIndex(), value_edit->text(), cmp);
|
||||||
|
model->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogsWidget::updateState() {
|
||||||
|
if (isVisible() && dynamic_mode->isChecked()) {
|
||||||
|
model->updateState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LogsWidget::showEvent(QShowEvent *event) {
|
||||||
|
if (dynamic_mode->isChecked() || model->canFetchMore({}) && model->rowCount() == 0) {
|
||||||
|
model->refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
tools/cabana/historylog.h
Executable file
94
tools/cabana/historylog.h
Executable file
@@ -0,0 +1,94 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <deque>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QTableView>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
#include "tools/cabana/util.h"
|
||||||
|
|
||||||
|
class HeaderView : public QHeaderView {
|
||||||
|
public:
|
||||||
|
HeaderView(Qt::Orientation orientation, QWidget *parent = nullptr) : QHeaderView(orientation, parent) {}
|
||||||
|
QSize sectionSizeFromContents(int logicalIndex) const override;
|
||||||
|
void paintSection(QPainter *painter, const QRect &rect, int logicalIndex) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
class HistoryLogModel : public QAbstractTableModel {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
HistoryLogModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||||
|
void setMessage(const MessageId &message_id);
|
||||||
|
void updateState();
|
||||||
|
void setFilter(int sig_idx, const QString &value, std::function<bool(double, double)> cmp);
|
||||||
|
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
void fetchMore(const QModelIndex &parent) override;
|
||||||
|
inline bool canFetchMore(const QModelIndex &parent) const override { return has_more_data; }
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return messages.size(); }
|
||||||
|
int columnCount(const QModelIndex &parent = QModelIndex()) const override {
|
||||||
|
return display_signals_mode && !sigs.empty() ? sigs.size() + 1 : 2;
|
||||||
|
}
|
||||||
|
void refresh(bool fetch_message = true);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void setDisplayType(int type);
|
||||||
|
void setDynamicMode(int state);
|
||||||
|
void segmentsMerged();
|
||||||
|
|
||||||
|
public:
|
||||||
|
struct Message {
|
||||||
|
uint64_t mono_time = 0;
|
||||||
|
std::vector<double> sig_values;
|
||||||
|
std::vector<uint8_t> data;
|
||||||
|
std::vector<QColor> colors;
|
||||||
|
};
|
||||||
|
|
||||||
|
template <class InputIt>
|
||||||
|
std::deque<HistoryLogModel::Message> fetchData(InputIt first, InputIt last, uint64_t min_time);
|
||||||
|
std::deque<Message> fetchData(uint64_t from_time, uint64_t min_time = 0);
|
||||||
|
|
||||||
|
MessageId msg_id;
|
||||||
|
CanData hex_colors;
|
||||||
|
bool has_more_data = true;
|
||||||
|
const int batch_size = 50;
|
||||||
|
int filter_sig_idx = -1;
|
||||||
|
double filter_value = 0;
|
||||||
|
uint64_t last_fetch_time = 0;
|
||||||
|
std::function<bool(double, double)> filter_cmp = nullptr;
|
||||||
|
std::deque<Message> messages;
|
||||||
|
std::vector<cabana::Signal *> sigs;
|
||||||
|
bool dynamic_mode = true;
|
||||||
|
bool display_signals_mode = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
class LogsWidget : public QFrame {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
LogsWidget(QWidget *parent);
|
||||||
|
void setMessage(const MessageId &message_id);
|
||||||
|
void updateState();
|
||||||
|
void showEvent(QShowEvent *event) override;
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void setFilter();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
QTableView *logs;
|
||||||
|
HistoryLogModel *model;
|
||||||
|
QCheckBox *dynamic_mode;
|
||||||
|
QComboBox *signals_cb, *comp_box, *display_type_cb;
|
||||||
|
QLineEdit *value_edit;
|
||||||
|
QWidget *filters_widget;
|
||||||
|
MessageBytesDelegate *delegate;
|
||||||
|
};
|
||||||
697
tools/cabana/mainwin.cc
Executable file
697
tools/cabana/mainwin.cc
Executable file
@@ -0,0 +1,697 @@
|
|||||||
|
#include "tools/cabana/mainwin.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <iostream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QDesktopWidget>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QMenuBar>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QResizeEvent>
|
||||||
|
#include <QShortcut>
|
||||||
|
#include <QTextDocument>
|
||||||
|
#include <QUndoView>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QWidgetAction>
|
||||||
|
|
||||||
|
#include "tools/cabana/commands.h"
|
||||||
|
#include "tools/cabana/streamselector.h"
|
||||||
|
#include "tools/cabana/tools/findsignal.h"
|
||||||
|
#include "tools/replay/replay.h"
|
||||||
|
|
||||||
|
MainWindow::MainWindow() : QMainWindow() {
|
||||||
|
loadFingerprints();
|
||||||
|
createDockWindows();
|
||||||
|
setCentralWidget(center_widget = new CenterWidget(this));
|
||||||
|
createActions();
|
||||||
|
createStatusBar();
|
||||||
|
createShortcuts();
|
||||||
|
|
||||||
|
// save default window state to allow resetting it
|
||||||
|
default_state = saveState();
|
||||||
|
|
||||||
|
// restore states
|
||||||
|
restoreGeometry(settings.geometry);
|
||||||
|
if (isMaximized()) {
|
||||||
|
setGeometry(QApplication::desktop()->availableGeometry(this));
|
||||||
|
}
|
||||||
|
restoreState(settings.window_state);
|
||||||
|
|
||||||
|
// install handlers
|
||||||
|
static auto static_main_win = this;
|
||||||
|
qRegisterMetaType<uint64_t>("uint64_t");
|
||||||
|
qRegisterMetaType<SourceSet>("SourceSet");
|
||||||
|
qRegisterMetaType<ReplyMsgType>("ReplyMsgType");
|
||||||
|
installDownloadProgressHandler([](uint64_t cur, uint64_t total, bool success) {
|
||||||
|
emit static_main_win->updateProgressBar(cur, total, success);
|
||||||
|
});
|
||||||
|
qInstallMessageHandler([](QtMsgType type, const QMessageLogContext &context, const QString &msg) {
|
||||||
|
if (type == QtDebugMsg) std::cout << msg.toStdString() << std::endl;
|
||||||
|
emit static_main_win->showMessage(msg, 2000);
|
||||||
|
});
|
||||||
|
installMessageHandler([](ReplyMsgType type, const std::string msg) {
|
||||||
|
qInfo() << QString::fromStdString(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
setStyleSheet(QString(R"(QMainWindow::separator {
|
||||||
|
width: %1px; /* when vertical */
|
||||||
|
height: %1px; /* when horizontal */
|
||||||
|
})").arg(style()->pixelMetric(QStyle::PM_SplitterWidth)));
|
||||||
|
|
||||||
|
QObject::connect(this, &MainWindow::showMessage, statusBar(), &QStatusBar::showMessage);
|
||||||
|
QObject::connect(this, &MainWindow::updateProgressBar, this, &MainWindow::updateDownloadProgress);
|
||||||
|
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &MainWindow::DBCFileChanged);
|
||||||
|
QObject::connect(UndoStack::instance(), &QUndoStack::cleanChanged, this, &MainWindow::undoStackCleanChanged);
|
||||||
|
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, this, &MainWindow::undoStackIndexChanged);
|
||||||
|
QObject::connect(&settings, &Settings::changed, this, &MainWindow::updateStatus);
|
||||||
|
QObject::connect(StreamNotifier::instance(), &StreamNotifier::changingStream, this, &MainWindow::changingStream);
|
||||||
|
QObject::connect(StreamNotifier::instance(), &StreamNotifier::streamStarted, this, &MainWindow::streamStarted);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::loadFingerprints() {
|
||||||
|
QFile json_file(QApplication::applicationDirPath() + "/dbc/car_fingerprint_to_dbc.json");
|
||||||
|
if (json_file.open(QIODevice::ReadOnly)) {
|
||||||
|
fingerprint_to_dbc = QJsonDocument::fromJson(json_file.readAll());
|
||||||
|
}
|
||||||
|
// get opendbc names
|
||||||
|
for (auto fn : QDir(OPENDBC_FILE_PATH).entryList({"*.dbc"}, QDir::Files, QDir::Name)) {
|
||||||
|
opendbc_names << QFileInfo(fn).baseName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::createActions() {
|
||||||
|
// File menu
|
||||||
|
QMenu *file_menu = menuBar()->addMenu(tr("&File"));
|
||||||
|
file_menu->addAction(tr("Open Stream..."), this, &MainWindow::openStream);
|
||||||
|
close_stream_act = file_menu->addAction(tr("Close stream"), this, &MainWindow::closeStream);
|
||||||
|
close_stream_act->setEnabled(false);
|
||||||
|
file_menu->addSeparator();
|
||||||
|
|
||||||
|
file_menu->addAction(tr("New DBC File"), [this]() { newFile(); }, QKeySequence::New);
|
||||||
|
file_menu->addAction(tr("Open DBC File..."), [this]() { openFile(); }, QKeySequence::Open);
|
||||||
|
|
||||||
|
manage_dbcs_menu = file_menu->addMenu(tr("Manage &DBC Files"));
|
||||||
|
|
||||||
|
open_recent_menu = file_menu->addMenu(tr("Open &Recent"));
|
||||||
|
for (int i = 0; i < MAX_RECENT_FILES; ++i) {
|
||||||
|
recent_files_acts[i] = new QAction(this);
|
||||||
|
recent_files_acts[i]->setVisible(false);
|
||||||
|
QObject::connect(recent_files_acts[i], &QAction::triggered, this, &MainWindow::openRecentFile);
|
||||||
|
open_recent_menu->addAction(recent_files_acts[i]);
|
||||||
|
}
|
||||||
|
updateRecentFileActions();
|
||||||
|
|
||||||
|
file_menu->addSeparator();
|
||||||
|
QMenu *load_opendbc_menu = file_menu->addMenu(tr("Load DBC from commaai/opendbc"));
|
||||||
|
// load_opendbc_menu->setStyleSheet("QMenu { menu-scrollable: true; }");
|
||||||
|
for (const auto &dbc_name : opendbc_names) {
|
||||||
|
load_opendbc_menu->addAction(dbc_name, [this, name = dbc_name]() { loadDBCFromOpendbc(name); });
|
||||||
|
}
|
||||||
|
|
||||||
|
file_menu->addAction(tr("Load DBC From Clipboard"), [=]() { loadFromClipboard(); });
|
||||||
|
|
||||||
|
file_menu->addSeparator();
|
||||||
|
save_dbc = file_menu->addAction(tr("Save DBC..."), this, &MainWindow::save, QKeySequence::Save);
|
||||||
|
save_dbc_as = file_menu->addAction(tr("Save DBC As..."), this, &MainWindow::saveAs, QKeySequence::SaveAs);
|
||||||
|
copy_dbc_to_clipboard = file_menu->addAction(tr("Copy DBC To Clipboard"), this, &MainWindow::saveToClipboard);
|
||||||
|
|
||||||
|
file_menu->addSeparator();
|
||||||
|
file_menu->addAction(tr("Settings..."), this, &MainWindow::setOption, QKeySequence::Preferences);
|
||||||
|
|
||||||
|
file_menu->addSeparator();
|
||||||
|
file_menu->addAction(tr("E&xit"), qApp, &QApplication::closeAllWindows, QKeySequence::Quit);
|
||||||
|
|
||||||
|
// Edit Menu
|
||||||
|
QMenu *edit_menu = menuBar()->addMenu(tr("&Edit"));
|
||||||
|
auto undo_act = UndoStack::instance()->createUndoAction(this, tr("&Undo"));
|
||||||
|
undo_act->setShortcuts(QKeySequence::Undo);
|
||||||
|
edit_menu->addAction(undo_act);
|
||||||
|
auto redo_act = UndoStack::instance()->createRedoAction(this, tr("&Rndo"));
|
||||||
|
redo_act->setShortcuts(QKeySequence::Redo);
|
||||||
|
edit_menu->addAction(redo_act);
|
||||||
|
edit_menu->addSeparator();
|
||||||
|
|
||||||
|
QMenu *commands_menu = edit_menu->addMenu(tr("Command &List"));
|
||||||
|
QWidgetAction *commands_act = new QWidgetAction(this);
|
||||||
|
commands_act->setDefaultWidget(new QUndoView(UndoStack::instance()));
|
||||||
|
commands_menu->addAction(commands_act);
|
||||||
|
|
||||||
|
// View Menu
|
||||||
|
QMenu *view_menu = menuBar()->addMenu(tr("&View"));
|
||||||
|
auto act = view_menu->addAction(tr("Full Screen"), this, &MainWindow::toggleFullScreen, QKeySequence::FullScreen);
|
||||||
|
addAction(act);
|
||||||
|
view_menu->addSeparator();
|
||||||
|
view_menu->addAction(messages_dock->toggleViewAction());
|
||||||
|
view_menu->addAction(video_dock->toggleViewAction());
|
||||||
|
view_menu->addSeparator();
|
||||||
|
view_menu->addAction(tr("Reset Window Layout"), [this]() { restoreState(default_state); });
|
||||||
|
|
||||||
|
// Tools Menu
|
||||||
|
tools_menu = menuBar()->addMenu(tr("&Tools"));
|
||||||
|
tools_menu->addAction(tr("Find &Similar Bits"), this, &MainWindow::findSimilarBits);
|
||||||
|
tools_menu->addAction(tr("&Find Signal"), this, &MainWindow::findSignal);
|
||||||
|
|
||||||
|
// Help Menu
|
||||||
|
QMenu *help_menu = menuBar()->addMenu(tr("&Help"));
|
||||||
|
help_menu->addAction(tr("Help"), this, &MainWindow::onlineHelp, QKeySequence::HelpContents);
|
||||||
|
help_menu->addAction(tr("About &Qt"), qApp, &QApplication::aboutQt);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::createDockWindows() {
|
||||||
|
messages_dock = new QDockWidget(tr("MESSAGES"), this);
|
||||||
|
messages_dock->setObjectName("MessagesPanel");
|
||||||
|
messages_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::TopDockWidgetArea | Qt::BottomDockWidgetArea);
|
||||||
|
messages_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable);
|
||||||
|
addDockWidget(Qt::LeftDockWidgetArea, messages_dock);
|
||||||
|
|
||||||
|
video_dock = new QDockWidget("", this);
|
||||||
|
video_dock->setObjectName(tr("VideoPanel"));
|
||||||
|
video_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
|
||||||
|
video_dock->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable);
|
||||||
|
addDockWidget(Qt::RightDockWidgetArea, video_dock);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::createDockWidgets() {
|
||||||
|
messages_widget = new MessagesWidget(this);
|
||||||
|
messages_dock->setWidget(messages_widget);
|
||||||
|
|
||||||
|
// right panel
|
||||||
|
charts_widget = new ChartsWidget(this);
|
||||||
|
QWidget *charts_container = new QWidget(this);
|
||||||
|
charts_layout = new QVBoxLayout(charts_container);
|
||||||
|
charts_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
charts_layout->addWidget(charts_widget);
|
||||||
|
|
||||||
|
// splitter between video and charts
|
||||||
|
video_splitter = new QSplitter(Qt::Vertical, this);
|
||||||
|
video_widget = new VideoWidget(this);
|
||||||
|
video_splitter->addWidget(video_widget);
|
||||||
|
QObject::connect(charts_widget, &ChartsWidget::rangeChanged, video_widget, &VideoWidget::updateTimeRange);
|
||||||
|
|
||||||
|
video_splitter->addWidget(charts_container);
|
||||||
|
video_splitter->setStretchFactor(1, 1);
|
||||||
|
video_splitter->restoreState(settings.video_splitter_state);
|
||||||
|
video_splitter->handle(1)->setEnabled(!can->liveStreaming());
|
||||||
|
video_dock->setWidget(video_splitter);
|
||||||
|
QObject::connect(charts_widget, &ChartsWidget::dock, this, &MainWindow::dockCharts);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::createStatusBar() {
|
||||||
|
progress_bar = new QProgressBar();
|
||||||
|
progress_bar->setRange(0, 100);
|
||||||
|
progress_bar->setTextVisible(true);
|
||||||
|
progress_bar->setFixedSize({300, 16});
|
||||||
|
progress_bar->setVisible(false);
|
||||||
|
statusBar()->addWidget(new QLabel(tr("For Help, Press F1")));
|
||||||
|
statusBar()->addPermanentWidget(progress_bar);
|
||||||
|
|
||||||
|
statusBar()->addPermanentWidget(status_label = new QLabel(this));
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::createShortcuts() {
|
||||||
|
auto shortcut = new QShortcut(QKeySequence(Qt::Key_Space), this, nullptr, nullptr, Qt::ApplicationShortcut);
|
||||||
|
QObject::connect(shortcut, &QShortcut::activated, []() { can->pause(!can->isPaused()); });
|
||||||
|
// TODO: add more shortcuts here.
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::undoStackIndexChanged(int index) {
|
||||||
|
int count = UndoStack::instance()->count();
|
||||||
|
if (count >= 0) {
|
||||||
|
QString command_text;
|
||||||
|
if (index == count) {
|
||||||
|
command_text = (count == prev_undostack_count ? "Redo " : "") + UndoStack::instance()->text(index - 1);
|
||||||
|
} else if (index < prev_undostack_index) {
|
||||||
|
command_text = tr("Undo %1").arg(UndoStack::instance()->text(index));
|
||||||
|
} else if (index > prev_undostack_index) {
|
||||||
|
command_text = tr("Redo %1").arg(UndoStack::instance()->text(index - 1));
|
||||||
|
}
|
||||||
|
statusBar()->showMessage(command_text, 2000);
|
||||||
|
}
|
||||||
|
prev_undostack_index = index;
|
||||||
|
prev_undostack_count = count;
|
||||||
|
autoSave();
|
||||||
|
updateLoadSaveMenus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::undoStackCleanChanged(bool clean) {
|
||||||
|
if (clean) {
|
||||||
|
prev_undostack_index = 0;
|
||||||
|
prev_undostack_count = 0;
|
||||||
|
}
|
||||||
|
setWindowModified(!clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::DBCFileChanged() {
|
||||||
|
UndoStack::instance()->clear();
|
||||||
|
updateLoadSaveMenus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::openStream() {
|
||||||
|
AbstractStream *stream = nullptr;
|
||||||
|
StreamSelector dlg(&stream, this);
|
||||||
|
if (dlg.exec()) {
|
||||||
|
if (!dlg.dbcFile().isEmpty()) {
|
||||||
|
loadFile(dlg.dbcFile());
|
||||||
|
}
|
||||||
|
stream->start();
|
||||||
|
statusBar()->showMessage(tr("Route %1 loaded").arg(can->routeName()), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::closeStream() {
|
||||||
|
AbstractStream *stream = new DummyStream(this);
|
||||||
|
stream->start();
|
||||||
|
if (dbc()->nonEmptyDBCCount() > 0) {
|
||||||
|
emit dbc()->DBCFileChanged();
|
||||||
|
}
|
||||||
|
statusBar()->showMessage(tr("stream closed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::newFile(SourceSet s) {
|
||||||
|
closeFile(s);
|
||||||
|
dbc()->open(s, "", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::openFile(SourceSet s) {
|
||||||
|
remindSaveChanges();
|
||||||
|
QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)");
|
||||||
|
if (!fn.isEmpty()) {
|
||||||
|
loadFile(fn, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::loadFile(const QString &fn, SourceSet s) {
|
||||||
|
if (!fn.isEmpty()) {
|
||||||
|
closeFile(s);
|
||||||
|
|
||||||
|
QString dbc_fn = fn;
|
||||||
|
// Prompt user to load auto saved file if it exists.
|
||||||
|
if (QFile::exists(fn + AUTO_SAVE_EXTENSION)) {
|
||||||
|
auto ret = QMessageBox::question(this, tr("Auto saved DBC found"), tr("Auto saved DBC file from previous session found. Do you want to load it instead?"));
|
||||||
|
if (ret == QMessageBox::Yes) {
|
||||||
|
dbc_fn += AUTO_SAVE_EXTENSION;
|
||||||
|
UndoStack::instance()->resetClean(); // Force user to save on close so the auto saved file is not lost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString error;
|
||||||
|
if (dbc()->open(s, dbc_fn, &error)) {
|
||||||
|
updateRecentFiles(fn);
|
||||||
|
statusBar()->showMessage(tr("DBC File %1 loaded").arg(fn), 2000);
|
||||||
|
} else {
|
||||||
|
QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC file"), tr("Failed to parse DBC file %1").arg(fn));
|
||||||
|
msg_box.setDetailedText(error);
|
||||||
|
msg_box.exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::openRecentFile() {
|
||||||
|
if (auto action = qobject_cast<QAction *>(sender())) {
|
||||||
|
loadFile(action->data().toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::loadDBCFromOpendbc(const QString &name) {
|
||||||
|
QString opendbc_file_path = QString("%1/%2.dbc").arg(OPENDBC_FILE_PATH, name);
|
||||||
|
loadFile(opendbc_file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::loadFromClipboard(SourceSet s, bool close_all) {
|
||||||
|
closeFile(s);
|
||||||
|
|
||||||
|
QString dbc_str = QGuiApplication::clipboard()->text();
|
||||||
|
QString error;
|
||||||
|
bool ret = dbc()->open(s, "", dbc_str, &error);
|
||||||
|
if (ret && dbc()->msgCount() > 0) {
|
||||||
|
QMessageBox::information(this, tr("Load From Clipboard"), tr("DBC Successfully Loaded!"));
|
||||||
|
} else {
|
||||||
|
QMessageBox msg_box(QMessageBox::Warning, tr("Failed to load DBC from clipboard"), tr("Make sure that you paste the text with correct format."));
|
||||||
|
msg_box.setDetailedText(error);
|
||||||
|
msg_box.exec();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::changingStream() {
|
||||||
|
center_widget->clear();
|
||||||
|
delete messages_widget;
|
||||||
|
delete video_splitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::streamStarted() {
|
||||||
|
bool has_stream = dynamic_cast<DummyStream *>(can) == nullptr;
|
||||||
|
close_stream_act->setEnabled(has_stream);
|
||||||
|
tools_menu->setEnabled(has_stream);
|
||||||
|
createDockWidgets();
|
||||||
|
|
||||||
|
video_dock->setWindowTitle(can->routeName());
|
||||||
|
if (can->liveStreaming() || video_splitter->sizes()[0] == 0) {
|
||||||
|
// display video at minimum size.
|
||||||
|
video_splitter->setSizes({1, 1});
|
||||||
|
}
|
||||||
|
// Don't overwrite already loaded DBC
|
||||||
|
if (!dbc()->msgCount()) {
|
||||||
|
newFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
QObject::connect(messages_widget, &MessagesWidget::msgSelectionChanged, center_widget, &CenterWidget::setMessage);
|
||||||
|
QObject::connect(can, &AbstractStream::eventsMerged, this, &MainWindow::eventsMerged);
|
||||||
|
QObject::connect(can, &AbstractStream::sourcesUpdated, this, &MainWindow::updateLoadSaveMenus);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::eventsMerged() {
|
||||||
|
if (!can->liveStreaming() && std::exchange(car_fingerprint, can->carFingerprint()) != car_fingerprint) {
|
||||||
|
video_dock->setWindowTitle(tr("ROUTE: %1 FINGERPRINT: %2")
|
||||||
|
.arg(can->routeName())
|
||||||
|
.arg(car_fingerprint.isEmpty() ? tr("Unknown Car") : car_fingerprint));
|
||||||
|
// Don't overwrite already loaded DBC
|
||||||
|
if (!dbc()->msgCount() && !car_fingerprint.isEmpty()) {
|
||||||
|
auto dbc_name = fingerprint_to_dbc[car_fingerprint];
|
||||||
|
if (dbc_name != QJsonValue::Undefined) {
|
||||||
|
// Prevent dialog that load autosaved file from blocking replay->start().
|
||||||
|
QTimer::singleShot(0, this, [dbc_name, this]() { loadDBCFromOpendbc(dbc_name.toString()); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::save() {
|
||||||
|
// Save all open DBC files
|
||||||
|
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||||
|
if (dbc_file->isEmpty()) continue;
|
||||||
|
saveFile(dbc_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::saveAs() {
|
||||||
|
// Save as all open DBC files. Should not be called with more than 1 file open
|
||||||
|
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||||
|
if (dbc_file->isEmpty()) continue;
|
||||||
|
saveFileAs(dbc_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::autoSave() {
|
||||||
|
if (!UndoStack::instance()->isClean()) {
|
||||||
|
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||||
|
if (!dbc_file->filename.isEmpty()) {
|
||||||
|
dbc_file->autoSave();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::cleanupAutoSaveFile() {
|
||||||
|
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||||
|
dbc_file->cleanupAutoSaveFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::closeFile(SourceSet s) {
|
||||||
|
remindSaveChanges();
|
||||||
|
if (s == SOURCE_ALL) {
|
||||||
|
dbc()->closeAll();
|
||||||
|
} else {
|
||||||
|
dbc()->close(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::closeFile(DBCFile *dbc_file) {
|
||||||
|
assert(dbc_file != nullptr);
|
||||||
|
remindSaveChanges();
|
||||||
|
dbc()->close(dbc_file);
|
||||||
|
// Ensure we always have at least one file open
|
||||||
|
if (dbc()->dbcCount() == 0) {
|
||||||
|
newFile();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::saveFile(DBCFile *dbc_file) {
|
||||||
|
assert(dbc_file != nullptr);
|
||||||
|
if (!dbc_file->filename.isEmpty()) {
|
||||||
|
dbc_file->save();
|
||||||
|
updateLoadSaveMenus();
|
||||||
|
UndoStack::instance()->setClean();
|
||||||
|
statusBar()->showMessage(tr("File saved"), 2000);
|
||||||
|
} else if (!dbc_file->isEmpty()) {
|
||||||
|
saveFileAs(dbc_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::saveFileAs(DBCFile *dbc_file) {
|
||||||
|
QString title = tr("Save File (bus: %1)").arg(toString(dbc()->sources(dbc_file)));
|
||||||
|
QString fn = QFileDialog::getSaveFileName(this, title, QDir::cleanPath(settings.last_dir + "/untitled.dbc"), tr("DBC (*.dbc)"));
|
||||||
|
if (!fn.isEmpty()) {
|
||||||
|
dbc_file->saveAs(fn);
|
||||||
|
UndoStack::instance()->setClean();
|
||||||
|
statusBar()->showMessage(tr("File saved as %1").arg(fn), 2000);
|
||||||
|
updateRecentFiles(fn);
|
||||||
|
updateLoadSaveMenus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::saveToClipboard() {
|
||||||
|
// Copy all open DBC files to clipboard. Should not be called with more than 1 file open
|
||||||
|
for (auto dbc_file : dbc()->allDBCFiles()) {
|
||||||
|
if (dbc_file->isEmpty()) continue;
|
||||||
|
saveFileToClipboard(dbc_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::saveFileToClipboard(DBCFile *dbc_file) {
|
||||||
|
assert(dbc_file != nullptr);
|
||||||
|
QGuiApplication::clipboard()->setText(dbc_file->generateDBC());
|
||||||
|
QMessageBox::information(this, tr("Copy To Clipboard"), tr("DBC Successfully copied!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::updateLoadSaveMenus() {
|
||||||
|
int cnt = dbc()->nonEmptyDBCCount();
|
||||||
|
save_dbc->setText(cnt > 1 ? tr("Save %1 DBCs...").arg(cnt) : tr("Save DBC..."));
|
||||||
|
save_dbc->setEnabled(cnt > 0);
|
||||||
|
save_dbc_as->setEnabled(cnt == 1);
|
||||||
|
|
||||||
|
// TODO: Support clipboard for multiple files
|
||||||
|
copy_dbc_to_clipboard->setEnabled(cnt == 1);
|
||||||
|
|
||||||
|
manage_dbcs_menu->clear();
|
||||||
|
manage_dbcs_menu->setEnabled(dynamic_cast<DummyStream *>(can) == nullptr);
|
||||||
|
|
||||||
|
for (int source : can->sources) {
|
||||||
|
if (source >= 64) continue; // Sent and blocked buses are handled implicitly
|
||||||
|
|
||||||
|
SourceSet ss = {source, uint8_t(source + 128), uint8_t(source + 192)};
|
||||||
|
|
||||||
|
QMenu *bus_menu = new QMenu(this);
|
||||||
|
bus_menu->addAction(tr("New DBC File..."), [=]() { newFile(ss); });
|
||||||
|
bus_menu->addAction(tr("Open DBC File..."), [=]() { openFile(ss); });
|
||||||
|
bus_menu->addAction(tr("Load DBC From Clipboard..."), [=]() { loadFromClipboard(ss, false); });
|
||||||
|
|
||||||
|
// Show sub-menu for each dbc for this source.
|
||||||
|
QString file_name = "No DBCs loaded";
|
||||||
|
if (auto dbc_file = dbc()->findDBCFile(source)) {
|
||||||
|
bus_menu->addSeparator();
|
||||||
|
bus_menu->addAction(dbc_file->name() + " (" + toString(dbc()->sources(dbc_file)) + ")")->setEnabled(false);
|
||||||
|
bus_menu->addAction(tr("Save..."), [=]() { saveFile(dbc_file); });
|
||||||
|
bus_menu->addAction(tr("Save As..."), [=]() { saveFileAs(dbc_file); });
|
||||||
|
bus_menu->addAction(tr("Copy to Clipboard..."), [=]() { saveFileToClipboard(dbc_file); });
|
||||||
|
bus_menu->addAction(tr("Remove from this bus..."), [=]() { closeFile(ss); });
|
||||||
|
bus_menu->addAction(tr("Remove from all buses..."), [=]() { closeFile(dbc_file); });
|
||||||
|
|
||||||
|
file_name = dbc_file->name();
|
||||||
|
}
|
||||||
|
|
||||||
|
manage_dbcs_menu->addMenu(bus_menu);
|
||||||
|
bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(file_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList title;
|
||||||
|
for (auto f : dbc()->allDBCFiles()) {
|
||||||
|
title.push_back(tr("(%1) %2").arg(toString(dbc()->sources(f)), f->name()));
|
||||||
|
}
|
||||||
|
setWindowFilePath(title.join(" | "));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::updateRecentFiles(const QString &fn) {
|
||||||
|
settings.recent_files.removeAll(fn);
|
||||||
|
settings.recent_files.prepend(fn);
|
||||||
|
while (settings.recent_files.size() > MAX_RECENT_FILES) {
|
||||||
|
settings.recent_files.removeLast();
|
||||||
|
}
|
||||||
|
settings.last_dir = QFileInfo(fn).absolutePath();
|
||||||
|
updateRecentFileActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::updateRecentFileActions() {
|
||||||
|
int num_recent_files = std::min<int>(settings.recent_files.size(), MAX_RECENT_FILES);
|
||||||
|
|
||||||
|
for (int i = 0; i < num_recent_files; ++i) {
|
||||||
|
QString text = tr("&%1 %2").arg(i + 1).arg(QFileInfo(settings.recent_files[i]).fileName());
|
||||||
|
recent_files_acts[i]->setText(text);
|
||||||
|
recent_files_acts[i]->setData(settings.recent_files[i]);
|
||||||
|
recent_files_acts[i]->setVisible(true);
|
||||||
|
}
|
||||||
|
for (int i = num_recent_files; i < MAX_RECENT_FILES; ++i) {
|
||||||
|
recent_files_acts[i]->setVisible(false);
|
||||||
|
}
|
||||||
|
open_recent_menu->setEnabled(num_recent_files > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::remindSaveChanges() {
|
||||||
|
bool discard_changes = false;
|
||||||
|
while (!UndoStack::instance()->isClean() && !discard_changes) {
|
||||||
|
QString text = tr("You have unsaved changes. Press ok to save them, cancel to discard.");
|
||||||
|
int ret = (QMessageBox::question(this, tr("Unsaved Changes"), text, QMessageBox::Ok | QMessageBox::Cancel));
|
||||||
|
if (ret == QMessageBox::Ok) {
|
||||||
|
save();
|
||||||
|
} else {
|
||||||
|
discard_changes = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UndoStack::instance()->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::updateDownloadProgress(uint64_t cur, uint64_t total, bool success) {
|
||||||
|
if (success && cur < total) {
|
||||||
|
progress_bar->setValue((cur / (double)total) * 100);
|
||||||
|
progress_bar->setFormat(tr("Downloading %p% (%1)").arg(formattedDataSize(total).c_str()));
|
||||||
|
progress_bar->show();
|
||||||
|
} else {
|
||||||
|
progress_bar->hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::updateStatus() {
|
||||||
|
status_label->setText(tr("Cached Minutes:%1 FPS:%2").arg(settings.max_cached_minutes).arg(settings.fps));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::dockCharts(bool dock) {
|
||||||
|
if (dock && floating_window) {
|
||||||
|
floating_window->removeEventFilter(charts_widget);
|
||||||
|
charts_layout->insertWidget(0, charts_widget, 1);
|
||||||
|
floating_window->deleteLater();
|
||||||
|
floating_window = nullptr;
|
||||||
|
} else if (!dock && !floating_window) {
|
||||||
|
floating_window = new QWidget(this);
|
||||||
|
floating_window->setWindowFlags(Qt::Window);
|
||||||
|
floating_window->setWindowTitle("Charts");
|
||||||
|
floating_window->setLayout(new QVBoxLayout());
|
||||||
|
floating_window->layout()->addWidget(charts_widget);
|
||||||
|
floating_window->installEventFilter(charts_widget);
|
||||||
|
floating_window->showMaximized();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||||
|
cleanupAutoSaveFile();
|
||||||
|
remindSaveChanges();
|
||||||
|
|
||||||
|
installDownloadProgressHandler(nullptr);
|
||||||
|
qInstallMessageHandler(nullptr);
|
||||||
|
|
||||||
|
if (floating_window)
|
||||||
|
floating_window->deleteLater();
|
||||||
|
|
||||||
|
// save states
|
||||||
|
settings.geometry = saveGeometry();
|
||||||
|
settings.window_state = saveState();
|
||||||
|
if (!can->liveStreaming()) {
|
||||||
|
settings.video_splitter_state = video_splitter->saveState();
|
||||||
|
}
|
||||||
|
settings.message_header_state = messages_widget->saveHeaderState();
|
||||||
|
|
||||||
|
QWidget::closeEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::setOption() {
|
||||||
|
SettingsDlg dlg(this);
|
||||||
|
dlg.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::findSimilarBits() {
|
||||||
|
FindSimilarBitsDlg *dlg = new FindSimilarBitsDlg(this);
|
||||||
|
QObject::connect(dlg, &FindSimilarBitsDlg::openMessage, messages_widget, &MessagesWidget::selectMessage);
|
||||||
|
dlg->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::findSignal() {
|
||||||
|
FindSignalDlg *dlg = new FindSignalDlg(this);
|
||||||
|
QObject::connect(dlg, &FindSignalDlg::openMessage, messages_widget, &MessagesWidget::selectMessage);
|
||||||
|
dlg->show();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::onlineHelp() {
|
||||||
|
if (auto help = findChild<HelpOverlay*>()) {
|
||||||
|
help->close();
|
||||||
|
} else {
|
||||||
|
help = new HelpOverlay(this);
|
||||||
|
help->setGeometry(rect());
|
||||||
|
help->show();
|
||||||
|
help->raise();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::toggleFullScreen() {
|
||||||
|
if (isFullScreen()) {
|
||||||
|
menuBar()->show();
|
||||||
|
statusBar()->show();
|
||||||
|
showNormal();
|
||||||
|
showMaximized();
|
||||||
|
} else {
|
||||||
|
menuBar()->hide();
|
||||||
|
statusBar()->hide();
|
||||||
|
showFullScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HelpOverlay
|
||||||
|
HelpOverlay::HelpOverlay(MainWindow *parent) : QWidget(parent) {
|
||||||
|
setAttribute(Qt::WA_NoSystemBackground, true);
|
||||||
|
setAttribute(Qt::WA_TranslucentBackground, true);
|
||||||
|
setAttribute(Qt::WA_DeleteOnClose);
|
||||||
|
parent->installEventFilter(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void HelpOverlay::paintEvent(QPaintEvent *event) {
|
||||||
|
QPainter painter(this);
|
||||||
|
painter.fillRect(rect(), QColor(0, 0, 0, 50));
|
||||||
|
MainWindow *parent = (MainWindow *)parentWidget();
|
||||||
|
drawHelpForWidget(painter, parent->findChild<MessagesWidget *>());
|
||||||
|
drawHelpForWidget(painter, parent->findChild<BinaryView *>());
|
||||||
|
drawHelpForWidget(painter, parent->findChild<SignalView *>());
|
||||||
|
drawHelpForWidget(painter, parent->findChild<ChartsWidget *>());
|
||||||
|
drawHelpForWidget(painter, parent->findChild<VideoWidget *>());
|
||||||
|
}
|
||||||
|
|
||||||
|
void HelpOverlay::drawHelpForWidget(QPainter &painter, QWidget *w) {
|
||||||
|
if (w && w->isVisible() && !w->whatsThis().isEmpty()) {
|
||||||
|
QPoint pt = mapFromGlobal(w->mapToGlobal(w->rect().center()));
|
||||||
|
if (rect().contains(pt)) {
|
||||||
|
QTextDocument document;
|
||||||
|
document.setHtml(w->whatsThis());
|
||||||
|
QSize doc_size = document.size().toSize();
|
||||||
|
QPoint topleft = {pt.x() - doc_size.width() / 2, pt.y() - doc_size.height() / 2};
|
||||||
|
painter.translate(topleft);
|
||||||
|
painter.fillRect(QRect{{0, 0}, doc_size}, palette().toolTipBase());
|
||||||
|
document.drawContents(&painter);
|
||||||
|
painter.translate(-topleft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HelpOverlay::eventFilter(QObject *obj, QEvent *event) {
|
||||||
|
if (obj == parentWidget() && event->type() == QEvent::Resize) {
|
||||||
|
QResizeEvent *resize_event = (QResizeEvent *)(event);
|
||||||
|
setGeometry(QRect{QPoint(0, 0), resize_event->size()});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void HelpOverlay::mouseReleaseEvent(QMouseEvent *event) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
117
tools/cabana/mainwin.h
Executable file
117
tools/cabana/mainwin.h
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDockWidget>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QMainWindow>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QProgressBar>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QStatusBar>
|
||||||
|
#include <set>
|
||||||
|
|
||||||
|
#include "tools/cabana/chart/chartswidget.h"
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/detailwidget.h"
|
||||||
|
#include "tools/cabana/messageswidget.h"
|
||||||
|
#include "tools/cabana/videowidget.h"
|
||||||
|
#include "tools/cabana/tools/findsimilarbits.h"
|
||||||
|
|
||||||
|
class MainWindow : public QMainWindow {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
MainWindow();
|
||||||
|
void dockCharts(bool dock);
|
||||||
|
void showStatusMessage(const QString &msg, int timeout = 0) { statusBar()->showMessage(msg, timeout); }
|
||||||
|
void loadFile(const QString &fn, SourceSet s = SOURCE_ALL);
|
||||||
|
ChartsWidget *charts_widget = nullptr;
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void openStream();
|
||||||
|
void closeStream();
|
||||||
|
void changingStream();
|
||||||
|
void streamStarted();
|
||||||
|
|
||||||
|
void newFile(SourceSet s = SOURCE_ALL);
|
||||||
|
void openFile(SourceSet s = SOURCE_ALL);
|
||||||
|
void openRecentFile();
|
||||||
|
void loadDBCFromOpendbc(const QString &name);
|
||||||
|
void save();
|
||||||
|
void saveAs();
|
||||||
|
void saveToClipboard();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void showMessage(const QString &msg, int timeout);
|
||||||
|
void updateProgressBar(uint64_t cur, uint64_t total, bool success);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void remindSaveChanges();
|
||||||
|
void closeFile(SourceSet s = SOURCE_ALL);
|
||||||
|
void closeFile(DBCFile *dbc_file);
|
||||||
|
void saveFile(DBCFile *dbc_file);
|
||||||
|
void saveFileAs(DBCFile *dbc_file);
|
||||||
|
void saveFileToClipboard(DBCFile *dbc_file);
|
||||||
|
void loadFingerprints();
|
||||||
|
void loadFromClipboard(SourceSet s = SOURCE_ALL, bool close_all = true);
|
||||||
|
void autoSave();
|
||||||
|
void cleanupAutoSaveFile();
|
||||||
|
void updateRecentFiles(const QString &fn);
|
||||||
|
void updateRecentFileActions();
|
||||||
|
void createActions();
|
||||||
|
void createDockWindows();
|
||||||
|
void createStatusBar();
|
||||||
|
void createShortcuts();
|
||||||
|
void closeEvent(QCloseEvent *event) override;
|
||||||
|
void DBCFileChanged();
|
||||||
|
void updateDownloadProgress(uint64_t cur, uint64_t total, bool success);
|
||||||
|
void setOption();
|
||||||
|
void findSimilarBits();
|
||||||
|
void findSignal();
|
||||||
|
void undoStackCleanChanged(bool clean);
|
||||||
|
void undoStackIndexChanged(int index);
|
||||||
|
void onlineHelp();
|
||||||
|
void toggleFullScreen();
|
||||||
|
void updateStatus();
|
||||||
|
void updateLoadSaveMenus();
|
||||||
|
void createDockWidgets();
|
||||||
|
void eventsMerged();
|
||||||
|
|
||||||
|
VideoWidget *video_widget = nullptr;
|
||||||
|
QDockWidget *video_dock;
|
||||||
|
QDockWidget *messages_dock;
|
||||||
|
MessagesWidget *messages_widget = nullptr;
|
||||||
|
CenterWidget *center_widget;
|
||||||
|
QWidget *floating_window = nullptr;
|
||||||
|
QVBoxLayout *charts_layout;
|
||||||
|
QProgressBar *progress_bar;
|
||||||
|
QLabel *status_label;
|
||||||
|
QJsonDocument fingerprint_to_dbc;
|
||||||
|
QStringList opendbc_names;
|
||||||
|
QSplitter *video_splitter = nullptr;
|
||||||
|
enum { MAX_RECENT_FILES = 15 };
|
||||||
|
QAction *recent_files_acts[MAX_RECENT_FILES] = {};
|
||||||
|
QMenu *open_recent_menu = nullptr;
|
||||||
|
QMenu *manage_dbcs_menu = nullptr;
|
||||||
|
QMenu *tools_menu = nullptr;
|
||||||
|
QAction *close_stream_act = nullptr;
|
||||||
|
QAction *save_dbc = nullptr;
|
||||||
|
QAction *save_dbc_as = nullptr;
|
||||||
|
QAction *copy_dbc_to_clipboard = nullptr;
|
||||||
|
QString car_fingerprint;
|
||||||
|
int prev_undostack_index = 0;
|
||||||
|
int prev_undostack_count = 0;
|
||||||
|
QByteArray default_state;
|
||||||
|
friend class OnlineHelp;
|
||||||
|
};
|
||||||
|
|
||||||
|
class HelpOverlay : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
HelpOverlay(MainWindow *parent);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void drawHelpForWidget(QPainter &painter, QWidget *w);
|
||||||
|
void paintEvent(QPaintEvent *event) override;
|
||||||
|
void mouseReleaseEvent(QMouseEvent *event) override;
|
||||||
|
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||||
|
};
|
||||||
442
tools/cabana/messageswidget.cc
Executable file
442
tools/cabana/messageswidget.cc
Executable file
@@ -0,0 +1,442 @@
|
|||||||
|
#include "tools/cabana/messageswidget.h"
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScrollBar>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "tools/cabana/commands.h"
|
||||||
|
|
||||||
|
MessagesWidget::MessagesWidget(QWidget *parent) : menu(new QMenu(this)), QWidget(parent) {
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
main_layout->setSpacing(0);
|
||||||
|
// toolbar
|
||||||
|
main_layout->addWidget(createToolBar());
|
||||||
|
// message table
|
||||||
|
main_layout->addWidget(view = new MessageView(this));
|
||||||
|
view->setItemDelegate(delegate = new MessageBytesDelegate(view, settings.multiple_lines_hex));
|
||||||
|
view->setModel(model = new MessageListModel(this));
|
||||||
|
view->setHeader(header = new MessageViewHeader(this));
|
||||||
|
view->setSortingEnabled(true);
|
||||||
|
view->sortByColumn(MessageListModel::Column::NAME, Qt::AscendingOrder);
|
||||||
|
view->setAllColumnsShowFocus(true);
|
||||||
|
view->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||||
|
view->setItemsExpandable(false);
|
||||||
|
view->setIndentation(0);
|
||||||
|
view->setRootIsDecorated(false);
|
||||||
|
view->setUniformRowHeights(!settings.multiple_lines_hex);
|
||||||
|
|
||||||
|
// Must be called before setting any header parameters to avoid overriding
|
||||||
|
restoreHeaderState(settings.message_header_state);
|
||||||
|
header->setSectionsMovable(true);
|
||||||
|
header->setSectionResizeMode(MessageListModel::Column::DATA, QHeaderView::Fixed);
|
||||||
|
header->setStretchLastSection(true);
|
||||||
|
header->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
|
|
||||||
|
// suppress
|
||||||
|
QHBoxLayout *suppress_layout = new QHBoxLayout();
|
||||||
|
suppress_layout->addWidget(suppress_add = new QPushButton("Suppress Highlighted"));
|
||||||
|
suppress_layout->addWidget(suppress_clear = new QPushButton());
|
||||||
|
suppress_clear->setToolTip(tr("Clear suppressed"));
|
||||||
|
suppress_layout->addStretch(1);
|
||||||
|
QCheckBox *suppress_defined_signals = new QCheckBox(tr("Suppress Signals"), this);
|
||||||
|
suppress_defined_signals->setToolTip(tr("Suppress defined signals"));
|
||||||
|
suppress_defined_signals->setChecked(settings.suppress_defined_signals);
|
||||||
|
suppress_layout->addWidget(suppress_defined_signals);
|
||||||
|
main_layout->addLayout(suppress_layout);
|
||||||
|
|
||||||
|
// signals/slots
|
||||||
|
QObject::connect(menu, &QMenu::aboutToShow, this, &MessagesWidget::menuAboutToShow);
|
||||||
|
QObject::connect(header, &MessageViewHeader::customContextMenuRequested, this, &MessagesWidget::headerContextMenuEvent);
|
||||||
|
QObject::connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, header, &MessageViewHeader::updateHeaderPositions);
|
||||||
|
QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, can, &AbstractStream::suppressDefinedSignals);
|
||||||
|
QObject::connect(can, &AbstractStream::msgsReceived, model, &MessageListModel::msgsReceived);
|
||||||
|
QObject::connect(dbc(), &DBCManager::DBCFileChanged, model, &MessageListModel::dbcModified);
|
||||||
|
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &MessageListModel::dbcModified);
|
||||||
|
QObject::connect(model, &MessageListModel::modelReset, [this]() {
|
||||||
|
if (current_msg_id) {
|
||||||
|
selectMessage(*current_msg_id);
|
||||||
|
}
|
||||||
|
view->updateBytesSectionSize();
|
||||||
|
updateTitle();
|
||||||
|
});
|
||||||
|
QObject::connect(view->selectionModel(), &QItemSelectionModel::currentChanged, [=](const QModelIndex ¤t, const QModelIndex &previous) {
|
||||||
|
if (current.isValid() && current.row() < model->items_.size()) {
|
||||||
|
const auto &id = model->items_[current.row()].id;
|
||||||
|
if (!current_msg_id || id != *current_msg_id) {
|
||||||
|
current_msg_id = id;
|
||||||
|
emit msgSelectionChanged(*current_msg_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
|
||||||
|
QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
|
||||||
|
suppressHighlighted();
|
||||||
|
|
||||||
|
setWhatsThis(tr(R"(
|
||||||
|
<b>Message View</b><br/>
|
||||||
|
<!-- TODO: add descprition here -->
|
||||||
|
<span style="color:gray">Byte color</span><br />
|
||||||
|
<span style="color:gray;">■ </span> constant changing<br />
|
||||||
|
<span style="color:blue;">■ </span> increasing<br />
|
||||||
|
<span style="color:red;">■ </span> decreasing
|
||||||
|
)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QToolBar *MessagesWidget::createToolBar() {
|
||||||
|
QToolBar *toolbar = new QToolBar(this);
|
||||||
|
toolbar->setIconSize({12, 12});
|
||||||
|
toolbar->addWidget(num_msg_label = new QLabel(this));
|
||||||
|
num_msg_label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||||||
|
|
||||||
|
auto views_btn = toolbar->addAction(utils::icon("three-dots"), tr("View..."));
|
||||||
|
views_btn->setMenu(menu);
|
||||||
|
auto view_button = qobject_cast<QToolButton *>(toolbar->widgetForAction(views_btn));
|
||||||
|
view_button->setPopupMode(QToolButton::InstantPopup);
|
||||||
|
view_button->setToolButtonStyle(Qt::ToolButtonIconOnly);
|
||||||
|
view_button->setStyleSheet("QToolButton::menu-indicator { image: none; }");
|
||||||
|
return toolbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessagesWidget::updateTitle() {
|
||||||
|
auto stats = std::accumulate(
|
||||||
|
model->items_.begin(), model->items_.end(), std::pair<size_t, size_t>(),
|
||||||
|
[](const auto &pair, const auto &item) {
|
||||||
|
auto m = dbc()->msg(item.id);
|
||||||
|
return m ? std::make_pair(pair.first + 1, pair.second + m->sigs.size()) : pair;
|
||||||
|
});
|
||||||
|
num_msg_label->setText(tr("%1 Messages (%2 DBC Messages, %3 Signals)")
|
||||||
|
.arg(model->items_.size()).arg(stats.first).arg(stats.second));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessagesWidget::selectMessage(const MessageId &msg_id) {
|
||||||
|
auto it = std::find_if(model->items_.cbegin(), model->items_.cend(),
|
||||||
|
[&msg_id](auto &item) { return item.id == msg_id; });
|
||||||
|
if (it != model->items_.cend()) {
|
||||||
|
view->setCurrentIndex(model->index(std::distance(model->items_.cbegin(), it), 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessagesWidget::suppressHighlighted() {
|
||||||
|
if (sender() == suppress_add) {
|
||||||
|
size_t n = can->suppressHighlighted();
|
||||||
|
suppress_clear->setText(tr("Clear (%1)").arg(n));
|
||||||
|
suppress_clear->setEnabled(true);
|
||||||
|
} else {
|
||||||
|
can->clearSuppressed();
|
||||||
|
suppress_clear->setText(tr("Clear"));
|
||||||
|
suppress_clear->setEnabled(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessagesWidget::headerContextMenuEvent(const QPoint &pos) {
|
||||||
|
menu->exec(header->mapToGlobal(pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessagesWidget::menuAboutToShow() {
|
||||||
|
menu->clear();
|
||||||
|
for (int i = 0; i < header->count(); ++i) {
|
||||||
|
int logical_index = header->logicalIndex(i);
|
||||||
|
auto action = menu->addAction(model->headerData(logical_index, Qt::Horizontal).toString(),
|
||||||
|
[=](bool checked) { header->setSectionHidden(logical_index, !checked); });
|
||||||
|
action->setCheckable(true);
|
||||||
|
action->setChecked(!header->isSectionHidden(logical_index));
|
||||||
|
// Can't hide the name column
|
||||||
|
action->setEnabled(logical_index > 0);
|
||||||
|
}
|
||||||
|
menu->addSeparator();
|
||||||
|
auto action = menu->addAction(tr("Mutlti-Line bytes"), this, &MessagesWidget::setMultiLineBytes);
|
||||||
|
action->setCheckable(true);
|
||||||
|
action->setChecked(settings.multiple_lines_hex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessagesWidget::setMultiLineBytes(bool multi) {
|
||||||
|
settings.multiple_lines_hex = multi;
|
||||||
|
delegate->setMultipleLines(multi);
|
||||||
|
view->setUniformRowHeights(!multi);
|
||||||
|
view->updateBytesSectionSize();
|
||||||
|
view->doItemsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageListModel
|
||||||
|
|
||||||
|
QVariant MessageListModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||||
|
if (orientation == Qt::Horizontal && role == Qt::DisplayRole) {
|
||||||
|
switch (section) {
|
||||||
|
case Column::NAME: return tr("Name");
|
||||||
|
case Column::SOURCE: return tr("Bus");
|
||||||
|
case Column::ADDRESS: return tr("ID");
|
||||||
|
case Column::NODE: return tr("Node");
|
||||||
|
case Column::FREQ: return tr("Freq");
|
||||||
|
case Column::COUNT: return tr("Count");
|
||||||
|
case Column::DATA: return tr("Bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant MessageListModel::data(const QModelIndex &index, int role) const {
|
||||||
|
if (!index.isValid() || index.row() >= items_.size()) return {};
|
||||||
|
|
||||||
|
auto getFreq = [](const CanData &d) {
|
||||||
|
if (d.freq > 0 && (can->currentSec() - d.ts - 1.0 / settings.fps) < (5.0 / d.freq)) {
|
||||||
|
return d.freq >= 0.95 ? QString::number(std::nearbyint(d.freq)) : QString::number(d.freq, 'f', 2);
|
||||||
|
} else {
|
||||||
|
return QStringLiteral("--");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto &item = items_[index.row()];
|
||||||
|
const auto &data = can->lastMessage(item.id);
|
||||||
|
if (role == Qt::DisplayRole) {
|
||||||
|
switch (index.column()) {
|
||||||
|
case Column::NAME: return item.name;
|
||||||
|
case Column::SOURCE: return item.id.source != INVALID_SOURCE ? QString::number(item.id.source) : "N/A";
|
||||||
|
case Column::ADDRESS: return QString::number(item.id.address, 16);
|
||||||
|
case Column::NODE: return item.node;
|
||||||
|
case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(data) : "N/A";
|
||||||
|
case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(data.count) : "N/A";
|
||||||
|
case Column::DATA: return item.id.source != INVALID_SOURCE ? "" : "N/A";
|
||||||
|
}
|
||||||
|
} else if (role == ColorsRole) {
|
||||||
|
return QVariant::fromValue((void*)(&data.colors));
|
||||||
|
} else if (role == BytesRole && index.column() == Column::DATA && item.id.source != INVALID_SOURCE) {
|
||||||
|
return QVariant::fromValue((void*)(&data.dat));
|
||||||
|
} else if (role == Qt::ToolTipRole && index.column() == Column::NAME) {
|
||||||
|
auto msg = dbc()->msg(item.id);
|
||||||
|
auto tooltip = item.name;
|
||||||
|
if (msg && !msg->comment.isEmpty()) tooltip += "<br /><span style=\"color:gray;\">" + msg->comment + "</span>";
|
||||||
|
return tooltip;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageListModel::setFilterStrings(const QMap<int, QString> &filters) {
|
||||||
|
filters_ = filters;
|
||||||
|
filterAndSort();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageListModel::dbcModified() {
|
||||||
|
dbc_messages_.clear();
|
||||||
|
for (const auto &[_, m] : dbc()->getMessages(-1)) {
|
||||||
|
dbc_messages_.insert(MessageId{.source = INVALID_SOURCE, .address = m.address});
|
||||||
|
}
|
||||||
|
filterAndSort();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageListModel::sortItems(std::vector<MessageListModel::Item> &items) {
|
||||||
|
auto do_sort = [order = sort_order](std::vector<MessageListModel::Item> &m, auto proj) {
|
||||||
|
std::stable_sort(m.begin(), m.end(), [order, proj = std::move(proj)](auto &l, auto &r) {
|
||||||
|
return order == Qt::AscendingOrder ? proj(l) < proj(r) : proj(l) > proj(r);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
switch (sort_column) {
|
||||||
|
case Column::NAME: do_sort(items, [](auto &item) { return std::tie(item.name, item.id); }); break;
|
||||||
|
case Column::SOURCE: do_sort(items, [](auto &item) { return std::tie(item.id.source, item.id); }); break;
|
||||||
|
case Column::ADDRESS: do_sort(items, [](auto &item) { return std::tie(item.id.address, item.id);}); break;
|
||||||
|
case Column::NODE: do_sort(items, [](auto &item) { return std::tie(item.node, item.id);}); break;
|
||||||
|
case Column::FREQ: do_sort(items, [](auto &item) { return std::make_pair(can->lastMessage(item.id).freq, item.id); }); break;
|
||||||
|
case Column::COUNT: do_sort(items, [](auto &item) { return std::make_pair(can->lastMessage(item.id).count, item.id); }); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool parseRange(const QString &filter, uint32_t value, int base = 10) {
|
||||||
|
// Parse out filter string into a range (e.g. "1" -> {1, 1}, "1-3" -> {1, 3}, "1-" -> {1, inf})
|
||||||
|
unsigned int min = std::numeric_limits<unsigned int>::min();
|
||||||
|
unsigned int max = std::numeric_limits<unsigned int>::max();
|
||||||
|
auto s = filter.split('-');
|
||||||
|
bool ok = s.size() >= 1 && s.size() <= 2;
|
||||||
|
if (ok && !s[0].isEmpty()) min = s[0].toUInt(&ok, base);
|
||||||
|
if (ok && s.size() == 1) {
|
||||||
|
max = min;
|
||||||
|
} else if (ok && s.size() == 2 && !s[1].isEmpty()) {
|
||||||
|
max = s[1].toUInt(&ok, base);
|
||||||
|
}
|
||||||
|
return ok && value >= min && value <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MessageListModel::match(const MessageListModel::Item &item) {
|
||||||
|
if (filters_.isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
bool match = true;
|
||||||
|
const auto &data = can->lastMessage(item.id);
|
||||||
|
for (auto it = filters_.cbegin(); it != filters_.cend() && match; ++it) {
|
||||||
|
const QString &txt = it.value();
|
||||||
|
switch (it.key()) {
|
||||||
|
case Column::NAME: {
|
||||||
|
match = item.name.contains(txt, Qt::CaseInsensitive);
|
||||||
|
if (!match) {
|
||||||
|
const auto m = dbc()->msg(item.id);
|
||||||
|
match = m && std::any_of(m->sigs.cbegin(), m->sigs.cend(),
|
||||||
|
[&txt](const auto &s) { return s->name.contains(txt, Qt::CaseInsensitive); });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Column::SOURCE:
|
||||||
|
match = parseRange(txt, item.id.source);
|
||||||
|
break;
|
||||||
|
case Column::ADDRESS:
|
||||||
|
match = QString::number(item.id.address, 16).contains(txt, Qt::CaseInsensitive);
|
||||||
|
match = match || parseRange(txt, item.id.address, 16);
|
||||||
|
break;
|
||||||
|
case Column::NODE:
|
||||||
|
match = item.node.contains(txt, Qt::CaseInsensitive);
|
||||||
|
break;
|
||||||
|
case Column::FREQ:
|
||||||
|
// TODO: Hide stale messages?
|
||||||
|
match = parseRange(txt, data.freq);
|
||||||
|
break;
|
||||||
|
case Column::COUNT:
|
||||||
|
match = parseRange(txt, data.count);
|
||||||
|
break;
|
||||||
|
case Column::DATA:
|
||||||
|
match = utils::toHex(data.dat).contains(txt, Qt::CaseInsensitive);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageListModel::filterAndSort() {
|
||||||
|
// merge CAN and DBC messages
|
||||||
|
std::vector<MessageId> all_messages;
|
||||||
|
all_messages.reserve(can->lastMessages().size() + dbc_messages_.size());
|
||||||
|
auto dbc_msgs = dbc_messages_;
|
||||||
|
for (const auto &[id, m] : can->lastMessages()) {
|
||||||
|
all_messages.push_back(id);
|
||||||
|
dbc_msgs.erase(MessageId{.source = INVALID_SOURCE, .address = id.address});
|
||||||
|
}
|
||||||
|
all_messages.insert(all_messages.end(), dbc_msgs.begin(), dbc_msgs.end());
|
||||||
|
|
||||||
|
// filter and sort
|
||||||
|
std::vector<Item> items;
|
||||||
|
for (const auto &id : all_messages) {
|
||||||
|
auto msg = dbc()->msg(id);
|
||||||
|
Item item = {.id = id,
|
||||||
|
.name = msg ? msg->name : UNTITLED,
|
||||||
|
.node = msg ? msg->transmitter : QString()};
|
||||||
|
if (match(item))
|
||||||
|
items.emplace_back(item);
|
||||||
|
}
|
||||||
|
sortItems(items);
|
||||||
|
|
||||||
|
if (items_ != items) {
|
||||||
|
beginResetModel();
|
||||||
|
items_ = std::move(items);
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageListModel::msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids) {
|
||||||
|
if (has_new_ids || filters_.contains(Column::FREQ) || filters_.contains(Column::COUNT) || filters_.contains(Column::DATA)) {
|
||||||
|
filterAndSort();
|
||||||
|
}
|
||||||
|
for (int i = 0; i < items_.size(); ++i) {
|
||||||
|
if (!new_msgs || new_msgs->count(items_[i].id)) {
|
||||||
|
for (int col = Column::FREQ; col < columnCount(); ++col)
|
||||||
|
emit dataChanged(index(i, col), index(i, col), {Qt::DisplayRole});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageListModel::sort(int column, Qt::SortOrder order) {
|
||||||
|
if (column != Column::DATA) {
|
||||||
|
sort_column = column;
|
||||||
|
sort_order = order;
|
||||||
|
filterAndSort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageView
|
||||||
|
|
||||||
|
void MessageView::drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||||
|
QTreeView::drawRow(painter, option, index);
|
||||||
|
const int gridHint = style()->styleHint(QStyle::SH_Table_GridLineColor, &option, this);
|
||||||
|
const QColor gridColor = QColor::fromRgba(static_cast<QRgb>(gridHint));
|
||||||
|
QPen old_pen = painter->pen();
|
||||||
|
painter->setPen(gridColor);
|
||||||
|
painter->drawLine(option.rect.left(), option.rect.bottom(), option.rect.right(), option.rect.bottom());
|
||||||
|
|
||||||
|
auto y = option.rect.y();
|
||||||
|
painter->translate(visualRect(model()->index(0, 0)).x() - indentation() - .5, -.5);
|
||||||
|
for (int i = 0; i < header()->count(); ++i) {
|
||||||
|
painter->translate(header()->sectionSize(header()->logicalIndex(i)), 0);
|
||||||
|
painter->drawLine(0, y, 0, y + option.rect.height());
|
||||||
|
}
|
||||||
|
painter->setPen(old_pen);
|
||||||
|
painter->resetTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageView::dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector<int> &roles) {
|
||||||
|
// Bypass the slow call to QTreeView::dataChanged.
|
||||||
|
// QTreeView::dataChanged will invalidate the height cache and that's what we don't need in MessageView.
|
||||||
|
QAbstractItemView::dataChanged(topLeft, bottomRight, roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageView::updateBytesSectionSize() {
|
||||||
|
auto delegate = ((MessageBytesDelegate *)itemDelegate());
|
||||||
|
int max_bytes = 8;
|
||||||
|
if (!delegate->multipleLines()) {
|
||||||
|
for (const auto &[_, m] : can->lastMessages()) {
|
||||||
|
max_bytes = std::max<int>(max_bytes, m.dat.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header()->resizeSection(MessageListModel::Column::DATA, delegate->sizeForBytes(max_bytes).width());
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageViewHeader
|
||||||
|
|
||||||
|
MessageViewHeader::MessageViewHeader(QWidget *parent) : QHeaderView(Qt::Horizontal, parent) {
|
||||||
|
QObject::connect(this, &QHeaderView::sectionResized, this, &MessageViewHeader::updateHeaderPositions);
|
||||||
|
QObject::connect(this, &QHeaderView::sectionMoved, this, &MessageViewHeader::updateHeaderPositions);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageViewHeader::updateFilters() {
|
||||||
|
QMap<int, QString> filters;
|
||||||
|
for (int i = 0; i < count(); i++) {
|
||||||
|
if (editors[i] && !editors[i]->text().isEmpty()) {
|
||||||
|
filters[i] = editors[i]->text();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qobject_cast<MessageListModel*>(model())->setFilterStrings(filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageViewHeader::updateHeaderPositions() {
|
||||||
|
QSize sz = QHeaderView::sizeHint();
|
||||||
|
for (int i = 0; i < count(); i++) {
|
||||||
|
if (editors[i]) {
|
||||||
|
int h = editors[i]->sizeHint().height();
|
||||||
|
editors[i]->setGeometry(sectionViewportPosition(i), sz.height(), sectionSize(i), h);
|
||||||
|
editors[i]->setHidden(isSectionHidden(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageViewHeader::updateGeometries() {
|
||||||
|
for (int i = 0; i < count(); i++) {
|
||||||
|
if (!editors[i]) {
|
||||||
|
QString column_name = model()->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString();
|
||||||
|
editors[i] = new QLineEdit(this);
|
||||||
|
editors[i]->setClearButtonEnabled(true);
|
||||||
|
editors[i]->setPlaceholderText(tr("Filter %1").arg(column_name));
|
||||||
|
|
||||||
|
QObject::connect(editors[i], &QLineEdit::textChanged, this, &MessageViewHeader::updateFilters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setViewportMargins(0, 0, 0, editors[0] ? editors[0]->sizeHint().height() : 0);
|
||||||
|
|
||||||
|
QHeaderView::updateGeometries();
|
||||||
|
updateHeaderPositions();
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize MessageViewHeader::sizeHint() const {
|
||||||
|
QSize sz = QHeaderView::sizeHint();
|
||||||
|
return editors[0] ? QSize(sz.width(), sz.height() + editors[0]->height() + 1) : sz;
|
||||||
|
}
|
||||||
116
tools/cabana/messageswidget.h
Executable file
116
tools/cabana/messageswidget.h
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <optional>
|
||||||
|
#include <set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QAbstractTableModel>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QToolBar>
|
||||||
|
#include <QTreeView>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
class MessageListModel : public QAbstractTableModel {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Column {
|
||||||
|
NAME = 0,
|
||||||
|
SOURCE,
|
||||||
|
ADDRESS,
|
||||||
|
NODE,
|
||||||
|
FREQ,
|
||||||
|
COUNT,
|
||||||
|
DATA,
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageListModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||||
|
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||||
|
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return Column::DATA + 1; }
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const;
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return items_.size(); }
|
||||||
|
void sort(int column, Qt::SortOrder order = Qt::AscendingOrder) override;
|
||||||
|
void setFilterStrings(const QMap<int, QString> &filters);
|
||||||
|
void msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids);
|
||||||
|
void filterAndSort();
|
||||||
|
void dbcModified();
|
||||||
|
|
||||||
|
struct Item {
|
||||||
|
MessageId id;
|
||||||
|
QString name;
|
||||||
|
QString node;
|
||||||
|
bool operator==(const Item &other) const {
|
||||||
|
return id == other.id && name == other.name && node == other.node;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::vector<Item> items_;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void sortItems(std::vector<MessageListModel::Item> &items);
|
||||||
|
bool match(const MessageListModel::Item &id);
|
||||||
|
|
||||||
|
QMap<int, QString> filters_;
|
||||||
|
std::set<MessageId> dbc_messages_;
|
||||||
|
int sort_column = 0;
|
||||||
|
Qt::SortOrder sort_order = Qt::AscendingOrder;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MessageView : public QTreeView {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
MessageView(QWidget *parent) : QTreeView(parent) {}
|
||||||
|
void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
void drawBranches(QPainter *painter, const QRect &rect, const QModelIndex &index) const override {}
|
||||||
|
void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector<int> &roles = QVector<int>()) override;
|
||||||
|
void updateBytesSectionSize();
|
||||||
|
};
|
||||||
|
|
||||||
|
class MessageViewHeader : public QHeaderView {
|
||||||
|
// https://stackoverflow.com/a/44346317
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
MessageViewHeader(QWidget *parent);
|
||||||
|
void updateHeaderPositions();
|
||||||
|
void updateGeometries() override;
|
||||||
|
QSize sizeHint() const override;
|
||||||
|
void updateFilters();
|
||||||
|
|
||||||
|
QMap<int, QLineEdit *> editors;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MessagesWidget : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
MessagesWidget(QWidget *parent);
|
||||||
|
void selectMessage(const MessageId &message_id);
|
||||||
|
QByteArray saveHeaderState() const { return view->header()->saveState(); }
|
||||||
|
bool restoreHeaderState(const QByteArray &state) const { return view->header()->restoreState(state); }
|
||||||
|
void suppressHighlighted();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void msgSelectionChanged(const MessageId &message_id);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
QToolBar *createToolBar();
|
||||||
|
void headerContextMenuEvent(const QPoint &pos);
|
||||||
|
void menuAboutToShow();
|
||||||
|
void setMultiLineBytes(bool multi);
|
||||||
|
void updateTitle();
|
||||||
|
|
||||||
|
MessageView *view;
|
||||||
|
MessageViewHeader *header;
|
||||||
|
MessageBytesDelegate *delegate;
|
||||||
|
std::optional<MessageId> current_msg_id;
|
||||||
|
MessageListModel *model;
|
||||||
|
QPushButton *suppress_add;
|
||||||
|
QPushButton *suppress_clear;
|
||||||
|
QLabel *num_msg_label;
|
||||||
|
QMenu *menu;
|
||||||
|
};
|
||||||
139
tools/cabana/settings.cc
Executable file
139
tools/cabana/settings.cc
Executable file
@@ -0,0 +1,139 @@
|
|||||||
|
#include "tools/cabana/settings.h"
|
||||||
|
|
||||||
|
#include <QAbstractButton>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
#include "tools/cabana/util.h"
|
||||||
|
|
||||||
|
Settings settings;
|
||||||
|
|
||||||
|
template <class SettingOperation>
|
||||||
|
void settings_op(SettingOperation op) {
|
||||||
|
QSettings s("cabana");
|
||||||
|
op(s, "absolute_time", settings.absolute_time);
|
||||||
|
op(s, "fps", settings.fps);
|
||||||
|
op(s, "max_cached_minutes", settings.max_cached_minutes);
|
||||||
|
op(s, "chart_height", settings.chart_height);
|
||||||
|
op(s, "chart_range", settings.chart_range);
|
||||||
|
op(s, "chart_column_count", settings.chart_column_count);
|
||||||
|
op(s, "last_dir", settings.last_dir);
|
||||||
|
op(s, "last_route_dir", settings.last_route_dir);
|
||||||
|
op(s, "window_state", settings.window_state);
|
||||||
|
op(s, "geometry", settings.geometry);
|
||||||
|
op(s, "video_splitter_state", settings.video_splitter_state);
|
||||||
|
op(s, "recent_files", settings.recent_files);
|
||||||
|
op(s, "message_header_state", settings.message_header_state);
|
||||||
|
op(s, "chart_series_type", settings.chart_series_type);
|
||||||
|
op(s, "theme", settings.theme);
|
||||||
|
op(s, "sparkline_range", settings.sparkline_range);
|
||||||
|
op(s, "multiple_lines_hex", settings.multiple_lines_hex);
|
||||||
|
op(s, "log_livestream", settings.log_livestream);
|
||||||
|
op(s, "log_path", settings.log_path);
|
||||||
|
op(s, "drag_direction", (int &)settings.drag_direction);
|
||||||
|
op(s, "suppress_defined_signals", settings.suppress_defined_signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings::Settings() {
|
||||||
|
last_dir = last_route_dir = QDir::homePath();
|
||||||
|
log_path = QStandardPaths::writableLocation(QStandardPaths::HomeLocation) + "/cabana_live_stream/";
|
||||||
|
settings_op([](QSettings &s, const QString &key, auto &value) {
|
||||||
|
if (auto v = s.value(key); v.canConvert<std::decay_t<decltype(value)>>())
|
||||||
|
value = v.value<std::decay_t<decltype(value)>>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings::~Settings() {
|
||||||
|
settings_op([](QSettings &s, const QString &key, auto &v) { s.setValue(key, v); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsDlg
|
||||||
|
|
||||||
|
SettingsDlg::SettingsDlg(QWidget *parent) : QDialog(parent) {
|
||||||
|
setWindowTitle(tr("Settings"));
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
QGroupBox *groupbox = new QGroupBox("General");
|
||||||
|
QFormLayout *form_layout = new QFormLayout(groupbox);
|
||||||
|
|
||||||
|
form_layout->addRow(tr("Color Theme"), theme = new QComboBox(this));
|
||||||
|
theme->setToolTip(tr("You may need to restart cabana after changes theme"));
|
||||||
|
theme->addItems({tr("Automatic"), tr("Light"), tr("Dark")});
|
||||||
|
theme->setCurrentIndex(settings.theme);
|
||||||
|
|
||||||
|
form_layout->addRow("FPS", fps = new QSpinBox(this));
|
||||||
|
fps->setRange(10, 100);
|
||||||
|
fps->setSingleStep(10);
|
||||||
|
fps->setValue(settings.fps);
|
||||||
|
|
||||||
|
form_layout->addRow(tr("Max Cached Minutes"), cached_minutes = new QSpinBox(this));
|
||||||
|
cached_minutes->setRange(5, 60);
|
||||||
|
cached_minutes->setSingleStep(1);
|
||||||
|
cached_minutes->setValue(settings.max_cached_minutes);
|
||||||
|
main_layout->addWidget(groupbox);
|
||||||
|
|
||||||
|
groupbox = new QGroupBox("New Signal Settings");
|
||||||
|
form_layout = new QFormLayout(groupbox);
|
||||||
|
form_layout->addRow(tr("Drag Direction"), drag_direction = new QComboBox(this));
|
||||||
|
drag_direction->addItems({tr("MSB First"), tr("LSB First"), tr("Always Little Endian"), tr("Always Big Endian")});
|
||||||
|
drag_direction->setCurrentIndex(settings.drag_direction);
|
||||||
|
main_layout->addWidget(groupbox);
|
||||||
|
|
||||||
|
groupbox = new QGroupBox("Chart");
|
||||||
|
form_layout = new QFormLayout(groupbox);
|
||||||
|
form_layout->addRow(tr("Default Series Type"), chart_series_type = new QComboBox(this));
|
||||||
|
chart_series_type->addItems({tr("Line"), tr("Step Line"), tr("Scatter")});
|
||||||
|
chart_series_type->setCurrentIndex(settings.chart_series_type);
|
||||||
|
|
||||||
|
form_layout->addRow(tr("Chart Height"), chart_height = new QSpinBox(this));
|
||||||
|
chart_height->setRange(100, 500);
|
||||||
|
chart_height->setSingleStep(10);
|
||||||
|
chart_height->setValue(settings.chart_height);
|
||||||
|
main_layout->addWidget(groupbox);
|
||||||
|
|
||||||
|
log_livestream = new QGroupBox(tr("Enable live stream logging"), this);
|
||||||
|
log_livestream->setCheckable(true);
|
||||||
|
QHBoxLayout *path_layout = new QHBoxLayout(log_livestream);
|
||||||
|
path_layout->addWidget(log_path = new QLineEdit(settings.log_path, this));
|
||||||
|
log_path->setReadOnly(true);
|
||||||
|
auto browse_btn = new QPushButton(tr("B&rowse..."));
|
||||||
|
path_layout->addWidget(browse_btn);
|
||||||
|
main_layout->addWidget(log_livestream);
|
||||||
|
|
||||||
|
auto buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||||
|
main_layout->addWidget(buttonBox);
|
||||||
|
setFixedSize(400, sizeHint().height());
|
||||||
|
|
||||||
|
QObject::connect(browse_btn, &QPushButton::clicked, [this]() {
|
||||||
|
QString fn = QFileDialog::getExistingDirectory(
|
||||||
|
this, tr("Log File Location"),
|
||||||
|
QStandardPaths::writableLocation(QStandardPaths::HomeLocation),
|
||||||
|
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
|
||||||
|
if (!fn.isEmpty()) {
|
||||||
|
log_path->setText(fn);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
QObject::connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
QObject::connect(buttonBox, &QDialogButtonBox::accepted, this, &SettingsDlg::save);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDlg::save() {
|
||||||
|
if (std::exchange(settings.theme, theme->currentIndex()) != settings.theme) {
|
||||||
|
// set theme before emit changed
|
||||||
|
utils::setTheme(settings.theme);
|
||||||
|
}
|
||||||
|
settings.fps = fps->value();
|
||||||
|
settings.max_cached_minutes = cached_minutes->value();
|
||||||
|
settings.chart_series_type = chart_series_type->currentIndex();
|
||||||
|
settings.chart_height = chart_height->value();
|
||||||
|
settings.log_livestream = log_livestream->isChecked();
|
||||||
|
settings.log_path = log_path->text();
|
||||||
|
settings.drag_direction = (Settings::DragDirection)drag_direction->currentIndex();
|
||||||
|
emit settings.changed();
|
||||||
|
QDialog::accept();
|
||||||
|
}
|
||||||
67
tools/cabana/settings.h
Executable file
67
tools/cabana/settings.h
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QGroupBox>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QSpinBox>
|
||||||
|
|
||||||
|
#define LIGHT_THEME 1
|
||||||
|
#define DARK_THEME 2
|
||||||
|
|
||||||
|
class Settings : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum DragDirection {
|
||||||
|
MsbFirst,
|
||||||
|
LsbFirst,
|
||||||
|
AlwaysLE,
|
||||||
|
AlwaysBE,
|
||||||
|
};
|
||||||
|
|
||||||
|
Settings();
|
||||||
|
~Settings();
|
||||||
|
|
||||||
|
bool absolute_time = false;
|
||||||
|
int fps = 10;
|
||||||
|
int max_cached_minutes = 30;
|
||||||
|
int chart_height = 200;
|
||||||
|
int chart_column_count = 1;
|
||||||
|
int chart_range = 3 * 60; // 3 minutes
|
||||||
|
int chart_series_type = 0;
|
||||||
|
int theme = 0;
|
||||||
|
int sparkline_range = 15; // 15 seconds
|
||||||
|
bool multiple_lines_hex = false;
|
||||||
|
bool log_livestream = true;
|
||||||
|
bool suppress_defined_signals = false;
|
||||||
|
QString log_path;
|
||||||
|
QString last_dir;
|
||||||
|
QString last_route_dir;
|
||||||
|
QByteArray geometry;
|
||||||
|
QByteArray video_splitter_state;
|
||||||
|
QByteArray window_state;
|
||||||
|
QStringList recent_files;
|
||||||
|
QByteArray message_header_state;
|
||||||
|
DragDirection drag_direction = MsbFirst;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void changed();
|
||||||
|
};
|
||||||
|
|
||||||
|
class SettingsDlg : public QDialog {
|
||||||
|
public:
|
||||||
|
SettingsDlg(QWidget *parent);
|
||||||
|
void save();
|
||||||
|
QSpinBox *fps;
|
||||||
|
QSpinBox *cached_minutes;
|
||||||
|
QSpinBox *chart_height;
|
||||||
|
QComboBox *chart_series_type;
|
||||||
|
QComboBox *theme;
|
||||||
|
QGroupBox *log_livestream;
|
||||||
|
QLineEdit *log_path;
|
||||||
|
QComboBox *drag_direction;
|
||||||
|
};
|
||||||
|
|
||||||
|
extern Settings settings;
|
||||||
727
tools/cabana/signalview.cc
Executable file
727
tools/cabana/signalview.cc
Executable file
@@ -0,0 +1,727 @@
|
|||||||
|
#include "tools/cabana/signalview.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include <QCompleter>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPainterPath>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScrollBar>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
#include "tools/cabana/commands.h"
|
||||||
|
|
||||||
|
// SignalModel
|
||||||
|
|
||||||
|
static QString signalTypeToString(cabana::Signal::Type type) {
|
||||||
|
if (type == cabana::Signal::Type::Multiplexor) return "Multiplexor Signal";
|
||||||
|
else if (type == cabana::Signal::Type::Multiplexed) return "Multiplexed Signal";
|
||||||
|
else return "Normal Signal";
|
||||||
|
}
|
||||||
|
|
||||||
|
SignalModel::SignalModel(QObject *parent) : root(new Item), QAbstractItemModel(parent) {
|
||||||
|
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &SignalModel::refresh);
|
||||||
|
QObject::connect(dbc(), &DBCManager::msgUpdated, this, &SignalModel::handleMsgChanged);
|
||||||
|
QObject::connect(dbc(), &DBCManager::msgRemoved, this, &SignalModel::handleMsgChanged);
|
||||||
|
QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalModel::handleSignalAdded);
|
||||||
|
QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalModel::handleSignalUpdated);
|
||||||
|
QObject::connect(dbc(), &DBCManager::signalRemoved, this, &SignalModel::handleSignalRemoved);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalModel::insertItem(SignalModel::Item *parent_item, int pos, const cabana::Signal *sig) {
|
||||||
|
Item *item = new Item{.sig = sig, .parent = parent_item, .title = sig->name, .type = Item::Sig};
|
||||||
|
parent_item->children.insert(pos, item);
|
||||||
|
QString titles[]{"Name", "Size", "Receiver Nodes", "Little Endian", "Signed", "Offset", "Factor", "Type",
|
||||||
|
"Multiplex Value", "Extra Info", "Unit", "Comment", "Minimum Value", "Maximum Value", "Value Table"};
|
||||||
|
for (int i = 0; i < std::size(titles); ++i) {
|
||||||
|
item->children.push_back(new Item{.sig = sig, .parent = item, .title = titles[i], .type = (Item::Type)(i + Item::Name)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalModel::setMessage(const MessageId &id) {
|
||||||
|
msg_id = id;
|
||||||
|
filter_str = "";
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalModel::setFilter(const QString &txt) {
|
||||||
|
filter_str = txt;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalModel::refresh() {
|
||||||
|
beginResetModel();
|
||||||
|
root.reset(new SignalModel::Item);
|
||||||
|
if (auto msg = dbc()->msg(msg_id)) {
|
||||||
|
for (auto s : msg->getSignals()) {
|
||||||
|
if (filter_str.isEmpty() || s->name.contains(filter_str, Qt::CaseInsensitive)) {
|
||||||
|
insertItem(root.get(), root->children.size(), s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
SignalModel::Item *SignalModel::getItem(const QModelIndex &index) const {
|
||||||
|
auto item = index.isValid() ? (SignalModel::Item *)index.internalPointer() : nullptr;
|
||||||
|
return item ? item : root.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
int SignalModel::rowCount(const QModelIndex &parent) const {
|
||||||
|
if (parent.isValid() && parent.column() > 0) return 0;
|
||||||
|
|
||||||
|
auto parent_item = getItem(parent);
|
||||||
|
int row_count = parent_item->children.size();
|
||||||
|
if (parent_item->type == Item::Sig && !parent_item->extra_expanded) {
|
||||||
|
row_count -= (Item::Desc - Item::ExtraInfo);
|
||||||
|
}
|
||||||
|
return row_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
Qt::ItemFlags SignalModel::flags(const QModelIndex &index) const {
|
||||||
|
if (!index.isValid()) return Qt::NoItemFlags;
|
||||||
|
|
||||||
|
auto item = getItem(index);
|
||||||
|
Qt::ItemFlags flags = Qt::ItemIsSelectable | Qt::ItemIsEnabled;
|
||||||
|
if (index.column() == 1 && item->type != Item::Sig && item->type != Item::ExtraInfo) {
|
||||||
|
flags |= (item->type == Item::Endian || item->type == Item::Signed) ? Qt::ItemIsUserCheckable : Qt::ItemIsEditable;
|
||||||
|
}
|
||||||
|
if (item->type == Item::MultiplexValue && item->sig->type != cabana::Signal::Type::Multiplexed) {
|
||||||
|
flags &= ~Qt::ItemIsEnabled;
|
||||||
|
}
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
int SignalModel::signalRow(const cabana::Signal *sig) const {
|
||||||
|
for (int i = 0; i < root->children.size(); ++i) {
|
||||||
|
if (root->children[i]->sig == sig) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
QModelIndex SignalModel::index(int row, int column, const QModelIndex &parent) const {
|
||||||
|
if (parent.isValid() && parent.column() != 0) return {};
|
||||||
|
|
||||||
|
auto parent_item = getItem(parent);
|
||||||
|
if (parent_item && row < parent_item->children.size()) {
|
||||||
|
return createIndex(row, column, parent_item->children[row]);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QModelIndex SignalModel::parent(const QModelIndex &index) const {
|
||||||
|
if (!index.isValid()) return {};
|
||||||
|
Item *parent_item = getItem(index)->parent;
|
||||||
|
return !parent_item || parent_item == root.get() ? QModelIndex() : createIndex(parent_item->row(), 0, parent_item);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant SignalModel::data(const QModelIndex &index, int role) const {
|
||||||
|
if (index.isValid()) {
|
||||||
|
const Item *item = getItem(index);
|
||||||
|
if (role == Qt::DisplayRole || role == Qt::EditRole) {
|
||||||
|
if (index.column() == 0) {
|
||||||
|
return item->type == Item::Sig ? item->sig->name : item->title;
|
||||||
|
} else {
|
||||||
|
switch (item->type) {
|
||||||
|
case Item::Sig: return item->sig_val;
|
||||||
|
case Item::Name: return item->sig->name;
|
||||||
|
case Item::Size: return item->sig->size;
|
||||||
|
case Item::Node: return item->sig->receiver_name;
|
||||||
|
case Item::SignalType: return signalTypeToString(item->sig->type);
|
||||||
|
case Item::MultiplexValue: return item->sig->multiplex_value;
|
||||||
|
case Item::Offset: return doubleToString(item->sig->offset);
|
||||||
|
case Item::Factor: return doubleToString(item->sig->factor);
|
||||||
|
case Item::Unit: return item->sig->unit;
|
||||||
|
case Item::Comment: return item->sig->comment;
|
||||||
|
case Item::Min: return doubleToString(item->sig->min);
|
||||||
|
case Item::Max: return doubleToString(item->sig->max);
|
||||||
|
case Item::Desc: {
|
||||||
|
QStringList val_desc;
|
||||||
|
for (auto &[val, desc] : item->sig->val_desc) {
|
||||||
|
val_desc << QString("%1 \"%2\"").arg(val).arg(desc);
|
||||||
|
}
|
||||||
|
return val_desc.join(" ");
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (role == Qt::CheckStateRole && index.column() == 1) {
|
||||||
|
if (item->type == Item::Endian) return item->sig->is_little_endian ? Qt::Checked : Qt::Unchecked;
|
||||||
|
if (item->type == Item::Signed) return item->sig->is_signed ? Qt::Checked : Qt::Unchecked;
|
||||||
|
} else if (role == Qt::DecorationRole && index.column() == 0 && item->type == Item::ExtraInfo) {
|
||||||
|
return utils::icon(item->parent->extra_expanded ? "chevron-compact-down" : "chevron-compact-up");
|
||||||
|
} else if (role == Qt::ToolTipRole && item->type == Item::Sig) {
|
||||||
|
return (index.column() == 0) ? signalToolTip(item->sig) : QString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SignalModel::setData(const QModelIndex &index, const QVariant &value, int role) {
|
||||||
|
if (role != Qt::EditRole && role != Qt::CheckStateRole) return false;
|
||||||
|
|
||||||
|
Item *item = getItem(index);
|
||||||
|
cabana::Signal s = *item->sig;
|
||||||
|
switch (item->type) {
|
||||||
|
case Item::Name: s.name = value.toString(); break;
|
||||||
|
case Item::Size: s.size = value.toInt(); break;
|
||||||
|
case Item::Node: s.receiver_name = value.toString().trimmed(); break;
|
||||||
|
case Item::SignalType: s.type = (cabana::Signal::Type)value.toInt(); break;
|
||||||
|
case Item::MultiplexValue: s.multiplex_value = value.toInt(); break;
|
||||||
|
case Item::Endian: s.is_little_endian = value.toBool(); break;
|
||||||
|
case Item::Signed: s.is_signed = value.toBool(); break;
|
||||||
|
case Item::Offset: s.offset = value.toDouble(); break;
|
||||||
|
case Item::Factor: s.factor = value.toDouble(); break;
|
||||||
|
case Item::Unit: s.unit = value.toString(); break;
|
||||||
|
case Item::Comment: s.comment = value.toString(); break;
|
||||||
|
case Item::Min: s.min = value.toDouble(); break;
|
||||||
|
case Item::Max: s.max = value.toDouble(); break;
|
||||||
|
case Item::Desc: s.val_desc = value.value<ValueDescription>(); break;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
bool ret = saveSignal(item->sig, s);
|
||||||
|
emit dataChanged(index, index, {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole});
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalModel::showExtraInfo(const QModelIndex &index) {
|
||||||
|
auto item = getItem(index);
|
||||||
|
if (item->type == Item::ExtraInfo) {
|
||||||
|
if (!item->parent->extra_expanded) {
|
||||||
|
item->parent->extra_expanded = true;
|
||||||
|
beginInsertRows(index.parent(), Item::ExtraInfo - 2, Item::Desc - 2);
|
||||||
|
endInsertRows();
|
||||||
|
} else {
|
||||||
|
item->parent->extra_expanded = false;
|
||||||
|
beginRemoveRows(index.parent(), Item::ExtraInfo - 2, Item::Desc - 2);
|
||||||
|
endRemoveRows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SignalModel::saveSignal(const cabana::Signal *origin_s, cabana::Signal &s) {
|
||||||
|
auto msg = dbc()->msg(msg_id);
|
||||||
|
if (s.name != origin_s->name && msg->sig(s.name) != nullptr) {
|
||||||
|
QString text = tr("There is already a signal with the same name '%1'").arg(s.name);
|
||||||
|
QMessageBox::warning(nullptr, tr("Failed to save signal"), text);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.is_little_endian != origin_s->is_little_endian) {
|
||||||
|
s.start_bit = flipBitPos(s.start_bit);
|
||||||
|
}
|
||||||
|
UndoStack::push(new EditSignalCommand(msg_id, origin_s, s));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalModel::handleMsgChanged(MessageId id) {
|
||||||
|
if (id.address == msg_id.address) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalModel::handleSignalAdded(MessageId id, const cabana::Signal *sig) {
|
||||||
|
if (id == msg_id) {
|
||||||
|
if (filter_str.isEmpty()) {
|
||||||
|
int i = dbc()->msg(msg_id)->indexOf(sig);
|
||||||
|
beginInsertRows({}, i, i);
|
||||||
|
insertItem(root.get(), i, sig);
|
||||||
|
endInsertRows();
|
||||||
|
} else if (sig->name.contains(filter_str, Qt::CaseInsensitive)) {
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalModel::handleSignalUpdated(const cabana::Signal *sig) {
|
||||||
|
if (int row = signalRow(sig); row != -1) {
|
||||||
|
emit dataChanged(index(row, 0), index(row, 1), {Qt::DisplayRole, Qt::EditRole, Qt::CheckStateRole});
|
||||||
|
|
||||||
|
if (filter_str.isEmpty()) {
|
||||||
|
// move row when the order changes.
|
||||||
|
int to = dbc()->msg(msg_id)->indexOf(sig);
|
||||||
|
if (to != row) {
|
||||||
|
beginMoveRows({}, row, row, {}, to > row ? to + 1 : to);
|
||||||
|
root->children.move(row, to);
|
||||||
|
endMoveRows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalModel::handleSignalRemoved(const cabana::Signal *sig) {
|
||||||
|
if (int row = signalRow(sig); row != -1) {
|
||||||
|
beginRemoveRows({}, row, row);
|
||||||
|
delete root->children.takeAt(row);
|
||||||
|
endRemoveRows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalItemDelegate
|
||||||
|
|
||||||
|
SignalItemDelegate::SignalItemDelegate(QObject *parent) : QStyledItemDelegate(parent) {
|
||||||
|
name_validator = new NameValidator(this);
|
||||||
|
node_validator = new QRegExpValidator(QRegExp("^\\w+(,\\w+)*$"), this);
|
||||||
|
double_validator = new DoubleValidator(this);
|
||||||
|
|
||||||
|
label_font.setPointSize(8);
|
||||||
|
minmax_font.setPixelSize(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize SignalItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||||
|
int width = option.widget->size().width() / 2;
|
||||||
|
if (index.column() == 0) {
|
||||||
|
int spacing = option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + 8;
|
||||||
|
auto text = index.data(Qt::DisplayRole).toString();
|
||||||
|
auto item = (SignalModel::Item *)index.internalPointer();
|
||||||
|
if (item->type == SignalModel::Item::Sig && item->sig->type != cabana::Signal::Type::Normal) {
|
||||||
|
text += item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value);
|
||||||
|
spacing += (option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1) * 2;
|
||||||
|
}
|
||||||
|
auto it = width_cache.find(text);
|
||||||
|
if (it == width_cache.end()) {
|
||||||
|
it = width_cache.insert(text, option.fontMetrics.width(text));
|
||||||
|
}
|
||||||
|
width = std::min<int>(option.widget->size().width() / 3.0, it.value() + spacing);
|
||||||
|
}
|
||||||
|
return {width, option.fontMetrics.height()};
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalItemDelegate::updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||||
|
auto item = (SignalModel::Item *)index.internalPointer();
|
||||||
|
if (editor && item->type == SignalModel::Item::Sig && index.column() == 1) {
|
||||||
|
QRect geom = option.rect;
|
||||||
|
geom.setLeft(geom.right() - editor->sizeHint().width());
|
||||||
|
editor->setGeometry(geom);
|
||||||
|
button_size = geom.size();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QStyledItemDelegate::updateEditorGeometry(editor, option, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||||
|
auto item = (SignalModel::Item *)index.internalPointer();
|
||||||
|
if (item && item->type == SignalModel::Item::Sig) {
|
||||||
|
painter->setRenderHint(QPainter::Antialiasing);
|
||||||
|
if (option.state & QStyle::State_Selected) {
|
||||||
|
painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight));
|
||||||
|
}
|
||||||
|
|
||||||
|
int h_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1;
|
||||||
|
int v_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin);
|
||||||
|
QRect r = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin);
|
||||||
|
if (index.column() == 0) {
|
||||||
|
// color label
|
||||||
|
QPainterPath path;
|
||||||
|
QRect icon_rect{r.x(), r.y(), color_label_width, r.height()};
|
||||||
|
path.addRoundedRect(icon_rect, 3, 3);
|
||||||
|
painter->setPen(item->highlight ? Qt::white : Qt::black);
|
||||||
|
painter->setFont(label_font);
|
||||||
|
painter->fillPath(path, item->sig->color.darker(item->highlight ? 125 : 0));
|
||||||
|
painter->drawText(icon_rect, Qt::AlignCenter, QString::number(item->row() + 1));
|
||||||
|
|
||||||
|
r.setLeft(icon_rect.right() + h_margin * 2);
|
||||||
|
// multiplexer indicator
|
||||||
|
if (item->sig->type != cabana::Signal::Type::Normal) {
|
||||||
|
QString indicator = item->sig->type == cabana::Signal::Type::Multiplexor ? QString(" M ") : QString(" m%1 ").arg(item->sig->multiplex_value);
|
||||||
|
QRect indicator_rect{r.x(), r.y(), option.fontMetrics.width(indicator), r.height()};
|
||||||
|
painter->setBrush(Qt::gray);
|
||||||
|
painter->setPen(Qt::NoPen);
|
||||||
|
painter->drawRoundedRect(indicator_rect, 3, 3);
|
||||||
|
painter->setPen(Qt::white);
|
||||||
|
painter->drawText(indicator_rect, Qt::AlignCenter, indicator);
|
||||||
|
r.setLeft(indicator_rect.right() + h_margin * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// name
|
||||||
|
auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, r.width());
|
||||||
|
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
|
||||||
|
painter->setFont(option.font);
|
||||||
|
painter->drawText(r, option.displayAlignment, text);
|
||||||
|
} else if (index.column() == 1 && !item->sparkline.pixmap.isNull()) {
|
||||||
|
// sparkline
|
||||||
|
QSize sparkline_size = item->sparkline.pixmap.size() / item->sparkline.pixmap.devicePixelRatio();
|
||||||
|
painter->drawPixmap(QRect(r.topLeft(), sparkline_size), item->sparkline.pixmap);
|
||||||
|
// min-max value
|
||||||
|
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
|
||||||
|
QRect rect = r.adjusted(sparkline_size.width() + 1, 0, 0, 0);
|
||||||
|
int value_adjust = 10;
|
||||||
|
if (!item->sparkline.isEmpty() && (item->highlight || option.state & QStyle::State_Selected)) {
|
||||||
|
painter->drawLine(rect.topLeft(), rect.bottomLeft());
|
||||||
|
rect.adjust(5, -v_margin, 0, v_margin);
|
||||||
|
painter->setFont(minmax_font);
|
||||||
|
QString min = QString::number(item->sparkline.min_val);
|
||||||
|
QString max = QString::number(item->sparkline.max_val);
|
||||||
|
painter->drawText(rect, Qt::AlignLeft | Qt::AlignTop, max);
|
||||||
|
painter->drawText(rect, Qt::AlignLeft | Qt::AlignBottom, min);
|
||||||
|
QFontMetrics fm(minmax_font);
|
||||||
|
value_adjust = std::max(fm.width(min), fm.width(max)) + 5;
|
||||||
|
} else if (!item->sparkline.isEmpty() && item->sig->type == cabana::Signal::Type::Multiplexed) {
|
||||||
|
// display freq of multiplexed signal
|
||||||
|
painter->setFont(label_font);
|
||||||
|
QString freq = QString("%1 hz").arg(item->sparkline.freq(), 0, 'g', 2);
|
||||||
|
painter->drawText(rect.adjusted(5, 0, 0, 0), Qt::AlignLeft | Qt::AlignVCenter, freq);
|
||||||
|
value_adjust = QFontMetrics(label_font).width(freq) + 10;
|
||||||
|
}
|
||||||
|
// signal value
|
||||||
|
painter->setFont(option.font);
|
||||||
|
rect.adjust(value_adjust, 0, -button_size.width(), 0);
|
||||||
|
auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width());
|
||||||
|
painter->drawText(rect, Qt::AlignRight | Qt::AlignVCenter, text);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
QStyledItemDelegate::paint(painter, option, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget *SignalItemDelegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||||
|
auto item = (SignalModel::Item *)index.internalPointer();
|
||||||
|
if (item->type == SignalModel::Item::Name || item->type == SignalModel::Item::Node || item->type == SignalModel::Item::Offset ||
|
||||||
|
item->type == SignalModel::Item::Factor || item->type == SignalModel::Item::MultiplexValue ||
|
||||||
|
item->type == SignalModel::Item::Min || item->type == SignalModel::Item::Max) {
|
||||||
|
QLineEdit *e = new QLineEdit(parent);
|
||||||
|
e->setFrame(false);
|
||||||
|
if (item->type == SignalModel::Item::Name) e->setValidator(name_validator);
|
||||||
|
else if (item->type == SignalModel::Item::Node) e->setValidator(node_validator);
|
||||||
|
else e->setValidator(double_validator);
|
||||||
|
|
||||||
|
if (item->type == SignalModel::Item::Name) {
|
||||||
|
QCompleter *completer = new QCompleter(dbc()->signalNames(), e);
|
||||||
|
completer->setCaseSensitivity(Qt::CaseInsensitive);
|
||||||
|
completer->setFilterMode(Qt::MatchContains);
|
||||||
|
e->setCompleter(completer);
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
} else if (item->type == SignalModel::Item::Size) {
|
||||||
|
QSpinBox *spin = new QSpinBox(parent);
|
||||||
|
spin->setFrame(false);
|
||||||
|
spin->setRange(1, 64);
|
||||||
|
return spin;
|
||||||
|
} else if (item->type == SignalModel::Item::SignalType) {
|
||||||
|
QComboBox *c = new QComboBox(parent);
|
||||||
|
c->addItem(signalTypeToString(cabana::Signal::Type::Normal), (int)cabana::Signal::Type::Normal);
|
||||||
|
if (!dbc()->msg(((SignalModel *)index.model())->msg_id)->multiplexor) {
|
||||||
|
c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexor), (int)cabana::Signal::Type::Multiplexor);
|
||||||
|
} else if (item->sig->type != cabana::Signal::Type::Multiplexor) {
|
||||||
|
c->addItem(signalTypeToString(cabana::Signal::Type::Multiplexed), (int)cabana::Signal::Type::Multiplexed);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
} else if (item->type == SignalModel::Item::Desc) {
|
||||||
|
ValueDescriptionDlg dlg(item->sig->val_desc, parent);
|
||||||
|
dlg.setWindowTitle(item->sig->name);
|
||||||
|
if (dlg.exec()) {
|
||||||
|
((QAbstractItemModel *)index.model())->setData(index, QVariant::fromValue(dlg.val_desc));
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return QStyledItemDelegate::createEditor(parent, option, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalItemDelegate::setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const {
|
||||||
|
auto item = (SignalModel::Item *)index.internalPointer();
|
||||||
|
if (item->type == SignalModel::Item::SignalType) {
|
||||||
|
model->setData(index, ((QComboBox*)editor)->currentData().toInt());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QStyledItemDelegate::setModelData(editor, model, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalView
|
||||||
|
|
||||||
|
SignalView::SignalView(ChartsWidget *charts, QWidget *parent) : charts(charts), QFrame(parent) {
|
||||||
|
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
|
||||||
|
// title bar
|
||||||
|
QWidget *title_bar = new QWidget(this);
|
||||||
|
QHBoxLayout *hl = new QHBoxLayout(title_bar);
|
||||||
|
hl->addWidget(signal_count_lb = new QLabel());
|
||||||
|
filter_edit = new QLineEdit(this);
|
||||||
|
QRegularExpression re("\\S+");
|
||||||
|
filter_edit->setValidator(new QRegularExpressionValidator(re, this));
|
||||||
|
filter_edit->setClearButtonEnabled(true);
|
||||||
|
filter_edit->setPlaceholderText(tr("Filter Signal"));
|
||||||
|
hl->addWidget(filter_edit);
|
||||||
|
hl->addStretch(1);
|
||||||
|
|
||||||
|
// WARNING: increasing the maximum range can result in severe performance degradation.
|
||||||
|
// 30s is a reasonable value at present.
|
||||||
|
const int max_range = 30; // 30s
|
||||||
|
settings.sparkline_range = std::clamp(settings.sparkline_range, 1, max_range);
|
||||||
|
hl->addWidget(sparkline_label = new QLabel());
|
||||||
|
hl->addWidget(sparkline_range_slider = new QSlider(Qt::Horizontal, this));
|
||||||
|
sparkline_range_slider->setRange(1, max_range);
|
||||||
|
sparkline_range_slider->setValue(settings.sparkline_range);
|
||||||
|
sparkline_range_slider->setToolTip(tr("Sparkline time range"));
|
||||||
|
|
||||||
|
auto collapse_btn = new ToolButton("dash-square", tr("Collapse All"));
|
||||||
|
collapse_btn->setIconSize({12, 12});
|
||||||
|
hl->addWidget(collapse_btn);
|
||||||
|
|
||||||
|
// tree view
|
||||||
|
tree = new TreeView(this);
|
||||||
|
tree->setModel(model = new SignalModel(this));
|
||||||
|
tree->setItemDelegate(delegate = new SignalItemDelegate(this));
|
||||||
|
tree->setFrameShape(QFrame::NoFrame);
|
||||||
|
tree->setHeaderHidden(true);
|
||||||
|
tree->setMouseTracking(true);
|
||||||
|
tree->setExpandsOnDoubleClick(false);
|
||||||
|
tree->setEditTriggers(QAbstractItemView::AllEditTriggers);
|
||||||
|
tree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||||||
|
tree->header()->setStretchLastSection(true);
|
||||||
|
tree->setMinimumHeight(300);
|
||||||
|
|
||||||
|
// Use a distinctive background for the whole row containing a QSpinBox or QLineEdit
|
||||||
|
QString nodeBgColor = palette().color(QPalette::AlternateBase).name(QColor::HexArgb);
|
||||||
|
tree->setStyleSheet(QString("QSpinBox{background-color:%1;border:none;} QLineEdit{background-color:%1;}").arg(nodeBgColor));
|
||||||
|
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
main_layout->setSpacing(0);
|
||||||
|
main_layout->addWidget(title_bar);
|
||||||
|
main_layout->addWidget(tree);
|
||||||
|
updateToolBar();
|
||||||
|
|
||||||
|
QObject::connect(filter_edit, &QLineEdit::textEdited, model, &SignalModel::setFilter);
|
||||||
|
QObject::connect(sparkline_range_slider, &QSlider::valueChanged, this, &SignalView::setSparklineRange);
|
||||||
|
QObject::connect(collapse_btn, &QPushButton::clicked, tree, &QTreeView::collapseAll);
|
||||||
|
QObject::connect(tree, &QAbstractItemView::clicked, this, &SignalView::rowClicked);
|
||||||
|
QObject::connect(tree, &QTreeView::viewportEntered, [this]() { emit highlight(nullptr); });
|
||||||
|
QObject::connect(tree, &QTreeView::entered, [this](const QModelIndex &index) { emit highlight(model->getItem(index)->sig); });
|
||||||
|
QObject::connect(model, &QAbstractItemModel::modelReset, this, &SignalView::rowsChanged);
|
||||||
|
QObject::connect(model, &QAbstractItemModel::rowsRemoved, this, &SignalView::rowsChanged);
|
||||||
|
QObject::connect(dbc(), &DBCManager::signalAdded, this, &SignalView::handleSignalAdded);
|
||||||
|
QObject::connect(dbc(), &DBCManager::signalUpdated, this, &SignalView::handleSignalUpdated);
|
||||||
|
QObject::connect(tree->verticalScrollBar(), &QScrollBar::valueChanged, [this]() { updateState(); });
|
||||||
|
QObject::connect(tree->verticalScrollBar(), &QScrollBar::rangeChanged, [this]() { updateState(); });
|
||||||
|
QObject::connect(can, &AbstractStream::msgsReceived, this, &SignalView::updateState);
|
||||||
|
QObject::connect(tree->header(), &QHeaderView::sectionResized, [this](int logicalIndex, int oldSize, int newSize) {
|
||||||
|
if (logicalIndex == 1) {
|
||||||
|
value_column_width = newSize;
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setWhatsThis(tr(R"(
|
||||||
|
<b>Signal view</b><br />
|
||||||
|
<!-- TODO: add descprition here -->
|
||||||
|
)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::setMessage(const MessageId &id) {
|
||||||
|
max_value_width = 0;
|
||||||
|
filter_edit->clear();
|
||||||
|
model->setMessage(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::rowsChanged() {
|
||||||
|
for (int i = 0; i < model->rowCount(); ++i) {
|
||||||
|
auto index = model->index(i, 1);
|
||||||
|
if (!tree->indexWidget(index)) {
|
||||||
|
QWidget *w = new QWidget(this);
|
||||||
|
QHBoxLayout *h = new QHBoxLayout(w);
|
||||||
|
int v_margin = style()->pixelMetric(QStyle::PM_FocusFrameVMargin);
|
||||||
|
int h_margin = style()->pixelMetric(QStyle::PM_FocusFrameHMargin);
|
||||||
|
h->setContentsMargins(0, v_margin, -h_margin, v_margin);
|
||||||
|
h->setSpacing(style()->pixelMetric(QStyle::PM_ToolBarItemSpacing));
|
||||||
|
|
||||||
|
auto remove_btn = new ToolButton("x", tr("Remove signal"));
|
||||||
|
auto plot_btn = new ToolButton("graph-up", "");
|
||||||
|
plot_btn->setCheckable(true);
|
||||||
|
h->addWidget(plot_btn);
|
||||||
|
h->addWidget(remove_btn);
|
||||||
|
|
||||||
|
tree->setIndexWidget(index, w);
|
||||||
|
auto sig = model->getItem(index)->sig;
|
||||||
|
QObject::connect(remove_btn, &QToolButton::clicked, [=]() { UndoStack::push(new RemoveSigCommand(model->msg_id, sig)); });
|
||||||
|
QObject::connect(plot_btn, &QToolButton::clicked, [=](bool checked) {
|
||||||
|
emit showChart(model->msg_id, sig, checked, QGuiApplication::keyboardModifiers() & Qt::ShiftModifier);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateToolBar();
|
||||||
|
updateChartState();
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::rowClicked(const QModelIndex &index) {
|
||||||
|
auto item = model->getItem(index);
|
||||||
|
if (item->type == SignalModel::Item::Sig) {
|
||||||
|
auto sig_index = model->index(index.row(), 0, index.parent());
|
||||||
|
tree->setExpanded(sig_index, !tree->isExpanded(sig_index));
|
||||||
|
} else if (item->type == SignalModel::Item::ExtraInfo) {
|
||||||
|
model->showExtraInfo(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::selectSignal(const cabana::Signal *sig, bool expand) {
|
||||||
|
if (int row = model->signalRow(sig); row != -1) {
|
||||||
|
auto idx = model->index(row, 0);
|
||||||
|
if (expand) {
|
||||||
|
tree->setExpanded(idx, !tree->isExpanded(idx));
|
||||||
|
}
|
||||||
|
tree->scrollTo(idx, QAbstractItemView::PositionAtTop);
|
||||||
|
tree->setCurrentIndex(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::updateChartState() {
|
||||||
|
int i = 0;
|
||||||
|
for (auto item : model->root->children) {
|
||||||
|
bool chart_opened = charts->hasSignal(model->msg_id, item->sig);
|
||||||
|
auto buttons = tree->indexWidget(model->index(i, 1))->findChildren<QToolButton *>();
|
||||||
|
if (buttons.size() > 0) {
|
||||||
|
buttons[0]->setChecked(chart_opened);
|
||||||
|
buttons[0]->setToolTip(chart_opened ? tr("Close Plot") : tr("Show Plot\nSHIFT click to add to previous opened plot"));
|
||||||
|
}
|
||||||
|
++i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::signalHovered(const cabana::Signal *sig) {
|
||||||
|
auto &children = model->root->children;
|
||||||
|
for (int i = 0; i < children.size(); ++i) {
|
||||||
|
bool highlight = children[i]->sig == sig;
|
||||||
|
if (std::exchange(children[i]->highlight, highlight) != highlight) {
|
||||||
|
emit model->dataChanged(model->index(i, 0), model->index(i, 0), {Qt::DecorationRole});
|
||||||
|
emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::updateToolBar() {
|
||||||
|
signal_count_lb->setText(tr("Signals: %1").arg(model->rowCount()));
|
||||||
|
sparkline_label->setText(utils::formatSeconds(settings.sparkline_range));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::setSparklineRange(int value) {
|
||||||
|
settings.sparkline_range = value;
|
||||||
|
updateToolBar();
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::handleSignalAdded(MessageId id, const cabana::Signal *sig) {
|
||||||
|
if (id.address == model->msg_id.address) {
|
||||||
|
selectSignal(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::handleSignalUpdated(const cabana::Signal *sig) {
|
||||||
|
if (int row = model->signalRow(sig); row != -1)
|
||||||
|
updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::updateState(const std::set<MessageId> *msgs) {
|
||||||
|
const auto &last_msg = can->lastMessage(model->msg_id);
|
||||||
|
if (model->rowCount() == 0 || (msgs && !msgs->count(model->msg_id)) || last_msg.dat.size() == 0) return;
|
||||||
|
|
||||||
|
for (auto item : model->root->children) {
|
||||||
|
double value = 0;
|
||||||
|
if (item->sig->getValue(last_msg.dat.data(), last_msg.dat.size(), &value)) {
|
||||||
|
item->sig_val = item->sig->formatValue(value);
|
||||||
|
}
|
||||||
|
max_value_width = std::max(max_value_width, fontMetrics().width(item->sig_val));
|
||||||
|
}
|
||||||
|
|
||||||
|
QModelIndex top = tree->indexAt(QPoint(0, 0));
|
||||||
|
if (top.isValid()) {
|
||||||
|
// update visible sparkline
|
||||||
|
int first_visible_row = top.parent().isValid() ? top.parent().row() + 1 : top.row();
|
||||||
|
int last_visible_row = model->rowCount() - 1;
|
||||||
|
QModelIndex bottom = tree->indexAt(tree->viewport()->rect().bottomLeft());
|
||||||
|
if (bottom.isValid()) {
|
||||||
|
last_visible_row = bottom.parent().isValid() ? bottom.parent().row() : bottom.row();
|
||||||
|
}
|
||||||
|
|
||||||
|
const static int min_max_width = QFontMetrics(delegate->minmax_font).width("-000.00") + 5;
|
||||||
|
int available_width = value_column_width - delegate->button_size.width();
|
||||||
|
int value_width = std::min<int>(max_value_width + min_max_width, available_width / 2);
|
||||||
|
QSize size(available_width - value_width,
|
||||||
|
delegate->button_size.height() - style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2);
|
||||||
|
QFutureSynchronizer<void> synchronizer;
|
||||||
|
for (int i = first_visible_row; i <= last_visible_row; ++i) {
|
||||||
|
auto item = model->getItem(model->index(i, 1));
|
||||||
|
synchronizer.addFuture(QtConcurrent::run(
|
||||||
|
&item->sparkline, &Sparkline::update, model->msg_id, item->sig, last_msg.ts, settings.sparkline_range, size));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < model->rowCount(); ++i) {
|
||||||
|
emit model->dataChanged(model->index(i, 1), model->index(i, 1), {Qt::DisplayRole});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SignalView::resizeEvent(QResizeEvent* event) {
|
||||||
|
updateState();
|
||||||
|
QFrame::resizeEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValueDescriptionDlg
|
||||||
|
|
||||||
|
ValueDescriptionDlg::ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent) : QDialog(parent) {
|
||||||
|
QHBoxLayout *toolbar_layout = new QHBoxLayout();
|
||||||
|
QPushButton *add = new QPushButton(utils::icon("plus"), "");
|
||||||
|
QPushButton *remove = new QPushButton(utils::icon("dash"), "");
|
||||||
|
remove->setEnabled(false);
|
||||||
|
toolbar_layout->addWidget(add);
|
||||||
|
toolbar_layout->addWidget(remove);
|
||||||
|
toolbar_layout->addStretch(0);
|
||||||
|
|
||||||
|
table = new QTableWidget(descriptions.size(), 2, this);
|
||||||
|
table->setItemDelegate(new Delegate(this));
|
||||||
|
table->setHorizontalHeaderLabels({"Value", "Description"});
|
||||||
|
table->horizontalHeader()->setStretchLastSection(true);
|
||||||
|
table->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||||
|
table->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||||
|
table->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
|
||||||
|
table->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||||
|
|
||||||
|
int row = 0;
|
||||||
|
for (auto &[val, desc] : descriptions) {
|
||||||
|
table->setItem(row, 0, new QTableWidgetItem(QString::number(val)));
|
||||||
|
table->setItem(row, 1, new QTableWidgetItem(desc));
|
||||||
|
++row;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto btn_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
main_layout->addLayout(toolbar_layout);
|
||||||
|
main_layout->addWidget(table);
|
||||||
|
main_layout->addWidget(btn_box);
|
||||||
|
setMinimumWidth(500);
|
||||||
|
|
||||||
|
QObject::connect(btn_box, &QDialogButtonBox::accepted, this, &ValueDescriptionDlg::save);
|
||||||
|
QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
QObject::connect(add, &QPushButton::clicked, [this]() {
|
||||||
|
table->setRowCount(table->rowCount() + 1);
|
||||||
|
table->setItem(table->rowCount() - 1, 0, new QTableWidgetItem);
|
||||||
|
table->setItem(table->rowCount() - 1, 1, new QTableWidgetItem);
|
||||||
|
});
|
||||||
|
QObject::connect(remove, &QPushButton::clicked, [this]() { table->removeRow(table->currentRow()); });
|
||||||
|
QObject::connect(table, &QTableWidget::itemSelectionChanged, [=]() {
|
||||||
|
remove->setEnabled(table->currentRow() != -1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void ValueDescriptionDlg::save() {
|
||||||
|
for (int i = 0; i < table->rowCount(); ++i) {
|
||||||
|
QString val = table->item(i, 0)->text().trimmed();
|
||||||
|
QString desc = table->item(i, 1)->text().trimmed();
|
||||||
|
if (!val.isEmpty() && !desc.isEmpty()) {
|
||||||
|
val_desc.push_back({val.toDouble(), desc});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QDialog::accept();
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget *ValueDescriptionDlg::Delegate::createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||||
|
QLineEdit *edit = new QLineEdit(parent);
|
||||||
|
edit->setFrame(false);
|
||||||
|
if (index.column() == 0) {
|
||||||
|
edit->setValidator(new DoubleValidator(parent));
|
||||||
|
}
|
||||||
|
return edit;
|
||||||
|
}
|
||||||
149
tools/cabana/signalview.h
Executable file
149
tools/cabana/signalview.h
Executable file
@@ -0,0 +1,149 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <set>
|
||||||
|
|
||||||
|
#include <QAbstractItemModel>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QStyledItemDelegate>
|
||||||
|
#include <QTableWidget>
|
||||||
|
#include <QTreeView>
|
||||||
|
|
||||||
|
#include "tools/cabana/chart/chartswidget.h"
|
||||||
|
#include "tools/cabana/chart/sparkline.h"
|
||||||
|
|
||||||
|
class SignalModel : public QAbstractItemModel {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
struct Item {
|
||||||
|
enum Type {Root, Sig, Name, Size, Node, Endian, Signed, Offset, Factor, SignalType, MultiplexValue, ExtraInfo, Unit, Comment, Min, Max, Desc };
|
||||||
|
~Item() { qDeleteAll(children); }
|
||||||
|
inline int row() { return parent->children.indexOf(this); }
|
||||||
|
|
||||||
|
Type type = Type::Root;
|
||||||
|
Item *parent = nullptr;
|
||||||
|
QList<Item *> children;
|
||||||
|
|
||||||
|
const cabana::Signal *sig = nullptr;
|
||||||
|
QString title;
|
||||||
|
bool highlight = false;
|
||||||
|
bool extra_expanded = false;
|
||||||
|
QString sig_val = "-";
|
||||||
|
Sparkline sparkline;
|
||||||
|
};
|
||||||
|
|
||||||
|
SignalModel(QObject *parent);
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 2; }
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
QModelIndex parent(const QModelIndex &index) const override;
|
||||||
|
Qt::ItemFlags flags(const QModelIndex &index) const override;
|
||||||
|
bool setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override;
|
||||||
|
void setMessage(const MessageId &id);
|
||||||
|
void setFilter(const QString &txt);
|
||||||
|
bool saveSignal(const cabana::Signal *origin_s, cabana::Signal &s);
|
||||||
|
Item *getItem(const QModelIndex &index) const;
|
||||||
|
int signalRow(const cabana::Signal *sig) const;
|
||||||
|
void showExtraInfo(const QModelIndex &index);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void insertItem(SignalModel::Item *parent_item, int pos, const cabana::Signal *sig);
|
||||||
|
void handleSignalAdded(MessageId id, const cabana::Signal *sig);
|
||||||
|
void handleSignalUpdated(const cabana::Signal *sig);
|
||||||
|
void handleSignalRemoved(const cabana::Signal *sig);
|
||||||
|
void handleMsgChanged(MessageId id);
|
||||||
|
void refresh();
|
||||||
|
|
||||||
|
MessageId msg_id;
|
||||||
|
QString filter_str;
|
||||||
|
std::unique_ptr<Item> root;
|
||||||
|
friend class SignalView;
|
||||||
|
friend class SignalItemDelegate;
|
||||||
|
};
|
||||||
|
|
||||||
|
class ValueDescriptionDlg : public QDialog {
|
||||||
|
public:
|
||||||
|
ValueDescriptionDlg(const ValueDescription &descriptions, QWidget *parent);
|
||||||
|
ValueDescription val_desc;
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Delegate : public QStyledItemDelegate {
|
||||||
|
Delegate(QWidget *parent) : QStyledItemDelegate(parent) {}
|
||||||
|
QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
void save();
|
||||||
|
QTableWidget *table;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SignalItemDelegate : public QStyledItemDelegate {
|
||||||
|
public:
|
||||||
|
SignalItemDelegate(QObject *parent);
|
||||||
|
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
void setModelData(QWidget *editor, QAbstractItemModel *model, const QModelIndex &index) const override;
|
||||||
|
|
||||||
|
QValidator *name_validator, *double_validator, *node_validator;
|
||||||
|
QFont label_font, minmax_font;
|
||||||
|
const int color_label_width = 18;
|
||||||
|
mutable QSize button_size;
|
||||||
|
mutable QHash<QString, int> width_cache;
|
||||||
|
};
|
||||||
|
|
||||||
|
class SignalView : public QFrame {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
SignalView(ChartsWidget *charts, QWidget *parent);
|
||||||
|
void setMessage(const MessageId &id);
|
||||||
|
void signalHovered(const cabana::Signal *sig);
|
||||||
|
void updateChartState();
|
||||||
|
void selectSignal(const cabana::Signal *sig, bool expand = false);
|
||||||
|
void rowClicked(const QModelIndex &index);
|
||||||
|
SignalModel *model = nullptr;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void highlight(const cabana::Signal *sig);
|
||||||
|
void showChart(const MessageId &id, const cabana::Signal *sig, bool show, bool merge);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void rowsChanged();
|
||||||
|
void resizeEvent(QResizeEvent* event) override;
|
||||||
|
void updateToolBar();
|
||||||
|
void setSparklineRange(int value);
|
||||||
|
void handleSignalAdded(MessageId id, const cabana::Signal *sig);
|
||||||
|
void handleSignalUpdated(const cabana::Signal *sig);
|
||||||
|
void updateState(const std::set<MessageId> *msgs = nullptr);
|
||||||
|
|
||||||
|
struct TreeView : public QTreeView {
|
||||||
|
TreeView(QWidget *parent) : QTreeView(parent) {}
|
||||||
|
void rowsInserted(const QModelIndex &parent, int start, int end) override {
|
||||||
|
((SignalView *)parentWidget())->rowsChanged();
|
||||||
|
// update widget geometries in QTreeView::rowsInserted
|
||||||
|
QTreeView::rowsInserted(parent, start, end);
|
||||||
|
}
|
||||||
|
void dataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector<int> &roles = QVector<int>()) override {
|
||||||
|
// Bypass the slow call to QTreeView::dataChanged.
|
||||||
|
QAbstractItemView::dataChanged(topLeft, bottomRight, roles);
|
||||||
|
}
|
||||||
|
void leaveEvent(QEvent *event) override {
|
||||||
|
emit static_cast<SignalView *>(parentWidget())->highlight(nullptr);
|
||||||
|
QTreeView::leaveEvent(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
int max_value_width = 0;
|
||||||
|
int value_column_width = 0;
|
||||||
|
TreeView *tree;
|
||||||
|
QLabel *sparkline_label;
|
||||||
|
QSlider *sparkline_range_slider;
|
||||||
|
QLineEdit *filter_edit;
|
||||||
|
ChartsWidget *charts;
|
||||||
|
QLabel *signal_count_lb;
|
||||||
|
SignalItemDelegate *delegate;
|
||||||
|
friend SignalItemDelegate;
|
||||||
|
};
|
||||||
286
tools/cabana/streams/abstractstream.cc
Executable file
286
tools/cabana/streams/abstractstream.cc
Executable file
@@ -0,0 +1,286 @@
|
|||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "common/timing.h"
|
||||||
|
#include "tools/cabana/settings.h"
|
||||||
|
|
||||||
|
static const int EVENT_NEXT_BUFFER_SIZE = 6 * 1024 * 1024; // 6MB
|
||||||
|
|
||||||
|
AbstractStream *can = nullptr;
|
||||||
|
|
||||||
|
StreamNotifier *StreamNotifier::instance() {
|
||||||
|
static StreamNotifier notifier;
|
||||||
|
return ¬ifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
AbstractStream::AbstractStream(QObject *parent) : QObject(parent) {
|
||||||
|
assert(parent != nullptr);
|
||||||
|
event_buffer_ = std::make_unique<MonotonicBuffer>(EVENT_NEXT_BUFFER_SIZE);
|
||||||
|
|
||||||
|
QObject::connect(this, &AbstractStream::privateUpdateLastMsgsSignal, this, &AbstractStream::updateLastMessages, Qt::QueuedConnection);
|
||||||
|
QObject::connect(this, &AbstractStream::seekedTo, this, &AbstractStream::updateLastMsgsTo);
|
||||||
|
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &AbstractStream::updateMasks);
|
||||||
|
QObject::connect(dbc(), &DBCManager::maskUpdated, this, &AbstractStream::updateMasks);
|
||||||
|
QObject::connect(this, &AbstractStream::streamStarted, [this]() {
|
||||||
|
emit StreamNotifier::instance()->changingStream();
|
||||||
|
delete can;
|
||||||
|
can = this;
|
||||||
|
emit StreamNotifier::instance()->streamStarted();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void AbstractStream::updateMasks() {
|
||||||
|
std::lock_guard lk(mutex_);
|
||||||
|
masks_.clear();
|
||||||
|
if (!settings.suppress_defined_signals)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (const auto s : sources) {
|
||||||
|
for (const auto &[address, m] : dbc()->getMessages(s)) {
|
||||||
|
masks_[{.source = (uint8_t)s, .address = address}] = m.mask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// clear bit change counts
|
||||||
|
for (auto &[id, m] : messages_) {
|
||||||
|
auto &mask = masks_[id];
|
||||||
|
const int size = std::min(mask.size(), m.last_changes.size());
|
||||||
|
for (int i = 0; i < size; ++i) {
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
if (((mask[i] >> (7 - j)) & 1) != 0) m.last_changes[i].bit_change_counts[j] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AbstractStream::suppressDefinedSignals(bool suppress) {
|
||||||
|
settings.suppress_defined_signals = suppress;
|
||||||
|
updateMasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t AbstractStream::suppressHighlighted() {
|
||||||
|
std::lock_guard lk(mutex_);
|
||||||
|
size_t cnt = 0;
|
||||||
|
const double cur_ts = currentSec();
|
||||||
|
for (auto &[_, m] : messages_) {
|
||||||
|
for (auto &last_change : m.last_changes) {
|
||||||
|
const double dt = cur_ts - last_change.ts;
|
||||||
|
if (dt < 2.0) {
|
||||||
|
last_change.suppressed = true;
|
||||||
|
}
|
||||||
|
// clear bit change counts
|
||||||
|
last_change.bit_change_counts.fill(0);
|
||||||
|
cnt += last_change.suppressed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cnt;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AbstractStream::clearSuppressed() {
|
||||||
|
std::lock_guard lk(mutex_);
|
||||||
|
for (auto &[_, m] : messages_) {
|
||||||
|
std::for_each(m.last_changes.begin(), m.last_changes.end(), [](auto &c) { c.suppressed = false; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AbstractStream::updateLastMessages() {
|
||||||
|
auto prev_src_size = sources.size();
|
||||||
|
auto prev_msg_size = last_msgs.size();
|
||||||
|
std::set<MessageId> msgs;
|
||||||
|
{
|
||||||
|
std::lock_guard lk(mutex_);
|
||||||
|
for (const auto &id : new_msgs_) {
|
||||||
|
const auto &can_data = messages_[id];
|
||||||
|
current_sec_ = std::max(current_sec_, can_data.ts);
|
||||||
|
last_msgs[id] = can_data;
|
||||||
|
sources.insert(id.source);
|
||||||
|
}
|
||||||
|
msgs = std::move(new_msgs_);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sources.size() != prev_src_size) {
|
||||||
|
updateMasks();
|
||||||
|
emit sourcesUpdated(sources);
|
||||||
|
}
|
||||||
|
emit msgsReceived(&msgs, prev_msg_size != last_msgs.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void AbstractStream::updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size) {
|
||||||
|
std::lock_guard lk(mutex_);
|
||||||
|
messages_[id].compute(id, data, size, sec, getSpeed(), masks_[id]);
|
||||||
|
new_msgs_.insert(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<const CanEvent *> &AbstractStream::events(const MessageId &id) const {
|
||||||
|
static std::vector<const CanEvent *> empty_events;
|
||||||
|
auto it = events_.find(id);
|
||||||
|
return it != events_.end() ? it->second : empty_events;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CanData &AbstractStream::lastMessage(const MessageId &id) {
|
||||||
|
static CanData empty_data = {};
|
||||||
|
auto it = last_msgs.find(id);
|
||||||
|
return it != last_msgs.end() ? it->second : empty_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// it is thread safe to update data in updateLastMsgsTo.
|
||||||
|
// updateLastMsgsTo is always called in UI thread.
|
||||||
|
void AbstractStream::updateLastMsgsTo(double sec) {
|
||||||
|
new_msgs_.clear();
|
||||||
|
messages_.clear();
|
||||||
|
|
||||||
|
current_sec_ = sec;
|
||||||
|
uint64_t last_ts = (sec + routeStartTime()) * 1e9;
|
||||||
|
for (const auto &[id, ev] : events_) {
|
||||||
|
auto it = std::upper_bound(ev.begin(), ev.end(), last_ts, CompareCanEvent());
|
||||||
|
if (it != ev.begin()) {
|
||||||
|
auto prev = std::prev(it);
|
||||||
|
double ts = (*prev)->mono_time / 1e9 - routeStartTime();
|
||||||
|
auto &m = messages_[id];
|
||||||
|
m.compute(id, (*prev)->dat, (*prev)->size, ts, getSpeed(), {});
|
||||||
|
m.count = std::distance(ev.begin(), prev) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool id_changed = messages_.size() != last_msgs.size() ||
|
||||||
|
std::any_of(messages_.cbegin(), messages_.cend(),
|
||||||
|
[this](const auto &m) { return !last_msgs.count(m.first); });
|
||||||
|
last_msgs = messages_;
|
||||||
|
emit msgsReceived(nullptr, id_changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CanEvent *AbstractStream::newEvent(uint64_t mono_time, const cereal::CanData::Reader &c) {
|
||||||
|
auto dat = c.getDat();
|
||||||
|
CanEvent *e = (CanEvent *)event_buffer_->allocate(sizeof(CanEvent) + sizeof(uint8_t) * dat.size());
|
||||||
|
e->src = c.getSrc();
|
||||||
|
e->address = c.getAddress();
|
||||||
|
e->mono_time = mono_time;
|
||||||
|
e->size = dat.size();
|
||||||
|
memcpy(e->dat, (uint8_t *)dat.begin(), e->size);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AbstractStream::mergeEvents(const std::vector<const CanEvent *> &events) {
|
||||||
|
static MessageEventsMap msg_events;
|
||||||
|
std::for_each(msg_events.begin(), msg_events.end(), [](auto &e) { e.second.clear(); });
|
||||||
|
for (auto e : events) {
|
||||||
|
msg_events[{.source = e->src, .address = e->address}].push_back(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!events.empty()) {
|
||||||
|
for (const auto &[id, new_e] : msg_events) {
|
||||||
|
if (!new_e.empty()) {
|
||||||
|
auto &e = events_[id];
|
||||||
|
auto pos = std::upper_bound(e.cbegin(), e.cend(), new_e.front()->mono_time, CompareCanEvent());
|
||||||
|
e.insert(pos, new_e.cbegin(), new_e.cend());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto pos = std::upper_bound(all_events_.cbegin(), all_events_.cend(), events.front()->mono_time, CompareCanEvent());
|
||||||
|
all_events_.insert(pos, events.cbegin(), events.cend());
|
||||||
|
emit eventsMerged(msg_events);
|
||||||
|
}
|
||||||
|
lastest_event_ts = all_events_.empty() ? 0 : all_events_.back()->mono_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanData
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
enum Color { GREYISH_BLUE, CYAN, RED};
|
||||||
|
QColor getColor(int c) {
|
||||||
|
constexpr int start_alpha = 128;
|
||||||
|
static const QColor colors[] = {
|
||||||
|
[GREYISH_BLUE] = QColor(102, 86, 169, start_alpha / 2),
|
||||||
|
[CYAN] = QColor(0, 187, 255, start_alpha),
|
||||||
|
[RED] = QColor(255, 0, 0, start_alpha),
|
||||||
|
};
|
||||||
|
return settings.theme == LIGHT_THEME ? colors[c] : colors[c].lighter(135);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline QColor blend(const QColor &a, const QColor &b) {
|
||||||
|
return QColor((a.red() + b.red()) / 2, (a.green() + b.green()) / 2, (a.blue() + b.blue()) / 2, (a.alpha() + b.alpha()) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the frequency of the past minute.
|
||||||
|
double calc_freq(const MessageId &msg_id, double current_sec) {
|
||||||
|
const auto &events = can->events(msg_id);
|
||||||
|
uint64_t cur_mono_time = (can->routeStartTime() + current_sec) * 1e9;
|
||||||
|
uint64_t first_mono_time = std::max<int64_t>(0, cur_mono_time - 59 * 1e9);
|
||||||
|
auto first = std::lower_bound(events.begin(), events.end(), first_mono_time, CompareCanEvent());
|
||||||
|
auto second = std::lower_bound(first, events.end(), cur_mono_time, CompareCanEvent());
|
||||||
|
if (first != events.end() && second != events.end()) {
|
||||||
|
double duration = ((*second)->mono_time - (*first)->mono_time) / 1e9;
|
||||||
|
uint32_t count = std::distance(first, second);
|
||||||
|
return count / std::max(1.0, duration);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void CanData::compute(const MessageId &msg_id, const uint8_t *can_data, const int size, double current_sec,
|
||||||
|
double playback_speed, const std::vector<uint8_t> &mask, double in_freq) {
|
||||||
|
ts = current_sec;
|
||||||
|
++count;
|
||||||
|
|
||||||
|
if (auto sec = seconds_since_boot(); (sec - last_freq_update_ts) >= 1) {
|
||||||
|
last_freq_update_ts = sec;
|
||||||
|
freq = !in_freq ? calc_freq(msg_id, ts) : in_freq;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dat.size() != size) {
|
||||||
|
dat.resize(size);
|
||||||
|
colors.assign(size, QColor(0, 0, 0, 0));
|
||||||
|
last_changes.resize(size);
|
||||||
|
std::for_each(last_changes.begin(), last_changes.end(), [current_sec](auto &c) { c.ts = current_sec; });
|
||||||
|
} else {
|
||||||
|
constexpr int periodic_threshold = 10;
|
||||||
|
constexpr float fade_time = 2.0;
|
||||||
|
const float alpha_delta = 1.0 / (freq + 1) / (fade_time * playback_speed);
|
||||||
|
|
||||||
|
for (int i = 0; i < size; ++i) {
|
||||||
|
auto &last_change = last_changes[i];
|
||||||
|
|
||||||
|
uint8_t mask_byte = last_change.suppressed ? 0x00 : 0xFF;
|
||||||
|
if (i < mask.size()) mask_byte &= ~(mask[i]);
|
||||||
|
|
||||||
|
const uint8_t last = dat[i] & mask_byte;
|
||||||
|
const uint8_t cur = can_data[i] & mask_byte;
|
||||||
|
if (last != cur) {
|
||||||
|
const int delta = cur - last;
|
||||||
|
// Keep track if signal is changing randomly, or mostly moving in the same direction
|
||||||
|
if (std::signbit(delta) == std::signbit(last_change.delta)) {
|
||||||
|
last_change.same_delta_counter = std::min(16, last_change.same_delta_counter + 1);
|
||||||
|
} else {
|
||||||
|
last_change.same_delta_counter = std::max(0, last_change.same_delta_counter - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const double delta_t = ts - last_change.ts;
|
||||||
|
// Mostly moves in the same direction, color based on delta up/down
|
||||||
|
if (delta_t * freq > periodic_threshold || last_change.same_delta_counter > 8) {
|
||||||
|
// Last change was while ago, choose color based on delta up or down
|
||||||
|
colors[i] = getColor(cur > last ? CYAN : RED);
|
||||||
|
} else {
|
||||||
|
// Periodic changes
|
||||||
|
colors[i] = blend(colors[i], getColor(GREYISH_BLUE));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track bit level changes
|
||||||
|
const uint8_t tmp = (cur ^ last);
|
||||||
|
for (int bit = 0; bit < 8; bit++) {
|
||||||
|
if (tmp & (1 << (7 - bit))) {
|
||||||
|
last_change.bit_change_counts[bit] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
last_change.ts = ts;
|
||||||
|
last_change.delta = delta;
|
||||||
|
} else {
|
||||||
|
// Fade out
|
||||||
|
colors[i].setAlphaF(std::max(0.0, colors[i].alphaF() - alpha_delta));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memcpy(dat.data(), can_data, size);
|
||||||
|
}
|
||||||
157
tools/cabana/streams/abstractstream.h
Executable file
157
tools/cabana/streams/abstractstream.h
Executable file
@@ -0,0 +1,157 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <memory>
|
||||||
|
#include <mutex>
|
||||||
|
#include <set>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
|
#include "cereal/messaging/messaging.h"
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/util.h"
|
||||||
|
|
||||||
|
struct CanData {
|
||||||
|
void compute(const MessageId &msg_id, const uint8_t *dat, const int size, double current_sec,
|
||||||
|
double playback_speed, const std::vector<uint8_t> &mask, double in_freq = 0);
|
||||||
|
|
||||||
|
double ts = 0.;
|
||||||
|
uint32_t count = 0;
|
||||||
|
double freq = 0;
|
||||||
|
std::vector<uint8_t> dat;
|
||||||
|
std::vector<QColor> colors;
|
||||||
|
|
||||||
|
struct ByteLastChange {
|
||||||
|
double ts;
|
||||||
|
int delta;
|
||||||
|
int same_delta_counter;
|
||||||
|
bool suppressed;
|
||||||
|
std::array<uint32_t, 8> bit_change_counts;
|
||||||
|
};
|
||||||
|
std::vector<ByteLastChange> last_changes;
|
||||||
|
double last_freq_update_ts = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CanEvent {
|
||||||
|
uint8_t src;
|
||||||
|
uint32_t address;
|
||||||
|
uint64_t mono_time;
|
||||||
|
uint8_t size;
|
||||||
|
uint8_t dat[];
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CompareCanEvent {
|
||||||
|
constexpr bool operator()(const CanEvent *const e, uint64_t ts) const { return e->mono_time < ts; }
|
||||||
|
constexpr bool operator()(uint64_t ts, const CanEvent *const e) const { return ts < e->mono_time; }
|
||||||
|
};
|
||||||
|
|
||||||
|
struct BusConfig {
|
||||||
|
int can_speed_kbps = 500;
|
||||||
|
int data_speed_kbps = 2000;
|
||||||
|
bool can_fd = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef std::unordered_map<MessageId, std::vector<const CanEvent *>> MessageEventsMap;
|
||||||
|
|
||||||
|
class AbstractStream : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
AbstractStream(QObject *parent);
|
||||||
|
virtual ~AbstractStream() {}
|
||||||
|
virtual void start() = 0;
|
||||||
|
virtual bool liveStreaming() const { return true; }
|
||||||
|
virtual void seekTo(double ts) {}
|
||||||
|
virtual QString routeName() const = 0;
|
||||||
|
virtual QString carFingerprint() const { return ""; }
|
||||||
|
virtual QDateTime beginDateTime() const { return {}; }
|
||||||
|
virtual double routeStartTime() const { return 0; }
|
||||||
|
inline double currentSec() const { return current_sec_; }
|
||||||
|
virtual double totalSeconds() const { return lastEventMonoTime() / 1e9 - routeStartTime(); }
|
||||||
|
virtual void setSpeed(float speed) {}
|
||||||
|
virtual double getSpeed() { return 1; }
|
||||||
|
virtual bool isPaused() const { return false; }
|
||||||
|
virtual void pause(bool pause) {}
|
||||||
|
|
||||||
|
inline const std::unordered_map<MessageId, CanData> &lastMessages() const { return last_msgs; }
|
||||||
|
inline const MessageEventsMap &eventsMap() const { return events_; }
|
||||||
|
inline const std::vector<const CanEvent *> &allEvents() const { return all_events_; }
|
||||||
|
const CanData &lastMessage(const MessageId &id);
|
||||||
|
const std::vector<const CanEvent *> &events(const MessageId &id) const;
|
||||||
|
|
||||||
|
size_t suppressHighlighted();
|
||||||
|
void clearSuppressed();
|
||||||
|
void suppressDefinedSignals(bool suppress);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void paused();
|
||||||
|
void resume();
|
||||||
|
void seekedTo(double sec);
|
||||||
|
void streamStarted();
|
||||||
|
void eventsMerged(const MessageEventsMap &events_map);
|
||||||
|
void msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids);
|
||||||
|
void sourcesUpdated(const SourceSet &s);
|
||||||
|
void privateUpdateLastMsgsSignal();
|
||||||
|
|
||||||
|
public:
|
||||||
|
SourceSet sources;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void mergeEvents(const std::vector<const CanEvent *> &events);
|
||||||
|
const CanEvent *newEvent(uint64_t mono_time, const cereal::CanData::Reader &c);
|
||||||
|
void updateEvent(const MessageId &id, double sec, const uint8_t *data, uint8_t size);
|
||||||
|
uint64_t lastEventMonoTime() const { return lastest_event_ts; }
|
||||||
|
|
||||||
|
std::vector<const CanEvent *> all_events_;
|
||||||
|
uint64_t lastest_event_ts = 0;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void updateLastMessages();
|
||||||
|
void updateLastMsgsTo(double sec);
|
||||||
|
void updateMasks();
|
||||||
|
|
||||||
|
double current_sec_ = 0;
|
||||||
|
MessageEventsMap events_;
|
||||||
|
std::unordered_map<MessageId, CanData> last_msgs;
|
||||||
|
std::unique_ptr<MonotonicBuffer> event_buffer_;
|
||||||
|
|
||||||
|
// Members accessed in multiple threads. (mutex protected)
|
||||||
|
std::mutex mutex_;
|
||||||
|
std::set<MessageId> new_msgs_;
|
||||||
|
std::unordered_map<MessageId, CanData> messages_;
|
||||||
|
std::unordered_map<MessageId, std::vector<uint8_t>> masks_;
|
||||||
|
};
|
||||||
|
|
||||||
|
class AbstractOpenStreamWidget : public QWidget {
|
||||||
|
public:
|
||||||
|
AbstractOpenStreamWidget(AbstractStream **stream, QWidget *parent = nullptr) : stream(stream), QWidget(parent) {}
|
||||||
|
virtual bool open() = 0;
|
||||||
|
virtual QString title() = 0;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
AbstractStream **stream = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
class DummyStream : public AbstractStream {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
DummyStream(QObject *parent) : AbstractStream(parent) {}
|
||||||
|
QString routeName() const override { return tr("No Stream"); }
|
||||||
|
void start() override { emit streamStarted(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
class StreamNotifier : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
StreamNotifier(QObject *parent = nullptr) : QObject(parent) {}
|
||||||
|
static StreamNotifier* instance();
|
||||||
|
signals:
|
||||||
|
void streamStarted();
|
||||||
|
void changingStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
// A global pointer referring to the unique AbstractStream object
|
||||||
|
extern AbstractStream *can;
|
||||||
70
tools/cabana/streams/devicestream.cc
Executable file
70
tools/cabana/streams/devicestream.cc
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
#include "tools/cabana/streams/devicestream.h"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <QButtonGroup>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QRadioButton>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QRegularExpressionValidator>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
// DeviceStream
|
||||||
|
|
||||||
|
DeviceStream::DeviceStream(QObject *parent, QString address) : zmq_address(address), LiveStream(parent) {
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeviceStream::streamThread() {
|
||||||
|
zmq_address.isEmpty() ? unsetenv("ZMQ") : setenv("ZMQ", "1", 1);
|
||||||
|
|
||||||
|
std::unique_ptr<Context> context(Context::create());
|
||||||
|
std::string address = zmq_address.isEmpty() ? "127.0.0.1" : zmq_address.toStdString();
|
||||||
|
std::unique_ptr<SubSocket> sock(SubSocket::create(context.get(), "can", address));
|
||||||
|
assert(sock != NULL);
|
||||||
|
// run as fast as messages come in
|
||||||
|
while (!QThread::currentThread()->isInterruptionRequested()) {
|
||||||
|
std::unique_ptr<Message> msg(sock->receive(true));
|
||||||
|
if (!msg) {
|
||||||
|
QThread::msleep(50);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
handleEvent(kj::ArrayPtr<capnp::word>((capnp::word*)msg->getData(), msg->getSize() / sizeof(capnp::word)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AbstractOpenStreamWidget *DeviceStream::widget(AbstractStream **stream) {
|
||||||
|
return new OpenDeviceWidget(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenDeviceWidget
|
||||||
|
|
||||||
|
OpenDeviceWidget::OpenDeviceWidget(AbstractStream **stream) : AbstractOpenStreamWidget(stream) {
|
||||||
|
QRadioButton *msgq = new QRadioButton(tr("MSGQ"));
|
||||||
|
QRadioButton *zmq = new QRadioButton(tr("ZMQ"));
|
||||||
|
ip_address = new QLineEdit(this);
|
||||||
|
ip_address->setPlaceholderText(tr("Enter device Ip Address"));
|
||||||
|
QString ip_range = "(?:[0-1]?[0-9]?[0-9]|2[0-4][0-9]|25[0-5])";
|
||||||
|
QString pattern("^" + ip_range + "\\." + ip_range + "\\." + ip_range + "\\." + ip_range + "$");
|
||||||
|
QRegularExpression re(pattern);
|
||||||
|
ip_address->setValidator(new QRegularExpressionValidator(re, this));
|
||||||
|
|
||||||
|
group = new QButtonGroup(this);
|
||||||
|
group->addButton(msgq, 0);
|
||||||
|
group->addButton(zmq, 1);
|
||||||
|
|
||||||
|
QFormLayout *form_layout = new QFormLayout(this);
|
||||||
|
form_layout->addRow(msgq);
|
||||||
|
form_layout->addRow(zmq, ip_address);
|
||||||
|
QObject::connect(group, qOverload<QAbstractButton *, bool>(&QButtonGroup::buttonToggled), [=](QAbstractButton *button, bool checked) {
|
||||||
|
ip_address->setEnabled(button == zmq && checked);
|
||||||
|
});
|
||||||
|
zmq->setChecked(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OpenDeviceWidget::open() {
|
||||||
|
QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text();
|
||||||
|
bool msgq = group->checkedId() == 0;
|
||||||
|
*stream = new DeviceStream(qApp, msgq ? "" : ip);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
30
tools/cabana/streams/devicestream.h
Executable file
30
tools/cabana/streams/devicestream.h
Executable file
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "tools/cabana/streams/livestream.h"
|
||||||
|
|
||||||
|
class DeviceStream : public LiveStream {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
DeviceStream(QObject *parent, QString address = {});
|
||||||
|
static AbstractOpenStreamWidget *widget(AbstractStream **stream);
|
||||||
|
inline QString routeName() const override {
|
||||||
|
return QString("Live Streaming From %1").arg(zmq_address.isEmpty() ? "127.0.0.1" : zmq_address);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void streamThread() override;
|
||||||
|
const QString zmq_address;
|
||||||
|
};
|
||||||
|
|
||||||
|
class OpenDeviceWidget : public AbstractOpenStreamWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
OpenDeviceWidget(AbstractStream **stream);
|
||||||
|
bool open() override;
|
||||||
|
QString title() override { return tr("&Device"); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QLineEdit *ip_address;
|
||||||
|
QButtonGroup *group;
|
||||||
|
};
|
||||||
140
tools/cabana/streams/livestream.cc
Executable file
140
tools/cabana/streams/livestream.cc
Executable file
@@ -0,0 +1,140 @@
|
|||||||
|
#include "tools/cabana/streams/livestream.h"
|
||||||
|
|
||||||
|
#include <QThread>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <fstream>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "common/timing.h"
|
||||||
|
#include "common/util.h"
|
||||||
|
|
||||||
|
struct LiveStream::Logger {
|
||||||
|
Logger() : start_ts(seconds_since_epoch()), segment_num(-1) {}
|
||||||
|
|
||||||
|
void write(kj::ArrayPtr<capnp::word> data) {
|
||||||
|
int n = (seconds_since_epoch() - start_ts) / 60.0;
|
||||||
|
if (std::exchange(segment_num, n) != segment_num) {
|
||||||
|
QString dir = QString("%1/%2--%3")
|
||||||
|
.arg(settings.log_path)
|
||||||
|
.arg(QDateTime::fromSecsSinceEpoch(start_ts).toString("yyyy-MM-dd--hh-mm-ss"))
|
||||||
|
.arg(n);
|
||||||
|
util::create_directories(dir.toStdString(), 0755);
|
||||||
|
fs.reset(new std::ofstream((dir + "/rlog").toStdString(), std::ios::binary | std::ios::out));
|
||||||
|
}
|
||||||
|
|
||||||
|
auto bytes = data.asBytes();
|
||||||
|
fs->write((const char*)bytes.begin(), bytes.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<std::ofstream> fs;
|
||||||
|
int segment_num;
|
||||||
|
uint64_t start_ts;
|
||||||
|
};
|
||||||
|
|
||||||
|
LiveStream::LiveStream(QObject *parent) : AbstractStream(parent) {
|
||||||
|
if (settings.log_livestream) {
|
||||||
|
logger = std::make_unique<Logger>();
|
||||||
|
}
|
||||||
|
stream_thread = new QThread(this);
|
||||||
|
|
||||||
|
QObject::connect(&settings, &Settings::changed, this, &LiveStream::startUpdateTimer);
|
||||||
|
QObject::connect(stream_thread, &QThread::started, [=]() { streamThread(); });
|
||||||
|
QObject::connect(stream_thread, &QThread::finished, stream_thread, &QThread::deleteLater);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LiveStream::startUpdateTimer() {
|
||||||
|
update_timer.stop();
|
||||||
|
update_timer.start(1000.0 / settings.fps, this);
|
||||||
|
timer_id = update_timer.timerId();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LiveStream::start() {
|
||||||
|
emit streamStarted();
|
||||||
|
stream_thread->start();
|
||||||
|
startUpdateTimer();
|
||||||
|
begin_date_time = QDateTime::currentDateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
LiveStream::~LiveStream() {
|
||||||
|
update_timer.stop();
|
||||||
|
stream_thread->requestInterruption();
|
||||||
|
stream_thread->quit();
|
||||||
|
stream_thread->wait();
|
||||||
|
}
|
||||||
|
|
||||||
|
// called in streamThread
|
||||||
|
void LiveStream::handleEvent(kj::ArrayPtr<capnp::word> data) {
|
||||||
|
if (logger) {
|
||||||
|
logger->write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
capnp::FlatArrayMessageReader reader(data);
|
||||||
|
auto event = reader.getRoot<cereal::Event>();
|
||||||
|
if (event.which() == cereal::Event::Which::CAN) {
|
||||||
|
const uint64_t mono_time = event.getLogMonoTime();
|
||||||
|
std::lock_guard lk(lock);
|
||||||
|
for (const auto &c : event.getCan()) {
|
||||||
|
received_events_.push_back(newEvent(mono_time, c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LiveStream::timerEvent(QTimerEvent *event) {
|
||||||
|
if (event->timerId() == timer_id) {
|
||||||
|
{
|
||||||
|
// merge events received from live stream thread.
|
||||||
|
std::lock_guard lk(lock);
|
||||||
|
mergeEvents(received_events_);
|
||||||
|
received_events_.clear();
|
||||||
|
}
|
||||||
|
if (!all_events_.empty()) {
|
||||||
|
begin_event_ts = all_events_.front()->mono_time;
|
||||||
|
updateEvents();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QObject::timerEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LiveStream::updateEvents() {
|
||||||
|
static double prev_speed = 1.0;
|
||||||
|
|
||||||
|
if (first_update_ts == 0) {
|
||||||
|
first_update_ts = nanos_since_boot();
|
||||||
|
first_event_ts = current_event_ts = all_events_.back()->mono_time;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paused_ || prev_speed != speed_) {
|
||||||
|
prev_speed = speed_;
|
||||||
|
first_update_ts = nanos_since_boot();
|
||||||
|
first_event_ts = current_event_ts;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t last_ts = post_last_event && speed_ == 1.0
|
||||||
|
? all_events_.back()->mono_time
|
||||||
|
: first_event_ts + (nanos_since_boot() - first_update_ts) * speed_;
|
||||||
|
auto first = std::upper_bound(all_events_.cbegin(), all_events_.cend(), current_event_ts, CompareCanEvent());
|
||||||
|
auto last = std::upper_bound(first, all_events_.cend(), last_ts, CompareCanEvent());
|
||||||
|
|
||||||
|
for (auto it = first; it != last; ++it) {
|
||||||
|
const CanEvent *e = *it;
|
||||||
|
MessageId id = {.source = e->src, .address = e->address};
|
||||||
|
updateEvent(id, (e->mono_time - begin_event_ts) / 1e9, e->dat, e->size);
|
||||||
|
current_event_ts = e->mono_time;
|
||||||
|
}
|
||||||
|
emit privateUpdateLastMsgsSignal();
|
||||||
|
}
|
||||||
|
|
||||||
|
void LiveStream::seekTo(double sec) {
|
||||||
|
sec = std::max(0.0, sec);
|
||||||
|
first_update_ts = nanos_since_boot();
|
||||||
|
current_event_ts = first_event_ts = std::min<uint64_t>(sec * 1e9 + begin_event_ts, lastEventMonoTime());
|
||||||
|
post_last_event = (first_event_ts == lastEventMonoTime());
|
||||||
|
emit seekedTo((current_event_ts - begin_event_ts) / 1e9);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LiveStream::pause(bool pause) {
|
||||||
|
paused_ = pause;
|
||||||
|
emit(pause ? paused() : resume());
|
||||||
|
}
|
||||||
52
tools/cabana/streams/livestream.h
Executable file
52
tools/cabana/streams/livestream.h
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QBasicTimer>
|
||||||
|
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
class LiveStream : public AbstractStream {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
LiveStream(QObject *parent);
|
||||||
|
virtual ~LiveStream();
|
||||||
|
void start() override;
|
||||||
|
inline QDateTime beginDateTime() const { return begin_date_time; }
|
||||||
|
inline double routeStartTime() const override { return begin_event_ts / 1e9; }
|
||||||
|
void setSpeed(float speed) override { speed_ = speed; }
|
||||||
|
double getSpeed() override { return speed_; }
|
||||||
|
bool isPaused() const override { return paused_; }
|
||||||
|
void pause(bool pause) override;
|
||||||
|
void seekTo(double sec) override;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void streamThread() = 0;
|
||||||
|
void handleEvent(kj::ArrayPtr<capnp::word> event);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void startUpdateTimer();
|
||||||
|
void timerEvent(QTimerEvent *event) override;
|
||||||
|
void updateEvents();
|
||||||
|
|
||||||
|
std::mutex lock;
|
||||||
|
QThread *stream_thread;
|
||||||
|
std::vector<const CanEvent *> received_events_;
|
||||||
|
|
||||||
|
int timer_id;
|
||||||
|
QBasicTimer update_timer;
|
||||||
|
|
||||||
|
QDateTime begin_date_time;
|
||||||
|
uint64_t begin_event_ts = 0;
|
||||||
|
uint64_t current_event_ts = 0;
|
||||||
|
uint64_t first_event_ts = 0;
|
||||||
|
uint64_t first_update_ts = 0;
|
||||||
|
bool post_last_event = true;
|
||||||
|
double speed_ = 1;
|
||||||
|
bool paused_ = false;
|
||||||
|
|
||||||
|
struct Logger;
|
||||||
|
std::unique_ptr<Logger> logger;
|
||||||
|
};
|
||||||
220
tools/cabana/streams/pandastream.cc
Executable file
220
tools/cabana/streams/pandastream.cc
Executable file
@@ -0,0 +1,220 @@
|
|||||||
|
#include "tools/cabana/streams/pandastream.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QThread>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
// TODO: remove clearLayout
|
||||||
|
static void clearLayout(QLayout* layout) {
|
||||||
|
while (layout->count() > 0) {
|
||||||
|
QLayoutItem* item = layout->takeAt(0);
|
||||||
|
if (QWidget* widget = item->widget()) {
|
||||||
|
widget->deleteLater();
|
||||||
|
}
|
||||||
|
if (QLayout* childLayout = item->layout()) {
|
||||||
|
clearLayout(childLayout);
|
||||||
|
}
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) {
|
||||||
|
if (config.serial.isEmpty()) {
|
||||||
|
auto serials = Panda::list();
|
||||||
|
if (serials.size() == 0) {
|
||||||
|
throw std::runtime_error("No panda found");
|
||||||
|
}
|
||||||
|
config.serial = QString::fromStdString(serials[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "Connecting to panda with serial" << config.serial;
|
||||||
|
if (!connect()) {
|
||||||
|
throw std::runtime_error("Failed to connect to panda");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PandaStream::connect() {
|
||||||
|
try {
|
||||||
|
panda.reset(new Panda(config.serial.toStdString()));
|
||||||
|
config.bus_config.resize(3);
|
||||||
|
qDebug() << "Connected";
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
panda->set_safety_model(cereal::CarParams::SafetyModel::SILENT);
|
||||||
|
|
||||||
|
for (int bus = 0; bus < config.bus_config.size(); bus++) {
|
||||||
|
panda->set_can_speed_kbps(bus, config.bus_config[bus].can_speed_kbps);
|
||||||
|
|
||||||
|
// CAN-FD
|
||||||
|
if (panda->hw_type == cereal::PandaState::PandaType::RED_PANDA || panda->hw_type == cereal::PandaState::PandaType::RED_PANDA_V2) {
|
||||||
|
if (config.bus_config[bus].can_fd) {
|
||||||
|
panda->set_data_speed_kbps(bus, config.bus_config[bus].data_speed_kbps);
|
||||||
|
} else {
|
||||||
|
// Hack to disable can-fd by setting data speed to a low value
|
||||||
|
panda->set_data_speed_kbps(bus, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PandaStream::streamThread() {
|
||||||
|
std::vector<can_frame> raw_can_data;
|
||||||
|
|
||||||
|
while (!QThread::currentThread()->isInterruptionRequested()) {
|
||||||
|
QThread::msleep(1);
|
||||||
|
|
||||||
|
if (!panda->connected()) {
|
||||||
|
qDebug() << "Connection to panda lost. Attempting reconnect.";
|
||||||
|
if (!connect()){
|
||||||
|
QThread::msleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw_can_data.clear();
|
||||||
|
if (!panda->can_receive(raw_can_data)) {
|
||||||
|
qDebug() << "failed to receive";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageBuilder msg;
|
||||||
|
auto evt = msg.initEvent();
|
||||||
|
auto canData = evt.initCan(raw_can_data.size());
|
||||||
|
for (uint i = 0; i<raw_can_data.size(); i++) {
|
||||||
|
canData[i].setAddress(raw_can_data[i].address);
|
||||||
|
canData[i].setBusTime(raw_can_data[i].busTime);
|
||||||
|
canData[i].setDat(kj::arrayPtr((uint8_t*)raw_can_data[i].dat.data(), raw_can_data[i].dat.size()));
|
||||||
|
canData[i].setSrc(raw_can_data[i].src);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(capnp::messageToFlatArray(msg));
|
||||||
|
|
||||||
|
panda->send_heartbeat(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AbstractOpenStreamWidget *PandaStream::widget(AbstractStream **stream) {
|
||||||
|
return new OpenPandaWidget(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenPandaWidget
|
||||||
|
|
||||||
|
OpenPandaWidget::OpenPandaWidget(AbstractStream **stream) : AbstractOpenStreamWidget(stream) {
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
main_layout->addStretch(1);
|
||||||
|
|
||||||
|
QFormLayout *form_layout = new QFormLayout();
|
||||||
|
|
||||||
|
QHBoxLayout *serial_layout = new QHBoxLayout();
|
||||||
|
serial_edit = new QComboBox();
|
||||||
|
serial_edit->setFixedWidth(300);
|
||||||
|
serial_layout->addWidget(serial_edit);
|
||||||
|
|
||||||
|
QPushButton *refresh = new QPushButton(tr("Refresh"));
|
||||||
|
refresh->setFixedWidth(100);
|
||||||
|
serial_layout->addWidget(refresh);
|
||||||
|
form_layout->addRow(tr("Serial"), serial_layout);
|
||||||
|
main_layout->addLayout(form_layout);
|
||||||
|
|
||||||
|
config_layout = new QFormLayout();
|
||||||
|
main_layout->addLayout(config_layout);
|
||||||
|
|
||||||
|
main_layout->addStretch(1);
|
||||||
|
|
||||||
|
QObject::connect(refresh, &QPushButton::clicked, this, &OpenPandaWidget::refreshSerials);
|
||||||
|
QObject::connect(serial_edit, &QComboBox::currentTextChanged, this, &OpenPandaWidget::buildConfigForm);
|
||||||
|
|
||||||
|
// Populate serials
|
||||||
|
refreshSerials();
|
||||||
|
buildConfigForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenPandaWidget::refreshSerials() {
|
||||||
|
serial_edit->clear();
|
||||||
|
for (auto serial : Panda::list()) {
|
||||||
|
serial_edit->addItem(QString::fromStdString(serial));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenPandaWidget::buildConfigForm() {
|
||||||
|
clearLayout(config_layout);
|
||||||
|
QString serial = serial_edit->currentText();
|
||||||
|
|
||||||
|
bool has_fd = false;
|
||||||
|
bool has_panda = !serial.isEmpty();
|
||||||
|
|
||||||
|
if (has_panda) {
|
||||||
|
try {
|
||||||
|
Panda panda = Panda(serial.toStdString());
|
||||||
|
has_fd = (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA) || (panda.hw_type == cereal::PandaState::PandaType::RED_PANDA_V2);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
has_panda = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has_panda) {
|
||||||
|
config.serial = serial;
|
||||||
|
config.bus_config.resize(3);
|
||||||
|
for (int i = 0; i < config.bus_config.size(); i++) {
|
||||||
|
QHBoxLayout *bus_layout = new QHBoxLayout;
|
||||||
|
|
||||||
|
// CAN Speed
|
||||||
|
bus_layout->addWidget(new QLabel(tr("CAN Speed (kbps):")));
|
||||||
|
QComboBox *can_speed = new QComboBox;
|
||||||
|
for (int j = 0; j < std::size(speeds); j++) {
|
||||||
|
can_speed->addItem(QString::number(speeds[j]));
|
||||||
|
|
||||||
|
if (data_speeds[j] == config.bus_config[i].can_speed_kbps) {
|
||||||
|
can_speed->setCurrentIndex(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QObject::connect(can_speed, qOverload<int>(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].can_speed_kbps = speeds[index];});
|
||||||
|
bus_layout->addWidget(can_speed);
|
||||||
|
|
||||||
|
// CAN-FD Speed
|
||||||
|
if (has_fd) {
|
||||||
|
QCheckBox *enable_fd = new QCheckBox("CAN-FD");
|
||||||
|
bus_layout->addWidget(enable_fd);
|
||||||
|
bus_layout->addWidget(new QLabel(tr("Data Speed (kbps):")));
|
||||||
|
QComboBox *data_speed = new QComboBox;
|
||||||
|
for (int j = 0; j < std::size(data_speeds); j++) {
|
||||||
|
data_speed->addItem(QString::number(data_speeds[j]));
|
||||||
|
|
||||||
|
if (data_speeds[j] == config.bus_config[i].data_speed_kbps) {
|
||||||
|
data_speed->setCurrentIndex(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data_speed->setEnabled(false);
|
||||||
|
bus_layout->addWidget(data_speed);
|
||||||
|
|
||||||
|
QObject::connect(data_speed, qOverload<int>(&QComboBox::currentIndexChanged), [=](int index) {config.bus_config[i].data_speed_kbps = data_speeds[index];});
|
||||||
|
QObject::connect(enable_fd, &QCheckBox::stateChanged, data_speed, &QComboBox::setEnabled);
|
||||||
|
QObject::connect(enable_fd, &QCheckBox::stateChanged, [=](int state) {config.bus_config[i].can_fd = (bool)state;});
|
||||||
|
}
|
||||||
|
|
||||||
|
config_layout->addRow(tr("Bus %1:").arg(i), bus_layout);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
config.serial = "";
|
||||||
|
config_layout->addWidget(new QLabel(tr("No panda found")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OpenPandaWidget::open() {
|
||||||
|
try {
|
||||||
|
*stream = new PandaStream(qApp, config);
|
||||||
|
} catch (std::exception &e) {
|
||||||
|
QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
52
tools/cabana/streams/pandastream.h
Executable file
52
tools/cabana/streams/pandastream.h
Executable file
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QFormLayout>
|
||||||
|
|
||||||
|
#include "tools/cabana/streams/livestream.h"
|
||||||
|
#include "selfdrive/boardd/panda.h"
|
||||||
|
|
||||||
|
const uint32_t speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U};
|
||||||
|
const uint32_t data_speeds[] = {10U, 20U, 50U, 100U, 125U, 250U, 500U, 1000U, 2000U, 5000U};
|
||||||
|
|
||||||
|
struct PandaStreamConfig {
|
||||||
|
QString serial = "";
|
||||||
|
std::vector<BusConfig> bus_config;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PandaStream : public LiveStream {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
PandaStream(QObject *parent, PandaStreamConfig config_ = {});
|
||||||
|
static AbstractOpenStreamWidget *widget(AbstractStream **stream);
|
||||||
|
inline QString routeName() const override {
|
||||||
|
return QString("Live Streaming From Panda %1").arg(config.serial);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void streamThread() override;
|
||||||
|
bool connect();
|
||||||
|
|
||||||
|
std::unique_ptr<Panda> panda;
|
||||||
|
PandaStreamConfig config = {};
|
||||||
|
};
|
||||||
|
|
||||||
|
class OpenPandaWidget : public AbstractOpenStreamWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
OpenPandaWidget(AbstractStream **stream);
|
||||||
|
bool open() override;
|
||||||
|
QString title() override { return tr("&Panda"); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void refreshSerials();
|
||||||
|
void buildConfigForm();
|
||||||
|
|
||||||
|
QComboBox *serial_edit;
|
||||||
|
QFormLayout *config_layout;
|
||||||
|
PandaStreamConfig config = {};
|
||||||
|
};
|
||||||
145
tools/cabana/streams/replaystream.cc
Executable file
145
tools/cabana/streams/replaystream.cc
Executable file
@@ -0,0 +1,145 @@
|
|||||||
|
#include "tools/cabana/streams/replaystream.h"
|
||||||
|
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPushButton>
|
||||||
|
|
||||||
|
ReplayStream::ReplayStream(QObject *parent) : AbstractStream(parent) {
|
||||||
|
unsetenv("ZMQ");
|
||||||
|
setenv("COMMA_CACHE", "/tmp/comma_download_cache", 1);
|
||||||
|
|
||||||
|
// TODO: Remove when OpenpilotPrefix supports ZMQ
|
||||||
|
#ifndef __APPLE__
|
||||||
|
op_prefix = std::make_unique<OpenpilotPrefix>();
|
||||||
|
#endif
|
||||||
|
|
||||||
|
QObject::connect(&settings, &Settings::changed, this, [this]() {
|
||||||
|
if (replay) replay->setSegmentCacheLimit(settings.max_cached_minutes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool event_filter(const Event *e, void *opaque) {
|
||||||
|
return ((ReplayStream *)opaque)->eventFilter(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayStream::mergeSegments() {
|
||||||
|
for (auto &[n, seg] : replay->segments()) {
|
||||||
|
if (seg && seg->isLoaded() && !processed_segments.count(n)) {
|
||||||
|
processed_segments.insert(n);
|
||||||
|
|
||||||
|
std::vector<const CanEvent *> new_events;
|
||||||
|
new_events.reserve(seg->log->events.size());
|
||||||
|
for (auto it = seg->log->events.cbegin(); it != seg->log->events.cend(); ++it) {
|
||||||
|
if ((*it)->which == cereal::Event::Which::CAN) {
|
||||||
|
const uint64_t ts = (*it)->mono_time;
|
||||||
|
for (const auto &c : (*it)->event.getCan()) {
|
||||||
|
new_events.push_back(newEvent(ts, c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mergeEvents(new_events);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReplayStream::loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags) {
|
||||||
|
replay.reset(new Replay(route, {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"},
|
||||||
|
{}, nullptr, replay_flags, data_dir, this));
|
||||||
|
replay->setSegmentCacheLimit(settings.max_cached_minutes);
|
||||||
|
replay->installEventFilter(event_filter, this);
|
||||||
|
QObject::connect(replay.get(), &Replay::seekedTo, this, &AbstractStream::seekedTo);
|
||||||
|
QObject::connect(replay.get(), &Replay::segmentsMerged, this, &ReplayStream::mergeSegments);
|
||||||
|
QObject::connect(replay.get(), &Replay::qLogLoaded, this, &ReplayStream::qLogLoaded, Qt::QueuedConnection);
|
||||||
|
return replay->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayStream::start() {
|
||||||
|
emit streamStarted();
|
||||||
|
replay->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReplayStream::eventFilter(const Event *event) {
|
||||||
|
static double prev_update_ts = 0;
|
||||||
|
if (event->which == cereal::Event::Which::CAN) {
|
||||||
|
double current_sec = event->mono_time / 1e9 - routeStartTime();
|
||||||
|
for (const auto &c : event->event.getCan()) {
|
||||||
|
MessageId id = {.source = c.getSrc(), .address = c.getAddress()};
|
||||||
|
const auto dat = c.getDat();
|
||||||
|
updateEvent(id, current_sec, (const uint8_t*)dat.begin(), dat.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double ts = millis_since_boot();
|
||||||
|
if ((ts - prev_update_ts) > (1000.0 / settings.fps)) {
|
||||||
|
emit privateUpdateLastMsgsSignal();
|
||||||
|
prev_update_ts = ts;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayStream::pause(bool pause) {
|
||||||
|
replay->pause(pause);
|
||||||
|
emit(pause ? paused() : resume());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
AbstractOpenStreamWidget *ReplayStream::widget(AbstractStream **stream) {
|
||||||
|
return new OpenReplayWidget(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenReplayWidget
|
||||||
|
|
||||||
|
OpenReplayWidget::OpenReplayWidget(AbstractStream **stream) : AbstractOpenStreamWidget(stream) {
|
||||||
|
// TODO: get route list from api.comma.ai
|
||||||
|
QGridLayout *grid_layout = new QGridLayout(this);
|
||||||
|
grid_layout->addWidget(new QLabel(tr("Route")), 0, 0);
|
||||||
|
grid_layout->addWidget(route_edit = new QLineEdit(this), 0, 1);
|
||||||
|
route_edit->setPlaceholderText(tr("Enter remote route name or click browse to select a local route"));
|
||||||
|
auto file_btn = new QPushButton(tr("Browse..."), this);
|
||||||
|
grid_layout->addWidget(file_btn, 0, 2);
|
||||||
|
|
||||||
|
grid_layout->addWidget(new QLabel(tr("Camera")), 1, 0);
|
||||||
|
QHBoxLayout *camera_layout = new QHBoxLayout();
|
||||||
|
for (auto c : {tr("Road camera"), tr("Driver camera"), tr("Wide road camera")})
|
||||||
|
camera_layout->addWidget(cameras.emplace_back(new QCheckBox(c, this)));
|
||||||
|
camera_layout->addStretch(1);
|
||||||
|
grid_layout->addItem(camera_layout, 1, 1);
|
||||||
|
|
||||||
|
setMinimumWidth(550);
|
||||||
|
QObject::connect(file_btn, &QPushButton::clicked, [=]() {
|
||||||
|
QString dir = QFileDialog::getExistingDirectory(this, tr("Open Local Route"), settings.last_route_dir);
|
||||||
|
if (!dir.isEmpty()) {
|
||||||
|
route_edit->setText(dir);
|
||||||
|
settings.last_route_dir = QFileInfo(dir).absolutePath();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OpenReplayWidget::open() {
|
||||||
|
QString route = route_edit->text();
|
||||||
|
QString data_dir;
|
||||||
|
if (int idx = route.lastIndexOf('/'); idx != -1) {
|
||||||
|
data_dir = route.mid(0, idx + 1);
|
||||||
|
route = route.mid(idx + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_valid_format = Route::parseRoute(route).str.size() > 0;
|
||||||
|
if (!is_valid_format) {
|
||||||
|
QMessageBox::warning(nullptr, tr("Warning"), tr("Invalid route format: '%1'").arg(route));
|
||||||
|
} else {
|
||||||
|
auto replay_stream = std::make_unique<ReplayStream>(qApp);
|
||||||
|
uint32_t flags = REPLAY_FLAG_NONE;
|
||||||
|
if (cameras[1]->isChecked()) flags |= REPLAY_FLAG_DCAM;
|
||||||
|
if (cameras[2]->isChecked()) flags |= REPLAY_FLAG_ECAM;
|
||||||
|
if (flags == REPLAY_FLAG_NONE && !cameras[0]->isChecked()) flags = REPLAY_FLAG_NO_VIPC;
|
||||||
|
|
||||||
|
if (replay_stream->loadRoute(route, data_dir, flags)) {
|
||||||
|
*stream = replay_stream.release();
|
||||||
|
} else {
|
||||||
|
QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to load route: '%1'").arg(route));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return *stream != nullptr;
|
||||||
|
}
|
||||||
57
tools/cabana/streams/replaystream.h
Executable file
57
tools/cabana/streams/replaystream.h
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <memory>
|
||||||
|
#include <set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "common/prefix.h"
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
#include "tools/replay/replay.h"
|
||||||
|
|
||||||
|
class ReplayStream : public AbstractStream {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
ReplayStream(QObject *parent);
|
||||||
|
void start() override;
|
||||||
|
bool loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE);
|
||||||
|
bool eventFilter(const Event *event);
|
||||||
|
void seekTo(double ts) override { replay->seekTo(std::max(double(0), ts), false); }
|
||||||
|
bool liveStreaming() const override { return false; }
|
||||||
|
inline QString routeName() const override { return replay->route()->name(); }
|
||||||
|
inline QString carFingerprint() const override { return replay->carFingerprint().c_str(); }
|
||||||
|
double totalSeconds() const override { return replay->totalSeconds(); }
|
||||||
|
inline QDateTime beginDateTime() const { return replay->route()->datetime(); }
|
||||||
|
inline double routeStartTime() const override { return replay->routeStartTime() / (double)1e9; }
|
||||||
|
inline const Route *route() const { return replay->route(); }
|
||||||
|
inline void setSpeed(float speed) override { replay->setSpeed(speed); }
|
||||||
|
inline float getSpeed() const { return replay->getSpeed(); }
|
||||||
|
inline Replay *getReplay() const { return replay.get(); }
|
||||||
|
inline bool isPaused() const override { return replay->isPaused(); }
|
||||||
|
void pause(bool pause) override;
|
||||||
|
static AbstractOpenStreamWidget *widget(AbstractStream **stream);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void qLogLoaded(int segnum, std::shared_ptr<LogReader> qlog);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void mergeSegments();
|
||||||
|
std::unique_ptr<Replay> replay = nullptr;
|
||||||
|
std::set<int> processed_segments;
|
||||||
|
std::unique_ptr<OpenpilotPrefix> op_prefix;
|
||||||
|
};
|
||||||
|
|
||||||
|
class OpenReplayWidget : public AbstractOpenStreamWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
OpenReplayWidget(AbstractStream **stream);
|
||||||
|
bool open() override;
|
||||||
|
QString title() override { return tr("&Replay"); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QLineEdit *route_edit;
|
||||||
|
std::vector<QCheckBox *> cameras;
|
||||||
|
};
|
||||||
115
tools/cabana/streams/socketcanstream.cc
Executable file
115
tools/cabana/streams/socketcanstream.cc
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
#include "tools/cabana/streams/socketcanstream.h"
|
||||||
|
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
SocketCanStream::SocketCanStream(QObject *parent, SocketCanStreamConfig config_) : config(config_), LiveStream(parent) {
|
||||||
|
if (!available()) {
|
||||||
|
throw std::runtime_error("SocketCAN plugin not available");
|
||||||
|
}
|
||||||
|
|
||||||
|
qDebug() << "Connecting to SocketCAN device" << config.device;
|
||||||
|
if (!connect()) {
|
||||||
|
throw std::runtime_error("Failed to connect to SocketCAN device");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SocketCanStream::available() {
|
||||||
|
return QCanBus::instance()->plugins().contains("socketcan");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SocketCanStream::connect() {
|
||||||
|
// Connecting might generate some warnings about missing socketcan/libsocketcan libraries
|
||||||
|
// These are expected and can be ignored, we don't need the advanced features of libsocketcan
|
||||||
|
QString errorString;
|
||||||
|
device.reset(QCanBus::instance()->createDevice("socketcan", config.device, &errorString));
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
qDebug() << "Failed to create SocketCAN device" << errorString;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device->connectDevice()) {
|
||||||
|
qDebug() << "Failed to connect to device";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SocketCanStream::streamThread() {
|
||||||
|
while (!QThread::currentThread()->isInterruptionRequested()) {
|
||||||
|
QThread::msleep(1);
|
||||||
|
|
||||||
|
auto frames = device->readAllFrames();
|
||||||
|
if (frames.size() == 0) continue;
|
||||||
|
|
||||||
|
MessageBuilder msg;
|
||||||
|
auto evt = msg.initEvent();
|
||||||
|
auto canData = evt.initCan(frames.size());
|
||||||
|
|
||||||
|
for (uint i = 0; i < frames.size(); i++) {
|
||||||
|
if (!frames[i].isValid()) continue;
|
||||||
|
|
||||||
|
canData[i].setAddress(frames[i].frameId());
|
||||||
|
canData[i].setSrc(0);
|
||||||
|
|
||||||
|
auto payload = frames[i].payload();
|
||||||
|
canData[i].setDat(kj::arrayPtr((uint8_t*)payload.data(), payload.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEvent(capnp::messageToFlatArray(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AbstractOpenStreamWidget *SocketCanStream::widget(AbstractStream **stream) {
|
||||||
|
return new OpenSocketCanWidget(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
OpenSocketCanWidget::OpenSocketCanWidget(AbstractStream **stream) : AbstractOpenStreamWidget(stream) {
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
main_layout->addStretch(1);
|
||||||
|
|
||||||
|
QFormLayout *form_layout = new QFormLayout();
|
||||||
|
|
||||||
|
QHBoxLayout *device_layout = new QHBoxLayout();
|
||||||
|
device_edit = new QComboBox();
|
||||||
|
device_edit->setFixedWidth(300);
|
||||||
|
device_layout->addWidget(device_edit);
|
||||||
|
|
||||||
|
QPushButton *refresh = new QPushButton(tr("Refresh"));
|
||||||
|
refresh->setFixedWidth(100);
|
||||||
|
device_layout->addWidget(refresh);
|
||||||
|
form_layout->addRow(tr("Device"), device_layout);
|
||||||
|
main_layout->addLayout(form_layout);
|
||||||
|
|
||||||
|
main_layout->addStretch(1);
|
||||||
|
|
||||||
|
QObject::connect(refresh, &QPushButton::clicked, this, &OpenSocketCanWidget::refreshDevices);
|
||||||
|
QObject::connect(device_edit, &QComboBox::currentTextChanged, this, [=]{ config.device = device_edit->currentText(); });
|
||||||
|
|
||||||
|
// Populate devices
|
||||||
|
refreshDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
void OpenSocketCanWidget::refreshDevices() {
|
||||||
|
device_edit->clear();
|
||||||
|
for (auto device : QCanBus::instance()->availableDevices(QStringLiteral("socketcan"))) {
|
||||||
|
device_edit->addItem(device.name());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool OpenSocketCanWidget::open() {
|
||||||
|
try {
|
||||||
|
*stream = new SocketCanStream(qApp, config);
|
||||||
|
} catch (std::exception &e) {
|
||||||
|
QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to SocketCAN device: '%1'").arg(e.what()));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
48
tools/cabana/streams/socketcanstream.h
Executable file
48
tools/cabana/streams/socketcanstream.h
Executable file
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QtSerialBus/QCanBus>
|
||||||
|
#include <QtSerialBus/QCanBusDevice>
|
||||||
|
#include <QtSerialBus/QCanBusDeviceInfo>
|
||||||
|
#include <QComboBox>
|
||||||
|
|
||||||
|
#include "tools/cabana/streams/livestream.h"
|
||||||
|
|
||||||
|
struct SocketCanStreamConfig {
|
||||||
|
QString device = ""; // TODO: support multiple devices/buses at once
|
||||||
|
};
|
||||||
|
|
||||||
|
class SocketCanStream : public LiveStream {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
SocketCanStream(QObject *parent, SocketCanStreamConfig config_ = {});
|
||||||
|
static AbstractOpenStreamWidget *widget(AbstractStream **stream);
|
||||||
|
static bool available();
|
||||||
|
|
||||||
|
inline QString routeName() const override {
|
||||||
|
return QString("Live Streaming From Socket CAN %1").arg(config.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void streamThread() override;
|
||||||
|
bool connect();
|
||||||
|
|
||||||
|
SocketCanStreamConfig config = {};
|
||||||
|
std::unique_ptr<QCanBusDevice> device;
|
||||||
|
};
|
||||||
|
|
||||||
|
class OpenSocketCanWidget : public AbstractOpenStreamWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
OpenSocketCanWidget(AbstractStream **stream);
|
||||||
|
bool open() override;
|
||||||
|
QString title() override { return tr("&SocketCAN"); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
void refreshDevices();
|
||||||
|
|
||||||
|
QComboBox *device_edit;
|
||||||
|
SocketCanStreamConfig config = {};
|
||||||
|
};
|
||||||
71
tools/cabana/streamselector.cc
Executable file
71
tools/cabana/streamselector.cc
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#include "tools/cabana/streamselector.h"
|
||||||
|
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPushButton>
|
||||||
|
|
||||||
|
#include "streams/socketcanstream.h"
|
||||||
|
#include "tools/cabana/streams/devicestream.h"
|
||||||
|
#include "tools/cabana/streams/pandastream.h"
|
||||||
|
#include "tools/cabana/streams/replaystream.h"
|
||||||
|
#include "tools/cabana/streams/socketcanstream.h"
|
||||||
|
|
||||||
|
StreamSelector::StreamSelector(AbstractStream **stream, QWidget *parent) : QDialog(parent) {
|
||||||
|
setWindowTitle(tr("Open stream"));
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
|
||||||
|
QWidget *w = new QWidget(this);
|
||||||
|
QVBoxLayout *layout = new QVBoxLayout(w);
|
||||||
|
tab = new QTabWidget(this);
|
||||||
|
tab->setTabBarAutoHide(true);
|
||||||
|
layout->addWidget(tab);
|
||||||
|
|
||||||
|
QHBoxLayout *dbc_layout = new QHBoxLayout();
|
||||||
|
dbc_file = new QLineEdit(this);
|
||||||
|
dbc_file->setReadOnly(true);
|
||||||
|
dbc_file->setPlaceholderText(tr("Choose a dbc file to open"));
|
||||||
|
QPushButton *file_btn = new QPushButton(tr("Browse..."));
|
||||||
|
dbc_layout->addWidget(new QLabel(tr("dbc File")));
|
||||||
|
dbc_layout->addWidget(dbc_file);
|
||||||
|
dbc_layout->addWidget(file_btn);
|
||||||
|
layout->addLayout(dbc_layout);
|
||||||
|
|
||||||
|
QFrame *line = new QFrame(this);
|
||||||
|
line->setFrameStyle(QFrame::HLine | QFrame::Sunken);
|
||||||
|
layout->addWidget(line);
|
||||||
|
|
||||||
|
main_layout->addWidget(w);
|
||||||
|
auto btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel);
|
||||||
|
main_layout->addWidget(btn_box);
|
||||||
|
|
||||||
|
addStreamWidget(ReplayStream::widget(stream));
|
||||||
|
addStreamWidget(PandaStream::widget(stream));
|
||||||
|
if (SocketCanStream::available()) {
|
||||||
|
addStreamWidget(SocketCanStream::widget(stream));
|
||||||
|
}
|
||||||
|
addStreamWidget(DeviceStream::widget(stream));
|
||||||
|
|
||||||
|
QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||||
|
QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() {
|
||||||
|
btn_box->button(QDialogButtonBox::Open)->setEnabled(false);
|
||||||
|
w->setEnabled(false);
|
||||||
|
if (((AbstractOpenStreamWidget *)tab->currentWidget())->open()) {
|
||||||
|
accept();
|
||||||
|
} else {
|
||||||
|
btn_box->button(QDialogButtonBox::Open)->setEnabled(true);
|
||||||
|
w->setEnabled(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
QObject::connect(file_btn, &QPushButton::clicked, [this]() {
|
||||||
|
QString fn = QFileDialog::getOpenFileName(this, tr("Open File"), settings.last_dir, "DBC (*.dbc)");
|
||||||
|
if (!fn.isEmpty()) {
|
||||||
|
dbc_file->setText(fn);
|
||||||
|
settings.last_dir = QFileInfo(fn).absolutePath();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void StreamSelector::addStreamWidget(AbstractOpenStreamWidget *w) {
|
||||||
|
tab->addTab(w, w->title());
|
||||||
|
}
|
||||||
20
tools/cabana/streamselector.h
Executable file
20
tools/cabana/streamselector.h
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QTabWidget>
|
||||||
|
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
class StreamSelector : public QDialog {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
StreamSelector(AbstractStream **stream, QWidget *parent = nullptr);
|
||||||
|
void addStreamWidget(AbstractOpenStreamWidget *w);
|
||||||
|
QString dbcFile() const { return dbc_file->text(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
QLineEdit *dbc_file;
|
||||||
|
QTabWidget *tab;
|
||||||
|
};
|
||||||
87
tools/cabana/tests/test_cabana.cc
Executable file
87
tools/cabana/tests/test_cabana.cc
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
|
||||||
|
#undef INFO
|
||||||
|
#include "catch2/catch.hpp"
|
||||||
|
#include "tools/replay/logreader.h"
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
const std::string TEST_RLOG_URL = "https://commadataci.blob.core.windows.net/openpilotci/0c94aa1e1296d7c6/2021-05-05--19-48-37/0/rlog.bz2";
|
||||||
|
|
||||||
|
TEST_CASE("DBCFile::generateDBC") {
|
||||||
|
QString fn = QString("%1/%2.dbc").arg(OPENDBC_FILE_PATH, "tesla_can");
|
||||||
|
DBCFile dbc_origin(fn);
|
||||||
|
DBCFile dbc_from_generated("", dbc_origin.generateDBC());
|
||||||
|
|
||||||
|
REQUIRE(dbc_origin.msgCount() == dbc_from_generated.msgCount());
|
||||||
|
auto &msgs = dbc_origin.getMessages();
|
||||||
|
auto &new_msgs = dbc_from_generated.getMessages();
|
||||||
|
for (auto &[id, m] : msgs) {
|
||||||
|
auto &new_m = new_msgs.at(id);
|
||||||
|
REQUIRE(m.name == new_m.name);
|
||||||
|
REQUIRE(m.size == new_m.size);
|
||||||
|
REQUIRE(m.getSignals().size() == new_m.getSignals().size());
|
||||||
|
auto sigs = m.getSignals();
|
||||||
|
auto new_sigs = new_m.getSignals();
|
||||||
|
for (int i = 0; i < sigs.size(); ++i) {
|
||||||
|
REQUIRE(*sigs[i] == *new_sigs[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("parse_dbc") {
|
||||||
|
QString content = R"(
|
||||||
|
BO_ 160 message_1: 8 EON
|
||||||
|
SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX
|
||||||
|
SG_ signal_2 : 12|1@1+ (1.0,0.0) [0.0|1] "" XXX
|
||||||
|
|
||||||
|
BO_ 162 message_1: 8 XXX
|
||||||
|
SG_ signal_1 M : 0|12@1+ (1,0) [0|4095] "unit" XXX
|
||||||
|
SG_ signal_2 M4 : 12|1@1+ (1.0,0.0) [0.0|1] "" XXX
|
||||||
|
|
||||||
|
VAL_ 160 signal_1 0 "disabled" 1.2 "initializing" 2 "fault";
|
||||||
|
|
||||||
|
CM_ BO_ 160 "message comment" ;
|
||||||
|
CM_ SG_ 160 signal_1 "signal comment";
|
||||||
|
CM_ SG_ 160 signal_2 "multiple line comment
|
||||||
|
1
|
||||||
|
2
|
||||||
|
";)";
|
||||||
|
|
||||||
|
DBCFile file("", content);
|
||||||
|
auto msg = file.msg(160);
|
||||||
|
REQUIRE(msg != nullptr);
|
||||||
|
REQUIRE(msg->name == "message_1");
|
||||||
|
REQUIRE(msg->size == 8);
|
||||||
|
REQUIRE(msg->comment == "message comment");
|
||||||
|
REQUIRE(msg->sigs.size() == 2);
|
||||||
|
REQUIRE(msg->transmitter == "EON");
|
||||||
|
REQUIRE(file.msg("message_1") != nullptr);
|
||||||
|
|
||||||
|
auto sig_1 = msg->sigs[0];
|
||||||
|
REQUIRE(sig_1->name == "signal_1");
|
||||||
|
REQUIRE(sig_1->start_bit == 0);
|
||||||
|
REQUIRE(sig_1->size == 12);
|
||||||
|
REQUIRE(sig_1->min == 0);
|
||||||
|
REQUIRE(sig_1->max == 4095);
|
||||||
|
REQUIRE(sig_1->unit == "unit");
|
||||||
|
REQUIRE(sig_1->comment == "signal comment");
|
||||||
|
REQUIRE(sig_1->receiver_name == "XXX");
|
||||||
|
REQUIRE(sig_1->val_desc.size() == 3);
|
||||||
|
REQUIRE(sig_1->val_desc[0] == std::pair<double, QString>{0, "disabled"});
|
||||||
|
REQUIRE(sig_1->val_desc[1] == std::pair<double, QString>{1.2, "initializing"});
|
||||||
|
REQUIRE(sig_1->val_desc[2] == std::pair<double, QString>{2, "fault"});
|
||||||
|
|
||||||
|
auto &sig_2 = msg->sigs[1];
|
||||||
|
REQUIRE(sig_2->comment == "multiple line comment \n1\n2");
|
||||||
|
|
||||||
|
// multiplexed signals
|
||||||
|
msg = file.msg(162);
|
||||||
|
REQUIRE(msg != nullptr);
|
||||||
|
REQUIRE(msg->sigs.size() == 2);
|
||||||
|
REQUIRE(msg->sigs[0]->type == cabana::Signal::Type::Multiplexor);
|
||||||
|
REQUIRE(msg->sigs[1]->type == cabana::Signal::Type::Multiplexed);
|
||||||
|
REQUIRE(msg->sigs[1]->multiplex_value == 4);
|
||||||
|
REQUIRE(msg->sigs[1]->start_bit == 12);
|
||||||
|
REQUIRE(msg->sigs[1]->size == 1);
|
||||||
|
REQUIRE(msg->sigs[1]->receiver_name == "XXX");
|
||||||
|
}
|
||||||
10
tools/cabana/tests/test_runner.cc
Executable file
10
tools/cabana/tests/test_runner.cc
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#define CATCH_CONFIG_RUNNER
|
||||||
|
#include "catch2/catch.hpp"
|
||||||
|
#include <QCoreApplication>
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
// unit tests for Qt
|
||||||
|
QCoreApplication app(argc, argv);
|
||||||
|
const int res = Catch::Session().run(argc, argv);
|
||||||
|
return (res < 0xff ? res : 0xff);
|
||||||
|
}
|
||||||
269
tools/cabana/tools/findsignal.cc
Executable file
269
tools/cabana/tools/findsignal.cc
Executable file
@@ -0,0 +1,269 @@
|
|||||||
|
#include "tools/cabana/tools/findsignal.h"
|
||||||
|
|
||||||
|
#include <QFormLayout>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
// FindSignalModel
|
||||||
|
|
||||||
|
QVariant FindSignalModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||||
|
static QString titles[] = {"Id", "Start Bit, size", "(time, value)"};
|
||||||
|
if (role != Qt::DisplayRole) return {};
|
||||||
|
return orientation == Qt::Horizontal ? titles[section] : QString::number(section + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant FindSignalModel::data(const QModelIndex &index, int role) const {
|
||||||
|
if (role == Qt::DisplayRole) {
|
||||||
|
const auto &s = filtered_signals[index.row()];
|
||||||
|
switch (index.column()) {
|
||||||
|
case 0: return s.id.toString();
|
||||||
|
case 1: return QString("%1, %2").arg(s.sig.start_bit).arg(s.sig.size);
|
||||||
|
case 2: return s.values.join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
void FindSignalModel::search(std::function<bool(double)> cmp) {
|
||||||
|
beginResetModel();
|
||||||
|
|
||||||
|
std::mutex lock;
|
||||||
|
const auto prev_sigs = !histories.isEmpty() ? histories.back() : initial_signals;
|
||||||
|
filtered_signals.clear();
|
||||||
|
filtered_signals.reserve(prev_sigs.size());
|
||||||
|
QtConcurrent::blockingMap(prev_sigs, [&](auto &s) {
|
||||||
|
const auto &events = can->events(s.id);
|
||||||
|
auto first = std::upper_bound(events.cbegin(), events.cend(), s.mono_time, CompareCanEvent());
|
||||||
|
auto last = events.cend();
|
||||||
|
if (last_time < std::numeric_limits<uint64_t>::max()) {
|
||||||
|
last = std::upper_bound(events.cbegin(), events.cend(), last_time, CompareCanEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
auto it = std::find_if(first, last, [&](const CanEvent *e) { return cmp(get_raw_value(e->dat, e->size, s.sig)); });
|
||||||
|
if (it != last) {
|
||||||
|
auto values = s.values;
|
||||||
|
values += QString("(%1, %2)").arg((*it)->mono_time / 1e9 - can->routeStartTime(), 0, 'f', 2).arg(get_raw_value((*it)->dat, (*it)->size, s.sig));
|
||||||
|
std::lock_guard lk(lock);
|
||||||
|
filtered_signals.push_back({.id = s.id, .mono_time = (*it)->mono_time, .sig = s.sig, .values = values});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
histories.push_back(filtered_signals);
|
||||||
|
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void FindSignalModel::undo() {
|
||||||
|
if (!histories.isEmpty()) {
|
||||||
|
beginResetModel();
|
||||||
|
histories.pop_back();
|
||||||
|
filtered_signals.clear();
|
||||||
|
if (!histories.isEmpty()) filtered_signals = histories.back();
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FindSignalModel::reset() {
|
||||||
|
beginResetModel();
|
||||||
|
histories.clear();
|
||||||
|
filtered_signals.clear();
|
||||||
|
initial_signals.clear();
|
||||||
|
endResetModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSignalDlg
|
||||||
|
FindSignalDlg::FindSignalDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) {
|
||||||
|
setWindowTitle(tr("Find Signal"));
|
||||||
|
setAttribute(Qt::WA_DeleteOnClose);
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
|
||||||
|
// Messages group
|
||||||
|
message_group = new QGroupBox(tr("Messages"), this);
|
||||||
|
QFormLayout *message_layout = new QFormLayout(message_group);
|
||||||
|
message_layout->addRow(tr("Bus"), bus_edit = new QLineEdit());
|
||||||
|
bus_edit->setPlaceholderText(tr("comma-seperated values. Leave blank for all"));
|
||||||
|
message_layout->addRow(tr("Address"), address_edit = new QLineEdit());
|
||||||
|
address_edit->setPlaceholderText(tr("comma-seperated hex values. Leave blank for all"));
|
||||||
|
QHBoxLayout *hlayout = new QHBoxLayout();
|
||||||
|
hlayout->addWidget(first_time_edit = new QLineEdit("0"));
|
||||||
|
hlayout->addWidget(new QLabel("-"));
|
||||||
|
hlayout->addWidget(last_time_edit = new QLineEdit("MAX"));
|
||||||
|
hlayout->addWidget(new QLabel("seconds"));
|
||||||
|
hlayout->addStretch(0);
|
||||||
|
message_layout->addRow(tr("Time"), hlayout);
|
||||||
|
|
||||||
|
// Signal group
|
||||||
|
properties_group = new QGroupBox(tr("Signal"));
|
||||||
|
QFormLayout *property_layout = new QFormLayout(properties_group);
|
||||||
|
property_layout->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint);
|
||||||
|
|
||||||
|
hlayout = new QHBoxLayout();
|
||||||
|
hlayout->addWidget(min_size = new QSpinBox);
|
||||||
|
hlayout->addWidget(new QLabel("-"));
|
||||||
|
hlayout->addWidget(max_size = new QSpinBox);
|
||||||
|
hlayout->addWidget(litter_endian = new QCheckBox(tr("Little endian")));
|
||||||
|
hlayout->addWidget(is_signed = new QCheckBox(tr("Signed")));
|
||||||
|
hlayout->addStretch(0);
|
||||||
|
min_size->setRange(1, 64);
|
||||||
|
max_size->setRange(1, 64);
|
||||||
|
min_size->setValue(8);
|
||||||
|
max_size->setValue(8);
|
||||||
|
litter_endian->setChecked(true);
|
||||||
|
property_layout->addRow(tr("Size"), hlayout);
|
||||||
|
property_layout->addRow(tr("Factor"), factor_edit = new QLineEdit("1.0"));
|
||||||
|
property_layout->addRow(tr("Offset"), offset_edit = new QLineEdit("0.0"));
|
||||||
|
|
||||||
|
// find group
|
||||||
|
QGroupBox *find_group = new QGroupBox(tr("Find signal"), this);
|
||||||
|
QVBoxLayout *vlayout = new QVBoxLayout(find_group);
|
||||||
|
hlayout = new QHBoxLayout();
|
||||||
|
hlayout->addWidget(new QLabel(tr("Value")));
|
||||||
|
hlayout->addWidget(compare_cb = new QComboBox(this));
|
||||||
|
hlayout->addWidget(value1 = new QLineEdit);
|
||||||
|
hlayout->addWidget(to_label = new QLabel("-"));
|
||||||
|
hlayout->addWidget(value2 = new QLineEdit);
|
||||||
|
hlayout->addWidget(undo_btn = new QPushButton(tr("Undo prev find"), this));
|
||||||
|
hlayout->addWidget(search_btn = new QPushButton(tr("Find")));
|
||||||
|
hlayout->addWidget(reset_btn = new QPushButton(tr("Reset"), this));
|
||||||
|
vlayout->addLayout(hlayout);
|
||||||
|
|
||||||
|
compare_cb->addItems({"=", ">", ">=", "!=", "<", "<=", "between"});
|
||||||
|
value1->setFocus(Qt::OtherFocusReason);
|
||||||
|
value2->setVisible(false);
|
||||||
|
to_label->setVisible(false);
|
||||||
|
undo_btn->setEnabled(false);
|
||||||
|
reset_btn->setEnabled(false);
|
||||||
|
|
||||||
|
auto double_validator = new DoubleValidator(this);
|
||||||
|
for (auto edit : {value1, value2, factor_edit, offset_edit, first_time_edit, last_time_edit}) {
|
||||||
|
edit->setValidator(double_validator);
|
||||||
|
}
|
||||||
|
|
||||||
|
vlayout->addWidget(view = new QTableView(this));
|
||||||
|
view->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
|
view->horizontalHeader()->setStretchLastSection(true);
|
||||||
|
view->horizontalHeader()->setSelectionMode(QAbstractItemView::NoSelection);
|
||||||
|
view->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||||
|
view->setModel(model = new FindSignalModel(this));
|
||||||
|
|
||||||
|
hlayout = new QHBoxLayout();
|
||||||
|
hlayout->addWidget(message_group);
|
||||||
|
hlayout->addWidget(properties_group);
|
||||||
|
main_layout->addLayout(hlayout);
|
||||||
|
main_layout->addWidget(find_group);
|
||||||
|
main_layout->addWidget(stats_label = new QLabel());
|
||||||
|
|
||||||
|
setMinimumSize({700, 650});
|
||||||
|
QObject::connect(search_btn, &QPushButton::clicked, this, &FindSignalDlg::search);
|
||||||
|
QObject::connect(undo_btn, &QPushButton::clicked, model, &FindSignalModel::undo);
|
||||||
|
QObject::connect(model, &QAbstractItemModel::modelReset, this, &FindSignalDlg::modelReset);
|
||||||
|
QObject::connect(reset_btn, &QPushButton::clicked, model, &FindSignalModel::reset);
|
||||||
|
QObject::connect(view, &QTableView::customContextMenuRequested, this, &FindSignalDlg::customMenuRequested);
|
||||||
|
QObject::connect(view, &QTableView::doubleClicked, [this](const QModelIndex &index) {
|
||||||
|
if (index.isValid()) emit openMessage(model->filtered_signals[index.row()].id);
|
||||||
|
});
|
||||||
|
QObject::connect(compare_cb, qOverload<int>(&QComboBox::currentIndexChanged), [=](int index) {
|
||||||
|
to_label->setVisible(index == compare_cb->count() - 1);
|
||||||
|
value2->setVisible(index == compare_cb->count() - 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void FindSignalDlg::search() {
|
||||||
|
if (model->histories.isEmpty()) {
|
||||||
|
setInitialSignals();
|
||||||
|
}
|
||||||
|
auto v1 = value1->text().toDouble();
|
||||||
|
auto v2 = value2->text().toDouble();
|
||||||
|
std::function<bool(double)> cmp = nullptr;
|
||||||
|
switch (compare_cb->currentIndex()) {
|
||||||
|
case 0: cmp = [v1](double v) { return v == v1;}; break;
|
||||||
|
case 1: cmp = [v1](double v) { return v > v1;}; break;
|
||||||
|
case 2: cmp = [v1](double v) { return v >= v1;}; break;
|
||||||
|
case 3: cmp = [v1](double v) { return v != v1;}; break;
|
||||||
|
case 4: cmp = [v1](double v) { return v < v1;}; break;
|
||||||
|
case 5: cmp = [v1](double v) { return v <= v1;}; break;
|
||||||
|
case 6: cmp = [v1, v2](double v) { return v >= v1 && v <= v2;}; break;
|
||||||
|
}
|
||||||
|
properties_group->setEnabled(false);
|
||||||
|
message_group->setEnabled(false);
|
||||||
|
search_btn->setEnabled(false);
|
||||||
|
stats_label->setVisible(false);
|
||||||
|
search_btn->setText("Finding ....");
|
||||||
|
QTimer::singleShot(0, this, [=]() { model->search(cmp); });
|
||||||
|
}
|
||||||
|
|
||||||
|
void FindSignalDlg::setInitialSignals() {
|
||||||
|
QSet<ushort> buses;
|
||||||
|
for (auto bus : bus_edit->text().trimmed().split(",")) {
|
||||||
|
bus = bus.trimmed();
|
||||||
|
if (!bus.isEmpty()) buses.insert(bus.toUShort());
|
||||||
|
}
|
||||||
|
|
||||||
|
QSet<uint32_t> addresses;
|
||||||
|
for (auto addr : address_edit->text().trimmed().split(",")) {
|
||||||
|
addr = addr.trimmed();
|
||||||
|
if (!addr.isEmpty()) addresses.insert(addr.toULong(nullptr, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
cabana::Signal sig{};
|
||||||
|
sig.is_little_endian = litter_endian->isChecked();
|
||||||
|
sig.is_signed = is_signed->isChecked();
|
||||||
|
sig.factor = factor_edit->text().toDouble();
|
||||||
|
sig.offset = offset_edit->text().toDouble();
|
||||||
|
|
||||||
|
double first_time_val = first_time_edit->text().toDouble();
|
||||||
|
double last_time_val = last_time_edit->text().toDouble();
|
||||||
|
auto [first_sec, last_sec] = std::minmax(first_time_val, last_time_val);
|
||||||
|
uint64_t first_time = (can->routeStartTime() + first_sec) * 1e9;
|
||||||
|
model->last_time = std::numeric_limits<uint64_t>::max();
|
||||||
|
if (last_sec > 0) {
|
||||||
|
model->last_time = (can->routeStartTime() + last_sec) * 1e9;
|
||||||
|
}
|
||||||
|
model->initial_signals.clear();
|
||||||
|
|
||||||
|
for (const auto &[id, m] : can->lastMessages()) {
|
||||||
|
if (buses.isEmpty() || buses.contains(id.source) && (addresses.isEmpty() || addresses.contains(id.address))) {
|
||||||
|
const auto &events = can->events(id);
|
||||||
|
auto e = std::lower_bound(events.cbegin(), events.cend(), first_time, CompareCanEvent());
|
||||||
|
if (e != events.cend()) {
|
||||||
|
const int total_size = m.dat.size() * 8;
|
||||||
|
for (int size = min_size->value(); size <= max_size->value(); ++size) {
|
||||||
|
for (int start = 0; start <= total_size - size; ++start) {
|
||||||
|
FindSignalModel::SearchSignal s{.id = id, .mono_time = first_time, .sig = sig};
|
||||||
|
s.sig.start_bit = start;
|
||||||
|
s.sig.size = size;
|
||||||
|
updateMsbLsb(s.sig);
|
||||||
|
s.value = get_raw_value((*e)->dat, (*e)->size, s.sig);
|
||||||
|
model->initial_signals.push_back(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void FindSignalDlg::modelReset() {
|
||||||
|
properties_group->setEnabled(model->histories.isEmpty());
|
||||||
|
message_group->setEnabled(model->histories.isEmpty());
|
||||||
|
search_btn->setText(model->histories.isEmpty() ? tr("Find") : tr("Find Next"));
|
||||||
|
reset_btn->setEnabled(!model->histories.isEmpty());
|
||||||
|
undo_btn->setEnabled(model->histories.size() > 1);
|
||||||
|
search_btn->setEnabled(model->rowCount() > 0 || model->histories.isEmpty());
|
||||||
|
stats_label->setVisible(true);
|
||||||
|
stats_label->setText(tr("%1 matches. right click on an item to create signal. double click to open message").arg(model->filtered_signals.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void FindSignalDlg::customMenuRequested(const QPoint &pos) {
|
||||||
|
if (auto index = view->indexAt(pos); index.isValid()) {
|
||||||
|
QMenu menu(this);
|
||||||
|
menu.addAction(tr("Create Signal"));
|
||||||
|
if (menu.exec(view->mapToGlobal(pos))) {
|
||||||
|
auto &s = model->filtered_signals[index.row()];
|
||||||
|
UndoStack::push(new AddSigCommand(s.id, s.sig));
|
||||||
|
emit openMessage(s.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
tools/cabana/tools/findsignal.h
Executable file
64
tools/cabana/tools/findsignal.h
Executable file
@@ -0,0 +1,64 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
#include <QAbstractTableModel>
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QTableView>
|
||||||
|
|
||||||
|
#include "tools/cabana/commands.h"
|
||||||
|
#include "tools/cabana/settings.h"
|
||||||
|
|
||||||
|
class FindSignalModel : public QAbstractTableModel {
|
||||||
|
public:
|
||||||
|
struct SearchSignal {
|
||||||
|
MessageId id = {};
|
||||||
|
uint64_t mono_time = 0;
|
||||||
|
cabana::Signal sig = {};
|
||||||
|
double value = 0.;
|
||||||
|
QStringList values;
|
||||||
|
};
|
||||||
|
|
||||||
|
FindSignalModel(QObject *parent) : QAbstractTableModel(parent) {}
|
||||||
|
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
|
||||||
|
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return 3; }
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return std::min(filtered_signals.size(), 300); }
|
||||||
|
void search(std::function<bool(double)> cmp);
|
||||||
|
void reset();
|
||||||
|
void undo();
|
||||||
|
|
||||||
|
QList<SearchSignal> filtered_signals;
|
||||||
|
QList<SearchSignal> initial_signals;
|
||||||
|
QList<QList<SearchSignal>> histories;
|
||||||
|
uint64_t last_time = std::numeric_limits<uint64_t>::max();
|
||||||
|
};
|
||||||
|
|
||||||
|
class FindSignalDlg : public QDialog {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
FindSignalDlg(QWidget *parent);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void openMessage(const MessageId &id);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void search();
|
||||||
|
void modelReset();
|
||||||
|
void setInitialSignals();
|
||||||
|
void customMenuRequested(const QPoint &pos);
|
||||||
|
|
||||||
|
QLineEdit *value1, *value2, *factor_edit, *offset_edit;
|
||||||
|
QLineEdit *bus_edit, *address_edit, *first_time_edit, *last_time_edit;
|
||||||
|
QComboBox *compare_cb;
|
||||||
|
QSpinBox *min_size, *max_size;
|
||||||
|
QCheckBox *litter_endian, *is_signed;
|
||||||
|
QPushButton *search_btn, *reset_btn, *undo_btn;
|
||||||
|
QGroupBox *properties_group, *message_group;
|
||||||
|
QTableView *view;
|
||||||
|
QLabel *to_label, *stats_label;
|
||||||
|
FindSignalModel *model;
|
||||||
|
};
|
||||||
160
tools/cabana/tools/findsimilarbits.cc
Executable file
160
tools/cabana/tools/findsimilarbits.cc
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#include "tools/cabana/tools/findsimilarbits.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QHeaderView>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QIntValidator>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QRadioButton>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
#include "tools/cabana/streams/abstractstream.h"
|
||||||
|
|
||||||
|
FindSimilarBitsDlg::FindSimilarBitsDlg(QWidget *parent) : QDialog(parent, Qt::WindowFlags() | Qt::Window) {
|
||||||
|
setWindowTitle(tr("Find similar bits"));
|
||||||
|
setAttribute(Qt::WA_DeleteOnClose);
|
||||||
|
|
||||||
|
QVBoxLayout *main_layout = new QVBoxLayout(this);
|
||||||
|
|
||||||
|
QHBoxLayout *src_layout = new QHBoxLayout();
|
||||||
|
src_bus_combo = new QComboBox(this);
|
||||||
|
find_bus_combo = new QComboBox(this);
|
||||||
|
for (auto cb : {src_bus_combo, find_bus_combo}) {
|
||||||
|
for (uint8_t bus : can->sources) {
|
||||||
|
cb->addItem(QString::number(bus), bus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg_cb = new QComboBox(this);
|
||||||
|
// TODO: update when src_bus_combo changes
|
||||||
|
for (auto &[address, msg] : dbc()->getMessages(-1)) {
|
||||||
|
msg_cb->addItem(msg.name, address);
|
||||||
|
}
|
||||||
|
msg_cb->model()->sort(0);
|
||||||
|
msg_cb->setCurrentIndex(0);
|
||||||
|
|
||||||
|
byte_idx_sb = new QSpinBox(this);
|
||||||
|
byte_idx_sb->setFixedWidth(50);
|
||||||
|
byte_idx_sb->setRange(0, 63);
|
||||||
|
|
||||||
|
bit_idx_sb = new QSpinBox(this);
|
||||||
|
bit_idx_sb->setFixedWidth(50);
|
||||||
|
bit_idx_sb->setRange(0, 7);
|
||||||
|
|
||||||
|
src_layout->addWidget(new QLabel(tr("Bus")));
|
||||||
|
src_layout->addWidget(src_bus_combo);
|
||||||
|
src_layout->addWidget(msg_cb);
|
||||||
|
src_layout->addWidget(new QLabel(tr("Byte Index")));
|
||||||
|
src_layout->addWidget(byte_idx_sb);
|
||||||
|
src_layout->addWidget(new QLabel(tr("Bit Index")));
|
||||||
|
src_layout->addWidget(bit_idx_sb);
|
||||||
|
src_layout->addStretch(0);
|
||||||
|
|
||||||
|
QHBoxLayout *find_layout = new QHBoxLayout();
|
||||||
|
find_layout->addWidget(new QLabel(tr("Bus")));
|
||||||
|
find_layout->addWidget(find_bus_combo);
|
||||||
|
find_layout->addWidget(new QLabel(tr("Equal")));
|
||||||
|
equal_combo = new QComboBox(this);
|
||||||
|
equal_combo->addItems({"Yes", "No"});
|
||||||
|
find_layout->addWidget(equal_combo);
|
||||||
|
min_msgs = new QLineEdit(this);
|
||||||
|
min_msgs->setValidator(new QIntValidator(this));
|
||||||
|
min_msgs->setText("100");
|
||||||
|
find_layout->addWidget(new QLabel(tr("Min msg count")));
|
||||||
|
find_layout->addWidget(min_msgs);
|
||||||
|
search_btn = new QPushButton(tr("&Find"), this);
|
||||||
|
find_layout->addWidget(search_btn);
|
||||||
|
find_layout->addStretch(0);
|
||||||
|
|
||||||
|
QGridLayout *grid_layout = new QGridLayout();
|
||||||
|
grid_layout->addWidget(new QLabel("Find From:"), 0, 0);
|
||||||
|
grid_layout->addLayout(src_layout, 0, 1);
|
||||||
|
grid_layout->addWidget(new QLabel("Find In:"), 1, 0);
|
||||||
|
grid_layout->addLayout(find_layout, 1, 1);
|
||||||
|
main_layout->addLayout(grid_layout);
|
||||||
|
|
||||||
|
table = new QTableWidget(this);
|
||||||
|
table->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||||
|
table->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||||
|
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||||
|
table->horizontalHeader()->setStretchLastSection(true);
|
||||||
|
main_layout->addWidget(table);
|
||||||
|
|
||||||
|
setMinimumSize({700, 500});
|
||||||
|
QObject::connect(search_btn, &QPushButton::clicked, this, &FindSimilarBitsDlg::find);
|
||||||
|
QObject::connect(table, &QTableWidget::doubleClicked, [this](const QModelIndex &index) {
|
||||||
|
if (index.isValid()) {
|
||||||
|
MessageId msg_id = {.source = (uint8_t)find_bus_combo->currentData().toUInt(), .address = table->item(index.row(), 0)->text().toUInt(0, 16)};
|
||||||
|
emit openMessage(msg_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void FindSimilarBitsDlg::find() {
|
||||||
|
search_btn->setEnabled(false);
|
||||||
|
table->clear();
|
||||||
|
uint32_t selected_address = msg_cb->currentData().toUInt();
|
||||||
|
auto msg_mismatched = calcBits(src_bus_combo->currentText().toUInt(), selected_address, byte_idx_sb->value(), bit_idx_sb->value(),
|
||||||
|
find_bus_combo->currentText().toUInt(), equal_combo->currentIndex() == 0, min_msgs->text().toInt());
|
||||||
|
table->setRowCount(msg_mismatched.size());
|
||||||
|
table->setColumnCount(6);
|
||||||
|
table->setHorizontalHeaderLabels({"address", "byte idx", "bit idx", "mismatches", "total msgs", "% mismatched"});
|
||||||
|
for (int i = 0; i < msg_mismatched.size(); ++i) {
|
||||||
|
auto &m = msg_mismatched[i];
|
||||||
|
table->setItem(i, 0, new QTableWidgetItem(QString("%1").arg(m.address, 1, 16)));
|
||||||
|
table->setItem(i, 1, new QTableWidgetItem(QString::number(m.byte_idx)));
|
||||||
|
table->setItem(i, 2, new QTableWidgetItem(QString::number(m.bit_idx)));
|
||||||
|
table->setItem(i, 3, new QTableWidgetItem(QString::number(m.mismatches)));
|
||||||
|
table->setItem(i, 4, new QTableWidgetItem(QString::number(m.total)));
|
||||||
|
table->setItem(i, 5, new QTableWidgetItem(QString::number(m.perc, 'f', 2)));
|
||||||
|
}
|
||||||
|
search_btn->setEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<FindSimilarBitsDlg::mismatched_struct> FindSimilarBitsDlg::calcBits(uint8_t bus, uint32_t selected_address, int byte_idx,
|
||||||
|
int bit_idx, uint8_t find_bus, bool equal, int min_msgs_cnt) {
|
||||||
|
QHash<uint32_t, QVector<uint32_t>> mismatches;
|
||||||
|
QHash<uint32_t, uint32_t> msg_count;
|
||||||
|
const auto &events = can->allEvents();
|
||||||
|
int bit_to_find = -1;
|
||||||
|
for (const CanEvent *e : events) {
|
||||||
|
if (e->src == bus) {
|
||||||
|
if (e->address == selected_address && e->size > byte_idx) {
|
||||||
|
bit_to_find = ((e->dat[byte_idx] >> (7 - bit_idx)) & 1) != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e->src == find_bus) {
|
||||||
|
++msg_count[e->address];
|
||||||
|
if (bit_to_find == -1) continue;
|
||||||
|
|
||||||
|
auto &mismatched = mismatches[e->address];
|
||||||
|
if (mismatched.size() < e->size * 8) {
|
||||||
|
mismatched.resize(e->size * 8);
|
||||||
|
}
|
||||||
|
for (int i = 0; i < e->size; ++i) {
|
||||||
|
for (int j = 0; j < 8; ++j) {
|
||||||
|
int bit = ((e->dat[i] >> (7 - j)) & 1) != 0;
|
||||||
|
mismatched[i * 8 + j] += equal ? (bit != bit_to_find) : (bit == bit_to_find);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<mismatched_struct> result;
|
||||||
|
result.reserve(mismatches.size());
|
||||||
|
for (auto it = mismatches.begin(); it != mismatches.end(); ++it) {
|
||||||
|
if (auto cnt = msg_count[it.key()]; cnt > min_msgs_cnt) {
|
||||||
|
auto &mismatched = it.value();
|
||||||
|
for (int i = 0; i < mismatched.size(); ++i) {
|
||||||
|
if (float perc = (mismatched[i] / (double)cnt) * 100; perc < 50) {
|
||||||
|
result.push_back({it.key(), (uint32_t)i / 8, (uint32_t)i % 8, mismatched[i], cnt, perc});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::sort(result.begin(), result.end(), [](auto &l, auto &r) { return l.perc < r.perc; });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
34
tools/cabana/tools/findsimilarbits.h
Executable file
34
tools/cabana/tools/findsimilarbits.h
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDialog>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QSpinBox>
|
||||||
|
#include <QTableWidget>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbcmanager.h"
|
||||||
|
|
||||||
|
class FindSimilarBitsDlg : public QDialog {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
FindSimilarBitsDlg(QWidget *parent);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void openMessage(const MessageId &msg_id);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct mismatched_struct {
|
||||||
|
uint32_t address, byte_idx, bit_idx, mismatches, total;
|
||||||
|
float perc;
|
||||||
|
};
|
||||||
|
QList<mismatched_struct> calcBits(uint8_t bus, uint32_t selected_address, int byte_idx, int bit_idx, uint8_t find_bus,
|
||||||
|
bool equal, int min_msgs_cnt);
|
||||||
|
void find();
|
||||||
|
|
||||||
|
QTableWidget *table;
|
||||||
|
QComboBox *src_bus_combo, *find_bus_combo, *msg_cb, *equal_combo;
|
||||||
|
QSpinBox *byte_idx_sb, *bit_idx_sb;
|
||||||
|
QPushButton *search_btn;
|
||||||
|
QLineEdit *min_msgs;
|
||||||
|
};
|
||||||
283
tools/cabana/util.cc
Executable file
283
tools/cabana/util.cc
Executable file
@@ -0,0 +1,283 @@
|
|||||||
|
#include "tools/cabana/util.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <csignal>
|
||||||
|
#include <limits>
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QFontDatabase>
|
||||||
|
#include <QLocale>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPixmapCache>
|
||||||
|
|
||||||
|
#include "selfdrive/ui/qt/util.h"
|
||||||
|
|
||||||
|
// SegmentTree
|
||||||
|
|
||||||
|
void SegmentTree::build(const std::vector<QPointF> &arr) {
|
||||||
|
size = arr.size();
|
||||||
|
tree.resize(4 * size); // size of the tree is 4 times the size of the array
|
||||||
|
if (size > 0) {
|
||||||
|
build_tree(arr, 1, 0, size - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SegmentTree::build_tree(const std::vector<QPointF> &arr, int n, int left, int right) {
|
||||||
|
if (left == right) {
|
||||||
|
const double y = arr[left].y();
|
||||||
|
tree[n] = {y, y};
|
||||||
|
} else {
|
||||||
|
const int mid = (left + right) >> 1;
|
||||||
|
build_tree(arr, 2 * n, left, mid);
|
||||||
|
build_tree(arr, 2 * n + 1, mid + 1, right);
|
||||||
|
tree[n] = {std::min(tree[2 * n].first, tree[2 * n + 1].first), std::max(tree[2 * n].second, tree[2 * n + 1].second)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::pair<double, double> SegmentTree::get_minmax(int n, int left, int right, int range_left, int range_right) const {
|
||||||
|
if (range_left > right || range_right < left)
|
||||||
|
return {std::numeric_limits<double>::max(), std::numeric_limits<double>::lowest()};
|
||||||
|
if (range_left <= left && range_right >= right)
|
||||||
|
return tree[n];
|
||||||
|
int mid = (left + right) >> 1;
|
||||||
|
auto l = get_minmax(2 * n, left, mid, range_left, range_right);
|
||||||
|
auto r = get_minmax(2 * n + 1, mid + 1, right, range_left, range_right);
|
||||||
|
return {std::min(l.first, r.first), std::max(l.second, r.second)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageBytesDelegate
|
||||||
|
|
||||||
|
MessageBytesDelegate::MessageBytesDelegate(QObject *parent, bool multiple_lines) : multiple_lines(multiple_lines), QStyledItemDelegate(parent) {
|
||||||
|
fixed_font = QFontDatabase::systemFont(QFontDatabase::FixedFont);
|
||||||
|
byte_size = QFontMetrics(fixed_font).size(Qt::TextSingleLine, "00 ") + QSize(0, 2);
|
||||||
|
for (int i = 0; i < 256; ++i) {
|
||||||
|
hex_text_table[i].setText(QStringLiteral("%1").arg(i, 2, 16, QLatin1Char('0')).toUpper());
|
||||||
|
hex_text_table[i].prepare({}, fixed_font);
|
||||||
|
}
|
||||||
|
h_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1;
|
||||||
|
v_margin = QApplication::style()->pixelMetric(QStyle::PM_FocusFrameVMargin) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize MessageBytesDelegate::sizeForBytes(int n) const {
|
||||||
|
int rows = multiple_lines ? std::max(1, n / 8) : 1;
|
||||||
|
return {(n / rows) * byte_size.width() + h_margin * 2, rows * byte_size.height() + v_margin * 2};
|
||||||
|
}
|
||||||
|
|
||||||
|
QSize MessageBytesDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||||
|
auto data = index.data(BytesRole);
|
||||||
|
return sizeForBytes(data.isValid() ? static_cast<std::vector<uint8_t> *>(data.value<void *>())->size() : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void MessageBytesDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const {
|
||||||
|
auto data = index.data(BytesRole);
|
||||||
|
if (!data.isValid()) {
|
||||||
|
return QStyledItemDelegate::paint(painter, option, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
QFont old_font = painter->font();
|
||||||
|
QPen old_pen = painter->pen();
|
||||||
|
if (option.state & QStyle::State_Selected) {
|
||||||
|
painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight));
|
||||||
|
}
|
||||||
|
const QPoint pt{option.rect.left() + h_margin, option.rect.top() + v_margin};
|
||||||
|
painter->setFont(fixed_font);
|
||||||
|
|
||||||
|
const auto &bytes = *static_cast<std::vector<uint8_t>*>(data.value<void*>());
|
||||||
|
const auto &colors = *static_cast<std::vector<QColor>*>(index.data(ColorsRole).value<void*>());
|
||||||
|
for (int i = 0; i < bytes.size(); ++i) {
|
||||||
|
int row = !multiple_lines ? 0 : i / 8;
|
||||||
|
int column = !multiple_lines ? i : i % 8;
|
||||||
|
QRect r = QRect({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size);
|
||||||
|
if (i < colors.size() && colors[i].alpha() > 0) {
|
||||||
|
if (option.state & QStyle::State_Selected) {
|
||||||
|
painter->setPen(option.palette.color(QPalette::Text));
|
||||||
|
painter->fillRect(r, option.palette.color(QPalette::Window));
|
||||||
|
}
|
||||||
|
painter->fillRect(r, colors[i]);
|
||||||
|
} else if (option.state & QStyle::State_Selected) {
|
||||||
|
painter->setPen(option.palette.color(QPalette::HighlightedText));
|
||||||
|
}
|
||||||
|
utils::drawStaticText(painter, r, hex_text_table[bytes[i]]);
|
||||||
|
}
|
||||||
|
painter->setFont(old_font);
|
||||||
|
painter->setPen(old_pen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TabBar
|
||||||
|
|
||||||
|
int TabBar::addTab(const QString &text) {
|
||||||
|
int index = QTabBar::addTab(text);
|
||||||
|
QToolButton *btn = new ToolButton("x", tr("Close Tab"));
|
||||||
|
int width = style()->pixelMetric(QStyle::PM_TabCloseIndicatorWidth, nullptr, btn);
|
||||||
|
int height = style()->pixelMetric(QStyle::PM_TabCloseIndicatorHeight, nullptr, btn);
|
||||||
|
btn->setFixedSize({width, height});
|
||||||
|
setTabButton(index, QTabBar::RightSide, btn);
|
||||||
|
QObject::connect(btn, &QToolButton::clicked, this, &TabBar::closeTabClicked);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabBar::closeTabClicked() {
|
||||||
|
QObject *object = sender();
|
||||||
|
for (int i = 0; i < count(); ++i) {
|
||||||
|
if (tabButton(i, QTabBar::RightSide) == object) {
|
||||||
|
emit tabCloseRequested(i);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnixSignalHandler
|
||||||
|
|
||||||
|
UnixSignalHandler::UnixSignalHandler(QObject *parent) : QObject(nullptr) {
|
||||||
|
if (::socketpair(AF_UNIX, SOCK_STREAM, 0, sig_fd)) {
|
||||||
|
qFatal("Couldn't create TERM socketpair");
|
||||||
|
}
|
||||||
|
|
||||||
|
sn = new QSocketNotifier(sig_fd[1], QSocketNotifier::Read, this);
|
||||||
|
connect(sn, &QSocketNotifier::activated, this, &UnixSignalHandler::handleSigTerm);
|
||||||
|
std::signal(SIGINT, signalHandler);
|
||||||
|
std::signal(SIGTERM, UnixSignalHandler::signalHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
UnixSignalHandler::~UnixSignalHandler() {
|
||||||
|
::close(sig_fd[0]);
|
||||||
|
::close(sig_fd[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UnixSignalHandler::signalHandler(int s) {
|
||||||
|
::write(sig_fd[0], &s, sizeof(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
void UnixSignalHandler::handleSigTerm() {
|
||||||
|
sn->setEnabled(false);
|
||||||
|
int tmp;
|
||||||
|
::read(sig_fd[1], &tmp, sizeof(tmp));
|
||||||
|
|
||||||
|
printf("\nexiting...\n");
|
||||||
|
qApp->closeAllWindows();
|
||||||
|
qApp->exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NameValidator
|
||||||
|
|
||||||
|
NameValidator::NameValidator(QObject *parent) : QRegExpValidator(QRegExp("^(\\w+)"), parent) {}
|
||||||
|
|
||||||
|
QValidator::State NameValidator::validate(QString &input, int &pos) const {
|
||||||
|
input.replace(' ', '_');
|
||||||
|
return QRegExpValidator::validate(input, pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
DoubleValidator::DoubleValidator(QObject *parent) : QDoubleValidator(parent) {
|
||||||
|
// Match locale of QString::toDouble() instead of system
|
||||||
|
QLocale locale(QLocale::C);
|
||||||
|
locale.setNumberOptions(QLocale::RejectGroupSeparator);
|
||||||
|
setLocale(locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace utils {
|
||||||
|
QPixmap icon(const QString &id) {
|
||||||
|
bool dark_theme = settings.theme == DARK_THEME;
|
||||||
|
QPixmap pm;
|
||||||
|
QString key = "bootstrap_" % id % (dark_theme ? "1" : "0");
|
||||||
|
if (!QPixmapCache::find(key, &pm)) {
|
||||||
|
pm = bootstrapPixmap(id);
|
||||||
|
if (dark_theme) {
|
||||||
|
QPainter p(&pm);
|
||||||
|
p.setCompositionMode(QPainter::CompositionMode_SourceIn);
|
||||||
|
p.fillRect(pm.rect(), QColor("#bbbbbb"));
|
||||||
|
}
|
||||||
|
QPixmapCache::insert(key, pm);
|
||||||
|
}
|
||||||
|
return pm;
|
||||||
|
}
|
||||||
|
|
||||||
|
void setTheme(int theme) {
|
||||||
|
auto style = QApplication::style();
|
||||||
|
if (!style) return;
|
||||||
|
|
||||||
|
static int prev_theme = 0;
|
||||||
|
if (theme != prev_theme) {
|
||||||
|
prev_theme = theme;
|
||||||
|
QPalette new_palette;
|
||||||
|
if (theme == DARK_THEME) {
|
||||||
|
// "Darcula" like dark theme
|
||||||
|
new_palette.setColor(QPalette::Window, QColor("#353535"));
|
||||||
|
new_palette.setColor(QPalette::WindowText, QColor("#bbbbbb"));
|
||||||
|
new_palette.setColor(QPalette::Base, QColor("#3c3f41"));
|
||||||
|
new_palette.setColor(QPalette::AlternateBase, QColor("#3c3f41"));
|
||||||
|
new_palette.setColor(QPalette::ToolTipBase, QColor("#3c3f41"));
|
||||||
|
new_palette.setColor(QPalette::ToolTipText, QColor("#bbb"));
|
||||||
|
new_palette.setColor(QPalette::Text, QColor("#bbbbbb"));
|
||||||
|
new_palette.setColor(QPalette::Button, QColor("#3c3f41"));
|
||||||
|
new_palette.setColor(QPalette::ButtonText, QColor("#bbbbbb"));
|
||||||
|
new_palette.setColor(QPalette::Highlight, QColor("#2f65ca"));
|
||||||
|
new_palette.setColor(QPalette::HighlightedText, QColor("#bbbbbb"));
|
||||||
|
new_palette.setColor(QPalette::BrightText, QColor("#f0f0f0"));
|
||||||
|
new_palette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor("#777777"));
|
||||||
|
new_palette.setColor(QPalette::Disabled, QPalette::WindowText, QColor("#777777"));
|
||||||
|
new_palette.setColor(QPalette::Disabled, QPalette::Text, QColor("#777777"));
|
||||||
|
new_palette.setColor(QPalette::Light, QColor("#777777"));
|
||||||
|
new_palette.setColor(QPalette::Dark, QColor("#353535"));
|
||||||
|
} else {
|
||||||
|
new_palette = style->standardPalette();
|
||||||
|
}
|
||||||
|
qApp->setPalette(new_palette);
|
||||||
|
style->polish(qApp);
|
||||||
|
for (auto w : QApplication::allWidgets()) {
|
||||||
|
w->setPalette(new_palette);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString formatSeconds(double sec, bool include_milliseconds, bool absolute_time) {
|
||||||
|
QString format = absolute_time ? "yyyy-MM-dd hh:mm:ss"
|
||||||
|
: (sec > 60 * 60 ? "hh:mm:ss" : "mm:ss");
|
||||||
|
if (include_milliseconds) format += ".zzz";
|
||||||
|
return QDateTime::fromMSecsSinceEpoch(sec * 1000).toString(format);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace utils
|
||||||
|
|
||||||
|
int num_decimals(double num) {
|
||||||
|
const QString string = QString::number(num);
|
||||||
|
auto dot_pos = string.indexOf('.');
|
||||||
|
return dot_pos == -1 ? 0 : string.size() - dot_pos - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString signalToolTip(const cabana::Signal *sig) {
|
||||||
|
return QObject::tr(R"(
|
||||||
|
%1<br /><span font-size:small">
|
||||||
|
Start Bit: %2 Size: %3<br />
|
||||||
|
MSB: %4 LSB: %5<br />
|
||||||
|
Little Endian: %6 Signed: %7</span>
|
||||||
|
)").arg(sig->name).arg(sig->start_bit).arg(sig->size).arg(sig->msb).arg(sig->lsb)
|
||||||
|
.arg(sig->is_little_endian ? "Y" : "N").arg(sig->is_signed ? "Y" : "N");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonotonicBuffer
|
||||||
|
|
||||||
|
void *MonotonicBuffer::allocate(size_t bytes, size_t alignment) {
|
||||||
|
assert(bytes > 0);
|
||||||
|
void *p = std::align(alignment, bytes, current_buf, available);
|
||||||
|
if (p == nullptr) {
|
||||||
|
available = next_buffer_size = std::max(next_buffer_size, bytes);
|
||||||
|
current_buf = buffers.emplace_back(std::aligned_alloc(alignment, next_buffer_size));
|
||||||
|
next_buffer_size *= growth_factor;
|
||||||
|
p = current_buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
current_buf = (char *)current_buf + bytes;
|
||||||
|
available -= bytes;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
MonotonicBuffer::~MonotonicBuffer() {
|
||||||
|
for (auto buf : buffers) {
|
||||||
|
free(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
tools/cabana/util.h
Executable file
179
tools/cabana/util.h
Executable file
@@ -0,0 +1,179 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cmath>
|
||||||
|
#include <deque>
|
||||||
|
#include <vector>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDoubleValidator>
|
||||||
|
#include <QFont>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QRegExpValidator>
|
||||||
|
#include <QSocketNotifier>
|
||||||
|
#include <QStaticText>
|
||||||
|
#include <QStringBuilder>
|
||||||
|
#include <QStyledItemDelegate>
|
||||||
|
#include <QToolButton>
|
||||||
|
|
||||||
|
#include "tools/cabana/dbc/dbc.h"
|
||||||
|
#include "tools/cabana/settings.h"
|
||||||
|
|
||||||
|
class LogSlider : public QSlider {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
LogSlider(double factor, Qt::Orientation orientation, QWidget *parent = nullptr) : factor(factor), QSlider(orientation, parent) {}
|
||||||
|
|
||||||
|
void setRange(double min, double max) {
|
||||||
|
log_min = factor * std::log10(min);
|
||||||
|
log_max = factor * std::log10(max);
|
||||||
|
QSlider::setRange(min, max);
|
||||||
|
setValue(QSlider::value());
|
||||||
|
}
|
||||||
|
int value() const {
|
||||||
|
double v = log_min + (log_max - log_min) * ((QSlider::value() - minimum()) / double(maximum() - minimum()));
|
||||||
|
return std::lround(std::pow(10, v / factor));
|
||||||
|
}
|
||||||
|
void setValue(int v) {
|
||||||
|
double log_v = std::clamp(factor * std::log10(v), log_min, log_max);
|
||||||
|
v = minimum() + (maximum() - minimum()) * ((log_v - log_min) / (log_max - log_min));
|
||||||
|
QSlider::setValue(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
double factor, log_min = 0, log_max = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum {
|
||||||
|
ColorsRole = Qt::UserRole + 1,
|
||||||
|
BytesRole = Qt::UserRole + 2
|
||||||
|
};
|
||||||
|
|
||||||
|
class SegmentTree {
|
||||||
|
public:
|
||||||
|
SegmentTree() = default;
|
||||||
|
void build(const std::vector<QPointF> &arr);
|
||||||
|
inline std::pair<double, double> minmax(int left, int right) const { return get_minmax(1, 0, size - 1, left, right); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::pair<double, double> get_minmax(int n, int left, int right, int range_left, int range_right) const;
|
||||||
|
void build_tree(const std::vector<QPointF> &arr, int n, int left, int right);
|
||||||
|
std::vector<std::pair<double, double>> tree;
|
||||||
|
int size = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MessageBytesDelegate : public QStyledItemDelegate {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
MessageBytesDelegate(QObject *parent, bool multiple_lines = false);
|
||||||
|
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
bool multipleLines() const { return multiple_lines; }
|
||||||
|
void setMultipleLines(bool v) { multiple_lines = v; }
|
||||||
|
QSize sizeForBytes(int n) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::array<QStaticText, 256> hex_text_table;
|
||||||
|
QFont fixed_font;
|
||||||
|
QSize byte_size = {};
|
||||||
|
bool multiple_lines = false;
|
||||||
|
int h_margin, v_margin;
|
||||||
|
};
|
||||||
|
|
||||||
|
class NameValidator : public QRegExpValidator {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
NameValidator(QObject *parent=nullptr);
|
||||||
|
QValidator::State validate(QString &input, int &pos) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
class DoubleValidator : public QDoubleValidator {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
DoubleValidator(QObject *parent = nullptr);
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace utils {
|
||||||
|
QPixmap icon(const QString &id);
|
||||||
|
void setTheme(int theme);
|
||||||
|
QString formatSeconds(double sec, bool include_milliseconds = false, bool absolute_time = false);
|
||||||
|
inline void drawStaticText(QPainter *p, const QRect &r, const QStaticText &text) {
|
||||||
|
auto size = (r.size() - text.size()) / 2;
|
||||||
|
p->drawStaticText(r.left() + size.width(), r.top() + size.height(), text);
|
||||||
|
}
|
||||||
|
inline QString toHex(const std::vector<uint8_t> &dat, char separator = '\0') {
|
||||||
|
return QByteArray::fromRawData((const char *)dat.data(), dat.size()).toHex(separator).toUpper();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToolButton : public QToolButton {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
ToolButton(const QString &icon, const QString &tooltip = {}, QWidget *parent = nullptr) : QToolButton(parent) {
|
||||||
|
setIcon(icon);
|
||||||
|
setToolTip(tooltip);
|
||||||
|
setAutoRaise(true);
|
||||||
|
const int metric = QApplication::style()->pixelMetric(QStyle::PM_SmallIconSize);
|
||||||
|
setIconSize({metric, metric});
|
||||||
|
theme = settings.theme;
|
||||||
|
connect(&settings, &Settings::changed, this, &ToolButton::updateIcon);
|
||||||
|
}
|
||||||
|
void setIcon(const QString &icon) {
|
||||||
|
icon_str = icon;
|
||||||
|
QToolButton::setIcon(utils::icon(icon_str));
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void updateIcon() { if (std::exchange(theme, settings.theme) != theme) setIcon(icon_str); }
|
||||||
|
QString icon_str;
|
||||||
|
int theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
class TabBar : public QTabBar {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
TabBar(QWidget *parent) : QTabBar(parent) {}
|
||||||
|
int addTab(const QString &text);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void closeTabClicked();
|
||||||
|
};
|
||||||
|
|
||||||
|
class UnixSignalHandler : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
UnixSignalHandler(QObject *parent = nullptr);
|
||||||
|
~UnixSignalHandler();
|
||||||
|
static void signalHandler(int s);
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
void handleSigTerm();
|
||||||
|
|
||||||
|
private:
|
||||||
|
inline static int sig_fd[2] = {};
|
||||||
|
QSocketNotifier *sn;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MonotonicBuffer {
|
||||||
|
public:
|
||||||
|
MonotonicBuffer(size_t initial_size) : next_buffer_size(initial_size) {}
|
||||||
|
~MonotonicBuffer();
|
||||||
|
void *allocate(size_t bytes, size_t alignment = 16ul);
|
||||||
|
void deallocate(void *p) {}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void *current_buf = nullptr;
|
||||||
|
size_t next_buffer_size = 0;
|
||||||
|
size_t available = 0;
|
||||||
|
std::deque<void *> buffers;
|
||||||
|
static constexpr float growth_factor = 1.5;
|
||||||
|
};
|
||||||
|
|
||||||
|
int num_decimals(double num);
|
||||||
|
QString signalToolTip(const cabana::Signal *sig);
|
||||||
411
tools/cabana/videowidget.cc
Executable file
411
tools/cabana/videowidget.cc
Executable file
@@ -0,0 +1,411 @@
|
|||||||
|
#include "tools/cabana/videowidget.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
|
#include <QActionGroup>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QStackedLayout>
|
||||||
|
#include <QStyleOptionSlider>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QtConcurrent>
|
||||||
|
|
||||||
|
#include "tools/cabana/streams/replaystream.h"
|
||||||
|
|
||||||
|
const int MIN_VIDEO_HEIGHT = 100;
|
||||||
|
const int THUMBNAIL_MARGIN = 3;
|
||||||
|
|
||||||
|
static const QColor timeline_colors[] = {
|
||||||
|
[(int)TimelineType::None] = QColor(111, 143, 175),
|
||||||
|
[(int)TimelineType::Engaged] = QColor(0, 163, 108),
|
||||||
|
[(int)TimelineType::UserFlag] = Qt::magenta,
|
||||||
|
[(int)TimelineType::AlertInfo] = Qt::green,
|
||||||
|
[(int)TimelineType::AlertWarning] = QColor(255, 195, 0),
|
||||||
|
[(int)TimelineType::AlertCritical] = QColor(199, 0, 57),
|
||||||
|
};
|
||||||
|
|
||||||
|
VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) {
|
||||||
|
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
|
||||||
|
auto main_layout = new QVBoxLayout(this);
|
||||||
|
if (!can->liveStreaming())
|
||||||
|
main_layout->addWidget(createCameraWidget());
|
||||||
|
main_layout->addLayout(createPlaybackController());
|
||||||
|
|
||||||
|
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Maximum);
|
||||||
|
QObject::connect(can, &AbstractStream::paused, this, &VideoWidget::updatePlayBtnState);
|
||||||
|
QObject::connect(can, &AbstractStream::resume, this, &VideoWidget::updatePlayBtnState);
|
||||||
|
QObject::connect(can, &AbstractStream::msgsReceived, this, &VideoWidget::updateState);
|
||||||
|
|
||||||
|
updatePlayBtnState();
|
||||||
|
setWhatsThis(tr(R"(
|
||||||
|
<b>Video</b><br />
|
||||||
|
<!-- TODO: add descprition here -->
|
||||||
|
<span style="color:gray">Timeline color</span>
|
||||||
|
<table>
|
||||||
|
<tr><td><span style="color:%1;">■ </span>Disengaged </td>
|
||||||
|
<td><span style="color:%2;">■ </span>Engaged</td></tr>
|
||||||
|
<tr><td><span style="color:%3;">■ </span>User Flag </td>
|
||||||
|
<td><span style="color:%4;">■ </span>Info</td></tr>
|
||||||
|
<tr><td><span style="color:%5;">■ </span>Warning </td>
|
||||||
|
<td><span style="color:%6;">■ </span>Critical</td></tr>
|
||||||
|
</table>
|
||||||
|
<span style="color:gray">Shortcuts</span><br/>
|
||||||
|
Pause/Resume: <span style="background-color:lightGray;color:gray"> space </span>
|
||||||
|
)").arg(timeline_colors[(int)TimelineType::None].name(),
|
||||||
|
timeline_colors[(int)TimelineType::Engaged].name(),
|
||||||
|
timeline_colors[(int)TimelineType::UserFlag].name(),
|
||||||
|
timeline_colors[(int)TimelineType::AlertInfo].name(),
|
||||||
|
timeline_colors[(int)TimelineType::AlertWarning].name(),
|
||||||
|
timeline_colors[(int)TimelineType::AlertCritical].name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
QHBoxLayout *VideoWidget::createPlaybackController() {
|
||||||
|
QHBoxLayout *layout = new QHBoxLayout();
|
||||||
|
layout->addWidget(seek_backward_btn = new ToolButton("rewind", tr("Seek backward")));
|
||||||
|
layout->addWidget(play_btn = new ToolButton("play", tr("Play")));
|
||||||
|
layout->addWidget(seek_forward_btn = new ToolButton("fast-forward", tr("Seek forward")));
|
||||||
|
|
||||||
|
if (can->liveStreaming()) {
|
||||||
|
layout->addWidget(skip_to_end_btn = new ToolButton("skip-end", tr("Skip to the end"), this));
|
||||||
|
QObject::connect(skip_to_end_btn, &QToolButton::clicked, [this]() {
|
||||||
|
// set speed to 1.0
|
||||||
|
speed_btn->menu()->actions()[7]->setChecked(true);
|
||||||
|
can->pause(false);
|
||||||
|
can->seekTo(can->totalSeconds() + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
layout->addWidget(time_btn = new QToolButton);
|
||||||
|
time_btn->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time"));
|
||||||
|
time_btn->setAutoRaise(true);
|
||||||
|
layout->addStretch(0);
|
||||||
|
|
||||||
|
if (!can->liveStreaming()) {
|
||||||
|
layout->addWidget(loop_btn = new ToolButton("repeat", tr("Loop playback")));
|
||||||
|
QObject::connect(loop_btn, &QToolButton::clicked, this, &VideoWidget::loopPlaybackClicked);
|
||||||
|
}
|
||||||
|
|
||||||
|
// speed selector
|
||||||
|
layout->addWidget(speed_btn = new QToolButton(this));
|
||||||
|
speed_btn->setAutoRaise(true);
|
||||||
|
speed_btn->setMenu(new QMenu(speed_btn));
|
||||||
|
speed_btn->setPopupMode(QToolButton::InstantPopup);
|
||||||
|
QActionGroup *speed_group = new QActionGroup(this);
|
||||||
|
speed_group->setExclusive(true);
|
||||||
|
|
||||||
|
int max_width = 0;
|
||||||
|
QFont font = speed_btn->font();
|
||||||
|
font.setBold(true);
|
||||||
|
speed_btn->setFont(font);
|
||||||
|
QFontMetrics fm(font);
|
||||||
|
for (float speed : {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}) {
|
||||||
|
QString name = QString("%1x").arg(speed);
|
||||||
|
max_width = std::max(max_width, fm.width(name) + fm.horizontalAdvance(QLatin1Char(' ')) * 2);
|
||||||
|
|
||||||
|
QAction *act = new QAction(name, speed_group);
|
||||||
|
act->setCheckable(true);
|
||||||
|
QObject::connect(act, &QAction::toggled, [this, speed]() {
|
||||||
|
can->setSpeed(speed);
|
||||||
|
speed_btn->setText(QString("%1x ").arg(speed));
|
||||||
|
});
|
||||||
|
speed_btn->menu()->addAction(act);
|
||||||
|
if (speed == 1.0)act->setChecked(true);
|
||||||
|
}
|
||||||
|
speed_btn->setMinimumWidth(max_width + style()->pixelMetric(QStyle::PM_MenuButtonIndicator));
|
||||||
|
|
||||||
|
QObject::connect(play_btn, &QToolButton::clicked, []() { can->pause(!can->isPaused()); });
|
||||||
|
QObject::connect(seek_backward_btn, &QToolButton::clicked, []() { can->seekTo(can->currentSec() - 1); });
|
||||||
|
QObject::connect(seek_forward_btn, &QToolButton::clicked, []() { can->seekTo(can->currentSec() + 1); });
|
||||||
|
QObject::connect(time_btn, &QToolButton::clicked, [this]() {
|
||||||
|
settings.absolute_time = !settings.absolute_time;
|
||||||
|
time_btn->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time"));
|
||||||
|
updateState();
|
||||||
|
});
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget *VideoWidget::createCameraWidget() {
|
||||||
|
QWidget *w = new QWidget(this);
|
||||||
|
QVBoxLayout *l = new QVBoxLayout(w);
|
||||||
|
l->setContentsMargins(0, 0, 0, 0);
|
||||||
|
l->setSpacing(0);
|
||||||
|
|
||||||
|
l->addWidget(camera_tab = new TabBar(w));
|
||||||
|
camera_tab->setAutoHide(true);
|
||||||
|
camera_tab->setExpanding(false);
|
||||||
|
|
||||||
|
QStackedLayout *stacked = new QStackedLayout();
|
||||||
|
stacked->setStackingMode(QStackedLayout::StackAll);
|
||||||
|
stacked->addWidget(cam_widget = new CameraWidget("camerad", VISION_STREAM_ROAD, false));
|
||||||
|
cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT);
|
||||||
|
cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
|
||||||
|
stacked->addWidget(alert_label = new InfoLabel(this));
|
||||||
|
l->addLayout(stacked);
|
||||||
|
|
||||||
|
l->addWidget(slider = new Slider(w));
|
||||||
|
slider->setSingleStep(0);
|
||||||
|
|
||||||
|
setMaximumTime(can->totalSeconds());
|
||||||
|
QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); });
|
||||||
|
QObject::connect(slider, &Slider::updateMaximumTime, this, &VideoWidget::setMaximumTime, Qt::QueuedConnection);
|
||||||
|
QObject::connect(static_cast<ReplayStream*>(can), &ReplayStream::qLogLoaded, slider, &Slider::parseQLog);
|
||||||
|
QObject::connect(cam_widget, &CameraWidget::clicked, []() { can->pause(!can->isPaused()); });
|
||||||
|
QObject::connect(cam_widget, &CameraWidget::vipcAvailableStreamsUpdated, this, &VideoWidget::vipcAvailableStreamsUpdated);
|
||||||
|
QObject::connect(camera_tab, &QTabBar::currentChanged, [this](int index) {
|
||||||
|
if (index != -1) cam_widget->setStreamType((VisionStreamType)camera_tab->tabData(index).toInt());
|
||||||
|
});
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWidget::vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams) {
|
||||||
|
static const QString stream_names[] = {
|
||||||
|
[VISION_STREAM_ROAD] = "Road camera",
|
||||||
|
[VISION_STREAM_WIDE_ROAD] = "Wide road camera",
|
||||||
|
[VISION_STREAM_DRIVER] = "Driver camera"};
|
||||||
|
|
||||||
|
for (int i = 0; i < streams.size(); ++i) {
|
||||||
|
if (camera_tab->count() <= i) {
|
||||||
|
camera_tab->addTab(QString());
|
||||||
|
}
|
||||||
|
int type = *std::next(streams.begin(), i);
|
||||||
|
camera_tab->setTabText(i, stream_names[type]);
|
||||||
|
camera_tab->setTabData(i, type);
|
||||||
|
}
|
||||||
|
while (camera_tab->count() > streams.size()) {
|
||||||
|
camera_tab->removeTab(camera_tab->count() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWidget::loopPlaybackClicked() {
|
||||||
|
auto replay = qobject_cast<ReplayStream *>(can)->getReplay();
|
||||||
|
if (!replay) return;
|
||||||
|
|
||||||
|
if (replay->hasFlag(REPLAY_FLAG_NO_LOOP)) {
|
||||||
|
replay->removeFlag(REPLAY_FLAG_NO_LOOP);
|
||||||
|
loop_btn->setIcon("repeat");
|
||||||
|
} else {
|
||||||
|
replay->addFlag(REPLAY_FLAG_NO_LOOP);
|
||||||
|
loop_btn->setIcon("repeat-1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWidget::setMaximumTime(double sec) {
|
||||||
|
maximum_time = sec;
|
||||||
|
slider->setTimeRange(0, sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWidget::updateTimeRange(double min, double max, bool is_zoomed) {
|
||||||
|
if (can->liveStreaming()) {
|
||||||
|
skip_to_end_btn->setEnabled(!is_zoomed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
is_zoomed ? slider->setTimeRange(min, max)
|
||||||
|
: slider->setTimeRange(0, maximum_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString VideoWidget::formatTime(double sec, bool include_milliseconds) {
|
||||||
|
if (settings.absolute_time)
|
||||||
|
sec = can->beginDateTime().addMSecs(sec * 1000).toMSecsSinceEpoch() / 1000.0;
|
||||||
|
return utils::formatSeconds(sec, include_milliseconds, settings.absolute_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWidget::updateState() {
|
||||||
|
if (slider) {
|
||||||
|
if (!slider->isSliderDown())
|
||||||
|
slider->setCurrentSecond(can->currentSec());
|
||||||
|
alert_label->showAlert(slider->alertInfo(can->currentSec()));
|
||||||
|
time_btn->setText(QString("%1 / %2").arg(formatTime(can->currentSec(), true),
|
||||||
|
formatTime(slider->maximum() / slider->factor)));
|
||||||
|
} else {
|
||||||
|
time_btn->setText(formatTime(can->currentSec(), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void VideoWidget::updatePlayBtnState() {
|
||||||
|
play_btn->setIcon(can->isPaused() ? "play" : "pause");
|
||||||
|
play_btn->setToolTip(can->isPaused() ? tr("Play") : tr("Pause"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slider
|
||||||
|
|
||||||
|
Slider::Slider(QWidget *parent) : QSlider(Qt::Horizontal, parent) {
|
||||||
|
thumbnail_label = new InfoLabel(parent);
|
||||||
|
setMouseTracking(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
AlertInfo Slider::alertInfo(double seconds) {
|
||||||
|
uint64_t mono_time = (seconds + can->routeStartTime()) * 1e9;
|
||||||
|
auto alert_it = alerts.lower_bound(mono_time);
|
||||||
|
bool has_alert = (alert_it != alerts.end()) && ((alert_it->first - mono_time) <= 1e8);
|
||||||
|
return has_alert ? alert_it->second : AlertInfo{};
|
||||||
|
}
|
||||||
|
|
||||||
|
QPixmap Slider::thumbnail(double seconds) {
|
||||||
|
uint64_t mono_time = (seconds + can->routeStartTime()) * 1e9;
|
||||||
|
auto it = thumbnails.lowerBound(mono_time);
|
||||||
|
return it != thumbnails.end() ? it.value() : QPixmap();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Slider::setTimeRange(double min, double max) {
|
||||||
|
assert(min < max);
|
||||||
|
setRange(min * factor, max * factor);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Slider::parseQLog(int segnum, std::shared_ptr<LogReader> qlog) {
|
||||||
|
const auto &segments = qobject_cast<ReplayStream *>(can)->route()->segments();
|
||||||
|
if (segments.size() > 0 && segnum == segments.rbegin()->first && !qlog->events.empty()) {
|
||||||
|
emit updateMaximumTime(qlog->events.back()->mono_time / 1e9 - can->routeStartTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::mutex mutex;
|
||||||
|
QtConcurrent::blockingMap(qlog->events.cbegin(), qlog->events.cend(), [&mutex, this](const Event *e) {
|
||||||
|
if (e->which == cereal::Event::Which::THUMBNAIL) {
|
||||||
|
auto thumb = e->event.getThumbnail();
|
||||||
|
auto data = thumb.getThumbnail();
|
||||||
|
if (QPixmap pm; pm.loadFromData(data.begin(), data.size(), "jpeg")) {
|
||||||
|
QPixmap scaled = pm.scaledToHeight(MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2, Qt::SmoothTransformation);
|
||||||
|
std::lock_guard lk(mutex);
|
||||||
|
thumbnails[thumb.getTimestampEof()] = scaled;
|
||||||
|
}
|
||||||
|
} else if (e->which == cereal::Event::Which::CONTROLS_STATE) {
|
||||||
|
auto cs = e->event.getControlsState();
|
||||||
|
if (cs.getAlertType().size() > 0 && cs.getAlertText1().size() > 0 &&
|
||||||
|
cs.getAlertSize() != cereal::ControlsState::AlertSize::NONE) {
|
||||||
|
std::lock_guard lk(mutex);
|
||||||
|
alerts.emplace(e->mono_time, AlertInfo{cs.getAlertStatus(), cs.getAlertText1().cStr(), cs.getAlertText2().cStr()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void Slider::paintEvent(QPaintEvent *ev) {
|
||||||
|
QPainter p(this);
|
||||||
|
QRect r = rect().adjusted(0, 4, 0, -4);
|
||||||
|
p.fillRect(r, timeline_colors[(int)TimelineType::None]);
|
||||||
|
double min = minimum() / factor;
|
||||||
|
double max = maximum() / factor;
|
||||||
|
|
||||||
|
auto fillRange = [&](double begin, double end, const QColor &color) {
|
||||||
|
if (begin > max || end < min) return;
|
||||||
|
r.setLeft(((std::max(min, begin) - min) / (max - min)) * width());
|
||||||
|
r.setRight(((std::min(max, end) - min) / (max - min)) * width());
|
||||||
|
p.fillRect(r, color);
|
||||||
|
};
|
||||||
|
|
||||||
|
const auto replay = qobject_cast<ReplayStream *>(can)->getReplay();
|
||||||
|
for (auto [begin, end, type] : replay->getTimeline()) {
|
||||||
|
fillRange(begin, end, timeline_colors[(int)type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
QColor empty_color = palette().color(QPalette::Window);
|
||||||
|
empty_color.setAlpha(160);
|
||||||
|
for (const auto &[n, seg] : replay->segments()) {
|
||||||
|
if (!(seg && seg->isLoaded()))
|
||||||
|
fillRange(n * 60.0, (n + 1) * 60.0, empty_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
QStyleOptionSlider opt;
|
||||||
|
opt.initFrom(this);
|
||||||
|
opt.minimum = minimum();
|
||||||
|
opt.maximum = maximum();
|
||||||
|
opt.subControls = QStyle::SC_SliderHandle;
|
||||||
|
opt.sliderPosition = value();
|
||||||
|
style()->drawComplexControl(QStyle::CC_Slider, &opt, &p);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Slider::mousePressEvent(QMouseEvent *e) {
|
||||||
|
QSlider::mousePressEvent(e);
|
||||||
|
if (e->button() == Qt::LeftButton && !isSliderDown()) {
|
||||||
|
setValue(minimum() + ((maximum() - minimum()) * e->x()) / width());
|
||||||
|
emit sliderReleased();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Slider::mouseMoveEvent(QMouseEvent *e) {
|
||||||
|
int pos = std::clamp(e->pos().x(), 0, width());
|
||||||
|
double seconds = (minimum() + pos * ((maximum() - minimum()) / (double)width())) / factor;
|
||||||
|
QPixmap thumb = thumbnail(seconds);
|
||||||
|
if (!thumb.isNull()) {
|
||||||
|
int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1);
|
||||||
|
int y = -thumb.height() - THUMBNAIL_MARGIN;
|
||||||
|
thumbnail_label->showPixmap(mapToParent(QPoint(x, y)), utils::formatSeconds(seconds), thumb, alertInfo(seconds));
|
||||||
|
} else {
|
||||||
|
thumbnail_label->hide();
|
||||||
|
}
|
||||||
|
QSlider::mouseMoveEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Slider::event(QEvent *event) {
|
||||||
|
switch (event->type()) {
|
||||||
|
case QEvent::WindowActivate:
|
||||||
|
case QEvent::WindowDeactivate:
|
||||||
|
case QEvent::FocusIn:
|
||||||
|
case QEvent::FocusOut:
|
||||||
|
case QEvent::Leave:
|
||||||
|
thumbnail_label->hide();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return QSlider::event(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoLabel
|
||||||
|
|
||||||
|
InfoLabel::InfoLabel(QWidget *parent) : QWidget(parent, Qt::WindowStaysOnTopHint) {
|
||||||
|
setAttribute(Qt::WA_ShowWithoutActivating);
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InfoLabel::showPixmap(const QPoint &pt, const QString &sec, const QPixmap &pm, const AlertInfo &alert) {
|
||||||
|
second = sec;
|
||||||
|
pixmap = pm;
|
||||||
|
alert_info = alert;
|
||||||
|
setGeometry(QRect(pt, pm.size()));
|
||||||
|
setVisible(true);
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InfoLabel::showAlert(const AlertInfo &alert) {
|
||||||
|
alert_info = alert;
|
||||||
|
pixmap = {};
|
||||||
|
setVisible(!alert_info.text1.isEmpty());
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void InfoLabel::paintEvent(QPaintEvent *event) {
|
||||||
|
QPainter p(this);
|
||||||
|
p.setPen(QPen(palette().color(QPalette::BrightText), 2));
|
||||||
|
if (!pixmap.isNull()) {
|
||||||
|
p.drawPixmap(0, 0, pixmap);
|
||||||
|
p.drawRect(rect());
|
||||||
|
p.drawText(rect().adjusted(0, 0, 0, -THUMBNAIL_MARGIN), second, Qt::AlignHCenter | Qt::AlignBottom);
|
||||||
|
}
|
||||||
|
if (alert_info.text1.size() > 0) {
|
||||||
|
QColor color = timeline_colors[(int)TimelineType::AlertInfo];
|
||||||
|
if (alert_info.status == cereal::ControlsState::AlertStatus::USER_PROMPT) {
|
||||||
|
color = timeline_colors[(int)TimelineType::AlertWarning];
|
||||||
|
} else if (alert_info.status == cereal::ControlsState::AlertStatus::CRITICAL) {
|
||||||
|
color = timeline_colors[(int)TimelineType::AlertCritical];
|
||||||
|
}
|
||||||
|
color.setAlphaF(0.5);
|
||||||
|
QString text = alert_info.text1;
|
||||||
|
if (!alert_info.text2.isEmpty()) {
|
||||||
|
text += "\n" + alert_info.text2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pixmap.isNull()) {
|
||||||
|
QFont font;
|
||||||
|
font.setPixelSize(11);
|
||||||
|
p.setFont(font);
|
||||||
|
}
|
||||||
|
QRect text_rect = rect().adjusted(2, 2, -2, -2);
|
||||||
|
QRect r = p.fontMetrics().boundingRect(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text);
|
||||||
|
p.fillRect(text_rect.left(), r.top(), text_rect.width(), r.height(), color);
|
||||||
|
p.drawText(text_rect, Qt::AlignTop | Qt::AlignHCenter | Qt::TextWordWrap, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
90
tools/cabana/videowidget.h
Executable file
90
tools/cabana/videowidget.h
Executable file
@@ -0,0 +1,90 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
|
#include <set>
|
||||||
|
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QTabBar>
|
||||||
|
|
||||||
|
#include "selfdrive/ui/qt/widgets/cameraview.h"
|
||||||
|
#include "tools/cabana/util.h"
|
||||||
|
#include "tools/replay/logreader.h"
|
||||||
|
|
||||||
|
struct AlertInfo {
|
||||||
|
cereal::ControlsState::AlertStatus status;
|
||||||
|
QString text1;
|
||||||
|
QString text2;
|
||||||
|
};
|
||||||
|
|
||||||
|
class InfoLabel : public QWidget {
|
||||||
|
public:
|
||||||
|
InfoLabel(QWidget *parent);
|
||||||
|
void showPixmap(const QPoint &pt, const QString &sec, const QPixmap &pm, const AlertInfo &alert);
|
||||||
|
void showAlert(const AlertInfo &alert);
|
||||||
|
void paintEvent(QPaintEvent *event) override;
|
||||||
|
QPixmap pixmap;
|
||||||
|
QString second;
|
||||||
|
AlertInfo alert_info;
|
||||||
|
};
|
||||||
|
|
||||||
|
class Slider : public QSlider {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
Slider(QWidget *parent);
|
||||||
|
double currentSecond() const { return value() / factor; }
|
||||||
|
void setCurrentSecond(double sec) { setValue(sec * factor); }
|
||||||
|
void setTimeRange(double min, double max);
|
||||||
|
AlertInfo alertInfo(double sec);
|
||||||
|
QPixmap thumbnail(double sec);
|
||||||
|
void parseQLog(int segnum, std::shared_ptr<LogReader> qlog);
|
||||||
|
|
||||||
|
const double factor = 1000.0;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void updateMaximumTime(double);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void mousePressEvent(QMouseEvent *e) override;
|
||||||
|
void mouseMoveEvent(QMouseEvent *e) override;
|
||||||
|
bool event(QEvent *event) override;
|
||||||
|
void paintEvent(QPaintEvent *ev) override;
|
||||||
|
|
||||||
|
QMap<uint64_t, QPixmap> thumbnails;
|
||||||
|
std::map<uint64_t, AlertInfo> alerts;
|
||||||
|
InfoLabel *thumbnail_label;
|
||||||
|
};
|
||||||
|
|
||||||
|
class VideoWidget : public QFrame {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
VideoWidget(QWidget *parnet = nullptr);
|
||||||
|
void updateTimeRange(double min, double max, bool is_zommed);
|
||||||
|
void setMaximumTime(double sec);
|
||||||
|
|
||||||
|
protected:
|
||||||
|
QString formatTime(double sec, bool include_milliseconds = false);
|
||||||
|
void updateState();
|
||||||
|
void updatePlayBtnState();
|
||||||
|
QWidget *createCameraWidget();
|
||||||
|
QHBoxLayout *createPlaybackController();
|
||||||
|
void loopPlaybackClicked();
|
||||||
|
void vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams);
|
||||||
|
|
||||||
|
CameraWidget *cam_widget;
|
||||||
|
double maximum_time = 0;
|
||||||
|
QToolButton *time_btn = nullptr;
|
||||||
|
ToolButton *seek_backward_btn = nullptr;
|
||||||
|
ToolButton *play_btn = nullptr;
|
||||||
|
ToolButton *seek_forward_btn = nullptr;
|
||||||
|
ToolButton *loop_btn = nullptr;
|
||||||
|
QToolButton *speed_btn = nullptr;
|
||||||
|
ToolButton *skip_to_end_btn = nullptr;
|
||||||
|
InfoLabel *alert_label = nullptr;
|
||||||
|
Slider *slider = nullptr;
|
||||||
|
QTabBar *camera_tab = nullptr;
|
||||||
|
};
|
||||||
139
tools/camerastream/compressed_vipc.py
Executable file
139
tools/camerastream/compressed_vipc.py
Executable file
@@ -0,0 +1,139 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import av
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
import numpy as np
|
||||||
|
import multiprocessing
|
||||||
|
import time
|
||||||
|
|
||||||
|
import cereal.messaging as messaging
|
||||||
|
from cereal.visionipc import VisionIpcServer, VisionStreamType
|
||||||
|
|
||||||
|
W, H = 1928, 1208
|
||||||
|
V4L2_BUF_FLAG_KEYFRAME = 8
|
||||||
|
|
||||||
|
ENCODE_SOCKETS = {
|
||||||
|
VisionStreamType.VISION_STREAM_ROAD: "roadEncodeData",
|
||||||
|
VisionStreamType.VISION_STREAM_WIDE_ROAD: "wideRoadEncodeData",
|
||||||
|
VisionStreamType.VISION_STREAM_DRIVER: "driverEncodeData",
|
||||||
|
}
|
||||||
|
|
||||||
|
def decoder(addr, vst, nvidia, debug=False):
|
||||||
|
vipc_server = VisionIpcServer("camerad")
|
||||||
|
vipc_server.create_buffers(vst, 4, False, W, H)
|
||||||
|
vipc_server.start_listener()
|
||||||
|
|
||||||
|
sock_name = ENCODE_SOCKETS[vst]
|
||||||
|
if debug:
|
||||||
|
print("start decoder for %s" % sock_name)
|
||||||
|
if nvidia:
|
||||||
|
os.environ["NV_LOW_LATENCY"] = "3" # both bLowLatency and CUVID_PKT_ENDOFPICTURE
|
||||||
|
sys.path += os.environ["LD_LIBRARY_PATH"].split(":")
|
||||||
|
import PyNvCodec as nvc
|
||||||
|
|
||||||
|
nvDec = nvc.PyNvDecoder(W, H, nvc.PixelFormat.NV12, nvc.CudaVideoCodec.HEVC, 0)
|
||||||
|
cc1 = nvc.ColorspaceConversionContext(nvc.ColorSpace.BT_709, nvc.ColorRange.JPEG)
|
||||||
|
conv_yuv = nvc.PySurfaceConverter(W, H, nvc.PixelFormat.NV12, nvc.PixelFormat.YUV420, 0)
|
||||||
|
nvDwn_yuv = nvc.PySurfaceDownloader(W, H, nvc.PixelFormat.YUV420, 0)
|
||||||
|
img_yuv = np.ndarray((H*W//2*3), dtype=np.uint8)
|
||||||
|
else:
|
||||||
|
codec = av.CodecContext.create("hevc", "r")
|
||||||
|
|
||||||
|
os.environ["ZMQ"] = "1"
|
||||||
|
messaging.context = messaging.Context()
|
||||||
|
sock = messaging.sub_sock(sock_name, None, addr=addr, conflate=False)
|
||||||
|
cnt = 0
|
||||||
|
last_idx = -1
|
||||||
|
seen_iframe = False
|
||||||
|
|
||||||
|
time_q = []
|
||||||
|
while 1:
|
||||||
|
msgs = messaging.drain_sock(sock, wait_for_one=True)
|
||||||
|
for evt in msgs:
|
||||||
|
evta = getattr(evt, evt.which())
|
||||||
|
if debug and evta.idx.encodeId != 0 and evta.idx.encodeId != (last_idx+1):
|
||||||
|
print("DROP PACKET!")
|
||||||
|
last_idx = evta.idx.encodeId
|
||||||
|
if not seen_iframe and not (evta.idx.flags & V4L2_BUF_FLAG_KEYFRAME):
|
||||||
|
if debug:
|
||||||
|
print("waiting for iframe")
|
||||||
|
continue
|
||||||
|
time_q.append(time.monotonic())
|
||||||
|
network_latency = (int(time.time()*1e9) - evta.unixTimestampNanos)/1e6
|
||||||
|
frame_latency = ((evta.idx.timestampEof/1e9) - (evta.idx.timestampSof/1e9))*1000
|
||||||
|
process_latency = ((evt.logMonoTime/1e9) - (evta.idx.timestampEof/1e9))*1000
|
||||||
|
|
||||||
|
# put in header (first)
|
||||||
|
if not seen_iframe:
|
||||||
|
if nvidia:
|
||||||
|
nvDec.DecodeSurfaceFromPacket(np.frombuffer(evta.header, dtype=np.uint8))
|
||||||
|
else:
|
||||||
|
codec.decode(av.packet.Packet(evta.header))
|
||||||
|
seen_iframe = True
|
||||||
|
|
||||||
|
if nvidia:
|
||||||
|
rawSurface = nvDec.DecodeSurfaceFromPacket(np.frombuffer(evta.data, dtype=np.uint8))
|
||||||
|
if rawSurface.Empty():
|
||||||
|
if debug:
|
||||||
|
print("DROP SURFACE")
|
||||||
|
continue
|
||||||
|
convSurface = conv_yuv.Execute(rawSurface, cc1)
|
||||||
|
nvDwn_yuv.DownloadSingleSurface(convSurface, img_yuv)
|
||||||
|
else:
|
||||||
|
frames = codec.decode(av.packet.Packet(evta.data))
|
||||||
|
if len(frames) == 0:
|
||||||
|
if debug:
|
||||||
|
print("DROP SURFACE")
|
||||||
|
continue
|
||||||
|
assert len(frames) == 1
|
||||||
|
img_yuv = frames[0].to_ndarray(format=av.video.format.VideoFormat('yuv420p')).flatten()
|
||||||
|
uv_offset = H*W
|
||||||
|
y = img_yuv[:uv_offset]
|
||||||
|
uv = img_yuv[uv_offset:].reshape(2, -1).ravel('F')
|
||||||
|
img_yuv = np.hstack((y, uv))
|
||||||
|
|
||||||
|
vipc_server.send(vst, img_yuv.data, cnt, int(time_q[0]*1e9), int(time.monotonic()*1e9))
|
||||||
|
cnt += 1
|
||||||
|
|
||||||
|
pc_latency = (time.monotonic()-time_q[0])*1000
|
||||||
|
time_q = time_q[1:]
|
||||||
|
if debug:
|
||||||
|
print("%2d %4d %.3f %.3f roll %6.2f ms latency %6.2f ms + %6.2f ms + %6.2f ms = %6.2f ms"
|
||||||
|
% (len(msgs), evta.idx.encodeId, evt.logMonoTime/1e9, evta.idx.timestampEof/1e6, frame_latency,
|
||||||
|
process_latency, network_latency, pc_latency, process_latency+network_latency+pc_latency ), len(evta.data), sock_name)
|
||||||
|
|
||||||
|
class CompressedVipc:
|
||||||
|
def __init__(self, addr, vision_streams, nvidia=False, debug=False):
|
||||||
|
self.procs = []
|
||||||
|
for vst in vision_streams:
|
||||||
|
p = multiprocessing.Process(target=decoder, args=(addr, vst, nvidia, debug))
|
||||||
|
p.start()
|
||||||
|
self.procs.append(p)
|
||||||
|
|
||||||
|
def join(self):
|
||||||
|
for p in self.procs:
|
||||||
|
p.join()
|
||||||
|
|
||||||
|
def kill(self):
|
||||||
|
for p in self.procs:
|
||||||
|
p.terminate()
|
||||||
|
self.join()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Decode video streams and broadcast on VisionIPC")
|
||||||
|
parser.add_argument("addr", help="Address of comma three")
|
||||||
|
parser.add_argument("--nvidia", action="store_true", help="Use nvidia instead of ffmpeg")
|
||||||
|
parser.add_argument("--cams", default="0,1,2", help="Cameras to decode")
|
||||||
|
parser.add_argument("--silent", action="store_true", help="Suppress debug output")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
vision_streams = [
|
||||||
|
VisionStreamType.VISION_STREAM_ROAD,
|
||||||
|
VisionStreamType.VISION_STREAM_WIDE_ROAD,
|
||||||
|
VisionStreamType.VISION_STREAM_DRIVER,
|
||||||
|
]
|
||||||
|
|
||||||
|
vsts = [vision_streams[int(x)] for x in args.cams.split(",")]
|
||||||
|
cvipc = CompressedVipc(args.addr, vsts, args.nvidia, debug=(not args.silent))
|
||||||
|
cvipc.join()
|
||||||
44
tools/camerastream/receive.py
Executable file
44
tools/camerastream/receive.py
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import numpy as np
|
||||||
|
os.environ['ZMQ'] = '1'
|
||||||
|
|
||||||
|
from openpilot.common.window import Window
|
||||||
|
import cereal.messaging as messaging
|
||||||
|
|
||||||
|
# start camerad with 'SEND_ROAD=1 SEND_DRIVER=1 SEND_WIDE_ROAD=1 XMIN=771 XMAX=1156 YMIN=483 YMAX=724 ./camerad'
|
||||||
|
# also start bridge
|
||||||
|
# then run this "./receive.py <ip>"
|
||||||
|
|
||||||
|
if "FULL" in os.environ:
|
||||||
|
SCALE = 2
|
||||||
|
XMIN, XMAX = 0, 1927
|
||||||
|
YMIN, YMAX = 0, 1207
|
||||||
|
else:
|
||||||
|
SCALE = 1
|
||||||
|
XMIN = 771
|
||||||
|
XMAX = 1156
|
||||||
|
YMIN = 483
|
||||||
|
YMAX = 724
|
||||||
|
H, W = ((YMAX-YMIN+1)//SCALE, (XMAX-XMIN+1)//SCALE)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cameras = ['roadCameraState', 'wideRoadCameraState', 'driverCameraState']
|
||||||
|
if "CAM" in os.environ:
|
||||||
|
cam = int(os.environ['CAM'])
|
||||||
|
cameras = cameras[cam:cam+1]
|
||||||
|
sm = messaging.SubMaster(cameras, addr=sys.argv[1])
|
||||||
|
win = Window(W*len(cameras), H)
|
||||||
|
bdat = np.zeros((H, W*len(cameras), 3), dtype=np.uint8)
|
||||||
|
|
||||||
|
while 1:
|
||||||
|
sm.update()
|
||||||
|
for i,k in enumerate(cameras):
|
||||||
|
if sm.updated[k]:
|
||||||
|
#print("update", k)
|
||||||
|
bgr_raw = sm[k].image
|
||||||
|
dat = np.frombuffer(bgr_raw, dtype=np.uint8).reshape(H, W, 3)[:, :, [2,1,0]]
|
||||||
|
bdat[:, W*i:W*(i+1)] = dat
|
||||||
|
win.draw(bdat)
|
||||||
|
|
||||||
86
tools/install_python_dependencies.sh
Executable file
86
tools/install_python_dependencies.sh
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||||
|
ROOT=$DIR/../
|
||||||
|
cd $ROOT
|
||||||
|
|
||||||
|
RC_FILE="${HOME}/.$(basename ${SHELL})rc"
|
||||||
|
if [ "$(uname)" == "Darwin" ] && [ $SHELL == "/bin/bash" ]; then
|
||||||
|
RC_FILE="$HOME/.bash_profile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v "pyenv" > /dev/null 2>&1; then
|
||||||
|
echo "pyenv install ..."
|
||||||
|
curl -L https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash
|
||||||
|
PYENV_PATH_SETUP="export PATH=\$HOME/.pyenv/bin:\$HOME/.pyenv/shims:\$PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$PYENV_SHELL" ] || [ -n "$PYENV_PATH_SETUP" ]; then
|
||||||
|
echo "pyenvrc setup ..."
|
||||||
|
cat <<EOF > "${HOME}/.pyenvrc"
|
||||||
|
if [ -z "\$PYENV_ROOT" ]; then
|
||||||
|
$PYENV_PATH_SETUP
|
||||||
|
export PYENV_ROOT="\$HOME/.pyenv"
|
||||||
|
eval "\$(pyenv init -)"
|
||||||
|
eval "\$(pyenv virtualenv-init -)"
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
SOURCE_PYENVRC="source ~/.pyenvrc"
|
||||||
|
if ! grep "^$SOURCE_PYENVRC$" $RC_FILE > /dev/null; then
|
||||||
|
printf "\n$SOURCE_PYENVRC\n" >> $RC_FILE
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval "$SOURCE_PYENVRC"
|
||||||
|
# $(pyenv init -) produces a function which is broken on bash 3.2 which ships on macOS
|
||||||
|
if [ $(uname) == "Darwin" ]; then
|
||||||
|
unset -f pyenv
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
export MAKEFLAGS="-j$(nproc)"
|
||||||
|
|
||||||
|
PYENV_PYTHON_VERSION=$(cat $ROOT/.python-version)
|
||||||
|
if ! pyenv prefix ${PYENV_PYTHON_VERSION} &> /dev/null; then
|
||||||
|
# no pyenv update on mac
|
||||||
|
if [ "$(uname)" == "Linux" ]; then
|
||||||
|
echo "pyenv update ..."
|
||||||
|
pyenv update
|
||||||
|
fi
|
||||||
|
echo "python ${PYENV_PYTHON_VERSION} install ..."
|
||||||
|
CONFIGURE_OPTS="--enable-shared" pyenv install -f ${PYENV_PYTHON_VERSION}
|
||||||
|
fi
|
||||||
|
eval "$(pyenv init --path)"
|
||||||
|
|
||||||
|
echo "update pip"
|
||||||
|
pip install pip==23.3
|
||||||
|
pip install poetry==1.6.1
|
||||||
|
|
||||||
|
poetry config virtualenvs.prefer-active-python true --local
|
||||||
|
poetry config virtualenvs.in-project true --local
|
||||||
|
|
||||||
|
echo "PYTHONPATH=${PWD}" > $ROOT/.env
|
||||||
|
if [[ "$(uname)" == 'Darwin' ]]; then
|
||||||
|
echo "# msgq doesn't work on mac" >> $ROOT/.env
|
||||||
|
echo "export ZMQ=1" >> $ROOT/.env
|
||||||
|
echo "export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES" >> $ROOT/.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
poetry self add poetry-dotenv-plugin@^0.1.0
|
||||||
|
|
||||||
|
echo "pip packages install..."
|
||||||
|
poetry install --no-cache --no-root
|
||||||
|
pyenv rehash
|
||||||
|
|
||||||
|
[ -n "$POETRY_VIRTUALENVS_CREATE" ] && RUN="" || RUN="poetry run"
|
||||||
|
|
||||||
|
if [ "$(uname)" != "Darwin" ]; then
|
||||||
|
echo "pre-commit hooks install..."
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in .pre-commit-config.yaml */.pre-commit-config.yaml; do
|
||||||
|
if [ -e "$ROOT/$(dirname $f)/.git" ]; then
|
||||||
|
$RUN pre-commit install -c "$f"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
135
tools/install_ubuntu_dependencies.sh
Executable file
135
tools/install_ubuntu_dependencies.sh
Executable file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
|
||||||
|
# Use sudo if not root
|
||||||
|
if [[ ! $(id -u) -eq 0 ]]; then
|
||||||
|
if [[ -z $(which sudo) ]]; then
|
||||||
|
echo "Please install sudo or run as root"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install packages present in all supported versions of Ubuntu
|
||||||
|
function install_ubuntu_common_requirements() {
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
|
autoconf \
|
||||||
|
build-essential \
|
||||||
|
ca-certificates \
|
||||||
|
casync \
|
||||||
|
clang \
|
||||||
|
cmake \
|
||||||
|
make \
|
||||||
|
cppcheck \
|
||||||
|
libtool \
|
||||||
|
gcc-arm-none-eabi \
|
||||||
|
bzip2 \
|
||||||
|
liblzma-dev \
|
||||||
|
libarchive-dev \
|
||||||
|
libbz2-dev \
|
||||||
|
capnproto \
|
||||||
|
libcapnp-dev \
|
||||||
|
curl \
|
||||||
|
libcurl4-openssl-dev \
|
||||||
|
git \
|
||||||
|
git-lfs \
|
||||||
|
ffmpeg \
|
||||||
|
libavformat-dev \
|
||||||
|
libavcodec-dev \
|
||||||
|
libavdevice-dev \
|
||||||
|
libavutil-dev \
|
||||||
|
libavfilter-dev \
|
||||||
|
libeigen3-dev \
|
||||||
|
libffi-dev \
|
||||||
|
libglew-dev \
|
||||||
|
libgles2-mesa-dev \
|
||||||
|
libglfw3-dev \
|
||||||
|
libglib2.0-0 \
|
||||||
|
libncurses5-dev \
|
||||||
|
libncursesw5-dev \
|
||||||
|
libomp-dev \
|
||||||
|
libopencv-dev \
|
||||||
|
libpng16-16 \
|
||||||
|
libportaudio2 \
|
||||||
|
libssl-dev \
|
||||||
|
libsqlite3-dev \
|
||||||
|
libusb-1.0-0-dev \
|
||||||
|
libzmq3-dev \
|
||||||
|
libsystemd-dev \
|
||||||
|
locales \
|
||||||
|
opencl-headers \
|
||||||
|
ocl-icd-libopencl1 \
|
||||||
|
ocl-icd-opencl-dev \
|
||||||
|
clinfo \
|
||||||
|
portaudio19-dev \
|
||||||
|
qml-module-qtquick2 \
|
||||||
|
qtmultimedia5-dev \
|
||||||
|
qtlocation5-dev \
|
||||||
|
qtpositioning5-dev \
|
||||||
|
qttools5-dev-tools \
|
||||||
|
libqt5sql5-sqlite \
|
||||||
|
libqt5svg5-dev \
|
||||||
|
libqt5charts5-dev \
|
||||||
|
libqt5serialbus5-dev \
|
||||||
|
libqt5x11extras5-dev \
|
||||||
|
libreadline-dev \
|
||||||
|
libdw1 \
|
||||||
|
valgrind
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install Ubuntu 22.04 LTS packages
|
||||||
|
function install_ubuntu_lts_latest_requirements() {
|
||||||
|
install_ubuntu_common_requirements
|
||||||
|
|
||||||
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
|
g++-12 \
|
||||||
|
qtbase5-dev \
|
||||||
|
qtchooser \
|
||||||
|
qt5-qmake \
|
||||||
|
qtbase5-dev-tools \
|
||||||
|
python3-dev
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install Ubuntu 20.04 packages
|
||||||
|
function install_ubuntu_focal_requirements() {
|
||||||
|
install_ubuntu_common_requirements
|
||||||
|
|
||||||
|
$SUDO apt-get install -y --no-install-recommends \
|
||||||
|
libavresample-dev \
|
||||||
|
qt5-default \
|
||||||
|
python-dev
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detect OS using /etc/os-release file
|
||||||
|
if [ -f "/etc/os-release" ]; then
|
||||||
|
source /etc/os-release
|
||||||
|
case "$VERSION_CODENAME" in
|
||||||
|
"jammy")
|
||||||
|
install_ubuntu_lts_latest_requirements
|
||||||
|
;;
|
||||||
|
"kinetic")
|
||||||
|
install_ubuntu_lts_latest_requirements
|
||||||
|
;;
|
||||||
|
"focal")
|
||||||
|
install_ubuntu_focal_requirements
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "$ID $VERSION_ID is unsupported. This setup script is written for Ubuntu 20.04."
|
||||||
|
read -p "Would you like to attempt installation anyway? " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$UBUNTU_CODENAME" = "jammy" ] || [ "$UBUNTU_CODENAME" = "kinetic" ]; then
|
||||||
|
install_ubuntu_lts_latest_requirements
|
||||||
|
else
|
||||||
|
install_ubuntu_focal_requirements
|
||||||
|
fi
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
echo "No /etc/os-release in the system"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
92
tools/latencylogger/README.md
Executable file
92
tools/latencylogger/README.md
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
# LatencyLogger
|
||||||
|
|
||||||
|
LatencyLogger is a tool to track the time from first pixel to actuation. Timestamps are printed in a table as well as plotted in a graph. Start openpilot with `LOG_TIMESTAMPS=1` set to enable the necessary logging.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
$ python latency_logger.py -h
|
||||||
|
usage: latency_logger.py [-h] [--relative] [--demo] [--plot] [route_or_segment_name]
|
||||||
|
|
||||||
|
A tool for analyzing openpilot's end-to-end latency
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
route_or_segment_name
|
||||||
|
The route to print (default: None)
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--relative Make timestamps relative to the start of each frame (default: False)
|
||||||
|
--demo Use the demo route instead of providing one (default: False)
|
||||||
|
--plot If a plot should be generated (default: False)
|
||||||
|
--offset Offset service to better visualize overlap (default: False)
|
||||||
|
```
|
||||||
|
To timestamp an event, use `LOGT("msg")` in c++ code or `cloudlog.timestamp("msg")` in python code. If the print is warning for frameId assignment ambiguity, use `LOGT(frameId ,"msg")`.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
Timestamps are visualized as diamonds
|
||||||
|
|
||||||
|
| | Relative | Absolute |
|
||||||
|
| ------------- | ------------- | ------------- |
|
||||||
|
| Inline |  |  |
|
||||||
|
| Offset |  |  |
|
||||||
|
|
||||||
|
Printed timestamps of a frame with internal durations.
|
||||||
|
```
|
||||||
|
Frame ID: 1202
|
||||||
|
camerad
|
||||||
|
wideRoadCameraState start of frame 0.0
|
||||||
|
roadCameraState start of frame 0.049583
|
||||||
|
wideRoadCameraState published 35.01206
|
||||||
|
WideRoadCamera: Image set 35.020028
|
||||||
|
roadCameraState published 38.508261
|
||||||
|
RoadCamera: Image set 38.520344
|
||||||
|
RoadCamera: Transformed 38.616176
|
||||||
|
wideRoadCameraState.processingTime 3.152403049170971
|
||||||
|
roadCameraState.processingTime 6.453451234847307
|
||||||
|
modeld
|
||||||
|
Image added 40.909841
|
||||||
|
Extra image added 42.515027
|
||||||
|
Execution finished 63.002552
|
||||||
|
modelV2 published 63.148747
|
||||||
|
modelV2.modelExecutionTime 23.62649142742157
|
||||||
|
modelV2.gpuExecutionTime 0.0
|
||||||
|
plannerd
|
||||||
|
lateralPlan published 66.915049
|
||||||
|
longitudinalPlan published 69.715999
|
||||||
|
lateralPlan.solverExecutionTime 0.8170719956979156
|
||||||
|
longitudinalPlan.solverExecutionTime 0.5619999719783664
|
||||||
|
controlsd
|
||||||
|
Data sampled 70.217763
|
||||||
|
Events updated 71.037178
|
||||||
|
sendcan published 72.278775
|
||||||
|
controlsState published 72.825226
|
||||||
|
Data sampled 80.008354
|
||||||
|
Events updated 80.787666
|
||||||
|
sendcan published 81.849682
|
||||||
|
controlsState published 82.238323
|
||||||
|
Data sampled 90.521123
|
||||||
|
Events updated 91.626003
|
||||||
|
sendcan published 93.413218
|
||||||
|
controlsState published 94.143989
|
||||||
|
Data sampled 100.991497
|
||||||
|
Events updated 101.973774
|
||||||
|
sendcan published 103.565575
|
||||||
|
controlsState published 104.146088
|
||||||
|
Data sampled 110.284387
|
||||||
|
Events updated 111.183541
|
||||||
|
sendcan published 112.981692
|
||||||
|
controlsState published 113.731994
|
||||||
|
boardd
|
||||||
|
sending sendcan to panda: 250027001751393037323631 81.928119
|
||||||
|
sendcan sent to panda: 250027001751393037323631 82.164834
|
||||||
|
sending sendcan to panda: 250027001751393037323631 93.569986
|
||||||
|
sendcan sent to panda: 250027001751393037323631 93.92795
|
||||||
|
sending sendcan to panda: 250027001751393037323631 103.689167
|
||||||
|
sendcan sent to panda: 250027001751393037323631 104.012235
|
||||||
|
sending sendcan to panda: 250027001751393037323631 113.109555
|
||||||
|
sendcan sent to panda: 250027001751393037323631 113.525487
|
||||||
|
sending sendcan to panda: 250027001751393037323631 122.508434
|
||||||
|
sendcan sent to panda: 250027001751393037323631 122.834314
|
||||||
|
```
|
||||||
244
tools/latencylogger/latency_logger.py
Executable file
244
tools/latencylogger/latency_logger.py
Executable file
@@ -0,0 +1,244 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import matplotlib.patches as mpatches
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import mpld3
|
||||||
|
import sys
|
||||||
|
from bisect import bisect_left, bisect_right
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from openpilot.tools.lib.logreader import logreader_from_route_or_segment
|
||||||
|
|
||||||
|
DEMO_ROUTE = "9f583b1d93915c31|2022-05-18--10-49-51--0"
|
||||||
|
|
||||||
|
SERVICES = ['camerad', 'modeld', 'plannerd', 'controlsd', 'boardd']
|
||||||
|
# Retrieve controlsd frameId from lateralPlan, mismatch with longitudinalPlan will be ignored
|
||||||
|
MONOTIME_KEYS = ['modelMonoTime', 'lateralPlanMonoTime']
|
||||||
|
MSGQ_TO_SERVICE = {
|
||||||
|
'roadCameraState': 'camerad',
|
||||||
|
'wideRoadCameraState': 'camerad',
|
||||||
|
'modelV2': 'modeld',
|
||||||
|
'lateralPlan': 'plannerd',
|
||||||
|
'longitudinalPlan': 'plannerd',
|
||||||
|
'sendcan': 'controlsd',
|
||||||
|
'controlsState': 'controlsd'
|
||||||
|
}
|
||||||
|
SERVICE_TO_DURATIONS = {
|
||||||
|
'camerad': ['processingTime'],
|
||||||
|
'modeld': ['modelExecutionTime', 'gpuExecutionTime'],
|
||||||
|
'plannerd': ['solverExecutionTime'],
|
||||||
|
}
|
||||||
|
|
||||||
|
def read_logs(lr):
|
||||||
|
data = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
|
||||||
|
mono_to_frame = {}
|
||||||
|
frame_mismatches = []
|
||||||
|
frame_id_fails = 0
|
||||||
|
latest_sendcan_monotime = 0
|
||||||
|
for msg in lr:
|
||||||
|
if msg.which() == 'sendcan':
|
||||||
|
latest_sendcan_monotime = msg.logMonoTime
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msg.which() in MSGQ_TO_SERVICE:
|
||||||
|
service = MSGQ_TO_SERVICE[msg.which()]
|
||||||
|
msg_obj = getattr(msg, msg.which())
|
||||||
|
|
||||||
|
frame_id = -1
|
||||||
|
if hasattr(msg_obj, "frameId"):
|
||||||
|
frame_id = msg_obj.frameId
|
||||||
|
else:
|
||||||
|
continue_outer = False
|
||||||
|
for key in MONOTIME_KEYS:
|
||||||
|
if hasattr(msg_obj, key):
|
||||||
|
if getattr(msg_obj, key) == 0:
|
||||||
|
# Filter out controlsd messages which arrive before the camera loop
|
||||||
|
continue_outer = True
|
||||||
|
elif getattr(msg_obj, key) in mono_to_frame:
|
||||||
|
frame_id = mono_to_frame[getattr(msg_obj, key)]
|
||||||
|
if continue_outer:
|
||||||
|
continue
|
||||||
|
if frame_id == -1:
|
||||||
|
frame_id_fails += 1
|
||||||
|
continue
|
||||||
|
mono_to_frame[msg.logMonoTime] = frame_id
|
||||||
|
data['timestamp'][frame_id][service].append((msg.which()+" published", msg.logMonoTime))
|
||||||
|
|
||||||
|
next_service = SERVICES[SERVICES.index(service)+1]
|
||||||
|
if not data['start'][frame_id][next_service]:
|
||||||
|
data['start'][frame_id][next_service] = msg.logMonoTime
|
||||||
|
data['end'][frame_id][service] = msg.logMonoTime
|
||||||
|
|
||||||
|
if service in SERVICE_TO_DURATIONS:
|
||||||
|
for duration in SERVICE_TO_DURATIONS[service]:
|
||||||
|
data['duration'][frame_id][service].append((msg.which()+"."+duration, getattr(msg_obj, duration)))
|
||||||
|
|
||||||
|
if service == SERVICES[0]:
|
||||||
|
data['timestamp'][frame_id][service].append((msg.which()+" start of frame", msg_obj.timestampSof))
|
||||||
|
if not data['start'][frame_id][service]:
|
||||||
|
data['start'][frame_id][service] = msg_obj.timestampSof
|
||||||
|
elif msg.which() == 'controlsState':
|
||||||
|
# Sendcan is published before controlsState, but the frameId is retrieved in CS
|
||||||
|
data['timestamp'][frame_id][service].append(("sendcan published", latest_sendcan_monotime))
|
||||||
|
elif msg.which() == 'modelV2':
|
||||||
|
if msg_obj.frameIdExtra != frame_id:
|
||||||
|
frame_mismatches.append(frame_id)
|
||||||
|
|
||||||
|
if frame_id_fails > 20:
|
||||||
|
print("Warning, many frameId fetch fails", frame_id_fails)
|
||||||
|
if len(frame_mismatches) > 20:
|
||||||
|
print("Warning, many frame mismatches", len(frame_mismatches))
|
||||||
|
return (data, frame_mismatches)
|
||||||
|
|
||||||
|
# This is not needed in 3.10 as a "key" parameter is added to bisect
|
||||||
|
class KeyifyList(object):
|
||||||
|
def __init__(self, inner, key):
|
||||||
|
self.inner = inner
|
||||||
|
self.key = key
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.inner)
|
||||||
|
def __getitem__(self, k):
|
||||||
|
return self.key(self.inner[k])
|
||||||
|
|
||||||
|
def find_frame_id(time, service, start_times, end_times):
|
||||||
|
left = bisect_left(KeyifyList(list(start_times.items()),
|
||||||
|
lambda x: x[1][service] if x[1][service] else -1), time) - 1
|
||||||
|
right = bisect_right(KeyifyList(list(end_times.items()),
|
||||||
|
lambda x: x[1][service] if x[1][service] else float("inf")), time)
|
||||||
|
return left, right
|
||||||
|
|
||||||
|
def find_t0(start_times, frame_id=-1):
|
||||||
|
frame_id = frame_id if frame_id > -1 else min(start_times.keys())
|
||||||
|
m = max(start_times.keys())
|
||||||
|
while frame_id <= m:
|
||||||
|
for service in SERVICES:
|
||||||
|
if start_times[frame_id][service]:
|
||||||
|
return start_times[frame_id][service]
|
||||||
|
frame_id += 1
|
||||||
|
raise Exception('No start time has been set')
|
||||||
|
|
||||||
|
def insert_cloudlogs(lr, timestamps, start_times, end_times):
|
||||||
|
# at least one cloudlog must be made in controlsd
|
||||||
|
|
||||||
|
t0 = find_t0(start_times)
|
||||||
|
failed_inserts = 0
|
||||||
|
latest_controls_frameid = 0
|
||||||
|
for msg in lr:
|
||||||
|
if msg.which() == "logMessage":
|
||||||
|
jmsg = json.loads(msg.logMessage)
|
||||||
|
if "timestamp" in jmsg['msg']:
|
||||||
|
time = int(jmsg['msg']['timestamp']['time'])
|
||||||
|
service = jmsg['ctx']['daemon']
|
||||||
|
event = jmsg['msg']['timestamp']['event']
|
||||||
|
if time < t0:
|
||||||
|
# Filter out controlsd messages which arrive before the camera loop
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "frame_id" in jmsg['msg']['timestamp']:
|
||||||
|
timestamps[int(jmsg['msg']['timestamp']['frame_id'])][service].append((event, time))
|
||||||
|
continue
|
||||||
|
|
||||||
|
if service == "boardd":
|
||||||
|
timestamps[latest_controls_frameid][service].append((event, time))
|
||||||
|
end_times[latest_controls_frameid][service] = time
|
||||||
|
else:
|
||||||
|
frame_id = find_frame_id(time, service, start_times, end_times)
|
||||||
|
if frame_id:
|
||||||
|
if frame_id[0] != frame_id[1]:
|
||||||
|
event += " (warning: ambiguity)"
|
||||||
|
frame_id = frame_id[0]
|
||||||
|
if service == 'controlsd':
|
||||||
|
latest_controls_frameid = frame_id
|
||||||
|
timestamps[frame_id][service].append((event, time))
|
||||||
|
else:
|
||||||
|
failed_inserts += 1
|
||||||
|
|
||||||
|
if latest_controls_frameid == 0:
|
||||||
|
print("Warning: failed to bind boardd logs to a frame ID. Add a timestamp cloudlog in controlsd.")
|
||||||
|
elif failed_inserts > len(timestamps):
|
||||||
|
print(f"Warning: failed to bind {failed_inserts} cloudlog timestamps to a frame ID")
|
||||||
|
|
||||||
|
def print_timestamps(timestamps, durations, start_times, relative):
|
||||||
|
t0 = find_t0(start_times)
|
||||||
|
for frame_id in timestamps.keys():
|
||||||
|
print('='*80)
|
||||||
|
print("Frame ID:", frame_id)
|
||||||
|
if relative:
|
||||||
|
t0 = find_t0(start_times, frame_id)
|
||||||
|
|
||||||
|
for service in SERVICES:
|
||||||
|
print(" "+service)
|
||||||
|
events = timestamps[frame_id][service]
|
||||||
|
for event, time in sorted(events, key = lambda x: x[1]):
|
||||||
|
print(" "+'%-53s%-53s' %(event, str((time-t0)/1e6)))
|
||||||
|
for event, time in durations[frame_id][service]:
|
||||||
|
print(" "+'%-53s%-53s' %(event, str(time*1000)))
|
||||||
|
|
||||||
|
def graph_timestamps(timestamps, start_times, end_times, relative, offset_services=False, title=""):
|
||||||
|
# mpld3 doesn't convert properly to D3 font sizes
|
||||||
|
plt.rcParams.update({'font.size': 18})
|
||||||
|
|
||||||
|
t0 = find_t0(start_times)
|
||||||
|
fig, ax = plt.subplots()
|
||||||
|
ax.set_xlim(0, 130 if relative else 750)
|
||||||
|
ax.set_ylim(0, 17)
|
||||||
|
ax.set_xlabel('Time (milliseconds)')
|
||||||
|
colors = ['blue', 'green', 'red', 'yellow', 'purple']
|
||||||
|
offsets = [[0, -5*j] for j in range(len(SERVICES))] if offset_services else None
|
||||||
|
height = 0.3 if offset_services else 0.9
|
||||||
|
assert len(colors) == len(SERVICES), 'Each service needs a color'
|
||||||
|
|
||||||
|
points = {"x": [], "y": [], "labels": []}
|
||||||
|
for i, (frame_id, services) in enumerate(timestamps.items()):
|
||||||
|
if relative:
|
||||||
|
t0 = find_t0(start_times, frame_id)
|
||||||
|
service_bars = []
|
||||||
|
for service, events in services.items():
|
||||||
|
if start_times[frame_id][service] and end_times[frame_id][service]:
|
||||||
|
start = start_times[frame_id][service]
|
||||||
|
end = end_times[frame_id][service]
|
||||||
|
service_bars.append(((start-t0)/1e6,(end-start)/1e6))
|
||||||
|
for event in events:
|
||||||
|
points['x'].append((event[1]-t0)/1e6)
|
||||||
|
points['y'].append(i)
|
||||||
|
points['labels'].append(event[0])
|
||||||
|
ax.broken_barh(service_bars, (i-height/2, height), facecolors=(colors), alpha=0.5, offsets=offsets)
|
||||||
|
|
||||||
|
scatter = ax.scatter(points['x'], points['y'], marker='d', edgecolor='black')
|
||||||
|
tooltip = mpld3.plugins.PointLabelTooltip(scatter, labels=points['labels'])
|
||||||
|
mpld3.plugins.connect(fig, tooltip)
|
||||||
|
|
||||||
|
plt.title(title)
|
||||||
|
# Set size relative window size is not trivial: https://github.com/mpld3/mpld3/issues/65
|
||||||
|
fig.set_size_inches(18, 9)
|
||||||
|
plt.legend(handles=[mpatches.Patch(color=colors[i], label=SERVICES[i]) for i in range(len(SERVICES))])
|
||||||
|
return fig
|
||||||
|
|
||||||
|
def get_timestamps(lr):
|
||||||
|
lr = list(lr)
|
||||||
|
data, frame_mismatches = read_logs(lr)
|
||||||
|
insert_cloudlogs(lr, data['timestamp'], data['start'], data['end'])
|
||||||
|
return data, frame_mismatches
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="A tool for analyzing openpilot's end-to-end latency",
|
||||||
|
formatter_class = argparse.ArgumentDefaultsHelpFormatter)
|
||||||
|
parser.add_argument("--relative", action="store_true", help="Make timestamps relative to the start of each frame")
|
||||||
|
parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one")
|
||||||
|
parser.add_argument("--plot", action="store_true", help="If a plot should be generated")
|
||||||
|
parser.add_argument("--offset", action="store_true", help="Vertically offset service to better visualize overlap")
|
||||||
|
parser.add_argument("route_or_segment_name", nargs='?', help="The route to print")
|
||||||
|
|
||||||
|
if len(sys.argv) == 1:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
r = DEMO_ROUTE if args.demo else args.route_or_segment_name.strip()
|
||||||
|
lr = logreader_from_route_or_segment(r, sort_by_time=True)
|
||||||
|
|
||||||
|
data, _ = get_timestamps(lr)
|
||||||
|
print_timestamps(data['timestamp'], data['duration'], data['start'], args.relative)
|
||||||
|
if args.plot:
|
||||||
|
mpld3.show(graph_timestamps(data['timestamp'], data['start'], data['end'], args.relative, offset_services=args.offset, title=r))
|
||||||
@@ -32,22 +32,20 @@ for msg in lr:
|
|||||||
print(msg.carState.steeringAngleDeg)
|
print(msg.carState.steeringAngleDeg)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Segment Ranges
|
### MultiLogIterator
|
||||||
|
|
||||||
We also support a new format called a "segment range", where you can specify which segments from a route to load.
|
`MultiLogIterator` is similar to `LogReader`, but reads multiple logs.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
from openpilot.tools.lib.route import Route
|
||||||
|
from openpilot.tools.lib.logreader import MultiLogIterator
|
||||||
|
|
||||||
lr = LogReader("a2a0ccea32023010|2023-07-27--13-01-19/4") # 4th segment
|
# setup a MultiLogIterator to read all the logs in the route
|
||||||
lr = LogReader("a2a0ccea32023010|2023-07-27--13-01-19/4:6") # 4th and 5th segment
|
r = Route("a2a0ccea32023010|2023-07-27--13-01-19")
|
||||||
lr = LogReader("a2a0ccea32023010|2023-07-27--13-01-19/-1") # last segment
|
lr = MultiLogIterator(r.log_paths())
|
||||||
lr = LogReader("a2a0ccea32023010|2023-07-27--13-01-19/:5") # first 5 segments
|
|
||||||
lr = LogReader("a2a0ccea32023010|2023-07-27--13-01-19/1:") # all except first segment
|
# print all the steering angles values from all the logs in the route
|
||||||
```
|
for msg in lr:
|
||||||
|
if msg.which() == "carState":
|
||||||
and can select which type of logs to grab
|
print(msg.carState.steeringAngleDeg)
|
||||||
|
|
||||||
```python
|
|
||||||
lr = LogReader("a2a0ccea32023010|2023-07-27--13-01-19/4/q") # get qlogs
|
|
||||||
lr = LogReader("a2a0ccea32023010|2023-07-27--13-01-19/4/r") # get rlogs (default)
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import datetime
|
||||||
import functools
|
import functools
|
||||||
import re
|
import re
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from openpilot.tools.lib.auth_config import get_token
|
from openpilot.tools.lib.auth_config import get_token
|
||||||
from openpilot.tools.lib.api import CommaApi
|
from openpilot.tools.lib.api import CommaApi
|
||||||
from openpilot.tools.lib.helpers import RE
|
from openpilot.tools.lib.helpers import RE, timestamp_to_datetime
|
||||||
|
|
||||||
|
|
||||||
@functools.total_ordering
|
@functools.total_ordering
|
||||||
@@ -16,8 +17,8 @@ class Bootlog:
|
|||||||
if not r:
|
if not r:
|
||||||
raise Exception(f"Unable to parse: {url}")
|
raise Exception(f"Unable to parse: {url}")
|
||||||
|
|
||||||
self._id = r.group('log_id')
|
|
||||||
self._dongle_id = r.group('dongle_id')
|
self._dongle_id = r.group('dongle_id')
|
||||||
|
self._timestamp = r.group('timestamp')
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
@@ -28,21 +29,25 @@ class Bootlog:
|
|||||||
return self._dongle_id
|
return self._dongle_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def id(self) -> str:
|
def timestamp(self) -> str:
|
||||||
return self._id
|
return self._timestamp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def datetime(self) -> datetime.datetime:
|
||||||
|
return timestamp_to_datetime(self._timestamp)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self._dongle_id}/{self._id}"
|
return f"{self._dongle_id}|{self._timestamp}"
|
||||||
|
|
||||||
def __eq__(self, b) -> bool:
|
def __eq__(self, b) -> bool:
|
||||||
if not isinstance(b, Bootlog):
|
if not isinstance(b, Bootlog):
|
||||||
return False
|
return False
|
||||||
return self.id == b.id
|
return self.datetime == b.datetime
|
||||||
|
|
||||||
def __lt__(self, b) -> bool:
|
def __lt__(self, b) -> bool:
|
||||||
if not isinstance(b, Bootlog):
|
if not isinstance(b, Bootlog):
|
||||||
return False
|
return False
|
||||||
return self.id < b.id
|
return self.datetime < b.datetime
|
||||||
|
|
||||||
def get_bootlog_from_id(bootlog_id: str) -> Optional[Bootlog]:
|
def get_bootlog_from_id(bootlog_id: str) -> Optional[Bootlog]:
|
||||||
# TODO: implement an API endpoint for this
|
# TODO: implement an API endpoint for this
|
||||||
|
|||||||
@@ -1,36 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
import socket
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from openpilot.tools.lib.url_file import URLFile
|
from openpilot.tools.lib.url_file import URLFile
|
||||||
|
|
||||||
DATA_ENDPOINT = os.getenv("DATA_ENDPOINT", "http://data-raw.comma.internal/")
|
DATA_ENDPOINT = os.getenv("DATA_ENDPOINT", "http://data-raw.comma.internal/")
|
||||||
|
|
||||||
|
|
||||||
def internal_source_available():
|
|
||||||
try:
|
|
||||||
hostname = urlparse(DATA_ENDPOINT).hostname
|
|
||||||
if hostname:
|
|
||||||
socket.gethostbyname(hostname)
|
|
||||||
return True
|
|
||||||
except socket.gaierror:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_name(fn):
|
def resolve_name(fn):
|
||||||
if fn.startswith("cd:/"):
|
if fn.startswith("cd:/"):
|
||||||
return fn.replace("cd:/", DATA_ENDPOINT)
|
return fn.replace("cd:/", DATA_ENDPOINT)
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
|
|
||||||
def file_exists(fn):
|
|
||||||
fn = resolve_name(fn)
|
|
||||||
if fn.startswith(("http://", "https://")):
|
|
||||||
return URLFile(fn).get_length_online() != -1
|
|
||||||
return os.path.exists(fn)
|
|
||||||
|
|
||||||
|
|
||||||
def FileReader(fn, debug=False):
|
def FileReader(fn, debug=False):
|
||||||
fn = resolve_name(fn)
|
fn = resolve_name(fn)
|
||||||
if fn.startswith(("http://", "https://")):
|
if fn.startswith(("http://", "https://")):
|
||||||
|
|||||||
@@ -3,20 +3,15 @@ import datetime
|
|||||||
|
|
||||||
TIME_FMT = "%Y-%m-%d--%H-%M-%S"
|
TIME_FMT = "%Y-%m-%d--%H-%M-%S"
|
||||||
|
|
||||||
|
|
||||||
# regex patterns
|
# regex patterns
|
||||||
class RE:
|
class RE:
|
||||||
DONGLE_ID = r'(?P<dongle_id>[a-f0-9]{16})'
|
DONGLE_ID = r'(?P<dongle_id>[a-z0-9]{16})'
|
||||||
TIMESTAMP = r'(?P<timestamp>[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2})'
|
TIMESTAMP = r'(?P<timestamp>[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2})'
|
||||||
LOG_ID_V2 = r'(?P<count>[a-f0-9]{8})--(?P<uid>[a-z0-9]{10})'
|
ROUTE_NAME = r'(?P<route_name>{}[|_/]{})'.format(DONGLE_ID, TIMESTAMP)
|
||||||
LOG_ID = r'(?P<log_id>(?:{}|{}))'.format(TIMESTAMP, LOG_ID_V2)
|
|
||||||
ROUTE_NAME = r'(?P<route_name>{}[|_/]{})'.format(DONGLE_ID, LOG_ID)
|
|
||||||
SEGMENT_NAME = r'{}(?:--|/)(?P<segment_num>[0-9]+)'.format(ROUTE_NAME)
|
SEGMENT_NAME = r'{}(?:--|/)(?P<segment_num>[0-9]+)'.format(ROUTE_NAME)
|
||||||
|
|
||||||
INDEX = r'-?[0-9]+'
|
INDEX = r'-?[0-9]+'
|
||||||
SLICE = r'(?P<start>{})?:?(?P<end>{})?:?(?P<step>{})?'.format(INDEX, INDEX, INDEX)
|
SLICE = r'(?P<start>{})?:?(?P<end>{})?:?(?P<step>{})?'.format(INDEX, INDEX, INDEX)
|
||||||
SEGMENT_RANGE = r'{}(?:(--|/)(?P<slice>({})))?(?:/(?P<selector>([qras])))?'.format(ROUTE_NAME, SLICE)
|
SEGMENT_RANGE = r'{}(?:--|/)?(?P<slice>({}))?/?(?P<selector>([qr]))?'.format(ROUTE_NAME, SLICE)
|
||||||
|
|
||||||
BOOTLOG_NAME = ROUTE_NAME
|
BOOTLOG_NAME = ROUTE_NAME
|
||||||
|
|
||||||
EXPLORER_FILE = r'^(?P<segment_name>{})--(?P<file_name>[a-z]+\.[a-z0-9]+)$'.format(SEGMENT_NAME)
|
EXPLORER_FILE = r'^(?P<segment_name>{})--(?P<file_name>[a-z]+\.[a-z0-9]+)$'.format(SEGMENT_NAME)
|
||||||
|
|||||||
@@ -1,32 +1,82 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import bz2
|
|
||||||
from functools import partial
|
|
||||||
import multiprocessing
|
|
||||||
import capnp
|
|
||||||
import enum
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
|
||||||
import sys
|
import sys
|
||||||
import tqdm
|
import bz2
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import capnp
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from typing import Callable, Dict, Iterable, Iterator, List, Optional, Type
|
from typing import Iterable, Iterator
|
||||||
from urllib.parse import parse_qs, urlparse
|
|
||||||
|
|
||||||
from cereal import log as capnp_log
|
from cereal import log as capnp_log
|
||||||
from openpilot.common.swaglog import cloudlog
|
from openpilot.tools.lib.filereader import FileReader
|
||||||
from openpilot.tools.lib.comma_car_segments import get_url as get_comma_segments_url
|
from openpilot.tools.lib.route import Route, SegmentName
|
||||||
from openpilot.tools.lib.openpilotci import get_url
|
|
||||||
from openpilot.tools.lib.filereader import FileReader, file_exists, internal_source_available
|
|
||||||
from openpilot.tools.lib.route import Route, SegmentRange
|
|
||||||
|
|
||||||
LogMessage = Type[capnp._DynamicStructReader]
|
LogIterable = Iterable[capnp._DynamicStructReader]
|
||||||
LogIterable = Iterable[LogMessage]
|
|
||||||
RawLogIterable = Iterable[bytes]
|
# this is an iterator itself, and uses private variables from LogReader
|
||||||
|
class MultiLogIterator:
|
||||||
|
def __init__(self, log_paths, sort_by_time=False):
|
||||||
|
self._log_paths = log_paths
|
||||||
|
self.sort_by_time = sort_by_time
|
||||||
|
|
||||||
|
self._first_log_idx = next(i for i in range(len(log_paths)) if log_paths[i] is not None)
|
||||||
|
self._current_log = self._first_log_idx
|
||||||
|
self._idx = 0
|
||||||
|
self._log_readers = [None]*len(log_paths)
|
||||||
|
self.start_time = self._log_reader(self._first_log_idx)._ts[0]
|
||||||
|
|
||||||
|
def _log_reader(self, i):
|
||||||
|
if self._log_readers[i] is None and self._log_paths[i] is not None:
|
||||||
|
log_path = self._log_paths[i]
|
||||||
|
self._log_readers[i] = LogReader(log_path, sort_by_time=self.sort_by_time)
|
||||||
|
|
||||||
|
return self._log_readers[i]
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[capnp._DynamicStructReader]:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _inc(self):
|
||||||
|
lr = self._log_reader(self._current_log)
|
||||||
|
if self._idx < len(lr._ents)-1:
|
||||||
|
self._idx += 1
|
||||||
|
else:
|
||||||
|
self._idx = 0
|
||||||
|
self._current_log = next(i for i in range(self._current_log + 1, len(self._log_readers) + 1)
|
||||||
|
if i == len(self._log_readers) or self._log_paths[i] is not None)
|
||||||
|
if self._current_log == len(self._log_readers):
|
||||||
|
raise StopIteration
|
||||||
|
|
||||||
|
def __next__(self):
|
||||||
|
while 1:
|
||||||
|
lr = self._log_reader(self._current_log)
|
||||||
|
ret = lr._ents[self._idx]
|
||||||
|
self._inc()
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def tell(self):
|
||||||
|
# returns seconds from start of log
|
||||||
|
return (self._log_reader(self._current_log)._ts[self._idx] - self.start_time) * 1e-9
|
||||||
|
|
||||||
|
def seek(self, ts):
|
||||||
|
# seek to nearest minute
|
||||||
|
minute = int(ts/60)
|
||||||
|
if minute >= len(self._log_paths) or self._log_paths[minute] is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._current_log = minute
|
||||||
|
|
||||||
|
# HACK: O(n) seek afterward
|
||||||
|
self._idx = 0
|
||||||
|
while self.tell() < ts:
|
||||||
|
self._inc()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.__init__(self._log_paths, sort_by_time=self.sort_by_time)
|
||||||
|
|
||||||
|
|
||||||
class _LogFileReader:
|
class LogReader:
|
||||||
def __init__(self, fn, canonicalize=True, only_union_types=False, sort_by_time=False, dat=None):
|
def __init__(self, fn, canonicalize=True, only_union_types=False, sort_by_time=False, dat=None):
|
||||||
self.data_version = None
|
self.data_version = None
|
||||||
self._only_union_types = only_union_types
|
self._only_union_types = only_union_types
|
||||||
@@ -56,6 +106,10 @@ class _LogFileReader:
|
|||||||
self._ents = list(sorted(_ents, key=lambda x: x.logMonoTime) if sort_by_time else _ents)
|
self._ents = list(sorted(_ents, key=lambda x: x.logMonoTime) if sort_by_time else _ents)
|
||||||
self._ts = [x.logMonoTime for x in self._ents]
|
self._ts = [x.logMonoTime for x in self._ents]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, dat):
|
||||||
|
return cls("", dat=dat)
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[capnp._DynamicStructReader]:
|
def __iter__(self) -> Iterator[capnp._DynamicStructReader]:
|
||||||
for ent in self._ents:
|
for ent in self._ents:
|
||||||
if self._only_union_types:
|
if self._only_union_types:
|
||||||
@@ -67,222 +121,17 @@ class _LogFileReader:
|
|||||||
else:
|
else:
|
||||||
yield ent
|
yield ent
|
||||||
|
|
||||||
|
def logreader_from_route_or_segment(r, sort_by_time=False):
|
||||||
class ReadMode(enum.StrEnum):
|
sn = SegmentName(r, allow_route_name=True)
|
||||||
RLOG = "r" # only read rlogs
|
route = Route(sn.route_name.canonical_name)
|
||||||
QLOG = "q" # only read qlogs
|
if sn.segment_num < 0:
|
||||||
SANITIZED = "s" # read from the commaCarSegments database
|
return MultiLogIterator(route.log_paths(), sort_by_time=sort_by_time)
|
||||||
AUTO = "a" # default to rlogs, fallback to qlogs
|
|
||||||
AUTO_INTERACTIVE = "i" # default to rlogs, fallback to qlogs with a prompt from the user
|
|
||||||
|
|
||||||
|
|
||||||
LogPath = Optional[str]
|
|
||||||
LogPaths = List[LogPath]
|
|
||||||
ValidFileCallable = Callable[[LogPath], bool]
|
|
||||||
Source = Callable[[SegmentRange, ReadMode], LogPaths]
|
|
||||||
|
|
||||||
InternalUnavailableException = Exception("Internal source not available")
|
|
||||||
|
|
||||||
def default_valid_file(fn: LogPath) -> bool:
|
|
||||||
return fn is not None and file_exists(fn)
|
|
||||||
|
|
||||||
|
|
||||||
def auto_strategy(rlog_paths: LogPaths, qlog_paths: LogPaths, interactive: bool, valid_file: ValidFileCallable) -> LogPaths:
|
|
||||||
# auto select logs based on availability
|
|
||||||
if any(rlog is None or not valid_file(rlog) for rlog in rlog_paths):
|
|
||||||
if interactive:
|
|
||||||
if input("Some rlogs were not found, would you like to fallback to qlogs for those segments? (y/n) ").lower() != "y":
|
|
||||||
return rlog_paths
|
|
||||||
else:
|
else:
|
||||||
cloudlog.warning("Some rlogs were not found, falling back to qlogs for those segments...")
|
return LogReader(route.log_paths()[sn.segment_num], sort_by_time=sort_by_time)
|
||||||
|
|
||||||
return [rlog if valid_file(rlog) else (qlog if valid_file(qlog) else None)
|
|
||||||
for (rlog, qlog) in zip(rlog_paths, qlog_paths, strict=True)]
|
|
||||||
return rlog_paths
|
|
||||||
|
|
||||||
|
|
||||||
def apply_strategy(mode: ReadMode, rlog_paths: LogPaths, qlog_paths: LogPaths, valid_file: ValidFileCallable = default_valid_file) -> LogPaths:
|
|
||||||
if mode == ReadMode.RLOG:
|
|
||||||
return rlog_paths
|
|
||||||
elif mode == ReadMode.QLOG:
|
|
||||||
return qlog_paths
|
|
||||||
elif mode == ReadMode.AUTO:
|
|
||||||
return auto_strategy(rlog_paths, qlog_paths, False, valid_file)
|
|
||||||
elif mode == ReadMode.AUTO_INTERACTIVE:
|
|
||||||
return auto_strategy(rlog_paths, qlog_paths, True, valid_file)
|
|
||||||
raise Exception(f"invalid mode: {mode}")
|
|
||||||
|
|
||||||
|
|
||||||
def comma_api_source(sr: SegmentRange, mode: ReadMode) -> LogPaths:
|
|
||||||
route = Route(sr.route_name)
|
|
||||||
|
|
||||||
rlog_paths = [route.log_paths()[seg] for seg in sr.seg_idxs]
|
|
||||||
qlog_paths = [route.qlog_paths()[seg] for seg in sr.seg_idxs]
|
|
||||||
|
|
||||||
# comma api will have already checked if the file exists
|
|
||||||
def valid_file(fn):
|
|
||||||
return fn is not None
|
|
||||||
|
|
||||||
return apply_strategy(mode, rlog_paths, qlog_paths, valid_file=valid_file)
|
|
||||||
|
|
||||||
|
|
||||||
def internal_source(sr: SegmentRange, mode: ReadMode) -> LogPaths:
|
|
||||||
if not internal_source_available():
|
|
||||||
raise InternalUnavailableException
|
|
||||||
|
|
||||||
def get_internal_url(sr: SegmentRange, seg, file):
|
|
||||||
return f"cd:/{sr.dongle_id}/{sr.timestamp}/{seg}/{file}.bz2"
|
|
||||||
|
|
||||||
rlog_paths = [get_internal_url(sr, seg, "rlog") for seg in sr.seg_idxs]
|
|
||||||
qlog_paths = [get_internal_url(sr, seg, "qlog") for seg in sr.seg_idxs]
|
|
||||||
|
|
||||||
return apply_strategy(mode, rlog_paths, qlog_paths)
|
|
||||||
|
|
||||||
|
|
||||||
def openpilotci_source(sr: SegmentRange, mode: ReadMode) -> LogPaths:
|
|
||||||
rlog_paths = [get_url(sr.route_name, seg, "rlog") for seg in sr.seg_idxs]
|
|
||||||
qlog_paths = [get_url(sr.route_name, seg, "qlog") for seg in sr.seg_idxs]
|
|
||||||
|
|
||||||
return apply_strategy(mode, rlog_paths, qlog_paths)
|
|
||||||
|
|
||||||
|
|
||||||
def comma_car_segments_source(sr: SegmentRange, mode=ReadMode.RLOG) -> LogPaths:
|
|
||||||
return [get_comma_segments_url(sr.route_name, seg) for seg in sr.seg_idxs]
|
|
||||||
|
|
||||||
|
|
||||||
def direct_source(file_or_url: str) -> LogPaths:
|
|
||||||
return [file_or_url]
|
|
||||||
|
|
||||||
|
|
||||||
def get_invalid_files(files):
|
|
||||||
for f in files:
|
|
||||||
if f is None or not file_exists(f):
|
|
||||||
yield f
|
|
||||||
|
|
||||||
|
|
||||||
def check_source(source: Source, *args) -> LogPaths:
|
|
||||||
files = source(*args)
|
|
||||||
assert next(get_invalid_files(files), False) is False
|
|
||||||
return files
|
|
||||||
|
|
||||||
|
|
||||||
def auto_source(sr: SegmentRange, mode=ReadMode.RLOG) -> LogPaths:
|
|
||||||
if mode == ReadMode.SANITIZED:
|
|
||||||
return comma_car_segments_source(sr, mode)
|
|
||||||
|
|
||||||
SOURCES: List[Source] = [internal_source, openpilotci_source, comma_api_source, comma_car_segments_source,]
|
|
||||||
exceptions = []
|
|
||||||
# Automatically determine viable source
|
|
||||||
for source in SOURCES:
|
|
||||||
try:
|
|
||||||
return check_source(source, sr, mode)
|
|
||||||
except Exception as e:
|
|
||||||
exceptions.append(e)
|
|
||||||
|
|
||||||
raise Exception(f"auto_source could not find any valid source, exceptions for sources: {exceptions}")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_useradmin(identifier: str):
|
|
||||||
if "useradmin.comma.ai" in identifier:
|
|
||||||
query = parse_qs(urlparse(identifier).query)
|
|
||||||
return query["onebox"][0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cabana(identifier: str):
|
|
||||||
if "cabana.comma.ai" in identifier:
|
|
||||||
query = parse_qs(urlparse(identifier).query)
|
|
||||||
return query["route"][0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_direct(identifier: str):
|
|
||||||
if identifier.startswith(("http://", "https://", "cd:/")) or pathlib.Path(identifier).exists():
|
|
||||||
return identifier
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_indirect(identifier: str):
|
|
||||||
parsed = parse_useradmin(identifier) or parse_cabana(identifier)
|
|
||||||
|
|
||||||
if parsed is not None:
|
|
||||||
return parsed, comma_api_source, True
|
|
||||||
|
|
||||||
return identifier, None, False
|
|
||||||
|
|
||||||
|
|
||||||
class LogReader:
|
|
||||||
def _parse_identifiers(self, identifier: str | List[str]):
|
|
||||||
if isinstance(identifier, list):
|
|
||||||
return [i for j in identifier for i in self._parse_identifiers(j)]
|
|
||||||
|
|
||||||
parsed, source, is_indirect = parse_indirect(identifier)
|
|
||||||
|
|
||||||
if not is_indirect:
|
|
||||||
direct_parsed = parse_direct(identifier)
|
|
||||||
if direct_parsed is not None:
|
|
||||||
return direct_source(identifier)
|
|
||||||
|
|
||||||
sr = SegmentRange(parsed)
|
|
||||||
mode = self.default_mode if sr.selector is None else ReadMode(sr.selector)
|
|
||||||
source = self.default_source if source is None else source
|
|
||||||
|
|
||||||
identifiers = source(sr, mode)
|
|
||||||
|
|
||||||
invalid_count = len(list(get_invalid_files(identifiers)))
|
|
||||||
assert invalid_count == 0, f"{invalid_count}/{len(identifiers)} invalid log(s) found, please ensure all logs \
|
|
||||||
are uploaded or auto fallback to qlogs with '/a' selector at the end of the route name."
|
|
||||||
return identifiers
|
|
||||||
|
|
||||||
def __init__(self, identifier: str | List[str], default_mode: ReadMode = ReadMode.RLOG,
|
|
||||||
default_source=auto_source, sort_by_time=False, only_union_types=False):
|
|
||||||
self.default_mode = default_mode
|
|
||||||
self.default_source = default_source
|
|
||||||
self.identifier = identifier
|
|
||||||
|
|
||||||
self.sort_by_time = sort_by_time
|
|
||||||
self.only_union_types = only_union_types
|
|
||||||
|
|
||||||
self.__lrs: Dict[int, _LogFileReader] = {}
|
|
||||||
self.reset()
|
|
||||||
|
|
||||||
def _get_lr(self, i):
|
|
||||||
if i not in self.__lrs:
|
|
||||||
self.__lrs[i] = _LogFileReader(self.logreader_identifiers[i])
|
|
||||||
return self.__lrs[i]
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
for i in range(len(self.logreader_identifiers)):
|
|
||||||
yield from self._get_lr(i)
|
|
||||||
|
|
||||||
def _run_on_segment(self, func, i):
|
|
||||||
return func(self._get_lr(i))
|
|
||||||
|
|
||||||
def run_across_segments(self, num_processes, func):
|
|
||||||
with multiprocessing.Pool(num_processes) as pool:
|
|
||||||
ret = []
|
|
||||||
num_segs = len(self.logreader_identifiers)
|
|
||||||
for p in tqdm.tqdm(pool.imap(partial(self._run_on_segment, func), range(num_segs)), total=num_segs):
|
|
||||||
ret.extend(p)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def reset(self):
|
|
||||||
self.logreader_identifiers = self._parse_identifiers(self.identifier)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def from_bytes(dat):
|
|
||||||
return _LogFileReader("", dat=dat)
|
|
||||||
|
|
||||||
def filter(self, msg_type: str):
|
|
||||||
return (getattr(m, m.which()) for m in filter(lambda m: m.which() == msg_type, self))
|
|
||||||
|
|
||||||
def first(self, msg_type: str):
|
|
||||||
return next(self.filter(msg_type), None)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import codecs
|
import codecs
|
||||||
|
|
||||||
# capnproto <= 0.8.0 throws errors converting byte data to string
|
# capnproto <= 0.8.0 throws errors converting byte data to string
|
||||||
# below line catches those errors and replaces the bytes with \x__
|
# below line catches those errors and replaces the bytes with \x__
|
||||||
codecs.register_error("strict", codecs.backslashreplace_errors)
|
codecs.register_error("strict", codecs.backslashreplace_errors)
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from functools import cache
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from typing import Optional, cast
|
from typing import Optional
|
||||||
|
|
||||||
from openpilot.tools.lib.auth_config import get_token
|
from openpilot.tools.lib.auth_config import get_token
|
||||||
from openpilot.tools.lib.api import CommaApi
|
from openpilot.tools.lib.api import CommaApi
|
||||||
@@ -17,7 +16,6 @@ CAMERA_FILENAMES = ['fcamera.hevc', 'video.hevc']
|
|||||||
DCAMERA_FILENAMES = ['dcamera.hevc']
|
DCAMERA_FILENAMES = ['dcamera.hevc']
|
||||||
ECAMERA_FILENAMES = ['ecamera.hevc']
|
ECAMERA_FILENAMES = ['ecamera.hevc']
|
||||||
|
|
||||||
|
|
||||||
class Route:
|
class Route:
|
||||||
def __init__(self, name, data_dir=None):
|
def __init__(self, name, data_dir=None):
|
||||||
self._name = RouteName(name)
|
self._name = RouteName(name)
|
||||||
@@ -38,27 +36,27 @@ class Route:
|
|||||||
|
|
||||||
def log_paths(self):
|
def log_paths(self):
|
||||||
log_path_by_seg_num = {s.name.segment_num: s.log_path for s in self._segments}
|
log_path_by_seg_num = {s.name.segment_num: s.log_path for s in self._segments}
|
||||||
return [log_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
|
return [log_path_by_seg_num.get(i, None) for i in range(self.max_seg_number+1)]
|
||||||
|
|
||||||
def qlog_paths(self):
|
def qlog_paths(self):
|
||||||
qlog_path_by_seg_num = {s.name.segment_num: s.qlog_path for s in self._segments}
|
qlog_path_by_seg_num = {s.name.segment_num: s.qlog_path for s in self._segments}
|
||||||
return [qlog_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
|
return [qlog_path_by_seg_num.get(i, None) for i in range(self.max_seg_number+1)]
|
||||||
|
|
||||||
def camera_paths(self):
|
def camera_paths(self):
|
||||||
camera_path_by_seg_num = {s.name.segment_num: s.camera_path for s in self._segments}
|
camera_path_by_seg_num = {s.name.segment_num: s.camera_path for s in self._segments}
|
||||||
return [camera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
|
return [camera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number+1)]
|
||||||
|
|
||||||
def dcamera_paths(self):
|
def dcamera_paths(self):
|
||||||
dcamera_path_by_seg_num = {s.name.segment_num: s.dcamera_path for s in self._segments}
|
dcamera_path_by_seg_num = {s.name.segment_num: s.dcamera_path for s in self._segments}
|
||||||
return [dcamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
|
return [dcamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number+1)]
|
||||||
|
|
||||||
def ecamera_paths(self):
|
def ecamera_paths(self):
|
||||||
ecamera_path_by_seg_num = {s.name.segment_num: s.ecamera_path for s in self._segments}
|
ecamera_path_by_seg_num = {s.name.segment_num: s.ecamera_path for s in self._segments}
|
||||||
return [ecamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
|
return [ecamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number+1)]
|
||||||
|
|
||||||
def qcamera_paths(self):
|
def qcamera_paths(self):
|
||||||
qcamera_path_by_seg_num = {s.name.segment_num: s.qcamera_path for s in self._segments}
|
qcamera_path_by_seg_num = {s.name.segment_num: s.qcamera_path for s in self._segments}
|
||||||
return [qcamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
|
return [qcamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number+1)]
|
||||||
|
|
||||||
# TODO: refactor this, it's super repetitive
|
# TODO: refactor this, it's super repetitive
|
||||||
def _get_segments_remote(self):
|
def _get_segments_remote(self):
|
||||||
@@ -160,7 +158,6 @@ class Route:
|
|||||||
raise ValueError(f'Could not find segments for route {self.name.canonical_name} in data directory {data_dir}')
|
raise ValueError(f'Could not find segments for route {self.name.canonical_name} in data directory {data_dir}')
|
||||||
return sorted(segments, key=lambda seg: seg.name.segment_num)
|
return sorted(segments, key=lambda seg: seg.name.segment_num)
|
||||||
|
|
||||||
|
|
||||||
class Segment:
|
class Segment:
|
||||||
def __init__(self, name, log_path, qlog_path, camera_path, dcamera_path, ecamera_path, qcamera_path):
|
def __init__(self, name, log_path, qlog_path, camera_path, dcamera_path, ecamera_path, qcamera_path):
|
||||||
self._name = SegmentName(name)
|
self._name = SegmentName(name)
|
||||||
@@ -175,7 +172,6 @@ class Segment:
|
|||||||
def name(self):
|
def name(self):
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
|
|
||||||
class RouteName:
|
class RouteName:
|
||||||
def __init__(self, name_str: str):
|
def __init__(self, name_str: str):
|
||||||
self._name_str = name_str
|
self._name_str = name_str
|
||||||
@@ -197,7 +193,6 @@ class RouteName:
|
|||||||
|
|
||||||
def __str__(self) -> str: return self._canonical_name
|
def __str__(self) -> str: return self._canonical_name
|
||||||
|
|
||||||
|
|
||||||
class SegmentName:
|
class SegmentName:
|
||||||
# TODO: add constructor that takes dongle_id, time_str, segment_num and then create instances
|
# TODO: add constructor that takes dongle_id, time_str, segment_num and then create instances
|
||||||
# of this class instead of manually constructing a segment name (use canonical_name prop instead)
|
# of this class instead of manually constructing a segment name (use canonical_name prop instead)
|
||||||
@@ -236,62 +231,27 @@ class SegmentName:
|
|||||||
def __str__(self) -> str: return self._canonical_name
|
def __str__(self) -> str: return self._canonical_name
|
||||||
|
|
||||||
|
|
||||||
@cache
|
|
||||||
def get_max_seg_number_cached(sr: 'SegmentRange') -> int:
|
|
||||||
try:
|
|
||||||
api = CommaApi(get_token())
|
|
||||||
return cast(int, api.get("/v1/route/" + sr.route_name.replace("/", "|"))["segment_numbers"][-1])
|
|
||||||
except Exception as e:
|
|
||||||
raise Exception("unable to get max_segment_number. ensure you have access to this route or the route is public.") from e
|
|
||||||
|
|
||||||
|
|
||||||
class SegmentRange:
|
class SegmentRange:
|
||||||
def __init__(self, segment_range: str):
|
def __init__(self, segment_range: str):
|
||||||
m = re.fullmatch(RE.SEGMENT_RANGE, segment_range)
|
self.m = re.fullmatch(RE.SEGMENT_RANGE, segment_range)
|
||||||
assert m is not None, f"Segment range is not valid {segment_range}"
|
assert self.m, f"Segment range is not valid {segment_range}"
|
||||||
self.m = m
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def route_name(self) -> str:
|
def route_name(self):
|
||||||
return self.m.group("route_name")
|
return self.m.group("route_name")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dongle_id(self) -> str:
|
def dongle_id(self):
|
||||||
return self.m.group("dongle_id")
|
return self.m.group("dongle_id")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timestamp(self) -> str:
|
def timestamp(self):
|
||||||
return self.m.group("timestamp")
|
return self.m.group("timestamp")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def slice(self) -> str:
|
def _slice(self):
|
||||||
return self.m.group("slice") or ""
|
return self.m.group("slice")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def selector(self) -> str | None:
|
def selector(self):
|
||||||
return self.m.group("selector")
|
return self.m.group("selector")
|
||||||
|
|
||||||
@property
|
|
||||||
def seg_idxs(self) -> list[int]:
|
|
||||||
m = re.fullmatch(RE.SLICE, self.slice)
|
|
||||||
assert m is not None, f"Invalid slice: {self.slice}"
|
|
||||||
start, end, step = (None if s is None else int(s) for s in m.groups())
|
|
||||||
|
|
||||||
# one segment specified
|
|
||||||
if start is not None and end is None and ':' not in self.slice:
|
|
||||||
if start < 0:
|
|
||||||
start += get_max_seg_number_cached(self) + 1
|
|
||||||
return [start]
|
|
||||||
|
|
||||||
s = slice(start, end, step)
|
|
||||||
# no specified end or using relative indexing, need number of segments
|
|
||||||
if end is None or end < 0 or (start is not None and start < 0):
|
|
||||||
return list(range(get_max_seg_number_cached(self) + 1))[s]
|
|
||||||
else:
|
|
||||||
return list(range(end + 1))[s]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.dongle_id}/{self.timestamp}" + (f"/{self.slice}" if self.slice else "") + (f"/{self.selector}" if self.selector else "")
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return self.__str__()
|
|
||||||
|
|||||||
141
tools/lib/srreader.py
Executable file
141
tools/lib/srreader.py
Executable file
@@ -0,0 +1,141 @@
|
|||||||
|
import enum
|
||||||
|
import numpy as np
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from openpilot.selfdrive.test.openpilotci import get_url
|
||||||
|
from openpilot.tools.lib.helpers import RE
|
||||||
|
from openpilot.tools.lib.logreader import LogReader
|
||||||
|
from openpilot.tools.lib.route import Route, SegmentRange
|
||||||
|
|
||||||
|
class ReadMode(enum.StrEnum):
|
||||||
|
RLOG = "r" # only read rlogs
|
||||||
|
QLOG = "q" # only read qlogs
|
||||||
|
#AUTO = "a" # default to rlogs, fallback to qlogs, not supported yet
|
||||||
|
|
||||||
|
|
||||||
|
def create_slice_from_string(s: str):
|
||||||
|
m = re.fullmatch(RE.SLICE, s)
|
||||||
|
assert m is not None, f"Invalid slice: {s}"
|
||||||
|
start, end, step = m.groups()
|
||||||
|
start = int(start) if start is not None else None
|
||||||
|
end = int(end) if end is not None else None
|
||||||
|
step = int(step) if step is not None else None
|
||||||
|
|
||||||
|
if start is not None and ":" not in s and end is None and step is None:
|
||||||
|
return start
|
||||||
|
return slice(start, end, step)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_slice(sr: SegmentRange):
|
||||||
|
route = Route(sr.route_name)
|
||||||
|
segs = np.arange(route.max_seg_number+1)
|
||||||
|
s = create_slice_from_string(sr._slice)
|
||||||
|
return segs[s] if isinstance(s, slice) else [segs[s]]
|
||||||
|
|
||||||
|
def comma_api_source(sr: SegmentRange, mode=ReadMode.RLOG, sort_by_time=False):
|
||||||
|
segs = parse_slice(sr)
|
||||||
|
route = Route(sr.route_name)
|
||||||
|
|
||||||
|
log_paths = route.log_paths() if mode == ReadMode.RLOG else route.qlog_paths()
|
||||||
|
|
||||||
|
invalid_segs = [seg for seg in segs if log_paths[seg] is None]
|
||||||
|
|
||||||
|
assert not len(invalid_segs), f"Some of the requested segments are not available: {invalid_segs}"
|
||||||
|
|
||||||
|
for seg in segs:
|
||||||
|
yield LogReader(log_paths[seg], sort_by_time=sort_by_time)
|
||||||
|
|
||||||
|
def internal_source(sr: SegmentRange, mode=ReadMode.RLOG, sort_by_time=False):
|
||||||
|
segs = parse_slice(sr)
|
||||||
|
|
||||||
|
for seg in segs:
|
||||||
|
yield LogReader(f"cd:/{sr.dongle_id}/{sr.timestamp}/{seg}/{'rlog' if mode == ReadMode.RLOG else 'qlog'}.bz2", sort_by_time=sort_by_time)
|
||||||
|
|
||||||
|
def openpilotci_source(sr: SegmentRange, mode=ReadMode.RLOG, sort_by_time=False):
|
||||||
|
segs = parse_slice(sr)
|
||||||
|
|
||||||
|
for seg in segs:
|
||||||
|
yield LogReader(get_url(sr.route_name, seg, 'rlog' if mode == ReadMode.RLOG else 'qlog'), sort_by_time=sort_by_time)
|
||||||
|
|
||||||
|
def direct_source(file_or_url, sort_by_time):
|
||||||
|
yield LogReader(file_or_url, sort_by_time=sort_by_time)
|
||||||
|
|
||||||
|
def auto_source(*args, **kwargs):
|
||||||
|
# Automatically determine viable source
|
||||||
|
|
||||||
|
try:
|
||||||
|
next(internal_source(*args, **kwargs))
|
||||||
|
return internal_source(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
next(openpilotci_source(*args, **kwargs))
|
||||||
|
return openpilotci_source(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return comma_api_source(*args, **kwargs)
|
||||||
|
|
||||||
|
def parse_useradmin(identifier):
|
||||||
|
if "useradmin.comma.ai" in identifier:
|
||||||
|
query = parse_qs(urlparse(identifier).query)
|
||||||
|
return query["onebox"][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_cabana(identifier):
|
||||||
|
if "cabana.comma.ai" in identifier:
|
||||||
|
query = parse_qs(urlparse(identifier).query)
|
||||||
|
return query["route"][0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_cd(identifier):
|
||||||
|
if "cd:/" in identifier:
|
||||||
|
return identifier.replace("cd:/", "")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_direct(identifier):
|
||||||
|
if "https://" in identifier or "http://" in identifier or pathlib.Path(identifier).exists():
|
||||||
|
return identifier
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_indirect(identifier):
|
||||||
|
parsed = parse_useradmin(identifier) or parse_cabana(identifier)
|
||||||
|
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed, comma_api_source, True
|
||||||
|
|
||||||
|
parsed = parse_cd(identifier)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed, internal_source, True
|
||||||
|
|
||||||
|
return identifier, None, False
|
||||||
|
|
||||||
|
class SegmentRangeReader:
|
||||||
|
def _logreaders_from_identifier(self, identifier):
|
||||||
|
parsed, source, is_indirect = parse_indirect(identifier)
|
||||||
|
|
||||||
|
if not is_indirect:
|
||||||
|
direct_parsed = parse_direct(identifier)
|
||||||
|
if direct_parsed is not None:
|
||||||
|
return direct_source(identifier, sort_by_time=self.sort_by_time)
|
||||||
|
|
||||||
|
sr = SegmentRange(parsed)
|
||||||
|
mode = self.default_mode if sr.selector is None else ReadMode(sr.selector)
|
||||||
|
source = self.default_source if source is None else source
|
||||||
|
|
||||||
|
return source(sr, mode, sort_by_time=self.sort_by_time)
|
||||||
|
|
||||||
|
def __init__(self, identifier: str, default_mode=ReadMode.RLOG, default_source=auto_source, sort_by_time=False):
|
||||||
|
self.default_mode = default_mode
|
||||||
|
self.default_source = default_source
|
||||||
|
self.sort_by_time = sort_by_time
|
||||||
|
|
||||||
|
self.lrs = self._logreaders_from_identifier(identifier)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for lr in self.lrs:
|
||||||
|
for m in lr:
|
||||||
|
yield m
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from functools import partial
|
from functools import wraps
|
||||||
import http.server
|
import http.server
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from parameterized import parameterized
|
from parameterized import parameterized
|
||||||
from openpilot.selfdrive.athena.tests.helpers import with_http_server
|
|
||||||
|
|
||||||
from openpilot.tools.lib.url_file import URLFile
|
from openpilot.tools.lib.url_file import URLFile
|
||||||
|
|
||||||
@@ -29,7 +30,27 @@ class CachingTestRequestHandler(http.server.BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
|
|
||||||
|
|
||||||
with_caching_server = partial(with_http_server, handler=CachingTestRequestHandler)
|
class CachingTestServer(threading.Thread):
|
||||||
|
def run(self):
|
||||||
|
self.server = http.server.HTTPServer(("127.0.0.1", 0), CachingTestRequestHandler)
|
||||||
|
self.port = self.server.server_port
|
||||||
|
self.server.serve_forever()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.server.server_close()
|
||||||
|
self.server.shutdown()
|
||||||
|
|
||||||
|
def with_caching_server(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
server = CachingTestServer()
|
||||||
|
server.start()
|
||||||
|
time.sleep(0.25) # wait for server to get it's port
|
||||||
|
try:
|
||||||
|
func(*args, **kwargs, port=server.port)
|
||||||
|
finally:
|
||||||
|
server.stop()
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class TestFileDownload(unittest.TestCase):
|
class TestFileDownload(unittest.TestCase):
|
||||||
@@ -89,10 +110,10 @@ class TestFileDownload(unittest.TestCase):
|
|||||||
|
|
||||||
@parameterized.expand([(True, ), (False, )])
|
@parameterized.expand([(True, ), (False, )])
|
||||||
@with_caching_server
|
@with_caching_server
|
||||||
def test_recover_from_missing_file(self, cache_enabled, host):
|
def test_recover_from_missing_file(self, cache_enabled, port):
|
||||||
os.environ["FILEREADER_CACHE"] = "1" if cache_enabled else "0"
|
os.environ["FILEREADER_CACHE"] = "1" if cache_enabled else "0"
|
||||||
|
|
||||||
file_url = f"{host}/test.png"
|
file_url = f"http://localhost:{port}/test.png"
|
||||||
|
|
||||||
CachingTestRequestHandler.FILE_EXISTS = False
|
CachingTestRequestHandler.FILE_EXISTS = False
|
||||||
length = URLFile(file_url).get_length()
|
length = URLFile(file_url).get_length()
|
||||||
|
|||||||
88
tools/lib/tests/test_srreader.py
Executable file
88
tools/lib/tests/test_srreader.py
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import numpy as np
|
||||||
|
import unittest
|
||||||
|
from parameterized import parameterized
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from openpilot.tools.lib.route import SegmentRange
|
||||||
|
from openpilot.tools.lib.srreader import ReadMode, SegmentRangeReader, parse_slice, parse_indirect
|
||||||
|
|
||||||
|
NUM_SEGS = 17 # number of segments in the test route
|
||||||
|
ALL_SEGS = list(np.arange(NUM_SEGS))
|
||||||
|
TEST_ROUTE = "344c5c15b34f2d8a/2024-01-03--09-37-12"
|
||||||
|
QLOG_FILE = "https://commadataci.blob.core.windows.net/openpilotci/0375fdf7b1ce594d/2019-06-13--08-32-25/3/qlog.bz2"
|
||||||
|
|
||||||
|
class TestSegmentRangeReader(unittest.TestCase):
|
||||||
|
@parameterized.expand([
|
||||||
|
(f"{TEST_ROUTE}", ALL_SEGS),
|
||||||
|
(f"{TEST_ROUTE.replace('/', '|')}", ALL_SEGS),
|
||||||
|
(f"{TEST_ROUTE}--0", [0]),
|
||||||
|
(f"{TEST_ROUTE}--5", [5]),
|
||||||
|
(f"{TEST_ROUTE}/0", [0]),
|
||||||
|
(f"{TEST_ROUTE}/5", [5]),
|
||||||
|
(f"{TEST_ROUTE}/0:10", ALL_SEGS[0:10]),
|
||||||
|
(f"{TEST_ROUTE}/0:0", []),
|
||||||
|
(f"{TEST_ROUTE}/4:6", ALL_SEGS[4:6]),
|
||||||
|
(f"{TEST_ROUTE}/0:-1", ALL_SEGS[0:-1]),
|
||||||
|
(f"{TEST_ROUTE}/:5", ALL_SEGS[:5]),
|
||||||
|
(f"{TEST_ROUTE}/2:", ALL_SEGS[2:]),
|
||||||
|
(f"{TEST_ROUTE}/2:-1", ALL_SEGS[2:-1]),
|
||||||
|
(f"{TEST_ROUTE}/-1", [ALL_SEGS[-1]]),
|
||||||
|
(f"{TEST_ROUTE}/-2", [ALL_SEGS[-2]]),
|
||||||
|
(f"{TEST_ROUTE}/-2:-1", ALL_SEGS[-2:-1]),
|
||||||
|
(f"{TEST_ROUTE}/-4:-2", ALL_SEGS[-4:-2]),
|
||||||
|
(f"{TEST_ROUTE}/:10:2", ALL_SEGS[:10:2]),
|
||||||
|
(f"{TEST_ROUTE}/5::2", ALL_SEGS[5::2]),
|
||||||
|
(f"https://useradmin.comma.ai/?onebox={TEST_ROUTE}", ALL_SEGS),
|
||||||
|
(f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '|')}", ALL_SEGS),
|
||||||
|
(f"https://useradmin.comma.ai/?onebox={TEST_ROUTE.replace('/', '%7C')}", ALL_SEGS),
|
||||||
|
(f"https://cabana.comma.ai/?route={TEST_ROUTE}", ALL_SEGS),
|
||||||
|
(f"cd:/{TEST_ROUTE}", ALL_SEGS),
|
||||||
|
])
|
||||||
|
def test_indirect_parsing(self, identifier, expected):
|
||||||
|
parsed, _, _ = parse_indirect(identifier)
|
||||||
|
sr = SegmentRange(parsed)
|
||||||
|
segs = parse_slice(sr)
|
||||||
|
self.assertListEqual(list(segs), expected)
|
||||||
|
|
||||||
|
def test_direct_parsing(self):
|
||||||
|
qlog = tempfile.NamedTemporaryFile(mode='wb', delete=False)
|
||||||
|
|
||||||
|
with requests.get(QLOG_FILE, stream=True) as r:
|
||||||
|
with qlog as f:
|
||||||
|
shutil.copyfileobj(r.raw, f)
|
||||||
|
|
||||||
|
for f in [QLOG_FILE, qlog.name]:
|
||||||
|
l = len(list(SegmentRangeReader(f)))
|
||||||
|
self.assertGreater(l, 100)
|
||||||
|
|
||||||
|
@parameterized.expand([
|
||||||
|
(f"{TEST_ROUTE}///",),
|
||||||
|
(f"{TEST_ROUTE}---",),
|
||||||
|
(f"{TEST_ROUTE}/-4:--2",),
|
||||||
|
(f"{TEST_ROUTE}/-a",),
|
||||||
|
(f"{TEST_ROUTE}/j",),
|
||||||
|
(f"{TEST_ROUTE}/0:1:2:3",),
|
||||||
|
(f"{TEST_ROUTE}/:::3",),
|
||||||
|
])
|
||||||
|
def test_bad_ranges(self, segment_range):
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
sr = SegmentRange(segment_range)
|
||||||
|
parse_slice(sr)
|
||||||
|
|
||||||
|
def test_modes(self):
|
||||||
|
qlog_len = len(list(SegmentRangeReader(f"{TEST_ROUTE}/0", ReadMode.QLOG)))
|
||||||
|
rlog_len = len(list(SegmentRangeReader(f"{TEST_ROUTE}/0", ReadMode.RLOG)))
|
||||||
|
|
||||||
|
self.assertLess(qlog_len * 6, rlog_len)
|
||||||
|
|
||||||
|
def test_modes_from_name(self):
|
||||||
|
qlog_len = len(list(SegmentRangeReader(f"{TEST_ROUTE}/0/q")))
|
||||||
|
rlog_len = len(list(SegmentRangeReader(f"{TEST_ROUTE}/0/r")))
|
||||||
|
|
||||||
|
self.assertLess(qlog_len * 6, rlog_len)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import socket
|
|
||||||
import time
|
import time
|
||||||
|
import threading
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from urllib3 import PoolManager, Retry
|
from urllib3 import PoolManager
|
||||||
from urllib3.response import BaseHTTPResponse
|
|
||||||
from urllib3.util import Timeout
|
from urllib3.util import Timeout
|
||||||
|
from tenacity import retry, wait_random_exponential, stop_after_attempt
|
||||||
|
|
||||||
from openpilot.common.file_helpers import atomic_write_in_dir
|
from openpilot.common.file_helpers import atomic_write_in_dir
|
||||||
from openpilot.system.hardware.hw import Paths
|
from openpilot.system.hardware.hw import Paths
|
||||||
@@ -13,9 +12,8 @@ from openpilot.system.hardware.hw import Paths
|
|||||||
K = 1000
|
K = 1000
|
||||||
CHUNK_SIZE = 1000 * K
|
CHUNK_SIZE = 1000 * K
|
||||||
|
|
||||||
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
||||||
|
|
||||||
def hash_256(link: str) -> str:
|
def hash_256(link):
|
||||||
hsh = str(sha256((link.split("?")[0]).encode('utf-8')).hexdigest())
|
hsh = str(sha256((link.split("?")[0]).encode('utf-8')).hexdigest())
|
||||||
return hsh
|
return hsh
|
||||||
|
|
||||||
@@ -25,25 +23,13 @@ class URLFileException(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class URLFile:
|
class URLFile:
|
||||||
_pool_manager: PoolManager|None = None
|
_tlocal = threading.local()
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, url, debug=False, cache=None):
|
||||||
def reset() -> None:
|
|
||||||
URLFile._pool_manager = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def pool_manager() -> PoolManager:
|
|
||||||
if URLFile._pool_manager is None:
|
|
||||||
socket_options = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),]
|
|
||||||
retries = Retry(total=5, backoff_factor=0.5, status_forcelist=[409, 429, 503, 504])
|
|
||||||
URLFile._pool_manager = PoolManager(num_pools=10, maxsize=100, socket_options=socket_options, retries=retries)
|
|
||||||
return URLFile._pool_manager
|
|
||||||
|
|
||||||
def __init__(self, url: str, timeout: int=10, debug: bool=False, cache: bool|None=None):
|
|
||||||
self._url = url
|
self._url = url
|
||||||
self._timeout = Timeout(connect=timeout, read=timeout)
|
|
||||||
self._pos = 0
|
self._pos = 0
|
||||||
self._length: int|None = None
|
self._length = None
|
||||||
|
self._local_file = None
|
||||||
self._debug = debug
|
self._debug = debug
|
||||||
# True by default, false if FILEREADER_CACHE is defined, but can be overwritten by the cache input
|
# True by default, false if FILEREADER_CACHE is defined, but can be overwritten by the cache input
|
||||||
self._force_download = not int(os.environ.get("FILEREADER_CACHE", "0"))
|
self._force_download = not int(os.environ.get("FILEREADER_CACHE", "0"))
|
||||||
@@ -53,23 +39,30 @@ class URLFile:
|
|||||||
if not self._force_download:
|
if not self._force_download:
|
||||||
os.makedirs(Paths.download_cache_root(), exist_ok=True)
|
os.makedirs(Paths.download_cache_root(), exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._http_client = URLFile._tlocal.http_client
|
||||||
|
except AttributeError:
|
||||||
|
self._http_client = URLFile._tlocal.http_client = PoolManager()
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
pass
|
if self._local_file is not None:
|
||||||
|
os.remove(self._local_file.name)
|
||||||
|
self._local_file.close()
|
||||||
|
self._local_file = None
|
||||||
|
|
||||||
def _request(self, method: str, url: str, headers: dict[str, str]|None=None) -> BaseHTTPResponse:
|
@retry(wait=wait_random_exponential(multiplier=1, max=5), stop=stop_after_attempt(3), reraise=True)
|
||||||
return URLFile.pool_manager().request(method, url, timeout=self._timeout, headers=headers)
|
def get_length_online(self):
|
||||||
|
timeout = Timeout(connect=50.0, read=500.0)
|
||||||
def get_length_online(self) -> int:
|
response = self._http_client.request('HEAD', self._url, timeout=timeout, preload_content=False)
|
||||||
response = self._request('HEAD', self._url)
|
|
||||||
if not (200 <= response.status <= 299):
|
if not (200 <= response.status <= 299):
|
||||||
return -1
|
return -1
|
||||||
length = response.headers.get('content-length', 0)
|
length = response.headers.get('content-length', 0)
|
||||||
return int(length)
|
return int(length)
|
||||||
|
|
||||||
def get_length(self) -> int:
|
def get_length(self):
|
||||||
if self._length is not None:
|
if self._length is not None:
|
||||||
return self._length
|
return self._length
|
||||||
|
|
||||||
@@ -86,7 +79,7 @@ class URLFile:
|
|||||||
file_length.write(str(self._length))
|
file_length.write(str(self._length))
|
||||||
return self._length
|
return self._length
|
||||||
|
|
||||||
def read(self, ll: int|None=None) -> bytes:
|
def read(self, ll=None):
|
||||||
if self._force_download:
|
if self._force_download:
|
||||||
return self.read_aux(ll=ll)
|
return self.read_aux(ll=ll)
|
||||||
|
|
||||||
@@ -118,9 +111,10 @@ class URLFile:
|
|||||||
self._pos = file_end
|
self._pos = file_end
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def read_aux(self, ll: int|None=None) -> bytes:
|
@retry(wait=wait_random_exponential(multiplier=1, max=5), stop=stop_after_attempt(3), reraise=True)
|
||||||
|
def read_aux(self, ll=None):
|
||||||
download_range = False
|
download_range = False
|
||||||
headers = {}
|
headers = {'Connection': 'keep-alive'}
|
||||||
if self._pos != 0 or ll is not None:
|
if self._pos != 0 or ll is not None:
|
||||||
if ll is None:
|
if ll is None:
|
||||||
end = self.get_length() - 1
|
end = self.get_length() - 1
|
||||||
@@ -134,7 +128,8 @@ class URLFile:
|
|||||||
if self._debug:
|
if self._debug:
|
||||||
t1 = time.time()
|
t1 = time.time()
|
||||||
|
|
||||||
response = self._request('GET', self._url, headers=headers)
|
timeout = Timeout(connect=50.0, read=500.0)
|
||||||
|
response = self._http_client.request('GET', self._url, timeout=timeout, preload_content=False, headers=headers)
|
||||||
ret = response.data
|
ret = response.data
|
||||||
|
|
||||||
if self._debug:
|
if self._debug:
|
||||||
@@ -153,12 +148,9 @@ class URLFile:
|
|||||||
self._pos += len(ret)
|
self._pos += len(ret)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
def seek(self, pos:int) -> None:
|
def seek(self, pos):
|
||||||
self._pos = pos
|
self._pos = pos
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self):
|
||||||
return self._url
|
return self._url
|
||||||
|
|
||||||
|
|
||||||
os.register_at_fork(after_in_child=URLFile.reset)
|
|
||||||
|
|||||||
115
tools/mac_setup.sh
Executable file
115
tools/mac_setup.sh
Executable file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ -z "$SKIP_PROMPT" ]; then
|
||||||
|
echo "--------------- macOS support ---------------"
|
||||||
|
echo "Running openpilot natively on macOS is not officially supported."
|
||||||
|
echo "It might build, some parts of it might work, but it's not fully tested, so there might be some issues."
|
||||||
|
echo
|
||||||
|
echo "Check out devcontainers for a seamless experience (see tools/README.md)."
|
||||||
|
echo "-------------------------------------------------"
|
||||||
|
echo -n "Are you sure you want to continue? [y/N] "
|
||||||
|
read -r response
|
||||||
|
if [[ ! "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||||
|
ROOT="$(cd $DIR/../ && pwd)"
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
|
||||||
|
if [[ $SHELL == "/bin/zsh" ]]; then
|
||||||
|
RC_FILE="$HOME/.zshrc"
|
||||||
|
elif [[ $SHELL == "/bin/bash" ]]; then
|
||||||
|
RC_FILE="$HOME/.bash_profile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install brew if required
|
||||||
|
if [[ $(command -v brew) == "" ]]; then
|
||||||
|
echo "Installing Hombrew"
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install.sh)"
|
||||||
|
echo "[ ] installed brew t=$SECONDS"
|
||||||
|
|
||||||
|
# make brew available now
|
||||||
|
if [[ $ARCH == "x86_64" ]]; then
|
||||||
|
echo 'eval "$(/usr/local/homebrew/bin/brew shellenv)"' >> $RC_FILE
|
||||||
|
eval "$(/usr/local/homebrew/bin/brew shellenv)"
|
||||||
|
else
|
||||||
|
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> $RC_FILE
|
||||||
|
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
brew bundle --file=- <<-EOS
|
||||||
|
brew "catch2"
|
||||||
|
brew "cmake"
|
||||||
|
brew "cppcheck"
|
||||||
|
brew "git-lfs"
|
||||||
|
brew "zlib"
|
||||||
|
brew "bzip2"
|
||||||
|
brew "capnp"
|
||||||
|
brew "coreutils"
|
||||||
|
brew "eigen"
|
||||||
|
brew "ffmpeg"
|
||||||
|
brew "glfw"
|
||||||
|
brew "libarchive"
|
||||||
|
brew "libusb"
|
||||||
|
brew "libtool"
|
||||||
|
brew "llvm"
|
||||||
|
brew "openssl@3.0"
|
||||||
|
brew "pyenv"
|
||||||
|
brew "pyenv-virtualenv"
|
||||||
|
brew "qt@5"
|
||||||
|
brew "zeromq"
|
||||||
|
brew "gcc@13"
|
||||||
|
cask "gcc-arm-embedded"
|
||||||
|
brew "portaudio"
|
||||||
|
EOS
|
||||||
|
|
||||||
|
echo "[ ] finished brew install t=$SECONDS"
|
||||||
|
|
||||||
|
BREW_PREFIX=$(brew --prefix)
|
||||||
|
|
||||||
|
# archive backend tools for pip dependencies
|
||||||
|
export LDFLAGS="$LDFLAGS -L${BREW_PREFIX}/opt/zlib/lib"
|
||||||
|
export LDFLAGS="$LDFLAGS -L${BREW_PREFIX}/opt/bzip2/lib"
|
||||||
|
export CPPFLAGS="$CPPFLAGS -I${BREW_PREFIX}/opt/zlib/include"
|
||||||
|
export CPPFLAGS="$CPPFLAGS -I${BREW_PREFIX}/opt/bzip2/include"
|
||||||
|
|
||||||
|
# pycurl curl/openssl backend dependencies
|
||||||
|
export LDFLAGS="$LDFLAGS -L${BREW_PREFIX}/opt/openssl@3/lib"
|
||||||
|
export CPPFLAGS="$CPPFLAGS -I${BREW_PREFIX}/opt/openssl@3/include"
|
||||||
|
export PYCURL_CURL_CONFIG=/usr/bin/curl-config
|
||||||
|
export PYCURL_SSL_LIBRARY=openssl
|
||||||
|
|
||||||
|
# install python dependencies
|
||||||
|
$DIR/install_python_dependencies.sh
|
||||||
|
echo "[ ] installed python dependencies t=$SECONDS"
|
||||||
|
|
||||||
|
# brew does not link qt5 by default
|
||||||
|
# check if qt5 can be linked, if not, prompt the user to link it
|
||||||
|
QT_BIN_LOCATION="$(command -v lupdate || :)"
|
||||||
|
if [ -n "$QT_BIN_LOCATION" ]; then
|
||||||
|
# if qt6 is linked, prompt the user to unlink it and link the right version
|
||||||
|
QT_BIN_VERSION="$(lupdate -version | awk '{print $NF}')"
|
||||||
|
if [[ ! "$QT_BIN_VERSION" =~ 5\.[0-9]+\.[0-9]+ ]]; then
|
||||||
|
echo
|
||||||
|
echo "lupdate/lrelease available at PATH is $QT_BIN_VERSION"
|
||||||
|
if [[ "$QT_BIN_LOCATION" == "$(brew --prefix)/"* ]]; then
|
||||||
|
echo "Run the following command to link qt5:"
|
||||||
|
echo "brew unlink qt@6 && brew link qt@5"
|
||||||
|
else
|
||||||
|
echo "Remove conflicting qt entries from PATH and run the following command to link qt5:"
|
||||||
|
echo "brew link qt@5"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
brew link qt@5
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "---- OPENPILOT SETUP DONE ----"
|
||||||
|
echo "Open a new shell or configure your active shell env by running:"
|
||||||
|
echo "source $RC_FILE"
|
||||||
3
tools/plotjuggler/.gitignore
vendored
Executable file
3
tools/plotjuggler/.gitignore
vendored
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
bin/
|
||||||
|
bin
|
||||||
|
*.rlog
|
||||||
74
tools/plotjuggler/README.md
Executable file
74
tools/plotjuggler/README.md
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
# PlotJuggler
|
||||||
|
|
||||||
|
[PlotJuggler](https://github.com/facontidavide/PlotJuggler) is a tool to quickly visualize time series data, and we've written plugins to parse openpilot logs. Check out our plugins: https://github.com/commaai/PlotJuggler.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Once you've [set up the openpilot environment](../README.md), this command will download PlotJuggler and install our plugins:
|
||||||
|
|
||||||
|
`cd tools/plotjuggler && ./juggle.py --install`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ./juggle.py -h
|
||||||
|
usage: juggle.py [-h] [--demo] [--qlog] [--ci] [--can] [--stream] [--layout [LAYOUT]] [--install] [--dbc DBC]
|
||||||
|
[route_or_segment_name] [segment_count]
|
||||||
|
|
||||||
|
A helper to run PlotJuggler on openpilot routes
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
route_or_segment_name
|
||||||
|
The route or segment name to plot (cabana share URL accepted) (default: None)
|
||||||
|
segment_count The number of segments to plot (default: None)
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
--demo Use the demo route instead of providing one (default: False)
|
||||||
|
--qlog Use qlogs (default: False)
|
||||||
|
--ci Download data from openpilot CI bucket (default: False)
|
||||||
|
--can Parse CAN data (default: False)
|
||||||
|
--stream Start PlotJuggler in streaming mode (default: False)
|
||||||
|
--layout [LAYOUT] Run PlotJuggler with a pre-defined layout (default: None)
|
||||||
|
--install Install or update PlotJuggler + plugins (default: False)
|
||||||
|
--dbc DBC Set the DBC name to load for parsing CAN data. If not set, the DBC will be automatically
|
||||||
|
inferred from the logs. (default: None)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples using route name:
|
||||||
|
|
||||||
|
`./juggle.py "a2a0ccea32023010|2023-07-27--13-01-19"`
|
||||||
|
|
||||||
|
Examples using segment name:
|
||||||
|
|
||||||
|
`./juggle.py "a2a0ccea32023010|2023-07-27--13-01-19--1"`
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
|
||||||
|
Explore live data from your car! Follow these steps to stream from your comma device to your laptop:
|
||||||
|
- Enable wifi tethering on your comma device
|
||||||
|
- [SSH into your device](https://github.com/commaai/openpilot/wiki/SSH) and run `cd /data/openpilot && ./cereal/messaging/bridge`
|
||||||
|
- On your laptop, connect to the device's wifi hotspot
|
||||||
|
- Start PlotJuggler with `ZMQ=1 ./juggle.py --stream`, find the `Cereal Subscriber` plugin in the dropdown under Streaming, and click `Start`.
|
||||||
|
|
||||||
|
If streaming to PlotJuggler from a replay on your PC, simply run: `./juggle.py --stream` and start the cereal subscriber.
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
For a quick demo, go through the installation step and run this command:
|
||||||
|
|
||||||
|
`./juggle.py --demo --qlog --layout=layouts/demo.xml`
|
||||||
|
|
||||||
|
## Layouts
|
||||||
|
|
||||||
|
If you create a layout that's useful for others, consider upstreaming it.
|
||||||
|
|
||||||
|
### Tuning
|
||||||
|
|
||||||
|
Use this layout to improve your car's tuning and generate plots for tuning PRs. Also see the [tuning wiki](https://github.com/commaai/openpilot/wiki/Tuning) and tuning PR template.
|
||||||
|
|
||||||
|
`--layout layouts/tuning.xml`
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
180
tools/plotjuggler/juggle.py
Executable file
180
tools/plotjuggler/juggle.py
Executable file
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import multiprocessing
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import requests
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from openpilot.common.basedir import BASEDIR
|
||||||
|
from openpilot.selfdrive.test.openpilotci import get_url
|
||||||
|
from openpilot.tools.lib.logreader import LogReader
|
||||||
|
from openpilot.tools.lib.route import Route, SegmentName
|
||||||
|
from openpilot.tools.lib.helpers import save_log
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
juggle_dir = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
|
||||||
|
DEMO_ROUTE = "a2a0ccea32023010|2023-07-27--13-01-19"
|
||||||
|
RELEASES_URL="https://github.com/commaai/PlotJuggler/releases/download/latest"
|
||||||
|
INSTALL_DIR = os.path.join(juggle_dir, "bin")
|
||||||
|
PLOTJUGGLER_BIN = os.path.join(juggle_dir, "bin/plotjuggler")
|
||||||
|
MINIMUM_PLOTJUGGLER_VERSION = (3, 5, 2)
|
||||||
|
MAX_STREAMING_BUFFER_SIZE = 1000
|
||||||
|
|
||||||
|
def install():
|
||||||
|
m = f"{platform.system()}-{platform.machine()}"
|
||||||
|
supported = ("Linux-x86_64", "Darwin-arm64", "Darwin-x86_64")
|
||||||
|
if m not in supported:
|
||||||
|
raise Exception(f"Unsupported platform: '{m}'. Supported platforms: {supported}")
|
||||||
|
|
||||||
|
if os.path.exists(INSTALL_DIR):
|
||||||
|
shutil.rmtree(INSTALL_DIR)
|
||||||
|
os.mkdir(INSTALL_DIR)
|
||||||
|
|
||||||
|
url = os.path.join(RELEASES_URL, m + ".tar.gz")
|
||||||
|
with requests.get(url, stream=True, timeout=10) as r, tempfile.NamedTemporaryFile() as tmp:
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(tmp.name, 'wb') as tmpf:
|
||||||
|
for chunk in r.iter_content(chunk_size=1024*1024):
|
||||||
|
tmpf.write(chunk)
|
||||||
|
|
||||||
|
with tarfile.open(tmp.name) as tar:
|
||||||
|
tar.extractall(path=INSTALL_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def get_plotjuggler_version():
|
||||||
|
out = subprocess.check_output([PLOTJUGGLER_BIN, "-v"], encoding="utf-8").strip()
|
||||||
|
version = out.split(" ")[1]
|
||||||
|
return tuple(map(int, version.split(".")))
|
||||||
|
|
||||||
|
|
||||||
|
def load_segment(segment_name):
|
||||||
|
if segment_name is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
return list(LogReader(segment_name))
|
||||||
|
except (AssertionError, ValueError) as e:
|
||||||
|
print(f"Error parsing {segment_name}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def start_juggler(fn=None, dbc=None, layout=None, route_or_segment_name=None):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["BASEDIR"] = BASEDIR
|
||||||
|
env["PATH"] = f"{INSTALL_DIR}:{os.getenv('PATH', '')}"
|
||||||
|
if dbc:
|
||||||
|
env["DBC_NAME"] = dbc
|
||||||
|
|
||||||
|
extra_args = ""
|
||||||
|
if fn is not None:
|
||||||
|
extra_args += f" -d {fn}"
|
||||||
|
if layout is not None:
|
||||||
|
extra_args += f" -l {layout}"
|
||||||
|
if route_or_segment_name is not None:
|
||||||
|
extra_args += f" --window_title \"{route_or_segment_name}\""
|
||||||
|
|
||||||
|
cmd = f'{PLOTJUGGLER_BIN} --buffer_size {MAX_STREAMING_BUFFER_SIZE} --plugin_folders {INSTALL_DIR}{extra_args}'
|
||||||
|
subprocess.call(cmd, shell=True, env=env, cwd=juggle_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def juggle_route(route_or_segment_name, segment_count, qlog, can, layout, dbc=None, ci=False):
|
||||||
|
segment_start = 0
|
||||||
|
if 'cabana' in route_or_segment_name:
|
||||||
|
query = parse_qs(urlparse(route_or_segment_name).query)
|
||||||
|
route_or_segment_name = query["route"][0]
|
||||||
|
|
||||||
|
if route_or_segment_name.startswith(("http://", "https://", "cd:/")) or os.path.isfile(route_or_segment_name):
|
||||||
|
logs = [route_or_segment_name]
|
||||||
|
elif ci:
|
||||||
|
route_or_segment_name = SegmentName(route_or_segment_name, allow_route_name=True)
|
||||||
|
route = route_or_segment_name.route_name.canonical_name
|
||||||
|
segment_start = max(route_or_segment_name.segment_num, 0)
|
||||||
|
logs = [get_url(route, i) for i in range(100)] # Assume there not more than 100 segments
|
||||||
|
else:
|
||||||
|
route_or_segment_name = SegmentName(route_or_segment_name, allow_route_name=True)
|
||||||
|
segment_start = max(route_or_segment_name.segment_num, 0)
|
||||||
|
|
||||||
|
if route_or_segment_name.segment_num != -1 and segment_count is None:
|
||||||
|
segment_count = 1
|
||||||
|
|
||||||
|
r = Route(route_or_segment_name.route_name.canonical_name, route_or_segment_name.data_dir)
|
||||||
|
logs = r.qlog_paths() if qlog else r.log_paths()
|
||||||
|
|
||||||
|
segment_end = segment_start + segment_count if segment_count else None
|
||||||
|
logs = logs[segment_start:segment_end]
|
||||||
|
|
||||||
|
if None in logs:
|
||||||
|
resp = input(f"{logs.count(None)}/{len(logs)} of the rlogs in this segment are missing, would you like to fall back to the qlogs? (y/n) ")
|
||||||
|
if resp == 'y':
|
||||||
|
logs = r.qlog_paths()[segment_start:segment_end]
|
||||||
|
else:
|
||||||
|
print("Please try a different route or segment")
|
||||||
|
return
|
||||||
|
|
||||||
|
all_data = []
|
||||||
|
with multiprocessing.Pool(24) as pool:
|
||||||
|
for d in pool.map(load_segment, logs):
|
||||||
|
all_data += d
|
||||||
|
|
||||||
|
if not can:
|
||||||
|
all_data = [d for d in all_data if d.which() not in ['can', 'sendcan']]
|
||||||
|
|
||||||
|
# Infer DBC name from logs
|
||||||
|
if dbc is None:
|
||||||
|
for cp in [m for m in all_data if m.which() == 'carParams']:
|
||||||
|
try:
|
||||||
|
DBC = __import__(f"openpilot.selfdrive.car.{cp.carParams.carName}.values", fromlist=['DBC']).DBC
|
||||||
|
dbc = DBC[cp.carParams.carFingerprint]['pt']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix='.rlog', dir=juggle_dir) as tmp:
|
||||||
|
save_log(tmp.name, all_data, compress=False)
|
||||||
|
del all_data
|
||||||
|
start_juggler(tmp.name, dbc, layout, route_or_segment_name)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="A helper to run PlotJuggler on openpilot routes",
|
||||||
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||||
|
|
||||||
|
parser.add_argument("--demo", action="store_true", help="Use the demo route instead of providing one")
|
||||||
|
parser.add_argument("--qlog", action="store_true", help="Use qlogs")
|
||||||
|
parser.add_argument("--ci", action="store_true", help="Download data from openpilot CI bucket")
|
||||||
|
parser.add_argument("--can", action="store_true", help="Parse CAN data")
|
||||||
|
parser.add_argument("--stream", action="store_true", help="Start PlotJuggler in streaming mode")
|
||||||
|
parser.add_argument("--layout", nargs='?', help="Run PlotJuggler with a pre-defined layout")
|
||||||
|
parser.add_argument("--install", action="store_true", help="Install or update PlotJuggler + plugins")
|
||||||
|
parser.add_argument("--dbc", help="Set the DBC name to load for parsing CAN data. If not set, the DBC will be automatically inferred from the logs.")
|
||||||
|
parser.add_argument("route_or_segment_name", nargs='?', help="The route or segment name to plot (cabana share URL accepted)")
|
||||||
|
parser.add_argument("segment_count", type=int, nargs='?', help="The number of segments to plot")
|
||||||
|
|
||||||
|
if len(sys.argv) == 1:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.install:
|
||||||
|
install()
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
if not os.path.exists(PLOTJUGGLER_BIN):
|
||||||
|
print("PlotJuggler is missing. Downloading...")
|
||||||
|
install()
|
||||||
|
|
||||||
|
if get_plotjuggler_version() < MINIMUM_PLOTJUGGLER_VERSION:
|
||||||
|
print("PlotJuggler is out of date. Installing update...")
|
||||||
|
install()
|
||||||
|
|
||||||
|
if args.stream:
|
||||||
|
start_juggler(layout=args.layout)
|
||||||
|
else:
|
||||||
|
route_or_segment_name = DEMO_ROUTE if args.demo else args.route_or_segment_name.strip()
|
||||||
|
juggle_route(route_or_segment_name, args.segment_count, args.qlog, args.can, args.layout, args.dbc, args.ci)
|
||||||
86
tools/plotjuggler/layouts/CAN-bus-debug.xml
Executable file
86
tools/plotjuggler/layouts/CAN-bus-debug.xml
Executable file
@@ -0,0 +1,86 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget name="Main Window" parent="main_window">
|
||||||
|
<Tab containers="1" tab_name="tab1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter sizes="0.33362;0.33276;0.33362" count="3" orientation="-">
|
||||||
|
<DockArea name="CAN RX">
|
||||||
|
<plot style="Lines" flip_y="false" mode="TimeSeries" flip_x="false">
|
||||||
|
<range top="1101.875000" left="0.000000" bottom="-26.875000" right="60.526742"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/pandaStates/0/canState0/totalRxCnt" color="#f14cc1">
|
||||||
|
<transform alias="/pandaStates/0/canState0/totalRxCnt[Derivative]" name="Derivative">
|
||||||
|
<options radioChecked="radioCustom" lineEdit="1.0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve name="/pandaStates/0/canState1/totalRxCnt" color="#9467bd">
|
||||||
|
<transform alias="/pandaStates/0/canState1/totalRxCnt[Derivative]" name="Derivative">
|
||||||
|
<options radioChecked="radioCustom" lineEdit="1.0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve name="/pandaStates/0/canState2/totalRxCnt" color="#ff7f0e">
|
||||||
|
<transform alias="/pandaStates/0/canState2/totalRxCnt[Derivative]" name="Derivative">
|
||||||
|
<options radioChecked="radioCustom" lineEdit="1.0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="CAN TX">
|
||||||
|
<plot style="Lines" flip_y="false" mode="TimeSeries" flip_x="false">
|
||||||
|
<range top="455.100000" left="0.000000" bottom="-11.100000" right="60.526742"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/pandaStates/0/canState0/totalTxCnt" color="#17becf">
|
||||||
|
<transform alias="/pandaStates/0/canState0/totalTxCnt[Derivative]" name="Derivative">
|
||||||
|
<options radioChecked="radioCustom" lineEdit="1.0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve name="/pandaStates/0/canState1/totalTxCnt" color="#bcbd22">
|
||||||
|
<transform alias="/pandaStates/0/canState1/totalTxCnt[Derivative]" name="Derivative">
|
||||||
|
<options radioChecked="radioCustom" lineEdit="1.0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve name="/pandaStates/0/canState2/totalTxCnt" color="#1f77b4">
|
||||||
|
<transform alias="/pandaStates/0/canState2/totalTxCnt[Derivative]" name="Derivative">
|
||||||
|
<options radioChecked="radioCustom" lineEdit="1.0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="CAN errors">
|
||||||
|
<plot style="Lines" flip_y="false" mode="TimeSeries" flip_x="false">
|
||||||
|
<range top="2515.350000" left="0.000000" bottom="-61.350000" right="60.526742"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/pandaStates/0/canState0/totalErrorCnt" color="#1f77b4">
|
||||||
|
<transform alias="/pandaStates/0/canState0/totalErrorCnt[Derivative]" name="Derivative">
|
||||||
|
<options radioChecked="radioCustom" lineEdit="1.0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve name="/pandaStates/0/canState1/totalErrorCnt" color="#d62728">
|
||||||
|
<transform alias="/pandaStates/0/canState1/totalErrorCnt[Derivative]" name="Derivative">
|
||||||
|
<options radioChecked="radioCustom" lineEdit="1.0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve name="/pandaStates/0/canState2/totalErrorCnt" color="#1ac938">
|
||||||
|
<transform alias="/pandaStates/0/canState2/totalErrorCnt[Derivative]" name="Derivative">
|
||||||
|
<options radioChecked="radioCustom" lineEdit="1.0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<customMathEquations/>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
148
tools/plotjuggler/layouts/camera-timings.xml
Executable file
148
tools/plotjuggler/layouts/camera-timings.xml
Executable file
@@ -0,0 +1,148 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget name="Main Window" parent="main_window">
|
||||||
|
<Tab tab_name="SOF / EOF (encodeIdx)" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" sizes="0.500885;0.499115" count="2">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_x="false" mode="TimeSeries" flip_y="false" style="Lines">
|
||||||
|
<range bottom="35000000.000000" left="0.000000" top="65000000.000000" right="630.006367"/>
|
||||||
|
<limitY max="6.5e+07" min="3.5e+07"/>
|
||||||
|
<curve color="#1f77b4" name="/driverEncodeIdx/timestampSof">
|
||||||
|
<transform name="Derivative" alias="/driverEncodeIdx/timestampSof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve color="#d62728" name="/roadEncodeIdx/timestampSof">
|
||||||
|
<transform name="Derivative" alias="/roadEncodeIdx/timestampSof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve color="#1ac938" name="/wideRoadEncodeIdx/timestampSof">
|
||||||
|
<transform name="Derivative" alias="/wideRoadEncodeIdx/timestampSof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_x="false" mode="TimeSeries" flip_y="false" style="Lines">
|
||||||
|
<range bottom="35000000.000000" left="0.000000" top="65000000.000000" right="630.006367"/>
|
||||||
|
<limitY max="6.5e+07" min="3.5e+07"/>
|
||||||
|
<curve color="#f14cc1" name="/driverEncodeIdx/timestampEof">
|
||||||
|
<transform name="Derivative" alias="/driverEncodeIdx/timestampEof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve color="#9467bd" name="/roadEncodeIdx/timestampEof">
|
||||||
|
<transform name="Derivative" alias="/roadEncodeIdx/timestampEof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve color="#17becf" name="/wideRoadEncodeIdx/timestampEof">
|
||||||
|
<transform name="Derivative" alias="/wideRoadEncodeIdx/timestampEof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<Tab tab_name="model timings" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" sizes="0.5;0.5" count="2">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_x="false" mode="TimeSeries" flip_y="false" style="Lines">
|
||||||
|
<range bottom="0.015143" left="0.000000" top="0.016865" right="630.006367"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#ff7f0e" name="/modelV2/modelExecutionTime"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_x="false" mode="TimeSeries" flip_y="false" style="Lines">
|
||||||
|
<range bottom="-0.100000" left="0.000000" top="0.100000" right="630.006367"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#f14cc1" name="/modelV2/frameDropPerc"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<Tab tab_name="sensor info" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" sizes="1" count="1">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_x="false" mode="TimeSeries" flip_y="false" style="Lines">
|
||||||
|
<range bottom="-0.100000" left="0.000000" top="0.100000" right="630.006367"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#bcbd22" name="/driverCameraState/sensor"/>
|
||||||
|
<curve color="#1f77b4" name="/roadCameraState/sensor"/>
|
||||||
|
<curve color="#d62728" name="/wideRoadCameraState/sensor"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<Tab tab_name="SOF / EOF (cameraState)" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" sizes="0.500885;0.499115" count="2">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_x="false" mode="TimeSeries" flip_y="false" style="Lines">
|
||||||
|
<range bottom="35000000.000000" left="0.000000" top="65000000.000000" right="630.006367"/>
|
||||||
|
<limitY max="6.5e+07" min="3.5e+07"/>
|
||||||
|
<curve color="#1f77b4" name="/driverCameraState/timestampSof">
|
||||||
|
<transform name="Derivative" alias="/driverCameraState/timestampSof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve color="#d62728" name="/roadCameraState/timestampSof">
|
||||||
|
<transform name="Derivative" alias="/roadCameraState/timestampSof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve color="#1ac938" name="/wideRoadCameraState/timestampSof">
|
||||||
|
<transform name="Derivative" alias="/wideRoadCameraState/timestampSof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_x="false" mode="TimeSeries" flip_y="false" style="Lines">
|
||||||
|
<range bottom="35000000.000000" left="0.000000" top="65000000.000000" right="630.006367"/>
|
||||||
|
<limitY max="6.5e+07" min="3.5e+07"/>
|
||||||
|
<curve color="#ff7f0e" name="/driverCameraState/timestampEof">
|
||||||
|
<transform name="Derivative" alias="/driverCameraState/timestampEof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve color="#f14cc1" name="/roadCameraState/timestampEof">
|
||||||
|
<transform name="Derivative" alias="/roadCameraState/timestampEof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve color="#9467bd" name="/wideRoadCameraState/timestampEof">
|
||||||
|
<transform name="Derivative" alias="/wideRoadCameraState/timestampEof[Derivative]">
|
||||||
|
<options lineEdit="1.0" radioChecked="radioCustom"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<customMathEquations/>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
72
tools/plotjuggler/layouts/can-states.xml
Executable file
72
tools/plotjuggler/layouts/can-states.xml
Executable file
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget parent="main_window" name="Main Window">
|
||||||
|
<Tab containers="1" tab_name="tab1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter count="2" sizes="0.500381;0.499619" orientation="-">
|
||||||
|
<DockSplitter count="2" sizes="0.5;0.5" orientation="|">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" mode="TimeSeries" flip_y="false" flip_x="false">
|
||||||
|
<range right="632.799721" bottom="-17755.925000" top="771630.925000" left="0.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#1f77b4" name="/pandaStates/0/canState0/totalRxCnt"/>
|
||||||
|
<curve color="#d62728" name="/pandaStates/0/canState1/totalRxCnt"/>
|
||||||
|
<curve color="#1ac938" name="/pandaStates/0/canState2/totalRxCnt"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" mode="TimeSeries" flip_y="false" flip_x="false">
|
||||||
|
<range right="632.799721" bottom="-18545.500000" top="760365.500000" left="0.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#ff7f0e" name="/pandaStates/0/canState0/totalTxCnt"/>
|
||||||
|
<curve color="#f14cc1" name="/pandaStates/0/canState1/totalTxCnt"/>
|
||||||
|
<curve color="#9467bd" name="/pandaStates/0/canState2/totalTxCnt"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
<DockSplitter count="3" sizes="0.333333;0.333333;0.333333" orientation="|">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" mode="TimeSeries" flip_y="false" flip_x="false">
|
||||||
|
<range right="632.799721" bottom="-1.350000" top="55.350000" left="0.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#ff7f0e" name="/pandaStates/0/canState0/totalRxLostCnt"/>
|
||||||
|
<curve color="#f14cc1" name="/pandaStates/0/canState1/totalRxLostCnt"/>
|
||||||
|
<curve color="#9467bd" name="/pandaStates/0/canState2/totalRxLostCnt"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" mode="TimeSeries" flip_y="false" flip_x="false">
|
||||||
|
<range right="632.799721" bottom="-0.050000" top="2.050000" left="0.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#17becf" name="/pandaStates/0/canState0/totalTxLostCnt"/>
|
||||||
|
<curve color="#bcbd22" name="/pandaStates/0/canState1/totalTxLostCnt"/>
|
||||||
|
<curve color="#1f77b4" name="/pandaStates/0/canState2/totalTxLostCnt"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" mode="TimeSeries" flip_y="false" flip_x="false">
|
||||||
|
<range right="632.799721" bottom="-0.100000" top="0.100000" left="0.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#17becf" name="/pandaStates/0/canState0/busOffCnt"/>
|
||||||
|
<curve color="#1ac938" name="/pandaStates/0/canState1/busOffCnt"/>
|
||||||
|
<curve color="#bcbd22" name="/pandaStates/0/canState2/busOffCnt"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
60
tools/plotjuggler/layouts/controls_mismatch_debug.xml
Executable file
60
tools/plotjuggler/layouts/controls_mismatch_debug.xml
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget parent="main_window" name="Main Window">
|
||||||
|
<Tab tab_name="tab1" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" count="5" sizes="0.2;0.2;0.2;0.2;0.2">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" flip_x="false" mode="TimeSeries" flip_y="false">
|
||||||
|
<range top="1.025000" bottom="-0.025000" left="0.018309" right="59.674401"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#1f77b4" name="/controlsState/enabled"/>
|
||||||
|
<curve color="#d62728" name="/pandaStates/0/controlsAllowed"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" flip_x="false" mode="TimeSeries" flip_y="false">
|
||||||
|
<range top="27.087398" bottom="-0.905168" left="0.018309" right="59.674401"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#9467bd" name="/controlsState/cumLagMs"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" flip_x="false" mode="TimeSeries" flip_y="false">
|
||||||
|
<range top="1.025000" bottom="-0.025000" left="0.018309" right="59.674401"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#1f77b4" name="/pandaStates/0/safetyRxInvalid"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" flip_x="false" mode="TimeSeries" flip_y="false">
|
||||||
|
<range top="158.850000" bottom="-2.850000" left="0.018309" right="59.674401"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#d62728" name="/pandaStates/0/safetyTxBlocked"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" flip_x="false" mode="TimeSeries" flip_y="false">
|
||||||
|
<range top="1.025000" bottom="-0.025000" left="0.018309" right="59.674401"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#1ac938" name="/carState/gasPressed"/>
|
||||||
|
<curve color="#ff7f0e" name="/carState/brakePressed"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<customMathEquations/>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
104
tools/plotjuggler/layouts/demo.xml
Executable file
104
tools/plotjuggler/layouts/demo.xml
Executable file
@@ -0,0 +1,104 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root version="2.3.8">
|
||||||
|
<tabbed_widget parent="main_window" name="Main Window">
|
||||||
|
<Tab tab_name="tab1" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" sizes="1" count="1">
|
||||||
|
<DockSplitter orientation="|" sizes="0.5;0.5" count="2">
|
||||||
|
<DockSplitter orientation="-" sizes="0.500497;0.499503" count="2">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" style="Lines">
|
||||||
|
<range top="2.762667" bottom="-3.239397" right="56.512723" left="0.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#1f77b4" name="/carState/aEgo"/>
|
||||||
|
<curve color="#17becf" name="/carState/brake"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" style="Lines">
|
||||||
|
<range top="5.191867" bottom="-5.724069" right="56.512723" left="0.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#1ac938" name="dv/dt"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
<DockSplitter orientation="-" sizes="0.500497;0.499503" count="2">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" style="Lines">
|
||||||
|
<range top="16.065524" bottom="-0.470076" right="56.512723" left="0.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#d62728" name="/carState/vEgo"/>
|
||||||
|
<curve color="#bcbd22" name="/carState/gas"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" style="Lines">
|
||||||
|
<range top="1.014703" bottom="-0.012971" right="56.512723" left="0.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#ff7f0e" name="/model/meta/brakeDisengageProb"/>
|
||||||
|
<curve color="#f14cc1" name="/model/meta/engagedProb"/>
|
||||||
|
<curve color="#9467bd" name="/model/meta/steerOverrideProb"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</DockSplitter>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad CSV">
|
||||||
|
<default time_axis=""/>
|
||||||
|
</plugin>
|
||||||
|
<plugin ID="DataLoad ROS bags">
|
||||||
|
<use_header_stamp value="false"/>
|
||||||
|
<use_renaming_rules value="true"/>
|
||||||
|
<discard_large_arrays value="true"/>
|
||||||
|
<max_array_size value="100"/>
|
||||||
|
</plugin>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="DataLoad ULog"/>
|
||||||
|
<plugin ID="LSL Subscriber"/>
|
||||||
|
<plugin ID="MQTT Subscriber"/>
|
||||||
|
<plugin ID="ROS Topic Subscriber">
|
||||||
|
<use_header_stamp value="false"/>
|
||||||
|
<use_renaming_rules value="true"/>
|
||||||
|
<discard_large_arrays value="true"/>
|
||||||
|
<max_array_size value="100"/>
|
||||||
|
</plugin>
|
||||||
|
<plugin ID="UDP Server"/>
|
||||||
|
<plugin ID="WebSocket Server"/>
|
||||||
|
<plugin ID="ZMQ Subscriber"/>
|
||||||
|
<plugin status="idle" ID="ROS /rosout Visualization"/>
|
||||||
|
<plugin status="idle" ID="ROS Topic Re-Publisher"/>
|
||||||
|
</Plugins>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<customMathEquations>
|
||||||
|
<snippet name="dv/dt">
|
||||||
|
<global>prevX = 0
|
||||||
|
prevY = 0
|
||||||
|
is_first = true</global>
|
||||||
|
<function>if (is_first) then
|
||||||
|
is_first = false
|
||||||
|
prevX = time
|
||||||
|
prevY = value
|
||||||
|
end
|
||||||
|
|
||||||
|
dx = time - prevX
|
||||||
|
dy = value - prevY
|
||||||
|
prevX = time
|
||||||
|
prevY = value
|
||||||
|
|
||||||
|
return dy/dx</function>
|
||||||
|
<linkedSource>/carState/vEgo</linkedSource>
|
||||||
|
</snippet>
|
||||||
|
</customMathEquations>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
83
tools/plotjuggler/layouts/gps_vs_llk.xml
Executable file
83
tools/plotjuggler/layouts/gps_vs_llk.xml
Executable file
@@ -0,0 +1,83 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget name="Main Window" parent="main_window">
|
||||||
|
<Tab tab_name="tab1" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter count="3" sizes="0.333805;0.33239;0.333805" orientation="-">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" style="Lines" flip_y="false" flip_x="false">
|
||||||
|
<range bottom="0.368228" right="196.811937" left="76.646983" top="32.070386"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="haversine distance [m]" color="#1f77b4"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" style="Lines" flip_y="false" flip_x="false">
|
||||||
|
<range bottom="-0.259115" right="196.811937" left="76.646983" top="12.637299"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/carState/vEgo" color="#17becf"/>
|
||||||
|
<curve name="/gpsLocationExternal/speed" color="#bcbd22"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockSplitter count="2" sizes="0.500516;0.499484" orientation="|">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" style="Lines" flip_y="false" flip_x="false">
|
||||||
|
<range bottom="-0.100000" right="196.811937" left="76.646983" top="0.100000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/liveLocationKalman/positionGeodetic/std/0" color="#d62728"/>
|
||||||
|
<curve name="/liveLocationKalman/positionGeodetic/std/1" color="#1ac938"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" style="Lines" flip_y="false" flip_x="false">
|
||||||
|
<range bottom="-0.449385" right="196.811937" left="76.646983" top="7.160833"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/gpsLocationExternal/accuracy" color="#ff7f0e"/>
|
||||||
|
<curve name="/gpsLocationExternal/verticalAccuracy" color="#f14cc1"/>
|
||||||
|
<curve name="/gpsLocationExternal/speedAccuracy" color="#9467bd"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<customMathEquations>
|
||||||
|
<snippet name="haversine distance [m]">
|
||||||
|
<global>R = 6378.137 -- Radius of earth in KM</global>
|
||||||
|
<function>-- Compute the Haversine distance between
|
||||||
|
-- two points defined by latitude and longitude.
|
||||||
|
-- Return the distance in meters
|
||||||
|
lat1, lon1 = value, v1
|
||||||
|
lat2, lon2 = v2, v3
|
||||||
|
dLat = (lat2 - lat1) * math.pi / 180
|
||||||
|
dLon = (lon2 - lon1) * math.pi / 180
|
||||||
|
a = math.sin(dLat/2) * math.sin(dLat/2) +
|
||||||
|
math.cos(lat1 * math.pi / 180) * math.cos(lat2 * math.pi / 180) *
|
||||||
|
math.sin(dLon/2) * math.sin(dLon/2)
|
||||||
|
c = 2 * math.atan(math.sqrt(a), math.sqrt(1-a))
|
||||||
|
d = R * c
|
||||||
|
distance = d * 1000 -- meters
|
||||||
|
return distance</function>
|
||||||
|
<linked_source>/gpsLocationExternal/latitude</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/gpsLocationExternal/longitude</v1>
|
||||||
|
<v2>/liveLocationKalman/positionGeodetic/value/0</v2>
|
||||||
|
<v3>/liveLocationKalman/positionGeodetic/value/1</v3>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
</customMathEquations>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
59
tools/plotjuggler/layouts/longitudinal.xml
Executable file
59
tools/plotjuggler/layouts/longitudinal.xml
Executable file
@@ -0,0 +1,59 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget name="Main Window" parent="main_window">
|
||||||
|
<Tab tab_name="tab1" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" sizes="0.250401;0.249599;0.250401;0.249599" count="4">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" flip_x="false" mode="TimeSeries" style="Lines">
|
||||||
|
<range right="126.285782" top="1.391623" left="104.907277" bottom="-2.563614"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/carState/aEgo" color="#f14cc1"/>
|
||||||
|
<curve name="/longitudinalPlan/accels/0" color="#9467bd"/>
|
||||||
|
<curve name="/carControl/actuators/accel" color="#17becf"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" flip_x="false" mode="TimeSeries" style="Lines">
|
||||||
|
<range right="126.285782" top="1.184960" left="104.907277" bottom="-1.811222" />
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/controlsState/upAccelCmd" color="#1f77b4"/>
|
||||||
|
<curve name="/controlsState/uiAccelCmd" color="#d62728"/>
|
||||||
|
<curve name="/controlsState/ufAccelCmd" color="#1ac938"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" flip_x="false" mode="TimeSeries" style="Lines">
|
||||||
|
<range right="126.285782" top="15.862889" left="104.907277" bottom="-0.568809" />
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/carState/vEgo" color="#1ac938"/>
|
||||||
|
<curve name="/longitudinalPlan/speeds/0" color="#ff7f0e"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" flip_x="false" mode="TimeSeries" style="Lines">
|
||||||
|
<range right="126.285782" top="1.025000" left="104.907277" bottom="-0.025000" />
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/carControl/longActive" color="#1f77b4"/>
|
||||||
|
<curve name="/carState/gasPressed" color="#d62728"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<customMathEquations/>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
92
tools/plotjuggler/layouts/max-torque-debug.xml
Executable file
92
tools/plotjuggler/layouts/max-torque-debug.xml
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget name="Main Window" parent="main_window">
|
||||||
|
<Tab containers="1" tab_name="tab1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" count="4" sizes="0.249724;0.250829;0.249724;0.249724">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" flip_x="false" mode="TimeSeries">
|
||||||
|
<range left="0.000450" top="6.050533" right="2483.624998" bottom="-7.599037"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#1ac938" name="Actual lateral accel (roll compensated)"/>
|
||||||
|
<curve color="#ff7f0e" name="Desired lateral accel (roll compensated)"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" flip_x="false" mode="TimeSeries">
|
||||||
|
<range left="0.000450" top="5.384416" right="2483.624998" bottom="-7.503945"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#1ac938" name="roll compensated lateral acceleration"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" flip_x="false" mode="TimeSeries">
|
||||||
|
<range left="0.000450" top="1.050000" right="2483.624998" bottom="-1.050000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#0097ff" name="/carState/steeringPressed"/>
|
||||||
|
<curve color="#d62728" name="/carControl/actuatorsOutput/steer"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" flip_x="false" mode="TimeSeries">
|
||||||
|
<range left="0.000450" top="80.762969" right="2483.624998" bottom="-2.181837"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#f14cc1" name="/carState/vEgo">
|
||||||
|
<transform alias="/carState/vEgo[Scale/Offset]" name="Scale/Offset">
|
||||||
|
<options value_offset="0" time_offset="0" value_scale="2.23694"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<customMathEquations>
|
||||||
|
<snippet name="roll compensated lateral acceleration">
|
||||||
|
<global></global>
|
||||||
|
<function>if (v3 == 0 and v4 == 1) then
|
||||||
|
return (value * v1 ^ 2) - (v2 * 9.81)
|
||||||
|
end
|
||||||
|
return 0</function>
|
||||||
|
<linked_source>/controlsState/curvature</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/vEgo</v1>
|
||||||
|
<v2>/liveParameters/roll</v2>
|
||||||
|
<v3>/carState/steeringPressed</v3>
|
||||||
|
<v4>/carControl/latActive</v4>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="Desired lateral accel (roll compensated)">
|
||||||
|
<global></global>
|
||||||
|
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
|
||||||
|
<linked_source>/controlsState/desiredCurvature</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/vEgo</v1>
|
||||||
|
<v2>/liveParameters/roll</v2>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="Actual lateral accel (roll compensated)">
|
||||||
|
<global></global>
|
||||||
|
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
|
||||||
|
<linked_source>/controlsState/curvature</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/vEgo</v1>
|
||||||
|
<v2>/liveParameters/roll</v2>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
</customMathEquations>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
68
tools/plotjuggler/layouts/system_lag_debug.xml
Executable file
68
tools/plotjuggler/layouts/system_lag_debug.xml
Executable file
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget name="Main Window" parent="main_window">
|
||||||
|
<Tab tab_name="tab1" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" count="4" sizes="0.249729;0.250814;0.249729;0.249729">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||||
|
<range top="102.500000" right="59.992103" left="0.000000" bottom="-2.500000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/0" color="#1f77b4"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/1" color="#d62728"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/2" color="#1ac938"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/3" color="#ff7f0e"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/4" color="#f14cc1"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/5" color="#9467bd"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/6" color="#17becf"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/7" color="#bcbd22"/>
|
||||||
|
<curve name="/deviceState/gpuUsagePercent" color="#1f77b4"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||||
|
<range top="64.005001" right="59.992103" left="0.000000" bottom="51.195000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/deviceState/cpuTempC/0" color="#d62728"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/1" color="#1ac938"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/2" color="#ff7f0e"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/3" color="#f14cc1"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/4" color="#9467bd"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/5" color="#17becf"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/6" color="#bcbd22"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/7" color="#1f77b4"/>
|
||||||
|
<curve name="/deviceState/gpuTempC/0" color="#d62728"/>
|
||||||
|
<curve name="/deviceState/gpuTempC/1" color="#1ac938"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||||
|
<range top="37.371108" right="59.992103" left="0.000000" bottom="-0.911490"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/modelV2/frameDropPerc" color="#f14cc1"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot mode="TimeSeries" flip_x="false" flip_y="false" style="Lines">
|
||||||
|
<range top="-3.593455" right="59.992103" left="0.000000" bottom="-12.190956"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/controlsState/cumLagMs" color="#9467bd"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
107
tools/plotjuggler/layouts/thermal_debug.xml
Executable file
107
tools/plotjuggler/layouts/thermal_debug.xml
Executable file
@@ -0,0 +1,107 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget name="Main Window" parent="main_window">
|
||||||
|
<Tab containers="1" tab_name="tab1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter count="6" orientation="-" sizes="0.166785;0.166785;0.166075;0.166785;0.166785;0.166785">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
|
||||||
|
<range left="0.006955" top="87.987497" bottom="75.912497" right="301.842654"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/deviceState/cpuTempC/0" color="#1f77b4"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/1" color="#d62728"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/2" color="#1ac938"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/3" color="#ff7f0e"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/4" color="#f14cc1"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/5" color="#9467bd"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/6" color="#17becf"/>
|
||||||
|
<curve name="/deviceState/cpuTempC/7" color="#bcbd22"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
|
||||||
|
<range left="0.006955" top="85.861052" bottom="66.496950" right="301.842654"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/deviceState/pmicTempC/0" color="#1f77b4"/>
|
||||||
|
<curve name="/deviceState/gpuTempC/0" color="#d62728"/>
|
||||||
|
<curve name="/deviceState/gpuTempC/1" color="#1ac938"/>
|
||||||
|
<curve name="/deviceState/memoryTempC" color="#f14cc1"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
|
||||||
|
<range left="0.006955" top="86.207876" bottom="70.665918" right="301.842654"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/deviceState/maxTempC" color="#1f77b4"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
|
||||||
|
<range left="0.006955" top="1.025000" bottom="-0.025000" right="301.842654"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/deviceState/thermalStatus" color="#1f77b4"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockSplitter count="3" orientation="|" sizes="0.333124;0.333752;0.333124">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
|
||||||
|
<range left="0.006955" top="12.057358" bottom="4.843517" right="301.842654"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/deviceState/powerDrawW" color="#ff7f0e"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
|
||||||
|
<range left="0.006955" top="100.000000" bottom="0.000000" right="301.842654"/>
|
||||||
|
<limitY min="0" max="100"/>
|
||||||
|
<curve name="/deviceState/fanSpeedPercentDesired" color="#9467bd"/>
|
||||||
|
<curve name="/pandaStates/0/fanPower" color="#1f77b4"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
|
||||||
|
<range left="0.006955" top="5018.400000" bottom="255.600000" right="301.842654"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/peripheralState/fanSpeedRpm" color="#1f77b4"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
<DockSplitter count="2" orientation="|" sizes="0.502513;0.497487">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
|
||||||
|
<range left="0.006955" top="100.025000" bottom="14.975000" right="301.842654"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/0" color="#1f77b4"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/1" color="#d62728"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/2" color="#1ac938"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/3" color="#ff7f0e"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot flip_y="false" style="Lines" mode="TimeSeries" flip_x="false">
|
||||||
|
<range left="0.006955" top="102.500000" bottom="-2.500000" right="301.842654"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/4" color="#f14cc1"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/5" color="#9467bd"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/6" color="#17becf"/>
|
||||||
|
<curve name="/deviceState/cpuUsagePercent/7" color="#bcbd22"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<customMathEquations/>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
92
tools/plotjuggler/layouts/torque-controller.xml
Executable file
92
tools/plotjuggler/layouts/torque-controller.xml
Executable file
@@ -0,0 +1,92 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root>
|
||||||
|
<tabbed_widget name="Main Window" parent="main_window">
|
||||||
|
<Tab tab_name="tab1" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter sizes="0.250298;0.250298;0.249106;0.250298" orientation="-" count="4">
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" flip_x="false" flip_y="false" mode="TimeSeries">
|
||||||
|
<range bottom="-2.900899" top="3.526047" left="825.563261" right="1415.827546"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/controlsState/lateralControlState/torqueState/actualLateralAccel" color="#1f77b4"/>
|
||||||
|
<curve name="/controlsState/lateralControlState/torqueState/desiredLateralAccel" color="#d62728"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" flip_x="false" flip_y="false" mode="TimeSeries">
|
||||||
|
<range bottom="-4.577789" top="3.642392" left="825.563261" right="1415.827546"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="Actual lateral accel (roll compensated)" color="#1ac938"/>
|
||||||
|
<curve name="Desired lateral accel (roll compensated)" color="#ff7f0e"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" flip_x="false" flip_y="false" mode="TimeSeries">
|
||||||
|
<range bottom="-1.134948" top="1.052072" left="825.563261" right="1415.827546"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/carControl/actuatorsOutput/steer" color="#9467bd">
|
||||||
|
<transform name="Scale/Offset" alias="/carControl/actuatorsOutput/steer[Scale/Offset]">
|
||||||
|
<options time_offset="0" value_scale="-1" value_offset="0"/>
|
||||||
|
</transform>
|
||||||
|
</curve>
|
||||||
|
<curve name="/controlsState/lateralControlState/torqueState/f" color="#1f77b4"/>
|
||||||
|
<curve name="/carState/steeringPressed" color="#ff000f"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="...">
|
||||||
|
<plot style="Lines" flip_x="false" flip_y="false" mode="TimeSeries">
|
||||||
|
<range bottom="-1.373608" top="56.208012" left="825.563261" right="1415.827546"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="carState.vEgo mph" color="#d62728"/>
|
||||||
|
<curve name="carState.vEgo kmh" color="#1ac938"/>
|
||||||
|
<curve name="/carState/vEgo" color="#ff7f0e"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<customMathEquations>
|
||||||
|
<snippet name="carState.vEgo kmh">
|
||||||
|
<global></global>
|
||||||
|
<function>return value * 3.6</function>
|
||||||
|
<linked_source>/carState/vEgo</linked_source>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="carState.vEgo mph">
|
||||||
|
<global></global>
|
||||||
|
<function>return value * 2.23694</function>
|
||||||
|
<linked_source>/carState/vEgo</linked_source>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="Desired lateral accel (roll compensated)">
|
||||||
|
<global></global>
|
||||||
|
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
|
||||||
|
<linked_source>/controlsState/desiredCurvature</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/vEgo</v1>
|
||||||
|
<v2>/liveParameters/roll</v2>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="Actual lateral accel (roll compensated)">
|
||||||
|
<global></global>
|
||||||
|
<function>return (value * v1 ^ 2) - (v2 * 9.81)</function>
|
||||||
|
<linked_source>/controlsState/curvature</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/vEgo</v1>
|
||||||
|
<v2>/liveParameters/roll</v2>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
</customMathEquations>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
291
tools/plotjuggler/layouts/tuning.xml
Executable file
291
tools/plotjuggler/layouts/tuning.xml
Executable file
@@ -0,0 +1,291 @@
|
|||||||
|
<?xml version='1.0' encoding='UTF-8'?>
|
||||||
|
<root version="2.3.8">
|
||||||
|
<tabbed_widget parent="main_window" name="Main Window">
|
||||||
|
<Tab tab_name="Lateral" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" count="5" sizes="0.200458;0.199313;0.200458;0.199313;0.200458">
|
||||||
|
<DockArea name="Velocity [m/s]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="1.253354" top="29.954036" bottom="-0.841715" right="631.055584"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#0072b2" name="/carState/vEgo"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Curvature [1/m] True [blue] Vehicle Model [purple] Plan [green]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="0.000000" top="0.006648" bottom="-0.003150" right="631.055209"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#009e73" name="engaged curvature plan"/>
|
||||||
|
<curve color="#785ef0" name="engaged curvature vehicle model"/>
|
||||||
|
<curve color="#0072b2" name="engaged curvature yaw"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Roll [rad]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="0.000000" top="0.166067" bottom="-1.598381" right="631.038276"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#ffb000" name="/liveLocationKalman/calibratedOrientationNED/value/0"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Engaged [green] Steering Pressed [blue]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="1.252984" top="1.025000" bottom="-0.025000" right="631.055584"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#009e73" name="/controlsState/enabled"/>
|
||||||
|
<curve color="#0072b2" name="/carState/steeringPressed"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Steering Limited: Rate [orange] Saturated [magenta]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="1.253354" top="1.025000" bottom="-0.025000" right="631.055584"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/carState/steeringRateLimited" color="#ffb000"/>
|
||||||
|
<curve name="/controlsState/lateralControlState/pidState/saturated" color="#dc267f"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<Tab tab_name="Longitudinal" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" count="5" sizes="0.1875;0.1875;0.1875;0.1875;0.25">
|
||||||
|
<DockArea name="Velocity [m/s] True [blue] Plan [green] Cruise [magenta]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="0.000000" top="42.713492" bottom="-1.041792" right="631.055584"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#dc267f" name="/carState/cruiseState/speed"/>
|
||||||
|
<curve color="#009e73" name="/longitudinalPlan/speeds/0"/>
|
||||||
|
<curve color="#0072b2" name="/carState/vEgo"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Acceleration [m/s^2] True [blue] Actuator [purple] Plan [green]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="1.253354" top="0.808303" bottom="-1.213305" right="631.055759"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#009e73" name="engaged_accel_plan"/>
|
||||||
|
<curve color="#785ef0" name="engaged_accel_actuator"/>
|
||||||
|
<curve color="#0072b2" name="engaged_accel_actual"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Pitch [rad]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="0.000000" top="0.158854" bottom="-0.594843" right="631.038276"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#ffb000" name="/liveLocationKalman/calibratedOrientationNED/value/1"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Engaged [green] Gas [orange] Brake [magenta]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="1.253354" top="1.025000" bottom="-0.025000" right="631.055759"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#009e73" name="/carControl/enabled"/>
|
||||||
|
<curve color="#ffb000" name="/carState/gasPressed"/>
|
||||||
|
<curve color="#dc267f" name="/carState/brakePressed"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="State [blue: off,pid,stop,start] Source [green: cruise,lead0,lead1,lead2,e2e]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="1.253620" top="5.125000" bottom="-0.125000" right="631.055759"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#0072b2" name="/carControl/actuators/longControlState"/>
|
||||||
|
<curve color="#009e73" name="/longitudinalPlan/longitudinalPlanSource"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<Tab tab_name="Lateral Debug" containers="1">
|
||||||
|
<Container>
|
||||||
|
<DockSplitter orientation="-" count="4" sizes="0.25;0.25;0.25;0.25">
|
||||||
|
<DockArea name="Controller F [magenta] P [purple] I [blue]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="0.000000" top="1.000000" bottom="0.000000" right="1.000000"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/controlsState/lateralControlState/pidState/f" color="#f14cc1"/>
|
||||||
|
<curve name="/controlsState/lateralControlState/pidState/p" color="#9467bd"/>
|
||||||
|
<curve name="/controlsState/lateralControlState/pidState/i" color="#17becf"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Driver Torque [blue] EPS Torque [green]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="1.253354" top="2690.999030" bottom="-3450.198981" right="631.055584"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#009e73" name="/carState/steeringTorqueEps"/>
|
||||||
|
<curve color="#0072b2" name="/carState/steeringTorque"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Engaged [green] Steering Pressed [blue]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="1.253354" top="1.025000" bottom="-0.025000" right="631.055759"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve color="#009e73" name="/carControl/enabled"/>
|
||||||
|
<curve color="#0072b2" name="/carState/steeringPressed"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
<DockArea name="Steering Limited: Rate [orange] Saturated [magenta]">
|
||||||
|
<plot style="Lines" mode="TimeSeries">
|
||||||
|
<range left="1.253354" top="1.025000" bottom="-0.025000" right="631.055584"/>
|
||||||
|
<limitY/>
|
||||||
|
<curve name="/carState/steeringRateLimited" color="#ffb000"/>
|
||||||
|
<curve name="/controlsState/lateralControlState/pidState/saturated" color="#dc267f"/>
|
||||||
|
</plot>
|
||||||
|
</DockArea>
|
||||||
|
</DockSplitter>
|
||||||
|
</Container>
|
||||||
|
</Tab>
|
||||||
|
<currentTabIndex index="0"/>
|
||||||
|
</tabbed_widget>
|
||||||
|
<use_relative_time_offset enabled="1"/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<Plugins>
|
||||||
|
<plugin ID="DataLoad Rlog"/>
|
||||||
|
<plugin ID="Cereal Subscriber"/>
|
||||||
|
</Plugins>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
<customMathEquations>
|
||||||
|
<snippet name="engaged curvature yaw">
|
||||||
|
<global>engage_delay = 5
|
||||||
|
last_bad_time = -engage_delay</global>
|
||||||
|
<function>curvature = value / v3
|
||||||
|
pressed = v1
|
||||||
|
enabled = v2
|
||||||
|
|
||||||
|
if (pressed == 1 or enabled == 0) then
|
||||||
|
last_bad_time = time
|
||||||
|
end
|
||||||
|
|
||||||
|
if (time > last_bad_time + engage_delay) then
|
||||||
|
return curvature
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end</function>
|
||||||
|
<linked_source>/liveLocationKalman/angularVelocityCalibrated/value/2</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/steeringPressed</v1>
|
||||||
|
<v2>/carControl/enabled</v2>
|
||||||
|
<v3>/liveLocationKalman/velocityCalibrated/value/0</v3>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="engaged curvature vehicle model">
|
||||||
|
<global>engage_delay = 5
|
||||||
|
last_bad_time = -engage_delay</global>
|
||||||
|
<function>curvature = value
|
||||||
|
pressed = v1
|
||||||
|
enabled = v2
|
||||||
|
|
||||||
|
if (pressed == 1 or enabled == 0) then
|
||||||
|
last_bad_time = time
|
||||||
|
end
|
||||||
|
|
||||||
|
if (time > last_bad_time + engage_delay) then
|
||||||
|
return value
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end</function>
|
||||||
|
<linked_source>/controlsState/curvature</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/steeringPressed</v1>
|
||||||
|
<v2>/carControl/enabled</v2>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="engaged curvature plan">
|
||||||
|
<global>engage_delay = 5
|
||||||
|
last_bad_time = -engage_delay</global>
|
||||||
|
<function>curvature = value
|
||||||
|
pressed = v1
|
||||||
|
enabled = v2
|
||||||
|
|
||||||
|
if (pressed == 1 or enabled == 0) then
|
||||||
|
last_bad_time = time
|
||||||
|
end
|
||||||
|
|
||||||
|
if (time > last_bad_time + engage_delay) then
|
||||||
|
return value
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end</function>
|
||||||
|
<linked_source>/lateralPlan/curvatures/0</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/steeringPressed</v1>
|
||||||
|
<v2>/carControl/enabled</v2>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="engaged_accel_actual">
|
||||||
|
<global>engage_delay = 5
|
||||||
|
last_bad_time = -engage_delay</global>
|
||||||
|
<function>accel = value
|
||||||
|
brake = v1
|
||||||
|
gas = v2
|
||||||
|
enabled = v3
|
||||||
|
|
||||||
|
if (brake ~= 0 or gas ~= 0 or enabled == 0) then
|
||||||
|
last_bad_time = time
|
||||||
|
end
|
||||||
|
|
||||||
|
if (time > last_bad_time + engage_delay) then
|
||||||
|
return value
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end</function>
|
||||||
|
<linked_source>/carState/aEgo</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/brakePressed</v1>
|
||||||
|
<v2>/carState/gasPressed</v2>
|
||||||
|
<v3>/carControl/enabled</v3>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="engaged_accel_plan">
|
||||||
|
<global>engage_delay = 5
|
||||||
|
last_bad_time = -engage_delay</global>
|
||||||
|
<function>accel = value
|
||||||
|
brake = v1
|
||||||
|
gas = v2
|
||||||
|
enabled = v3
|
||||||
|
|
||||||
|
if (brake ~= 0 or gas ~= 0 or enabled == 0) then
|
||||||
|
last_bad_time = time
|
||||||
|
end
|
||||||
|
|
||||||
|
if (time > last_bad_time + engage_delay) then
|
||||||
|
return value
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end</function>
|
||||||
|
<linked_source>/longitudinalPlan/accels/0</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/brakePressed</v1>
|
||||||
|
<v2>/carState/gasPressed</v2>
|
||||||
|
<v3>/carControl/enabled</v3>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
<snippet name="engaged_accel_actuator">
|
||||||
|
<global>engage_delay = 5
|
||||||
|
last_bad_time = -engage_delay</global>
|
||||||
|
<function>accel = value
|
||||||
|
brake = v1
|
||||||
|
gas = v2
|
||||||
|
enabled = v3
|
||||||
|
|
||||||
|
if (brake ~= 0 or gas ~= 0 or enabled == 0) then
|
||||||
|
last_bad_time = time
|
||||||
|
end
|
||||||
|
|
||||||
|
if (time > last_bad_time + engage_delay) then
|
||||||
|
return value
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end</function>
|
||||||
|
<linked_source>/carControl/actuators/accel</linked_source>
|
||||||
|
<additional_sources>
|
||||||
|
<v1>/carState/brakePressed</v1>
|
||||||
|
<v2>/carState/gasPressed</v2>
|
||||||
|
<v3>/carControl/enabled</v3>
|
||||||
|
</additional_sources>
|
||||||
|
</snippet>
|
||||||
|
</customMathEquations>
|
||||||
|
<snippets/>
|
||||||
|
<!-- - - - - - - - - - - - - - - -->
|
||||||
|
</root>
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user