mirror of
https://github.com/herizon1054/openpilot.git
synced 2026-06-08 12:14:14 +08:00
openpilot v0.10.3 release
date: 2025-12-18T23:23:16 master commit: 154c2334110373950bac1c36fc6e943cb1208326
This commit is contained in:
20
tools/CTF.md
Normal file
20
tools/CTF.md
Normal 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 replay
|
||||
cd tools/replay
|
||||
./replay '0c7f0c7f0c7f0c7f|2021-10-13--13-00-00' --dcam --ecam
|
||||
|
||||
# start the UI in another terminal
|
||||
selfdrive/ui/ui
|
||||
```
|
||||
59
tools/README.md
Normal file
59
tools/README.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# openpilot tools
|
||||
|
||||
## System Requirements
|
||||
|
||||
openpilot is developed and tested on **Ubuntu 24.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).
|
||||
|
||||
Most of openpilot should work natively on macOS. On Windows you can use WSL for a nearly native Ubuntu experience. Running natively on any other system is not currently recommended and will likely require modifications.
|
||||
|
||||
## Native setup on Ubuntu 24.04 and macOS
|
||||
|
||||
Follow these instructions for a fully managed setup experience. If you'd like to manage the dependencies yourself, just read the setup scripts in this directory.
|
||||
|
||||
**1. Clone openpilot**
|
||||
``` bash
|
||||
git clone https://github.com/commaai/openpilot.git
|
||||
```
|
||||
|
||||
**2. Run the setup script**
|
||||
``` bash
|
||||
cd openpilot
|
||||
tools/op.sh setup
|
||||
```
|
||||
|
||||
**3. Activate a Python shell**
|
||||
Activate a shell with the Python dependencies installed:
|
||||
``` bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
**4. Build openpilot**
|
||||
``` bash
|
||||
scons -u -j$(nproc)
|
||||
```
|
||||
|
||||
## 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-24.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
|
||||
|
||||
```
|
||||
├── cabana/ # View and plot CAN messages from drives or in realtime
|
||||
├── camerastream/ # Cameras stream over the network
|
||||
├── 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
|
||||
└── webcam/ # Run openpilot on a PC with webcams
|
||||
```
|
||||
0
tools/__init__.py
Normal file
0
tools/__init__.py
Normal file
17
tools/auto_source.py
Executable file
17
tools/auto_source.py
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from openpilot.tools.lib.logreader import LogReader, ReadMode
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: python auto_source.py <log_path>")
|
||||
sys.exit(1)
|
||||
|
||||
log_path = sys.argv[1]
|
||||
lr = LogReader(log_path, default_mode=ReadMode.AUTO, sort_by_time=True)
|
||||
print("\n".join(lr.logreader_identifiers))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
tools/bodyteleop/.gitignore
vendored
Normal file
4
tools/bodyteleop/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
av
|
||||
av-10.0.0/*
|
||||
key.pem
|
||||
cert.pem
|
||||
103
tools/bodyteleop/static/index.html
Normal file
103
tools/bodyteleop/static/index.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>commabody</title>
|
||||
<link rel="stylesheet" href="/static/main.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/css/bootstrap.min.css" integrity="sha512-SbiR/eusphKoMVVXysTKG/7VseWii+Y3FdHrt0EpKgpToZeemhqHeZeLWLhJutz/2ut2Vw1uQEj2MbRF+TVBUA==" crossorigin="anonymous" referrerpolicy="no-referrer" /><script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.2.3/js/bootstrap.min.js" integrity="sha512-1/RvZTcCDEUjY/CypiMz+iqqtaoQfAITmNSJY17Myp4Ms5mdxPS5UV7iOfdZoxcGhzFbOm6sntTKJppjvuhg4g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@^3"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment@^2"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="main">
|
||||
<p class="jumbo">comma body</p>
|
||||
<audio id="audio" autoplay="true"></audio>
|
||||
<video id="video" playsinline autoplay muted loop poster="/static/poster.png"></video>
|
||||
<div id="icon-panel" class="row">
|
||||
<div class="col-sm-12 col-md-6 details">
|
||||
<div class="icon-sup-panel col-12">
|
||||
<div class="icon-sub-panel">
|
||||
<div class="icon-sub-sub-panel">
|
||||
<i class="bi bi-speaker-fill pre-blob"></i>
|
||||
<i class="bi bi-mic-fill pre-blob"></i>
|
||||
<i class="bi bi-camera-video-fill pre-blob"></i>
|
||||
</div>
|
||||
<p class="small">body</p>
|
||||
</div>
|
||||
<div class="icon-sub-panel">
|
||||
<div class="icon-sub-sub-panel">
|
||||
<i class="bi bi-speaker-fill pre-blob"></i>
|
||||
<i class="bi bi-mic-fill pre-blob"></i>
|
||||
</div>
|
||||
<p class="small">you</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12 col-md-6 details">
|
||||
<div class="icon-sup-panel col-12">
|
||||
<div class="icon-sub-panel">
|
||||
<div class="icon-sub-sub-panel">
|
||||
<i id="ping-time" class="pre-blob1">-</i>
|
||||
</div>
|
||||
<p class="bi bi-arrow-repeat small"> ping time</p>
|
||||
</div>
|
||||
<div class="icon-sub-panel">
|
||||
<div class="icon-sub-sub-panel">
|
||||
<i id="battery" class="pre-blob1">-</i>
|
||||
</div>
|
||||
<p class="bi bi-battery-half small"> battery</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="icon-sub-panel">
|
||||
<button type="button" id="start" class="btn btn-light btn-lg">Start</button>
|
||||
<button type="button" id="stop" class="btn btn-light btn-lg">Stop</button>
|
||||
</div> -->
|
||||
</div>
|
||||
<div class="row" style="width: 100%; padding: 0px 10px 0px 10px;">
|
||||
<div id="wasd" class="col-md-12 row">
|
||||
<div class="col-md-6 col-sm-12" style="justify-content: center; display: flex; flex-direction: column;">
|
||||
<div class="wasd-row">
|
||||
<div class="keys" id="key-w">W</div>
|
||||
<div id="key-val"><span id="pos-vals">0,0</span><span>x,y</span></div>
|
||||
</div>
|
||||
<div class="wasd-row">
|
||||
<div class="keys" id="key-a">A</div>
|
||||
<div class="keys" id="key-s">S</div>
|
||||
<div class="keys" id="key-d">D</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12 form-group plan-form">
|
||||
<label for="plan-text">Plan (w, a, s, d, t)</label>
|
||||
<label style="font-size: 15px;" for="plan-text">*Extremely Experimental*</label>
|
||||
<textarea class="form-control" id="plan-text" rows="7" placeholder="1,0,0,0,2"></textarea>
|
||||
<button type="button" id="plan-button" class="btn btn-light btn-lg">Execute</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="padding: 0px 10px 0px 10px; width: 100%;">
|
||||
<div class="panel row">
|
||||
<div class="col-sm-3" style="text-align: center;">
|
||||
<p>Play Sounds</p>
|
||||
</div>
|
||||
<div class="btn-group col-sm-8">
|
||||
<button type="button" id="sound-engage" class="btn btn-outline-success btn-lg sound">Engage</button>
|
||||
<button type="button" id="sound-disengage" class="btn btn-outline-warning btn-lg sound">Disengage</button>
|
||||
<button type="button" id="sound-error" class="btn btn-outline-danger btn-lg sound">Error</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" style="padding: 0px 10px 0px 10px; width: 100%;">
|
||||
<div class="panel row">
|
||||
<div class="col-sm-6"><canvas id="chart-ping"></canvas></div>
|
||||
<div class="col-sm-6"><canvas id="chart-battery"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/jsmain.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
54
tools/bodyteleop/static/js/controls.js
vendored
Normal file
54
tools/bodyteleop/static/js/controls.js
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
const keyVals = {w: 0, a: 0, s: 0, d: 0}
|
||||
|
||||
export function getXY() {
|
||||
let x = -keyVals.w + keyVals.s
|
||||
let y = -keyVals.d + keyVals.a
|
||||
return {x, y}
|
||||
}
|
||||
|
||||
export const handleKeyX = (key, setValue) => {
|
||||
if (['w', 'a', 's', 'd'].includes(key)){
|
||||
keyVals[key] = setValue;
|
||||
let color = "#333";
|
||||
if (setValue === 1){
|
||||
color = "#e74c3c";
|
||||
}
|
||||
$("#key-"+key).css('background', color);
|
||||
const {x, y} = getXY();
|
||||
$("#pos-vals").text(x+","+y);
|
||||
}
|
||||
};
|
||||
|
||||
export async function executePlan() {
|
||||
let plan = $("#plan-text").val();
|
||||
const planList = [];
|
||||
plan.split("\n").forEach(function(e){
|
||||
let line = e.split(",").map(k=>parseInt(k));
|
||||
if (line.length != 5 || line.slice(0, 4).map(e=>[1, 0].includes(e)).includes(false) || line[4] < 0 || line[4] > 10){
|
||||
console.log("invalid plan");
|
||||
}
|
||||
else{
|
||||
planList.push(line)
|
||||
}
|
||||
});
|
||||
|
||||
async function execute() {
|
||||
for (var i = 0; i < planList.length; i++) {
|
||||
let [w, a, s, d, t] = planList[i];
|
||||
while(t > 0){
|
||||
console.log(w, a, s, d, t);
|
||||
if(w==1){$("#key-w").mousedown();}
|
||||
if(a==1){$("#key-a").mousedown();}
|
||||
if(s==1){$("#key-s").mousedown();}
|
||||
if(d==1){$("#key-d").mousedown();}
|
||||
await sleep(50);
|
||||
$("#key-w").mouseup();
|
||||
$("#key-a").mouseup();
|
||||
$("#key-s").mouseup();
|
||||
$("#key-d").mouseup();
|
||||
t = t - 0.05;
|
||||
}
|
||||
}
|
||||
}
|
||||
execute();
|
||||
}
|
||||
27
tools/bodyteleop/static/js/jsmain.js
Normal file
27
tools/bodyteleop/static/js/jsmain.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { handleKeyX, executePlan } from "./controls.js";
|
||||
import { start, stop, lastChannelMessageTime, playSoundRequest } from "./webrtc.js";
|
||||
|
||||
export var pc = null;
|
||||
export var dc = null;
|
||||
|
||||
document.addEventListener('keydown', (e)=>(handleKeyX(e.key.toLowerCase(), 1)));
|
||||
document.addEventListener('keyup', (e)=>(handleKeyX(e.key.toLowerCase(), 0)));
|
||||
$(".keys").bind("mousedown touchstart", (e)=>handleKeyX($(e.target).attr('id').replace('key-', ''), 1));
|
||||
$(".keys").bind("mouseup touchend", (e)=>handleKeyX($(e.target).attr('id').replace('key-', ''), 0));
|
||||
$("#plan-button").click(executePlan);
|
||||
$(".sound").click((e)=>{
|
||||
const sound = $(e.target).attr('id').replace('sound-', '')
|
||||
return playSoundRequest(sound);
|
||||
});
|
||||
|
||||
setInterval( () => {
|
||||
const dt = new Date().getTime();
|
||||
if ((dt - lastChannelMessageTime) > 1000) {
|
||||
$(".pre-blob").removeClass('blob');
|
||||
$("#battery").text("-");
|
||||
$("#ping-time").text('-');
|
||||
$("video")[0].load();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
start(pc, dc);
|
||||
53
tools/bodyteleop/static/js/plots.js
Normal file
53
tools/bodyteleop/static/js/plots.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export const pingPoints = [];
|
||||
export const batteryPoints = [];
|
||||
|
||||
function getChartConfig(pts, color, title, ymax=100) {
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
label: title,
|
||||
data: pts,
|
||||
borderWidth: 1,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
fill: 'origin'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: 'minute',
|
||||
displayFormats: {
|
||||
second: 'h:mm a'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: '#222', // Grid lines color
|
||||
},
|
||||
ticks: {
|
||||
source: 'data',
|
||||
fontColor: 'rgba(255, 255, 255, 1.0)', // Y-axis label color
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
max: ymax,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.1)', // Grid lines color
|
||||
},
|
||||
ticks: {
|
||||
fontColor: 'rgba(255, 255, 255, 0.7)', // Y-axis label color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ctxPing = document.getElementById('chart-ping');
|
||||
const ctxBattery = document.getElementById('chart-battery');
|
||||
export const chartPing = new Chart(ctxPing, getChartConfig(pingPoints, 'rgba(192, 57, 43, 0.7)', 'Controls Ping Time (ms)', 250));
|
||||
export const chartBattery = new Chart(ctxBattery, getChartConfig(batteryPoints, 'rgba(41, 128, 185, 0.7)', 'Battery %', 100));
|
||||
209
tools/bodyteleop/static/js/webrtc.js
Normal file
209
tools/bodyteleop/static/js/webrtc.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { getXY } from "./controls.js";
|
||||
import { pingPoints, batteryPoints, chartPing, chartBattery } from "./plots.js";
|
||||
|
||||
export let controlCommandInterval = null;
|
||||
export let latencyInterval = null;
|
||||
export let lastChannelMessageTime = null;
|
||||
|
||||
|
||||
export function offerRtcRequest(sdp, type) {
|
||||
return fetch('/offer', {
|
||||
body: JSON.stringify({sdp: sdp, type: type}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function playSoundRequest(sound) {
|
||||
return fetch('/sound', {
|
||||
body: JSON.stringify({sound}),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function pingHeadRequest() {
|
||||
return fetch('/', {
|
||||
method: 'HEAD'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function createPeerConnection(pc) {
|
||||
var config = {
|
||||
sdpSemantics: 'unified-plan'
|
||||
};
|
||||
|
||||
pc = new RTCPeerConnection(config);
|
||||
|
||||
// connect audio / video
|
||||
pc.addEventListener('track', function(evt) {
|
||||
console.log("Adding Tracks!")
|
||||
if (evt.track.kind == 'video')
|
||||
document.getElementById('video').srcObject = evt.streams[0];
|
||||
else
|
||||
document.getElementById('audio').srcObject = evt.streams[0];
|
||||
});
|
||||
return pc;
|
||||
}
|
||||
|
||||
|
||||
export function negotiate(pc) {
|
||||
return pc.createOffer({offerToReceiveAudio:true, offerToReceiveVideo:true}).then(function(offer) {
|
||||
return pc.setLocalDescription(offer);
|
||||
}).then(function() {
|
||||
return new Promise(function(resolve) {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
}
|
||||
else {
|
||||
function checkState() {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
pc.removeEventListener('icegatheringstatechange', checkState);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
pc.addEventListener('icegatheringstatechange', checkState);
|
||||
}
|
||||
});
|
||||
}).then(function() {
|
||||
var offer = pc.localDescription;
|
||||
return offerRtcRequest(offer.sdp, offer.type);
|
||||
}).then(function(response) {
|
||||
console.log(response);
|
||||
return response.json();
|
||||
}).then(function(answer) {
|
||||
return pc.setRemoteDescription(answer);
|
||||
}).catch(function(e) {
|
||||
alert(e);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function isMobile() {
|
||||
let check = false;
|
||||
(function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera);
|
||||
return check;
|
||||
};
|
||||
|
||||
|
||||
export const constraints = {
|
||||
audio: {
|
||||
autoGainControl: false,
|
||||
sampleRate: 48000,
|
||||
sampleSize: 16,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
channelCount: 1
|
||||
},
|
||||
video: isMobile()
|
||||
};
|
||||
|
||||
|
||||
export function start(pc, dc) {
|
||||
pc = createPeerConnection(pc);
|
||||
|
||||
// add audio track
|
||||
navigator.mediaDevices.enumerateDevices()
|
||||
.then(function(devices) {
|
||||
const hasAudioInput = devices.find((device) => device.kind === "audioinput");
|
||||
var modifiedConstraints = {};
|
||||
modifiedConstraints.video = constraints.video;
|
||||
modifiedConstraints.audio = hasAudioInput ? constraints.audio : false;
|
||||
|
||||
return Promise.resolve(modifiedConstraints);
|
||||
})
|
||||
.then(function(constraints) {
|
||||
if (constraints.audio || constraints.video) {
|
||||
return navigator.mediaDevices.getUserMedia(constraints);
|
||||
} else{
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
})
|
||||
.then(function(stream) {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(function(track) {
|
||||
pc.addTrack(track, stream);
|
||||
});
|
||||
}
|
||||
|
||||
return negotiate(pc);
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('Could not acquire media: ' + err);
|
||||
});
|
||||
|
||||
var parameters = {"ordered": true};
|
||||
dc = pc.createDataChannel('data', parameters);
|
||||
dc.onclose = function() {
|
||||
clearInterval(controlCommandInterval);
|
||||
clearInterval(latencyInterval);
|
||||
};
|
||||
|
||||
function sendJoystickOverDataChannel() {
|
||||
const {x, y} = getXY();
|
||||
var message = JSON.stringify({type: "testJoystick", data: {axes: [x, y], buttons: [false]}})
|
||||
dc.send(message);
|
||||
}
|
||||
function checkLatency() {
|
||||
const initialTime = new Date().getTime();
|
||||
pingHeadRequest().then(function() {
|
||||
const currentTime = new Date().getTime();
|
||||
if (Math.abs(currentTime - lastChannelMessageTime) < 1000) {
|
||||
const pingtime = currentTime - initialTime;
|
||||
pingPoints.push({'x': currentTime, 'y': pingtime});
|
||||
if (pingPoints.length > 1000) {
|
||||
pingPoints.shift();
|
||||
}
|
||||
chartPing.update();
|
||||
$("#ping-time").text((pingtime) + "ms");
|
||||
}
|
||||
})
|
||||
}
|
||||
dc.onopen = function() {
|
||||
controlCommandInterval = setInterval(sendJoystickOverDataChannel, 50);
|
||||
latencyInterval = setInterval(checkLatency, 1000);
|
||||
sendJoystickOverDataChannel();
|
||||
};
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
var carStaterIndex = 0;
|
||||
dc.onmessage = function(evt) {
|
||||
const text = textDecoder.decode(evt.data);
|
||||
const msg = JSON.parse(text);
|
||||
if (carStaterIndex % 100 == 0 && msg.type === 'carState') {
|
||||
const batteryLevel = Math.round(msg.data.fuelGauge * 100);
|
||||
$("#battery").text(batteryLevel + "%");
|
||||
batteryPoints.push({'x': new Date().getTime(), 'y': batteryLevel});
|
||||
if (batteryPoints.length > 1000) {
|
||||
batteryPoints.shift();
|
||||
}
|
||||
chartBattery.update();
|
||||
}
|
||||
carStaterIndex += 1;
|
||||
lastChannelMessageTime = new Date().getTime();
|
||||
$(".pre-blob").addClass('blob');
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function stop(pc, dc) {
|
||||
if (dc) {
|
||||
dc.close();
|
||||
}
|
||||
if (pc.getTransceivers) {
|
||||
pc.getTransceivers().forEach(function(transceiver) {
|
||||
if (transceiver.stop) {
|
||||
transceiver.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
pc.getSenders().forEach(function(sender) {
|
||||
sender.track.stop();
|
||||
});
|
||||
setTimeout(function() {
|
||||
pc.close();
|
||||
}, 500);
|
||||
}
|
||||
185
tools/bodyteleop/static/main.css
Normal file
185
tools/bodyteleop/static/main.css
Normal file
@@ -0,0 +1,185 @@
|
||||
body {
|
||||
background: #333 !important;
|
||||
color: #fff !important;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0px !important;
|
||||
}
|
||||
|
||||
i {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 1em !important
|
||||
}
|
||||
|
||||
.jumbo {
|
||||
font-size: 8rem;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.small {
|
||||
font-size: 0.5em !important
|
||||
}
|
||||
.jumbo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 30px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
.pre-blob {
|
||||
display: flex;
|
||||
background: #333;
|
||||
border-radius: 50%;
|
||||
margin: 10px;
|
||||
height: 45px;
|
||||
width: 45px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.blob {
|
||||
background: rgba(231, 76, 60,1.0);
|
||||
box-shadow: 0 0 0 0 rgba(231, 76, 60,1.0);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0px rgba(192, 57, 43, 1);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 20px rgba(192, 57, 43, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.icon-sup-panel {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
background: #222;
|
||||
border-radius: 10px;
|
||||
padding: 5px;
|
||||
margin: 5px 0px 5px 0px;
|
||||
}
|
||||
|
||||
.icon-sub-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#icon-panel {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.icon-sub-sub-panel {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.keys, #key-val {
|
||||
background: #333;
|
||||
padding: 2rem;
|
||||
margin: 5px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#key-val {
|
||||
pointer-events: none;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
line-height: 1;
|
||||
font-size: 20px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wasd-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#wasd {
|
||||
margin: 5px 0px 5px 0px;
|
||||
background: #222;
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: stretch;
|
||||
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 5px 0px 5px 0px !important;
|
||||
background: #222;
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#ping-time, #battery {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
#stop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.plan-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
padding: 0px 10px 0px 10px;
|
||||
}
|
||||
BIN
tools/bodyteleop/static/poster.png
Normal file
BIN
tools/bodyteleop/static/poster.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.1 KiB |
127
tools/bodyteleop/web.py
Normal file
127
tools/bodyteleop/web.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import subprocess
|
||||
|
||||
import pyaudio
|
||||
import wave
|
||||
from aiohttp import web
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.system.webrtc.webrtcd import StreamRequestBody
|
||||
from openpilot.common.params import Params
|
||||
|
||||
logger = logging.getLogger("bodyteleop")
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
TELEOPDIR = f"{BASEDIR}/tools/bodyteleop"
|
||||
WEBRTCD_HOST, WEBRTCD_PORT = "localhost", 5001
|
||||
|
||||
|
||||
## UTILS
|
||||
async def play_sound(sound: str):
|
||||
SOUNDS = {
|
||||
"engage": "selfdrive/assets/sounds/engage.wav",
|
||||
"disengage": "selfdrive/assets/sounds/disengage.wav",
|
||||
"error": "selfdrive/assets/sounds/warning_immediate.wav",
|
||||
}
|
||||
assert sound in SOUNDS
|
||||
|
||||
chunk = 5120
|
||||
with wave.open(os.path.join(BASEDIR, SOUNDS[sound]), "rb") as wf:
|
||||
def callback(in_data, frame_count, time_info, status):
|
||||
data = wf.readframes(frame_count)
|
||||
return data, pyaudio.paContinue
|
||||
|
||||
p = pyaudio.PyAudio()
|
||||
stream = p.open(format=p.get_format_from_width(wf.getsampwidth()),
|
||||
channels=wf.getnchannels(),
|
||||
rate=wf.getframerate(),
|
||||
output=True,
|
||||
frames_per_buffer=chunk,
|
||||
stream_callback=callback)
|
||||
stream.start_stream()
|
||||
while stream.is_active():
|
||||
await asyncio.sleep(0)
|
||||
stream.stop_stream()
|
||||
stream.close()
|
||||
p.terminate()
|
||||
|
||||
## SSL
|
||||
def create_ssl_cert(cert_path: str, key_path: str):
|
||||
try:
|
||||
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"',
|
||||
capture_output=True, shell=True)
|
||||
proc.check_returncode()
|
||||
except subprocess.CalledProcessError as ex:
|
||||
raise ValueError(f"Error creating SSL certificate:\n[stdout]\n{proc.stdout.decode()}\n[stderr]\n{proc.stderr.decode()}") from ex
|
||||
|
||||
|
||||
def create_ssl_context():
|
||||
cert_path = os.path.join(TELEOPDIR, "cert.pem")
|
||||
key_path = os.path.join(TELEOPDIR, "key.pem")
|
||||
if not os.path.exists(cert_path) or not os.path.exists(key_path):
|
||||
logger.info("Creating certificate...")
|
||||
create_ssl_cert(cert_path, key_path)
|
||||
else:
|
||||
logger.info("Certificate exists!")
|
||||
ssl_context = ssl.SSLContext(protocol=ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(cert_path, key_path)
|
||||
|
||||
return ssl_context
|
||||
|
||||
## ENDPOINTS
|
||||
async def index(request: 'web.Request'):
|
||||
with open(os.path.join(TELEOPDIR, "static", "index.html")) as f:
|
||||
content = f.read()
|
||||
return web.Response(content_type="text/html", text=content)
|
||||
|
||||
|
||||
async def ping(request: 'web.Request'):
|
||||
return web.Response(text="pong")
|
||||
|
||||
|
||||
async def sound(request: 'web.Request'):
|
||||
params = await request.json()
|
||||
sound_to_play = params["sound"]
|
||||
|
||||
await play_sound(sound_to_play)
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
|
||||
async def offer(request: 'web.Request'):
|
||||
params = await request.json()
|
||||
body = StreamRequestBody(params["sdp"], ["driver"], ["testJoystick"], ["carState"])
|
||||
body_json = json.dumps(dataclasses.asdict(body))
|
||||
|
||||
logger.info("Sending offer to webrtcd...")
|
||||
webrtcd_url = f"http://{WEBRTCD_HOST}:{WEBRTCD_PORT}/stream"
|
||||
async with ClientSession() as session, session.post(webrtcd_url, data=body_json) as resp:
|
||||
assert resp.status == 200
|
||||
answer = await resp.json()
|
||||
return web.json_response(answer)
|
||||
|
||||
|
||||
def main():
|
||||
# Enable joystick debug mode
|
||||
Params().put_bool("JoystickDebugMode", True)
|
||||
|
||||
# App needs to be HTTPS for microphone and audio autoplay to work on the browser
|
||||
ssl_context = create_ssl_context()
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/", index)
|
||||
app.router.add_get("/ping", ping, allow_head=True)
|
||||
app.router.add_post("/offer", offer)
|
||||
app.router.add_post("/sound", sound)
|
||||
app.router.add_static('/static', os.path.join(TELEOPDIR, 'static'))
|
||||
web.run_app(app, access_log=None, host="0.0.0.0", port=5000, ssl_context=ssl_context)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
6
tools/cabana/.gitignore
vendored
Normal file
6
tools/cabana/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
moc_*
|
||||
*.moc
|
||||
|
||||
cabana
|
||||
dbc/car_fingerprint_to_dbc.json
|
||||
tests/test_cabana
|
||||
100
tools/cabana/README.md
Normal file
100
tools/cabana/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# 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
|
||||
--auto Auto load the route from the best available source (no video):
|
||||
internal, openpilotci, comma_api, car_segments, testing_closet
|
||||
--qcam load qcamera
|
||||
--ecam load wide road camera
|
||||
--msgq read can messages from msgq
|
||||
--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 <ip-address> read can messages from zmq at the specified ip-address
|
||||
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
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Running Cabana in Demo Mode
|
||||
To run Cabana using a built-in demo route, use the following command:
|
||||
|
||||
```shell
|
||||
cabana --demo
|
||||
```
|
||||
|
||||
### Loading a Specific Route
|
||||
|
||||
To load a specific route for replay, provide the route as an argument:
|
||||
|
||||
```shell
|
||||
cabana "a2a0ccea32023010|2023-07-27--13-01-19"
|
||||
```
|
||||
|
||||
Replace "0ccea32023010|2023-07-27--13-01-19" with your desired route identifier.
|
||||
|
||||
|
||||
### Running Cabana with multiple cameras
|
||||
To run Cabana with multiple cameras, use the following command:
|
||||
|
||||
```shell
|
||||
cabana "a2a0ccea32023010|2023-07-27--13-01-19" --dcam --ecam
|
||||
```
|
||||
|
||||
### Streaming CAN Messages from a comma Device
|
||||
|
||||
[SSH into your device](https://github.com/commaai/openpilot/wiki/SSH) and start the bridge with the following command:
|
||||
|
||||
```shell
|
||||
cd /data/openpilot/cereal/messaging/
|
||||
./bridge &
|
||||
```
|
||||
|
||||
Then Run Cabana with the device's IP address:
|
||||
|
||||
```shell
|
||||
cabana --zmq <ipaddress>
|
||||
```
|
||||
|
||||
Replace <ipaddress> with your comma device's IP address.
|
||||
|
||||
While streaming from the device, Cabana will log the CAN messages to a local directory. By default, this directory is ~/cabana_live_stream/. You can change the log directory in Cabana by navigating to menu -> tools -> settings.
|
||||
|
||||
After disconnecting from the device, you can replay the logged CAN messages from the stream selector dialog -> browse local route.
|
||||
|
||||
### Streaming CAN Messages from Panda
|
||||
|
||||
To read CAN messages from a connected Panda, use the following command:
|
||||
|
||||
```shell
|
||||
cabana --panda
|
||||
```
|
||||
|
||||
### Using the Stream Selector Dialog
|
||||
|
||||
If you run Cabana without any arguments, a stream selector dialog will pop up, allowing you to choose the stream.
|
||||
|
||||
```shell
|
||||
cabana
|
||||
```
|
||||
|
||||
## Additional Information
|
||||
|
||||
For more information, see the [openpilot wiki](https://github.com/commaai/openpilot/wiki/Cabana)
|
||||
97
tools/cabana/SConscript
Normal file
97
tools/cabana/SConscript
Normal file
@@ -0,0 +1,97 @@
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
Import('env', 'arch', 'common', 'messaging', 'visionipc', 'replay_lib', 'cereal')
|
||||
|
||||
qt_env = env.Clone()
|
||||
qt_modules = ["Widgets", "Gui", "Core", "Network", "Concurrent", "DBus", "Xml"]
|
||||
|
||||
qt_libs = []
|
||||
if arch == "Darwin":
|
||||
brew_prefix = subprocess.check_output(['brew', '--prefix'], encoding='utf8').strip()
|
||||
qt_env['QTDIR'] = f"{brew_prefix}/opt/qt@5"
|
||||
qt_dirs = [
|
||||
os.path.join(qt_env['QTDIR'], "include"),
|
||||
]
|
||||
qt_dirs += [f"{qt_env['QTDIR']}/include/Qt{m}" for m in qt_modules]
|
||||
qt_env["LINKFLAGS"] += ["-F" + os.path.join(qt_env['QTDIR'], "lib")]
|
||||
qt_env["FRAMEWORKS"] += [f"Qt{m}" for m in qt_modules] + ["OpenGL"]
|
||||
qt_env.AppendENVPath('PATH', os.path.join(qt_env['QTDIR'], "bin"))
|
||||
else:
|
||||
qt_install_prefix = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_PREFIX'], encoding='utf8').strip()
|
||||
qt_install_headers = subprocess.check_output(['qmake', '-query', 'QT_INSTALL_HEADERS'], encoding='utf8').strip()
|
||||
|
||||
qt_env['QTDIR'] = qt_install_prefix
|
||||
qt_dirs = [
|
||||
f"{qt_install_headers}",
|
||||
]
|
||||
|
||||
qt_gui_path = os.path.join(qt_install_headers, "QtGui")
|
||||
qt_gui_dirs = [d for d in os.listdir(qt_gui_path) if os.path.isdir(os.path.join(qt_gui_path, d))]
|
||||
qt_dirs += [f"{qt_install_headers}/QtGui/{qt_gui_dirs[0]}/QtGui", ] if qt_gui_dirs else []
|
||||
qt_dirs += [f"{qt_install_headers}/Qt{m}" for m in qt_modules]
|
||||
|
||||
qt_libs = [f"Qt5{m}" for m in qt_modules]
|
||||
if arch == "larch64":
|
||||
qt_libs += ["GLESv2", "wayland-client"]
|
||||
qt_env.PrependENVPath('PATH', Dir("#third_party/qt5/larch64/bin/").abspath)
|
||||
elif arch != "Darwin":
|
||||
qt_libs += ["GL"]
|
||||
qt_env['QT3DIR'] = qt_env['QTDIR']
|
||||
qt_env.Tool('qt3')
|
||||
|
||||
qt_env['CPPPATH'] += qt_dirs + ["#third_party/qrcode"]
|
||||
qt_flags = [
|
||||
"-D_REENTRANT",
|
||||
"-DQT_NO_DEBUG",
|
||||
"-DQT_WIDGETS_LIB",
|
||||
"-DQT_GUI_LIB",
|
||||
"-DQT_CORE_LIB",
|
||||
"-DQT_MESSAGELOGCONTEXT",
|
||||
]
|
||||
qt_env['CXXFLAGS'] += qt_flags
|
||||
qt_env['LIBPATH'] += ['#selfdrive/ui', ]
|
||||
qt_env['LIBS'] = qt_libs
|
||||
|
||||
base_frameworks = qt_env['FRAMEWORKS']
|
||||
base_libs = [common, messaging, cereal, visionipc, '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 = base_libs
|
||||
|
||||
cabana_env = qt_env.Clone()
|
||||
|
||||
cabana_libs = [cereal, messaging, visionipc, replay_lib, 'avutil', 'avcodec', 'avformat', 'bz2', 'zstd', 'curl', 'yuv', 'usb-1.0'] + qt_libs
|
||||
opendbc_path = '-DOPENDBC_FILE_PATH=\'"%s"\'' % (cabana_env.Dir("../../opendbc/dbc").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',
|
||||
'streams/routes.cc', 'dbc/dbc.cc', 'dbc/dbcfile.cc', 'dbc/dbcmanager.cc',
|
||||
'utils/export.cc', 'utils/util.cc', 'utils/elidedlabel.cc', 'utils/api.cc',
|
||||
'chart/chartswidget.cc', 'chart/chart.cc', 'chart/signalselector.cc', 'chart/tiplabel.cc', 'chart/sparkline.cc',
|
||||
'commands.cc', 'messageswidget.cc', 'streamselector.cc', 'settings.cc', 'panda.cc',
|
||||
'cameraview.cc', 'detailwidget.cc', 'tools/findsimilarbits.cc', 'tools/findsignal.cc', 'tools/routeinfo.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", '#opendbc_repo', "#cereal", "#msgq_repo"])
|
||||
1
tools/cabana/assets/.gitignore
vendored
Normal file
1
tools/cabana/assets/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.cc
|
||||
6
tools/cabana/assets/assets.qrc
Normal file
6
tools/cabana/assets/assets.qrc
Normal 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
Normal file
BIN
tools/cabana/assets/cabana-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
504
tools/cabana/binaryview.cc
Normal file
504
tools/cabana/binaryview.cc
Normal file
@@ -0,0 +1,504 @@
|
||||
#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();
|
||||
bit_flip_tracker = {};
|
||||
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);
|
||||
}
|
||||
endResetModel();
|
||||
updateState();
|
||||
}
|
||||
|
||||
void BinaryViewModel::updateItem(int row, int col, uint8_t val, const QColor &color) {
|
||||
auto &item = items[row * column_count + col];
|
||||
item.valid = true;
|
||||
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;
|
||||
// Handle size changes in binary data
|
||||
if (binary.size() > row_count) {
|
||||
beginInsertRows({}, row_count, binary.size() - 1);
|
||||
row_count = binary.size();
|
||||
items.resize(row_count * column_count);
|
||||
endInsertRows();
|
||||
}
|
||||
|
||||
auto &bit_flips = heatmap_live_mode ? last_msg.bit_flip_counts : getBitFlipChanges(binary.size());
|
||||
// Find the maximum bit flip count across the message
|
||||
uint32_t max_bit_flip_count = 1; // Default to 1 to avoid division by zero
|
||||
for (const auto &row : bit_flips) {
|
||||
for (uint32_t count : row) {
|
||||
max_bit_flip_count = std::max(max_bit_flip_count, count);
|
||||
}
|
||||
}
|
||||
|
||||
const double max_alpha = 255.0;
|
||||
const double min_alpha_with_signal = 25.0; // Base alpha for small flip counts
|
||||
const double min_alpha_no_signal = 10.0; // Base alpha for small flip counts for no signal bits
|
||||
const double log_factor = 1.0 + 0.2; // Factor for logarithmic scaling
|
||||
const double log_scaler = max_alpha / log2(log_factor * max_bit_flip_count);
|
||||
|
||||
for (size_t i = 0; i < binary.size(); ++i) {
|
||||
for (int j = 0; j < 8; ++j) {
|
||||
auto &item = items[i * column_count + j];
|
||||
int bit_val = (binary[i] >> (7 - j)) & 1;
|
||||
|
||||
double alpha = item.sigs.empty() ? 0 : min_alpha_with_signal;
|
||||
uint32_t flip_count = bit_flips[i][j];
|
||||
if (flip_count > 0) {
|
||||
double normalized_alpha = log2(1.0 + flip_count * log_factor) * log_scaler;
|
||||
double min_alpha = item.sigs.empty() ? min_alpha_no_signal : min_alpha_with_signal;
|
||||
alpha = std::clamp(normalized_alpha, min_alpha, max_alpha);
|
||||
}
|
||||
|
||||
auto color = item.bg_color;
|
||||
color.setAlpha(alpha);
|
||||
updateItem(i, j, bit_val, color);
|
||||
}
|
||||
updateItem(i, 8, binary[i], last_msg.colors[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<std::array<uint32_t, 8>> &BinaryViewModel::getBitFlipChanges(size_t msg_size) {
|
||||
// Return cached results if time range and data are unchanged
|
||||
auto time_range = can->timeRange();
|
||||
if (bit_flip_tracker.time_range == time_range && !bit_flip_tracker.flip_counts.empty())
|
||||
return bit_flip_tracker.flip_counts;
|
||||
|
||||
bit_flip_tracker.time_range = time_range;
|
||||
bit_flip_tracker.flip_counts.assign(msg_size, std::array<uint32_t, 8>{});
|
||||
|
||||
// Iterate over events within the specified time range and calculate bit flips
|
||||
auto [first, last] = can->eventsInRange(msg_id, time_range);
|
||||
if (std::distance(first, last) <= 1) return bit_flip_tracker.flip_counts;
|
||||
|
||||
std::vector<uint8_t> prev_values((*first)->dat, (*first)->dat + (*first)->size);
|
||||
for (auto it = std::next(first); it != last; ++it) {
|
||||
const CanEvent *event = *it;
|
||||
int size = std::min<int>(msg_size, event->size);
|
||||
for (int i = 0; i < size; ++i) {
|
||||
const uint8_t diff = event->dat[i] ^ prev_values[i];
|
||||
if (!diff) continue;
|
||||
|
||||
auto &bit_flips = bit_flip_tracker.flip_counts[i];
|
||||
for (int bit = 0; bit < 8; ++bit) {
|
||||
if (diff & (1u << bit)) ++bit_flips[7 - bit];
|
||||
}
|
||||
prev_values[i] = event->dat[i];
|
||||
}
|
||||
}
|
||||
|
||||
return bit_flip_tracker.flip_counts;
|
||||
}
|
||||
|
||||
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(bin_view->is_message_active ? QPalette::Normal : QPalette::Disabled, 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 color 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
104
tools/cabana/binaryview.h
Normal file
104
tools/cabana/binaryview.h
Normal file
@@ -0,0 +1,104 @@
|
||||
#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;
|
||||
}
|
||||
const std::vector<std::array<uint32_t, 8>> &getBitFlipChanges(size_t msg_size);
|
||||
|
||||
struct BitFlipTracker {
|
||||
std::optional<std::pair<double, double>> time_range;
|
||||
std::vector<std::array<uint32_t, 8>> flip_counts;
|
||||
} bit_flip_tracker;
|
||||
|
||||
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;
|
||||
bool heatmap_live_mode = true;
|
||||
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;
|
||||
void updateState() { model->updateState(); }
|
||||
void paintEvent(QPaintEvent *event) override {
|
||||
is_message_active = can->isMessageActive(model->msg_id);
|
||||
QTableView::paintEvent(event);
|
||||
}
|
||||
QSize minimumSizeHint() const override;
|
||||
void setHeatmapLiveMode(bool live) { model->heatmap_live_mode = live; updateState(); }
|
||||
|
||||
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;
|
||||
bool is_message_active = false;
|
||||
const cabana::Signal *resize_sig = nullptr;
|
||||
const cabana::Signal *hovered_sig = nullptr;
|
||||
friend class BinaryItemDelegate;
|
||||
};
|
||||
82
tools/cabana/cabana.cc
Normal file
82
tools/cabana/cabana.cc
Normal file
@@ -0,0 +1,82 @@
|
||||
#include <QApplication>
|
||||
#include <QCommandLineParser>
|
||||
|
||||
#include "tools/cabana/mainwin.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({"auto", "Auto load the route from the best available source (no video): internal, openpilotci, comma_api, car_segments, testing_closet"});
|
||||
cmd_parser.addOption({"qcam", "load qcamera"});
|
||||
cmd_parser.addOption({"ecam", "load wide road camera"});
|
||||
cmd_parser.addOption({"dcam", "load driver camera"});
|
||||
cmd_parser.addOption({"msgq", "read can messages from the msgq"});
|
||||
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", "read can messages from zmq at the specified ip-address", "ip-address"});
|
||||
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);
|
||||
|
||||
AbstractStream *stream = nullptr;
|
||||
|
||||
if (cmd_parser.isSet("msgq")) {
|
||||
stream = new DeviceStream(&app);
|
||||
} else if (cmd_parser.isSet("zmq")) {
|
||||
stream = new DeviceStream(&app, cmd_parser.value("zmq"));
|
||||
} else if (cmd_parser.isSet("panda") || cmd_parser.isSet("panda-serial")) {
|
||||
try {
|
||||
stream = new PandaStream(&app, {.serial = cmd_parser.value("panda-serial")});
|
||||
} catch (std::exception &e) {
|
||||
qWarning() << e.what();
|
||||
return 0;
|
||||
}
|
||||
} else if (SocketCanStream::available() && cmd_parser.isSet("socketcan")) {
|
||||
stream = new SocketCanStream(&app, {.device = cmd_parser.value("socketcan")});
|
||||
} 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 = std::make_unique<ReplayStream>(&app);
|
||||
bool auto_source = cmd_parser.isSet("auto");
|
||||
if (!replay_stream->loadRoute(route, cmd_parser.value("data_dir"), replay_flags, auto_source)) {
|
||||
return 0;
|
||||
}
|
||||
stream = replay_stream.release();
|
||||
}
|
||||
}
|
||||
|
||||
MainWindow w(stream, cmd_parser.value("dbc"));
|
||||
return app.exec();
|
||||
}
|
||||
261
tools/cabana/cameraview.cc
Normal file
261
tools/cabana/cameraview.cc
Normal file
@@ -0,0 +1,261 @@
|
||||
#include "tools/cabana/cameraview.h"
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <OpenGL/gl3.h>
|
||||
#else
|
||||
#include <GLES3/gl3.h>
|
||||
#endif
|
||||
|
||||
#include <QApplication>
|
||||
|
||||
namespace {
|
||||
|
||||
const char frame_vertex_shader[] =
|
||||
#ifdef __APPLE__
|
||||
"#version 330 core\n"
|
||||
#else
|
||||
"#version 300 es\n"
|
||||
#endif
|
||||
"layout(location = 0) in vec4 aPosition;\n"
|
||||
"layout(location = 1) in vec2 aTexCoord;\n"
|
||||
"uniform mat4 uTransform;\n"
|
||||
"out vec2 vTexCoord;\n"
|
||||
"void main() {\n"
|
||||
" gl_Position = uTransform * aPosition;\n"
|
||||
" vTexCoord = aTexCoord;\n"
|
||||
"}\n";
|
||||
|
||||
const char frame_fragment_shader[] =
|
||||
#ifdef __APPLE__
|
||||
"#version 330 core\n"
|
||||
#else
|
||||
"#version 300 es\n"
|
||||
"precision mediump float;\n"
|
||||
#endif
|
||||
"uniform sampler2D uTextureY;\n"
|
||||
"uniform sampler2D uTextureUV;\n"
|
||||
"in vec2 vTexCoord;\n"
|
||||
"out vec4 colorOut;\n"
|
||||
"void main() {\n"
|
||||
" float y = texture(uTextureY, vTexCoord).r;\n"
|
||||
" vec2 uv = texture(uTextureUV, vTexCoord).rg - 0.5;\n"
|
||||
" float r = y + 1.402 * uv.y;\n"
|
||||
" float g = y - 0.344 * uv.x - 0.714 * uv.y;\n"
|
||||
" float b = y + 1.772 * uv.x;\n"
|
||||
" colorOut = vec4(r, g, b, 1.0);\n"
|
||||
"}\n";
|
||||
|
||||
} // namespace
|
||||
|
||||
CameraWidget::CameraWidget(std::string stream_name, VisionStreamType type, QWidget* parent) :
|
||||
stream_name(stream_name), active_stream_type(type), requested_stream_type(type), QOpenGLWidget(parent) {
|
||||
setAttribute(Qt::WA_OpaquePaintEvent);
|
||||
qRegisterMetaType<std::set<VisionStreamType>>("availableStreams");
|
||||
QObject::connect(this, &CameraWidget::vipcThreadConnected, this, &CameraWidget::vipcConnected, Qt::BlockingQueuedConnection);
|
||||
QObject::connect(this, &CameraWidget::vipcThreadFrameReceived, this, &CameraWidget::vipcFrameReceived, Qt::QueuedConnection);
|
||||
QObject::connect(this, &CameraWidget::vipcAvailableStreamsUpdated, this, &CameraWidget::availableStreamsUpdated, Qt::QueuedConnection);
|
||||
QObject::connect(QApplication::instance(), &QCoreApplication::aboutToQuit, this, &CameraWidget::stopVipcThread);
|
||||
}
|
||||
|
||||
CameraWidget::~CameraWidget() {
|
||||
makeCurrent();
|
||||
stopVipcThread();
|
||||
if (isValid()) {
|
||||
glDeleteVertexArrays(1, &frame_vao);
|
||||
glDeleteBuffers(1, &frame_vbo);
|
||||
glDeleteBuffers(1, &frame_ibo);
|
||||
glDeleteTextures(2, textures);
|
||||
shader_program_.reset();
|
||||
}
|
||||
doneCurrent();
|
||||
}
|
||||
|
||||
void CameraWidget::initializeGL() {
|
||||
initializeOpenGLFunctions();
|
||||
|
||||
shader_program_ = std::make_unique<QOpenGLShaderProgram>(context());
|
||||
shader_program_->addShaderFromSourceCode(QOpenGLShader::Vertex, frame_vertex_shader);
|
||||
shader_program_->addShaderFromSourceCode(QOpenGLShader::Fragment, frame_fragment_shader);
|
||||
shader_program_->link();
|
||||
|
||||
GLint frame_pos_loc = shader_program_->attributeLocation("aPosition");
|
||||
GLint frame_texcoord_loc = shader_program_->attributeLocation("aTexCoord");
|
||||
|
||||
auto [x1, x2, y1, y2] = requested_stream_type == VISION_STREAM_DRIVER ? std::tuple(0.f, 1.f, 1.f, 0.f) : std::tuple(1.f, 0.f, 1.f, 0.f);
|
||||
const uint8_t frame_indicies[] = {0, 1, 2, 0, 2, 3};
|
||||
const float frame_coords[4][4] = {
|
||||
{-1.0, -1.0, x2, y1}, // bl
|
||||
{-1.0, 1.0, x2, y2}, // tl
|
||||
{ 1.0, 1.0, x1, y2}, // tr
|
||||
{ 1.0, -1.0, x1, y1}, // br
|
||||
};
|
||||
|
||||
glGenVertexArrays(1, &frame_vao);
|
||||
glBindVertexArray(frame_vao);
|
||||
glGenBuffers(1, &frame_vbo);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, frame_vbo);
|
||||
glBufferData(GL_ARRAY_BUFFER, sizeof(frame_coords), frame_coords, GL_STATIC_DRAW);
|
||||
glEnableVertexAttribArray(frame_pos_loc);
|
||||
glVertexAttribPointer(frame_pos_loc, 2, GL_FLOAT, GL_FALSE,
|
||||
sizeof(frame_coords[0]), (const void *)0);
|
||||
glEnableVertexAttribArray(frame_texcoord_loc);
|
||||
glVertexAttribPointer(frame_texcoord_loc, 2, GL_FLOAT, GL_FALSE,
|
||||
sizeof(frame_coords[0]), (const void *)(sizeof(float) * 2));
|
||||
glGenBuffers(1, &frame_ibo);
|
||||
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, frame_ibo);
|
||||
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(frame_indicies), frame_indicies, GL_STATIC_DRAW);
|
||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||
glBindVertexArray(0);
|
||||
|
||||
glGenTextures(2, textures);
|
||||
|
||||
shader_program_->bind();
|
||||
shader_program_->setUniformValue("uTextureY", 0);
|
||||
shader_program_->setUniformValue("uTextureUV", 1);
|
||||
shader_program_->release();
|
||||
}
|
||||
|
||||
void CameraWidget::showEvent(QShowEvent *event) {
|
||||
if (!vipc_thread) {
|
||||
clearFrames();
|
||||
vipc_thread = new QThread();
|
||||
connect(vipc_thread, &QThread::started, [=]() { vipcThread(); });
|
||||
connect(vipc_thread, &QThread::finished, vipc_thread, &QObject::deleteLater);
|
||||
vipc_thread->start();
|
||||
}
|
||||
}
|
||||
|
||||
void CameraWidget::stopVipcThread() {
|
||||
makeCurrent();
|
||||
if (vipc_thread) {
|
||||
vipc_thread->requestInterruption();
|
||||
vipc_thread->quit();
|
||||
vipc_thread->wait();
|
||||
vipc_thread = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void CameraWidget::availableStreamsUpdated(std::set<VisionStreamType> streams) {
|
||||
available_streams = streams;
|
||||
}
|
||||
|
||||
void CameraWidget::paintGL() {
|
||||
glClearColor(bg.redF(), bg.greenF(), bg.blueF(), bg.alphaF());
|
||||
glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
|
||||
|
||||
std::lock_guard lk(frame_lock);
|
||||
if (!current_frame_) return;
|
||||
|
||||
// Scale for aspect ratio
|
||||
float widget_ratio = (float)width() / height();
|
||||
float frame_ratio = (float)stream_width / stream_height;
|
||||
float scale_x = std::min(frame_ratio / widget_ratio, 1.0f);
|
||||
float scale_y = std::min(widget_ratio / frame_ratio, 1.0f);
|
||||
|
||||
glViewport(0, 0, width() * devicePixelRatio(), height() * devicePixelRatio());
|
||||
|
||||
shader_program_->bind();
|
||||
QMatrix4x4 transform;
|
||||
transform.scale(scale_x, scale_y, 1.0f);
|
||||
shader_program_->setUniformValue("uTransform", transform);
|
||||
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
||||
|
||||
glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, textures[0]);
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width, stream_height, GL_RED, GL_UNSIGNED_BYTE, current_frame_->y);
|
||||
|
||||
glPixelStorei(GL_UNPACK_ROW_LENGTH, stream_stride/2);
|
||||
glActiveTexture(GL_TEXTURE1);
|
||||
glBindTexture(GL_TEXTURE_2D, textures[1]);
|
||||
glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, stream_width/2, stream_height/2, GL_RG, GL_UNSIGNED_BYTE, current_frame_->uv);
|
||||
|
||||
glBindVertexArray(frame_vao);
|
||||
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_BYTE, nullptr);
|
||||
glBindVertexArray(0);
|
||||
|
||||
// Reset both texture units
|
||||
glActiveTexture(GL_TEXTURE1);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
glActiveTexture(GL_TEXTURE0);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
|
||||
glPixelStorei(GL_UNPACK_ROW_LENGTH, 0);
|
||||
|
||||
shader_program_->release();
|
||||
}
|
||||
|
||||
void CameraWidget::vipcConnected(VisionIpcClient *vipc_client) {
|
||||
makeCurrent();
|
||||
stream_width = vipc_client->buffers[0].width;
|
||||
stream_height = vipc_client->buffers[0].height;
|
||||
stream_stride = vipc_client->buffers[0].stride;
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, textures[0]);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, stream_width, stream_height, 0, GL_RED, GL_UNSIGNED_BYTE, nullptr);
|
||||
assert(glGetError() == GL_NO_ERROR);
|
||||
|
||||
glBindTexture(GL_TEXTURE_2D, textures[1]);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RG8, stream_width/2, stream_height/2, 0, GL_RG, GL_UNSIGNED_BYTE, nullptr);
|
||||
assert(glGetError() == GL_NO_ERROR);
|
||||
}
|
||||
|
||||
void CameraWidget::vipcFrameReceived() {
|
||||
update();
|
||||
}
|
||||
|
||||
void CameraWidget::vipcThread() {
|
||||
VisionStreamType cur_stream = requested_stream_type;
|
||||
std::unique_ptr<VisionIpcClient> vipc_client;
|
||||
VisionIpcBufExtra frame_meta = {};
|
||||
|
||||
while (!QThread::currentThread()->isInterruptionRequested()) {
|
||||
if (!vipc_client || cur_stream != requested_stream_type) {
|
||||
clearFrames();
|
||||
qDebug().nospace() << "connecting to stream " << requested_stream_type << ", was connected to " << cur_stream;
|
||||
cur_stream = requested_stream_type;
|
||||
vipc_client.reset(new VisionIpcClient(stream_name, cur_stream, false));
|
||||
}
|
||||
active_stream_type = cur_stream;
|
||||
|
||||
if (!vipc_client->connected) {
|
||||
clearFrames();
|
||||
auto streams = VisionIpcClient::getAvailableStreams(stream_name, false);
|
||||
if (streams.empty()) {
|
||||
QThread::msleep(100);
|
||||
continue;
|
||||
}
|
||||
emit vipcAvailableStreamsUpdated(streams);
|
||||
|
||||
if (!vipc_client->connect(false)) {
|
||||
QThread::msleep(100);
|
||||
continue;
|
||||
}
|
||||
emit vipcThreadConnected(vipc_client.get());
|
||||
}
|
||||
|
||||
if (VisionBuf *buf = vipc_client->recv(&frame_meta, 100)) {
|
||||
{
|
||||
std::lock_guard lk(frame_lock);
|
||||
current_frame_ = buf;
|
||||
frame_meta_ = frame_meta;
|
||||
}
|
||||
emit vipcThreadFrameReceived();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void CameraWidget::clearFrames() {
|
||||
std::lock_guard lk(frame_lock);
|
||||
current_frame_ = nullptr;
|
||||
available_streams.clear();
|
||||
}
|
||||
64
tools/cabana/cameraview.h
Normal file
64
tools/cabana/cameraview.h
Normal file
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include <QOpenGLFunctions>
|
||||
#include <QOpenGLShaderProgram>
|
||||
#include <QOpenGLWidget>
|
||||
#include <QThread>
|
||||
|
||||
#include "msgq/visionipc/visionipc_client.h"
|
||||
|
||||
class CameraWidget : public QOpenGLWidget, protected QOpenGLFunctions {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
using QOpenGLWidget::QOpenGLWidget;
|
||||
explicit CameraWidget(std::string stream_name, VisionStreamType stream_type, QWidget* parent = nullptr);
|
||||
~CameraWidget();
|
||||
void setStreamType(VisionStreamType type) { requested_stream_type = type; }
|
||||
VisionStreamType getStreamType() { return active_stream_type; }
|
||||
void stopVipcThread();
|
||||
|
||||
signals:
|
||||
void clicked();
|
||||
void vipcThreadConnected(VisionIpcClient *);
|
||||
void vipcThreadFrameReceived();
|
||||
void vipcAvailableStreamsUpdated(std::set<VisionStreamType>);
|
||||
|
||||
protected:
|
||||
void paintGL() override;
|
||||
void initializeGL() override;
|
||||
void showEvent(QShowEvent *event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override { emit clicked(); }
|
||||
void vipcThread();
|
||||
void clearFrames();
|
||||
|
||||
GLuint frame_vao, frame_vbo, frame_ibo;
|
||||
GLuint textures[2];
|
||||
std::unique_ptr<QOpenGLShaderProgram> shader_program_;
|
||||
QColor bg = Qt::black;
|
||||
|
||||
std::string stream_name;
|
||||
int stream_width = 0;
|
||||
int stream_height = 0;
|
||||
int stream_stride = 0;
|
||||
std::atomic<VisionStreamType> active_stream_type;
|
||||
std::atomic<VisionStreamType> requested_stream_type;
|
||||
std::set<VisionStreamType> available_streams;
|
||||
QThread *vipc_thread = nullptr;
|
||||
std::recursive_mutex frame_lock;
|
||||
VisionBuf* current_frame_ = nullptr;
|
||||
VisionIpcBufExtra frame_meta_ = {};
|
||||
|
||||
protected slots:
|
||||
void vipcConnected(VisionIpcClient *vipc_client);
|
||||
void vipcFrameReceived();
|
||||
void availableStreamsUpdated(std::set<VisionStreamType> streams);
|
||||
};
|
||||
|
||||
Q_DECLARE_METATYPE(std::set<VisionStreamType>);
|
||||
862
tools/cabana/chart/chart.cc
Normal file
862
tools/cabana/chart/chart.cc
Normal file
@@ -0,0 +1,862 @@
|
||||
#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;
|
||||
const double MIN_ZOOM_SECONDS = 0.01; // 10ms
|
||||
// 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(utils::isDarkTheme() ? 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));
|
||||
}
|
||||
axis_x->setLineVisible(false);
|
||||
axis_y->setLineVisible(false);
|
||||
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) {
|
||||
auto it = std::find_if(sigs.begin(), sigs.end(), [sig](auto &s) { return s.sig == sig; });
|
||||
if (it != sigs.end()) {
|
||||
if (it->series->color() != sig->color) {
|
||||
setSeriesColor(it->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(num_points == 1 || 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;
|
||||
for (const CanEvent *e : events) {
|
||||
if (sig->getValue(e->dat, e->size, &value)) {
|
||||
const double ts = can->toSeconds(e->mono_time);
|
||||
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() || can->toSeconds(it->second.back()->mono_time) > 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);
|
||||
}
|
||||
const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals;
|
||||
s.series->replace(QVector<QPointF>(points.cbegin(), points.cend()));
|
||||
}
|
||||
}
|
||||
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.horizontalAdvance(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;
|
||||
}
|
||||
|
||||
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(), can->minSeconds(), can->maxSeconds());
|
||||
double max = std::clamp(chart()->mapToValue(rect.bottomRight()).x(), can->minSeconds(), can->maxSeconds());
|
||||
if (rubber->width() <= 0) {
|
||||
// no rubber dragged, seek to mouse position
|
||||
can->seekTo(min);
|
||||
} else if (rubber->width() > 10 && (max - min) > MIN_ZOOM_SECONDS) {
|
||||
charts_widget->zoom_undo_stack->push(new ZoomCommand({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(), can->minSeconds(), can->maxSeconds()));
|
||||
}
|
||||
}
|
||||
|
||||
auto rubber = findChild<QRubberBand *>();
|
||||
bool is_zooming = rubber && rubber->isVisible();
|
||||
clearTrackPoints();
|
||||
|
||||
if (!is_zooming && plot_area.contains(ev->pos()) && isActiveWindow()) {
|
||||
charts_widget->showValueTip(secondsAtPoint(ev->pos()));
|
||||
} 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 = s.sig->formatValue(it->y(), false);
|
||||
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(), 1));
|
||||
painter->drawLine(QPointF{x, plot_area.top() - 1}, 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, utils::isDarkTheme() ? 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);
|
||||
const auto &points = series_type == SeriesType::StepLine ? s.step_vals : s.vals;
|
||||
s.series->replace(QVector<QPointF>(points.cbegin(), points.cend()));
|
||||
}
|
||||
updateSeriesPoints();
|
||||
updateTitle();
|
||||
|
||||
menu->actions()[(int)type]->setChecked(true);
|
||||
}
|
||||
}
|
||||
|
||||
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
Normal file
123
tools/cabana/chart/chart.h
Normal 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();
|
||||
double secondsAtPoint(const QPointF &pt) const { return chart()->mapToValue(pt).x(); }
|
||||
|
||||
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 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;
|
||||
};
|
||||
595
tools/cabana/chart/chartswidget.cc
Normal file
595
tools/cabana/chart/chartswidget.cc
Normal file
@@ -0,0 +1,595 @@
|
||||
#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
|
||||
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);
|
||||
|
||||
auto chart_type_action = toolbar->addAction("");
|
||||
QMenu *chart_type_menu = new QMenu(this);
|
||||
auto types = std::array{tr("Line"), tr("Step"), tr("Scatter")};
|
||||
for (int i = 0; i < types.size(); ++i) {
|
||||
QString type_text = types[i];
|
||||
chart_type_menu->addAction(type_text, this, [=]() {
|
||||
settings.chart_series_type = i;
|
||||
chart_type_action->setText("Type: " + type_text);
|
||||
settingChanged();
|
||||
});
|
||||
}
|
||||
chart_type_action->setText("Type: " + types[settings.chart_series_type]);
|
||||
chart_type_action->setMenu(chart_type_menu);
|
||||
qobject_cast<QToolButton *>(toolbar->widgetForAction(chart_type_action))->setPopupMode(QToolButton::InstantPopup);
|
||||
|
||||
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);
|
||||
|
||||
QWidget *spacer = new QWidget(this);
|
||||
spacer->setSizePolicy(QSizePolicy::MinimumExpanding, QSizePolicy::Preferred);
|
||||
toolbar->addWidget(spacer);
|
||||
|
||||
range_lb_action = toolbar->addWidget(range_lb = new QLabel(this));
|
||||
range_slider = new LogSlider(1000, Qt::Horizontal, this);
|
||||
range_slider->setFixedWidth(150 * qApp->devicePixelRatio());
|
||||
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 = std::make_pair(can->minSeconds(), can->minSeconds() + 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(can, &AbstractStream::seeking, this, &ChartsWidget::updateState);
|
||||
QObject::connect(can, &AbstractStream::timeRangeChanged, this, &ChartsWidget::timeRangeChanged);
|
||||
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, &ChartsWidget::toggleChartsDocking);
|
||||
|
||||
setIsDocked(true);
|
||||
newTab();
|
||||
qApp->installEventFilter(this);
|
||||
setWhatsThis(tr(R"(
|
||||
<b>Chart View</b><br />
|
||||
<b>Click</b>: Click to seek to a corresponding time.<br />
|
||||
<b>Drag</b>: Zoom into the chart.<br />
|
||||
<b>Shift + Drag</b>: Scrub through the chart to view values.<br />
|
||||
<b>Right Mouse</b>: Open the context menu.<br />
|
||||
)"));
|
||||
}
|
||||
|
||||
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::timeRangeChanged(const std::optional<std::pair<double, double>> &time_range) {
|
||||
updateToolBar();
|
||||
updateState();
|
||||
}
|
||||
|
||||
void ChartsWidget::zoomReset() {
|
||||
can->setTimeRange(std::nullopt);
|
||||
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) {
|
||||
emit showTip(sec);
|
||||
if (sec < 0 && !value_tip_visible_) return;
|
||||
|
||||
value_tip_visible_ = sec >= 0;
|
||||
for (auto c : currentCharts()) {
|
||||
value_tip_visible_ ? c->showTip(sec) : c->hideTip();
|
||||
}
|
||||
}
|
||||
|
||||
void ChartsWidget::updateState() {
|
||||
if (charts.isEmpty()) return;
|
||||
|
||||
const auto &time_range = can->timeRange();
|
||||
const double cur_sec = can->currentSec();
|
||||
if (!time_range.has_value()) {
|
||||
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(can->minSeconds(), cur_sec - max_chart_range * 0.1);
|
||||
}
|
||||
double max_sec = std::min(display_range.first + max_chart_range, can->maxSeconds());
|
||||
display_range.first = std::max(can->minSeconds(), max_sec - max_chart_range);
|
||||
display_range.second = display_range.first + max_chart_range;
|
||||
}
|
||||
|
||||
const auto &range = time_range ? *time_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::setIsDocked(bool docked) {
|
||||
is_docked = docked;
|
||||
dock_btn->setIcon(is_docked ? "arrow-up-right-square" : "arrow-down-left-square");
|
||||
dock_btn->setToolTip(is_docked ? tr("Float the charts window") : tr("Dock the charts window"));
|
||||
}
|
||||
|
||||
void ChartsWidget::updateToolBar() {
|
||||
title_label->setText(tr("Charts: %1").arg(charts.size()));
|
||||
columns_action->setText(tr("Columns: %1").arg(column_count));
|
||||
range_lb->setText(utils::formatSeconds(max_chart_range));
|
||||
|
||||
bool is_zoomed = can->timeRange().has_value();
|
||||
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(can->timeRange()->first, 0, 'f', 2).arg(can->timeRange()->second, 0, 'f', 2) : "");
|
||||
remove_all_btn->setEnabled(!charts.isEmpty());
|
||||
}
|
||||
|
||||
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 = utils::isDarkTheme() ? QChart::QChart::ChartThemeDark : QChart::ChartThemeLight;
|
||||
for (auto c : charts) {
|
||||
c->setTheme(theme);
|
||||
}
|
||||
}
|
||||
if (range_slider->maximum() != settings.max_cached_minutes * 60) {
|
||||
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(int pos) {
|
||||
auto chart = new ChartView(can->timeRange().value_or(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));
|
||||
pos = std::clamp(pos, 0, charts.size());
|
||||
charts.insert(pos, chart);
|
||||
currentCharts().insert(pos, 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) {
|
||||
int pos = charts.indexOf(src_chart) + 1;
|
||||
for (auto it = src_chart->sigs.begin() + 1; it != src_chart->sigs.end(); /**/) {
|
||||
auto c = createChart(pos);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
QStringList ChartsWidget::serializeChartIds() const {
|
||||
QStringList chart_ids;
|
||||
for (auto c : charts) {
|
||||
QStringList ids;
|
||||
for (const auto& s : c->sigs)
|
||||
ids += QString("%1|%2").arg(s.msg_id.toString(), s.sig->name);
|
||||
chart_ids += ids.join(',');
|
||||
}
|
||||
std::reverse(chart_ids.begin(), chart_ids.end());
|
||||
return chart_ids;
|
||||
}
|
||||
|
||||
void ChartsWidget::restoreChartsFromIds(const QStringList& chart_ids) {
|
||||
for (const auto& chart_id : chart_ids) {
|
||||
int index = 0;
|
||||
for (const auto& part : chart_id.split(',')) {
|
||||
const auto sig_parts = part.split('|');
|
||||
if (sig_parts.size() != 2) continue;
|
||||
MessageId msg_id = MessageId::fromString(sig_parts[0]);
|
||||
if (auto* msg = dbc()->msg(msg_id))
|
||||
if (auto* sig = msg->sig(sig_parts[1]))
|
||||
showChart(msg_id, sig, true, index++ > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 * 1.5 * qApp->devicePixelRatio(), QWidget::minimumSizeHint().height());
|
||||
}
|
||||
|
||||
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 *o, QEvent *e) {
|
||||
if (!value_tip_visible_) return false;
|
||||
|
||||
if (e->type() == QEvent::MouseMove) {
|
||||
bool on_tip = qobject_cast<TipLabel *>(o) != nullptr;
|
||||
auto global_pos = static_cast<QMouseEvent *>(e)->globalPos();
|
||||
|
||||
for (const auto &c : charts) {
|
||||
auto local_pos = c->mapFromGlobal(global_pos);
|
||||
if (c->chart()->plotArea().contains(local_pos)) {
|
||||
if (on_tip) {
|
||||
showValueTip(c->secondsAtPoint(local_pos));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
showValueTip(-1);
|
||||
} else if (e->type() == QEvent::Wheel) {
|
||||
if (auto tip = qobject_cast<TipLabel *>(o)) {
|
||||
// Forward the event to the parent widget
|
||||
QCoreApplication::sendEvent(tip->parentWidget(), e);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ChartsWidget::event(QEvent *event) {
|
||||
bool back_button = false;
|
||||
switch (event->type()) {
|
||||
case QEvent::Resize:
|
||||
updateLayout();
|
||||
break;
|
||||
case QEvent::MouseButtonPress:
|
||||
back_button = static_cast<QMouseEvent *>(event)->button() == Qt::BackButton;
|
||||
break;
|
||||
case QEvent::NativeGesture:
|
||||
back_button = (static_cast<QNativeGestureEvent *>(event)->value() == 180);
|
||||
break;
|
||||
case QEvent::WindowDeactivate:
|
||||
case QEvent::FocusOut:
|
||||
showValueTip(-1);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (back_button) {
|
||||
zoom_undo_stack->undo();
|
||||
return true; // Return true since the event has been handled
|
||||
}
|
||||
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 = geometry();
|
||||
r.setHeight(CHART_SPACING);
|
||||
if (auto insert_after = getDropAfter(drop_indictor_pos)) {
|
||||
r.moveTop(insert_after->geometry().bottom());
|
||||
}
|
||||
|
||||
QPainter p(this);
|
||||
p.fillRect(r, palette().highlight());
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
131
tools/cabana/chart/chartswidget.h
Normal file
131
tools/cabana/chart/chartswidget.h
Normal file
@@ -0,0 +1,131 @@
|
||||
#pragma once
|
||||
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
|
||||
#include <QGridLayout>
|
||||
#include <QLabel>
|
||||
#include <QScrollArea>
|
||||
#include <QTimer>
|
||||
#include <QToolBar>
|
||||
#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; }
|
||||
QStringList serializeChartIds() const;
|
||||
void restoreChartsFromIds(const QStringList &chart_ids);
|
||||
|
||||
public slots:
|
||||
void setColumnCount(int n);
|
||||
void removeAll();
|
||||
void timeRangeChanged(const std::optional<std::pair<double, double>> &time_range);
|
||||
void setIsDocked(bool dock);
|
||||
|
||||
signals:
|
||||
void toggleChartsDocking();
|
||||
void seriesChanged();
|
||||
void showTip(double seconds);
|
||||
|
||||
private:
|
||||
QSize minimumSizeHint() const override;
|
||||
bool event(QEvent *event) override;
|
||||
void alignCharts();
|
||||
void newChart();
|
||||
ChartView *createChart(int pos = 0);
|
||||
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 is_docked = true;
|
||||
ToolButton *dock_btn;
|
||||
|
||||
QToolBar *toolbar;
|
||||
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;
|
||||
std::pair<double, double> display_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;
|
||||
bool value_tip_visible_ = false;
|
||||
friend class ChartView;
|
||||
friend class ChartsContainer;
|
||||
};
|
||||
|
||||
class ZoomCommand : public QUndoCommand {
|
||||
public:
|
||||
ZoomCommand(std::pair<double, double> range) : range(range), QUndoCommand() {
|
||||
prev_range = can->timeRange();
|
||||
setText(QObject::tr("Zoom to %1-%2").arg(range.first, 0, 'f', 2).arg(range.second, 0, 'f', 2));
|
||||
}
|
||||
void undo() override { can->setTimeRange(prev_range); }
|
||||
void redo() override { can->setTimeRange(range); }
|
||||
std::optional<std::pair<double, double>> prev_range, range;
|
||||
};
|
||||
109
tools/cabana/chart/signalselector.cc
Normal file
109
tools/cabana/chart/signalselector.cc
Normal file
@@ -0,0 +1,109 @@
|
||||
#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, &msg_id](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
Normal file
30
tools/cabana/chart/signalselector.h
Normal 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;
|
||||
};
|
||||
100
tools/cabana/chart/sparkline.cc
Normal file
100
tools/cabana/chart/sparkline.cc
Normal file
@@ -0,0 +1,100 @@
|
||||
#include "tools/cabana/chart/sparkline.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <QPainter>
|
||||
|
||||
void Sparkline::update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, int range, QSize size) {
|
||||
if (first == last || size.isEmpty()) {
|
||||
pixmap = QPixmap();
|
||||
return;
|
||||
}
|
||||
|
||||
points_.clear();
|
||||
min_val = std::numeric_limits<double>::max();
|
||||
max_val = std::numeric_limits<double>::lowest();
|
||||
points_.reserve(std::distance(first, last));
|
||||
|
||||
uint64_t start_time = (*first)->mono_time;
|
||||
double value = 0.0;
|
||||
for (auto it = first; it != last; ++it) {
|
||||
if (sig->getValue((*it)->dat, (*it)->size, &value)) {
|
||||
min_val = std::min(min_val, value);
|
||||
max_val = std::max(max_val, value);
|
||||
points_.emplace_back(((*it)->mono_time - start_time) / 1e9, value);
|
||||
}
|
||||
}
|
||||
|
||||
if (points_.empty()) {
|
||||
pixmap = QPixmap();
|
||||
return;
|
||||
}
|
||||
|
||||
freq_ = points_.size() / std::max(points_.back().x() - points_.front().x(), 1.0);
|
||||
render(sig->color, range, size);
|
||||
}
|
||||
|
||||
void Sparkline::render(const QColor &color, int range, QSize size) {
|
||||
// Adjust for flat lines
|
||||
bool is_flat_line = min_val == max_val;
|
||||
if (is_flat_line) {
|
||||
min_val -= 1.0;
|
||||
max_val += 1.0;
|
||||
}
|
||||
|
||||
// Calculate scaling
|
||||
const double xscale = (size.width() - 1) / (double)range;
|
||||
const double yscale = (size.height() - 3) / (max_val - min_val);
|
||||
bool draw_individual_points = (points_.back().x() * xscale / points_.size()) > 8.0;
|
||||
|
||||
// Transform or downsample points
|
||||
render_points_.reserve(points_.size());
|
||||
render_points_.clear();
|
||||
if (draw_individual_points) {
|
||||
for (const auto &p : points_) {
|
||||
render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - p.y()) * yscale);
|
||||
}
|
||||
} else if (is_flat_line) {
|
||||
double y = size.height() / 2.0;
|
||||
render_points_.emplace_back(0.0, y);
|
||||
render_points_.emplace_back(points_.back().x() * xscale, y);
|
||||
} else {
|
||||
double prev_y = points_.front().y();
|
||||
render_points_.emplace_back(points_.front().x() * xscale, 1.0 + (max_val - prev_y) * yscale);
|
||||
bool in_flat = false;
|
||||
|
||||
for (size_t i = 1; i < points_.size(); ++i) {
|
||||
const auto &p = points_[i];
|
||||
double y = p.y();
|
||||
if (std::abs(y - prev_y) < 1e-6) {
|
||||
in_flat = true;
|
||||
} else {
|
||||
if (in_flat) render_points_.emplace_back(points_[i - 1].x() * xscale, 1.0 + (max_val - prev_y) * yscale);
|
||||
render_points_.emplace_back(p.x() * xscale, 1.0 + (max_val - y) * yscale);
|
||||
in_flat = false;
|
||||
}
|
||||
prev_y = y;
|
||||
}
|
||||
if (in_flat) render_points_.emplace_back(points_.back().x() * xscale, 1.0 + (max_val - prev_y) * yscale);
|
||||
}
|
||||
|
||||
// Render to pixmap
|
||||
qreal dpr = qApp->devicePixelRatio();
|
||||
const QSize pixmap_size = size * dpr;
|
||||
if (pixmap.size() != pixmap_size) {
|
||||
pixmap = QPixmap(pixmap_size);
|
||||
}
|
||||
pixmap.setDevicePixelRatio(dpr);
|
||||
pixmap.fill(Qt::transparent);
|
||||
QPainter painter(&pixmap);
|
||||
painter.setRenderHint(QPainter::Antialiasing, render_points_.size() <= 500);
|
||||
painter.setPen(color);
|
||||
painter.drawPolyline(render_points_.data(), render_points_.size());
|
||||
|
||||
painter.setPen(QPen(color, 3));
|
||||
if (draw_individual_points) {
|
||||
painter.drawPoints(render_points_.data(), render_points_.size());
|
||||
} else {
|
||||
painter.drawPoint(render_points_.back());
|
||||
}
|
||||
}
|
||||
26
tools/cabana/chart/sparkline.h
Normal file
26
tools/cabana/chart/sparkline.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <QPixmap>
|
||||
#include <QPointF>
|
||||
#include <vector>
|
||||
|
||||
#include "tools/cabana/dbc/dbc.h"
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
class Sparkline {
|
||||
public:
|
||||
void update(const cabana::Signal *sig, CanEventIter first, CanEventIter last, 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_;
|
||||
std::vector<QPointF> render_points_;
|
||||
double freq_ = 0;
|
||||
};
|
||||
58
tools/cabana/chart/tiplabel.cc
Normal file
58
tools/cabana/chart/tiplabel.cc
Normal file
@@ -0,0 +1,58 @@
|
||||
#include "tools/cabana/chart/tiplabel.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QStylePainter>
|
||||
#include <QToolTip>
|
||||
|
||||
#include "tools/cabana/settings.h"
|
||||
#include "tools/cabana/utils/util.h"
|
||||
|
||||
TipLabel::TipLabel(QWidget *parent) : QLabel(parent, Qt::ToolTip | Qt::FramelessWindowHint) {
|
||||
setAttribute(Qt::WA_ShowWithoutActivating);
|
||||
setAttribute(Qt::WA_TransparentForMouseEvents);
|
||||
|
||||
setForegroundRole(QPalette::ToolTipText);
|
||||
setBackgroundRole(QPalette::ToolTipBase);
|
||||
|
||||
QFont font;
|
||||
font.setPointSizeF(8.34563465);
|
||||
setFont(font);
|
||||
auto palette = QToolTip::palette();
|
||||
if (!utils::isDarkTheme()) {
|
||||
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));
|
||||
setTextFormat(Qt::RichText);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
12
tools/cabana/chart/tiplabel.h
Normal file
12
tools/cabana/chart/tiplabel.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
|
||||
#include <QLabel>
|
||||
|
||||
class TipLabel : public QLabel {
|
||||
Q_OBJECT
|
||||
|
||||
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
Normal file
124
tools/cabana/commands.cc
Normal 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
Normal file
72
tools/cabana/commands.h
Normal 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); }
|
||||
};
|
||||
223
tools/cabana/dbc/dbc.cc
Normal file
223
tools/cabana/dbc/dbc.cc
Normal file
@@ -0,0 +1,223 @@
|
||||
#include "tools/cabana/dbc/dbc.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "tools/cabana/utils/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;
|
||||
transmitter = other.transmitter;
|
||||
|
||||
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 -= sz;
|
||||
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, bool with_unit) 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 (with_unit && !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) {
|
||||
const int msb_byte = sig.msb / 8;
|
||||
if (msb_byte >= (int)data_size) return 0;
|
||||
|
||||
const int lsb_byte = sig.lsb / 8;
|
||||
uint64_t val = 0;
|
||||
|
||||
// Fast path: signal fits in a single byte
|
||||
if (msb_byte == lsb_byte) {
|
||||
val = (data[msb_byte] >> (sig.lsb & 7)) & ((1ULL << sig.size) - 1);
|
||||
} else {
|
||||
// Multi-byte case: signal spans across multiple bytes
|
||||
int bits = sig.size;
|
||||
int i = msb_byte;
|
||||
const int step = sig.is_little_endian ? -1 : 1;
|
||||
while (i >= 0 && i < (int)data_size && bits > 0) {
|
||||
const int msb = (i == msb_byte) ? sig.msb & 7 : 7;
|
||||
const int lsb = (i == lsb_byte) ? sig.lsb & 7 : 0;
|
||||
const int nbits = msb - lsb + 1;
|
||||
val = (val << nbits) | ((data[i] >> lsb) & ((1ULL << nbits) - 1));
|
||||
bits -= nbits;
|
||||
i += step;
|
||||
}
|
||||
}
|
||||
|
||||
// Sign extension (if needed)
|
||||
if (sig.is_signed && (val & (1ULL << (sig.size - 1)))) {
|
||||
val |= ~((1ULL << sig.size) - 1);
|
||||
}
|
||||
|
||||
return static_cast<int64_t>(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;
|
||||
}
|
||||
}
|
||||
126
tools/cabana/dbc/dbc.h
Normal file
126
tools/cabana/dbc/dbc.h
Normal file
@@ -0,0 +1,126 @@
|
||||
#pragma once
|
||||
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QColor>
|
||||
#include <QMetaType>
|
||||
#include <QString>
|
||||
|
||||
const QString UNTITLED = "untitled";
|
||||
const QString DEFAULT_NODE_NAME = "XXX";
|
||||
constexpr int CAN_MAX_DATA_BYTES = 64;
|
||||
|
||||
struct MessageId {
|
||||
uint8_t source = 0;
|
||||
uint32_t address = 0;
|
||||
|
||||
QString toString() const {
|
||||
return QString("%1:%2").arg(source).arg(QString::number(address, 16).toUpper());
|
||||
}
|
||||
|
||||
inline static MessageId fromString(const QString &str) {
|
||||
auto parts = str.split(':');
|
||||
if (parts.size() != 2) return {};
|
||||
return MessageId{.source = uint8_t(parts[0].toUInt()), .address = parts[1].toUInt(nullptr, 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::tie(source, address) < std::tie(other.source, other.address);
|
||||
}
|
||||
|
||||
bool operator>(const MessageId &other) const {
|
||||
return std::tie(source, address) > std::tie(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 std::vector<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, bool with_unit = true) 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); }
|
||||
274
tools/cabana/dbc/dbcfile.cc
Normal file
274
tools/cabana/dbc/dbcfile.cc
Normal file
@@ -0,0 +1,274 @@
|
||||
#include "tools/cabana/dbc/dbcfile.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QRegularExpression>
|
||||
|
||||
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;
|
||||
parse(file.readAll());
|
||||
} else {
|
||||
throw std::runtime_error("Failed to open file.");
|
||||
}
|
||||
}
|
||||
|
||||
DBCFile::DBCFile(const QString &name, const QString &content) : name_(name), filename("") {
|
||||
parse(content);
|
||||
}
|
||||
|
||||
bool DBCFile::save() {
|
||||
assert(!filename.isEmpty());
|
||||
return writeContents(filename);
|
||||
}
|
||||
|
||||
bool DBCFile::saveAs(const QString &new_filename) {
|
||||
filename = new_filename;
|
||||
return save();
|
||||
}
|
||||
|
||||
bool DBCFile::writeContents(const QString &fn) {
|
||||
QFile file(fn);
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
return file.write(generateDBC().toUtf8()) >= 0;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
cabana::Signal *DBCFile::signal(uint32_t address, const QString &name) {
|
||||
auto m = msg(address);
|
||||
return m ? (cabana::Signal *)m->sig(name) : nullptr;
|
||||
}
|
||||
|
||||
void DBCFile::parse(const QString &content) {
|
||||
msgs.clear();
|
||||
|
||||
int line_num = 0;
|
||||
QString line;
|
||||
cabana::Msg *current_msg = nullptr;
|
||||
int multiplexor_cnt = 0;
|
||||
bool seen_first = false;
|
||||
QTextStream stream((QString *)&content);
|
||||
|
||||
while (!stream.atEnd()) {
|
||||
++line_num;
|
||||
QString raw_line = stream.readLine();
|
||||
line = raw_line.trimmed();
|
||||
|
||||
bool seen = true;
|
||||
try {
|
||||
if (line.startsWith("BO_ ")) {
|
||||
multiplexor_cnt = 0;
|
||||
current_msg = parseBO(line);
|
||||
} else if (line.startsWith("SG_ ")) {
|
||||
parseSG(line, current_msg, multiplexor_cnt);
|
||||
} else if (line.startsWith("VAL_ ")) {
|
||||
parseVAL(line);
|
||||
} else if (line.startsWith("CM_ BO_")) {
|
||||
parseCM_BO(line, content, raw_line, stream);
|
||||
} else if (line.startsWith("CM_ SG_ ")) {
|
||||
parseCM_SG(line, content, raw_line, stream);
|
||||
} else {
|
||||
seen = false;
|
||||
}
|
||||
} catch (std::exception &e) {
|
||||
throw std::runtime_error(QString("[%1:%2]%3: %4").arg(filename).arg(line_num).arg(e.what()).arg(line).toStdString());
|
||||
}
|
||||
|
||||
if (seen) {
|
||||
seen_first = true;
|
||||
} else if (!seen_first) {
|
||||
header += raw_line + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
for (auto &[_, m] : msgs) {
|
||||
m.update();
|
||||
}
|
||||
}
|
||||
|
||||
cabana::Msg *DBCFile::parseBO(const QString &line) {
|
||||
static QRegularExpression bo_regexp(R"(^BO_ (?<address>\w+) (?<name>\w+) *: (?<size>\w+) (?<transmitter>\w+))");
|
||||
|
||||
QRegularExpressionMatch match = bo_regexp.match(line);
|
||||
if (!match.hasMatch())
|
||||
throw std::runtime_error("Invalid BO_ line format");
|
||||
|
||||
uint32_t address = match.captured("address").toUInt();
|
||||
if (msgs.count(address) > 0)
|
||||
throw std::runtime_error(QString("Duplicate message address: %1").arg(address).toStdString());
|
||||
|
||||
// Create a new message object
|
||||
cabana::Msg *msg = &msgs[address];
|
||||
msg->address = address;
|
||||
msg->name = match.captured("name");
|
||||
msg->size = match.captured("size").toULong();
|
||||
msg->transmitter = match.captured("transmitter").trimmed();
|
||||
return msg;
|
||||
}
|
||||
|
||||
void DBCFile::parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) {
|
||||
static QRegularExpression msg_comment_regexp(R"(^CM_ BO_ *(?<address>\w+) *\"(?<comment>(?:[^"\\]|\\.)*)\"\s*;)");
|
||||
|
||||
QString parse_line = line;
|
||||
if (!parse_line.endsWith("\";")) {
|
||||
int pos = stream.pos() - raw_line.length() - 1;
|
||||
parse_line = content.mid(pos, content.indexOf("\";", pos));
|
||||
}
|
||||
auto match = msg_comment_regexp.match(parse_line);
|
||||
if (!match.hasMatch())
|
||||
throw std::runtime_error("Invalid message comment format");
|
||||
|
||||
if (auto m = (cabana::Msg *)msg(match.captured("address").toUInt()))
|
||||
m->comment = match.captured("comment").trimmed().replace("\\\"", "\"");
|
||||
}
|
||||
|
||||
void DBCFile::parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt) {
|
||||
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]+)\] \"(.*)\" (.*))");
|
||||
|
||||
if (!current_msg)
|
||||
throw std::runtime_error("No Message");
|
||||
|
||||
int offset = 0;
|
||||
auto match = sg_regexp.match(line);
|
||||
if (!match.hasMatch()) {
|
||||
match = sgm_regexp.match(line);
|
||||
offset = 1;
|
||||
}
|
||||
if (!match.hasMatch())
|
||||
throw std::runtime_error("Invalid SG_ line format");
|
||||
|
||||
QString name = match.captured(1);
|
||||
if (current_msg->sig(name) != nullptr)
|
||||
throw std::runtime_error("Duplicate signal name");
|
||||
|
||||
cabana::Signal s{};
|
||||
if (offset == 1) {
|
||||
auto indicator = match.captured(2);
|
||||
if (indicator == "M") {
|
||||
++multiplexor_cnt;
|
||||
// Only one signal within a single message can be the multiplexer switch.
|
||||
if (multiplexor_cnt >= 2)
|
||||
throw std::runtime_error("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));
|
||||
}
|
||||
|
||||
void DBCFile::parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream) {
|
||||
static QRegularExpression sg_comment_regexp(R"(^CM_ SG_ *(\w+) *(\w+) *\"((?:[^"\\]|\\.)*)\"\s*;)");
|
||||
|
||||
QString parse_line = line;
|
||||
if (!parse_line.endsWith("\";")) {
|
||||
int pos = stream.pos() - raw_line.length() - 1;
|
||||
parse_line = content.mid(pos, content.indexOf("\";", pos));
|
||||
}
|
||||
auto match = sg_comment_regexp.match(parse_line);
|
||||
if (!match.hasMatch())
|
||||
throw std::runtime_error("Invalid CM_ SG_ line format");
|
||||
|
||||
if (auto s = signal(match.captured(1).toUInt(), match.captured(2))) {
|
||||
s->comment = match.captured(3).trimmed().replace("\\\"", "\"");
|
||||
}
|
||||
}
|
||||
|
||||
void DBCFile::parseVAL(const QString &line) {
|
||||
static QRegularExpression val_regexp(R"(VAL_ (\w+) (\w+) (\s*[-+]?[0-9]+\s+\".+?\"[^;]*))");
|
||||
|
||||
auto match = val_regexp.match(line);
|
||||
if (!match.hasMatch())
|
||||
throw std::runtime_error("invalid VAL_ line format");
|
||||
|
||||
if (auto s = signal(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});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString DBCFile::generateDBC() {
|
||||
QString dbc_string, 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()) {
|
||||
comment += QString("CM_ BO_ %1 \"%2\";\n").arg(address).arg(QString(m.comment).replace("\"", "\\\""));
|
||||
}
|
||||
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()) {
|
||||
comment += QString("CM_ SG_ %1 %2 \"%3\";\n").arg(address).arg(sig->name).arg(QString(sig->comment).replace("\"", "\\\""));
|
||||
}
|
||||
if (!sig->val_desc.empty()) {
|
||||
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 header + dbc_string + comment + val_desc;
|
||||
}
|
||||
44
tools/cabana/dbc/dbcfile.h
Normal file
44
tools/cabana/dbc/dbcfile.h
Normal file
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <QTextStream>
|
||||
|
||||
#include "tools/cabana/dbc/dbc.h"
|
||||
|
||||
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 writeContents(const QString &fn);
|
||||
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); }
|
||||
cabana::Signal *signal(uint32_t address, const QString &name);
|
||||
|
||||
inline QString name() const { return name_.isEmpty() ? "untitled" : name_; }
|
||||
inline bool isEmpty() const { return msgs.empty() && name_.isEmpty(); }
|
||||
|
||||
QString filename;
|
||||
|
||||
private:
|
||||
void parse(const QString &content);
|
||||
cabana::Msg *parseBO(const QString &line);
|
||||
void parseSG(const QString &line, cabana::Msg *current_msg, int &multiplexor_cnt);
|
||||
void parseCM_BO(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream);
|
||||
void parseCM_SG(const QString &line, const QString &content, const QString &raw_line, const QTextStream &stream);
|
||||
void parseVAL(const QString &line);
|
||||
|
||||
QString header;
|
||||
std::map<uint32_t, cabana::Msg> msgs;
|
||||
QString name_;
|
||||
};
|
||||
178
tools/cabana/dbc/dbcmanager.cc
Normal file
178
tools/cabana/dbc/dbcmanager.cc
Normal file
@@ -0,0 +1,178 @@
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
|
||||
#include <QSet>
|
||||
#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::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
|
||||
QSet<QString> names;
|
||||
for (auto &f : allDBCFiles()) {
|
||||
for (auto &[_, m] : f->getMessages()) {
|
||||
for (auto sig : m.getSignals()) {
|
||||
names.insert(sig->name);
|
||||
}
|
||||
}
|
||||
}
|
||||
QStringList ret = names.values();
|
||||
ret.sort();
|
||||
return ret;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
69
tools/cabana/dbc/dbcmanager.h
Normal file
69
tools/cabana/dbc/dbcmanager.h
Normal file
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <memory>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
#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::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();
|
||||
inline int dbcCount() { return allDBCFiles().size(); }
|
||||
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;
|
||||
}
|
||||
39
tools/cabana/dbc/generate_dbc_json.py
Executable file
39
tools/cabana/dbc/generate_dbc_json.py
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from opendbc.car import Bus
|
||||
from opendbc.car.fingerprints import MIGRATION
|
||||
from opendbc.car.values import PLATFORMS
|
||||
|
||||
|
||||
def generate_dbc_dict() -> dict[str, str]:
|
||||
dbc_map = {}
|
||||
for platform in PLATFORMS.values():
|
||||
if platform != "MOCK":
|
||||
if Bus.pt in platform.config.dbc_dict:
|
||||
dbc_map[platform.name] = platform.config.dbc_dict[Bus.pt]
|
||||
elif Bus.main in platform.config.dbc_dict:
|
||||
dbc_map[platform.name] = platform.config.dbc_dict[Bus.main]
|
||||
elif Bus.party in platform.config.dbc_dict:
|
||||
dbc_map[platform.name] = platform.config.dbc_dict[Bus.party]
|
||||
else:
|
||||
raise ValueError("Unknown main type")
|
||||
|
||||
for m in MIGRATION:
|
||||
if MIGRATION[m] in dbc_map:
|
||||
dbc_map[m] = dbc_map[MIGRATION[m]]
|
||||
|
||||
return dbc_map
|
||||
|
||||
|
||||
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(json.dumps(dict(sorted(generate_dbc_dict().items())), indent=2))
|
||||
print(f"Generated and written to {args.out}")
|
||||
323
tools/cabana/detailwidget.cc
Normal file
323
tools/cabana/detailwidget.cc
Normal file
@@ -0,0 +1,323 @@
|
||||
#include "tools/cabana/detailwidget.h"
|
||||
|
||||
#include <QFormLayout>
|
||||
#include <QMenu>
|
||||
#include <QRadioButton>
|
||||
#include <QPushButton>
|
||||
#include <QToolBar>
|
||||
|
||||
#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);
|
||||
|
||||
createToolBar();
|
||||
|
||||
// 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(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::createToolBar() {
|
||||
QToolBar *toolbar = new QToolBar(this);
|
||||
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
|
||||
toolbar->setIconSize({icon_size, icon_size});
|
||||
toolbar->addWidget(name_label = new ElidedLabel(this));
|
||||
name_label->setStyleSheet("QLabel{font-weight:bold;}");
|
||||
|
||||
QWidget *spacer = new QWidget();
|
||||
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||||
toolbar->addWidget(spacer);
|
||||
|
||||
// Heatmap label and radio buttons
|
||||
toolbar->addWidget(new QLabel(tr("Heatmap:"), this));
|
||||
auto *heatmap_live = new QRadioButton(tr("Live"), this);
|
||||
auto *heatmap_all = new QRadioButton(tr("All"), this);
|
||||
heatmap_live->setChecked(true);
|
||||
|
||||
toolbar->addWidget(heatmap_live);
|
||||
toolbar->addWidget(heatmap_all);
|
||||
|
||||
// Edit and remove buttons
|
||||
toolbar->addSeparator();
|
||||
toolbar->addAction(utils::icon("pencil"), tr("Edit Message"), this, &DetailWidget::editMsg);
|
||||
action_remove_msg = toolbar->addAction(utils::icon("x-lg"), tr("Remove Message"), this, &DetailWidget::removeMsg);
|
||||
|
||||
layout()->addWidget(toolbar);
|
||||
|
||||
connect(heatmap_live, &QAbstractButton::toggled, this, [this](bool on) { binary_view->setHeatmapLiveMode(on); });
|
||||
connect(can, &AbstractStream::timeRangeChanged, this, [=](const std::optional<std::pair<double, double>> &range) {
|
||||
auto text = range ? QString("%1 - %2").arg(range->first, 0, 'f', 3).arg(range->second, 0, 'f', 3) : "All";
|
||||
heatmap_all->setText(text);
|
||||
(range ? heatmap_all : heatmap_live)->setChecked(true);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int DetailWidget::findOrAddTab(const MessageId& message_id) {
|
||||
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));
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
void DetailWidget::setMessage(const MessageId &message_id) {
|
||||
if (std::exchange(msg_id, message_id) == message_id) return;
|
||||
|
||||
tabbar->blockSignals(true);
|
||||
int index = findOrAddTab(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);
|
||||
}
|
||||
|
||||
std::pair<QString, QStringList> DetailWidget::serializeMessageIds() const {
|
||||
QStringList msgs;
|
||||
for (int i = 0; i < tabbar->count(); ++i) {
|
||||
MessageId id = tabbar->tabData(i).value<MessageId>();
|
||||
msgs.append(id.toString());
|
||||
}
|
||||
return std::make_pair(msg_id.toString(), msgs);
|
||||
}
|
||||
|
||||
void DetailWidget::restoreTabs(const QString active_msg_id, const QStringList& msg_ids) {
|
||||
tabbar->blockSignals(true);
|
||||
for (const auto& str_id : msg_ids) {
|
||||
MessageId id = MessageId::fromString(str_id);
|
||||
if (dbc()->msg(id) != nullptr)
|
||||
findOrAddTab(id);
|
||||
}
|
||||
tabbar->blockSignals(false);
|
||||
|
||||
auto active_id = MessageId::fromString(active_msg_id);
|
||||
if (dbc()->msg(active_id) != nullptr)
|
||||
setMessage(active_id);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
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);
|
||||
action_remove_msg->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));
|
||||
size_spin->setRange(1, CAN_MAX_DATA_BYTES);
|
||||
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());
|
||||
}
|
||||
|
||||
DetailWidget* CenterWidget::ensureDetailWidget() {
|
||||
if (!detail_widget) {
|
||||
delete welcome_widget;
|
||||
welcome_widget = nullptr;
|
||||
layout()->addWidget(detail_widget = new DetailWidget(((MainWindow*)parentWidget())->charts_widget, this));
|
||||
}
|
||||
return detail_widget;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
75
tools/cabana/detailwidget.h
Normal file
75
tools/cabana/detailwidget.h
Normal file
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QSplitter>
|
||||
#include <QTabWidget>
|
||||
#include <QTextEdit>
|
||||
#include <set>
|
||||
|
||||
#include "tools/cabana/binaryview.h"
|
||||
#include "tools/cabana/chart/chartswidget.h"
|
||||
#include "tools/cabana/historylog.h"
|
||||
#include "tools/cabana/signalview.h"
|
||||
#include "tools/cabana/utils/elidedlabel.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();
|
||||
std::pair<QString, QStringList> serializeMessageIds() const;
|
||||
void restoreTabs(const QString active_msg_id, const QStringList &msg_ids);
|
||||
|
||||
private:
|
||||
void createToolBar();
|
||||
int findOrAddTab(const MessageId& message_id);
|
||||
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;
|
||||
QAction *action_remove_msg;
|
||||
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 &message_id) { ensureDetailWidget()->setMessage(message_id); }
|
||||
DetailWidget* getDetailWidget() { return detail_widget; }
|
||||
DetailWidget* ensureDetailWidget();
|
||||
void clear();
|
||||
|
||||
private:
|
||||
QWidget *createWelcomeWidget();
|
||||
DetailWidget *detail_widget = nullptr;
|
||||
QWidget *welcome_widget = nullptr;
|
||||
};
|
||||
248
tools/cabana/historylog.cc
Normal file
248
tools/cabana/historylog.cc
Normal file
@@ -0,0 +1,248 @@
|
||||
#include "tools/cabana/historylog.h"
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <QFileDialog>
|
||||
#include <QPainter>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "tools/cabana/commands.h"
|
||||
#include "tools/cabana/utils/export.h"
|
||||
|
||||
QVariant HistoryLogModel::data(const QModelIndex &index, int role) const {
|
||||
const auto &m = messages[index.row()];
|
||||
const int col = index.column();
|
||||
if (role == Qt::DisplayRole) {
|
||||
if (col == 0) return QString::number(can->toSeconds(m.mono_time), 'f', 3);
|
||||
if (!isHexMode()) return sigs[col - 1]->formatValue(m.sig_values[col - 1], false);
|
||||
} else if (role == Qt::TextAlignmentRole) {
|
||||
return (uint32_t)(Qt::AlignRight | Qt::AlignVCenter);
|
||||
}
|
||||
|
||||
if (isHexMode() && col == 1) {
|
||||
if (role == ColorsRole) return QVariant::fromValue((void *)(&m.colors));
|
||||
if (role == BytesRole) return QVariant::fromValue((void *)(&m.data));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void HistoryLogModel::setMessage(const MessageId &message_id) {
|
||||
msg_id = message_id;
|
||||
reset();
|
||||
}
|
||||
|
||||
void HistoryLogModel::reset() {
|
||||
beginResetModel();
|
||||
sigs.clear();
|
||||
if (auto dbc_msg = dbc()->msg(msg_id)) {
|
||||
sigs = dbc_msg->getSignals();
|
||||
}
|
||||
messages.clear();
|
||||
hex_colors = {};
|
||||
endResetModel();
|
||||
setFilter(0, "", nullptr);
|
||||
}
|
||||
|
||||
QVariant HistoryLogModel::headerData(int section, Qt::Orientation orientation, int role) const {
|
||||
if (orientation == Qt::Horizontal) {
|
||||
if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
|
||||
if (section == 0) return "Time";
|
||||
if (isHexMode()) return "Data";
|
||||
|
||||
QString name = sigs[section - 1]->name;
|
||||
QString unit = sigs[section - 1]->unit;
|
||||
return unit.isEmpty() ? name : QString("%1 (%2)").arg(name, unit);
|
||||
} else if (role == Qt::BackgroundRole && section > 0 && !isHexMode()) {
|
||||
// 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::setHexMode(bool hex) {
|
||||
hex_mode = hex;
|
||||
reset();
|
||||
}
|
||||
|
||||
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;
|
||||
updateState(true);
|
||||
}
|
||||
|
||||
void HistoryLogModel::updateState(bool clear) {
|
||||
if (clear && !messages.empty()) {
|
||||
beginRemoveRows({}, 0, messages.size() - 1);
|
||||
messages.clear();
|
||||
endRemoveRows();
|
||||
}
|
||||
uint64_t current_time = can->toMonoTime(can->lastMessage(msg_id).ts) + 1;
|
||||
fetchData(messages.begin(), current_time, messages.empty() ? 0 : messages.front().mono_time);
|
||||
}
|
||||
|
||||
bool HistoryLogModel::canFetchMore(const QModelIndex &parent) const {
|
||||
const auto &events = can->events(msg_id);
|
||||
return !events.empty() && !messages.empty() && messages.back().mono_time > events.front()->mono_time;
|
||||
}
|
||||
|
||||
void HistoryLogModel::fetchMore(const QModelIndex &parent) {
|
||||
if (!messages.empty())
|
||||
fetchData(messages.end(), messages.back().mono_time, 0);
|
||||
}
|
||||
|
||||
void HistoryLogModel::fetchData(std::deque<Message>::iterator insert_pos, uint64_t from_time, uint64_t min_time) {
|
||||
const auto &events = can->events(msg_id);
|
||||
auto first = std::upper_bound(events.rbegin(), events.rend(), from_time, [](uint64_t ts, auto e) {
|
||||
return ts > e->mono_time;
|
||||
});
|
||||
|
||||
std::vector<HistoryLogModel::Message> msgs;
|
||||
std::vector<double> values(sigs.size());
|
||||
msgs.reserve(batch_size);
|
||||
for (; first != events.rend() && (*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)) {
|
||||
msgs.emplace_back(Message{e->mono_time, values, {e->dat, e->dat + e->size}});
|
||||
if (msgs.size() >= batch_size && min_time == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!msgs.empty()) {
|
||||
if (isHexMode() && (min_time > 0 || messages.empty())) {
|
||||
const auto freq = can->lastMessage(msg_id).freq;
|
||||
const std::vector<uint8_t> no_mask;
|
||||
for (auto &m : msgs) {
|
||||
hex_colors.compute(msg_id, m.data.data(), m.data.size(), m.mono_time / (double)1e9, can->getSpeed(), no_mask, freq);
|
||||
m.colors = hex_colors.colors;
|
||||
}
|
||||
}
|
||||
int pos = std::distance(messages.begin(), insert_pos);
|
||||
beginInsertRows({}, pos , pos + msgs.size() - 1);
|
||||
messages.insert(insert_pos, std::move_iterator(msgs.begin()), std::move_iterator(msgs.end()));
|
||||
endInsertRows();
|
||||
}
|
||||
}
|
||||
|
||||
// HeaderView
|
||||
|
||||
QSize HeaderView::sectionSizeFromContents(int logicalIndex) const {
|
||||
static const QSize time_col_size = fontMetrics().size(Qt::TextSingleLine, "000000.000") + 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(utils::isDarkTheme() ? 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);
|
||||
export_btn = new ToolButton("filetype-csv", tr("Export to CSV file..."));
|
||||
h->addWidget(export_btn, 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));
|
||||
|
||||
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));
|
||||
logs->setItemDelegate(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->setFrameShape(QFrame::NoFrame);
|
||||
|
||||
QObject::connect(display_type_cb, qOverload<int>(&QComboBox::activated), model, &HistoryLogModel::setHexMode);
|
||||
QObject::connect(signals_cb, SIGNAL(activated(int)), this, SLOT(filterChanged()));
|
||||
QObject::connect(comp_box, SIGNAL(activated(int)), this, SLOT(filterChanged()));
|
||||
QObject::connect(value_edit, &QLineEdit::textEdited, this, &LogsWidget::filterChanged);
|
||||
QObject::connect(export_btn, &QToolButton::clicked, this, &LogsWidget::exportToCSV);
|
||||
QObject::connect(can, &AbstractStream::seekedTo, model, &HistoryLogModel::reset);
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, model, &HistoryLogModel::reset);
|
||||
QObject::connect(UndoStack::instance(), &QUndoStack::indexChanged, model, &HistoryLogModel::reset);
|
||||
QObject::connect(model, &HistoryLogModel::modelReset, this, &LogsWidget::modelReset);
|
||||
QObject::connect(model, &HistoryLogModel::rowsInserted, [this]() { export_btn->setEnabled(true); });
|
||||
}
|
||||
|
||||
void LogsWidget::modelReset() {
|
||||
signals_cb->clear();
|
||||
for (auto s : model->sigs) {
|
||||
signals_cb->addItem(s->name);
|
||||
}
|
||||
export_btn->setEnabled(false);
|
||||
value_edit->clear();
|
||||
comp_box->setCurrentIndex(0);
|
||||
filters_widget->setVisible(!model->sigs.empty());
|
||||
}
|
||||
|
||||
void LogsWidget::filterChanged() {
|
||||
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);
|
||||
}
|
||||
|
||||
void LogsWidget::exportToCSV() {
|
||||
QString dir = QString("%1/%2_%3.csv").arg(settings.last_dir).arg(can->routeName()).arg(msgName(model->msg_id));
|
||||
QString fn = QFileDialog::getSaveFileName(this, QString("Export %1 to CSV file").arg(msgName(model->msg_id)),
|
||||
dir, tr("csv (*.csv)"));
|
||||
if (!fn.isEmpty()) {
|
||||
model->isHexMode() ? utils::exportToCSV(fn, model->msg_id)
|
||||
: utils::exportSignalsToCSV(fn, model->msg_id);
|
||||
}
|
||||
}
|
||||
81
tools/cabana/historylog.h
Normal file
81
tools/cabana/historylog.h
Normal file
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QHeaderView>
|
||||
#include <QLineEdit>
|
||||
#include <QTableView>
|
||||
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/streams/abstractstream.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(bool clear = false);
|
||||
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;
|
||||
bool canFetchMore(const QModelIndex &parent) const override;
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override { return messages.size(); }
|
||||
int columnCount(const QModelIndex &parent = QModelIndex()) const override { return !isHexMode() ? sigs.size() + 1 : 2; }
|
||||
inline bool isHexMode() const { return sigs.empty() || hex_mode; }
|
||||
void reset();
|
||||
void setHexMode(bool hex_mode);
|
||||
|
||||
struct Message {
|
||||
uint64_t mono_time = 0;
|
||||
std::vector<double> sig_values;
|
||||
std::vector<uint8_t> data;
|
||||
std::vector<QColor> colors;
|
||||
};
|
||||
|
||||
void fetchData(std::deque<Message>::iterator insert_pos, uint64_t from_time, uint64_t min_time);
|
||||
|
||||
MessageId msg_id;
|
||||
CanData hex_colors;
|
||||
const int batch_size = 50;
|
||||
int filter_sig_idx = -1;
|
||||
double filter_value = 0;
|
||||
std::function<bool(double, double)> filter_cmp = nullptr;
|
||||
std::deque<Message> messages;
|
||||
std::vector<cabana::Signal *> sigs;
|
||||
bool hex_mode = false;
|
||||
};
|
||||
|
||||
class LogsWidget : public QFrame {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
LogsWidget(QWidget *parent);
|
||||
void setMessage(const MessageId &message_id) { model->setMessage(message_id); }
|
||||
void updateState() { model->updateState(); }
|
||||
void showEvent(QShowEvent *event) override { model->updateState(true); }
|
||||
|
||||
private slots:
|
||||
void filterChanged();
|
||||
void exportToCSV();
|
||||
void modelReset();
|
||||
|
||||
private:
|
||||
QTableView *logs;
|
||||
HistoryLogModel *model;
|
||||
QComboBox *signals_cb, *comp_box, *display_type_cb;
|
||||
QLineEdit *value_edit;
|
||||
QWidget *filters_widget;
|
||||
ToolButton *export_btn;
|
||||
MessageBytesDelegate *delegate;
|
||||
};
|
||||
699
tools/cabana/mainwin.cc
Normal file
699
tools/cabana/mainwin.cc
Normal file
@@ -0,0 +1,699 @@
|
||||
#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 <QProgressDialog>
|
||||
#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/cabana/utils/export.h"
|
||||
|
||||
MainWindow::MainWindow(AbstractStream *stream, const QString &dbc_file) : 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");
|
||||
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) return;
|
||||
emit static_main_win->showMessage(msg, 2000);
|
||||
});
|
||||
installMessageHandler([](ReplyMsgType type, const std::string msg) { qInfo() << msg.c_str(); });
|
||||
|
||||
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(&settings, &Settings::changed, this, &MainWindow::updateStatus);
|
||||
|
||||
QTimer::singleShot(0, this, [=]() { stream ? openStream(stream, dbc_file) : selectAndOpenStream(); });
|
||||
show();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::createActions() {
|
||||
// File menu
|
||||
QMenu *file_menu = menuBar()->addMenu(tr("&File"));
|
||||
file_menu->addAction(tr("Open Stream..."), this, &MainWindow::selectAndOpenStream);
|
||||
close_stream_act = file_menu->addAction(tr("Close stream"), this, &MainWindow::closeStream);
|
||||
export_to_csv_act = file_menu->addAction(tr("Export to CSV..."), this, &MainWindow::exportToCSV);
|
||||
close_stream_act->setEnabled(false);
|
||||
export_to_csv_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"));
|
||||
QObject::connect(manage_dbcs_menu, &QMenu::aboutToShow, this, &MainWindow::updateLoadSaveMenus);
|
||||
|
||||
open_recent_menu = file_menu->addMenu(tr("Open &Recent"));
|
||||
QObject::connect(open_recent_menu, &QMenu::aboutToShow, this, &MainWindow::updateRecentFileMenu);
|
||||
|
||||
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 : QDir(OPENDBC_FILE_PATH).entryList({"*.dbc"}, QDir::Files, QDir::Name)) {
|
||||
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("&Redo"));
|
||||
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);
|
||||
QObject::connect(messages_widget, &MessagesWidget::titleChanged, messages_dock, &QDockWidget::setWindowTitle);
|
||||
QObject::connect(messages_widget, &MessagesWidget::msgSelectionChanged, center_widget, &CenterWidget::setMessage);
|
||||
|
||||
// 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);
|
||||
|
||||
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::toggleChartsDocking, this, &MainWindow::toggleChartsDocking);
|
||||
QObject::connect(charts_widget, &ChartsWidget::showTip, video_widget, &VideoWidget::showThumbnail);
|
||||
}
|
||||
|
||||
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, this, []() {
|
||||
if (can) can->pause(!can->isPaused());
|
||||
});
|
||||
// TODO: add more shortcuts here.
|
||||
}
|
||||
|
||||
void MainWindow::undoStackCleanChanged(bool clean) {
|
||||
setWindowModified(!clean);
|
||||
}
|
||||
|
||||
void MainWindow::DBCFileChanged() {
|
||||
UndoStack::instance()->clear();
|
||||
|
||||
// Update file menu
|
||||
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->setEnabled(dynamic_cast<DummyStream *>(can) == nullptr);
|
||||
|
||||
QStringList title;
|
||||
for (auto f : dbc()->allDBCFiles()) {
|
||||
title.push_back(tr("(%1) %2").arg(toString(dbc()->sources(f)), f->name()));
|
||||
}
|
||||
setWindowFilePath(title.join(" | "));
|
||||
|
||||
QTimer::singleShot(0, this, &::MainWindow::restoreSessionState);
|
||||
}
|
||||
|
||||
void MainWindow::selectAndOpenStream() {
|
||||
StreamSelector dlg(this);
|
||||
if (dlg.exec()) {
|
||||
openStream(dlg.stream(), dlg.dbcFile());
|
||||
} else if (!can) {
|
||||
openStream(new DummyStream(this));
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::closeStream() {
|
||||
openStream(new DummyStream(this));
|
||||
if (dbc()->nonEmptyDBCCount() > 0) {
|
||||
emit dbc()->DBCFileChanged();
|
||||
}
|
||||
statusBar()->showMessage(tr("stream closed"));
|
||||
}
|
||||
|
||||
void MainWindow::exportToCSV() {
|
||||
QString dir = QString("%1/%2.csv").arg(settings.last_dir).arg(can->routeName());
|
||||
QString fn = QFileDialog::getSaveFileName(this, "Export stream to CSV file", dir, tr("csv (*.csv)"));
|
||||
if (!fn.isEmpty()) {
|
||||
utils::exportToCSV(fn);
|
||||
}
|
||||
}
|
||||
|
||||
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 error;
|
||||
if (dbc()->open(s, 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::loadDBCFromOpendbc(const QString &name) {
|
||||
loadFile(QString("%1/%2").arg(OPENDBC_FILE_PATH, name));
|
||||
}
|
||||
|
||||
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()->nonEmptyDBCCount() > 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::openStream(AbstractStream *stream, const QString &dbc_file) {
|
||||
if (can) {
|
||||
QObject::connect(can, &QObject::destroyed, this, [=]() { startStream(stream, dbc_file); });
|
||||
can->deleteLater();
|
||||
} else {
|
||||
startStream(stream, dbc_file);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::startStream(AbstractStream *stream, QString dbc_file) {
|
||||
center_widget->clear();
|
||||
delete messages_widget;
|
||||
delete video_splitter;
|
||||
|
||||
can = stream;
|
||||
can->setParent(this); // take ownership
|
||||
can->start();
|
||||
|
||||
loadFile(dbc_file);
|
||||
statusBar()->showMessage(tr("Stream [%1] started").arg(can->routeName()), 2000);
|
||||
|
||||
bool has_stream = dynamic_cast<DummyStream *>(can) == nullptr;
|
||||
close_stream_act->setEnabled(has_stream);
|
||||
export_to_csv_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()->nonEmptyDBCCount()) {
|
||||
newFile();
|
||||
}
|
||||
|
||||
QObject::connect(can, &AbstractStream::eventsMerged, this, &MainWindow::eventsMerged);
|
||||
|
||||
if (has_stream) {
|
||||
auto wait_dlg = new QProgressDialog(
|
||||
can->liveStreaming() ? tr("Waiting for the live stream to start...") : tr("Loading segment data..."),
|
||||
tr("&Abort"), 0, 100, this);
|
||||
wait_dlg->setWindowModality(Qt::WindowModal);
|
||||
wait_dlg->setFixedSize(400, wait_dlg->sizeHint().height());
|
||||
QObject::connect(wait_dlg, &QProgressDialog::canceled, this, &MainWindow::close);
|
||||
QObject::connect(can, &AbstractStream::eventsMerged, wait_dlg, &QProgressDialog::deleteLater);
|
||||
QObject::connect(this, &MainWindow::updateProgressBar, wait_dlg, [=](uint64_t cur, uint64_t total, bool success) {
|
||||
wait_dlg->setValue((int)((cur / (double)total) * 100));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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()->nonEmptyDBCCount() && fingerprint_to_dbc.object().contains(car_fingerprint)) {
|
||||
QTimer::singleShot(0, this, [this]() { loadDBCFromOpendbc(fingerprint_to_dbc[car_fingerprint].toString() + ".dbc"); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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::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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
manage_dbcs_menu->clear();
|
||||
|
||||
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.
|
||||
auto dbc_file = dbc()->findDBCFile(source);
|
||||
if (dbc_file) {
|
||||
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); });
|
||||
}
|
||||
bus_menu->setTitle(tr("Bus %1 (%2)").arg(source).arg(dbc_file ? dbc_file->name() : "No DBCs loaded"));
|
||||
|
||||
manage_dbcs_menu->addMenu(bus_menu);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
void MainWindow::updateRecentFileMenu() {
|
||||
open_recent_menu->clear();
|
||||
|
||||
int num_recent_files = std::min<int>(settings.recent_files.size(), MAX_RECENT_FILES);
|
||||
if (!num_recent_files) {
|
||||
open_recent_menu->addAction(tr("No Recent Files"))->setEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < num_recent_files; ++i) {
|
||||
QString text = tr("&%1 %2").arg(i + 1).arg(QFileInfo(settings.recent_files[i]).fileName());
|
||||
open_recent_menu->addAction(text, this, [this, file = settings.recent_files[i]]() { loadFile(file); });
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::remindSaveChanges() {
|
||||
while (!UndoStack::instance()->isClean()) {
|
||||
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) break;
|
||||
save();
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
bool MainWindow::eventFilter(QObject *obj, QEvent *event) {
|
||||
if (obj == floating_window && event->type() == QEvent::Close) {
|
||||
toggleChartsDocking();
|
||||
return true;
|
||||
}
|
||||
return QMainWindow::eventFilter(obj, event);
|
||||
}
|
||||
|
||||
void MainWindow::toggleChartsDocking() {
|
||||
if (floating_window) {
|
||||
// Dock the charts widget back to the main window
|
||||
floating_window->removeEventFilter(this);
|
||||
charts_layout->insertWidget(0, charts_widget, 1);
|
||||
floating_window->deleteLater();
|
||||
floating_window = nullptr;
|
||||
charts_widget->setIsDocked(true);
|
||||
} else {
|
||||
// Float the charts widget in a separate window
|
||||
floating_window = new QWidget(this, Qt::Window);
|
||||
floating_window->setWindowTitle("Charts");
|
||||
floating_window->setLayout(new QVBoxLayout());
|
||||
floating_window->layout()->addWidget(charts_widget);
|
||||
floating_window->installEventFilter(this);
|
||||
floating_window->showMaximized();
|
||||
charts_widget->setIsDocked(false);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::closeEvent(QCloseEvent *event) {
|
||||
remindSaveChanges();
|
||||
|
||||
installDownloadProgressHandler(nullptr);
|
||||
qInstallMessageHandler(nullptr);
|
||||
|
||||
if (floating_window)
|
||||
floating_window->deleteLater();
|
||||
|
||||
// save states
|
||||
settings.geometry = saveGeometry();
|
||||
settings.window_state = saveState();
|
||||
if (can && !can->liveStreaming()) {
|
||||
settings.video_splitter_state = video_splitter->saveState();
|
||||
}
|
||||
if (messages_widget) {
|
||||
settings.message_header_state = messages_widget->saveHeaderState();
|
||||
}
|
||||
|
||||
saveSessionState();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::saveSessionState() {
|
||||
settings.recent_dbc_file = "";
|
||||
settings.active_msg_id = "";
|
||||
settings.selected_msg_ids.clear();
|
||||
settings.active_charts.clear();
|
||||
|
||||
for (auto &f : dbc()->allDBCFiles())
|
||||
if (!f->isEmpty()) { settings.recent_dbc_file = f->filename; break; }
|
||||
|
||||
if (auto *detail = center_widget->getDetailWidget()) {
|
||||
auto [active_id, ids] = detail->serializeMessageIds();
|
||||
settings.active_msg_id = active_id;
|
||||
settings.selected_msg_ids = ids;
|
||||
}
|
||||
if (charts_widget)
|
||||
settings.active_charts = charts_widget->serializeChartIds();
|
||||
}
|
||||
|
||||
void MainWindow::restoreSessionState() {
|
||||
if (settings.recent_dbc_file.isEmpty() || dbc()->nonEmptyDBCCount() == 0) return;
|
||||
|
||||
QString dbc_file;
|
||||
for (auto& f : dbc()->allDBCFiles())
|
||||
if (!f->isEmpty()) { dbc_file = f->filename; break; }
|
||||
if (dbc_file != settings.recent_dbc_file) return;
|
||||
|
||||
if (!settings.selected_msg_ids.isEmpty())
|
||||
center_widget->ensureDetailWidget()->restoreTabs(settings.active_msg_id, settings.selected_msg_ids);
|
||||
|
||||
if (charts_widget != nullptr && !settings.active_charts.empty())
|
||||
charts_widget->restoreChartsFromIds(settings.active_charts);
|
||||
}
|
||||
|
||||
// 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));
|
||||
auto parent = 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();
|
||||
}
|
||||
113
tools/cabana/mainwin.h
Normal file
113
tools/cabana/mainwin.h
Normal file
@@ -0,0 +1,113 @@
|
||||
#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(AbstractStream *stream, const QString &dbc_file);
|
||||
void toggleChartsDocking();
|
||||
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 selectAndOpenStream();
|
||||
void openStream(AbstractStream *stream, const QString &dbc_file = {});
|
||||
void closeStream();
|
||||
void exportToCSV();
|
||||
|
||||
void newFile(SourceSet s = SOURCE_ALL);
|
||||
void openFile(SourceSet s = SOURCE_ALL);
|
||||
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 startStream(AbstractStream *stream, QString dbc_file);
|
||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||
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 updateRecentFiles(const QString &fn);
|
||||
void updateRecentFileMenu();
|
||||
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 onlineHelp();
|
||||
void toggleFullScreen();
|
||||
void updateStatus();
|
||||
void updateLoadSaveMenus();
|
||||
void createDockWidgets();
|
||||
void eventsMerged();
|
||||
void saveSessionState();
|
||||
void restoreSessionState();
|
||||
|
||||
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;
|
||||
QSplitter *video_splitter = nullptr;
|
||||
enum { MAX_RECENT_FILES = 15 };
|
||||
QMenu *open_recent_menu = nullptr;
|
||||
QMenu *manage_dbcs_menu = nullptr;
|
||||
QMenu *tools_menu = nullptr;
|
||||
QAction *close_stream_act = nullptr;
|
||||
QAction *export_to_csv_act = nullptr;
|
||||
QAction *save_dbc = nullptr;
|
||||
QAction *save_dbc_as = nullptr;
|
||||
QAction *copy_dbc_to_clipboard = nullptr;
|
||||
QString car_fingerprint;
|
||||
QByteArray default_state;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
464
tools/cabana/messageswidget.cc
Normal file
464
tools/cabana/messageswidget.cc
Normal file
@@ -0,0 +1,464 @@
|
||||
#include "tools/cabana/messageswidget.h"
|
||||
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QPainter>
|
||||
#include <QPalette>
|
||||
#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);
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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(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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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<br />
|
||||
<span style="color:gray">Shortcuts</span><br />
|
||||
Horizontal Scrolling: <span style="background-color:lightGray;color:gray"> shift+wheel </span>
|
||||
)"));
|
||||
}
|
||||
|
||||
QWidget *MessagesWidget::createToolBar() {
|
||||
QWidget *toolbar = new QWidget(this);
|
||||
QHBoxLayout *layout = new QHBoxLayout(toolbar);
|
||||
layout->setContentsMargins(0, 9, 0, 0);
|
||||
layout->addWidget(suppress_add = new QPushButton("Suppress Highlighted"));
|
||||
layout->addWidget(suppress_clear = new QPushButton());
|
||||
suppress_clear->setToolTip(tr("Clear suppressed"));
|
||||
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);
|
||||
layout->addWidget(suppress_defined_signals);
|
||||
|
||||
auto view_button = new ToolButton("three-dots", tr("View..."));
|
||||
view_button->setMenu(menu);
|
||||
view_button->setPopupMode(QToolButton::InstantPopup);
|
||||
view_button->setStyleSheet("QToolButton::menu-indicator { image: none; }");
|
||||
layout->addWidget(view_button);
|
||||
|
||||
QObject::connect(suppress_add, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
|
||||
QObject::connect(suppress_clear, &QPushButton::clicked, this, &MessagesWidget::suppressHighlighted);
|
||||
QObject::connect(suppress_defined_signals, &QCheckBox::stateChanged, can, &AbstractStream::suppressDefinedSignals);
|
||||
|
||||
suppressHighlighted();
|
||||
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;
|
||||
});
|
||||
emit titleChanged(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() {
|
||||
int n = sender() == suppress_add ? can->suppressHighlighted() : (can->clearSuppressed(), 0);
|
||||
suppress_clear->setText(n > 0 ? tr("Clear (%1)").arg(n) : tr("Clear"));
|
||||
suppress_clear->setEnabled(n > 0);
|
||||
}
|
||||
|
||||
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("Multi-Line bytes"), this, &MessagesWidget::setMultiLineBytes);
|
||||
action->setCheckable(true);
|
||||
action->setChecked(settings.multiple_lines_hex);
|
||||
|
||||
action = menu->addAction(tr("Show inactive Messages"), model, &MessageListModel::showInactivemessages);
|
||||
action->setCheckable(true);
|
||||
action->setChecked(model->show_inactive_messages);
|
||||
}
|
||||
|
||||
void MessagesWidget::setMultiLineBytes(bool multi) {
|
||||
settings.multiple_lines_hex = multi;
|
||||
delegate->setMultipleLines(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 = [](float freq) {
|
||||
if (freq > 0) {
|
||||
return freq >= 0.95 ? QString::number(std::nearbyint(freq)) : QString::number(freq, 'f', 2);
|
||||
} else {
|
||||
return QStringLiteral("--");
|
||||
}
|
||||
};
|
||||
|
||||
const static QString NA = QStringLiteral("N/A");
|
||||
const auto &item = items_[index.row()];
|
||||
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) : NA;
|
||||
case Column::ADDRESS: return toHexString(item.id.address);
|
||||
case Column::NODE: return item.node;
|
||||
case Column::FREQ: return item.id.source != INVALID_SOURCE ? getFreq(can->lastMessage(item.id).freq) : NA;
|
||||
case Column::COUNT: return item.id.source != INVALID_SOURCE ? QString::number(can->lastMessage(item.id).count) : NA;
|
||||
case Column::DATA: return item.id.source != INVALID_SOURCE ? "" : NA;
|
||||
}
|
||||
} else if (role == ColorsRole) {
|
||||
return QVariant::fromValue((void*)(&can->lastMessage(item.id).colors));
|
||||
} else if (role == BytesRole && index.column() == Column::DATA && item.id.source != INVALID_SOURCE) {
|
||||
return QVariant::fromValue((void*)(&can->lastMessage(item.id).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::showInactivemessages(bool show) {
|
||||
show_inactive_messages = show;
|
||||
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 compare = [this](const auto &l, const auto &r) {
|
||||
switch (sort_column) {
|
||||
case Column::NAME: return std::tie(l.name, l.id) < std::tie(r.name, r.id);
|
||||
case Column::SOURCE: return std::tie(l.id.source, l.id.address) < std::tie(r.id.source, r.id.address);
|
||||
case Column::ADDRESS: return std::tie(l.id.address, l.id.source) < std::tie(r.id.address, r.id.source);
|
||||
case Column::NODE: return std::tie(l.node, l.id) < std::tie(r.node, r.id);
|
||||
case Column::FREQ: return std::tie(can->lastMessage(l.id).freq, l.id) < std::tie(can->lastMessage(r.id).freq, r.id);
|
||||
case Column::COUNT: return std::tie(can->lastMessage(l.id).count, l.id) < std::tie(can->lastMessage(r.id).count, r.id);
|
||||
default: return false; // Default case to suppress compiler warning
|
||||
}
|
||||
};
|
||||
|
||||
if (sort_order == Qt::DescendingOrder)
|
||||
std::stable_sort(items.rbegin(), items.rend(), compare);
|
||||
else
|
||||
std::stable_sort(items.begin(), items.end(), compare);
|
||||
}
|
||||
|
||||
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 = toHexString(item.id.address).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:
|
||||
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;
|
||||
}
|
||||
|
||||
bool 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;
|
||||
items.reserve(all_messages.size());
|
||||
for (const auto &id : all_messages) {
|
||||
if (show_inactive_messages || can->isMessageActive(id)) {
|
||||
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();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void MessageListModel::msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids) {
|
||||
if (has_new_ids || ((filters_.count(Column::FREQ) || filters_.count(Column::COUNT) || filters_.count(Column::DATA)) &&
|
||||
++sort_threshold_ == settings.fps)) {
|
||||
sort_threshold_ = 0;
|
||||
if (filterAndSort()) return;
|
||||
}
|
||||
|
||||
// Update viewport
|
||||
emit dataChanged(index(0, 0), index(rowCount() - 1, columnCount() - 1));
|
||||
}
|
||||
|
||||
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 {
|
||||
const auto &item = ((MessageListModel*)model())->items_[index.row()];
|
||||
if (!can->isMessageActive(item.id)) {
|
||||
QStyleOptionViewItem custom_option = option;
|
||||
custom_option.palette.setBrush(QPalette::Text, custom_option.palette.color(QPalette::Disabled, QPalette::Text));
|
||||
auto color = QApplication::palette().color(QPalette::HighlightedText);
|
||||
color.setAlpha(100);
|
||||
custom_option.palette.setBrush(QPalette::HighlightedText, color);
|
||||
QTreeView::drawRow(painter, custom_option, index);
|
||||
} else {
|
||||
QTreeView::drawRow(painter, option, index);
|
||||
}
|
||||
|
||||
QPen oldPen = painter->pen();
|
||||
const int gridHint = style()->styleHint(QStyle::SH_Table_GridLineColor, &option, this);
|
||||
painter->setPen(QColor::fromRgba(static_cast<QRgb>(gridHint)));
|
||||
// Draw bottom border for the row
|
||||
painter->drawLine(option.rect.bottomLeft(), option.rect.bottomRight());
|
||||
// Draw vertical borders for each column
|
||||
for (int i = 0; i < header()->count(); ++i) {
|
||||
int sectionX = header()->sectionViewportPosition(i);
|
||||
painter->drawLine(sectionX, option.rect.top(), sectionX, option.rect.bottom());
|
||||
}
|
||||
painter->setPen(oldPen);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
setUniformRowHeights(!delegate->multipleLines());
|
||||
header()->resizeSection(MessageListModel::Column::DATA, delegate->sizeForBytes(max_bytes).width());
|
||||
}
|
||||
|
||||
void MessageView::wheelEvent(QWheelEvent *event) {
|
||||
if (event->modifiers() == Qt::ShiftModifier) {
|
||||
QApplication::sendEvent(horizontalScrollBar(), event);
|
||||
} else {
|
||||
QTreeView::wheelEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
121
tools/cabana/messageswidget.h
Normal file
121
tools/cabana/messageswidget.h
Normal file
@@ -0,0 +1,121 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
|
||||
#include <QAbstractTableModel>
|
||||
#include <QHeaderView>
|
||||
#include <QLineEdit>
|
||||
#include <QMenu>
|
||||
#include <QTreeView>
|
||||
#include <QWheelEvent>
|
||||
|
||||
#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 showInactivemessages(bool show);
|
||||
void msgsReceived(const std::set<MessageId> *new_msgs, bool has_new_ids);
|
||||
bool 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_;
|
||||
bool show_inactive_messages = true;
|
||||
|
||||
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;
|
||||
int sort_threshold_ = 0;
|
||||
};
|
||||
|
||||
class MessageView : public QTreeView {
|
||||
Q_OBJECT
|
||||
public:
|
||||
MessageView(QWidget *parent) : QTreeView(parent) {}
|
||||
void updateBytesSectionSize();
|
||||
|
||||
protected:
|
||||
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 wheelEvent(QWheelEvent *event) override;
|
||||
};
|
||||
|
||||
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);
|
||||
void titleChanged(const QString &title);
|
||||
|
||||
protected:
|
||||
QWidget *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;
|
||||
QMenu *menu;
|
||||
};
|
||||
346
tools/cabana/panda.cc
Normal file
346
tools/cabana/panda.cc
Normal file
@@ -0,0 +1,346 @@
|
||||
#include "tools/cabana/panda.h"
|
||||
|
||||
#include <unistd.h>
|
||||
#include <cassert>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
|
||||
#include "cereal/messaging/messaging.h"
|
||||
#include "common/swaglog.h"
|
||||
#include "common/util.h"
|
||||
|
||||
static libusb_context *init_usb_ctx() {
|
||||
libusb_context *context = nullptr;
|
||||
int err = libusb_init(&context);
|
||||
if (err != 0) {
|
||||
LOGE("libusb initialization error");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
#if LIBUSB_API_VERSION >= 0x01000106
|
||||
libusb_set_option(context, LIBUSB_OPTION_LOG_LEVEL, LIBUSB_LOG_LEVEL_INFO);
|
||||
#else
|
||||
libusb_set_debug(context, 3);
|
||||
#endif
|
||||
return context;
|
||||
}
|
||||
|
||||
Panda::Panda(std::string serial, uint32_t bus_offset) : bus_offset(bus_offset) {
|
||||
if (!init_usb_connection(serial)) {
|
||||
throw std::runtime_error("Error connecting to panda");
|
||||
}
|
||||
|
||||
LOGW("connected to %s over USB", serial.c_str());
|
||||
hw_type = get_hw_type();
|
||||
can_reset_communications();
|
||||
}
|
||||
|
||||
Panda::~Panda() {
|
||||
cleanup_usb();
|
||||
}
|
||||
|
||||
bool Panda::connected() {
|
||||
return connected_flag;
|
||||
}
|
||||
|
||||
bool Panda::comms_healthy() {
|
||||
return comms_healthy_flag;
|
||||
}
|
||||
|
||||
std::string Panda::hw_serial() {
|
||||
return hw_serial_str;
|
||||
}
|
||||
|
||||
std::vector<std::string> Panda::list(bool usb_only) {
|
||||
static std::unique_ptr<libusb_context, decltype(&libusb_exit)> context(init_usb_ctx(), libusb_exit);
|
||||
|
||||
ssize_t num_devices;
|
||||
libusb_device **dev_list = NULL;
|
||||
std::vector<std::string> serials;
|
||||
if (!context) { return serials; }
|
||||
|
||||
num_devices = libusb_get_device_list(context.get(), &dev_list);
|
||||
if (num_devices < 0) {
|
||||
LOGE("libusb can't get device list");
|
||||
goto finish;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < num_devices; ++i) {
|
||||
libusb_device *device = dev_list[i];
|
||||
libusb_device_descriptor desc;
|
||||
libusb_get_device_descriptor(device, &desc);
|
||||
if (desc.idVendor == 0x3801 && desc.idProduct == 0xddcc) {
|
||||
libusb_device_handle *handle = NULL;
|
||||
int ret = libusb_open(device, &handle);
|
||||
if (ret < 0) { goto finish; }
|
||||
|
||||
unsigned char desc_serial[26] = { 0 };
|
||||
ret = libusb_get_string_descriptor_ascii(handle, desc.iSerialNumber, desc_serial, std::size(desc_serial));
|
||||
libusb_close(handle);
|
||||
if (ret < 0) { goto finish; }
|
||||
|
||||
serials.push_back(std::string((char *)desc_serial, ret));
|
||||
}
|
||||
}
|
||||
|
||||
finish:
|
||||
if (dev_list != NULL) {
|
||||
libusb_free_device_list(dev_list, 1);
|
||||
}
|
||||
return serials;
|
||||
}
|
||||
|
||||
void Panda::set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16_t safety_param) {
|
||||
control_write(0xdc, (uint16_t)safety_model, safety_param);
|
||||
}
|
||||
|
||||
|
||||
cereal::PandaState::PandaType Panda::get_hw_type() {
|
||||
unsigned char hw_query[1] = {0};
|
||||
|
||||
control_read(0xc1, 0, 0, hw_query, 1);
|
||||
return (cereal::PandaState::PandaType)(hw_query[0]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
void Panda::send_heartbeat(bool engaged) {
|
||||
control_write(0xf3, engaged, 0);
|
||||
}
|
||||
|
||||
void Panda::set_can_speed_kbps(uint16_t bus, uint16_t speed) {
|
||||
control_write(0xde, bus, (speed * 10));
|
||||
}
|
||||
|
||||
|
||||
void Panda::set_data_speed_kbps(uint16_t bus, uint16_t speed) {
|
||||
control_write(0xf9, bus, (speed * 10));
|
||||
}
|
||||
|
||||
|
||||
|
||||
bool Panda::can_receive(std::vector<can_frame>& out_vec) {
|
||||
// Check if enough space left in buffer to store RECV_SIZE data
|
||||
assert(receive_buffer_size + RECV_SIZE <= sizeof(receive_buffer));
|
||||
|
||||
int recv = bulk_read(0x81, &receive_buffer[receive_buffer_size], RECV_SIZE);
|
||||
if (!comms_healthy()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ret = true;
|
||||
if (recv > 0) {
|
||||
receive_buffer_size += recv;
|
||||
ret = unpack_can_buffer(receive_buffer, receive_buffer_size, out_vec);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
void Panda::can_reset_communications() {
|
||||
control_write(0xc0, 0, 0);
|
||||
}
|
||||
|
||||
bool Panda::unpack_can_buffer(uint8_t *data, uint32_t &size, std::vector<can_frame> &out_vec) {
|
||||
int pos = 0;
|
||||
|
||||
while (pos <= size - sizeof(can_header)) {
|
||||
can_header header;
|
||||
memcpy(&header, &data[pos], sizeof(can_header));
|
||||
|
||||
const uint8_t data_len = dlc_to_len[header.data_len_code];
|
||||
if (pos + sizeof(can_header) + data_len > size) {
|
||||
// we don't have all the data for this message yet
|
||||
break;
|
||||
}
|
||||
|
||||
if (calculate_checksum(&data[pos], sizeof(can_header) + data_len) != 0) {
|
||||
LOGE("Panda CAN checksum failed");
|
||||
size = 0;
|
||||
can_reset_communications();
|
||||
return false;
|
||||
}
|
||||
|
||||
can_frame &canData = out_vec.emplace_back();
|
||||
canData.address = header.addr;
|
||||
canData.src = header.bus + bus_offset;
|
||||
if (header.rejected) {
|
||||
canData.src += CAN_REJECTED_BUS_OFFSET;
|
||||
}
|
||||
if (header.returned) {
|
||||
canData.src += CAN_RETURNED_BUS_OFFSET;
|
||||
}
|
||||
|
||||
canData.dat.assign((char *)&data[pos + sizeof(can_header)], data_len);
|
||||
|
||||
pos += sizeof(can_header) + data_len;
|
||||
}
|
||||
|
||||
// move the overflowing data to the beginning of the buffer for the next round
|
||||
memmove(data, &data[pos], size - pos);
|
||||
size -= pos;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
uint8_t Panda::calculate_checksum(uint8_t *data, uint32_t len) {
|
||||
uint8_t checksum = 0U;
|
||||
for (uint32_t i = 0U; i < len; i++) {
|
||||
checksum ^= data[i];
|
||||
}
|
||||
return checksum;
|
||||
}
|
||||
|
||||
// USB implementation methods
|
||||
bool Panda::init_usb_connection(const std::string& serial) {
|
||||
ssize_t num_devices;
|
||||
libusb_device **dev_list = NULL;
|
||||
int err = 0;
|
||||
|
||||
ctx = init_usb_ctx();
|
||||
if (!ctx) { goto fail; }
|
||||
|
||||
// connect by serial
|
||||
num_devices = libusb_get_device_list(ctx, &dev_list);
|
||||
if (num_devices < 0) { goto fail; }
|
||||
|
||||
for (size_t i = 0; i < num_devices; ++i) {
|
||||
libusb_device_descriptor desc;
|
||||
libusb_get_device_descriptor(dev_list[i], &desc);
|
||||
if (desc.idVendor == 0x3801 && desc.idProduct == 0xddcc) {
|
||||
int ret = libusb_open(dev_list[i], &dev_handle);
|
||||
if (dev_handle == NULL || ret < 0) { goto fail; }
|
||||
|
||||
unsigned char desc_serial[26] = { 0 };
|
||||
ret = libusb_get_string_descriptor_ascii(dev_handle, desc.iSerialNumber, desc_serial, std::size(desc_serial));
|
||||
if (ret < 0) { goto fail; }
|
||||
|
||||
hw_serial_str = std::string((char *)desc_serial, ret);
|
||||
if (serial.empty() || serial == hw_serial_str) {
|
||||
break;
|
||||
}
|
||||
libusb_close(dev_handle);
|
||||
dev_handle = NULL;
|
||||
}
|
||||
}
|
||||
if (dev_handle == NULL) goto fail;
|
||||
libusb_free_device_list(dev_list, 1);
|
||||
|
||||
if (libusb_kernel_driver_active(dev_handle, 0) == 1) {
|
||||
libusb_detach_kernel_driver(dev_handle, 0);
|
||||
}
|
||||
|
||||
err = libusb_set_configuration(dev_handle, 1);
|
||||
if (err != 0) { goto fail; }
|
||||
|
||||
err = libusb_claim_interface(dev_handle, 0);
|
||||
if (err != 0) { goto fail; }
|
||||
|
||||
return true;
|
||||
|
||||
fail:
|
||||
if (dev_list != NULL) {
|
||||
libusb_free_device_list(dev_list, 1);
|
||||
}
|
||||
cleanup_usb();
|
||||
return false;
|
||||
}
|
||||
|
||||
void Panda::cleanup_usb() {
|
||||
if (dev_handle) {
|
||||
libusb_release_interface(dev_handle, 0);
|
||||
libusb_close(dev_handle);
|
||||
dev_handle = nullptr;
|
||||
}
|
||||
|
||||
if (ctx) {
|
||||
libusb_exit(ctx);
|
||||
ctx = nullptr;
|
||||
}
|
||||
connected_flag = false;
|
||||
}
|
||||
|
||||
void Panda::handle_usb_issue(int err, const char func[]) {
|
||||
LOGE_100("usb error %d \"%s\" in %s", err, libusb_strerror((enum libusb_error)err), func);
|
||||
if (err == LIBUSB_ERROR_NO_DEVICE) {
|
||||
LOGE("lost connection");
|
||||
connected_flag = false;
|
||||
}
|
||||
}
|
||||
|
||||
int Panda::control_write(uint8_t bRequest, uint16_t wValue, uint16_t wIndex, unsigned int timeout) {
|
||||
int err;
|
||||
const uint8_t bmRequestType = LIBUSB_ENDPOINT_OUT | LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE;
|
||||
|
||||
if (!connected_flag) {
|
||||
return LIBUSB_ERROR_NO_DEVICE;
|
||||
}
|
||||
|
||||
do {
|
||||
err = libusb_control_transfer(dev_handle, bmRequestType, bRequest, wValue, wIndex, NULL, 0, timeout);
|
||||
if (err < 0) handle_usb_issue(err, __func__);
|
||||
} while (err < 0 && connected_flag);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
int Panda::control_read(uint8_t bRequest, uint16_t wValue, uint16_t wIndex, unsigned char *data, uint16_t wLength, unsigned int timeout) {
|
||||
int err;
|
||||
const uint8_t bmRequestType = LIBUSB_ENDPOINT_IN | LIBUSB_REQUEST_TYPE_VENDOR | LIBUSB_RECIPIENT_DEVICE;
|
||||
|
||||
if (!connected_flag) {
|
||||
return LIBUSB_ERROR_NO_DEVICE;
|
||||
}
|
||||
|
||||
do {
|
||||
err = libusb_control_transfer(dev_handle, bmRequestType, bRequest, wValue, wIndex, data, wLength, timeout);
|
||||
if (err < 0) handle_usb_issue(err, __func__);
|
||||
} while (err < 0 && connected_flag);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
int Panda::bulk_write(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) {
|
||||
int err;
|
||||
int transferred = 0;
|
||||
|
||||
if (!connected_flag) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
do {
|
||||
err = libusb_bulk_transfer(dev_handle, endpoint, data, length, &transferred, timeout);
|
||||
if (err == LIBUSB_ERROR_TIMEOUT) {
|
||||
LOGW("Transmit buffer full");
|
||||
break;
|
||||
} else if (err != 0 || length != transferred) {
|
||||
handle_usb_issue(err, __func__);
|
||||
}
|
||||
} while (err != 0 && connected_flag);
|
||||
|
||||
return transferred;
|
||||
}
|
||||
|
||||
int Panda::bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout) {
|
||||
int err;
|
||||
int transferred = 0;
|
||||
|
||||
if (!connected_flag) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
do {
|
||||
err = libusb_bulk_transfer(dev_handle, endpoint, data, length, &transferred, timeout);
|
||||
if (err == LIBUSB_ERROR_TIMEOUT) {
|
||||
break; // timeout is okay to exit, recv still happened
|
||||
} else if (err == LIBUSB_ERROR_OVERFLOW) {
|
||||
comms_healthy_flag = false;
|
||||
LOGE_100("overflow got 0x%x", transferred);
|
||||
} else if (err != 0) {
|
||||
handle_usb_issue(err, __func__);
|
||||
}
|
||||
} while (err != 0 && connected_flag);
|
||||
|
||||
return transferred;
|
||||
}
|
||||
95
tools/cabana/panda.h
Normal file
95
tools/cabana/panda.h
Normal file
@@ -0,0 +1,95 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <functional>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <libusb-1.0/libusb.h>
|
||||
|
||||
#include "cereal/gen/cpp/car.capnp.h"
|
||||
#include "cereal/gen/cpp/log.capnp.h"
|
||||
#include "panda/board/health.h"
|
||||
#include "panda/board/can.h"
|
||||
|
||||
#define USB_TX_SOFT_LIMIT (0x100U)
|
||||
#define USBPACKET_MAX_SIZE (0x40)
|
||||
#define RECV_SIZE (0x4000U)
|
||||
#define TIMEOUT 0
|
||||
|
||||
#define CAN_REJECTED_BUS_OFFSET 0xC0U
|
||||
#define CAN_RETURNED_BUS_OFFSET 0x80U
|
||||
|
||||
#define PANDA_BUS_OFFSET 4
|
||||
|
||||
struct __attribute__((packed)) can_header {
|
||||
uint8_t reserved : 1;
|
||||
uint8_t bus : 3;
|
||||
uint8_t data_len_code : 4;
|
||||
uint8_t rejected : 1;
|
||||
uint8_t returned : 1;
|
||||
uint8_t extended : 1;
|
||||
uint32_t addr : 29;
|
||||
uint8_t checksum : 8;
|
||||
};
|
||||
|
||||
struct can_frame {
|
||||
long address;
|
||||
std::string dat;
|
||||
long src;
|
||||
};
|
||||
|
||||
|
||||
class Panda {
|
||||
public:
|
||||
Panda(std::string serial="", uint32_t bus_offset=0);
|
||||
~Panda();
|
||||
|
||||
cereal::PandaState::PandaType hw_type = cereal::PandaState::PandaType::UNKNOWN;
|
||||
const uint32_t bus_offset;
|
||||
|
||||
bool connected();
|
||||
bool comms_healthy();
|
||||
std::string hw_serial();
|
||||
|
||||
// Static functions
|
||||
static std::vector<std::string> list(bool usb_only=false);
|
||||
|
||||
// Panda functionality
|
||||
cereal::PandaState::PandaType get_hw_type();
|
||||
void set_safety_model(cereal::CarParams::SafetyModel safety_model, uint16_t safety_param=0U);
|
||||
void send_heartbeat(bool engaged);
|
||||
void set_can_speed_kbps(uint16_t bus, uint16_t speed);
|
||||
void set_data_speed_kbps(uint16_t bus, uint16_t speed);
|
||||
bool can_receive(std::vector<can_frame>& out_vec);
|
||||
void can_reset_communications();
|
||||
|
||||
private:
|
||||
// USB connection members
|
||||
libusb_context *ctx = nullptr;
|
||||
libusb_device_handle *dev_handle = nullptr;
|
||||
std::string hw_serial_str;
|
||||
std::atomic<bool> connected_flag = true;
|
||||
std::atomic<bool> comms_healthy_flag = true;
|
||||
|
||||
// CAN buffer members
|
||||
uint8_t receive_buffer[RECV_SIZE + sizeof(can_header) + 64];
|
||||
uint32_t receive_buffer_size = 0;
|
||||
|
||||
// Internal methods
|
||||
bool init_usb_connection(const std::string& serial);
|
||||
void cleanup_usb();
|
||||
void handle_usb_issue(int err, const char func[]);
|
||||
int control_write(uint8_t request, uint16_t param1, uint16_t param2, unsigned int timeout=TIMEOUT);
|
||||
int control_read(uint8_t request, uint16_t param1, uint16_t param2, unsigned char *data, uint16_t length, unsigned int timeout=TIMEOUT);
|
||||
int bulk_write(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout=TIMEOUT);
|
||||
int bulk_read(unsigned char endpoint, unsigned char* data, int length, unsigned int timeout=TIMEOUT);
|
||||
bool unpack_can_buffer(uint8_t *data, uint32_t &size, std::vector<can_frame> &out_vec);
|
||||
uint8_t calculate_checksum(uint8_t *data, uint32_t len);
|
||||
};
|
||||
141
tools/cabana/settings.cc
Normal file
141
tools/cabana/settings.cc
Normal file
@@ -0,0 +1,141 @@
|
||||
#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/utils/util.h"
|
||||
|
||||
const int MIN_CACHE_MINIUTES = 30;
|
||||
const int MAX_CACHE_MINIUTES = 120;
|
||||
|
||||
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);
|
||||
op(s, "recent_dbc_file", settings.recent_dbc_file);
|
||||
op(s, "active_msg_id", settings.active_msg_id);
|
||||
op(s, "selected_msg_ids", settings.selected_msg_ids);
|
||||
op(s, "active_charts", settings.active_charts);
|
||||
}
|
||||
|
||||
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(MIN_CACHE_MINIUTES, MAX_CACHE_MINIUTES);
|
||||
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("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_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();
|
||||
}
|
||||
73
tools/cabana/settings.h
Normal file
73
tools/cabana/settings.h
Normal file
@@ -0,0 +1,73 @@
|
||||
#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;
|
||||
|
||||
// session data
|
||||
QString recent_dbc_file;
|
||||
QString active_msg_id;
|
||||
QStringList selected_msg_ids;
|
||||
QStringList active_charts;
|
||||
|
||||
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;
|
||||
722
tools/cabana/signalview.cc
Normal file
722
tools/cabana/signalview.cc
Normal file
@@ -0,0 +1,722 @@
|
||||
#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 *root_item, int pos, const cabana::Signal *sig) {
|
||||
Item *parent_item = new Item{.sig = sig, .parent = root_item, .title = sig->name, .type = Item::Sig};
|
||||
root_item->children.insert(pos, parent_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) {
|
||||
auto item = new Item{.sig = sig, .parent = parent_item, .title = titles[i], .type = (Item::Type)(i + Item::Name)};
|
||||
parent_item->children.push_back(item);
|
||||
if (item->type == Item::ExtraInfo) {
|
||||
parent_item = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return getItem(parent)->children.size();
|
||||
}
|
||||
|
||||
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->children.empty()) {
|
||||
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::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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
width = std::min<int>(option.widget->size().width() / 3.0, option.fontMetrics.horizontalAdvance(text) + spacing);
|
||||
}
|
||||
return {width, option.fontMetrics.height() + option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin) * 2};
|
||||
}
|
||||
|
||||
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 {
|
||||
const int h_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameHMargin) + 1;
|
||||
const int v_margin = option.widget->style()->pixelMetric(QStyle::PM_FocusFrameVMargin);
|
||||
auto item = static_cast<SignalModel::Item*>(index.internalPointer());
|
||||
|
||||
QRect rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin);
|
||||
painter->setRenderHint(QPainter::Antialiasing);
|
||||
if (option.state & QStyle::State_Selected) {
|
||||
painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight));
|
||||
}
|
||||
|
||||
if (index.column() == 0) {
|
||||
if (item->type == SignalModel::Item::Sig) {
|
||||
// color label
|
||||
QPainterPath path;
|
||||
QRect icon_rect{rect.x(), rect.y(), color_label_width, rect.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));
|
||||
|
||||
rect.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{rect.x(), rect.y(), option.fontMetrics.horizontalAdvance(indicator), rect.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);
|
||||
rect.setLeft(indicator_rect.right() + h_margin * 2);
|
||||
}
|
||||
} else {
|
||||
rect.setLeft(option.widget->style()->pixelMetric(QStyle::PM_TreeViewIndentation) + color_label_width + h_margin * 3);
|
||||
}
|
||||
|
||||
// name
|
||||
auto text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, rect.width());
|
||||
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
|
||||
painter->setFont(option.font);
|
||||
painter->drawText(rect, option.displayAlignment, text);
|
||||
} else if (index.column() == 1) {
|
||||
if (!item->sparkline.pixmap.isNull()) {
|
||||
QSize sparkline_size = item->sparkline.pixmap.size() / item->sparkline.pixmap.devicePixelRatio();
|
||||
painter->drawPixmap(QRect(rect.topLeft(), sparkline_size), item->sparkline.pixmap);
|
||||
// min-max value
|
||||
painter->setPen(option.palette.color(option.state & QStyle::State_Selected ? QPalette::HighlightedText : QPalette::Text));
|
||||
rect.adjust(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.horizontalAdvance(min), fm.horizontalAdvance(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).horizontalAdvance(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, CAN_MAX_DATA_BYTES);
|
||||
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 || item->type == SignalModel::Item::ExtraInfo) {
|
||||
auto expand_index = model->index(index.row(), 0, index.parent());
|
||||
tree->setExpanded(expand_index, !tree->isExpanded(expand_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();
|
||||
}
|
||||
|
||||
std::pair<QModelIndex, QModelIndex> SignalView::visibleSignalRange() {
|
||||
auto topLevelIndex = [](QModelIndex index) {
|
||||
while (index.isValid() && index.parent().isValid()) index = index.parent();
|
||||
return index;
|
||||
};
|
||||
|
||||
const auto viewport_rect = tree->viewport()->rect();
|
||||
QModelIndex first_visible = tree->indexAt(viewport_rect.topLeft());
|
||||
if (first_visible.parent().isValid()) {
|
||||
first_visible = topLevelIndex(first_visible);
|
||||
first_visible = first_visible.siblingAtRow(first_visible.row() + 1);
|
||||
}
|
||||
|
||||
QModelIndex last_visible = topLevelIndex(tree->indexAt(viewport_rect.bottomRight()));
|
||||
if (!last_visible.isValid()) {
|
||||
last_visible = model->index(model->rowCount() - 1, 0);
|
||||
}
|
||||
return {first_visible, last_visible};
|
||||
}
|
||||
|
||||
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().horizontalAdvance(item->sig_val));
|
||||
}
|
||||
}
|
||||
|
||||
auto [first_visible, last_visible] = visibleSignalRange();
|
||||
if (first_visible.isValid() && last_visible.isValid()) {
|
||||
const static int min_max_width = QFontMetrics(delegate->minmax_font).horizontalAdvance("-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);
|
||||
|
||||
auto [first, last] = can->eventsInRange(model->msg_id, std::make_pair(last_msg.ts -settings.sparkline_range, last_msg.ts));
|
||||
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, item->sig, first, last, settings.sparkline_range, size));
|
||||
}
|
||||
synchronizer.waitForFinished();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
147
tools/cabana/signalview.h
Normal file
147
tools/cabana/signalview.h
Normal file
@@ -0,0 +1,147 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <utility>
|
||||
|
||||
#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;
|
||||
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;
|
||||
|
||||
private:
|
||||
void insertItem(SignalModel::Item *root_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;
|
||||
};
|
||||
|
||||
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);
|
||||
std::pair<QModelIndex, QModelIndex> visibleSignalRange();
|
||||
|
||||
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;
|
||||
};
|
||||
325
tools/cabana/streams/abstractstream.cc
Normal file
325
tools/cabana/streams/abstractstream.cc
Normal file
@@ -0,0 +1,325 @@
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
|
||||
#include <QApplication>
|
||||
#include "common/timing.h"
|
||||
#include "tools/cabana/settings.h"
|
||||
|
||||
static const int EVENT_NEXT_BUFFER_SIZE = 6 * 1024 * 1024; // 6MB
|
||||
|
||||
AbstractStream *can = nullptr;
|
||||
|
||||
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(this, &AbstractStream::seeking, this, [this](double sec) { current_sec_ = sec; });
|
||||
QObject::connect(dbc(), &DBCManager::DBCFileChanged, this, &AbstractStream::updateMasks);
|
||||
QObject::connect(dbc(), &DBCManager::maskUpdated, this, &AbstractStream::updateMasks);
|
||||
}
|
||||
|
||||
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.bit_flip_counts[i][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;
|
||||
for (auto &[_, m] : messages_) {
|
||||
for (auto &last_change : m.last_changes) {
|
||||
const double dt = current_sec_ - last_change.ts;
|
||||
if (dt < 2.0) {
|
||||
last_change.suppressed = true;
|
||||
}
|
||||
cnt += last_change.suppressed;
|
||||
}
|
||||
for (auto &flip_counts : m.bit_flip_counts) flip_counts.fill(0);
|
||||
}
|
||||
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 (time_range_ && (current_sec_ < time_range_->first || current_sec_ >= time_range_->second)) {
|
||||
seekTo(time_range_->first);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sources.size() != prev_src_size) {
|
||||
updateMasks();
|
||||
emit sourcesUpdated(sources);
|
||||
}
|
||||
emit msgsReceived(&msgs, prev_msg_size != last_msgs.size());
|
||||
}
|
||||
|
||||
void AbstractStream::setTimeRange(const std::optional<std::pair<double, double>> &range) {
|
||||
time_range_ = range;
|
||||
if (time_range_ && (current_sec_ < time_range_->first || current_sec_ >= time_range_->second)) {
|
||||
seekTo(time_range_->first);
|
||||
}
|
||||
emit timeRangeChanged(time_range_);
|
||||
}
|
||||
|
||||
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) const {
|
||||
static CanData empty_data = {};
|
||||
auto it = last_msgs.find(id);
|
||||
return it != last_msgs.end() ? it->second : empty_data;
|
||||
}
|
||||
|
||||
bool AbstractStream::isMessageActive(const MessageId &id) const {
|
||||
if (id.source == INVALID_SOURCE) {
|
||||
return false;
|
||||
}
|
||||
// Check if the message is active based on time difference and frequency
|
||||
const auto &m = lastMessage(id);
|
||||
float delta = currentSec() - m.ts;
|
||||
|
||||
if (m.freq < std::numeric_limits<double>::epsilon()) {
|
||||
return delta < 1.5;
|
||||
}
|
||||
|
||||
return delta < (5.0 / m.freq) + (1.0 / settings.fps);
|
||||
}
|
||||
|
||||
void AbstractStream::updateLastMsgsTo(double sec) {
|
||||
current_sec_ = sec;
|
||||
uint64_t last_ts = toMonoTime(sec);
|
||||
std::unordered_map<MessageId, CanData> msgs;
|
||||
msgs.reserve(events_.size());
|
||||
|
||||
for (const auto &[id, ev] : events_) {
|
||||
auto it = std::upper_bound(ev.begin(), ev.end(), last_ts, CompareCanEvent());
|
||||
if (it != ev.begin()) {
|
||||
auto &m = msgs[id];
|
||||
double freq = 0;
|
||||
// Keep suppressed bits.
|
||||
if (auto old_m = messages_.find(id); old_m != messages_.end()) {
|
||||
freq = old_m->second.freq;
|
||||
m.last_changes.reserve(old_m->second.last_changes.size());
|
||||
std::transform(old_m->second.last_changes.cbegin(), old_m->second.last_changes.cend(),
|
||||
std::back_inserter(m.last_changes),
|
||||
[](const auto &change) { return CanData::ByteLastChange{.suppressed = change.suppressed}; });
|
||||
}
|
||||
|
||||
auto prev = std::prev(it);
|
||||
m.compute(id, (*prev)->dat, (*prev)->size, toSeconds((*prev)->mono_time), getSpeed(), {}, freq);
|
||||
m.count = std::distance(ev.begin(), prev) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
new_msgs_.clear();
|
||||
messages_ = std::move(msgs);
|
||||
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);
|
||||
|
||||
std::lock_guard lk(mutex_);
|
||||
seek_finished_ = true;
|
||||
seek_finished_cv_.notify_one();
|
||||
}
|
||||
|
||||
void AbstractStream::waitForSeekFinshed() {
|
||||
std::unique_lock lock(mutex_);
|
||||
seek_finished_cv_.wait(lock, [this]() { return seek_finished_; });
|
||||
seek_finished_ = false;
|
||||
}
|
||||
|
||||
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(); });
|
||||
|
||||
// Group events by message ID
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
std::pair<CanEventIter, CanEventIter> AbstractStream::eventsInRange(const MessageId &id, std::optional<std::pair<double, double>> time_range) const {
|
||||
const auto &events = can->events(id);
|
||||
if (!time_range) return {events.begin(), events.end()};
|
||||
|
||||
auto first = std::lower_bound(events.begin(), events.end(), can->toMonoTime(time_range->first), CompareCanEvent());
|
||||
auto last = std::upper_bound(first, events.end(), can->toMonoTime(time_range->second), CompareCanEvent());
|
||||
return {first, last};
|
||||
}
|
||||
|
||||
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 from the past one minute data
|
||||
double calc_freq(const MessageId &msg_id, double current_sec) {
|
||||
auto [first, last] = can->eventsInRange(msg_id, std::make_pair(current_sec - 59, current_sec));
|
||||
int count = std::distance(first, last);
|
||||
if (count <= 1) return 0.0;
|
||||
|
||||
double duration = ((*std::prev(last))->mono_time - (*first)->mono_time) / 1e9;
|
||||
return duration > std::numeric_limits<double>::epsilon() ? (count - 1) / duration : 0.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.assign(can_data, can_data + size);
|
||||
colors.assign(size, QColor(0, 0, 0, 0));
|
||||
last_changes.resize(size);
|
||||
bit_flip_counts.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
|
||||
last_change.same_delta_counter += std::signbit(delta) == std::signbit(last_change.delta) ? 1 : -4;
|
||||
last_change.same_delta_counter = std::clamp(last_change.same_delta_counter, 0, 16);
|
||||
|
||||
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
|
||||
auto &row_bit_flips = bit_flip_counts[i];
|
||||
const uint8_t diff = (cur ^ last);
|
||||
for (int bit = 0; bit < 8; bit++) {
|
||||
if (diff & (1u << bit)) {
|
||||
++row_bit_flips[7 - bit];
|
||||
}
|
||||
}
|
||||
|
||||
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
Normal file
157
tools/cabana/streams/abstractstream.h
Normal file
@@ -0,0 +1,157 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <condition_variable>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <QColor>
|
||||
#include <QDateTime>
|
||||
|
||||
#include "cereal/messaging/messaging.h"
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
#include "tools/cabana/utils/util.h"
|
||||
#include "tools/replay/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 = 0;
|
||||
int delta = 0;
|
||||
int same_delta_counter = 0;
|
||||
bool suppressed = false;
|
||||
};
|
||||
std::vector<ByteLastChange> last_changes;
|
||||
std::vector<std::array<uint32_t, 8>> bit_flip_counts;
|
||||
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; }
|
||||
};
|
||||
|
||||
typedef std::unordered_map<MessageId, std::vector<const CanEvent *>> MessageEventsMap;
|
||||
using CanEventIter = std::vector<const CanEvent *>::const_iterator;
|
||||
|
||||
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 uint64_t beginMonoTime() const { return 0; }
|
||||
virtual double minSeconds() const { return 0; }
|
||||
virtual double maxSeconds() const { return 0; }
|
||||
virtual void setSpeed(float speed) {}
|
||||
virtual double getSpeed() { return 1; }
|
||||
virtual bool isPaused() const { return false; }
|
||||
virtual void pause(bool pause) {}
|
||||
void setTimeRange(const std::optional<std::pair<double, double>> &range);
|
||||
const std::optional<std::pair<double, double>> &timeRange() const { return time_range_; }
|
||||
|
||||
inline double currentSec() const { return current_sec_; }
|
||||
inline uint64_t toMonoTime(double sec) const { return beginMonoTime() + std::max(sec, 0.0) * 1e9; }
|
||||
inline double toSeconds(uint64_t mono_time) const { return std::max(0.0, (mono_time - beginMonoTime()) / 1e9); }
|
||||
|
||||
inline const std::unordered_map<MessageId, CanData> &lastMessages() const { return last_msgs; }
|
||||
bool isMessageActive(const MessageId &id) const;
|
||||
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;
|
||||
const std::vector<const CanEvent *> &events(const MessageId &id) const;
|
||||
std::pair<CanEventIter, CanEventIter> eventsInRange(const MessageId &id, std::optional<std::pair<double, double>> time_range) const;
|
||||
|
||||
size_t suppressHighlighted();
|
||||
void clearSuppressed();
|
||||
void suppressDefinedSignals(bool suppress);
|
||||
|
||||
signals:
|
||||
void paused();
|
||||
void resume();
|
||||
void seeking(double sec);
|
||||
void seekedTo(double sec);
|
||||
void timeRangeChanged(const std::optional<std::pair<double, double>> &range);
|
||||
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);
|
||||
void waitForSeekFinshed();
|
||||
std::vector<const CanEvent *> all_events_;
|
||||
double current_sec_ = 0;
|
||||
std::optional<std::pair<double, double>> time_range_;
|
||||
|
||||
private:
|
||||
void updateLastMessages();
|
||||
void updateLastMsgsTo(double sec);
|
||||
void updateMasks();
|
||||
|
||||
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::condition_variable seek_finished_cv_;
|
||||
bool seek_finished_ = false;
|
||||
std::set<MessageId> new_msgs_;
|
||||
std::unordered_map<MessageId, CanData> messages_;
|
||||
std::unordered_map<MessageId, std::vector<uint8_t>> masks_;
|
||||
};
|
||||
|
||||
class AbstractOpenStreamWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
public:
|
||||
AbstractOpenStreamWidget(QWidget *parent = nullptr) : QWidget(parent) {}
|
||||
virtual AbstractStream *open() = 0;
|
||||
|
||||
signals:
|
||||
void enableOpenButton(bool);
|
||||
};
|
||||
|
||||
class DummyStream : public AbstractStream {
|
||||
Q_OBJECT
|
||||
public:
|
||||
DummyStream(QObject *parent) : AbstractStream(parent) {}
|
||||
QString routeName() const override { return tr("No Stream"); }
|
||||
void start() override {}
|
||||
};
|
||||
|
||||
// A global pointer referring to the unique AbstractStream object
|
||||
extern AbstractStream *can;
|
||||
67
tools/cabana/streams/devicestream.cc
Normal file
67
tools/cabana/streams/devicestream.cc
Normal file
@@ -0,0 +1,67 @@
|
||||
#include "tools/cabana/streams/devicestream.h"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "cereal/services.h"
|
||||
|
||||
#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, false, true, services.at("can").queue_size));
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
// OpenDeviceWidget
|
||||
|
||||
OpenDeviceWidget::OpenDeviceWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) {
|
||||
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);
|
||||
}
|
||||
|
||||
AbstractStream *OpenDeviceWidget::open() {
|
||||
QString ip = ip_address->text().isEmpty() ? "127.0.0.1" : ip_address->text();
|
||||
bool msgq = group->checkedId() == 0;
|
||||
return new DeviceStream(qApp, msgq ? "" : ip);
|
||||
}
|
||||
28
tools/cabana/streams/devicestream.h
Normal file
28
tools/cabana/streams/devicestream.h
Normal file
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "tools/cabana/streams/livestream.h"
|
||||
|
||||
class DeviceStream : public LiveStream {
|
||||
Q_OBJECT
|
||||
public:
|
||||
DeviceStream(QObject *parent, QString address = {});
|
||||
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(QWidget *parent = nullptr);
|
||||
AbstractStream *open() override;
|
||||
|
||||
private:
|
||||
QLineEdit *ip_address;
|
||||
QButtonGroup *group;
|
||||
};
|
||||
148
tools/cabana/streams/livestream.cc
Normal file
148
tools/cabana/streams/livestream.cc
Normal file
@@ -0,0 +1,148 @@
|
||||
#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);
|
||||
}
|
||||
|
||||
LiveStream::~LiveStream() {
|
||||
stop();
|
||||
}
|
||||
|
||||
void LiveStream::startUpdateTimer() {
|
||||
update_timer.stop();
|
||||
update_timer.start(1000.0 / settings.fps, this);
|
||||
timer_id = update_timer.timerId();
|
||||
}
|
||||
|
||||
void LiveStream::start() {
|
||||
stream_thread->start();
|
||||
startUpdateTimer();
|
||||
begin_date_time = QDateTime::currentDateTime();
|
||||
}
|
||||
|
||||
void LiveStream::stop() {
|
||||
if (!stream_thread) return;
|
||||
|
||||
update_timer.stop();
|
||||
stream_thread->requestInterruption();
|
||||
stream_thread->quit();
|
||||
stream_thread->wait();
|
||||
stream_thread = nullptr;
|
||||
}
|
||||
|
||||
// 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_);
|
||||
uint64_t last_received_ts = !received_events_.empty() ? received_events_.back()->mono_time : 0;
|
||||
lastest_event_ts = std::max(lastest_event_ts, last_received_ts);
|
||||
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, lastest_event_ts);
|
||||
post_last_event = (first_event_ts == lastest_event_ts);
|
||||
emit seekedTo((current_event_ts - begin_event_ts) / 1e9);
|
||||
}
|
||||
|
||||
void LiveStream::pause(bool pause) {
|
||||
paused_ = pause;
|
||||
emit(pause ? paused() : resume());
|
||||
}
|
||||
56
tools/cabana/streams/livestream.h
Normal file
56
tools/cabana/streams/livestream.h
Normal file
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#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;
|
||||
void stop();
|
||||
inline QDateTime beginDateTime() const { return begin_date_time; }
|
||||
inline uint64_t beginMonoTime() const override { return begin_event_ts; }
|
||||
double maxSeconds() const override { return std::max(1.0, (lastest_event_ts - 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 lastest_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;
|
||||
};
|
||||
187
tools/cabana/streams/pandastream.cc
Normal file
187
tools/cabana/streams/pandastream.cc
Normal file
@@ -0,0 +1,187 @@
|
||||
#include "tools/cabana/streams/pandastream.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QCheckBox>
|
||||
#include <QLabel>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
|
||||
PandaStream::PandaStream(QObject *parent, PandaStreamConfig config_) : config(config_), LiveStream(parent) {
|
||||
if (!connect()) {
|
||||
throw std::runtime_error("Failed to connect to panda");
|
||||
}
|
||||
}
|
||||
|
||||
bool PandaStream::connect() {
|
||||
try {
|
||||
qDebug() << "Connecting to panda " << config.serial;
|
||||
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::NO_OUTPUT);
|
||||
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].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);
|
||||
}
|
||||
}
|
||||
|
||||
// OpenPandaWidget
|
||||
|
||||
OpenPandaWidget::OpenPandaWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) {
|
||||
form_layout = new QFormLayout(this);
|
||||
if (can && dynamic_cast<PandaStream *>(can) != nullptr) {
|
||||
form_layout->addWidget(new QLabel(tr("Already connected to %1.").arg(can->routeName())));
|
||||
form_layout->addWidget(new QLabel("Close the current connection via [File menu -> Close Stream] before connecting to another Panda."));
|
||||
QTimer::singleShot(0, [this]() { emit enableOpenButton(false); });
|
||||
return;
|
||||
}
|
||||
|
||||
QHBoxLayout *serial_layout = new QHBoxLayout();
|
||||
serial_layout->addWidget(serial_edit = new QComboBox());
|
||||
|
||||
QPushButton *refresh = new QPushButton(tr("Refresh"));
|
||||
refresh->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Preferred);
|
||||
serial_layout->addWidget(refresh);
|
||||
form_layout->addRow(tr("Serial"), serial_layout);
|
||||
|
||||
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() {
|
||||
for (int i = form_layout->rowCount() - 1; i > 0; --i) {
|
||||
form_layout->removeRow(i);
|
||||
}
|
||||
|
||||
QString serial = serial_edit->currentText();
|
||||
bool has_fd = false;
|
||||
bool has_panda = !serial.isEmpty();
|
||||
if (has_panda) {
|
||||
try {
|
||||
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) {
|
||||
qDebug() << "failed to open panda" << serial;
|
||||
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;});
|
||||
}
|
||||
|
||||
form_layout->addRow(tr("Bus %1:").arg(i), bus_layout);
|
||||
}
|
||||
} else {
|
||||
config.serial = "";
|
||||
form_layout->addWidget(new QLabel(tr("No panda found")));
|
||||
}
|
||||
}
|
||||
|
||||
AbstractStream *OpenPandaWidget::open() {
|
||||
try {
|
||||
return new PandaStream(qApp, config);
|
||||
} catch (std::exception &e) {
|
||||
QMessageBox::warning(nullptr, tr("Warning"), tr("Failed to connect to panda: '%1'").arg(e.what()));
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
57
tools/cabana/streams/pandastream.h
Normal file
57
tools/cabana/streams/pandastream.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QFormLayout>
|
||||
|
||||
#include "tools/cabana/streams/livestream.h"
|
||||
#include "tools/cabana/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 BusConfig {
|
||||
int can_speed_kbps = 500;
|
||||
int data_speed_kbps = 2000;
|
||||
bool can_fd = false;
|
||||
};
|
||||
|
||||
struct PandaStreamConfig {
|
||||
QString serial = "";
|
||||
std::vector<BusConfig> bus_config;
|
||||
};
|
||||
|
||||
class PandaStream : public LiveStream {
|
||||
Q_OBJECT
|
||||
public:
|
||||
PandaStream(QObject *parent, PandaStreamConfig config_ = {});
|
||||
~PandaStream() { stop(); }
|
||||
inline QString routeName() const override {
|
||||
return QString("Panda: %1").arg(config.serial);
|
||||
}
|
||||
|
||||
protected:
|
||||
bool connect();
|
||||
void streamThread() override;
|
||||
|
||||
std::unique_ptr<Panda> panda;
|
||||
PandaStreamConfig config = {};
|
||||
};
|
||||
|
||||
class OpenPandaWidget : public AbstractOpenStreamWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
OpenPandaWidget(QWidget *parent = nullptr);
|
||||
AbstractStream *open() override;
|
||||
|
||||
private:
|
||||
void refreshSerials();
|
||||
void buildConfigForm();
|
||||
|
||||
QComboBox *serial_edit;
|
||||
QFormLayout *form_layout;
|
||||
PandaStreamConfig config = {};
|
||||
};
|
||||
176
tools/cabana/streams/replaystream.cc
Normal file
176
tools/cabana/streams/replaystream.cc
Normal file
@@ -0,0 +1,176 @@
|
||||
#include "tools/cabana/streams/replaystream.h"
|
||||
|
||||
#include <QLabel>
|
||||
#include <QFileDialog>
|
||||
#include <QGridLayout>
|
||||
#include <QMessageBox>
|
||||
#include <QPushButton>
|
||||
|
||||
#include "common/timing.h"
|
||||
#include "common/util.h"
|
||||
#include "tools/cabana/streams/routes.h"
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
void ReplayStream::mergeSegments() {
|
||||
auto event_data = replay->getEventData();
|
||||
for (const auto &[n, seg] : event_data->segments) {
|
||||
if (!processed_segments.count(n)) {
|
||||
processed_segments.insert(n);
|
||||
|
||||
std::vector<const CanEvent *> new_events;
|
||||
new_events.reserve(seg->log->events.size());
|
||||
for (const Event &e : seg->log->events) {
|
||||
if (e.which == cereal::Event::Which::CAN) {
|
||||
capnp::FlatArrayMessageReader reader(e.data);
|
||||
auto event = reader.getRoot<cereal::Event>();
|
||||
for (const auto &c : event.getCan()) {
|
||||
new_events.push_back(newEvent(e.mono_time, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
mergeEvents(new_events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ReplayStream::loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags, bool auto_source) {
|
||||
replay.reset(new Replay(route.toStdString(), {"can", "roadEncodeIdx", "driverEncodeIdx", "wideRoadEncodeIdx", "carParams"},
|
||||
{}, nullptr, replay_flags, data_dir.toStdString(), auto_source));
|
||||
replay->setSegmentCacheLimit(settings.max_cached_minutes);
|
||||
replay->installEventFilter([this](const Event *event) { return eventFilter(event); });
|
||||
|
||||
// Forward replay callbacks to corresponding Qt signals.
|
||||
replay->onSeeking = [this](double sec) { emit seeking(sec); };
|
||||
replay->onSeekedTo = [this](double sec) {
|
||||
emit seekedTo(sec);
|
||||
waitForSeekFinshed();
|
||||
};
|
||||
replay->onQLogLoaded = [this](std::shared_ptr<LogReader> qlog) { emit qLogLoaded(qlog); };
|
||||
replay->onSegmentsMerged = [this]() { QMetaObject::invokeMethod(this, &ReplayStream::mergeSegments, Qt::BlockingQueuedConnection); };
|
||||
|
||||
bool success = replay->load();
|
||||
if (!success) {
|
||||
if (replay->lastRouteError() == RouteLoadError::Unauthorized) {
|
||||
auto auth_content = util::read_file(util::getenv("HOME") + "/.comma/auth.json");
|
||||
QString message;
|
||||
if (auth_content.empty()) {
|
||||
message = "Authentication Required. Please run the following command to authenticate:\n\n"
|
||||
"python3 tools/lib/auth.py\n\n"
|
||||
"This will grant access to routes from your comma account.";
|
||||
} else {
|
||||
message = tr("Access Denied. You do not have permission to access route:\n\n%1\n\n"
|
||||
"This is likely a private route.").arg(route);
|
||||
}
|
||||
QMessageBox::warning(nullptr, tr("Access Denied"), message);
|
||||
} else if (replay->lastRouteError() == RouteLoadError::NetworkError) {
|
||||
QMessageBox::warning(nullptr, tr("Network Error"),
|
||||
tr("Unable to load the route:\n\n %1.\n\nPlease check your network connection and try again.").arg(route));
|
||||
} else if (replay->lastRouteError() == RouteLoadError::FileNotFound) {
|
||||
QMessageBox::warning(nullptr, tr("Route Not Found"),
|
||||
tr("The specified route could not be found:\n\n %1.\n\nPlease check the route name and try again.").arg(route));
|
||||
} else {
|
||||
QMessageBox::warning(nullptr, tr("Route Load Failed"), tr("Failed to load route: '%1'").arg(route));
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
bool ReplayStream::eventFilter(const Event *event) {
|
||||
static double prev_update_ts = 0;
|
||||
if (event->which == cereal::Event::Which::CAN) {
|
||||
double current_sec = toSeconds(event->mono_time);
|
||||
capnp::FlatArrayMessageReader reader(event->data);
|
||||
auto e = reader.getRoot<cereal::Event>();
|
||||
for (const auto &c : e.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());
|
||||
}
|
||||
|
||||
|
||||
// OpenReplayWidget
|
||||
|
||||
OpenReplayWidget::OpenReplayWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) {
|
||||
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 route name or browse for local/remote route"));
|
||||
auto browse_remote_btn = new QPushButton(tr("Remote route..."), this);
|
||||
grid_layout->addWidget(browse_remote_btn, 0, 2);
|
||||
auto browse_local_btn = new QPushButton(tr("Local route..."), this);
|
||||
grid_layout->addWidget(browse_local_btn, 0, 3);
|
||||
|
||||
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)));
|
||||
cameras[0]->setChecked(true);
|
||||
camera_layout->addStretch(1);
|
||||
grid_layout->addItem(camera_layout, 1, 1);
|
||||
|
||||
setMinimumWidth(550);
|
||||
QObject::connect(browse_local_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();
|
||||
}
|
||||
});
|
||||
QObject::connect(browse_remote_btn, &QPushButton::clicked, [this]() {
|
||||
RoutesDialog route_dlg(this);
|
||||
if (route_dlg.exec()) {
|
||||
route_edit->setText(route_dlg.route());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
AbstractStream *OpenReplayWidget::open() {
|
||||
QString route = route_edit->text();
|
||||
QString data_dir;
|
||||
if (int idx = route.lastIndexOf('/'); idx != -1 && util::file_exists(route.toStdString())) {
|
||||
data_dir = route.mid(0, idx + 1);
|
||||
route = route.mid(idx + 1);
|
||||
}
|
||||
|
||||
bool is_valid_format = Route::parseRoute(route.toStdString()).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)) {
|
||||
return replay_stream.release();
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
57
tools/cabana/streams/replaystream.h
Normal file
57
tools/cabana/streams/replaystream.h
Normal 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"
|
||||
|
||||
Q_DECLARE_METATYPE(std::shared_ptr<LogReader>);
|
||||
|
||||
class ReplayStream : public AbstractStream {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
ReplayStream(QObject *parent);
|
||||
void start() override { replay->start(); }
|
||||
bool loadRoute(const QString &route, const QString &data_dir, uint32_t replay_flags = REPLAY_FLAG_NONE, bool auto_source = false);
|
||||
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 QString::fromStdString(replay->route().name()); }
|
||||
inline QString carFingerprint() const override { return replay->carFingerprint().c_str(); }
|
||||
double minSeconds() const override { return replay->minSeconds(); }
|
||||
double maxSeconds() const { return replay->maxSeconds(); }
|
||||
inline QDateTime beginDateTime() const { return QDateTime::fromSecsSinceEpoch(replay->routeDateTime()); }
|
||||
inline uint64_t beginMonoTime() const override { return replay->routeStartNanos(); }
|
||||
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;
|
||||
|
||||
signals:
|
||||
void qLogLoaded(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(QWidget *parent = nullptr);
|
||||
AbstractStream *open() override;
|
||||
|
||||
private:
|
||||
QLineEdit *route_edit;
|
||||
std::vector<QCheckBox *> cameras;
|
||||
};
|
||||
138
tools/cabana/streams/routes.cc
Normal file
138
tools/cabana/streams/routes.cc
Normal file
@@ -0,0 +1,138 @@
|
||||
#include "tools/cabana/streams/routes.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QFormLayout>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QListWidget>
|
||||
#include <QMessageBox>
|
||||
#include <QPainter>
|
||||
|
||||
class OneShotHttpRequest : public HttpRequest {
|
||||
public:
|
||||
OneShotHttpRequest(QObject *parent) : HttpRequest(parent, false) {}
|
||||
void send(const QString &url) {
|
||||
if (reply) {
|
||||
reply->disconnect();
|
||||
reply->abort();
|
||||
reply->deleteLater();
|
||||
reply = nullptr;
|
||||
}
|
||||
sendRequest(url);
|
||||
}
|
||||
};
|
||||
|
||||
// The RouteListWidget class extends QListWidget to display a custom message when empty
|
||||
class RouteListWidget : public QListWidget {
|
||||
public:
|
||||
RouteListWidget(QWidget *parent = nullptr) : QListWidget(parent) {}
|
||||
void setEmptyText(const QString &text) {
|
||||
empty_text_ = text;
|
||||
viewport()->update();
|
||||
}
|
||||
void paintEvent(QPaintEvent *event) override {
|
||||
QListWidget::paintEvent(event);
|
||||
if (count() == 0) {
|
||||
QPainter painter(viewport());
|
||||
painter.drawText(viewport()->rect(), Qt::AlignCenter, empty_text_);
|
||||
}
|
||||
}
|
||||
QString empty_text_ = tr("No items");
|
||||
};
|
||||
|
||||
RoutesDialog::RoutesDialog(QWidget *parent) : QDialog(parent), route_requester_(new OneShotHttpRequest(this)) {
|
||||
setWindowTitle(tr("Remote routes"));
|
||||
|
||||
QFormLayout *layout = new QFormLayout(this);
|
||||
layout->addRow(tr("Device"), device_list_ = new QComboBox(this));
|
||||
layout->addRow(period_selector_ = new QComboBox(this));
|
||||
layout->addRow(route_list_ = new RouteListWidget(this));
|
||||
auto button_box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
||||
layout->addRow(button_box);
|
||||
|
||||
device_list_->addItem(tr("Loading..."));
|
||||
// Populate period selector with predefined durations
|
||||
period_selector_->addItem(tr("Last week"), 7);
|
||||
period_selector_->addItem(tr("Last 2 weeks"), 14);
|
||||
period_selector_->addItem(tr("Last month"), 30);
|
||||
period_selector_->addItem(tr("Last 6 months"), 180);
|
||||
period_selector_->addItem(tr("Preserved"), -1);
|
||||
|
||||
// Connect signals and slots
|
||||
QObject::connect(route_requester_, &HttpRequest::requestDone, this, &RoutesDialog::parseRouteList);
|
||||
connect(device_list_, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes);
|
||||
connect(period_selector_, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &RoutesDialog::fetchRoutes);
|
||||
connect(route_list_, &QListWidget::itemDoubleClicked, this, &QDialog::accept);
|
||||
QObject::connect(button_box, &QDialogButtonBox::accepted, this, &QDialog::accept);
|
||||
QObject::connect(button_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
|
||||
// Send request to fetch devices
|
||||
HttpRequest *http = new HttpRequest(this, false);
|
||||
QObject::connect(http, &HttpRequest::requestDone, this, &RoutesDialog::parseDeviceList);
|
||||
http->sendRequest(CommaApi::BASE_URL + "/v1/me/devices/");
|
||||
}
|
||||
|
||||
void RoutesDialog::parseDeviceList(const QString &json, bool success, QNetworkReply::NetworkError err) {
|
||||
if (success) {
|
||||
device_list_->clear();
|
||||
auto devices = QJsonDocument::fromJson(json.toUtf8()).array();
|
||||
for (const QJsonValue &device : devices) {
|
||||
QString dongle_id = device["dongle_id"].toString();
|
||||
device_list_->addItem(dongle_id, dongle_id);
|
||||
}
|
||||
} else {
|
||||
bool unauthorized = (err == QNetworkReply::ContentAccessDenied || err == QNetworkReply::AuthenticationRequiredError);
|
||||
QMessageBox::warning(this, tr("Error"), unauthorized ? tr("Unauthorized, Authenticate with tools/lib/auth.py") : tr("Network error"));
|
||||
reject();
|
||||
}
|
||||
sender()->deleteLater();
|
||||
}
|
||||
|
||||
void RoutesDialog::fetchRoutes() {
|
||||
if (device_list_->currentIndex() == -1 || device_list_->currentData().isNull())
|
||||
return;
|
||||
|
||||
route_list_->clear();
|
||||
route_list_->setEmptyText(tr("Loading..."));
|
||||
// Construct URL with selected device and date range
|
||||
QString url = QString("%1/v1/devices/%2").arg(CommaApi::BASE_URL, device_list_->currentText());
|
||||
int period = period_selector_->currentData().toInt();
|
||||
if (period == -1) {
|
||||
url += "/routes/preserved";
|
||||
} else {
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
url += QString("/routes_segments?start=%1&end=%2")
|
||||
.arg(now.addDays(-period).toMSecsSinceEpoch())
|
||||
.arg(now.toMSecsSinceEpoch());
|
||||
}
|
||||
route_requester_->send(url);
|
||||
}
|
||||
|
||||
void RoutesDialog::parseRouteList(const QString &json, bool success, QNetworkReply::NetworkError err) {
|
||||
if (success) {
|
||||
for (const QJsonValue &route : QJsonDocument::fromJson(json.toUtf8()).array()) {
|
||||
QDateTime from, to;
|
||||
if (period_selector_->currentData().toInt() == -1) {
|
||||
from = QDateTime::fromString(route["start_time"].toString(), Qt::ISODateWithMs);
|
||||
to = QDateTime::fromString(route["end_time"].toString(), Qt::ISODateWithMs);
|
||||
} else {
|
||||
from = QDateTime::fromMSecsSinceEpoch(route["start_time_utc_millis"].toDouble());
|
||||
to = QDateTime::fromMSecsSinceEpoch(route["end_time_utc_millis"].toDouble());
|
||||
}
|
||||
auto item = new QListWidgetItem(QString("%1 %2min").arg(from.toString()).arg(from.secsTo(to) / 60));
|
||||
item->setData(Qt::UserRole, route["fullname"].toString());
|
||||
route_list_->addItem(item);
|
||||
}
|
||||
if (route_list_->count() > 0) route_list_->setCurrentRow(0);
|
||||
} else {
|
||||
QMessageBox::warning(this, tr("Error"), tr("Failed to fetch routes. Check your network connection."));
|
||||
reject();
|
||||
}
|
||||
route_list_->setEmptyText(tr("No items"));
|
||||
}
|
||||
|
||||
QString RoutesDialog::route() {
|
||||
auto current_item = route_list_->currentItem();
|
||||
return current_item ? current_item->data(Qt::UserRole).toString() : "";
|
||||
}
|
||||
25
tools/cabana/streams/routes.h
Normal file
25
tools/cabana/streams/routes.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QDialog>
|
||||
#include "tools/cabana/utils/api.h"
|
||||
|
||||
class RouteListWidget;
|
||||
class OneShotHttpRequest;
|
||||
|
||||
class RoutesDialog : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
RoutesDialog(QWidget *parent);
|
||||
QString route();
|
||||
|
||||
protected:
|
||||
void parseDeviceList(const QString &json, bool success, QNetworkReply::NetworkError err);
|
||||
void parseRouteList(const QString &json, bool success, QNetworkReply::NetworkError err);
|
||||
void fetchRoutes();
|
||||
|
||||
QComboBox *device_list_;
|
||||
QComboBox *period_selector_;
|
||||
RouteListWidget *route_list_;
|
||||
OneShotHttpRequest *route_requester_;
|
||||
};
|
||||
111
tools/cabana/streams/socketcanstream.cc
Normal file
111
tools/cabana/streams/socketcanstream.cc
Normal file
@@ -0,0 +1,111 @@
|
||||
#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));
|
||||
device->setConfigurationParameter(QCanBusDevice::CanFdKey, true);
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
OpenSocketCanWidget::OpenSocketCanWidget(QWidget *parent) : AbstractOpenStreamWidget(parent) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
AbstractStream *OpenSocketCanWidget::open() {
|
||||
try {
|
||||
return 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 nullptr;
|
||||
}
|
||||
}
|
||||
47
tools/cabana/streams/socketcanstream.h
Normal file
47
tools/cabana/streams/socketcanstream.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#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_ = {});
|
||||
~SocketCanStream() { stop(); }
|
||||
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(QWidget *parent = nullptr);
|
||||
AbstractStream *open() override;
|
||||
|
||||
private:
|
||||
void refreshDevices();
|
||||
|
||||
QComboBox *device_edit;
|
||||
SocketCanStreamConfig config = {};
|
||||
};
|
||||
64
tools/cabana/streamselector.cc
Normal file
64
tools/cabana/streamselector.cc
Normal file
@@ -0,0 +1,64 @@
|
||||
#include "tools/cabana/streamselector.h"
|
||||
|
||||
#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(QWidget *parent) : QDialog(parent) {
|
||||
setWindowTitle(tr("Open stream"));
|
||||
QVBoxLayout *layout = new QVBoxLayout(this);
|
||||
tab = new QTabWidget(this);
|
||||
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);
|
||||
|
||||
btn_box = new QDialogButtonBox(QDialogButtonBox::Open | QDialogButtonBox::Cancel);
|
||||
layout->addWidget(btn_box);
|
||||
|
||||
addStreamWidget(new OpenReplayWidget, tr("&Replay"));
|
||||
addStreamWidget(new OpenPandaWidget, tr("&Panda"));
|
||||
if (SocketCanStream::available()) {
|
||||
addStreamWidget(new OpenSocketCanWidget, tr("&SocketCAN"));
|
||||
}
|
||||
addStreamWidget(new OpenDeviceWidget, tr("&Device"));
|
||||
|
||||
QObject::connect(btn_box, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
QObject::connect(btn_box, &QDialogButtonBox::accepted, [=]() {
|
||||
setEnabled(false);
|
||||
if (stream_ = ((AbstractOpenStreamWidget *)tab->currentWidget())->open(); stream_) {
|
||||
accept();
|
||||
}
|
||||
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, const QString &title) {
|
||||
tab->addTab(w, title);
|
||||
auto open_btn = btn_box->button(QDialogButtonBox::Open);
|
||||
QObject::connect(w, &AbstractOpenStreamWidget::enableOpenButton, open_btn, &QPushButton::setEnabled);
|
||||
}
|
||||
24
tools/cabana/streamselector.h
Normal file
24
tools/cabana/streamselector.h
Normal file
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QTabWidget>
|
||||
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
class StreamSelector : public QDialog {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
StreamSelector(QWidget *parent = nullptr);
|
||||
void addStreamWidget(AbstractOpenStreamWidget *w, const QString &title);
|
||||
QString dbcFile() const { return dbc_file->text(); }
|
||||
AbstractStream *stream() const { return stream_; }
|
||||
|
||||
private:
|
||||
AbstractStream *stream_ = nullptr;
|
||||
QLineEdit *dbc_file;
|
||||
QTabWidget *tab;
|
||||
QDialogButtonBox *btn_box;
|
||||
};
|
||||
157
tools/cabana/tests/test_cabana.cc
Normal file
157
tools/cabana/tests/test_cabana.cc
Normal file
@@ -0,0 +1,157 @@
|
||||
|
||||
#undef INFO
|
||||
#include <QDir>
|
||||
|
||||
#include "catch2/catch.hpp"
|
||||
#include "tools/cabana/dbc/dbcmanager.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.getMessages().size() == dbc_from_generated.getMessages().size());
|
||||
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("DBCFile::generateDBC - comment order") {
|
||||
// Ensure that message comments are followed by signal comments and in the correct order
|
||||
auto content = R"(BO_ 160 message_1: 8 EON
|
||||
SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX
|
||||
|
||||
BO_ 162 message_2: 8 EON
|
||||
SG_ signal_2 : 0|12@1+ (1,0) [0|4095] "unit" XXX
|
||||
|
||||
CM_ BO_ 160 "message comment";
|
||||
CM_ SG_ 160 signal_1 "signal comment";
|
||||
CM_ BO_ 162 "message comment";
|
||||
CM_ SG_ 162 signal_2 "signal comment";
|
||||
)";
|
||||
DBCFile dbc("", content);
|
||||
REQUIRE(dbc.generateDBC() == content);
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile::generateDBC -- preserve original header") {
|
||||
QString content = R"(VERSION "1.0"
|
||||
|
||||
NS_ :
|
||||
CM_
|
||||
|
||||
BS_:
|
||||
|
||||
BU_: EON
|
||||
|
||||
BO_ 160 message_1: 8 EON
|
||||
SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX
|
||||
|
||||
CM_ BO_ 160 "message comment";
|
||||
CM_ SG_ 160 signal_1 "signal comment";
|
||||
)";
|
||||
DBCFile dbc("", content);
|
||||
REQUIRE(dbc.generateDBC() == content);
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile::generateDBC - escaped quotes") {
|
||||
QString content = R"(BO_ 160 message_1: 8 EON
|
||||
SG_ signal_1 : 0|12@1+ (1,0) [0|4095] "unit" XXX
|
||||
|
||||
CM_ BO_ 160 "message comment with \"escaped quotes\"";
|
||||
CM_ SG_ 160 signal_1 "signal comment with \"escaped quotes\"";
|
||||
)";
|
||||
DBCFile dbc("", content);
|
||||
REQUIRE(dbc.generateDBC() == content);
|
||||
}
|
||||
|
||||
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
|
||||
";
|
||||
|
||||
CM_ BO_ 162 "message comment with \"escaped quotes\"";
|
||||
CM_ SG_ 162 signal_1 "signal comment with \"escaped quotes\"";
|
||||
)";
|
||||
|
||||
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");
|
||||
|
||||
// escaped quotes
|
||||
REQUIRE(msg->comment == "message comment with \"escaped quotes\"");
|
||||
REQUIRE(msg->sigs[0]->comment == "signal comment with \"escaped quotes\"");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_opendbc") {
|
||||
QDir dir(OPENDBC_FILE_PATH);
|
||||
QStringList errors;
|
||||
for (auto fn : dir.entryList({"*.dbc"}, QDir::Files, QDir::Name)) {
|
||||
try {
|
||||
auto dbc = DBCFile(dir.filePath(fn));
|
||||
} catch (std::exception &e) {
|
||||
errors.push_back(e.what());
|
||||
}
|
||||
}
|
||||
INFO(errors.join("\n").toStdString());
|
||||
REQUIRE(errors.empty());
|
||||
}
|
||||
10
tools/cabana/tests/test_runner.cc
Normal file
10
tools/cabana/tests/test_runner.cc
Normal 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
Normal file
269
tools/cabana/tools/findsignal.cc
Normal 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(can->toSeconds((*it)->mono_time), 0, 'f', 3).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->toMonoTime(first_sec);
|
||||
model->last_time = std::numeric_limits<uint64_t>::max();
|
||||
if (last_sec > 0) {
|
||||
model->last_time = can->toMonoTime(last_sec);
|
||||
}
|
||||
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
Normal file
64
tools/cabana/tools/findsignal.h
Normal 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
Normal file
160
tools/cabana/tools/findsimilarbits.cc
Normal 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
Normal file
34
tools/cabana/tools/findsimilarbits.h
Normal 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;
|
||||
};
|
||||
40
tools/cabana/tools/routeinfo.cc
Normal file
40
tools/cabana/tools/routeinfo.cc
Normal file
@@ -0,0 +1,40 @@
|
||||
#include "tools/cabana/tools/routeinfo.h"
|
||||
#include <QHeaderView>
|
||||
#include <QScrollBar>
|
||||
#include <QTableWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include "tools/cabana/streams/replaystream.h"
|
||||
|
||||
RouteInfoDlg::RouteInfoDlg(QWidget *parent) : QDialog(parent) {
|
||||
auto *replay = qobject_cast<ReplayStream *>(can)->getReplay();
|
||||
setWindowTitle(tr("Route: %1").arg(QString::fromStdString(replay->route().name())));
|
||||
|
||||
auto *table = new QTableWidget(replay->route().segments().size(), 7, this);
|
||||
table->setToolTip(tr("Click on a row to seek to the corresponding segment."));
|
||||
table->setEditTriggers(QAbstractItemView::NoEditTriggers);
|
||||
table->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
table->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||
table->setHorizontalHeaderLabels({"", "rlog", "fcam", "ecam", "dcam", "qlog", "qcam"});
|
||||
table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
|
||||
table->verticalHeader()->setVisible(false);
|
||||
table->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
|
||||
int row = 0;
|
||||
for (const auto &[seg_num, seg] : replay->route().segments()) {
|
||||
table->setItem(row, 0, new QTableWidgetItem(QString::number(seg_num)));
|
||||
table->setItem(row, 1, new QTableWidgetItem(seg.rlog.empty() ? "--" : "Yes"));
|
||||
table->setItem(row, 2, new QTableWidgetItem(seg.road_cam.empty() ? "--" : "Yes"));
|
||||
table->setItem(row, 3, new QTableWidgetItem(seg.wide_road_cam.empty() ? "--" : "Yes"));
|
||||
table->setItem(row, 4, new QTableWidgetItem(seg.driver_cam.empty() ? "--" : "Yes"));
|
||||
table->setItem(row, 5, new QTableWidgetItem(seg.qlog.empty() ? "--" : "Yes"));
|
||||
table->setItem(row, 6, new QTableWidgetItem(seg.qcamera.empty() ? "--" : "Yes"));
|
||||
++row;
|
||||
}
|
||||
table->setMinimumWidth(table->horizontalHeader()->length() + table->verticalScrollBar()->sizeHint().width());
|
||||
table->setMinimumHeight(table->rowHeight(0) * std::min(table->rowCount(), 13) + table->horizontalHeader()->height() + table->frameWidth() * 2);
|
||||
|
||||
connect(table, &QTableWidget::itemClicked, [](QTableWidgetItem *item) { can->seekTo(item->row() * 60.0); });
|
||||
|
||||
QVBoxLayout *layout = new QVBoxLayout(this);
|
||||
layout->addWidget(table);
|
||||
}
|
||||
8
tools/cabana/tools/routeinfo.h
Normal file
8
tools/cabana/tools/routeinfo.h
Normal file
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include <QDialog>
|
||||
|
||||
class RouteInfoDlg : public QDialog {
|
||||
Q_OBJECT
|
||||
public:
|
||||
RouteInfoDlg(QWidget *parent = nullptr);
|
||||
};
|
||||
171
tools/cabana/utils/api.cc
Normal file
171
tools/cabana/utils/api.cc
Normal file
@@ -0,0 +1,171 @@
|
||||
#include "tools/cabana/utils/api.h"
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QJsonDocument>
|
||||
#include <QNetworkRequest>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "common/params.h"
|
||||
#include "common/util.h"
|
||||
#include "system/hardware/hw.h"
|
||||
#include "tools/cabana/utils/util.h"
|
||||
|
||||
QString getVersion() {
|
||||
static QString version = QString::fromStdString(Params().get("Version"));
|
||||
return version;
|
||||
}
|
||||
|
||||
QString getUserAgent() {
|
||||
return "openpilot-" + getVersion();
|
||||
}
|
||||
|
||||
std::optional<QString> getDongleId() {
|
||||
std::string id = Params().get("DongleId");
|
||||
|
||||
if (!id.empty() && (id != "UnregisteredDevice")) {
|
||||
return QString::fromStdString(id);
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
namespace CommaApi {
|
||||
|
||||
EVP_PKEY *get_private_key() {
|
||||
static std::unique_ptr<EVP_PKEY, decltype(&EVP_PKEY_free)> pkey(nullptr, EVP_PKEY_free);
|
||||
if (!pkey) {
|
||||
FILE *fp = fopen(Path::rsa_file().c_str(), "rb");
|
||||
if (!fp) {
|
||||
qDebug() << "No private key found, please run manager.py or registration.py";
|
||||
return nullptr;
|
||||
}
|
||||
pkey.reset(PEM_read_PrivateKey(fp, nullptr, nullptr, nullptr));
|
||||
fclose(fp);
|
||||
}
|
||||
return pkey.get();
|
||||
}
|
||||
|
||||
QByteArray rsa_sign(const QByteArray &data) {
|
||||
EVP_PKEY *pkey = get_private_key();
|
||||
if (!pkey) return {};
|
||||
|
||||
EVP_MD_CTX *mdctx = EVP_MD_CTX_new();
|
||||
if (!mdctx) return {};
|
||||
|
||||
QByteArray sig(EVP_PKEY_size(pkey), Qt::Uninitialized);
|
||||
size_t sig_len = sig.size();
|
||||
|
||||
int ret = EVP_DigestSignInit(mdctx, nullptr, EVP_sha256(), nullptr, pkey);
|
||||
ret &= EVP_DigestSignUpdate(mdctx, data.data(), data.size());
|
||||
ret &= EVP_DigestSignFinal(mdctx, (unsigned char*)sig.data(), &sig_len);
|
||||
|
||||
EVP_MD_CTX_free(mdctx);
|
||||
|
||||
if (ret != 1) return {};
|
||||
sig.resize(sig_len);
|
||||
return sig;
|
||||
}
|
||||
|
||||
QString create_jwt(const QJsonObject &payloads, int expiry) {
|
||||
QJsonObject header = {{"alg", "RS256"}};
|
||||
|
||||
auto t = QDateTime::currentSecsSinceEpoch();
|
||||
QJsonObject payload = {{"identity", getDongleId().value_or("")}, {"nbf", t}, {"iat", t}, {"exp", t + expiry}};
|
||||
for (auto it = payloads.begin(); it != payloads.end(); ++it) {
|
||||
payload.insert(it.key(), it.value());
|
||||
}
|
||||
|
||||
auto b64_opts = QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals;
|
||||
QString jwt = QJsonDocument(header).toJson(QJsonDocument::Compact).toBase64(b64_opts) + '.' +
|
||||
QJsonDocument(payload).toJson(QJsonDocument::Compact).toBase64(b64_opts);
|
||||
|
||||
auto hash = QCryptographicHash::hash(jwt.toUtf8(), QCryptographicHash::Sha256);
|
||||
return jwt + "." + rsa_sign(hash).toBase64(b64_opts);
|
||||
}
|
||||
|
||||
} // namespace CommaApi
|
||||
|
||||
HttpRequest::HttpRequest(QObject *parent, bool create_jwt, int timeout) : create_jwt(create_jwt), QObject(parent) {
|
||||
networkTimer = new QTimer(this);
|
||||
networkTimer->setSingleShot(true);
|
||||
networkTimer->setInterval(timeout);
|
||||
connect(networkTimer, &QTimer::timeout, this, &HttpRequest::requestTimeout);
|
||||
}
|
||||
|
||||
bool HttpRequest::active() const {
|
||||
return reply != nullptr;
|
||||
}
|
||||
|
||||
bool HttpRequest::timeout() const {
|
||||
return reply && reply->error() == QNetworkReply::OperationCanceledError;
|
||||
}
|
||||
|
||||
void HttpRequest::sendRequest(const QString &requestURL, const HttpRequest::Method method) {
|
||||
if (active()) {
|
||||
qDebug() << "HttpRequest is active";
|
||||
return;
|
||||
}
|
||||
QString token;
|
||||
if (create_jwt) {
|
||||
token = CommaApi::create_jwt();
|
||||
} else {
|
||||
QString token_json = QString::fromStdString(util::read_file(util::getenv("HOME") + "/.comma/auth.json"));
|
||||
QJsonDocument json_d = QJsonDocument::fromJson(token_json.toUtf8());
|
||||
token = json_d["access_token"].toString();
|
||||
}
|
||||
|
||||
QNetworkRequest request;
|
||||
request.setUrl(QUrl(requestURL));
|
||||
request.setRawHeader("User-Agent", getUserAgent().toUtf8());
|
||||
|
||||
if (!token.isEmpty()) {
|
||||
request.setRawHeader(QByteArray("Authorization"), ("JWT " + token).toUtf8());
|
||||
}
|
||||
|
||||
if (method == HttpRequest::Method::GET) {
|
||||
reply = nam()->get(request);
|
||||
} else if (method == HttpRequest::Method::DELETE) {
|
||||
reply = nam()->deleteResource(request);
|
||||
}
|
||||
|
||||
networkTimer->start();
|
||||
connect(reply, &QNetworkReply::finished, this, &HttpRequest::requestFinished);
|
||||
}
|
||||
|
||||
void HttpRequest::requestTimeout() {
|
||||
reply->abort();
|
||||
}
|
||||
|
||||
void HttpRequest::requestFinished() {
|
||||
networkTimer->stop();
|
||||
|
||||
if (reply->error() == QNetworkReply::NoError) {
|
||||
emit requestDone(reply->readAll(), true, reply->error());
|
||||
} else {
|
||||
QString error;
|
||||
if (reply->error() == QNetworkReply::OperationCanceledError) {
|
||||
nam()->clearAccessCache();
|
||||
nam()->clearConnectionCache();
|
||||
error = "Request timed out";
|
||||
} else {
|
||||
error = reply->errorString();
|
||||
}
|
||||
emit requestDone(error, false, reply->error());
|
||||
}
|
||||
|
||||
reply->deleteLater();
|
||||
reply = nullptr;
|
||||
}
|
||||
|
||||
QNetworkAccessManager *HttpRequest::nam() {
|
||||
static QNetworkAccessManager *networkAccessManager = new QNetworkAccessManager(qApp);
|
||||
return networkAccessManager;
|
||||
}
|
||||
47
tools/cabana/utils/api.h
Normal file
47
tools/cabana/utils/api.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkReply>
|
||||
#include <QString>
|
||||
#include <QTimer>
|
||||
|
||||
#include "common/util.h"
|
||||
|
||||
namespace CommaApi {
|
||||
|
||||
const QString BASE_URL = util::getenv("API_HOST", "https://api.commadotai.com").c_str();
|
||||
QByteArray rsa_sign(const QByteArray &data);
|
||||
QString create_jwt(const QJsonObject &payloads = {}, int expiry = 3600);
|
||||
|
||||
} // namespace CommaApi
|
||||
|
||||
/**
|
||||
* Makes a request to the request endpoint.
|
||||
*/
|
||||
|
||||
class HttpRequest : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
enum class Method {GET, DELETE};
|
||||
|
||||
explicit HttpRequest(QObject* parent, bool create_jwt = true, int timeout = 20000);
|
||||
void sendRequest(const QString &requestURL, const Method method = Method::GET);
|
||||
bool active() const;
|
||||
bool timeout() const;
|
||||
|
||||
signals:
|
||||
void requestDone(const QString &response, bool success, QNetworkReply::NetworkError error);
|
||||
|
||||
protected:
|
||||
QNetworkReply *reply = nullptr;
|
||||
|
||||
private:
|
||||
static QNetworkAccessManager *nam();
|
||||
QTimer *networkTimer = nullptr;
|
||||
bool create_jwt;
|
||||
|
||||
private slots:
|
||||
void requestTimeout();
|
||||
void requestFinished();
|
||||
};
|
||||
29
tools/cabana/utils/elidedlabel.cc
Normal file
29
tools/cabana/utils/elidedlabel.cc
Normal file
@@ -0,0 +1,29 @@
|
||||
#include "tools/cabana/utils/elidedlabel.h"
|
||||
#include <QPainter>
|
||||
#include <QStyleOption>
|
||||
|
||||
ElidedLabel::ElidedLabel(QWidget *parent) : ElidedLabel({}, parent) {}
|
||||
|
||||
ElidedLabel::ElidedLabel(const QString &text, QWidget *parent) : QLabel(text.trimmed(), parent) {
|
||||
setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Preferred);
|
||||
setMinimumWidth(1);
|
||||
}
|
||||
|
||||
void ElidedLabel::resizeEvent(QResizeEvent* event) {
|
||||
QLabel::resizeEvent(event);
|
||||
lastText_ = elidedText_ = "";
|
||||
}
|
||||
|
||||
void ElidedLabel::paintEvent(QPaintEvent *event) {
|
||||
const QString curText = text();
|
||||
if (curText != lastText_) {
|
||||
elidedText_ = fontMetrics().elidedText(curText, Qt::ElideRight, contentsRect().width());
|
||||
lastText_ = curText;
|
||||
}
|
||||
|
||||
QPainter painter(this);
|
||||
drawFrame(&painter);
|
||||
QStyleOption opt;
|
||||
opt.initFrom(this);
|
||||
style()->drawItemText(&painter, contentsRect(), alignment(), opt.palette, isEnabled(), elidedText_, foregroundRole());
|
||||
}
|
||||
25
tools/cabana/utils/elidedlabel.h
Normal file
25
tools/cabana/utils/elidedlabel.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include <QLabel>
|
||||
#include <QMouseEvent>
|
||||
|
||||
class ElidedLabel : public QLabel {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ElidedLabel(QWidget *parent = 0);
|
||||
explicit ElidedLabel(const QString &text, QWidget *parent = 0);
|
||||
|
||||
signals:
|
||||
void clicked();
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override;
|
||||
void resizeEvent(QResizeEvent* event) override;
|
||||
void mouseReleaseEvent(QMouseEvent *event) override {
|
||||
if (rect().contains(event->pos())) {
|
||||
emit clicked();
|
||||
}
|
||||
}
|
||||
QString lastText_, elidedText_;
|
||||
};
|
||||
45
tools/cabana/utils/export.cc
Normal file
45
tools/cabana/utils/export.cc
Normal file
@@ -0,0 +1,45 @@
|
||||
#include "tools/cabana/utils/export.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QTextStream>
|
||||
|
||||
#include "tools/cabana/streams/abstractstream.h"
|
||||
|
||||
namespace utils {
|
||||
|
||||
void exportToCSV(const QString &file_name, std::optional<MessageId> msg_id) {
|
||||
QFile file(file_name);
|
||||
if (file.open(QIODevice::ReadWrite | QIODevice::Truncate)) {
|
||||
QTextStream stream(&file);
|
||||
stream << "time,addr,bus,data\n";
|
||||
for (auto e : msg_id ? can->events(*msg_id) : can->allEvents()) {
|
||||
stream << QString::number(can->toSeconds(e->mono_time), 'f', 3) << ","
|
||||
<< "0x" << QString::number(e->address, 16) << "," << e->src << ","
|
||||
<< "0x" << QByteArray::fromRawData((const char *)e->dat, e->size).toHex().toUpper() << "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id) {
|
||||
QFile file(file_name);
|
||||
if (auto msg = dbc()->msg(msg_id); msg && msg->sigs.size() && file.open(QIODevice::ReadWrite | QIODevice::Truncate)) {
|
||||
QTextStream stream(&file);
|
||||
stream << "time,addr,bus";
|
||||
for (auto s : msg->sigs)
|
||||
stream << "," << s->name;
|
||||
stream << "\n";
|
||||
|
||||
for (auto e : can->events(msg_id)) {
|
||||
stream << QString::number(can->toSeconds(e->mono_time), 'f', 3) << ","
|
||||
<< "0x" << QString::number(e->address, 16) << "," << e->src;
|
||||
for (auto s : msg->sigs) {
|
||||
double value = 0;
|
||||
s->getValue(e->dat, e->size, &value);
|
||||
stream << "," << QString::number(value, 'f', s->precision);
|
||||
}
|
||||
stream << "\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace utils
|
||||
10
tools/cabana/utils/export.h
Normal file
10
tools/cabana/utils/export.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <optional>
|
||||
|
||||
#include "tools/cabana/dbc/dbcmanager.h"
|
||||
|
||||
namespace utils {
|
||||
void exportToCSV(const QString &file_name, std::optional<MessageId> msg_id = std::nullopt);
|
||||
void exportSignalsToCSV(const QString &file_name, const MessageId &msg_id);
|
||||
} // namespace utils
|
||||
360
tools/cabana/utils/util.cc
Normal file
360
tools/cabana/utils/util.cc
Normal file
@@ -0,0 +1,360 @@
|
||||
#include "tools/cabana/utils/util.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <csignal>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <QColor>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFontDatabase>
|
||||
#include <QLocale>
|
||||
#include <QPixmapCache>
|
||||
#include <QSurfaceFormat>
|
||||
#include <QFileInfo>
|
||||
#include <QPainterPath>
|
||||
#include <QTextStream>
|
||||
#include <QtXml/QDomDocument>
|
||||
#include "common/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)
|
||||
: font_metrics(QApplication::font()), 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 {
|
||||
if (option.state & QStyle::State_Selected) {
|
||||
painter->fillRect(option.rect, option.palette.brush(QPalette::Normal, QPalette::Highlight));
|
||||
}
|
||||
|
||||
QRect item_rect = option.rect.adjusted(h_margin, v_margin, -h_margin, -v_margin);
|
||||
QColor highlighted_color = option.palette.color(QPalette::HighlightedText);
|
||||
auto text_color = index.data(Qt::ForegroundRole).value<QColor>();
|
||||
bool inactive = text_color.isValid();
|
||||
if (!inactive) {
|
||||
text_color = option.palette.color(QPalette::Text);
|
||||
}
|
||||
auto data = index.data(BytesRole);
|
||||
if (!data.isValid()) {
|
||||
painter->setFont(option.font);
|
||||
painter->setPen(option.state & QStyle::State_Selected ? highlighted_color : text_color);
|
||||
QString text = font_metrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, item_rect.width());
|
||||
painter->drawText(item_rect, Qt::AlignLeft | Qt::AlignVCenter, text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Paint hex column
|
||||
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 *>());
|
||||
|
||||
painter->setFont(fixed_font);
|
||||
const QPen text_pen(option.state & QStyle::State_Selected ? highlighted_color : text_color);
|
||||
const QPoint pt = item_rect.topLeft();
|
||||
for (int i = 0; i < bytes.size(); ++i) {
|
||||
int row = !multiple_lines ? 0 : i / 8;
|
||||
int column = !multiple_lines ? i : i % 8;
|
||||
QRect r({pt.x() + column * byte_size.width(), pt.y() + row * byte_size.height()}, byte_size);
|
||||
|
||||
if (!inactive && 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 {
|
||||
painter->setPen(text_pen);
|
||||
}
|
||||
utils::drawStaticText(painter, r, hex_text_table[bytes[i]]);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
bool isDarkTheme() {
|
||||
QColor windowColor = QApplication::palette().color(QPalette::Window);
|
||||
return windowColor.lightness() < 128;
|
||||
}
|
||||
|
||||
QPixmap icon(const QString &id) {
|
||||
bool dark_theme = isDarkTheme();
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
void setSurfaceFormat() {
|
||||
QSurfaceFormat fmt;
|
||||
#ifdef __APPLE__
|
||||
fmt.setVersion(3, 2);
|
||||
fmt.setProfile(QSurfaceFormat::OpenGLContextProfile::CoreProfile);
|
||||
fmt.setRenderableType(QSurfaceFormat::OpenGL);
|
||||
#else
|
||||
fmt.setRenderableType(QSurfaceFormat::OpenGLES);
|
||||
#endif
|
||||
fmt.setSamples(16);
|
||||
fmt.setStencilBufferSize(1);
|
||||
QSurfaceFormat::setDefaultFormat(fmt);
|
||||
}
|
||||
|
||||
void sigTermHandler(int s) {
|
||||
std::signal(s, SIG_DFL);
|
||||
qApp->quit();
|
||||
}
|
||||
|
||||
void initApp(int argc, char *argv[], bool disable_hidpi) {
|
||||
// setup signal handlers to exit gracefully
|
||||
std::signal(SIGINT, sigTermHandler);
|
||||
std::signal(SIGTERM, sigTermHandler);
|
||||
|
||||
QString app_dir;
|
||||
#ifdef __APPLE__
|
||||
// Get the devicePixelRatio, and scale accordingly to maintain 1:1 rendering
|
||||
QApplication tmp(argc, argv);
|
||||
app_dir = QCoreApplication::applicationDirPath();
|
||||
if (disable_hidpi) {
|
||||
qputenv("QT_SCALE_FACTOR", QString::number(1.0 / tmp.devicePixelRatio()).toLocal8Bit());
|
||||
}
|
||||
#else
|
||||
app_dir = QFileInfo(util::readlink("/proc/self/exe").c_str()).path();
|
||||
#endif
|
||||
|
||||
qputenv("QT_DBL_CLICK_DIST", QByteArray::number(150));
|
||||
// ensure the current dir matches the exectuable's directory
|
||||
QDir::setCurrent(app_dir);
|
||||
|
||||
setSurfaceFormat();
|
||||
}
|
||||
|
||||
static QHash<QString, QByteArray> load_bootstrap_icons() {
|
||||
QHash<QString, QByteArray> icons;
|
||||
|
||||
QFile f(":/bootstrap-icons.svg");
|
||||
if (f.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
QDomDocument xml;
|
||||
xml.setContent(&f);
|
||||
QDomNode n = xml.documentElement().firstChild();
|
||||
while (!n.isNull()) {
|
||||
QDomElement e = n.toElement();
|
||||
if (!e.isNull() && e.hasAttribute("id")) {
|
||||
QString svg_str;
|
||||
QTextStream stream(&svg_str);
|
||||
n.save(stream, 0);
|
||||
svg_str.replace("<symbol", "<svg");
|
||||
svg_str.replace("</symbol>", "</svg>");
|
||||
icons[e.attribute("id")] = svg_str.toUtf8();
|
||||
}
|
||||
n = n.nextSibling();
|
||||
}
|
||||
}
|
||||
return icons;
|
||||
}
|
||||
|
||||
QPixmap bootstrapPixmap(const QString &id) {
|
||||
static QHash<QString, QByteArray> icons = load_bootstrap_icons();
|
||||
|
||||
QPixmap pixmap;
|
||||
if (auto it = icons.find(id); it != icons.end()) {
|
||||
pixmap.loadFromData(it.value(), "svg");
|
||||
}
|
||||
return pixmap;
|
||||
}
|
||||
170
tools/cabana/utils/util.h
Normal file
170
tools/cabana/utils/util.h
Normal file
@@ -0,0 +1,170 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QByteArray>
|
||||
#include <QDoubleValidator>
|
||||
#include <QFont>
|
||||
#include <QFontMetrics>
|
||||
#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;
|
||||
QFontMetrics font_metrics;
|
||||
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);
|
||||
bool isDarkTheme();
|
||||
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;
|
||||
};
|
||||
|
||||
int num_decimals(double num);
|
||||
QString signalToolTip(const cabana::Signal *sig);
|
||||
inline QString toHexString(int value) { return QString("0x%1").arg(QString::number(value, 16).toUpper(), 2, '0'); }
|
||||
void initApp(int argc, char *argv[], bool disable_hidpi = true);
|
||||
QPixmap bootstrapPixmap(const QString &id);
|
||||
426
tools/cabana/videowidget.cc
Normal file
426
tools/cabana/videowidget.cc
Normal file
@@ -0,0 +1,426 @@
|
||||
#include "tools/cabana/videowidget.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QAction>
|
||||
#include <QActionGroup>
|
||||
#include <QMenu>
|
||||
#include <QMouseEvent>
|
||||
#include <QPainter>
|
||||
#include <QStyleOptionSlider>
|
||||
#include <QVBoxLayout>
|
||||
#include <QtConcurrent>
|
||||
|
||||
#include "tools/cabana/tools/routeinfo.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::UserBookmark] = Qt::magenta,
|
||||
[(int)TimelineType::AlertInfo] = Qt::green,
|
||||
[(int)TimelineType::AlertWarning] = QColor(255, 195, 0),
|
||||
[(int)TimelineType::AlertCritical] = QColor(199, 0, 57),
|
||||
};
|
||||
|
||||
static Replay *getReplay() {
|
||||
auto stream = qobject_cast<ReplayStream *>(can);
|
||||
return stream ? stream->getReplay() : nullptr;
|
||||
}
|
||||
|
||||
VideoWidget::VideoWidget(QWidget *parent) : QFrame(parent) {
|
||||
setFrameStyle(QFrame::StyledPanel | QFrame::Plain);
|
||||
auto main_layout = new QVBoxLayout(this);
|
||||
main_layout->setContentsMargins(0, 0, 0, 0);
|
||||
main_layout->setSpacing(0);
|
||||
if (!can->liveStreaming())
|
||||
main_layout->addWidget(createCameraWidget());
|
||||
|
||||
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);
|
||||
QObject::connect(can, &AbstractStream::seeking, this, &VideoWidget::updateState);
|
||||
QObject::connect(can, &AbstractStream::timeRangeChanged, this, &VideoWidget::timeRangeChanged);
|
||||
|
||||
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::UserBookmark].name(),
|
||||
timeline_colors[(int)TimelineType::AlertInfo].name(),
|
||||
timeline_colors[(int)TimelineType::AlertWarning].name(),
|
||||
timeline_colors[(int)TimelineType::AlertCritical].name()));
|
||||
}
|
||||
|
||||
void VideoWidget::createPlaybackController() {
|
||||
QToolBar *toolbar = new QToolBar(this);
|
||||
layout()->addWidget(toolbar);
|
||||
|
||||
int icon_size = style()->pixelMetric(QStyle::PM_SmallIconSize);
|
||||
toolbar->setIconSize({icon_size, icon_size});
|
||||
|
||||
toolbar->addAction(utils::icon("rewind"), tr("Seek backward"), []() { can->seekTo(can->currentSec() - 1); });
|
||||
play_toggle_action = toolbar->addAction(utils::icon("play"), tr("Play"), []() { can->pause(!can->isPaused()); });
|
||||
toolbar->addAction(utils::icon("fast-forward"), tr("Seek forward"), []() { can->seekTo(can->currentSec() + 1); });
|
||||
|
||||
if (can->liveStreaming()) {
|
||||
skip_to_end_action = toolbar->addAction(utils::icon("skip-end"), tr("Skip to the end"), this, [this]() {
|
||||
// set speed to 1.0
|
||||
speed_btn->menu()->actions()[7]->setChecked(true);
|
||||
can->pause(false);
|
||||
can->seekTo(can->maxSeconds() + 1);
|
||||
});
|
||||
}
|
||||
|
||||
time_display_action = toolbar->addAction("", this, [this]() {
|
||||
settings.absolute_time = !settings.absolute_time;
|
||||
time_display_action->setToolTip(settings.absolute_time ? tr("Elapsed time") : tr("Absolute time"));
|
||||
updateState();
|
||||
});
|
||||
|
||||
QWidget *spacer = new QWidget();
|
||||
spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);
|
||||
toolbar->addWidget(spacer);
|
||||
|
||||
if (!can->liveStreaming()) {
|
||||
toolbar->addAction(utils::icon("repeat"), tr("Loop playback"), this, &VideoWidget::loopPlaybackClicked);
|
||||
createSpeedDropdown(toolbar);
|
||||
toolbar->addSeparator();
|
||||
toolbar->addAction(utils::icon("info-circle"), tr("View route details"), this, &VideoWidget::showRouteInfo);
|
||||
} else {
|
||||
createSpeedDropdown(toolbar);
|
||||
}
|
||||
}
|
||||
|
||||
void VideoWidget::createSpeedDropdown(QToolBar *toolbar) {
|
||||
toolbar->addWidget(speed_btn = new QToolButton(this));
|
||||
speed_btn->setMenu(new QMenu(speed_btn));
|
||||
speed_btn->setPopupMode(QToolButton::InstantPopup);
|
||||
QActionGroup *speed_group = new QActionGroup(this);
|
||||
speed_group->setExclusive(true);
|
||||
|
||||
for (float speed : {0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 0.8, 1., 2., 3., 5.}) {
|
||||
auto act = speed_btn->menu()->addAction(QString("%1x").arg(speed), this, [this, speed]() {
|
||||
can->setSpeed(speed);
|
||||
speed_btn->setText(QString("%1x ").arg(speed));
|
||||
});
|
||||
|
||||
speed_group->addAction(act);
|
||||
act->setCheckable(true);
|
||||
if (speed == 1.0) {
|
||||
act->setChecked(true);
|
||||
act->trigger();
|
||||
}
|
||||
}
|
||||
|
||||
QFont font = speed_btn->font();
|
||||
font.setBold(true);
|
||||
speed_btn->setFont(font);
|
||||
speed_btn->setMinimumWidth(speed_btn->fontMetrics().horizontalAdvance("0.05x ") + style()->pixelMetric(QStyle::PM_MenuButtonIndicator));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
l->addWidget(cam_widget = new StreamCameraView("camerad", VISION_STREAM_ROAD));
|
||||
cam_widget->setMinimumHeight(MIN_VIDEO_HEIGHT);
|
||||
cam_widget->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
|
||||
|
||||
l->addWidget(slider = new Slider(w));
|
||||
slider->setSingleStep(0);
|
||||
slider->setTimeRange(can->minSeconds(), can->maxSeconds());
|
||||
|
||||
QObject::connect(slider, &QSlider::sliderReleased, [this]() { can->seekTo(slider->currentSecond()); });
|
||||
QObject::connect(can, &AbstractStream::paused, cam_widget, [c = cam_widget]() { c->showPausedOverlay(); });
|
||||
QObject::connect(can, &AbstractStream::eventsMerged, this, [this]() { slider->update(); });
|
||||
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());
|
||||
});
|
||||
QObject::connect(static_cast<ReplayStream*>(can), &ReplayStream::qLogLoaded, cam_widget, &StreamCameraView::parseQLog, Qt::QueuedConnection);
|
||||
slider->installEventFilter(this);
|
||||
return w;
|
||||
}
|
||||
|
||||
void VideoWidget::vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams) {
|
||||
static const QString stream_names[] = {"Road camera", "Driver camera", "Wide road 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() {
|
||||
bool is_looping = getReplay()->loop();
|
||||
getReplay()->setLoop(!is_looping);
|
||||
qobject_cast<QAction*>(sender())->setIcon(utils::icon(!is_looping ? "repeat" : "repeat-1"));
|
||||
}
|
||||
|
||||
void VideoWidget::timeRangeChanged() {
|
||||
const auto time_range = can->timeRange();
|
||||
if (can->liveStreaming()) {
|
||||
skip_to_end_action->setEnabled(!time_range.has_value());
|
||||
return;
|
||||
}
|
||||
time_range ? slider->setTimeRange(time_range->first, time_range->second)
|
||||
: slider->setTimeRange(can->minSeconds(), can->maxSeconds());
|
||||
updateState();
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
if (camera_tab->count() == 0) { // No streams available
|
||||
cam_widget->update(); // Manually refresh to show alert events
|
||||
}
|
||||
time_display_action->setText(QString("%1 / %2").arg(formatTime(can->currentSec(), true),
|
||||
formatTime(slider->maximum() / slider->factor)));
|
||||
} else {
|
||||
time_display_action->setText(formatTime(can->currentSec(), true));
|
||||
}
|
||||
}
|
||||
|
||||
void VideoWidget::updatePlayBtnState() {
|
||||
play_toggle_action->setIcon(utils::icon(can->isPaused() ? "play" : "pause"));
|
||||
play_toggle_action->setToolTip(can->isPaused() ? tr("Play") : tr("Pause"));
|
||||
}
|
||||
|
||||
void VideoWidget::showThumbnail(double seconds) {
|
||||
if (can->liveStreaming()) return;
|
||||
|
||||
cam_widget->thumbnail_dispaly_time = seconds;
|
||||
slider->thumbnail_dispaly_time = seconds;
|
||||
cam_widget->update();
|
||||
slider->update();
|
||||
}
|
||||
|
||||
void VideoWidget::showRouteInfo() {
|
||||
RouteInfoDlg *route_info = new RouteInfoDlg(this);
|
||||
route_info->setAttribute(Qt::WA_DeleteOnClose);
|
||||
route_info->show();
|
||||
}
|
||||
|
||||
bool VideoWidget::eventFilter(QObject *obj, QEvent *event) {
|
||||
if (event->type() == QEvent::MouseMove) {
|
||||
auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds()));
|
||||
showThumbnail(min_sec + static_cast<QMouseEvent *>(event)->pos().x() * (max_sec - min_sec) / slider->width());
|
||||
} else if (event->type() == QEvent::Leave) {
|
||||
showThumbnail(-1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Slider
|
||||
Slider::Slider(QWidget *parent) : QSlider(Qt::Horizontal, parent) {
|
||||
setMouseTracking(true);
|
||||
}
|
||||
|
||||
void Slider::paintEvent(QPaintEvent *ev) {
|
||||
QPainter p(this);
|
||||
|
||||
QStyleOptionSlider opt;
|
||||
initStyleOption(&opt);
|
||||
QRect handle_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this);
|
||||
QRect groove_rect = style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderGroove, this);
|
||||
|
||||
// Adjust groove height to match handle height
|
||||
int handle_height = handle_rect.height();
|
||||
groove_rect.setHeight(handle_height * 0.5);
|
||||
groove_rect.moveCenter(QPoint(groove_rect.center().x(), rect().center().y()));
|
||||
|
||||
p.fillRect(groove_rect, 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;
|
||||
|
||||
QRect r = groove_rect;
|
||||
r.setLeft(((std::max(min, begin) - min) / (max - min)) * width());
|
||||
r.setRight(((std::min(max, end) - min) / (max - min)) * width());
|
||||
p.fillRect(r, color);
|
||||
};
|
||||
|
||||
if (auto replay = getReplay()) {
|
||||
for (const auto &entry : *replay->getTimeline()) {
|
||||
fillRange(entry.start_time, entry.end_time, timeline_colors[(int)entry.type]);
|
||||
}
|
||||
|
||||
QColor empty_color = palette().color(QPalette::Window);
|
||||
empty_color.setAlpha(160);
|
||||
const auto event_data = replay->getEventData();
|
||||
for (const auto &[n, _] : replay->route().segments()) {
|
||||
if (!event_data->isSegmentLoaded(n))
|
||||
fillRange(n * 60.0, (n + 1) * 60.0, empty_color);
|
||||
}
|
||||
}
|
||||
|
||||
opt.minimum = minimum();
|
||||
opt.maximum = maximum();
|
||||
opt.subControls = QStyle::SC_SliderHandle;
|
||||
opt.sliderPosition = value();
|
||||
style()->drawComplexControl(QStyle::CC_Slider, &opt, &p);
|
||||
|
||||
if (thumbnail_dispaly_time >= 0) {
|
||||
int left = (thumbnail_dispaly_time - min) * width() / (max - min) - 1;
|
||||
QRect rc(left, rect().top() + 1, 2, rect().height() - 2);
|
||||
p.setBrush(palette().highlight());
|
||||
p.setPen(Qt::NoPen);
|
||||
p.drawRoundedRect(rc, 1.5, 1.5);
|
||||
}
|
||||
}
|
||||
|
||||
void Slider::mousePressEvent(QMouseEvent *e) {
|
||||
QSlider::mousePressEvent(e);
|
||||
if (e->button() == Qt::LeftButton && !isSliderDown()) {
|
||||
setValue(minimum() + ((maximum() - minimum()) * e->x()) / width());
|
||||
emit sliderReleased();
|
||||
}
|
||||
}
|
||||
|
||||
// StreamCameraView
|
||||
StreamCameraView::StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent)
|
||||
: CameraWidget(stream_name, stream_type, parent) {
|
||||
fade_animation = new QPropertyAnimation(this, "overlayOpacity");
|
||||
fade_animation->setDuration(500);
|
||||
fade_animation->setStartValue(0.2f);
|
||||
fade_animation->setEndValue(0.7f);
|
||||
fade_animation->setEasingCurve(QEasingCurve::InOutQuad);
|
||||
connect(fade_animation, &QPropertyAnimation::valueChanged, this, QOverload<>::of(&StreamCameraView::update));
|
||||
}
|
||||
|
||||
void StreamCameraView::parseQLog(std::shared_ptr<LogReader> qlog) {
|
||||
std::mutex mutex;
|
||||
QtConcurrent::blockingMap(qlog->events.cbegin(), qlog->events.cend(), [this, &mutex](const Event &e) {
|
||||
if (e.which == cereal::Event::Which::THUMBNAIL) {
|
||||
capnp::FlatArrayMessageReader reader(e.data);
|
||||
auto thumb_data = reader.getRoot<cereal::Event>().getThumbnail();
|
||||
auto image_data = thumb_data.getThumbnail();
|
||||
if (QPixmap thumb; thumb.loadFromData(image_data.begin(), image_data.size(), "jpeg")) {
|
||||
QPixmap generated_thumb = generateThumbnail(thumb, can->toSeconds(thumb_data.getTimestampEof()));
|
||||
std::lock_guard lock(mutex);
|
||||
thumbnails[thumb_data.getTimestampEof()] = generated_thumb;
|
||||
big_thumbnails[thumb_data.getTimestampEof()] = thumb;
|
||||
}
|
||||
}
|
||||
});
|
||||
update();
|
||||
}
|
||||
|
||||
void StreamCameraView::paintGL() {
|
||||
CameraWidget::paintGL();
|
||||
|
||||
QPainter p(this);
|
||||
bool scrubbing = false;
|
||||
if (thumbnail_dispaly_time >= 0) {
|
||||
scrubbing = can->isPaused();
|
||||
scrubbing ? drawScrubThumbnail(p) : drawThumbnail(p);
|
||||
}
|
||||
if (auto alert = getReplay()->findAlertAtTime(scrubbing ? thumbnail_dispaly_time : can->currentSec())) {
|
||||
drawAlert(p, rect(), *alert);
|
||||
}
|
||||
|
||||
if (can->isPaused()) {
|
||||
p.setPen(QColor(200, 200, 200, static_cast<int>(255 * fade_animation->currentValue().toFloat())));
|
||||
p.setFont(QFont(font().family(), 16, QFont::Bold));
|
||||
p.drawText(rect(), Qt::AlignCenter, tr("PAUSED"));
|
||||
}
|
||||
}
|
||||
|
||||
QPixmap StreamCameraView::generateThumbnail(QPixmap thumb, double seconds) {
|
||||
QPixmap scaled = thumb.scaledToHeight(MIN_VIDEO_HEIGHT - THUMBNAIL_MARGIN * 2, Qt::SmoothTransformation);
|
||||
QPainter p(&scaled);
|
||||
p.setPen(QPen(palette().color(QPalette::BrightText), 2));
|
||||
p.drawRect(scaled.rect());
|
||||
if (auto alert = getReplay()->findAlertAtTime(seconds)) {
|
||||
p.setFont(QFont(font().family(), 10));
|
||||
drawAlert(p, scaled.rect(), *alert);
|
||||
}
|
||||
return scaled;
|
||||
}
|
||||
|
||||
void StreamCameraView::drawScrubThumbnail(QPainter &p) {
|
||||
p.fillRect(rect(), Qt::black);
|
||||
auto it = big_thumbnails.lowerBound(can->toMonoTime(thumbnail_dispaly_time));
|
||||
if (it != big_thumbnails.end()) {
|
||||
QPixmap scaled_thumb = it.value().scaled(rect().size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
QRect thumb_rect(rect().center() - scaled_thumb.rect().center(), scaled_thumb.size());
|
||||
p.drawPixmap(thumb_rect.topLeft(), scaled_thumb);
|
||||
drawTime(p, thumb_rect, thumbnail_dispaly_time);
|
||||
}
|
||||
}
|
||||
|
||||
void StreamCameraView::drawThumbnail(QPainter &p) {
|
||||
auto it = thumbnails.lowerBound(can->toMonoTime(thumbnail_dispaly_time));
|
||||
if (it != thumbnails.end()) {
|
||||
const QPixmap &thumb = it.value();
|
||||
auto [min_sec, max_sec] = can->timeRange().value_or(std::make_pair(can->minSeconds(), can->maxSeconds()));
|
||||
int pos = (thumbnail_dispaly_time - min_sec) * width() / (max_sec - min_sec);
|
||||
int x = std::clamp(pos - thumb.width() / 2, THUMBNAIL_MARGIN, width() - thumb.width() - THUMBNAIL_MARGIN + 1);
|
||||
int y = height() - thumb.height() - THUMBNAIL_MARGIN;
|
||||
|
||||
p.drawPixmap(x, y, thumb);
|
||||
drawTime(p, QRect{x, y, thumb.width(), thumb.height()}, thumbnail_dispaly_time);
|
||||
}
|
||||
}
|
||||
|
||||
void StreamCameraView::drawTime(QPainter &p, const QRect &rect, double seconds) {
|
||||
p.setPen(palette().color(QPalette::BrightText));
|
||||
p.setFont(QFont(font().family(), 10));
|
||||
p.drawText(rect.adjusted(0, 0, 0, -THUMBNAIL_MARGIN), Qt::AlignHCenter | Qt::AlignBottom, QString::number(seconds, 'f', 3));
|
||||
}
|
||||
|
||||
void StreamCameraView::drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert) {
|
||||
p.setPen(QPen(palette().color(QPalette::BrightText), 2));
|
||||
QColor color = timeline_colors[int(alert.type)];
|
||||
color.setAlphaF(0.5);
|
||||
QString text = QString::fromStdString(alert.text1);
|
||||
if (!alert.text2.empty()) text += "\n" + QString::fromStdString(alert.text2);
|
||||
|
||||
QRect text_rect = rect.adjusted(1, 1, -1, -1);
|
||||
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);
|
||||
}
|
||||
83
tools/cabana/videowidget.h
Normal file
83
tools/cabana/videowidget.h
Normal file
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include <QFrame>
|
||||
#include <QPropertyAnimation>
|
||||
#include <QSlider>
|
||||
#include <QToolBar>
|
||||
#include <QTabBar>
|
||||
|
||||
#include "tools/cabana/cameraview.h"
|
||||
#include "tools/cabana/utils/util.h"
|
||||
#include "tools/replay/logreader.h"
|
||||
#include "tools/cabana/streams/replaystream.h"
|
||||
|
||||
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) { setRange(min * factor, max * factor); }
|
||||
void mousePressEvent(QMouseEvent *e) override;
|
||||
void paintEvent(QPaintEvent *ev) override;
|
||||
const double factor = 1000.0;
|
||||
double thumbnail_dispaly_time = -1;
|
||||
};
|
||||
|
||||
class StreamCameraView : public CameraWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
StreamCameraView(std::string stream_name, VisionStreamType stream_type, QWidget *parent = nullptr);
|
||||
void paintGL() override;
|
||||
void showPausedOverlay() { fade_animation->start(); }
|
||||
void parseQLog(std::shared_ptr<LogReader> qlog);
|
||||
|
||||
private:
|
||||
QPixmap generateThumbnail(QPixmap thumbnail, double seconds);
|
||||
void drawAlert(QPainter &p, const QRect &rect, const Timeline::Entry &alert);
|
||||
void drawThumbnail(QPainter &p);
|
||||
void drawScrubThumbnail(QPainter &p);
|
||||
void drawTime(QPainter &p, const QRect &rect, double seconds);
|
||||
|
||||
QPropertyAnimation *fade_animation;
|
||||
QMap<uint64_t, QPixmap> big_thumbnails;
|
||||
QMap<uint64_t, QPixmap> thumbnails;
|
||||
double thumbnail_dispaly_time = -1;
|
||||
friend class VideoWidget;
|
||||
};
|
||||
|
||||
class VideoWidget : public QFrame {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
VideoWidget(QWidget *parnet = nullptr);
|
||||
void showThumbnail(double seconds);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject *obj, QEvent *event) override;
|
||||
QString formatTime(double sec, bool include_milliseconds = false);
|
||||
void timeRangeChanged();
|
||||
void updateState();
|
||||
void updatePlayBtnState();
|
||||
QWidget *createCameraWidget();
|
||||
void createPlaybackController();
|
||||
void createSpeedDropdown(QToolBar *toolbar);
|
||||
void loopPlaybackClicked();
|
||||
void vipcAvailableStreamsUpdated(std::set<VisionStreamType> streams);
|
||||
void showRouteInfo();
|
||||
|
||||
StreamCameraView *cam_widget;
|
||||
QAction *time_display_action = nullptr;
|
||||
QAction *play_toggle_action = nullptr;
|
||||
QToolButton *speed_btn = nullptr;
|
||||
QAction *skip_to_end_action = nullptr;
|
||||
Slider *slider = nullptr;
|
||||
QTabBar *camera_tab = nullptr;
|
||||
};
|
||||
66
tools/camerastream/README.md
Normal file
66
tools/camerastream/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Camera stream
|
||||
|
||||
`compressed_vipc.py` connects to a remote device running openpilot, decodes the video streams, and republishes them over VisionIPC.
|
||||
|
||||
## Usage
|
||||
|
||||
### On the device
|
||||
SSH into the device and run following in separate terminals:
|
||||
|
||||
`cd /data/openpilot/cereal/messaging && ./bridge`
|
||||
|
||||
`cd /data/openpilot/system/loggerd && ./encoderd`
|
||||
|
||||
`cd /data/openpilot/system/camerad && ./camerad`
|
||||
|
||||
Note that both the device and your PC must be on the same openpilot commit.
|
||||
|
||||
Alternatively paste this as a single command:
|
||||
```
|
||||
(
|
||||
cd /data/openpilot/cereal/messaging/
|
||||
./bridge &
|
||||
|
||||
cd /data/openpilot/system/camerad/
|
||||
./camerad &
|
||||
|
||||
cd /data/openpilot/system/loggerd/
|
||||
./encoderd &
|
||||
|
||||
wait
|
||||
) ; trap 'kill $(jobs -p)' SIGINT
|
||||
```
|
||||
Ctrl+C will stop all three processes.
|
||||
|
||||
### On the PC
|
||||
Decode the stream with `compressed_vipc.py`:
|
||||
|
||||
```cd ~/openpilot/tools/camerastream && ./compressed_vipc.py <ip>```
|
||||
|
||||
To actually display the stream, run `watch3` in separate terminal:
|
||||
|
||||
```cd ~/openpilot/selfdrive/ui/ && ./watch3.py```
|
||||
|
||||
## compressed_vipc.py usage
|
||||
```
|
||||
$ python3 compressed_vipc.py -h
|
||||
usage: compressed_vipc.py [-h] [--nvidia] [--cams CAMS] [--silent] addr
|
||||
|
||||
Decode video streams and broadcast on VisionIPC
|
||||
|
||||
positional arguments:
|
||||
addr Address of comma three
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
--nvidia Use nvidia instead of ffmpeg
|
||||
--cams CAMS Cameras to decode
|
||||
--silent Suppress debug output
|
||||
```
|
||||
|
||||
|
||||
## Example:
|
||||
```
|
||||
cd ~/openpilot/tools/camerastream && ./compressed_vipc.py comma-ffffffff --cams 0
|
||||
cd ~/openpilot/selfdrive/ui/ && ./watch3.py
|
||||
```
|
||||
163
tools/camerastream/compressed_vipc.py
Executable file
163
tools/camerastream/compressed_vipc.py
Executable file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
import av
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import numpy as np
|
||||
import multiprocessing
|
||||
import time
|
||||
import signal
|
||||
|
||||
|
||||
import cereal.messaging as messaging
|
||||
from msgq.visionipc import VisionIpcServer, VisionStreamType
|
||||
|
||||
V4L2_BUF_FLAG_KEYFRAME = 8
|
||||
|
||||
# start encoderd
|
||||
# also start cereal messaging bridge
|
||||
# then run this "./compressed_vipc.py <ip>"
|
||||
|
||||
ENCODE_SOCKETS = {
|
||||
VisionStreamType.VISION_STREAM_ROAD: "roadEncodeData",
|
||||
VisionStreamType.VISION_STREAM_DRIVER: "driverEncodeData",
|
||||
VisionStreamType.VISION_STREAM_WIDE_ROAD: "wideRoadEncodeData",
|
||||
}
|
||||
|
||||
def decoder(addr, vipc_server, vst, nvidia, W, H, debug=False):
|
||||
sock_name = ENCODE_SOCKETS[vst]
|
||||
if debug:
|
||||
print(f"start decoder for {sock_name}, {W}x{H}")
|
||||
|
||||
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.reset_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 # noqa: TID251
|
||||
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(f"{len(msgs):2d} {evta.idx.encodeId:4d} {evt.logMonoTime/1e9:.3f} {evta.idx.timestampEof/1e6:.3f} \
|
||||
roll {frame_latency:6.2f} ms latency {process_latency:6.2f} ms + {network_latency:6.2f} ms + {pc_latency:6.2f} ms \
|
||||
= {process_latency+network_latency+pc_latency:6.2f} ms", len(evta.data), sock_name)
|
||||
|
||||
|
||||
class CompressedVipc:
|
||||
def __init__(self, addr, vision_streams, server_name, nvidia=False, debug=False):
|
||||
print("getting frame sizes")
|
||||
os.environ["ZMQ"] = "1"
|
||||
messaging.reset_context()
|
||||
sm = messaging.SubMaster([ENCODE_SOCKETS[s] for s in vision_streams], addr=addr)
|
||||
while min(sm.recv_frame.values()) == 0:
|
||||
sm.update(100)
|
||||
os.environ.pop("ZMQ")
|
||||
messaging.reset_context()
|
||||
|
||||
self.vipc_server = VisionIpcServer(server_name)
|
||||
for vst in vision_streams:
|
||||
ed = sm[ENCODE_SOCKETS[vst]]
|
||||
self.vipc_server.create_buffers(vst, 4, ed.width, ed.height)
|
||||
self.vipc_server.start_listener()
|
||||
|
||||
self.procs = []
|
||||
for vst in vision_streams:
|
||||
ed = sm[ENCODE_SOCKETS[vst]]
|
||||
p = multiprocessing.Process(target=decoder, args=(addr, self.vipc_server, vst, nvidia, ed.width, ed.height, 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("--server", default="camerad", help="choose vipc server name")
|
||||
parser.add_argument("--silent", action="store_true", help="Suppress debug output")
|
||||
args = parser.parse_args()
|
||||
|
||||
vision_streams = [
|
||||
VisionStreamType.VISION_STREAM_ROAD,
|
||||
VisionStreamType.VISION_STREAM_DRIVER,
|
||||
VisionStreamType.VISION_STREAM_WIDE_ROAD,
|
||||
]
|
||||
|
||||
vsts = [vision_streams[int(x)] for x in args.cams.split(",")]
|
||||
cvipc = CompressedVipc(args.addr, vsts, args.server, args.nvidia, debug=(not args.silent))
|
||||
|
||||
# register exit handler
|
||||
signal.signal(signal.SIGINT, lambda sig, frame: cvipc.kill())
|
||||
|
||||
cvipc.join()
|
||||
129
tools/car_porting/README.md
Normal file
129
tools/car_porting/README.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# tools/car_porting
|
||||
|
||||
Check out [this blog post](https://blog.comma.ai/how-to-write-a-car-port-for-openpilot/) for a high-level overview of porting a car.
|
||||
|
||||
## Useful car porting utilities
|
||||
|
||||
Testing car ports in your car is very time-consuming. Check out these utilities to do basic checks on your work before running it in your car.
|
||||
|
||||
### [Cabana](/tools/cabana/README.md)
|
||||
|
||||
View your car's CAN signals through DBC files, which openpilot uses to parse and create messages that talk to the car.
|
||||
|
||||
Example:
|
||||
```bash
|
||||
> tools/cabana/cabana '1bbe6bf2d62f58a8|2022-07-14--17-11-43'
|
||||
```
|
||||
|
||||
### [tools/car_porting/auto_fingerprint.py](/tools/car_porting/auto_fingerprint.py)
|
||||
|
||||
Given a route and platform, automatically inserts FW fingerprints from the platform into the correct place in fingerprints.py
|
||||
|
||||
Example:
|
||||
```bash
|
||||
> python3 tools/car_porting/auto_fingerprint.py '1bbe6bf2d62f58a8|2022-07-14--17-11-43' 'OUTBACK'
|
||||
Attempting to add fw version for: OUTBACK
|
||||
```
|
||||
|
||||
### [selfdrive/car/tests/test_car_interfaces.py](/selfdrive/car/tests/test_car_interfaces.py)
|
||||
|
||||
Finds common bugs for car interfaces, without even requiring a route.
|
||||
|
||||
|
||||
#### Example: Typo in signal name
|
||||
```bash
|
||||
> pytest selfdrive/car/tests/test_car_interfaces.py -k subaru # replace with the brand you are working on
|
||||
|
||||
=====================================================================
|
||||
FAILED selfdrive/car/tests/test_car_interfaces.py::TestCarInterfaces::test_car_interfaces_165_SUBARU_LEGACY_7TH_GEN - KeyError: 'CruiseControlOOPS'
|
||||
|
||||
```
|
||||
|
||||
### [tools/car_porting/test_car_model.py](/tools/car_porting/test_car_model.py)
|
||||
|
||||
Given a route, runs most of the car interface to check for common errors like missing signals, blocked panda messages, and safety mismatches.
|
||||
|
||||
#### Example: panda safety mismatch for gasPressed
|
||||
```bash
|
||||
> python3 tools/car_porting/test_car_model.py '4822a427b188122a|2023-08-14--16-22-21'
|
||||
|
||||
=====================================================================
|
||||
FAIL: test_panda_safety_carstate (__main__.CarModelTestCase.test_panda_safety_carstate)
|
||||
Assert that panda safety matches openpilot's carState
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/home/batman/xx/openpilot/openpilot/selfdrive/car/tests/test_models.py", line 380, in test_panda_safety_carstate
|
||||
self.assertFalse(len(failed_checks), f"panda safety doesn't agree with openpilot: {failed_checks}")
|
||||
AssertionError: 1 is not false : panda safety doesn't agree with openpilot: {'gasPressed': 116}
|
||||
```
|
||||
|
||||
## Jupyter notebooks
|
||||
|
||||
To use these notebooks, install Jupyter within your [openpilot virtual environment](/tools/README.md).
|
||||
|
||||
```bash
|
||||
uv pip install jupyter ipykernel
|
||||
```
|
||||
|
||||
Launching:
|
||||
|
||||
```bash
|
||||
jupyter notebook
|
||||
```
|
||||
|
||||
### [examples/subaru_steer_temp_fault.ipynb](/tools/car_porting/examples/subaru_steer_temp_fault.ipynb)
|
||||
|
||||
An example of searching through a database of segments for a specific condition, and plotting the results.
|
||||
|
||||

|
||||
|
||||
*a plot of the steer_warning vs steering angle, where we can see it is clearly caused by a large steering angle change*
|
||||
|
||||
### [examples/subaru_long_accel.ipynb](/tools/car_porting/examples/subaru_long_accel.ipynb)
|
||||
|
||||
An example of plotting the response of an actuator when it is active.
|
||||
|
||||

|
||||
|
||||
*a plot of the brake_pressure vs acceleration, where we can see it is a fairly linear response.*
|
||||
|
||||
### [examples/ford_vin_fingerprint.ipynb](/tools/car_porting/examples/ford_vin_fingerprint.ipynb)
|
||||
|
||||
In this example, we use the public comma car segments database to check if vin fingerprinting is feasible for ford.
|
||||
|
||||
```
|
||||
vin: 1FM5K8GC7LGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False
|
||||
vin: 00000000000XXXXXX real platform: FORD ESCAPE 4TH GEN determined platform: mock correct: False
|
||||
vin: 3FTTW8F98NRXXXXXX real platform: FORD MAVERICK 1ST GEN determined platform: mock correct: False
|
||||
vin: 1FTVW1EL4NWXXXXXX real platform: FORD F-150 LIGHTNING 1ST GEN determined platform: FORD F-150 LIGHTNING 1ST GEN correct: True
|
||||
vin: 1FM5K7LC0MGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False
|
||||
vin: WF0NXXGCHNJXXXXXX real platform: FORD FOCUS 4TH GEN determined platform: mock correct: False
|
||||
vin: 1FMCU9J94MUXXXXXX real platform: FORD ESCAPE 4TH GEN determined platform: mock correct: False
|
||||
vin: 5LM5J7XC9LGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False
|
||||
vin: 3FMCR9B69NRXXXXXX real platform: FORD BRONCO SPORT 1ST GEN determined platform: mock correct: False
|
||||
vin: 3FMTK3SU0MMXXXXXX real platform: FORD MUSTANG MACH-E 1ST GEN determined platform: FORD MUSTANG MACH-E 1ST GEN correct: True
|
||||
vin: 1FM5K8HC7MGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False
|
||||
vin: 1FM5K8GC7NGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False
|
||||
vin: 5LM5J7XC8MGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False
|
||||
vin: 3FTTW8E31PRXXXXXX real platform: FORD MAVERICK 1ST GEN determined platform: mock correct: False
|
||||
vin: 3FTTW8E99NRXXXXXX real platform: FORD MAVERICK 1ST GEN determined platform: mock correct: False
|
||||
```
|
||||
|
||||
### [examples/find_segments_with_message.ipynb](/tools/car_porting/examples/find_segments_with_message.ipynb)
|
||||
|
||||
Searches for segments where a set of given CAN message IDs are present. In the example, we search for all messages
|
||||
used for CAN-based ignition detection.
|
||||
|
||||
```
|
||||
Match found: 46b21f1c5f7aa885/2024-01-23--15-19-34/20/s JEEP GRAND CHEROKEE V6 2018 ['VW CAN Ign']
|
||||
Match found: a63a23c3e628f288/2023-11-05--18-36-20/8/s JEEP GRAND CHEROKEE V6 2018 ['VW CAN Ign']
|
||||
Match found: ce31b7a998781ba8/2024-01-19--07-05-29/23/s JEEP GRAND CHEROKEE 2019 ['VW CAN Ign']
|
||||
Match found: e1dfba62a4e33f7b/2023-12-25--19-31-00/4/s JEEP GRAND CHEROKEE 2019 ['VW CAN Ign']
|
||||
Match found: e1dfba62a4e33f7b/2024-01-10--14-33-57/2/s JEEP GRAND CHEROKEE 2019 ['VW CAN Ign']
|
||||
Match found: ae679616266f4096/2023-12-05--15-43-46/4/s RAM HD 5TH GEN ['Tesla 3/Y CAN Ign']
|
||||
Match found: ae679616266f4096/2023-11-18--17-49-42/3/s RAM HD 5TH GEN ['Tesla 3/Y CAN Ign']
|
||||
Match found: ae679616266f4096/2024-01-03--21-57-09/25/s RAM HD 5TH GEN ['Tesla 3/Y CAN Ign']
|
||||
Match found: 6dae2984cc53cd7f/2023-12-10--11-53-15/17/s FORD BRONCO SPORT 1ST GEN ['Rivian CAN Ign']
|
||||
Match found: 6dae2984cc53cd7f/2023-12-03--17-31-17/29/s FORD BRONCO SPORT 1ST GEN ['Rivian CAN Ign']
|
||||
Match found: 6dae2984cc53cd7f/2023-11-27--23-29-07/1/s FORD BRONCO SPORT 1ST GEN ['Rivian CAN Ign']
|
||||
```
|
||||
45
tools/car_porting/auto_fingerprint.py
Executable file
45
tools/car_porting/auto_fingerprint.py
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from opendbc.car.debug.format_fingerprints import format_brand_fw_versions
|
||||
|
||||
from opendbc.car.fingerprints import MIGRATION
|
||||
from opendbc.car.fw_versions import MODEL_TO_BRAND, match_fw_to_car
|
||||
from openpilot.tools.lib.logreader import LogReader, ReadMode
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Auto fingerprint from a route")
|
||||
parser.add_argument("route", help="The route name to use")
|
||||
parser.add_argument("platform", help="The platform, or leave empty to auto-determine using fuzzy", default=None, nargs="?")
|
||||
args = parser.parse_args()
|
||||
|
||||
lr = LogReader(args.route, ReadMode.QLOG)
|
||||
CP = lr.first("carParams")
|
||||
assert CP is not None, "No carParams in route"
|
||||
|
||||
carPlatform = MIGRATION.get(CP.carFingerprint, CP.carFingerprint)
|
||||
|
||||
if args.platform is not None:
|
||||
platform = args.platform
|
||||
elif carPlatform != "MOCK":
|
||||
platform = carPlatform
|
||||
else:
|
||||
_, matches = match_fw_to_car(CP.carFw, CP.carVin, log=False)
|
||||
assert len(matches) == 1, f"Unable to auto-determine platform, matches: {matches}"
|
||||
platform = list(matches)[0]
|
||||
|
||||
print("Attempting to add fw version for:", platform)
|
||||
|
||||
fw_versions: dict[str, dict[tuple, list[bytes]]] = defaultdict(lambda: defaultdict(list))
|
||||
brand = MODEL_TO_BRAND[platform]
|
||||
|
||||
for fw in CP.carFw:
|
||||
if fw.brand == brand and not fw.logging:
|
||||
addr = fw.address
|
||||
subAddr = None if fw.subAddress == 0 else fw.subAddress
|
||||
key = (fw.ecu.raw, addr, subAddr)
|
||||
|
||||
fw_versions[platform][key].append(fw.fwVersion)
|
||||
|
||||
format_brand_fw_versions(brand, fw_versions)
|
||||
232
tools/car_porting/examples/find_segments_with_message.ipynb
Normal file
232
tools/car_porting/examples/find_segments_with_message.ipynb
Normal file
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 85,
|
||||
"id": "facb8edc-9924-491a-a4dd-fe6135b0c6c4",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Import all cars from opendbc\n",
|
||||
"\n",
|
||||
"from opendbc.car import structs\n",
|
||||
"from opendbc.car.values import PLATFORMS as TEST_PLATFORMS\n",
|
||||
"\n",
|
||||
"# Example: add additional platforms/segments to test outside of commaCarSegments\n",
|
||||
"\n",
|
||||
"EXTRA_SEGMENTS = {\n",
|
||||
" # \"81dd9e9fe256c397/0000001f--97c42cf98d\", # Volkswagen ID.4 test route, new car port, not in public dataset\n",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 86,
|
||||
"id": "ed1c8aec-c274-4c61-b83d-711ea194bf86",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Searching 221 platforms\n",
|
||||
"No segments available for DODGE_DURANGO\n",
|
||||
"No segments available for FORD_RANGER_MK2\n",
|
||||
"No segments available for HOLDEN_ASTRA\n",
|
||||
"No segments available for CADILLAC_ATS\n",
|
||||
"No segments available for CHEVROLET_MALIBU\n",
|
||||
"No segments available for CADILLAC_XT4\n",
|
||||
"No segments available for CHEVROLET_VOLT_2019\n",
|
||||
"No segments available for CHEVROLET_TRAVERSE\n",
|
||||
"No segments available for GMC_YUKON\n",
|
||||
"No segments available for HONDA_ODYSSEY_CHN\n",
|
||||
"No segments available for HYUNDAI_KONA_2022\n",
|
||||
"No segments available for HYUNDAI_NEXO_1ST_GEN\n",
|
||||
"No segments available for GENESIS_GV70_ELECTRIFIED_1ST_GEN\n",
|
||||
"No segments available for GENESIS_G80_2ND_GEN_FL\n",
|
||||
"No segments available for RIVIAN_R1_GEN1\n",
|
||||
"No segments available for SUBARU_FORESTER_HYBRID\n",
|
||||
"No segments available for TESLA_MODEL_3\n",
|
||||
"No segments available for TESLA_MODEL_Y\n",
|
||||
"No segments available for TOYOTA_RAV4_PRIME\n",
|
||||
"No segments available for TOYOTA_SIENNA_4TH_GEN\n",
|
||||
"No segments available for LEXUS_LC_TSS2\n",
|
||||
"No segments available for VOLKSWAGEN_CADDY_MK3\n",
|
||||
"No segments available for VOLKSWAGEN_CRAFTER_MK2\n",
|
||||
"No segments available for VOLKSWAGEN_JETTA_MK6\n",
|
||||
"Searching 577 segments\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import random\n",
|
||||
"\n",
|
||||
"from openpilot.tools.lib.logreader import LogReader\n",
|
||||
"from openpilot.tools.lib.comma_car_segments import get_comma_car_segments_database\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"MAX_SEGS_PER_PLATFORM = 3 # Increase this to search more segments\n",
|
||||
"\n",
|
||||
"database = get_comma_car_segments_database()\n",
|
||||
"TEST_SEGMENTS = []\n",
|
||||
"\n",
|
||||
"print(f\"Searching {len(TEST_PLATFORMS)} platforms\")\n",
|
||||
"\n",
|
||||
"for platform in TEST_PLATFORMS:\n",
|
||||
" if platform not in database:\n",
|
||||
" print(f\"No segments available for {platform}\")\n",
|
||||
" continue\n",
|
||||
"\n",
|
||||
" all_segments = database[platform]\n",
|
||||
" NUM_SEGMENTS = min(len(all_segments), MAX_SEGS_PER_PLATFORM)\n",
|
||||
" TEST_SEGMENTS.extend(random.sample(all_segments, NUM_SEGMENTS))\n",
|
||||
"\n",
|
||||
"TEST_SEGMENTS.extend(EXTRA_SEGMENTS)\n",
|
||||
"\n",
|
||||
"print(f\"Searching {len(TEST_SEGMENTS)} segments\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "0c75e8f2-4f5f-4f89-b8db-5223a6534a9f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"application/vnd.jupyter.widget-view+json": {
|
||||
"model_id": "27a243c33de44498b2b946190df44b23",
|
||||
"version_major": 2,
|
||||
"version_minor": 0
|
||||
},
|
||||
"text/plain": [
|
||||
"segments searched: 0%| | 0/577 [00:00<?, ?it/s]"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Match found: 0f53b336851e1384/2023-11-20--09-44-03/12/s CHRYSLER PACIFICA HYBRID 2018 ['VW CAN Ign']\n",
|
||||
"Match found: 7620ad20d3cefc64/2023-10-28--08-14-40/3/s CHRYSLER PACIFICA HYBRID 2018 ['VW CAN Ign']\n",
|
||||
"Match found: 00d247a9bb1f9196/2023-11-06--13-33-17/9/s CHRYSLER PACIFICA HYBRID 2018 ['VW CAN Ign']\n",
|
||||
"Match found: 120a432f63cb0de2/2023-10-30--20-01-34/1/s CHRYSLER PACIFICA HYBRID 2019 ['VW CAN Ign']\n",
|
||||
"Match found: b70b56b76a6217f2/2023-12-19--08-30-22/35/s CHRYSLER PACIFICA HYBRID 2019 ['VW CAN Ign']\n",
|
||||
"Match found: 97e388680a6716ed/2024-01-17--10-15-13/9/s CHRYSLER PACIFICA HYBRID 2019 ['VW CAN Ign']\n",
|
||||
"Match found: 2137b01aa0ca63f9/2024-01-06--22-06-14/70/s CHRYSLER PACIFICA 2018 ['VW CAN Ign']\n",
|
||||
"Match found: 8fc6a1b72c8b1357/2023-11-06--07-50-05/8/s CHRYSLER PACIFICA 2018 ['VW CAN Ign']\n",
|
||||
"Match found: 7e705eb5c27a49cc/2024-01-18--16-51-20/3/s CHRYSLER PACIFICA 2018 ['VW CAN Ign']\n",
|
||||
"Match found: 12208e5acdc97eb3/2024-01-20--14-46-24/12/s CHRYSLER PACIFICA 2020 ['VW CAN Ign']\n",
|
||||
"Match found: 12208e5acdc97eb3/2023-11-30--12-01-09/2/s CHRYSLER PACIFICA 2020 ['VW CAN Ign']\n",
|
||||
"Match found: 9cad19e0efce3650/2024-01-26--10-24-52/27/s CHRYSLER PACIFICA 2020 ['VW CAN Ign']\n",
|
||||
"Match found: 9db428338427dec2/2023-11-05--18-40-09/21/s JEEP GRAND CHEROKEE V6 2018 ['VW CAN Ign']\n",
|
||||
"Match found: d50ada8ee55a5e74/2023-12-11--13-38-09/0/s JEEP GRAND CHEROKEE V6 2018 ['VW CAN Ign']\n",
|
||||
"Match found: 900dfa83b4addfe6/2023-12-30--19-20-08/28/s JEEP GRAND CHEROKEE V6 2018 ['VW CAN Ign']\n",
|
||||
"Match found: 20acda0eb23d7f23/2024-01-19--17-33-26/41/s JEEP GRAND CHEROKEE 2019 ['VW CAN Ign']\n",
|
||||
"Match found: 1cc3b46843cad2ca/2024-01-10--20-20-54/24/s JEEP GRAND CHEROKEE 2019 ['VW CAN Ign']\n",
|
||||
"Match found: 2d9b6425552c52c1/2023-12-07--10-31-46/22/s JEEP GRAND CHEROKEE 2019 ['VW CAN Ign']\n",
|
||||
"Match found: ae679616266f4096/2023-12-04--13-13-56/16/s RAM HD 5TH GEN ['Tesla 3/Y CAN Ign']\n",
|
||||
"Match found: ae679616266f4096/2024-01-08--07-58-12/65/s RAM HD 5TH GEN ['Tesla 3/Y CAN Ign']\n",
|
||||
"Match found: ae679616266f4096/2023-12-05--15-43-46/25/s RAM HD 5TH GEN ['Tesla 3/Y CAN Ign']\n",
|
||||
"Match found: 6dae2984cc53cd7f/2024-01-09--21-41-11/4/s FORD BRONCO SPORT 1ST GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: 440a155809ba2b6d/2023-12-30--08-51-53/2/s FORD BRONCO SPORT 1ST GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: 6dae2984cc53cd7f/2024-01-06--10-11-07/1/s FORD BRONCO SPORT 1ST GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: a4218e6416dfd978/2023-11-27--13-48-46/19/s FORD ESCAPE 4TH GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: a4218e6416dfd978/2023-11-10--14-13-14/0/s FORD ESCAPE 4TH GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: a4218e6416dfd978/2023-11-27--13-48-46/4/s FORD ESCAPE 4TH GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: 8a732841c3a8d5ef/2023-12-10--19-02-33/3/s FORD EXPLORER 6TH GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: 0b91b433b9332780/2023-12-28--14-02-49/4/s FORD EXPLORER 6TH GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: 8a732841c3a8d5ef/2023-11-09--07-28-12/1/s FORD EXPLORER 6TH GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: e886087f430e7fe7/2023-11-05--19-59-40/59/s FORD FOCUS 4TH GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: e886087f430e7fe7/2023-11-05--19-59-40/82/s FORD FOCUS 4TH GEN ['Rivian CAN Ign']\n",
|
||||
"Match found: e886087f430e7fe7/2023-11-05--19-59-40/106/s FORD FOCUS 4TH GEN ['Rivian CAN Ign']\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n",
|
||||
"from tqdm.notebook import tqdm, tnrange\n",
|
||||
"\n",
|
||||
"# Example search for CAN ignition messages\n",
|
||||
"# Be careful when filtering by bus, account for odd harness arrangements on Honda/HKG\n",
|
||||
"\n",
|
||||
"BUSES_TO_SEARCH = [0, 1, 2]\n",
|
||||
"\n",
|
||||
"# Support for external Red Panda\n",
|
||||
"EXTERNAL_PANDA_BUSES = [bus + 4 for bus in BUSES_TO_SEARCH]\n",
|
||||
"\n",
|
||||
"MESSAGES_TO_FIND = {\n",
|
||||
" 0x1F1: \"GM CAN Ign\",\n",
|
||||
" 0x152: \"Rivian CAN Ign\",\n",
|
||||
" 0x221: \"Tesla 3/Y CAN Ign\",\n",
|
||||
" 0x9E: \"Mazda CAN Ign\",\n",
|
||||
" 0x3C0: \"VW CAN Ign\",\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"progress_bar = tnrange(len(TEST_SEGMENTS), desc=\"segments searched\")\n",
|
||||
"\n",
|
||||
"for segment in TEST_SEGMENTS:\n",
|
||||
" lr = LogReader(segment, sources=[comma_car_segments_source])\n",
|
||||
" CP = lr.first(\"carParams\")\n",
|
||||
" if CP is None:\n",
|
||||
" progress_bar.update()\n",
|
||||
" continue\n",
|
||||
"\n",
|
||||
" can_packets = [msg for msg in lr if msg.which() == \"can\"]\n",
|
||||
" matched_messages = set()\n",
|
||||
"\n",
|
||||
" for packet in can_packets:\n",
|
||||
" for msg in packet.can:\n",
|
||||
" if msg.address in MESSAGES_TO_FIND and msg.src in (BUSES_TO_SEARCH + EXTERNAL_PANDA_BUSES):\n",
|
||||
" # print(msg)\n",
|
||||
" matched_messages.add(msg.address)\n",
|
||||
"\n",
|
||||
" if len(matched_messages) > 0:\n",
|
||||
" message_names = [MESSAGES_TO_FIND[message] for message in matched_messages]\n",
|
||||
" print(f\"Match found: {segment:<45} {CP.carFingerprint:<38} {message_names}\")\n",
|
||||
"\n",
|
||||
" progress_bar.update()\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7724dd97-f62e-4fd3-9f64-63d49be669d2",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "9f393e00-8efd-40fb-a41e-d312531a83e8",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.0"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
175
tools/car_porting/examples/ford_vin_fingerprint.ipynb
Normal file
175
tools/car_porting/examples/ford_vin_fingerprint.ipynb
Normal file
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 12,
|
||||
"metadata": {
|
||||
"jupyter": {
|
||||
"is_executing": true
|
||||
}
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Got 9 Ford cars from opendbc\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"\"\"\"In this example, we use the public comma car segments database to check if vin fingerprinting is feasible for ford.\"\"\"\n",
|
||||
"\n",
|
||||
"from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n",
|
||||
"from openpilot.tools.lib.comma_car_segments import get_comma_car_segments_database\n",
|
||||
"from opendbc.car.ford.values import CAR\n",
|
||||
"\n",
|
||||
"database = get_comma_car_segments_database()\n",
|
||||
"\n",
|
||||
"platforms = [c.value for c in CAR]\n",
|
||||
"print(f\"Got {len(platforms)} Ford cars from opendbc\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# Adapted from https://github.com/commaai/openpilot/issues/31052#issuecomment-1902690083\n",
|
||||
"\n",
|
||||
"MODEL_YEAR_CODES = {'M': 2021, 'N': 2022, 'P': 2023, 'R': 2024, 'S': 2025}\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"F150_CODES = ['F1C', 'F1E', 'W1C', 'W1E', 'X1C', 'X1E', 'W1R', 'W1P', 'W1S', 'W1T']\n",
|
||||
"LIGHTNING_CODES = ['L', 'V']\n",
|
||||
"MACHE_CODES = ['K1R', 'K1S', 'K2S', 'K3R', 'K3S', 'K4S']\n",
|
||||
"\n",
|
||||
"FORD_VIN_START = ['1FT', '3FM', '5LM']\n",
|
||||
"\n",
|
||||
"def ford_vin_fingerprint(vin): # Check if it's a Ford vehicle and determine the model\n",
|
||||
" vin_positions_567 = vin[4:7]\n",
|
||||
"\n",
|
||||
" if vin.startswith('1FT'):\n",
|
||||
" if vin_positions_567 in F150_CODES:\n",
|
||||
" if vin[7] in LIGHTNING_CODES:\n",
|
||||
" return f\"FORD F-150 LIGHTNING 1ST GEN\"\n",
|
||||
" else:\n",
|
||||
" return f\"FORD F-150 14TH GEN\"\n",
|
||||
" elif vin.startswith('3FM'):\n",
|
||||
" if vin_positions_567 in MACHE_CODES:\n",
|
||||
" return f\"FORD MUSTANG MACH-E 1ST GEN\"\n",
|
||||
" elif vin.startswith('5LM'):\n",
|
||||
" pass\n",
|
||||
"\n",
|
||||
" return \"mock\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 13,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Collecting segments from commaCarSegments dataset:\n",
|
||||
"Got 287 segments for platform FORD_BRONCO_SPORT_MK1, sampling 5 segments\n",
|
||||
"Got 137 segments for platform FORD_ESCAPE_MK4, sampling 5 segments\n",
|
||||
"Got 1041 segments for platform FORD_EXPLORER_MK6, sampling 5 segments\n",
|
||||
"Got 5 segments for platform FORD_F_150_MK14, sampling 5 segments\n",
|
||||
"Got 3 segments for platform FORD_F_150_LIGHTNING_MK1, sampling 3 segments\n",
|
||||
"Got 56 segments for platform FORD_FOCUS_MK4, sampling 5 segments\n",
|
||||
"Got 637 segments for platform FORD_MAVERICK_MK1, sampling 5 segments\n",
|
||||
"Got 3 segments for platform FORD_MUSTANG_MACH_E_MK1, sampling 3 segments\n",
|
||||
"Skipping platform: FORD_RANGER_MK2, no data available\n",
|
||||
"Segment collection finished\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import random\n",
|
||||
"\n",
|
||||
"MAX_SEGS_PER_PLATFORM = 5\n",
|
||||
"\n",
|
||||
"VINS_TO_CHECK = set()\n",
|
||||
"\n",
|
||||
"print(\"Collecting segments from commaCarSegments dataset:\")\n",
|
||||
"for platform in platforms:\n",
|
||||
" if platform not in database:\n",
|
||||
" print(f\"Skipping platform: {platform}, no data available\")\n",
|
||||
" continue\n",
|
||||
"\n",
|
||||
" all_segments = database[platform]\n",
|
||||
"\n",
|
||||
" NUM_SEGMENTS = min(len(all_segments), MAX_SEGS_PER_PLATFORM)\n",
|
||||
"\n",
|
||||
" print(f\"Got {len(all_segments)} segments for platform {platform}, sampling {NUM_SEGMENTS} segments\")\n",
|
||||
"\n",
|
||||
" segments = random.sample(all_segments, NUM_SEGMENTS)\n",
|
||||
"\n",
|
||||
" for segment in segments:\n",
|
||||
" lr = LogReader(segment, sources=[comma_car_segments_source])\n",
|
||||
" CP = lr.first(\"carParams\")\n",
|
||||
" if \"FORD\" not in CP.carFingerprint:\n",
|
||||
" print(segment, CP.carFingerprint)\n",
|
||||
" VINS_TO_CHECK.add((CP.carVin, CP.carFingerprint))\n",
|
||||
"\n",
|
||||
"print(\"Segment collection finished\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 16,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"vin: 3FMCR9B69NRXXXXXX real platform: FORD BRONCO SPORT 1ST GEN determined platform: mock correct: False\n",
|
||||
"vin: 00000000000XXXXXX real platform: FORD F-150 14TH GEN determined platform: mock correct: False\n",
|
||||
"vin: 1FMCU9J94MUXXXXXX real platform: FORD ESCAPE 4TH GEN determined platform: mock correct: False\n",
|
||||
"vin: 3FMTK3SU0MMXXXXXX real platform: FORD MUSTANG MACH-E 1ST GEN determined platform: FORD MUSTANG MACH-E 1ST GEN correct: True\n",
|
||||
"vin: 1FM5K8HC7MGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False\n",
|
||||
"vin: 5LM5J7XC9LGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False\n",
|
||||
"vin: 1FTVW1EL4NWXXXXXX real platform: FORD F-150 LIGHTNING 1ST GEN determined platform: FORD F-150 LIGHTNING 1ST GEN correct: True\n",
|
||||
"vin: WF0NXXGCHNJXXXXXX real platform: FORD FOCUS 4TH GEN determined platform: mock correct: False\n",
|
||||
"vin: 3FTTW8E99NRXXXXXX real platform: FORD MAVERICK 1ST GEN determined platform: mock correct: False\n",
|
||||
"vin: 1FM5K8GC7LGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False\n",
|
||||
"vin: 3FTTW8E33NRXXXXXX real platform: FORD MAVERICK 1ST GEN determined platform: mock correct: False\n",
|
||||
"vin: 5LM5J7XC1LGXXXXXX real platform: FORD EXPLORER 6TH GEN determined platform: mock correct: False\n",
|
||||
"vin: 3FTTW8E3XPRXXXXXX real platform: FORD MAVERICK 1ST GEN determined platform: mock correct: False\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"for vin, real_fingerprint in VINS_TO_CHECK:\n",
|
||||
" determined_fingerprint = ford_vin_fingerprint(vin)\n",
|
||||
" print(f\"vin: {vin} real platform: {real_fingerprint: <30} determined platform: {determined_fingerprint: <30} correct: {real_fingerprint == determined_fingerprint}\")"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
277
tools/car_porting/examples/hkg_canfd_gear_message.ipynb
Normal file
277
tools/car_porting/examples/hkg_canfd_gear_message.ipynb
Normal file
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 62,
|
||||
"id": "228a6736-de31-4255-9d72-a6ff391b968d",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Found 6 qualifying vehicles:\n",
|
||||
" KIA_EV6\n",
|
||||
" HYUNDAI_KONA_EV_2ND_GEN\n",
|
||||
" HYUNDAI_IONIQ_5\n",
|
||||
" KIA_NIRO_EV_2ND_GEN\n",
|
||||
" HYUNDAI_IONIQ_6\n",
|
||||
" GENESIS_GV60_EV_1ST_GEN\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from opendbc.car import structs\n",
|
||||
"from opendbc.car.hyundai.values import CAR, HyundaiFlags\n",
|
||||
"from opendbc.car.hyundai.fingerprints import FW_VERSIONS\n",
|
||||
"\n",
|
||||
"TEST_PLATFORMS = set(CAR.with_flags(HyundaiFlags.CANFD)) & set(CAR.with_flags(HyundaiFlags.EV)) # CAN-FD electric vehicles only\n",
|
||||
"#TEST_PLATFORMS = set(CAR.with_flags(HyundaiFlags.CANFD)) - set(CAR.with_flags(HyundaiFlags.EV)) # CAN-FD hybrid and ICE vehicles only\n",
|
||||
"\n",
|
||||
"print(f\"Found {len(TEST_PLATFORMS)} qualifying vehicles:\")\n",
|
||||
"for platform in TEST_PLATFORMS:\n",
|
||||
" print(f\" {platform}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 63,
|
||||
"id": "ed1c8aec-c274-4c61-b83d-711ea194bf86",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Collecting segments from commaCarSegments dataset:\n",
|
||||
"Got 1300 segments for platform KIA_EV6, sampling 5 segments\n",
|
||||
"Got 9 segments for platform HYUNDAI_KONA_EV_2ND_GEN, sampling 5 segments\n",
|
||||
"Got 1570 segments for platform HYUNDAI_IONIQ_5, sampling 5 segments\n",
|
||||
"Got 34 segments for platform KIA_NIRO_EV_2ND_GEN, sampling 5 segments\n",
|
||||
"Got 974 segments for platform HYUNDAI_IONIQ_6, sampling 5 segments\n",
|
||||
"Got 157 segments for platform GENESIS_GV60_EV_1ST_GEN, sampling 5 segments\n",
|
||||
"Collected 30 segments for analysis\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import random\n",
|
||||
"\n",
|
||||
"from openpilot.tools.lib.logreader import LogReader\n",
|
||||
"from openpilot.tools.lib.comma_car_segments import get_comma_car_segments_database\n",
|
||||
"from opendbc.car.hyundai.values import CAR\n",
|
||||
"\n",
|
||||
"database = get_comma_car_segments_database()\n",
|
||||
"TEST_SEGMENTS = []\n",
|
||||
"\n",
|
||||
"MAX_SEGS_PER_PLATFORM = 5 # TODO: Increase this to search more segments\n",
|
||||
"\n",
|
||||
"print(\"Collecting segments from commaCarSegments dataset:\")\n",
|
||||
"for platform in TEST_PLATFORMS:\n",
|
||||
" assert(platform in database)\n",
|
||||
" #if platform not in database:\n",
|
||||
" # print(f\"Skipping platform: {platform}, no data available\")\n",
|
||||
" # continue\n",
|
||||
"\n",
|
||||
" all_segments = database[platform]\n",
|
||||
"\n",
|
||||
" NUM_SEGMENTS = min(len(all_segments), MAX_SEGS_PER_PLATFORM)\n",
|
||||
"\n",
|
||||
" print(f\"Got {len(all_segments)} segments for platform {platform}, sampling {NUM_SEGMENTS} segments\")\n",
|
||||
"\n",
|
||||
" TEST_SEGMENTS.extend(random.sample(all_segments, NUM_SEGMENTS))\n",
|
||||
"\n",
|
||||
"print(f\"Collected {len(TEST_SEGMENTS)} segments for analysis\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 64,
|
||||
"id": "0c75e8f2-4f5f-4f89-b8db-5223a6534a9f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Analyzing segment ff2bd20623fcaeaa/2023-11-26--16-27-04/5/s for KIA EV6 2022\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 3f1a6480f940cf9a/2024-01-10--23-06-11/16/s for KIA EV6 2022\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment b0a9998109ed0053/2023-12-15--11-10-18/12/s for KIA EV6 2022\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 6e14aa2ed85025df/2023-11-15--13-18-12/24/s for KIA EV6 2022\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment a43f21df3a1ca12d/2024-01-25--08-56-22/16/s for KIA EV6 2022\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 1618132d68afc876/2023-12-05--13-49-24/11/s for HYUNDAI KONA ELECTRIC 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 1618132d68afc876/2023-11-26--12-31-18/17/s for HYUNDAI KONA ELECTRIC 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 1618132d68afc876/2023-12-05--11-51-44/3/s for HYUNDAI KONA ELECTRIC 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 1618132d68afc876/2023-08-27--09-32-14/13/s for HYUNDAI KONA 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 1618132d68afc876/2024-01-25--15-07-04/24/s for HYUNDAI KONA ELECTRIC 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 223780ed74116bc2/2023-11-16--09-44-56/15/s for HYUNDAI IONIQ 5 2022\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment ba9951252624f37d/2024-01-20--22-33-23/118/s for HYUNDAI IONIQ 5 2022\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 8379b28e51ceb3b1/2023-11-09--23-21-58/92/s for HYUNDAI IONIQ 5 2022\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 26fac43e27cd6091/2023-11-06--12-23-21/9/s for HYUNDAI IONIQ 5 2022\n",
|
||||
" GEAR_SHIFTER gear=1.0\n",
|
||||
" ACCELERATOR gear=0.0\n",
|
||||
"Analyzing segment 5edb897a0ec7a477/2024-01-13--20-41-36/101/s for HYUNDAI IONIQ 5 2022\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 66cf8ea23b7c2789/2023-12-04--13-48-53/5/s for KIA NIRO EV 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment b153671049a867b3/2023-12-10--20-31-37/2/s for KIA NIRO EV 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment b153671049a867b3/2023-12-03--21-08-30/14/s for KIA NIRO EV 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment b153671049a867b3/2023-11-07--19-52-23/0/s for KIA NIRO EV 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment b153671049a867b3/2023-07-12--19-25-18/6/s for KIA NIRO EV 2ND GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 9ea4578ee2b1abcb/2023-11-18--07-59-26/11/s for HYUNDAI IONIQ 6 2023\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 0ad7facc77922c3e/2023-12-21--17-47-25/18/s for HYUNDAI IONIQ 6 2023\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 26968f888e7330d3/2024-01-02--11-18-37/8/s for HYUNDAI IONIQ 6 2023\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 9ea4578ee2b1abcb/2023-11-27--21-03-24/33/s for HYUNDAI IONIQ 6 2023\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment df7fdd56970d90fe/2024-01-07--01-04-39/26/s for HYUNDAI IONIQ 6 2023\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 94542b2d06f7a9a6/2023-12-11--14-45-44/0/s for GENESIS GV60 ELECTRIC 1ST GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 94542b2d06f7a9a6/2023-12-11--20-57-09/8/s for GENESIS GV60 ELECTRIC 1ST GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 94542b2d06f7a9a6/2024-01-03--12-52-38/5/s for GENESIS GV60 ELECTRIC 1ST GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 94542b2d06f7a9a6/2024-01-19--19-57-52/47/s for GENESIS GV60 ELECTRIC 1ST GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analyzing segment 94542b2d06f7a9a6/2024-01-03--13-01-23/1/s for GENESIS GV60 ELECTRIC 1ST GEN\n",
|
||||
" GEAR_SHIFTER gear=4.0\n",
|
||||
" ACCELERATOR gear=5.0\n",
|
||||
"Analysis finished\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import copy\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"import numpy as np\n",
|
||||
"\n",
|
||||
"from opendbc.can.parser import CANParser\n",
|
||||
"from opendbc.car.hyundai.values import DBC\n",
|
||||
"from opendbc.car.hyundai.hyundaicanfd import CanBus\n",
|
||||
"\n",
|
||||
"from openpilot.selfdrive.pandad import can_capnp_to_list\n",
|
||||
"from openpilot.tools.lib.logreader import LogReader, comma_car_segments_source\n",
|
||||
"\n",
|
||||
"message_names = [\"GEAR_SHIFTER\", \"ACCELERATOR\", \"GEAR\", \"GEAR_ALT\", \"GEAR_ALT_2\"]\n",
|
||||
"\n",
|
||||
"for segment in TEST_SEGMENTS:\n",
|
||||
" lr = LogReader(segment, sources=[comma_car_segments_source])\n",
|
||||
" CP = lr.first(\"carParams\")\n",
|
||||
" if CP is None:\n",
|
||||
" continue\n",
|
||||
"\n",
|
||||
" can_msgs = [msg for msg in lr if msg.which() == \"can\"]\n",
|
||||
" parser_messages = []\n",
|
||||
" for name in message_names:\n",
|
||||
" parser_messages.append((name, 0))\n",
|
||||
" cp = CANParser(DBC[platform][\"pt\"], parser_messages, CanBus(CP).ECAN)\n",
|
||||
"\n",
|
||||
" parsed_message_history = []\n",
|
||||
" examples = []\n",
|
||||
"\n",
|
||||
" for msg in can_msgs:\n",
|
||||
" cp.update_strings(can_capnp_to_list([msg.as_builder().to_bytes()]))\n",
|
||||
" parsed_message_history.append(copy.copy(cp.vl))\n",
|
||||
"\n",
|
||||
" print(f\"Analyzing segment {segment:<44} for {CP.carFingerprint}\")\n",
|
||||
" for name in message_names:\n",
|
||||
" if parsed_message_history[0][name][\"CHECKSUM\"] != 0: # Message is present for this segment\n",
|
||||
" gear_prev = parsed_message_history[0][name][\"GEAR\"]\n",
|
||||
" print(f\" {name:<15} gear={gear_prev}\")\n",
|
||||
" for i, parsed_messages in enumerate(parsed_message_history):\n",
|
||||
" gear = parsed_messages[name][\"GEAR\"]\n",
|
||||
" if gear != gear_prev:\n",
|
||||
" print(f\" *** Signal transition found! ***\")\n",
|
||||
" examples.append(i)\n",
|
||||
" gear_prev = gear\n",
|
||||
"\n",
|
||||
"print(f\"Analysis finished\")\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7724dd97-f62e-4fd3-9f64-63d49be669d2",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "9f393e00-8efd-40fb-a41e-d312531a83e8",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
260
tools/car_porting/examples/subaru_fuzzy_fingerprint.ipynb
Normal file
260
tools/car_porting/examples/subaru_fuzzy_fingerprint.ipynb
Normal file
@@ -0,0 +1,260 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from opendbc.car import structs\n",
|
||||
"from opendbc.car.subaru.values import CAR, SubaruFlags\n",
|
||||
"from opendbc.car.subaru.fingerprints import FW_VERSIONS\n",
|
||||
"\n",
|
||||
"TEST_PLATFORMS = set(CAR) - CAR.with_flags(SubaruFlags.PREGLOBAL)\n",
|
||||
"\n",
|
||||
"Ecu = structs.CarParams.Ecu\n",
|
||||
"\n",
|
||||
"FW_BY_ECU = {platform: {ecu: versions for (ecu, addr, sub_addr), versions in fw_versions.items()} for platform, fw_versions in FW_VERSIONS.items()}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"PLATFORM_CODES = {\n",
|
||||
" Ecu.abs: {\n",
|
||||
" 0: {\n",
|
||||
" b'\\xa5': [CAR.SUBARU_ASCENT, CAR.SUBARU_ASCENT_2023],\n",
|
||||
" b'\\xa2': [CAR.SUBARU_IMPREZA, CAR.SUBARU_IMPREZA_2020, CAR.SUBARU_CROSSTREK_HYBRID],\n",
|
||||
" b'\\xa1': [CAR.SUBARU_OUTBACK, CAR.SUBARU_LEGACY, CAR.SUBARU_OUTBACK_2023],\n",
|
||||
" b'\\xa3': [CAR.SUBARU_FORESTER, CAR.SUBARU_FORESTER_HYBRID, CAR.SUBARU_FORESTER_2022],\n",
|
||||
" b'z': [CAR.SUBARU_IMPREZA],\n",
|
||||
" }\n",
|
||||
" }\n",
|
||||
"}\n",
|
||||
"\n",
|
||||
"YEAR_CODES = {\n",
|
||||
" Ecu.abs: {\n",
|
||||
" 2: {\n",
|
||||
" b'\\x18': 2018,\n",
|
||||
" b'\\x19': 2019,\n",
|
||||
" b'\\x20': 2020,\n",
|
||||
" b'\\x21': 2021,\n",
|
||||
" b'\\x22': 2022,\n",
|
||||
" b'\\x23': 2023,\n",
|
||||
" }\n",
|
||||
" }\n",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def get_codes(platforms, codes):\n",
|
||||
" results = []\n",
|
||||
" for platform in platforms:\n",
|
||||
" for ecu in codes:\n",
|
||||
" for i in codes[ecu]:\n",
|
||||
" if isinstance(i, tuple):\n",
|
||||
" j = slice(i[0], i[1])\n",
|
||||
" else:\n",
|
||||
" j = slice(i, i+1)\n",
|
||||
" for version in FW_BY_ECU[platform][ecu]:\n",
|
||||
" code = version[j]\n",
|
||||
" if code not in codes[ecu][i]:\n",
|
||||
" print(f\"{platform} {code.hex()} not in {codes[ecu][i].keys()}\")\n",
|
||||
" else:\n",
|
||||
" results.append((platform, codes[ecu][i][code]))\n",
|
||||
" return results"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 9,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"SUBARU_IMPREZA 08 not in dict_keys([b'\\x18', b'\\x19', b' ', b'!', b'\"', b'#'])\n",
|
||||
"SUBARU_IMPREZA 08 not in dict_keys([b'\\x18', b'\\x19', b' ', b'!', b'\"', b'#'])\n",
|
||||
"SUBARU_IMPREZA 0c not in dict_keys([b'\\x18', b'\\x19', b' ', b'!', b'\"', b'#'])\n",
|
||||
"SUBARU_IMPREZA 0c not in dict_keys([b'\\x18', b'\\x19', b' ', b'!', b'\"', b'#'])\n",
|
||||
"SUBARU_IMPREZA 2e not in dict_keys([b'\\x18', b'\\x19', b' ', b'!', b'\"', b'#'])\n",
|
||||
"SUBARU_IMPREZA 3f not in dict_keys([b'\\x18', b'\\x19', b' ', b'!', b'\"', b'#'])\n",
|
||||
"correct_year=False platform=SUBARU_FORESTER year=2018 years=[2019, 2020, 2021]\n",
|
||||
"correct_year=False platform=SUBARU_FORESTER year=2018 years=[2019, 2020, 2021]\n",
|
||||
"correct_year=True platform=SUBARU_FORESTER year=2019 years=[2019, 2020, 2021]\n",
|
||||
"correct_year=True platform=SUBARU_FORESTER year=2019 years=[2019, 2020, 2021]\n",
|
||||
"correct_year=True platform=SUBARU_FORESTER year=2019 years=[2019, 2020, 2021]\n",
|
||||
"correct_year=True platform=SUBARU_FORESTER year=2020 years=[2019, 2020, 2021]\n",
|
||||
"correct_year=True platform=SUBARU_FORESTER year=2020 years=[2019, 2020, 2021]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2022 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK year=2022 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=False platform=SUBARU_FORESTER_HYBRID year=2019 years=[2020]\n",
|
||||
"correct_year=False platform=SUBARU_CROSSTREK_HYBRID year=2019 years=[2020]\n",
|
||||
"correct_year=False platform=SUBARU_CROSSTREK_HYBRID year=2021 years=[2020]\n",
|
||||
"correct_year=True platform=SUBARU_ASCENT_2023 year=2023 years=[2023]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA year=2019 years=[2017, 2018, 2019]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA year=2019 years=[2017, 2018, 2019]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA year=2018 years=[2017, 2018, 2019]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA year=2019 years=[2017, 2018, 2019]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA year=2019 years=[2017, 2018, 2019]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA year=2019 years=[2017, 2018, 2019]\n",
|
||||
"correct_year=False platform=SUBARU_FORESTER_2022 year=2021 years=[2022, 2023, 2024]\n",
|
||||
"correct_year=False platform=SUBARU_FORESTER_2022 year=2021 years=[2022, 2023, 2024]\n",
|
||||
"correct_year=True platform=SUBARU_FORESTER_2022 year=2022 years=[2022, 2023, 2024]\n",
|
||||
"correct_year=True platform=SUBARU_FORESTER_2022 year=2022 years=[2022, 2023, 2024]\n",
|
||||
"correct_year=True platform=SUBARU_ASCENT year=2019 years=[2019, 2020, 2021]\n",
|
||||
"correct_year=True platform=SUBARU_ASCENT year=2021 years=[2019, 2020, 2021]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK_2023 year=2023 years=[2023]\n",
|
||||
"correct_year=True platform=SUBARU_OUTBACK_2023 year=2023 years=[2023]\n",
|
||||
"correct_year=False platform=SUBARU_IMPREZA_2020 year=2019 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=False platform=SUBARU_IMPREZA_2020 year=2019 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA_2020 year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA_2020 year=2021 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA_2020 year=2021 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA_2020 year=2021 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_IMPREZA_2020 year=2021 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_LEGACY year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_LEGACY year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_LEGACY year=2020 years=[2020, 2021, 2022]\n",
|
||||
"correct_year=True platform=SUBARU_LEGACY year=2020 years=[2020, 2021, 2022]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def test_year_code(platform, year):\n",
|
||||
" car_docs = CAR(platform).config.car_docs\n",
|
||||
" if isinstance(car_docs, list):\n",
|
||||
" car_docs = car_docs[0]\n",
|
||||
" years = [int(y) for y in car_docs.year_list]\n",
|
||||
" correct_year = year in years\n",
|
||||
" print(f\"{correct_year=!s: <6} {platform=: <32} {year=: <5} {years=}\")\n",
|
||||
"\n",
|
||||
"codes = get_codes(TEST_PLATFORMS, YEAR_CODES)\n",
|
||||
"for platform, year in codes:\n",
|
||||
" test_year_code(platform, year)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER_HYBRID platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_CROSSTREK_HYBRID platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_CROSSTREK_HYBRID platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_ASCENT_2023 platforms=['SUBARU_ASCENT', 'SUBARU_ASCENT_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER_2022 platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER_2022 platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER_2022 platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_FORESTER_2022 platforms=['SUBARU_FORESTER', 'SUBARU_FORESTER_HYBRID', 'SUBARU_FORESTER_2022']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_ASCENT platforms=['SUBARU_ASCENT', 'SUBARU_ASCENT_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_ASCENT platforms=['SUBARU_ASCENT', 'SUBARU_ASCENT_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK_2023 platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_OUTBACK_2023 platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA_2020 platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA_2020 platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA_2020 platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA_2020 platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA_2020 platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA_2020 platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_IMPREZA_2020 platforms=['SUBARU_IMPREZA', 'SUBARU_IMPREZA_2020', 'SUBARU_CROSSTREK_HYBRID']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_LEGACY platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_LEGACY platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_LEGACY platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n",
|
||||
"in_possible_platforms=True platform=SUBARU_LEGACY platforms=['SUBARU_OUTBACK', 'SUBARU_LEGACY', 'SUBARU_OUTBACK_2023']\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def test_platform_code(platform, platforms):\n",
|
||||
" platforms = [str(p) for p in platforms]\n",
|
||||
" in_possible_platforms = platform in platforms\n",
|
||||
" print(f\"{in_possible_platforms=!s: <6} {platform=: <32} {platforms=}\")\n",
|
||||
"\n",
|
||||
"codes = get_codes(TEST_PLATFORMS, PLATFORM_CODES)\n",
|
||||
"for platform, possible_platforms in codes:\n",
|
||||
" test_platform_code(platform, possible_platforms)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
128
tools/car_porting/examples/subaru_long_accel.ipynb
Normal file
128
tools/car_porting/examples/subaru_long_accel.ipynb
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"segments = [\n",
|
||||
" \"d9df6f87e8feff94|2023-03-28--17-41-10/1:12\"\n",
|
||||
"]\n",
|
||||
"platform = \"SUBARU_OUTBACK\"\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import copy\n",
|
||||
"import numpy as np\n",
|
||||
"\n",
|
||||
"from opendbc.can.parser import CANParser\n",
|
||||
"from opendbc.car.subaru.values import DBC\n",
|
||||
"\n",
|
||||
"from openpilot.selfdrive.pandad import can_capnp_to_list\n",
|
||||
"from openpilot.tools.lib.logreader import LogReader\n",
|
||||
"\n",
|
||||
"\"\"\"\n",
|
||||
"In this example, we plot the relationship between Cruise_Brake and Acceleration for stock eyesight.\n",
|
||||
"\"\"\"\n",
|
||||
"\n",
|
||||
"for segment in segments:\n",
|
||||
" lr = LogReader(segment)\n",
|
||||
"\n",
|
||||
" messages = [\n",
|
||||
" (\"ES_Distance\", 20),\n",
|
||||
" (\"ES_Brake\", 20),\n",
|
||||
" (\"ES_Status\", 20),\n",
|
||||
" ]\n",
|
||||
"\n",
|
||||
" cp = CANParser(DBC[platform][\"pt\"], messages, 1)\n",
|
||||
"\n",
|
||||
" es_distance_history = []\n",
|
||||
" es_status_history = []\n",
|
||||
" es_brake_history = []\n",
|
||||
" acceleration_history = []\n",
|
||||
"\n",
|
||||
" last_acc = 0\n",
|
||||
"\n",
|
||||
" for msg in lr:\n",
|
||||
" if msg.which() == \"can\":\n",
|
||||
" cp.update_strings(can_capnp_to_list([msg.as_builder().to_bytes()]))\n",
|
||||
" es_distance_history.append(copy.copy(cp.vl[\"ES_Distance\"]))\n",
|
||||
" es_brake_history.append(copy.copy(cp.vl[\"ES_Brake\"]))\n",
|
||||
" es_status_history.append(copy.copy(cp.vl[\"ES_Status\"]))\n",
|
||||
"\n",
|
||||
" acceleration_history.append(last_acc)\n",
|
||||
"\n",
|
||||
" if msg.which() == \"carState\":\n",
|
||||
" last_acc = msg.carState.aEgo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def process(history, func):\n",
|
||||
" return np.array([func(h) for h in history])\n",
|
||||
"\n",
|
||||
"cruise_activated = process(es_status_history, lambda es_status: es_status[\"Cruise_Activated\"])\n",
|
||||
"cruise_throttle = process(es_distance_history, lambda es_distance: es_distance[\"Cruise_Throttle\"])\n",
|
||||
"cruise_rpm = process(es_status_history, lambda es_status: es_status[\"Cruise_RPM\"])\n",
|
||||
"cruise_brake = process(es_brake_history, lambda es_brake: es_brake[\"Brake_Pressure\"])\n",
|
||||
"acceleration = process(acceleration_history, lambda acc: acc)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"\n",
|
||||
"valid_brake = (cruise_activated==1) & (cruise_brake>0) # only when cruise is activated and eyesight is braking\n",
|
||||
"\n",
|
||||
"ax = plt.figure().add_subplot()\n",
|
||||
"\n",
|
||||
"ax.set_title(\"Brake_Pressure vs Acceleration\")\n",
|
||||
"ax.set_xlabel(\"Brake_Pessure\")\n",
|
||||
"ax.set_ylabel(\"Acceleration\")\n",
|
||||
"ax.scatter(cruise_brake[valid_brake], -acceleration[valid_brake])"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.3"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 4
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user