merge ah/cabana-webusb

main
Andy Haden 2017-08-03 14:41:52 -07:00
parent be6382f3f5
commit bd2ba52a35
45 changed files with 1642 additions and 428 deletions

View File

@ -113,7 +113,8 @@ module.exports = {
/\.(js|jsx)(\?.*)?$/,
/\.css$/,
/\.json$/,
/\.svg$/
/\.svg$/,
/\.png$/,
],
loader: 'url',
query: {
@ -165,6 +166,11 @@ module.exports = {
{ loader: 'worker-loader' },
{ loader: 'babel-loader' }
]
},
{
// static image loader
test: /\.png$/,
loader: 'base64-inline-loader?name=[name].[ext]'
}
// ** STOP ** Are you adding a new loader?
// Remember to add the new extension(s) to the "url" loader exclusion list.

View File

@ -111,7 +111,8 @@ module.exports = {
/\.(js|jsx)$/,
/\.css$/,
/\.json$/,
/\.svg$/
/\.svg$/,
/\.png$/
],
loader: 'url',
query: {
@ -169,6 +170,11 @@ module.exports = {
{ loader: 'worker-loader' },
{ loader: 'babel-loader' }
]
},
{
// static image loader
test: /\.png$/,
loader: 'base64-inline-loader?name=[name].[ext]'
}
// ** STOP ** Are you adding a new loader?
// Remember to add the new extension(s) to the "url" loader exclusion list.

View File

@ -5,6 +5,7 @@
"homepage": "https://community.comma.ai/cabana",
"dependencies": {
"aphrodite": "^1.2.1",
"base64-inline-loader": "^1.1.0",
"classnames": "^2.2.5",
"clipboard": "^1.7.1",
"core-js": "^2.4.1",
@ -28,6 +29,8 @@
"react-measure": "^2.0.2",
"react-test-renderer": "^15.6.1",
"react-vega": "^3.0.0",
"react-visibility-sensor": "^3.10.1",
"simple-statistics": "^4.1.0",
"socket.io-client": "^2.0.3",
"vega": "git+ssh://git@github.com/commaai/vega.git#HEAD",
"vega-tooltip": "^0.4.0"
@ -79,7 +82,7 @@
"start": "python simple-cors-http-server.py & python server.py & npm run sass & node scripts/start.js",
"build": "node scripts/build.js",
"test": "node scripts/test.js --env=jsdom",
"sass": "sass --watch src/index.scss:src/index.css"
"sass": "scss src/index.scss:src/index.css; sass --watch src/index.scss:src/index.css"
},
"esLintConfig": {
"rules": {

View File

@ -1,8 +1,9 @@
import React, { Component } from 'react';
import Moment from 'moment';
import PropTypes from 'prop-types';
import {USE_UNLOGGER, PART_SEGMENT_LENGTH} from './config';
import {USE_UNLOGGER, PART_SEGMENT_LENGTH, STREAMING_WINDOW} from './config';
import * as GithubAuth from './api/github-auth';
import cx from 'classnames';
@ -11,19 +12,23 @@ import DBC from './models/can/dbc';
import Meta from './components/meta';
import Explorer from './components/explorer';
import * as Routes from './api/routes';
import OnboardingModal from './components/Modals/OnboardingModal';
import SaveDbcModal from './components/SaveDbcModal';
import LoadDbcModal from './components/LoadDbcModal';
const CanFetcher = require('./workers/can-fetcher.worker.js');
const MessageParser = require("./workers/message-parser.worker.js");
const CanOffsetFinder = require('./workers/can-offset-finder.worker.js');
const CanStreamerWorker = require('./workers/CanStreamerWorker.worker.js');
import debounce from './utils/debounce';
import EditMessageModal from './components/EditMessageModal';
import LoadingBar from './components/LoadingBar';
import {persistDbc} from './api/localstorage';
import {persistDbc, fetchPersistedDbc} from './api/localstorage';
import OpenDbc from './api/opendbc';
import UnloggerClient from './api/unlogger';
import PandaReader from './api/panda-reader';
import * as ObjectUtils from './utils/object';
import {hash} from './utils/string';
import DbcUtils from './utils/dbc';
export default class CanExplorer extends Component {
static propTypes = {
@ -42,17 +47,20 @@ export default class CanExplorer extends Component {
this.state = {
messages: {},
selectedMessages: [],
route: {},
route: null,
canFrameOffset: -1,
firstCanTime: 0,
lastBusTime: null,
selectedMessage: null,
currentParts: [0,0],
showOnboarding: false,
showLoadDbc: false,
showSaveDbc: false,
showEditMessageModal: false,
editMessageModalMessage: null,
dbc: new DBC(),
dbcFilename: 'New_DBC',
dbc: (props.dbc ? props.dbc : new DBC()),
dbcText: (props.dbc ? props.dbc.text() : (new DBC()).text()),
dbcFilename: (props.dbcFilename ? props.dbcFilename : 'New_DBC'),
dbcLastSaved: null,
seekTime: 0,
seekIndex: 0,
@ -60,12 +68,18 @@ export default class CanExplorer extends Component {
isLoading: true,
partsLoaded: 0,
spawnWorkerHash: null,
attemptingPandaConnection: false,
pandaNoDeviceSelected: false,
};
this.openDbcClient = new OpenDbc(props.githubAuthToken);
if(USE_UNLOGGER) {
this.unloggerClient = new UnloggerClient();
}
this.pandaReader = new PandaReader();
this.showOnboarding = this.showOnboarding.bind(this);
this.hideOnboarding = this.hideOnboarding.bind(this);
this.showLoadDbc = this.showLoadDbc.bind(this);
this.hideLoadDbc = this.hideLoadDbc.bind(this);
this.showSaveDbc = this.showSaveDbc.bind(this);
@ -83,27 +97,33 @@ export default class CanExplorer extends Component {
this.onMessageUnselected = this.onMessageUnselected.bind(this);
this.initCanData = this.initCanData.bind(this);
this.updateSelectedMessages = this.updateSelectedMessages.bind(this);
this.handlePandaConnect = this.handlePandaConnect.bind(this);
this.processStreamedCanMessages = this.processStreamedCanMessages.bind(this);
this.onStreamedCanMessagesProcessed = this.onStreamedCanMessagesProcessed.bind(this);
this.showingModal = this.showingModal.bind(this);
this.lastMessageEntriesById = this.lastMessageEntriesById.bind(this);
}
componentWillMount() {
const {dongleId, name} = this.props;
Routes.fetchRoutes(dongleId).then((routes) => {
if(routes && routes[name]) {
const route = routes[name];
const {dongleId, name, isDemo} = this.props;
if(this.props.max && this.props.url) {
const {max, url} = this.props;
const route = {fullname: name, proclog: max, url: url};
this.setState({route, currentParts: [0, Math.min(max - 1, PART_SEGMENT_LENGTH - 1)]}, this.initCanData);
} else if(dongleId && name) {
Routes.fetchRoutes(dongleId).then((routes) => {
if(routes && routes[name]) {
const route = routes[name];
const newState = {route, currentParts: [0, Math.min(route.proclog - 1, PART_SEGMENT_LENGTH - 1)]};
if(this.props.dbc !== undefined) {
newState.dbc = this.props.dbc;
newState.dbcFilename = this.props.dbcFilename;
const newState = {route, currentParts: [0, Math.min(route.proclog - 1, PART_SEGMENT_LENGTH - 1)]};
this.setState(newState, this.initCanData);
} else {
this.showOnboarding();
}
this.setState(newState, this.initCanData);
} else if(this.props.max && this.props.url) {
const {max, url} = this.props;
const route = {fullname: name, proclog: max, url: url};
this.setState({route, currentParts: [0, Math.min(max - 1, PART_SEGMENT_LENGTH - 1)]}, this.initCanData);
}
});
});
} else {
this.showOnboarding();
}
}
initCanData() {
@ -123,20 +143,27 @@ export default class CanExplorer extends Component {
}
onDbcSelected(dbcFilename, dbc) {
const {route} = this.state;
const {route, messages} = this.state;
this.hideLoadDbc();
persistDbc(route.fullname,
{dbcFilename, dbc});
this.setState({dbc,
dbcFilename,
partsLoaded: 0,
selectedMessage: null,
messages: {}}, () => {
const {route} = this.state;
if(route) {
persistDbc(route.fullname,
{dbcFilename, dbc});
this.setState({dbc,
dbcFilename,
dbcText: dbc.text(),
partsLoaded: 0,
selectedMessage: null,
messages: {}}, () => {
const {route} = this.state;
// Pass DBC text to webworker b/c can't pass instance of es6 class
this.spawnWorker(this.state.currentParts);
});
// Pass DBC text to webworker b/c can't pass instance of es6 class
this.spawnWorker(this.state.currentParts);
});
} else {
persistDbc('live', {dbcFilename, dbc});
this.setState({dbc, dbcFilename, dbcText: dbc.text(), messages: {}});
}
}
onDbcSaved(dbcFilename) {
@ -145,6 +172,27 @@ export default class CanExplorer extends Component {
this.hideSaveDbc();
}
addAndRehydrateMessages(newMessages, options) {
// Adds new message entries to messages state
// and "rehydrates" ES6 classes (message frame)
// lost from JSON serialization in webworker data cloning.
if(options === undefined) options = {};
const messages = {...this.state.messages};
for(var key in newMessages) {
// add message
if ((options.replace !== true) && key in messages) {
messages[key].entries = messages[key].entries.concat(newMessages[key].entries);
messages[key].byteStateChangeCounts = newMessages[key].byteStateChangeCounts;
} else {
messages[key] = newMessages[key];
messages[key].frame = this.state.dbc.messages.get(messages[key].address);
}
}
return messages;
}
spawnWorker(parts, options) {
// options is object of {part, prevMsgEntries, spawnWorkerHash, prepend}
if(!this.state.isLoading) {
@ -168,7 +216,7 @@ export default class CanExplorer extends Component {
this.setState({partsLoaded: 0});
}
const {dbc, dbcFilename, route, firstCanTime, canFrameOffset} = this.state;
const {dbc, dbcFilename, route, firstCanTime, canFrameOffset, maxByteStateChangeCount} = this.state;
var worker = new CanFetcher();
worker.onmessage = (e) => {
@ -177,34 +225,28 @@ export default class CanExplorer extends Component {
return;
}
const {messages} = this.state;
if(this.state.dbcFilename != dbcFilename) {
// DBC changed while this worker was running
// -- don't update messages and halt recursion.
return;
}
const {newMessages, maxByteStateChangeCount} = e.data;
let {newMessages, maxByteStateChangeCount} = e.data;
if(maxByteStateChangeCount > this.state.maxByteStateChangeCount) {
this.setState({maxByteStateChangeCount});
} else {
maxByteStateChangeCount = this.state.maxByteStateChangeCount;
}
for(var key in newMessages) {
if (key in messages) {
messages[key].entries = messages[key].entries.concat(newMessages[key].entries);
} else {
messages[key] = newMessages[key];
messages[key].signals = this.state.dbc.getSignals(messages[key].address);
messages[key].frame = this.state.dbc.messages.get(messages[key].address);
}
}
const messages = this.addAndRehydrateMessages(newMessages, maxByteStateChangeCount);
const prevMsgEntries = {};
for(let key in newMessages) {
const msg = newMessages[key];
prevMsgEntries[key] = msg.entries[msg.entries.length - 1];
}
this.setState({messages,
partsLoaded: this.state.partsLoaded + 1}, () => {
if(part < maxPart) {
@ -219,18 +261,28 @@ export default class CanExplorer extends Component {
base: route.url,
num: part,
canStartTime: firstCanTime - canFrameOffset,
prevMsgEntries
prevMsgEntries,
maxByteStateChangeCount
});
}
showingModal() {
const {
showOnboarding,
showLoadDbc,
showSaveDbc,
showAddSignal,
showEditMessageModal,
} = this.state;
return showLoadDbc || showSaveDbc || showAddSignal || showEditMessageModal;
return showOnboarding || showLoadDbc || showSaveDbc || showAddSignal || showEditMessageModal;
}
showOnboarding() {
this.setState({ showOnboarding: true });
}
hideOnboarding() {
this.setState({ showOnboarding: false });
}
showLoadDbc() {
@ -249,33 +301,46 @@ export default class CanExplorer extends Component {
this.setState({showSaveDbc: false})
}
onConfirmedSignalChange(message) {
const signals = message.signals;
const {dbc, dbcFilename, route} = this.state;
dbc.setSignals(message.address, message.signals);
persistDbc(route.fullname,
{dbcFilename, dbc});
this.setState({dbc, isLoading: true});
reparseMessages(messages) {
this.setState({isLoading: true});
const {dbc} = this.state;
var worker = new MessageParser();
worker.onmessage = (e) => {
const newMessage = e.data;
newMessage.signals = dbc.getSignals(newMessage.address);
newMessage.frame = dbc.messages.get(newMessage.address);
let messages = e.data;
messages = this.addAndRehydrateMessages(messages, {replace: true});
const messages = {};
Object.assign(messages, this.state.messages);
messages[message.id] = newMessage;
this.setState({messages, isLoading: false})
}
worker.postMessage({message,
worker.postMessage({messages,
dbcText: dbc.text(),
canStartTime: this.state.firstCanTime});
}
onConfirmedSignalChange(message, signals) {
const {dbc, dbcFilename, route} = this.state;
dbc.setSignals(message.address, {...signals});
if(route) {
persistDbc(route.fullname,
{dbcFilename, dbc});
} else {
persistDbc('live', {dbcFilename, dbc});
}
const messages = {};
const newMessage = {...message};
const frame = dbc.messages.get(message.address);
newMessage.frame = frame;
messages[message.id] = newMessage;
this.setState({dbc, dbcText: dbc.text()},
() => this.reparseMessages(messages));
}
partChangeDebounced = debounce(() => {
const {currentParts} = this.state;
this.spawnWorker(currentParts);
@ -315,7 +380,8 @@ export default class CanExplorer extends Component {
this.setState({showEditMessageModal: true,
editMessageModalMessage: msgKey,
messages: this.state.messages});
messages: this.state.messages,
dbcText: dbc.text()});
}
hideEditMessageModal() {
@ -336,7 +402,7 @@ export default class CanExplorer extends Component {
{dbcFilename, dbc});
messages[editMessageModalMessage] = message;
this.setState({messages});
this.setState({messages, dbc, dbcText: dbc.text()});
this.hideEditMessageModal();
}
@ -388,8 +454,9 @@ export default class CanExplorer extends Component {
}
loginWithGithub() {
const {route} = this.state;
return (
<a href={GithubAuth.authorizeUrl(this.state.route.fullname || '')}
<a href={GithubAuth.authorizeUrl(route && route.fullname ? route.fullname : '')}
className='button button--dark button--inline'>
<i className='fa fa-github'></i>
<span> Log in with Github</span>
@ -397,6 +464,110 @@ export default class CanExplorer extends Component {
)
}
lastMessageEntriesById(obj, [msgId, message]) {
obj[msgId] = message.entries[message.entries.length - 1];
return obj;
}
processStreamedCanMessages(newCanMessages) {
const {dbcText} = this.state;
const {firstCanTime, lastBusTime, messages, maxByteStateChangeCount} = this.state;
// map msg id to arrays
const prevMsgEntries = Object.entries(messages).reduce(this.lastMessageEntriesById, {});
const byteStateChangeCountsByMessage = Object.entries(messages).reduce(
(obj, [msgId, msg]) => {
obj[msgId] = msg.byteStateChangeCounts
return obj;
}, {});
this.canStreamerWorker.postMessage({ newCanMessages,
prevMsgEntries,
firstCanTime,
dbcText,
lastBusTime,
byteStateChangeCountsByMessage,
maxByteStateChangeCount });
}
firstEntryIndexInsideStreamingWindow(entries) {
const lastEntryTime = entries[entries.length - 1].relTime;
const windowFloor = lastEntryTime - STREAMING_WINDOW;
for(let i = 0; i < entries.length; i++) {
if(entries[i].relTime > windowFloor) {
return i;
}
}
return 0;
}
enforceStreamingMessageWindow(messages) {
let messageIds = Object.keys(messages);
for(let i = 0; i < messageIds.length; i++) {
const messageId = messageIds[i];
const message = messages[messageId];
if(message.entries.length < 2) {
continue;
}
const lastEntryTime = message.entries[message.entries.length - 1].relTime;
const entrySpan = lastEntryTime - message.entries[0].relTime;
if(entrySpan > STREAMING_WINDOW) {
const newEntryFloor = this.firstEntryIndexInsideStreamingWindow(message.entries);
message.entries = message.entries.slice(newEntryFloor);
messages[messageId] = message;
}
}
return messages;
}
_onStreamedCanMessagesProcessed(data) {
let {newMessages, seekTime, lastBusTime, firstCanTime, byteStateChangeCountsByMessage, maxByteStateChangeCount} = data;
if(maxByteStateChangeCount < this.state.maxByteStateChangeCount) {
maxByteStateChangeCount = this.state.maxByteStateChangeCount;
}
let messages = this.addAndRehydrateMessages(newMessages);
messages = this.enforceStreamingMessageWindow(messages);
let {seekIndex, selectedMessages} = this.state;
if(selectedMessages.length > 0) {
seekIndex = messages[selectedMessages[0]].entries.length - 1;
}
this.setState({messages, seekTime, seekIndex, lastBusTime, firstCanTime, maxByteStateChangeCount});
}
onStreamedCanMessagesProcessed(e) {
this._onStreamedCanMessagesProcessed(e.data);
}
handlePandaConnect(e) {
this.setState({ attemptingPandaConnection: true, live: true });
const persistedDbc = fetchPersistedDbc('live');
if(persistedDbc) {
const {dbc, dbcText} = persistedDbc;
this.setState({dbc, dbcText});
}
this.canStreamerWorker = new CanStreamerWorker();
this.canStreamerWorker.onmessage = this.onStreamedCanMessagesProcessed;
this.pandaReader.setOnMessagesReceivedCallback(this.processStreamedCanMessages);
this.pandaReader.connect().then(() => {
this.pandaReader.readLoop();
this.setState({ attemptingPandaConnection: false });
this.setState({ showOnboarding: false });
this.setState({ showLoadDbc: true });
}).catch((err) => {
if (err.code === PandaReader.ERROR_NO_DEVICE_SELECTED) {
this.setState({ attemptingPandaConnection: false });
}
else { console.log(err) }
});
}
render() {
return (
<div id='cabana' className={ cx({ 'is-showing-modal': this.showingModal() }) }>
@ -414,7 +585,7 @@ export default class CanExplorer extends Component {
</div>
</div>
<div className='cabana-window'>
<Meta url={this.state.route.url}
<Meta url={this.state.route ? this.state.route.url : null}
messages={this.state.messages}
selectedMessages={this.state.selectedMessages}
updateSelectedMessages={this.updateSelectedMessages}
@ -430,12 +601,15 @@ export default class CanExplorer extends Component {
name={this.props.name}
route={this.state.route}
seekTime={this.state.seekTime}
seekIndex={this.state.seekIndex}
maxByteStateChangeCount={this.state.maxByteStateChangeCount}
isDemo={this.props.isDemo}
live={this.state.live}
/>
{this.state.route.url ?
{this.state.route || this.state.live ?
<Explorer
url={this.state.route.url}
url={this.state.route ? this.state.route.url : null}
live={this.state.live}
messages={this.state.messages}
selectedMessage={this.state.selectedMessage}
onConfirmedSignalChange={this.onConfirmedSignalChange}
@ -451,11 +625,17 @@ export default class CanExplorer extends Component {
showEditMessageModal={this.showEditMessageModal}
onPartChange={this.onPartChange}
route={this.state.route}
partsCount={this.state.route.proclog || 0}
partsCount={this.state.route ? this.state.route.proclog : 0}
/>
: null}
</div>
{ this.state.showOnboarding ?
<OnboardingModal
handlePandaConnect={ this.handlePandaConnect }
attemptingPandaConnection= { this.state.attemptingPandaConnection }
/> : null }
{this.state.showLoadDbc ?
<LoadDbcModal
onDbcSelected={this.onDbcSelected}
@ -481,6 +661,7 @@ export default class CanExplorer extends Component {
handleSave={this.onMessageFrameEdited}
message={this.state.messages[this.state.editMessageModalMessage]}
/> : null}
</div>
);
}

View File

@ -0,0 +1,23 @@
import Panda from '../../api/panda';
function arrayBufferFromHex(hex) {
const buffer = Buffer.from(hex, 'hex');
const arrayBuffer = new ArrayBuffer(buffer.length);
const view = new Uint8Array(arrayBuffer);
for(let i = 0; i < buffer.length; i++) {
view[i] = buffer[i];
}
return arrayBuffer;
}
test('parseCanBuffer correctly parses a message', () => {
const panda = new Panda();
// 16 byte buffer
const arrayBuffer = arrayBufferFromHex('abababababababababababababababab');
const messages = panda.parseCanBuffer(arrayBuffer);
expect(messages.length).toEqual(1)
expect(messages[0]).toEqual([1373, 43947, 'abababababababab', 10]);
});

View File

@ -0,0 +1,62 @@
global.__JEST__ = 1
import DbcUtils from '../../utils/dbc';
import DBC from '../../models/can/dbc';
import Signal from '../../models/can/signal';
// want to mock pandareader and test processStreamedCanMessages
const SAMPLE_MESSAGE = [0x10, 0, Buffer.from('abababababababab', 'hex'), 1];
const SAMPLE_MESSAGE_ID = '1:10';
function expectSampleMessageFieldsPreserved(messages, frame) {
const [address, busTime, data, source] = SAMPLE_MESSAGE;
expect(messages[SAMPLE_MESSAGE_ID].address).toEqual(address);
expect(messages[SAMPLE_MESSAGE_ID].id).toEqual(SAMPLE_MESSAGE_ID);
expect(messages[SAMPLE_MESSAGE_ID].bus).toEqual(source);
expect(messages[SAMPLE_MESSAGE_ID].frame).toEqual(frame);
expect(messages[SAMPLE_MESSAGE_ID].byteStateChangeCounts).toEqual(Array(8).fill(0));
}
function addMessages(messages, message, dbc, n) {
const firstCanTime = 0;
message = [...message];
let nextMessage = () => {message[1] = message[1] + 1; return message};
for(let i = 0; i < n; i++) {
DbcUtils.addCanMessage(nextMessage(), dbc, firstCanTime, messages);
}
}
test('addCanMessage should add raw can message with empty dbc', () => {
const messages = {};
addMessages(messages, SAMPLE_MESSAGE, new DBC(), 1);
expect(messages[SAMPLE_MESSAGE_ID].entries.length).toEqual(1);
expectSampleMessageFieldsPreserved(messages);
});
test('addCanMessage should add multiple raw can messages with empty dbc', () => {
const messages = {};
addMessages(messages, SAMPLE_MESSAGE, new DBC(), 3);
expect(messages[SAMPLE_MESSAGE_ID].entries.length).toEqual(3);
expectSampleMessageFieldsPreserved(messages);
});
test('addCanMessage should add parsed can message with dbc containing message spec', () => {
const messages = {};
// create dbc with message spec and signal for sample_message
const dbc = new DBC();
dbc.createFrame(SAMPLE_MESSAGE[0]);
const signal = new Signal({name: 'NEW_SIGNAL', startBit: 0, size: 8});
dbc.addSignal(SAMPLE_MESSAGE[0], signal);
// add 1 sample_message
addMessages(messages, SAMPLE_MESSAGE, dbc, 1);
// verify message and parsed signal added
const sampleMessages = messages[SAMPLE_MESSAGE_ID];
expect(sampleMessages.entries.length).toEqual(1);
expect(sampleMessages.entries[0].signals[signal.name]).toEqual(0xab);
expectSampleMessageFieldsPreserved(messages, dbc.messages.get(SAMPLE_MESSAGE[0]));
});

View File

@ -6,7 +6,7 @@ export function fetchPersistedDbc(routeName) {
const {dbcFilename, dbcText} = JSON.parse(maybeDbc);
const dbc = new DBC(dbcText);
return {dbc, dbcFilename};
return {dbc, dbcText, dbcFilename};
} else return null;
}

View File

@ -0,0 +1,66 @@
import Panda from './panda';
export default class PandaReader {
static ERROR_NO_DEVICE_SELECTED = 8;
constructor() {
this.panda = new Panda();
this.isReading = false;
this.onMessagesReceived = () => {};
this.callbackQueue = [];
this.callbackQueueTimer = null;
this.readLoop = this.readLoop.bind(this);
this.flushCallbackQueue = this.flushCallbackQueue.bind(this);
this._flushCallbackQueue = this._flushCallbackQueue.bind(this);
}
connect() {
return this.panda.connect();
}
setOnMessagesReceivedCallback(callback) {
this.onMessagesReceived = callback;
}
stopReadLoop() {
this.isReading = false;
window.cancelAnimationFrame(this.callbackQueueTimer);
}
_flushCallbackQueue() {
const messages = this.callbackQueue.reduce((arr, messages) => arr.concat(messages), [])
this.onMessagesReceived(messages);
this.callbackQueue = [];
}
flushCallbackQueue() {
if(this.callbackQueue.length > 0) {
this._flushCallbackQueue();
}
this.callbackQueueTimer = window.requestAnimationFrame(this.flushCallbackQueue);
}
readLoop() {
if(!this.isReading) {
this.isReading = true;
// this.flushCallbackQueueTimer = wi
this.callbackQueueTimer = window.requestAnimationFrame(this.flushCallbackQueue, 30);
}
this.panda.canRecv().then(messages => {
if(this.isReading && messages.canMessages.length > 0) {
this.callbackQueue.push(messages);
}
this.readLoop();
}, error => {
if(this.isReading) {
console.log('canRecv error', error);
this.readLoop();
}
});
}
}

79
src/api/panda.js 100644
View File

@ -0,0 +1,79 @@
import CloudLog from '../logging/CloudLog';
require('core-js/fn/string/pad-end');
const PANDA_VENDOR_ID = 0xbbaa;
const PANDA_PRODUCT_ID = 0xddcc;
const PANDA_ENDPOINT_IN = 1;
const PANDA_BUS_SPEED = 500000.0;
const CAN_EXTENDED = 4;
const BUFFER_SIZE = 0x10 * 256;
export default class Panda {
constructor() {
this.device = null;
}
connect() {
// Must be called via a mouse click handler, per Chrome restrictions.
return navigator.usb.requestDevice({ filters: [{ vendorId: PANDA_VENDOR_ID, productId: PANDA_PRODUCT_ID }] })
.then(device => {
this.device = device;
return device.open();
})
.then(() => this.device.selectConfiguration(1))
.then(() => this.device.claimInterface(0));
}
async health() {
const controlParams = {requestType: 'vendor',
recipient: 'device',
request: 0xd2,
value: 0,
index: 0};
try {
const result = await this.device.controlTransferIn(controlParams, 13);
} catch(err) {
CloudLog.error({event: 'Panda.health failed', 'error': err});
}
}
parseCanBuffer(buffer) {
const messages = [];
for(let i = 0; i < buffer.byteLength; i+=0x10) {
const dat = buffer.slice(i, i + 0x10);
const datView = Buffer.from(dat);
const f1 = datView.readInt32LE(0), f2 = datView.readInt32LE(4);
let address;
if(f2 & CAN_EXTENDED) {
address = f1 >>> 3;
} else {
address = f1 >>> 21;
}
const busTime = (f2 >>> 16);
const data = new Buffer(dat.slice(8, 8 + (f2 & 0xF)));
const source = ((f2 >> 4) & 0xF) & 0xFF;
messages.push([address, busTime, data.toString('hex').padEnd(16, '0'), source]);
}
return messages;
}
async canRecv() {
let result = null, receiptTime = null;
while(result === null) {
try {
result = await this.device.transferIn(1, BUFFER_SIZE);
receiptTime = performance.now() / 1000;
} catch(err) {
console.warn('can_recv failed, retrying');
}
}
return {time: receiptTime, canMessages: this.parseCanBuffer(result.data.buffer)};
}
}

View File

@ -31,8 +31,8 @@ export default class AddSignals extends Component {
super(props);
let signals = {};
if(props.message && props.message.signals) {
Object.assign(signals, props.message.signals);
if(props.message && props.message.frame && props.message.frame.signals) {
signals = this.copySignals(props.message.frame.signals);
}
this.state = {
@ -56,6 +56,20 @@ export default class AddSignals extends Component {
this.resetDragState = this.resetDragState.bind(this);
}
copySignals(signals) {
return Object.entries(signals).reduce((signalsCopy, [signalName, signal]) => {
signalsCopy[signalName] = Object.assign(Object.create(signal), signal);
return signalsCopy;
}, {});
}
shouldComponentUpdate(nextProps, nextState) {
return nextProps.message.hexData !== this.props.message.hexData
|| nextProps.messageIndex !== this.props.messageIndex
|| JSON.stringify(nextProps.plottedSignals) !== JSON.stringify(this.props.plottedSignals)
|| JSON.stringify(this.state) !== JSON.stringify(nextState);
}
signalColorStyle(signal) {
const colors = signal.colors();
@ -94,13 +108,11 @@ export default class AddSignals extends Component {
}
componentWillReceiveProps({message}) {
const isNewMessage = message.address != this.props.message.address;
const isNewMessage = message.address !== this.props.message.address;
if(isNewMessage) {
const signalStyles = this.updateSignalStyles(message.signals);
const signals = {};
Object.assign(signals, message.signals);
this.setState({signals}, this.updateSignalStyles);
const signals = (message.frame ? message.frame.signals : {});
const signalStyles = this.updateSignalStyles(signals);
this.setState({signals: this.copySignals(signals)}, this.updateSignalStyles);
}
}
@ -446,12 +458,8 @@ export default class AddSignals extends Component {
propagateUpSignalChange() {
const {signals} = this.state;
const newMessage = {};
Object.assign(newMessage, this.props.message);
newMessage.signals = signals;
this.props.onConfirmedSignalChange(newMessage);
this.props.onConfirmedSignalChange(this.props.message, this.copySignals(signals));
}
onSignalPlotChange(shouldPlot, signalName) {

View File

@ -1,6 +1,7 @@
import React, {Component} from 'react';
import Measure from 'react-measure';
import Vega from 'react-vega';
import * as vega from 'vega';
import PropTypes from 'prop-types';
import cx from 'classnames';
@ -42,6 +43,7 @@ export default class CanGraph extends Component {
shiftX: 0,
shiftY: 0,
bounds: null,
initialData: props.data,
};
this.onNewView = this.onNewView.bind(this);
this.onSignalClickTime = this.onSignalClickTime.bind(this);
@ -93,7 +95,7 @@ export default class CanGraph extends Component {
segmentChanged = true;
}
if(nextProps.currentTime !== this.props.currentTime) {
if(!nextProps.live && nextProps.currentTime !== this.props.currentTime) {
this.view.signal('videoTime', nextProps.currentTime);
segmentChanged = true;
}
@ -104,9 +106,7 @@ export default class CanGraph extends Component {
}
const dataChanged = this.dataChanged(this.props, nextProps);
if(dataChanged) {
this.view.run();
}
return dataChanged
|| JSON.stringify(this.state) !== JSON.stringify(nextState)
|| this.visualChanged(this.props, nextProps);
@ -114,7 +114,8 @@ export default class CanGraph extends Component {
componentDidUpdate(prevProps, prevState) {
if(this.dataChanged(prevProps, this.props)) {
this.view.run();
this.view.remove('table', () => true).run();
this.view.insert('table', this.props.data).run();
}
}
@ -211,7 +212,7 @@ export default class CanGraph extends Component {
<span className='fa fa-bars'></span>
</div>
{this.props.plottedSignals.map(({ messageId, signalName, messageName }) => {
const color = this.props.messages[messageId].signals[signalName].colors();
const color = this.props.messages[messageId].frame.signals[signalName].colors();
return (
<div className='cabana-explorer-visuals-plot-header'
@ -243,10 +244,11 @@ export default class CanGraph extends Component {
className='cabana-explorer-visuals-plot-container'>
<CanPlot
logLevel={0}
data={{table: this.props.data}}
data={{table: this.state.initialData}}
onNewView={this.onNewView}
onSignalClickTime={this.onSignalClickTime}
onSignalSegment={this.onSignalSegment}
renderer={'canvas'}
/>
</div>);
}

View File

@ -124,7 +124,7 @@ export default class CanGraphList extends Component {
messages={this.props.messages}
messageId={messageId}
messageName={msg.frame ? msg.frame.name : null}
signalSpec={Object.assign(Object.create(msg.signals[signalName]), msg.signals[signalName])}
signalSpec={Object.assign(Object.create(msg.frame.signals[signalName]), msg.frame.signals[signalName])}
onSegmentChanged={this.props.onSegmentChanged}
segment={this.props.segment}
data={this.props.graphData[index]}
@ -136,6 +136,7 @@ export default class CanGraphList extends Component {
dragPos={isDragging ? this.state.dragPos : null}
canReceiveGraphDrop={canReceiveGraphDrop}
plottedSignals={plottedSignals}
live={this.props.live}
/>
);
}

View File

@ -52,7 +52,8 @@ export default class CanLog extends Component {
const curMessageLength = this.props.message ? this.props.message.entries.length : 0;
const nextMessageLength = nextProps.message ? nextProps.message.entries.length : 0;
const shouldUpdate = nextMessageLength != curMessageLength
const shouldUpdate = this.props.message !== nextProps.message
|| nextMessageLength != curMessageLength
|| nextProps.messageIndex != this.props.messageIndex
|| nextProps.plottedSignals.length != this.props.plottedSignals.length
|| JSON.stringify(nextProps.segmentIndices) != JSON.stringify(this.props.segmentIndices)
@ -60,13 +61,11 @@ export default class CanLog extends Component {
|| this.props.message != nextProps.message
|| (this.props.message !== undefined
&& nextProps.message !== undefined
&& this.props.message.frame !== undefined
&& nextProps.message.frame !== undefined
&&
(
(this.props.message.signals
&& nextProps.message.signals
&& JSON.stringify(this.props.message.signals) != JSON.stringify(nextProps.message.signals))
||
(JSON.stringify(this.props.message.frame) != JSON.stringify(nextProps.message.frame))
(JSON.stringify(this.props.message.frame) !== JSON.stringify(nextProps.message.frame))
));
return shouldUpdate;
@ -115,7 +114,7 @@ export default class CanLog extends Component {
toggleExpandPacketSignals(msg) {
const msgIsExpanded = this.state.allPacketsExpanded || this.isMessageExpanded(msg);
const msgHasSignals = Object.keys(msg.signals).length > 0;
const msgHasSignals = Object.keys(this.props.message.frame.signals).length > 0;
if (msgIsExpanded && msgHasSignals) {
this.setState({expandedMessages: this.state.expandedMessages
.filter((expMsgTime) => expMsgTime !== msg.time)})
@ -132,7 +131,6 @@ export default class CanLog extends Component {
{ Object.entries(msg.signals).map(([name, value]) => {
return [name, value, this.isSignalPlotted(message.id, name)]
}).map(([name, value, isPlotted]) => {
const signalValue = msg.signals[name];
const plottedButtonClass = isPlotted ? null : 'button--alpha';
const plottedButtonText = isPlotted ? 'Hide Plot' : 'Show Plot';
@ -146,11 +144,11 @@ export default class CanLog extends Component {
</div>
<div className='signals-log-list-signal-value'>
<span>
(<strong>{ this.signalValuePretty(signalValue, value) }</strong> { unit })
(<strong>{ this.signalValuePretty(signal, value) }</strong> { unit })
</span>
</div>
<div className='signals-log-list-signal-action'
onClick={ () => { this.toggleSignalPlot(this.props.message.id, name, isPlotted) } }>
onClick={ () => { this.toggleSignalPlot(message.id, name, isPlotted) } }>
<button className={ cx('button--tiny', plottedButtonClass) }>
<span>{ plottedButtonText }</span>
</button>
@ -163,6 +161,7 @@ export default class CanLog extends Component {
}
renderLogListItemMessage(msg, key) {
const { message } = this.props;
const msgIsExpanded = this.state.allPacketsExpanded || this.isMessageExpanded(msg);
const msgHasSignals = Object.keys(msg.signals).length > 0;
const hasSignalsClass = msgHasSignals ? 'has-signals' : null;
@ -172,7 +171,7 @@ export default class CanLog extends Component {
<div className='signals-log-list-item-header'
onClick={ () => { this.toggleExpandPacketSignals(msg) } }>
<div className='signals-log-list-message'>
<strong>{(this.props.message.frame ? this.props.message.frame.name : null) || this.props.message.id}</strong>
<strong>{(message.frame ? message.frame.name : null) || message.id}</strong>
</div>
<div className='signals-log-list-time'>
<span>[{msg.relTime.toFixed(3)}]</span>

View File

@ -25,6 +25,11 @@
opacity: 1;
pointer-events: auto;
}
.cabana-modal.cabana-modal--not-closable {
.cabana-modal-backdrop {
pointer-events: none;
}
}
}
}

View File

@ -71,7 +71,8 @@ export default class GithubDbcList extends Component {
.map((path) => {
return (
<div className={ cx('cabana-dbc-list-file', {'is-selected': this.state.selectedPath === path})}
onClick={ () => { this.selectPath(path) } }>
onClick={ () => { this.selectPath(path) } }
key={path}>
<span>{ path }</span>
</div>
)

View File

@ -1,90 +1,96 @@
import React, {Component} from 'react';
import { StyleSheet, css } from 'aphrodite/no-important';
import PropTypes from 'prop-types';
import VisibilitySensor from 'react-visibility-sensor';
export default class MessageBytes extends Component {
static propTypes = {
seekTime: PropTypes.number.isRequired,
message: PropTypes.object.isRequired,
maxByteStateChangeCount: PropTypes.number.isRequired
seekIndex: PropTypes.number,
maxByteStateChangeCount: PropTypes.number.isRequired,
live: PropTypes.bool.isRequired,
};
constructor(props) {
super(props);
this.state = {
byteColors: []
byteColors: [],
isVisible: true,
lastUpdatedMillis: 0,
};
this.onVisibilityChange = this.onVisibilityChange.bind(this);
this.onCanvasRefAvailable = this.onCanvasRefAvailable.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
if(nextProps.live) {
const nextLastEntry = nextProps.message.entries[nextProps.message.entries.length - 1];
const curLastEntry = this.props.message.entries[this.props.message.entries.length - 1];
return (nextProps.hexData !== curLastEntry.hexData);
} else {
return nextProps.seekTime !== this.props.seekTime
}
}
componentWillReceiveProps(nextProps) {
const {message} = nextProps;
if(message && this.props.message && (message != this.props.message
|| JSON.stringify(message.byteStateChangeCounts) != JSON.stringify(this.props.message.byteStateChangeCounts))) {
this.updateByteColors(message);
}
this.updateCanvas(nextProps);
}
updateByteColors(message) {
const {maxByteStateChangeCount} = this.props;
updateCanvas(props) {
const {message, live, seekTime} = props;
if(!this.canvas || message.entries.length === 0) return;
const byteColors = message.byteStateChangeCounts.map((count) =>
Math.min(255, 75 + 180 * (count / maxByteStateChangeCount)) // TODO dynamic
).map((red) =>
'rgb(' + Math.round(red) + ',0,0)'
);
this.setState({byteColors});
}
componentWillMount() {
this.updateByteColors(this.props.message);
}
renderBytes() {
const {seekTime, message} = this.props;
const {byteColors} = this.state;
let mostRecentMsg = message.entries[message.entries.length - 1];
if(!live) {
let mostRecentMsgIndex = message.entries.findIndex((e) =>
e.relTime > seekTime) - 1;
mostRecentMsgIndex = Math.max(0, mostRecentMsgIndex);
const mostRecentMsg = message.entries[mostRecentMsgIndex];
mostRecentMsg = message.entries.find((e) => e.relTime >= seekTime);
const msgSize = message.frame ? message.frame.size : 8;
let byteOpacities;
if(mostRecentMsg.byteStateChangeTimes.every((time) => time == message.entries[0].relTime)) {
byteOpacities = Array(msgSize).fill(0.1);
} else {
byteOpacities = mostRecentMsg.byteStateChangeTimes.map((time) =>
Math.max(0.1, Math.min(1, 1.1 - ((seekTime - time))))
);
if(!mostRecentMsg) {
mostRecentMsg = message.entries[0];
}
}
const bytes = byteOpacities.map((opacity, idx) =>
<div key={idx} className={css(Styles.byte)}
style={{opacity, backgroundColor: byteColors[idx]}}>{mostRecentMsg.hexData.substr(idx * 2, 2)}</div>
);
const ctx = this.canvas.getContext('2d');
ctx.clearRect(0,0,180,15);
for(let i = 0; i < message.byteStateChangeCounts.length; i++) {
const hexData = mostRecentMsg.hexData.substr(i * 2, 2);
ctx.fillStyle = mostRecentMsg.byteStyles[i].backgroundColor;
return bytes;
ctx.fillRect(i * 20, 0, 20, 15);
ctx.font = '12px Courier';
ctx.fillStyle = 'white';
ctx.fillText(hexData, i * 20 + 2, 12);
}
}
onVisibilityChange(isVisible) {
if(isVisible !== this.state.isVisible) {
this.setState({isVisible});
}
}
onCanvasRefAvailable(ref) {
if(!ref) return;
this.canvas = ref;
this.canvas.width = 160 * window.devicePixelRatio;
this.canvas.height = 15 * window.devicePixelRatio;
const ctx = this.canvas.getContext('2d');
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
}
render() {
const {message} = this.props;
let bytes = null;
if(message.entries.length > 0) {
bytes = this.renderBytes();
}
return <div className={css(Styles.bytes)}>{bytes}</div>
return (<canvas ref={this.onCanvasRefAvailable}
className='cabana-meta-messages-list-item-bytes-canvas'></canvas>);
}
}
const Styles = StyleSheet.create({
bytes: {
display: 'flex',
flexDirection: 'row'
},
byte: {
width: 20,
height: 15,

View File

@ -76,6 +76,13 @@
cursor: pointer;
position: relative;
transition-duration: .1s;
&-bytes {
width: 160px;
&-canvas {
width: 160px;
height: 15px;
}
}
&:not(.is-selected):hover {
background: rgba(0,0,0,.05);
}

View File

@ -0,0 +1,155 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import Modal from '../Modals/baseModal';
export default class OnboardingModal extends Component {
static propTypes = {
handlePandaConnect: PropTypes.func,
attemptingPandaConnection: PropTypes.bool
};
static instructionalImages = {
step2: require("../../images/webusb-enable-experimental-features.png"),
step3: require("../../images/webusb-enable-webusb.png"),
}
constructor(props) {
super(props);
this.state = {
webUsbEnabled: !!navigator.usb,
viewingUsbInstructions: false,
pandaConnected: false,
authenticatedUser: {},
chffrDrives: [],
}
this.attemptPandaConnection = this.attemptPandaConnection.bind(this);
this.toggleUsbInstructions = this.toggleUsbInstructions.bind(this);
}
attemptPandaConnection() {
this.props.handlePandaConnect();
}
toggleUsbInstructions() {
this.setState({ viewingUsbInstructions: !this.state.viewingUsbInstructions });
}
navigateToDrivingExplorer() {
window.location.href = 'https://community.comma.ai/explorer.php';
}
renderPandaEligibility() {
const { webUsbEnabled, pandaConnected } = this.state;
const { attemptingPandaConnection } = this.props;
if (!webUsbEnabled) {
return (
<p>
<i className='fa fa-exclamation-triangle'></i>
<a onClick={ this.toggleUsbInstructions }>
<span>WebUSB is not enabled in your Chrome settings</span>
</a>
</p>
)
}
else if (!pandaConnected && attemptingPandaConnection) {
return (
<p>
<i className='fa fa-spinner animate-spin'></i>
<span className='animate-pulse-opacity'>Waiting for panda USB connection</span>
</p>
)
}
}
renderOnboardingOptions() {
return (
<div className='cabana-onboarding-modes'>
<div className='cabana-onboarding-mode'>
<button onClick={ this.navigateToDrivingExplorer }
className='button--primary button--kiosk'>
<i className='fa fa-video-camera'></i>
<strong>Load Drive From chffr</strong>
<sup>Click <em>[cabana]</em> from a drive in your driving explorer</sup>
</button>
</div>
<div className='cabana-onboarding-mode'>
<button className='button--secondary button--kiosk'
onClick={ this.attemptPandaConnection }
disabled={ !this.state.webUsbEnabled || this.props.attemptingPandaConnection }>
<i className='fa fa-bolt'></i>
<strong>Launch Realtime Streaming</strong>
<sup>Interactively stream car data over USB with <em>panda</em></sup>
{ this.renderPandaEligibility() }
</button>
</div>
</div>
)
}
renderUsbInstructions() {
return (
<div className='cabana-onboarding-instructions'>
<button className='button--small button--inverted' onClick={ this.toggleUsbInstructions }>
<i className='fa fa-chevron-left'></i>
<span> Go back</span>
</button>
<h3>Follow these directions to enable WebUSB:</h3>
<ol className='cabana-onboarding-instructions-list list--bubbled'>
<li>
<p><strong>Open your Chrome settings:</strong></p>
<div className='inset'>
<span>chrome://flags/#enable-experimental-web-platform-features</span>
</div>
</li>
<li>
<p><strong>Enable Expirimental Platform features:</strong></p>
<img src={ OnboardingModal.instructionalImages.step2 } />
</li>
<li>
<p><strong>Enable WebUSB:</strong></p>
<img src={ OnboardingModal.instructionalImages.step3 } />
</li>
<li>
<p><strong>Relaunch your Chrome browser and try enabling live mode again.</strong></p>
</li>
</ol>
</div>
)
}
renderModalContent() {
if (this.state.viewingUsbInstructions) {
return this.renderUsbInstructions();
}
else {
return this.renderOnboardingOptions();
}
}
renderModalFooter() {
return (
<p>
<span>Don{"\'"}t have a <a href='https://panda.comma.ai' target='_blank'>panda</a>? </span>
<span><a href='https://panda.comma.ai' target='_blank'>Get one here</a> </span>
<span>or <a href='https://community.comma.ai/cabana/?demo=1'>try the demo</a>.</span>
</p>
)
}
render() {
return (
<Modal
title='Welcome to cabana'
subtitle='Get started by viewing your chffr drives or enabling live mode'
footer={ this.renderModalFooter() }
disableClose={ true }
variations={['wide', 'dark']}>
{ this.renderModalContent() }
</Modal>
);
}
}

View File

@ -1,39 +1,95 @@
import React, { Component, PropTypes } from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import Measure from 'react-measure';
export default class Modal extends Component {
static PropTypes = {
variations: PropTypes.array,
disableClose: PropTypes.bool,
handleClose: PropTypes.func,
handleSave: PropTypes.func,
title: PropTypes.string,
subtitle: PropTypes.string,
navigation: PropTypes.node,
actions: PropTypes.node,
footer: PropTypes.node,
}
constructor(props) {
super(props);
this.state = {
windowHeight: {},
modalHeight: {},
}
this.updateHeights = this.updateHeights.bind(this);
}
updateHeights(contentRect) {
this.setState({ windowHeight: window.innerHeight });
this.setState({ modalHeight: contentRect.bounds.height });
}
readVariationClasses() {
if (this.props.variations) {
const { variations } = this.props;
let classes = '';
variations.map((variation) => {
classes = classes += `cabana-modal--${ variation } `;
})
return classes;
}
}
checkClosability() {
return (this.props.disableClose || false);
}
checkYScrollability() {
return (this.state.modalHeight > this.state.windowHeight);
}
render() {
return (
<div className='cabana-modal'>
<div className='cabana-modal-container'>
<div className='cabana-modal-close-icon'
onClick={ this.props.handleClose }></div>
<div className='cabana-modal-header'>
<h1>{ this.props.title }</h1>
<p>{ this.props.subtitle }</p>
<div className={ cx(
'cabana-modal',
this.readVariationClasses(), {
'cabana-modal--not-closable': this.checkClosability(),
'cabana-modal--scrollable-y': this.checkYScrollability(),
}) }>
<Measure
bounds
onResize={(contentRect) => {
this.updateHeights(contentRect);
}}>
{({ measureRef }) =>
<div ref={ measureRef } className='cabana-modal-container'>
<div className='cabana-modal-close-icon'
onClick={ this.props.handleClose }></div>
<div className='cabana-modal-header'>
<h1>{ this.props.title }</h1>
<p>{ this.props.subtitle }</p>
</div>
<div className='cabana-modal-navigation'>
{ this.props.navigation }
</div>
<div className='cabana-modal-body'>
<div className='cabana-modal-body-window'>
{ this.props.children }
</div>
<div className='cabana-modal-body-gradient'></div>
</div>
<div className='cabana-modal-actions'>
{ this.props.actions }
</div>
<div className='cabana-modal-footer'>
{ this.props.footer }
</div>
</div>
<div className='cabana-modal-navigation'>
{ this.props.navigation }
</div>
<div className='cabana-modal-body'>
{ this.props.children }
</div>
<div className='cabana-modal-actions'>
{ this.props.actions }
</div>
</div>
}
</Measure>
<div className='cabana-modal-backdrop'
onClick={ this.props.handleClose }>
</div>

View File

@ -7,6 +7,8 @@
@import '../../styles/_global/all';
$cabana-modal-padding--wide: 76px;
.cabana-modal {
&-backdrop {
background: rgba(233,233,233,.8);
@ -32,7 +34,7 @@
position: absolute;
top: 50%;
width: 600px;
transition: ease-in-out .2s;
// transition: ease-in-out .2s;
z-index: 101;
}
&-header {
@ -73,11 +75,14 @@
border-style: solid;
border-width: 1px 0;
padding: 34px 50px;
position: relative;
}
&-actions {
text-align: right;
padding: 22px 50px;
width: 100%;
&:not(:empty) {
padding: 22px 50px;
}
button {
display: inline-block;
&:not(:last-child) {
@ -85,13 +90,56 @@
}
}
}
&-footer {
background: $color-grey-20;
color: $color-grey-70;
font-size: 14px;
line-height: 70px;
text-align: center;
a {
color: inherit;
font-weight: 600;
&:hover {
text-decoration: underline;
}
}
}
// Functional Variations
&.cabana-modal--not-closable {
.cabana-modal-backdrop {
pointer-events: none;
}
.cabana-modal-close-icon {
display: none;
}
}
&.cabana-modal--scrollable-y {
.cabana-modal-container {
transform: translate(-50%, 0);
top: 5%;
margin-bottom: 5%;
}
}
// Size Variations
&.cabana-modal--small {
width: 480px;
}
&.cabana-modal--large {
width: 800px;
&.cabana-modal--wide {
.cabana-modal-container {
width: 820px;
}
.cabana-modal-header,
.cabana-modal-body {
padding-left: $cabana-modal-padding--wide;
padding-right: $cabana-modal-padding--wide;
}
}
// Color variations
&.cabana-modal--dark {
.cabana-modal-backdrop {
background: rgba(0,0,0,.85);
}
}
// Overrides
.cabana-tabs-navigation {
padding: 0 44px;

View File

@ -43,6 +43,7 @@ export default class RouteSeeker extends Component {
this.onClick = this.onClick.bind(this);
this.onPlay = this.onPlay.bind(this);
this.onPause = this.onPause.bind(this);
this.executePlayTimer = this.executePlayTimer.bind(this);
}
componentWillReceiveProps(nextProps) {
@ -73,7 +74,7 @@ export default class RouteSeeker extends Component {
}
componentWillUnmount() {
window.clearInterval(this.playTimer);
window.cancelAnimationFrame(this.playTimer);
}
mouseEventXOffsetPercent(e) {
@ -137,28 +138,9 @@ export default class RouteSeeker extends Component {
this.props.onUserSeek(ratio);
}
onPlay() {
window.clearInterval(this.playTimer);
this.playTimer = window.setInterval(() => {
const {videoElement} = this.props;
if(videoElement === null) return;
const {currentTime} = videoElement;
let newRatio = this.props.segmentProgress(currentTime);
if(newRatio === this.state.ratio) {
return;
}
if(newRatio >= 1) {
newRatio = 0;
this.props.onUserSeek(newRatio);
}
if(newRatio >= 0) {
this.updateSeekedBar(newRatio);
this.props.onPlaySeek(currentTime);
}
}, 30);
this.playTimer = window.requestAnimationFrame(this.executePlayTimer);
let {ratio} = this.state;
if(ratio >= 1) {
ratio = 0;
@ -167,8 +149,36 @@ export default class RouteSeeker extends Component {
this.props.onPlay();
}
executePlayTimer() {
const {videoElement} = this.props;
if(videoElement === null) {
this.playTimer = window.requestAnimationFrame(this.executePlayTimer);
return;
}
const {currentTime} = videoElement;
let newRatio = this.props.segmentProgress(currentTime);
if(newRatio === this.state.ratio) {
this.playTimer = window.requestAnimationFrame(this.executePlayTimer);
return;
}
if(newRatio >= 1) {
newRatio = 0;
this.props.onUserSeek(newRatio);
}
if(newRatio >= 0) {
this.updateSeekedBar(newRatio);
this.props.onPlaySeek(currentTime);
}
this.playTimer = window.requestAnimationFrame(this.executePlayTimer);
}
onPause() {
window.clearInterval(this.playTimer);
window.cancelAnimationFrame(this.playTimer);
this.setState({isPlaying: false});
this.props.onPause();
}

View File

@ -330,7 +330,8 @@ export default class SignalLegendEntry extends Component {
<div className='signals-legend-entry-form'>
{SignalLegendEntry.fields.map((field) => {
return (
<div className='signals-legend-entry-form-field'>
<div className='signals-legend-entry-form-field'
key={field.field}>
{this.renderFieldNode(field, signal.name)}
</div>
)

View File

@ -13,6 +13,7 @@ import CanLog from './CanLog';
import RouteSeeker from './RouteSeeker';
import Entries from '../models/can/entries';
import debounce from '../utils/debounce';
import ArrayUtils from '../utils/array';
import CommonStyles from '../styles/styles';
import Images from '../styles/images';
import PartSelector from './PartSelector';
@ -22,6 +23,7 @@ export default class Explorer extends Component {
static propTypes = {
selectedMessage: PropTypes.string,
url: PropTypes.string,
live: PropTypes.bool,
messages: PropTypes.objectOf(PropTypes.object),
onConfirmedSignalChange: PropTypes.func,
canFrameOffset: PropTypes.number,
@ -38,7 +40,7 @@ export default class Explorer extends Component {
const msg = props.messages[props.selectedMessage];
const ShowAddSignal = (
msg && Object.keys(msg.signals).length === 0);
msg && Object.keys(msg.frame.signals).length === 0);
this.state = {
plottedSignals: [],
@ -85,9 +87,10 @@ export default class Explorer extends Component {
componentDidUpdate(prevProps, prevState) {
if(this.props.selectedMessage === prevProps.selectedMessage
&& this.props.messages[this.props.selectedMessage]
&& prevProps.messages[prevProps.selectedMessage]) {
const nextSignalNames = Object.keys(this.props.messages[this.props.selectedMessage].signals);
const currentSignalNames = Object.keys(prevProps.messages[prevProps.selectedMessage].signals);
&& prevProps.messages[prevProps.selectedMessage]
&& this.props.messages[this.props.selectedMessage].frame !== undefined) {
const nextSignalNames = Object.keys(this.props.messages[this.props.selectedMessage].frame.signals);
const currentSignalNames = Object.keys(prevProps.messages[prevProps.selectedMessage].frame.signals);
const newSignalNames = nextSignalNames.filter((s) => currentSignalNames.indexOf(s) === -1);
for(let i = 0; i < newSignalNames.length; i++) {
@ -100,30 +103,27 @@ export default class Explorer extends Component {
componentWillReceiveProps(nextProps) {
const nextMessage = nextProps.messages[nextProps.selectedMessage];
const curMessage = this.props.messages[this.props.selectedMessage];
const {graphData} = this.state;
let {plottedSignals, graphData} = this.state;
if(Object.keys(nextProps.messages).length === 0) {
this.resetSegment();
}
if(nextMessage && nextMessage !== curMessage) {
const nextSignalNames = Object.keys(nextMessage.signals);
if(nextMessage && nextMessage.frame && nextMessage !== curMessage) {
const nextSignalNames = Object.keys(nextMessage.frame.signals);
// this.setState({signals: Object.assign({}, nextMessage.signals)});
if(nextSignalNames.length === 0) {
this.setState({shouldShowAddSignal: true});
}
}
let {plottedSignals} = this.state;
// unplot signals that have been removed
// remove plottedSignals that no longer exist
plottedSignals = plottedSignals.map((plot) => plot.filter(({messageId, signalName}, index) => {
const messageExists = Object.keys(nextProps.messages).indexOf(messageId) !== -1;
let signalExists = true;
if(!messageExists) {
graphData.splice(index, 1);
} else {
const signalNames = Object.keys(nextProps.messages[messageId].signals);
const signalNames = Object.keys(nextProps.messages[messageId].frame.signals);
signalExists = signalNames.indexOf(signalName) !== -1;
if(!signalExists) {
@ -133,7 +133,6 @@ export default class Explorer extends Component {
return messageExists && signalExists;
})).filter((plot) => plot.length > 0);
this.setState({plottedSignals, graphData});
if(nextProps.selectedMessage && nextProps.selectedMessage != this.props.selectedMessage) {
@ -177,10 +176,20 @@ export default class Explorer extends Component {
userSeekTime: nextSeekTime})
}
if(nextMessage && curMessage) {
// refresh graph data if the message entry lengths
// do not match
this.refreshGraphData(nextProps.messages, plottedSignals);
if(plottedSignals.length > 0) {
if(graphData.length === plottedSignals.length) {
if(plottedSignals.some((plot) =>
plot.some(({messageId, signalName}) =>
nextProps.messages[messageId].frame.signals[signalName] !== this.props.messages[messageId].frame.signals[signalName]
))) {
this.refreshGraphData(nextProps.messages, plottedSignals);
} else {
graphData = this.appendNewGraphData(plottedSignals, graphData, nextProps.messages);
this.setState({graphData});
}
} else {
this.refreshGraphData(nextProps.messages, plottedSignals);
}
}
if(JSON.stringify(nextProps.currentParts) !== JSON.stringify(this.props.currentParts)) {
@ -190,6 +199,92 @@ export default class Explorer extends Component {
}
}
appendNewGraphData(plottedSignals, graphData, messages) {
const messagesPerPlot = plottedSignals.map((plottedMessages) =>
plottedMessages.reduce((messages,
{messageId, signalName}) => {
messages.push(messageId);
return messages;
}, [])
);
const extendedPlots = messagesPerPlot
.map((plottedMessageIds, index) => {return {plottedMessageIds, index}}) // preserve index so we can look up graphData
.filter(({plottedMessageIds, index}) => {
let maxGraphTime = 0;
if(graphData.length > 0) {
maxGraphTime = graphData[index][graphData[index].length - 1].relTime;
}
return plottedMessageIds.some((messageId) =>
messages[messageId].entries.some((e) => e.relTime > maxGraphTime));
}).map(({plottedMessageIds, index}) => {
plottedMessageIds = plottedMessageIds.reduce((arr, messageId) => {
if(arr.indexOf(messageId) === -1) {
arr.push(messageId);
}
return arr;
}, []);
return {plottedMessageIds, index};
});
extendedPlots.forEach(({plottedMessageIds, index}) => {
const signalNamesByMessageId = plottedSignals[index].reduce((obj, {messageId, signalName}) => {
if(!obj[messageId]) {
obj[messageId] = []
}
obj[messageId].push(signalName);
return obj;
}, {});
const graphDataMaxMessageTimes = plottedMessageIds.reduce((obj, messageId) => {
const signalNames = signalNamesByMessageId[messageId];
const maxIndex = ArrayUtils.findIndexRight(graphData[index], (entry) => {
return signalNames.indexOf(entry.signalName) !== -1
});
if(maxIndex) {
obj[messageId] = graphData[index][maxIndex].relTime;
} else {
obj[messageId] = graphData[index][graphData[index].length - 1].relTime;
}
return obj;
}, {});
let newGraphData = [];
plottedMessageIds.map((messageId) => {
return { messageId, entries: messages[messageId].entries }
}).filter(({messageId, entries}) => // Filter to only messages with stale graphData
entries[entries.length - 1].relTime > graphDataMaxMessageTimes[messageId])
.forEach(({messageId, entries}) => { // Compute and append new graphData
let firstNewEntryIdx = entries.findIndex((entry) =>
entry.relTime > graphDataMaxMessageTimes[messageId]);
const newEntries = entries.slice(firstNewEntryIdx);
signalNamesByMessageId[messageId].forEach((signalName) => {
const signalGraphData = this._calcGraphData({...messages[messageId],
entries: newEntries}, signalName);
newGraphData = newGraphData.concat(signalGraphData);
});
});
const messageIdOutOfBounds = plottedMessageIds.find((messageId) =>
graphData[index][0].relTime < messages[messageId].entries[0].relTime);
graphData[index] = graphData[index].concat(newGraphData)
if(messageIdOutOfBounds) {
const graphDataLowerBound = graphData[index].findIndex(
(e) => e.relTime > messages[messageIdOutOfBounds].entries[0].relTime);
if(graphDataLowerBound) {
graphData[index] = graphData[index].slice(graphDataLowerBound);
}
}
});
return graphData;
}
timeWindow() {
const {route, currentParts} = this.props;
if(route) {
@ -219,30 +314,33 @@ export default class Explorer extends Component {
return samples.map((entry) => {
return {x: entry.time,
xRel: entry.time - this.props.firstCanTime,
relTime: entry.time - this.props.firstCanTime,
y: entry.signals[signalName],
unit: msg.signals[signalName].unit,
color: `rgba(${msg.signals[signalName].colors().join(",")}, 0.5)`,
unit: msg.frame.signals[signalName].unit,
color: `rgba(${msg.frame.signals[signalName].colors().join(",")}, 0.5)`,
signalName}
});
}
sortGraphData(graphData) {
return graphData.sort((entry1, entry2) => {
if(entry1.relTime < entry2.relTime) {
return -1;
} else if(entry1.relTime > entry2.relTime) {
return 1;
} else {
return 0;
}
});
}
calcGraphData(signals, messages) {
if(typeof messages === 'undefined') {
messages = this.props.messages;
}
return signals.map(({messageId, signalName}) => this._calcGraphData(messages[messageId], signalName))
.reduce((combined, signalData) => combined.concat(signalData), [])
.sort((entry1, entry2) => {
if(entry1.xRel < entry2.xRel) {
return -1;
} else if(entry1.xRel > entry2.xRel) {
return 1;
} else {
return 0;
}
});
return this.sortGraphData(signals.map(({messageId, signalName}) => this._calcGraphData(messages[messageId], signalName))
.reduce((combined, signalData) => combined.concat(signalData), []));
}
onSignalPlotPressed(messageId, signalName) {
@ -253,6 +351,7 @@ export default class Explorer extends Component {
graphData = [this.calcGraphData([{messageId, signalName}]), ...graphData];
plottedSignals = [[{messageId, signalName}], ...plottedSignals];
this.setState({plottedSignals, graphData});
// }
}
@ -265,6 +364,7 @@ export default class Explorer extends Component {
plottedSignals = this.state.plottedSignals;
}
let graphData = Array(plottedSignals.length);
plottedSignals.forEach((plotSignals, index) => {
const plotGraphData = this.calcGraphData(plotSignals, messages);
graphData[index] = plotGraphData;
@ -315,6 +415,8 @@ export default class Explorer extends Component {
// returns index guaranteed to be in [0, entries.length - 1]
const {entries} = this.props.messages[this.props.selectedMessage];
if(entries.length === 0) return null;
const {segmentIndices} = this.state;
let segmentLength, offset;
if(segmentIndices.length === 2) {
@ -345,11 +447,15 @@ export default class Explorer extends Component {
const {entries} = message;
const userSeekIndex = this.indexFromSeekTime(time);
const seekTime = entries[userSeekIndex].relTime;
if(userSeekIndex) {
const seekTime = entries[userSeekIndex].relTime;
this.setState({userSeekIndex, userSeekTime: seekTime});
this.props.onUserSeek(seekTime);
this.props.onSeek(userSeekIndex, seekTime);
this.setState({userSeekIndex, userSeekTime: seekTime});
this.props.onSeek(userSeekIndex, seekTime);
} else {
this.props.onUserSeek(time);
this.setState({userSeekTime: time});
}
}
onPlaySeek(time) {
@ -556,29 +662,33 @@ export default class Explorer extends Component {
: this.renderSelectMessagePrompt()}
</div>
<div className='cabana-explorer-visuals'>
<div className='cabana-explorer-visuals-header'>
{this.timeWindow()}
<PartSelector
onPartChange={this.props.onPartChange}
partsCount={this.props.partsCount}
/>
</div>
<RouteVideoSync
message={this.props.messages[this.props.selectedMessage]}
secondsLoaded={this.secondsLoaded()}
startOffset={this.startOffset()}
seekIndex={this.props.seekIndex}
userSeekIndex={this.state.userSeekIndex}
playing={this.state.playing}
url={this.props.url}
canFrameOffset={this.props.canFrameOffset}
firstCanTime={this.props.firstCanTime}
onVideoClick={this.onVideoClick}
onPlaySeek={this.onPlaySeek}
onUserSeek={this.onUserSeek}
onPlay={this.onPlay}
onPause={this.onPause}
userSeekTime={this.state.userSeekTime} />
{ this.props.route !== null ?
<div>
<div className='cabana-explorer-visuals-header'>
{this.timeWindow()}
<PartSelector
onPartChange={this.props.onPartChange}
partsCount={this.props.partsCount}
/>
</div>
<RouteVideoSync
message={this.props.messages[this.props.selectedMessage]}
secondsLoaded={this.secondsLoaded()}
startOffset={this.startOffset()}
seekIndex={this.props.seekIndex}
userSeekIndex={this.state.userSeekIndex}
playing={this.state.playing}
url={this.props.url}
canFrameOffset={this.props.canFrameOffset}
firstCanTime={this.props.firstCanTime}
onVideoClick={this.onVideoClick}
onPlaySeek={this.onPlaySeek}
onUserSeek={this.onUserSeek}
onPlay={this.onPlay}
onPause={this.onPause}
userSeekTime={this.state.userSeekTime} />
</div>
: null }
{this.state.segment.length > 0 ?
<div className={css(CommonStyles.button, Styles.resetSegment)}
onClick={() => {this.resetSegment()}}>
@ -594,7 +704,8 @@ export default class Explorer extends Component {
onSegmentChanged={this.onSegmentChanged}
onSignalUnplotPressed={this.onSignalUnplotPressed}
segment={this.state.segment}
mergePlots={this.mergePlots} />
mergePlots={this.mergePlots}
live={this.props.live} />
</div>
</div>
);

View File

@ -4,6 +4,7 @@ import cx from 'classnames';
import PropTypes from 'prop-types';
import Clipboard from 'clipboard';
require('core-js/fn/array/includes');
const {ckmeans} = require('simple-statistics');
import {modifyQueryParameters} from '../utils/url';
import LoadDbcModal from './LoadDbcModal';
@ -33,6 +34,7 @@ export default class Meta extends Component {
seekTime: PropTypes.number,
loginWithGithub: PropTypes.element,
isDemo: PropTypes.bool,
live: PropTypes.bool,
};
constructor(props) {
@ -41,12 +43,14 @@ export default class Meta extends Component {
this.state = {
filterText: 'Filter',
lastSaved: dbcLastSaved !== null ? this.props.dbcLastSaved.fromNow() : null,
hoveredMessages: []
hoveredMessages: [],
orderedMessageKeys: [],
};
this.onFilterChanged = this.onFilterChanged.bind(this);
this.onFilterFocus = this.onFilterFocus.bind(this);
this.onFilterUnfocus = this.onFilterUnfocus.bind(this);
this.msgKeyFilter = this.msgKeyFilter.bind(this);
this.msgFilter = this.msgFilter.bind(this);
this.renderMessageBytes = this.renderMessageBytes.bind(this);
}
componentWillMount() {
@ -70,10 +74,60 @@ export default class Meta extends Component {
if(JSON.stringify(nextMsgKeys) != JSON.stringify(Object.keys(this.props.messages))) {
let {selectedMessages} = this.props;
selectedMessages = selectedMessages.filter((m) => nextMsgKeys.indexOf(m) !== -1);
this.setState({hoveredMessages: []});
const orderedMessageKeys = this.sortMessages(nextProps.messages);
this.setState({hoveredMessages: [], orderedMessageKeys});
} else if((this.state.orderedMessageKeys.length === 0)
|| (!this.props.live && this.props.messages && nextProps.messages
&& this.byteCountsDidUpdate(this.props.messages, nextProps.messages))) {
const orderedMessageKeys = this.sortMessages(nextProps.messages);
this.setState({orderedMessageKeys});
}
}
byteCountsDidUpdate(prevMessages, nextMessages) {
return Object.entries(nextMessages).some(([msgId, msg]) =>
JSON.stringify(msg.byteStateChangeCounts)
!== JSON.stringify(prevMessages[msgId].byteStateChangeCounts));
}
sortMessages(messages) {
// Returns list of message keys, ordered as follows:
// messages are binned into at most 10 bins based on entry count
// each bin is sorted by message CAN address
// then the list of bins is flattened and reversed to
// yield a count-descending, address-ascending order.
if(Object.keys(messages).length === 0) return [];
const messagesByEntryCount = Object.entries(messages).reduce((partialMapping, [msgId, msg]) => {
const entryCountKey = msg.entries.length.toString(); // js object keys are strings
if( !partialMapping[entryCountKey] ) {
partialMapping[entryCountKey] = [msg];
} else {
partialMapping[entryCountKey].push(msg);
}
return partialMapping;
}, {});
const entryCounts = Object.keys(messagesByEntryCount).map((count) => parseInt(count));
const binnedEntryCounts = ckmeans(entryCounts, Math.min(entryCounts.length, 10));
const sortedKeys = binnedEntryCounts.map((bin) =>
bin.map((entryCount) => messagesByEntryCount[entryCount.toString()])
.reduce((messages, partial) => messages.concat(partial), [])
.sort((msg1, msg2) => {
if(msg1.address < msg2.address) {
return 1;
} else {
return -1;
}
})
.map((msg) => msg.id)
).reduce((keys, bin) => keys.concat(bin), [])
.reverse();
return sortedKeys;
}
onFilterChanged(e) {
let val = e.target.value;
if(val.trim() === 'Filter') val = '';
@ -93,14 +147,13 @@ export default class Meta extends Component {
}
}
msgKeyFilter(key) {
msgFilter(msg) {
const {filterText} = this.state;
const msg = this.props.messages[key];
const msgName = (msg.frame ? msg.frame.name : '');
return (filterText == 'Filter'
|| filterText == ''
|| key.toLowerCase().indexOf(filterText.toLowerCase()) !== -1
|| msg.id.toLowerCase().indexOf(filterText.toLowerCase()) !== -1
|| msgName.toLowerCase().indexOf(filterText.toLowerCase()) !== -1);
}
@ -140,50 +193,43 @@ export default class Meta extends Component {
}
orderedMessages() {
const {orderedMessageKeys} = this.state;
const {messages} = this.props;
const keys = Object.keys(messages)
.filter(this.msgKeyFilter)
.sort((key1, key2) => {
const msg1 = messages[key1], msg2 = messages[key2];
if(msg1.entries.length < msg2.entries.length) {
return 1;
} else if(msg1.entries.length === msg2.entries.length) {
return 0;
} else {
return -1;
}
});
let bins = [];
keys.forEach((key, idx) => {
const msg = messages[key];
let bin = bins.find((bin) =>
bin.some((binMsg) =>
Math.abs(binMsg.entries.length - msg.entries.length) < 100
)
);
if(bin) {
bin.push(msg);
} else {
bins.push([msg]);
}
});
bins = bins.map((bin) => bin.sort((msg1, msg2) => {
if(msg1.address < msg2.address) {
return -1;
} else {
return 1;
}
})
);
return bins.reduce((arr, bin) => arr.concat(bin), []);
return orderedMessageKeys.map((key) => messages[key]);
}
selectedMessageClass(messageId) {
return (this.props.selectedMessages.includes(messageId) ? 'is-selected' : null);
}
renderMessageBytes(msg) {
return (
<tr onClick={() => {this.onMessageSelected(msg.id)}}
key={msg.id}
className={cx('cabana-meta-messages-list-item', this.selectedMessageClass(msg.id))}>
<td>{msg.frame ? msg.frame.name : 'undefined'}</td>
<td>{msg.id}</td>
<td>{msg.entries.length}</td>
<td>
<div className='cabana-meta-messages-list-item-bytes'>
<MessageBytes
key={msg.id}
message={msg}
seekIndex={this.props.seekIndex}
seekTime={this.props.seekTime}
live={this.props.live}
/>
</div>
</td>
</tr>
);
}
renderMessages() {
return this.orderedMessages()
.filter(this.msgFilter)
.map(this.renderMessageBytes);
}
renderAvailableMessagesList() {
if(Object.keys(this.props.messages).length === 0) {
return <p>Loading messages...</p>;
@ -199,24 +245,7 @@ export default class Meta extends Component {
</tr>
</thead>
<tbody>
{this.orderedMessages()
.map((msg) => {
return (
<tr onClick={() => {this.onMessageSelected(msg.id)}}
key={msg.id}
className={cx('cabana-meta-messages-list-item', this.selectedMessageClass(msg.id))}>
<td>{msg.frame ? msg.frame.name : 'undefined'}</td>
<td>{msg.id}</td>
<td>{msg.entries.length}</td>
<td>
<MessageBytes
message={msg}
seekTime={this.props.seekTime}
maxByteStateChangeCount={this.props.maxByteStateChangeCount} />
</td>
</tr>
)
})}
{this.renderMessages()}
</tbody>
</table>
);
@ -258,14 +287,15 @@ export default class Meta extends Component {
<div className='cabana-meta-header-action'>
<button onClick={this.props.showLoadDbc}>Load DBC</button>
</div>
<div className='cabana-meta-header-action'
data-clipboard-text={this.shareUrl()}
data-clipboard-action='copy'
ref={(ref) => ref ? new Clipboard(ref) : null}>
<a className='button'
href={this.shareUrl()}
onClick={(e) => e.preventDefault()}>Copy Share Link</a>
</div>
{this.props.route ?
<div className='cabana-meta-header-action'
data-clipboard-text={this.shareUrl()}
data-clipboard-action='copy'
ref={(ref) => ref ? new Clipboard(ref) : null}>
<a className='button'
href={this.shareUrl()}
onClick={(e) => e.preventDefault()}>Copy Share Link</a>
</div> : null}
<div className='cabana-meta-header-action'>
<button onClick={this.props.showSaveDbc}>Save DBC</button>
</div>

View File

@ -20,4 +20,6 @@ export const LOGENTRIES_TOKEN = '4bc98019-8277-4fe0-867c-ed21ea843cc5';
export const PART_SEGMENT_LENGTH = 3;
export const CAN_GRAPH_MAX_POINTS = 10000;
export const CAN_GRAPH_MAX_POINTS = 10000;
export const STREAMING_WINDOW = 60;

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -15,36 +15,34 @@ import './index.css';
const routeFullName = getUrlParameter('route');
let isDemo = !routeFullName;
let props = {autoplay: false, isDemo};
let props = {autoplay: true, isDemo};
let persistedDbc = null;
if(routeFullName) {
const [dongleId, route] = routeFullName.split('|');
props.dongleId = dongleId;
props.name = route;
const persistedDbc = fetchPersistedDbc(routeFullName);
if(persistedDbc) {
const {dbcFilename, dbc} = persistedDbc;
props.dbc = dbc;
props.dbcFilename = dbcFilename;
}
persistedDbc = fetchPersistedDbc(routeFullName);
let max = getUrlParameter('max'), url = getUrlParameter('url');
if(max && url) {
props.max = max;
props.url = url;
}
} else if(getUrlParameter('prius')) {
props.autoplay = true;
props.dongleId = 'b67ff0c1d78774da';
props.name = '2017-06-30--17-37-49';
} else {
props.autoplay = true;
} else if(getUrlParameter('demo')) {
props.dongleId = 'cb38263377b873ee';
props.name = '2017-06-12--18-51-47';
props.dbc = AcuraDbc;
props.dbcFilename = 'acura_ilx_2016_can.dbc';
}
if(persistedDbc) {
const {dbcFilename, dbc} = persistedDbc;
props.dbc = dbc;
props.dbcFilename = dbcFilename;
}
const authTokenQueryParam = getUrlParameter(GITHUB_AUTH_TOKEN_KEY);
if(authTokenQueryParam !== null) {
props.githubAuthToken = authTokenQueryParam;
@ -67,4 +65,4 @@ if(routeFullName || isDemo) {
document.getElementById('root').appendChild(img);
document.getElementById('root').appendChild(comment);
}
}

View File

@ -12,6 +12,8 @@
@import './styles/base/lists';
@import './styles/base/typography';
@import './styles/base/utils';
@import './styles/base/animations';
@import './styles/base/insets';
// Libraries
@import './styles/lib/index';

View File

@ -40,6 +40,10 @@ class CloudLog {
warn(message) {
this.emit(message, 'warn');
}
error(message) {
this.emit(message, 'error');
}
}
export default (new CloudLog());

View File

@ -173,8 +173,9 @@ export default class DBC {
setSignals(msgId, signals) {
const msg = this.messages.get(msgId);
if(msg) {
msg.signals = signals;
this.messages.set(msgId, msg);
const newMsg = Object.assign(Object.create(msg), msg);
newMsg.signals = signals;
this.messages.set(msgId, newMsg);
} else {
const msg = this.createFrame(msgId);
msg.signals = signals;

View File

@ -1,4 +1,4 @@
// All Global Variables and Mixins
@import 'colors';
@import 'mixins';
@import '../../../node_modules/font-awesome/scss/variables';
@import '../../../node_modules/font-awesome/scss/_variables';

View File

@ -9,4 +9,4 @@ $color-grey-70: #6f6f6f;
$color-grey-80: #484848;
$color-grey-90: #2d2d2d;
$color-blue-50: #378FFF;
$color-blue-50: #258FDA;

View File

@ -0,0 +1,22 @@
/*
Animations
~~~~~~~~~~
*/
// Spinning Animation
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation-name: spin;
animation-duration: 1000ms;
animation-iteration-count: infinite;
animation-timing-function: linear;
}

View File

@ -34,3 +34,12 @@ hr {
margin: 22px 0;
width: 100%;
}
a {
cursor: pointer;
text-decoration: none;
}
img {
max-width: 100%;
}

View File

@ -165,6 +165,16 @@ a.button {
font-size: 12px;
line-height: $input-height--tiny - 2px;
}
// Button Disabled Variations
&[disabled] {
background: $button-background--disabled;
border-color: $button-border--disabled;
border-bottom-width: 1px;
color: $button-color--disabled;
cursor: default;
pointer-events: none;
text-shadow: none;
}
// Button Color Variations
// ~~~~~~~~~~~~~~~~~~~~~~~
&.button--primary {
@ -177,6 +187,13 @@ a.button {
background: darken($button-background--primary, 8%);
}
}
&.button--secondary {
background: linear-gradient(-180deg, #F9F9F9 0%, #F0F0F0 100%);
border-color: $color-grey-40;
border-radius: 5px;
color: $color-grey-80;
text-shadow: none;
}
&.button--alpha {
background: $button-background--alpha;
border-color: $color-grey-30;
@ -207,14 +224,84 @@ a.button {
background: darken($button-background--dark, 10%);
}
}
// Button Disabled Variations
&[disabled] {
background: $button-background--disabled;
border-color: $button-border--disabled;
border-bottom-width: 1px;
color: $button-color--disabled;
cursor: default;
pointer-events: none;
text-shadow: none;
// Major variations
&.button--kiosk {
box-shadow: 0 1px 3px 0 rgba(105,105,105,0.2);
height: 144px;
margin-bottom: 15px;
padding-bottom: 34px;
padding-left: 160px;
padding-top: 31px;
position: relative;
text-align: left;
width: 100%;
&:active {
> i {
top: 35px;
}
}
&[disabled],
&.is-disabled {
// it is expected when kiosk buttons are disabled
// that a context <p> will be provided
border-width: 1px;
box-shadow: none;
cursor: default;
padding-top: 22px;
strong,
sup,
> i {
opacity: .3;
}
sup {
font-size: 16px;
}
p {
font-size: 16px;
font-weight: 600;
span {
padding-left: 8px;
}
}
a {
color: inherit;
text-decoration: none;
pointer-events: auto;
&:hover {
text-decoration: underline;
}
}
}
> i {
background: rgba(0,0,0,.2);
border: 1px solid rgba(0,0,0,.1);
border-radius: 100%;
font-size: 36px;
line-height: 73px;
text-align: center;
left: 50px;
top: 34px;
opacity: .3;
position: absolute;
width: 73px;
}
strong,
sup {
display: block;
line-height: normal;
}
strong {
font-size: 26px;
letter-spacing: .0125em;
margin-bottom: 8px;
}
sup {
font-size: 18px;
font-weight: normal;
}
em {
font-style: normal;
font-weight: 500;
}
}
}

View File

@ -0,0 +1,13 @@
// Insets
// ~~~~~~
@import '../_global/all';
.inset {
background: $color-grey-20;
border: 1px solid $color-grey-30;
border-radius: 3px;
color: $color-grey-70;
padding: 12px;
width: 100%;
}

View File

@ -1,8 +1,46 @@
// Lists
// ~~~~~
@import '../_global/all';
$list-bubble-size: 25px;
ul {
list-style-type: none;
margin: 0;
padding: 0;
}
ol {
margin: 0;
&.list--bubbled {
list-style-type: none;
padding: 0;
li {
counter-increment: list-counter;
min-height: $list-bubble-size;
margin-bottom: 2%;
padding-bottom: 2%;
padding-top: 4px;
padding-left: $list-bubble-size * 1.6;
position: relative;
&:before {
background: #fff;
border: 1px solid $color-grey-30;
border-radius: 100%;
content: counter(list-counter);
font-size: 12px;
line-height: $list-bubble-size;
margin-right: 5px;
left: 0;
top: 0;
position: absolute;
text-align: center;
width: $list-bubble-size;
}
p {
margin-bottom: 3%;
}
}
}
}

View File

@ -1,3 +1,13 @@
export function elementWiseEquals(arr1, arr2) {
function elementWiseEquals(arr1, arr2) {
return arr1.length === arr2.length && arr1.every((ele, idx) => arr2[idx] === ele);
}
function findIndexRight(arr, condition) {
for(let i = arr.length - 1; i >= 0; i--) {
if(condition(arr[i])) {
return i;
}
}
}
export default {elementWiseEquals, findIndexRight};

View File

@ -1,42 +1,100 @@
require('core-js/fn/array/from');
function findMaxByteStateChangeCount(messages) {
return Object.values(messages).map((m) => m.byteStateChangeCounts)
.reduce((counts, countArr) => counts.concat(countArr), []) // flatten arrays
.reduce((count1, count2) => count1 > count2 ? count1 : count2, 0); // find max
}
function addCanMessage([address, busTime, data, source], dbc, canStartTime, messages, prevMsgEntries, byteStateChangeCountsByMessage) {
var id = source + ":" + address.toString(16);
if (messages[id] === undefined) messages[id] = createMessageSpec(dbc, address, id, source);
const prevMsgEntry = messages[id].entries.length > 0 ?
messages[id].entries[messages[id].entries.length - 1]
:
(prevMsgEntries[id] || null);
if(byteStateChangeCountsByMessage[id] && messages[id].byteStateChangeCounts.every((c) => c === 0)) {
messages[id].byteStateChangeCounts = byteStateChangeCountsByMessage[id]
}
const {msgEntry,
byteStateChangeCounts} = parseMessage(dbc,
busTime,
address,
data,
canStartTime,
prevMsgEntry);
messages[id].byteStateChangeCounts = byteStateChangeCounts.map((count, idx) =>
messages[id].byteStateChangeCounts[idx] + count
);
messages[id].entries.push(msgEntry);
return msgEntry;
}
function createMessageSpec(dbc, address, id, bus) {
const frame = dbc.messages.get(address);
const size = frame ? frame.size : 8;
return {address: address,
id: id,
bus: bus,
entries: [],
frame: dbc.messages.get(address),
byteColors: Array(size).fill(0),
byteStateChangeCounts: Array(size).fill(0)}
}
function determineByteStateChangeTimes(hexData, time, msgSize, lastParsedMessage) {
const byteStateChangeCounts = Array(msgSize).fill(0);
let byteStateChangeTimes;
const byteStateChangeCounts = Array(msgSize).fill(0);
let byteStateChangeTimes;
if(!lastParsedMessage) {
byteStateChangeTimes = Array(msgSize).fill(time);
} else {
byteStateChangeTimes = Array.from(lastParsedMessage.byteStateChangeTimes);
if(!lastParsedMessage) {
byteStateChangeTimes = Array(msgSize).fill(time);
} else {
byteStateChangeTimes = Array.from(lastParsedMessage.byteStateChangeTimes);
for(let i = 0; i < byteStateChangeTimes.length; i++) {
const currentData = hexData.substr(i * 2, 2),
prevData = lastParsedMessage.hexData.substr(i * 2, 2);
for(let i = 0; i < byteStateChangeTimes.length; i++) {
const currentData = hexData.substr(i * 2, 2),
prevData = lastParsedMessage.hexData.substr(i * 2, 2);
if(currentData != prevData) {
byteStateChangeTimes[i] = time;
byteStateChangeCounts[i] = 1;
if(currentData != prevData) {
byteStateChangeTimes[i] = time;
byteStateChangeCounts[i] = 1;
}
}
}
}
return {byteStateChangeTimes, byteStateChangeCounts};
return {byteStateChangeTimes, byteStateChangeCounts};
}
function parseMessage(dbc, time, address, data, timeStart, lastParsedMessage) {
const hexData = Buffer.from(data).toString('hex');
let hexData;
if(typeof data === 'string') {
hexData = data;
data = Buffer.from(data, 'hex');
} else {
hexData = Buffer.from(data).toString('hex');
}
const msgSpec = dbc.messages.get(address);
const msgSize = msgSpec ? msgSpec.size : 8;
const relTime = time - timeStart;
const {byteStateChangeTimes, byteStateChangeCounts} = determineByteStateChangeTimes(hexData,
relTime,
msgSize,
lastParsedMessage)
const {byteStateChangeTimes,
byteStateChangeCounts} = determineByteStateChangeTimes(hexData,
relTime,
msgSize,
lastParsedMessage);
const msgEntry = {time: time,
signals: dbc.getSignalValues(address, data),
relTime,
hexData,
byteStyles: new Array(8).fill({}),
byteStateChangeTimes}
return {msgEntry, byteStateChangeCounts};
@ -50,11 +108,34 @@ for(let i = 0; i < 64; i += 8) {
}
function bigEndianBitIndex(matrixBitIndex) {
return BIG_ENDIAN_START_BITS.indexOf(matrixBitIndex);
return BIG_ENDIAN_START_BITS.indexOf(matrixBitIndex);
}
function matrixBitNumber(bigEndianIndex) {
return BIG_ENDIAN_START_BITS[bigEndianIndex];
}
export default {bigEndianBitIndex, matrixBitNumber, parseMessage}
function setMessageByteColors(message, maxByteStateChangeCount) {
message.byteColors = message.byteStateChangeCounts.map((count) =>
isNaN(count) ? 0 : Math.min(255, 75 + 180 * (count / maxByteStateChangeCount))
).map((red) =>
'rgb(' + Math.round(red) + ',0,0)'
);
for(let i = 0; i < message.entries.length; i++) {
message.entries[i].byteStyles = message.entries[i].byteStyles.map((style, idx) => {
return {backgroundColor: message.byteColors[idx], ...style};
});
}
return message;
}
export default {bigEndianBitIndex,
addCanMessage,
createMessageSpec,
matrixBitNumber,
parseMessage,
findMaxByteStateChangeCount,
setMessageByteColors};

View File

@ -15,4 +15,4 @@ export function fromArray(arr) {
} else {
return {};
}
}
}

View File

@ -22,7 +22,8 @@ export default createClassFromSpec('CanPlot', {
},
{"name": "videoTime"},
{"name": "segment",
"value": {"data": "table", "field": "xRel"}}
"value": {"data": "table", "field": "relTime"}
}
],
"data": [
{
@ -34,11 +35,11 @@ export default createClassFromSpec('CanPlot', {
"transform": [
{
"type": "filter",
"expr": "abs(datum.xRel - tipTime) <= 0.1"
"expr": "abs(datum.relTime - tipTime) <= 0.1"
},
{
"type": "aggregate",
"fields": ["xRel", "y", "unit"],
"fields": ["relTime", "y", "unit"],
"ops": ["min", "argmin", "argmin"],
"as": ["min", "argmin", "argmin"]
}
@ -50,7 +51,7 @@ export default createClassFromSpec('CanPlot', {
"transform": [
{
"type": "filter",
"expr": "length(segment) != 2 || (datum.xRel >= segment[0] && datum.xRel <= segment[1])"
"expr": "length(segment) != 2 || (datum.relTime >= segment[0] && datum.relTime <= segment[1])"
},
{"type": "extent", "field": "y", "signal": "ySegment"}
]
@ -68,7 +69,7 @@ export default createClassFromSpec('CanPlot', {
"name": "xrelscale",
"type": "linear",
"range": "width",
"domain": {"data": "table", "field": "xRel"},
"domain": {"data": "table", "field": "relTime"},
"zero": false,
"clamp": true,
"domainRaw": {"signal": "segment"}
@ -167,7 +168,8 @@ export default createClassFromSpec('CanPlot', {
"interactive": true,
"encode": {
"update": {
"x": {"scale": "xrelscale", "field": "xRel"},
"interpolate": {"value": "step"},
"x": {"scale": "xrelscale", "field": "relTime"},
"y": {"scale": "yscale", "field": "y"}
},
"hover": {
@ -246,7 +248,7 @@ export default createClassFromSpec('CanPlot', {
"from": {"data": "tooltip"},
"encode": {
"update": {
"x": {"scale": "xrelscale", "field": "argmin.xRel"},
"x": {"scale": "xrelscale", "field": "argmin.relTime"},
"y": {"scale": "yscale", "field": "argmin.y"},
"size": {"value": 50},
"fill": {"value": "black"}
@ -260,8 +262,8 @@ export default createClassFromSpec('CanPlot', {
"name": "tooltipGroup",
"encode": {
"update": {
"x": [{"test": "inrange(datum.argmin.xRel + 80, domain('xrelscale'))", "scale": "xrelscale", "field": "argmin.xRel"},
{"scale": "xrelscale", "field": "argmin.xRel", "offset": -80}],
"x": [{"test": "inrange(datum.argmin.relTime + 80, domain('xrelscale'))", "scale": "xrelscale", "field": "argmin.relTime"},
{"scale": "xrelscale", "field": "argmin.relTime", "offset": -80}],
"y": {"scale": "yscale", "field": "argmin.y"},
"height": {"value": 20},
"width": {"value": 80},
@ -277,7 +279,7 @@ export default createClassFromSpec('CanPlot', {
"interactive": false,
"encode": {
"update": {
"text": {"signal": "format(parent.argmin.xRel, ',.2f') + ': ' + format(parent.argmin.y, ',.2f') + ' ' + parent.argmin.unit"},
"text": {"signal": "format(parent.argmin.relTime, ',.2f') + ': ' + format(parent.argmin.y, ',.2f') + ' ' + parent.argmin.unit"},
"fill": {"value": "black"},
"fontWeight": {"value": "bold"},
"y": {"value": 20}

View File

@ -0,0 +1,85 @@
import DBC from '../models/can/dbc';
import DbcUtils from '../utils/dbc';
function processStreamedCanMessages(newCanMessages,
prevMsgEntries,
firstCanTime,
dbc,
lastBusTime,
byteStateChangeCountsByMessage,
maxByteStateChangeCount) {
const messages = {};
let lastCanTime;
for(let batch = 0; batch < newCanMessages.length; batch++) {
let {time, canMessages} = newCanMessages[batch];
canMessages = canMessages.sort((msg1, msg2) => {
if(msg1[1] < msg2[1]) {
return -1;
} else if(msg1[1] > msg2[1]) {
return 1;
} else {
return 0;
}
});
let busTimeSum = 0;
for(let i = 0; i < canMessages.length; i++) {
let [address, busTime, data, source] = canMessages[i];
let prevBusTime;
if(i === 0) {
if(lastBusTime === null) {
prevBusTime = 0;
} else {
prevBusTime = lastBusTime;
}
} else {
prevBusTime = canMessages[i - 1][1];
}
if(busTime >= prevBusTime) {
busTimeSum += busTime - prevBusTime;
} else {
busTimeSum += (0x10000 - prevBusTime) + busTime;
}
const message = [...canMessages[i]];
message[1] = time + busTimeSum / 500000.0;
if(firstCanTime === 0) {
firstCanTime = message[1];
}
const msgEntry = DbcUtils.addCanMessage(message, dbc, firstCanTime, messages, prevMsgEntries, byteStateChangeCountsByMessage);
if(i === canMessages.length - 1) {
lastCanTime = msgEntry.relTime;
}
}
lastBusTime = canMessages[canMessages.length - 1][1];
const newMaxByteStateChangeCount = DbcUtils.findMaxByteStateChangeCount(messages);
if(newMaxByteStateChangeCount > maxByteStateChangeCount) {
maxByteStateChangeCount = newMaxByteStateChangeCount;
}
Object.keys(messages).forEach((key) => {
messages[key] = DbcUtils.setMessageByteColors(messages[key], maxByteStateChangeCount);
});
}
self.postMessage({newMessages: messages, seekTime: lastCanTime, lastBusTime, firstCanTime, maxByteStateChangeCount});
}
self.onmessage = function(e) {
const {newCanMessages, prevMsgEntries, firstCanTime, dbcText, lastBusTime, byteStateChangeCountsByMessage, maxByteStateChangeCount} = e.data;
const dbc = new DBC(dbcText);
processStreamedCanMessages(newCanMessages,
prevMsgEntries,
firstCanTime,
dbc,
lastBusTime,
byteStateChangeCountsByMessage,
maxByteStateChangeCount);
}

View File

@ -9,25 +9,7 @@ import * as CanApi from '../api/can';
const Int64LE = require('int64-buffer').Int64LE
function createMessageSpec(dbc, address, id, bus) {
const frame = dbc.messages.get(address);
const size = frame ? frame.size : 8;
return {address: address,
id: id,
bus: bus,
entries: [],
frame: dbc.messages.get(address),
byteStateChangeCounts: Array(size).fill(0)}
}
function findMaxByteStateChangeCount(messages) {
return Object.values(messages).map((m) => m.byteStateChangeCounts)
.reduce((counts, countArr) => counts.concat(countArr), []) // flatten arrays
.reduce((count1, count2) => count1 > count2 ? count1 : count2, 0); // find max
}
async function loadCanPart(dbc, base, num, canStartTime, prevMsgEntries) {
async function loadCanPart(dbc, base, num, canStartTime, prevMsgEntries, maxByteStateChangeCount) {
var messages = {};
const {times,
sources,
@ -44,7 +26,7 @@ async function loadCanPart(dbc, base, num, canStartTime, prevMsgEntries) {
var addressNum = address.toNumber();
var data = datas.slice(i*8, (i+1)*8);
if (messages[id] === undefined) messages[id] = createMessageSpec(dbc, address.toNumber(), id, src);
if (messages[id] === undefined) messages[id] = DbcUtils.createMessageSpec(dbc, address.toNumber(), id, src);
const prevMsgEntry = messages[id].entries.length > 0 ?
messages[id].entries[messages[id].entries.length - 1]
@ -65,15 +47,23 @@ async function loadCanPart(dbc, base, num, canStartTime, prevMsgEntries) {
messages[id].entries.push(msgEntry);
}
const maxByteStateChangeCount = findMaxByteStateChangeCount(messages);
const newMaxByteStateChangeCount = DbcUtils.findMaxByteStateChangeCount(messages);
if(newMaxByteStateChangeCount > maxByteStateChangeCount) {
maxByteStateChangeCount = newMaxByteStateChangeCount;
}
Object.keys(messages).forEach((key) => {
messages[key] = DbcUtils.setMessageByteColors(messages[key], maxByteStateChangeCount);
});
self.postMessage({newMessages: messages,
maxByteStateChangeCount});
self.close();
}
self.onmessage = function(e) {
const {dbcText, base, num, canStartTime, prevMsgEntries} = e.data;
const {dbcText, base, num, canStartTime, prevMsgEntries, maxByteStateChangeCount} = e.data;
const dbc = new DBC(dbcText);
loadCanPart(dbc, base, num, canStartTime, prevMsgEntries);
loadCanPart(dbc, base, num, canStartTime, prevMsgEntries, maxByteStateChangeCount);
}

View File

@ -11,17 +11,21 @@ function reparseEntry(entry, address, dbc, canStartTime, prevMsgEntry) {
}
self.onmessage = function(e) {
const {message, dbcText, canStartTime} = e.data;
const {messages, dbcText, canStartTime} = e.data;
const dbc = new DBC(dbcText);
for(var i = 0; i < message.entries.length; i++) {
const entry = message.entries[i];
const prevMsgEntry = i > 0 ? message.entries[i - 1] : null;
Object.keys(messages).forEach((messageId) => {
const message = messages[messageId];
for(var i = 0; i < message.entries.length; i++) {
const entry = message.entries[i];
const prevMsgEntry = i > 0 ? message.entries[i - 1] : null;
const {msgEntry} = reparseEntry(entry, message.address, dbc, canStartTime, prevMsgEntry);
const {msgEntry} = reparseEntry(entry, message.address, dbc, canStartTime, prevMsgEntry);
message.entries[i] = msgEntry;
}
message.entries[i] = msgEntry;
}
messages[messageId] = message;
});
self.postMessage(message);
self.postMessage(messages);
self.close();
}