openpilot v0.10.3 release

date: 2025-12-18T23:23:16
master commit: 154c2334110373950bac1c36fc6e943cb1208326
This commit is contained in:
Vehicle Researcher
2025-12-18 23:23:21 -08:00
commit 6928314c89
3535 changed files with 969255 additions and 0 deletions

20
tools/CTF.md Normal file
View 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
View 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
View File

17
tools/auto_source.py Executable file
View 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
View File

@@ -0,0 +1,4 @@
av
av-10.0.0/*
key.pem
cert.pem

View 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
View 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();
}

View 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);

View 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));

View 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);
}

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

127
tools/bodyteleop/web.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
moc_*
*.moc
cabana
dbc/car_fingerprint_to_dbc.json
tests/test_cabana

100
tools/cabana/README.md Normal file
View 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 &lt;ipaddress&gt; 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
View 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
View File

@@ -0,0 +1 @@
*.cc

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

504
tools/cabana/binaryview.cc Normal file
View 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">&nbsp;x&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Backspace&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;Delete&nbsp;</span><br />
Change endianness: <span style="background-color:lightGray;color:gray">&nbsp;e&nbsp; </span><br />
Change singedness: <span style="background-color:lightGray;color:gray">&nbsp;s&nbsp;</span><br />
Open chart:
<span style="background-color:lightGray;color:gray">&nbsp;c&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;p&nbsp;</span>,
<span style="background-color:lightGray;color:gray">&nbsp;g&nbsp;</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
View 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
View 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
View 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
View 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
View 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
View 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;
};

View 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 &current_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;
}

View 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 *> &currentCharts() { 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;
};

View 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;
}

View 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;
};

View 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());
}
}

View 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;
};

View 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);
}

View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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_;
};

View 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;
}

View 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;
}

View 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}")

View 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;
}

View 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
View 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
View 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
View 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
View 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;
};

View 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 &current, 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">&nbsp;shift+wheel&nbsp;</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;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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;
};

View 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);
}

View 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;

View 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);
}

View 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;
};

View 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());
}

View 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;
};

View 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;
}
}

View 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 = {};
};

View 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;
}

View 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;
};

View 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() : "";
}

View 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_;
};

View 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;
}
}

View 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 = {};
};

View 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);
}

View 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;
};

View 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());
}

View 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);
}

View 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);
}
}
}

View 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;
};

View 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;
}

View 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;
};

View 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);
}

View 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
View 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
View 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();
};

View 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());
}

View 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_;
};

View 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

View 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
View 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
View 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
View 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">&nbsp;space&nbsp;</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);
}

View 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;
};

View 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
```

View 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
View 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.
![steer warning example](https://github.com/commaai/openpilot/assets/9648890/d60ad120-4b44-4974-ac79-adc660fb8fe2)
*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.
![brake pressure example](https://github.com/commaai/openpilot/assets/9648890/8f32cf1d-8fc0-4407-b540-70625ebbf082)
*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']
```

View 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)

View 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
}

View 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
}

View 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
}

View 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
}

View 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