First commit for gui branch

This commit is contained in:
PAJohnson
2020-09-03 16:13:59 -04:00
parent 3c49b7f8ca
commit c0aa194765
44 changed files with 17984 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
#Electron-builder output
/dist_electron
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 ODrive Robotics
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+45
View File
@@ -0,0 +1,45 @@
# odrive_gui
This is the repo for the ODrive GUI
Flask (python 3) backend with Vue.js frontend, packaged with Electron. Windows and Linux binaries can be found under the [Releases](https://github.com/PAJohnson/odrive_gui/releases) page.
Python requirements: `pip install flask flask-socketio flask-cors odrive`
If the default odrive python package is not desired, the path to the modules can be passed as command line arguments.
example on windows 10:
```
./odrive_gui_win.exe C:/Users/<you>/ODrive/tools C:/Users/<you>/ODrive/Firmware
```
The first argument is for your local version of odrivetool, the second is for fibre.
## Development and testing instructions
### Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Lints and fixes files
```
npm run lint
```
### Serve electron version of GUI
```
npm run electron:serve
```
### Package electron app into executable
```
npm run electron:build
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
+14207
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
{
"name": "odrive_gui",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"electron:build": "vue-cli-service electron:build",
"electron:serve": "vue-cli-service electron:serve",
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps"
},
"main": "background.js",
"dependencies": {
"axios": "^0.19.2",
"chart.js": "^2.9.3",
"chartjs-plugin-streaming": "^1.8.0",
"core-js": "^3.6.5",
"electron": "^9.1.1",
"file-saver": "^2.0.2",
"socket.io": "^2.3.0",
"socket.io-client": "^2.3.0",
"uuid": "^8.2.0",
"vue": "^2.6.11",
"vue-chartjs": "^3.5.0",
"vue-context": "^5.2.0",
"vue-json-component": "^0.4.1",
"vue-slider-component": "^3.2.2",
"vue-socket.io": "^3.0.9",
"vuex": "^3.5.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.4.0",
"@vue/cli-plugin-eslint": "~4.4.0",
"@vue/cli-plugin-router": "^4.4.6",
"@vue/cli-service": "~4.4.0",
"babel-eslint": "^10.1.0",
"electron": "^9.0.0",
"electron-devtools-installer": "^3.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"vue-cli-plugin-electron-builder": "~2.0.0-rc.4",
"vue-cli-plugin-yaml": "^1.0.2",
"vue-json-component": "^0.4.1",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
+200
View File
@@ -0,0 +1,200 @@
import sys
import flask
import os
from flask import make_response, request, jsonify, session
from flask_socketio import SocketIO, send, emit
from flask_cors import CORS
import json
import time
import argparse
# interface for odrive GUI to get data from odrivetool
#better handling of websockets
# eventlet.monkey_patch()
app = flask.Flask(__name__)
app.config['SECRET_KEY'] = 'secret'
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='None'
)
CORS(app, support_credentials=True)
socketio = SocketIO(app, cors_allowed_origins="*")
odrives = []
odriveDict = {}
configDict = {}
def get_all_odrives():
odrives = []
odrives.append(odrive.find_any()) #, find_multiple=100)
#odrives.append(odrive.find_any())
return odrives
@socketio.on('enableSampling')
def enableSampling(message):
print("sampling enabled")
session['samplingEnabled'] = True
emit('samplingEnabled')
@socketio.on('stopSampling')
def stopSampling(message):
session['samplingEnabled'] = False
emit('samplingDisabled')
@socketio.on('sampledVarNames')
def sampledVarNames(message):
session['sampledVars'] = message
print(session['sampledVars'])
@socketio.on('startSampling')
def sendSamples(message):
print(session['samplingEnabled'])
while session['samplingEnabled']:
emit('sampledData', json.dumps(getSampledData(session['sampledVars'])))
time.sleep(0.02)
@socketio.on('message')
def handle_message(message):
print(message)
emit('response', 'hello from server!')
@app.route('/', methods=['GET'])
def home():
return "<h1>ODrive GUI Server</h1>"
@app.route('/api/odrives', methods=["GET"])
def api_odrives():
for (index, odrv) in enumerate(odrives):
odriveDict["odrive" + str(index)] = dictFromRO(odrv)
response = jsonify(odriveDict)
response.headers.add('Access-Control-Allow-Origin', '*')
return response
@app.route('/api/property', methods=["GET", "PUT"])
def api_property():
# here, reqDict["key"] is a list of keys from the query
# ?key=odrive0&key=axis0&key=config...
if request.method == 'PUT':
reqDict = request.args.to_dict(flat=False)
postVal(odrives, reqDict["key"], reqDict["val"][0], reqDict["type"][0])
response = make_response(jsonify({"message": "success"}), 200)
return response
else:
print("request: " + str(request))
reqDict = request.args.to_dict(flat=False)
response = jsonify(getVal(odrives, reqDict["key"]))
response.headers.add('Access-Control-Allow-Origin', '*')
return response
@app.route('/api/function', methods=["PUT"])
def api_function():
# execute a function from the odrive config dict?
reqDict = request.args.to_dict(flat=False)
callFunc(odrives, reqDict["key"])
response = make_response(jsonify({"message": "success"}), 200)
return response
def dictFromRO(RO):
# create dict from an odrive RemoteObject that's suitable for sending as JSON
returnDict = {}
for key in RO._remote_attributes.keys():
if isinstance(RO._remote_attributes[key], fibre.remote_object.RemoteObject):
# recurse
returnDict[key] = dictFromRO(RO._remote_attributes[key])
elif isinstance(RO._remote_attributes[key], fibre.remote_object.RemoteProperty):
# grab value of that property
# indicate if this property can be written or not
returnDict[key] = {"val": str(RO._remote_attributes[key].get_value()),
"readonly": not RO._remote_attributes[key]._can_write,
"type": str(RO._remote_attributes[key]._property_type.__name__)}
elif isinstance(RO._remote_attributes[key], fibre.remote_object.RemoteFunction):
# this is a function - do nothing for now.
returnDict[key] = "function"
else:
returnDict[key] = RO._remote_attributes[key]
return returnDict
# set a value from a POST http request
def postVal(odrives, keyList, value, argType):
# expect a list of keys in the form of ["key1", "key2", "keyN"]
# "key1" will be "odriveN"
# like this: postVal(odrives, ["odrive0","axis0","config","calibration_lockin","accel"], 17.0)
index = int(''.join([char for char in keyList.pop(0) if char.isnumeric()]))
RO = odrives[index]
for key in keyList:
RO = RO._remote_attributes[key]
if argType == "numeric":
RO.set_value(float(value))
elif argType == "boolean":
RO.set_value(value == "true")
else:
pass # dont support that type yet
def getVal(odrives, keyList):
index = int(''.join([char for char in keyList.pop(0) if char.isnumeric()]))
RO = odrives[index]
try:
for key in keyList:
RO = RO._remote_attributes[key]
if isinstance(RO, fibre.remote_object.RemoteObject):
return dictFromRO(RO)
else:
return RO.get_value()
except:
return 0
def getSampledData(vars):
#use getVal to populate a dict
#return a dict {path:value}
samples = {}
for path in vars["paths"]:
keys = path.split('.')
samples[path] = getVal(odrives, keys)
return samples
# call a function from a GET request
def callFunc(odrives, keyList):
index = int(''.join([char for char in keyList.pop(0) if char.isnumeric()]))
RO = odrives[index]
for key in keyList:
RO = RO._remote_attributes[key]
if isinstance(RO, fibre.remote_object.RemoteFunction):
RO.__call__()
if __name__ == "__main__":
print("args from python:")
print(sys.argv[1:])
# try to import based on command line arguments or config file
for optPath in sys.argv[1:]:
print("adding " + str(optPath.rstrip()) + " to import path for odrive_server.py")
sys.path.insert(0,optPath.rstrip())
import odrive
import odrive.utils # for dump_errors()
import fibre
# busy wait for connection
while len(odrives) == 0:
print("looking for odrives...")
odrives = get_all_odrives()
print("found odrives!")
for (index, odrv) in enumerate(odrives):
odriveDict["odrive" + str(index)] = dictFromRO(odrv)
socketio.run(app, host='0.0.0.0', port=5000)
+342
View File
@@ -0,0 +1,342 @@
<template>
<div id="app">
<!-- HEADER -->
<div class="header">
<button
class="dash-button"
@click="startsample"
v-bind:class="[{active: sampling === true}]"
>start sampling</button>
<button class="dash-button" @click="stopsample">stop sampling</button>
<button class="dash-button" @click="exportDash">export dash</button>
<button class="dash-button" @click="importDashWrapper">
import dash
<input
type="file"
id="inputDash"
ref="fileInput"
@change="importDashFile($event.target.files);$refs.fileInput.value=null"
value
style="display:none"
/>
</button>
<button
v-for="dash in dashboards"
v-bind:key="dash.id"
v-bind:class="['dash-button', { active: currentDash === dash.name}]"
v-on:click.self="currentDash = dash.name"
v-on:dblclick="changeDashName(dash.id)"
>
<button
v-if="dash.name !== 'Start' && dash.name !== 'Config' && dash.name !== 'Wizard'"
class="close-button"
v-on:click="deleteDash(dash.id)"
>X</button>
{{ dash.name }}
</button>
<button class="dash-button dash-add" @click="addDash">+</button>
<button class="emergency-stop" @click="estop">STOP</button>
</div>
<!-- PAGE CONTENT -->
<component
v-bind:is="currentDashName"
v-bind:odrives="odrives"
v-bind:dash="dash"
></component>
<!-- FOOTER -->
<div class="footer">
<Axis v-for="axis in axes" v-bind:key="axis.name" v-bind:axis="axis" v-bind:odrives="odrives"></Axis>
</div>
</div>
</template>
<script>
import Start from "./views/Start.vue";
import Dashboard from "./views/Dashboard.vue";
import Axis from "./components/Axis.vue";
import Wizard from "./views/Wizard.vue"
import * as socketio from "./comms/socketio";
import { saveAs } from "file-saver";
import ConfigDash from "./assets/dashboards/Config.json";
import { v4 as uuidv4 } from "uuid";
//let propSamplePeriod = 100; //sampling period for properties in ms
export default {
name: "App",
components: {
Start,
Dashboard,
Axis,
Wizard,
},
data: function () {
return {
currentDash: "Start",
};
},
computed: {
currentDashName: function () {
//get the appropriate component name from the currentDash variable
let comp = {};
for (const dash of this.dashboards) {
if (dash.name === this.currentDash) {
comp = dash.component;
}
}
return comp;
},
dash: function () {
let comp = {};
for (const dash of this.dashboards) {
if (dash.name === this.currentDash) {
comp = dash;
}
}
return comp;
},
currentCtrlList: function () {
let comp = {};
for (const dash of this.dashboards) {
if (dash.name === this.currentDash) {
comp = dash.controls;
}
}
return comp;
},
axes: function () {
return this.$store.state.axes;
},
odrives: function () {
return this.$store.state.odrives;
},
dashboards: function () {
return this.$store.state.dashboards;
},
sampling: function () {
return this.$store.state.sampling;
},
},
methods: {
updateOdrives() {
if (this.$store.state.serverConnected == true) {
//} && this.sampling == false) {
this.$store.dispatch("getOdrives");
}
setTimeout(() => {
this.updateOdrives();
}, 1000);
//console.log("updating data...");
},
addDash() {
let dashname = "Dashboard " + (this.dashboards.length - 2);
this.dashboards.push({
component: "Dashboard",
name: dashname,
id: uuidv4(),
controls: [],
actions: [],
plots: [],
});
},
deleteDash(dashID) {
this.currentDash = "Start";
console.log("Deleting dash " + dashID);
for (const dash of this.dashboards) {
if (dashID === dash.id) {
this.dashboards.splice(this.dashboards.indexOf(dash), 1);
}
}
},
exportDash() {
console.log("exporting dashboard");
const blob = new Blob([JSON.stringify(this.dash, null, 2)], {
type: "application/json",
});
saveAs(blob, this.dash.name);
},
importDashWrapper() {
const inputElem = document.getElementById("inputDash");
if (inputElem) {
console.log("importing dashboard");
inputElem.click();
}
},
importDashFile(files) {
console.log("file handler callback");
let file = files[0];
const reader = new FileReader();
// this is ugly, but it gets around scoping problems the "load" callback
let dashes = this.dashboards;
let addImportedDash = (dash) => {
console.log(dash);
dashes.push(dash);
// plots will have variables associated, add them to sampled variables list
for (const plot of dash.plots) {
console.log(plot);
for (const plotVar of plot.vars) {
console.log(plotVar);
//addsampledprop(path);
this.$store.commit("addSampledProperty", plotVar.path);
}
}
};
reader.addEventListener("load", function (e) {
addImportedDash(JSON.parse(e.target.result));
});
reader.readAsText(file);
},
changeDashName(e) {
console.log(e);
console.log("double clicked dashboard name");
},
startsample() {
socketio.sendEvent({
type: "sampledVarNames",
data: {
paths: this.$store.state.sampledProperties,
},
});
socketio.sendEvent({
type: "enableSampling",
});
this.$store.state.timeSampleStart = Date.now();
this.$store.state.sampling = true;
},
stopsample() {
socketio.sendEvent({
type: "stopSampling",
});
this.$store.state.sampling = false;
},
estop() {
// send stop command to odrives
// behavior on reset?
},
},
created() {
//grab full JSON
//this.getOdrives();
this.$store.dispatch("setServerAddress", "http://127.0.0.1:5000");
// connect to socketio on server for sampled data
this.updateOdrives();
this.dashboards.push(ConfigDash);
// plots will have variables associated, add them to sampled variables list
for (const plot of ConfigDash.plots) {
console.log(plot);
for (const plotVar of plot.vars) {
console.log(plotVar);
//addsampledprop(path);
this.$store.commit("addSampledProperty", plotVar.path);
}
}
},
};
</script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;700&family=Roboto:wght@400;700&display=swap");
@import "./assets/styles/vars.css";
@import "./assets/styles/style.css";
* {
/* font-family: Arial, Helvetica, sans-serif; */
font-family: "Roboto", sans-serif;
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
height: 100vh;
}
.header {
/* want this fixed and full width */
position: fixed;
top: 0px;
left: 0px;
width: 100vw;
display: flex;
background-color: var(--fg-color);
box-shadow: 0 0px 8px 0 rgba(0, 0, 0, 0.4);
z-index: 1;
}
button {
font-size: 1rem;
color: #2c3e50;
text-decoration: none;
padding: 10px;
background-color: var(--fg-color);
border-style: none;
outline: none;
}
.dash-button:active {
background-color: var(--bg-color);
}
.active {
color: #000000;
background-color: var(--bg-color);
}
.footer {
position: fixed;
width: 100vw;
left: 0px;
bottom: 0px;
display: flex;
background-color: var(--fg-color);
box-shadow: 0 0px 8px 0 rgba(0, 0, 0, 0.4);
z-index: 1;
}
.footer .left,
.right {
/* flex-grow: 1; */
display: flex;
background-color: var(--fg-color);
font-family: "Roboto Mono", monospace;
margin: auto 5px;
}
.odrvSer,
.errorState {
font-weight: bold;
font-family: "Roboto Mono", monospace;
margin: auto 5px;
background-color: var(--fg-color);
}
.errorState {
color: #13a100;
}
.dash-add {
font-weight: bold;
}
.parameter-button {
border-right: 1px solid lightgrey;
}
.emergency-stop {
margin-left: auto;
padding-left: 2rem;
padding-right: 2rem;
background-color: red;
font-weight: bold;
color: white;
display: none;
}
json-view {
z-index: 2;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
{
"name": "Config",
"component": "Dashboard",
"id": "deadb33f",
"controls": [
{
"controlType": "CtrlNumeric",
"path": "odrives.odrive0.axis0.motor.config.current_lim"
},
{
"controlType": "CtrlNumeric",
"path": "odrives.odrive0.axis0.controller.config.vel_limit"
},
{
"controlType": "CtrlNumeric",
"path": "odrives.odrive0.config.brake_resistance"
},
{
"controlType": "CtrlNumeric",
"path": "odrives.odrive0.axis0.motor.config.pole_pairs"
},
{
"controlType": "CtrlNumeric",
"path": "odrives.odrive0.axis0.encoder.config.cpr"
},
{
"controlType": "CtrlSlider",
"path": "odrives.odrive0.axis0.controller.config.pos_gain"
},
{
"controlType": "CtrlSlider",
"path": "odrives.odrive0.axis0.controller.config.vel_gain"
},
{
"controlType": "CtrlSlider",
"path": "odrives.odrive0.axis0.controller.config.vel_integrator_gain"
},
{
"controlType": "CtrlFunction",
"path": "odrives.odrive0.axis0.clear_errors"
},
{
"controlType": "CtrlFunction",
"path": "odrives.odrive0.save_configuration"
}
],
"actions": [
{
"id": "407aea0a-2b16-4d77-8043-2c43b27e4810",
"path": "odrives.odrive0.axis0.requested_state",
"val": 3
},
{
"id": "91b2c1b3-f1bb-4473-9e7a-dd89615cecc9",
"path": "odrives.odrive0.axis0.requested_state",
"val": 8
},
{
"id": "ad0ebe2a-a495-4e33-9cb8-b2ab17dc3a83",
"path": "odrives.odrive0.axis0.controller.pos_setpoint",
"val": 0
},
{
"id": "d7abba09-b0aa-438a-8220-6126e467004e",
"path": "odrives.odrive0.axis0.controller.pos_setpoint",
"val": 50000
}
],
"plots": [
{
"name": "65a2768d-e61e-4dd5-bf80-dba153838f74",
"vars": [
{
"path": "odrives.odrive0.axis0.controller.pos_setpoint",
"color": "#195bd7"
},
{
"path": "odrives.odrive0.axis0.encoder.pos_estimate",
"color": "#d6941a"
}
]
},
{
"name": "d9ee2474-3e53-408f-9eab-1656342eb531",
"vars": [
{
"path": "odrives.odrive0.axis0.controller.vel_setpoint",
"color": "#195bd7"
},
{
"path": "odrives.odrive0.axis0.encoder.vel_estimate",
"color": "#d6941a"
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+18
View File
@@ -0,0 +1,18 @@
.card {
padding: 10px;
margin: 10px;
background-color: #fff;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.4);
}
.close-button {
font-weight: bold;
cursor: pointer;
padding: 0 5px;
margin-right: 10px;
border: 1px solid black;
}
.close-button:active {
background-color: var(--bg-color);
}
+6
View File
@@ -0,0 +1,6 @@
:root {
--bg-color: rgb(235, 235, 235);
--fg-color: #fff;
--top-height: 39px;
--bottom-height: 29px;
}
+141
View File
@@ -0,0 +1,141 @@
'use strict'
import { app, protocol, BrowserWindow } from 'electron'
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'
const isDevelopment = process.env.NODE_ENV !== 'production'
const { spawnSync, execSync } = require('child_process');
const spawn = require('child_process').spawn;
const path = require('path');
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win
// Scheme must be registered before the app is ready
protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { secure: true, standard: true } }
])
// function to get determine correct command for python
function getPyCmd() {
let spawnRet = spawnSync('python',['-V']);
let vString;
if (spawnRet.stdout.toString().length > 1){
vString = spawnRet.stdout.toString();
}
else {
vString = spawnRet.stderr.toString();
}
if (vString.split(' ')[1].split('.')[0] == '2') {
return 'python3';
}
else {
return 'python';
}
}
function createWindow() {
// Spawn the python server
let scriptFilename = path.join(app.getAppPath(), '../server', 'odrive_server.py');
const args = process.argv;
let effectiveCommand = [];
effectiveCommand.push(scriptFilename);
if (app.isPackaged === true) {
for (const arg of args.slice(1)) {
effectiveCommand.push(arg);
}
}
else {
for (const arg of args.slice(2)) {
effectiveCommand.push(arg);
}
}
var python = spawn(getPyCmd(), effectiveCommand);
python.stdout.on('data',function(data) {
console.log(data.toString('utf8'));
});
python.stderr.on('data',function(data) {
console.log(data.toString('utf8'));
});
// Create the browser window.
win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
// Use pluginOptions.nodeIntegration, leave this alone
// See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info
nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION
}
})
win.maximize();
win.setMenu(null);
if (process.env.WEBPACK_DEV_SERVER_URL) {
// Load the url of the dev server if in development mode
win.loadURL(process.env.WEBPACK_DEV_SERVER_URL)
if (!process.env.IS_TEST) win.webContents.openDevTools()
} else {
createProtocol('app')
// Load the index.html when not in development
win.loadURL('app://./index.html')
}
win.on('closed', () => {
win = null
})
}
// Quit when all windows are closed.
app.on('window-all-closed', () => {
// kill flask server
if (process.platform !== 'win32') {
execSync('kill $(ps aux | grep \'[o]drive_server.py\' | awk \'{print $2}\')');
console.log("killed flask server");
}
// On macOS it is common for applications and their menu bar
// to stay active until the user quits explicitly with Cmd + Q
if (process.platform !== 'darwin') {
app.quit()
}
})
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (win === null) {
createWindow()
}
})
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', async () => {
if (isDevelopment && !process.env.IS_TEST) {
// Install Vue Devtools
try {
await installExtension(VUEJS_DEVTOOLS)
} catch (e) {
console.error('Vue Devtools failed to install:', e.toString())
}
}
createWindow()
})
// Exit cleanly on request from parent process in development mode.
if (isDevelopment) {
if (process.platform === 'win32') {
process.on('message', (data) => {
if (data === 'graceful-exit') {
app.quit()
}
})
} else {
process.on('SIGTERM', () => {
app.quit()
})
}
}
+33
View File
@@ -0,0 +1,33 @@
import io from 'socket.io-client';
let socket = undefined;
let url = 'https://0.0.0.0:8080';
function initSocket(url) {
socket = io(url);
}
export function setUrl(path) {
if (socket) {
socket.close();
socket = undefined;
}
url = path;
initSocket(url);
}
export function closeSocket() {
socket.close();
socket = undefined;
}
export function addEventListener(event) {
if (!socket) {
initSocket(url);
}
socket.on(event.type, event.callback);
}
export function sendEvent(event) {
socket.emit(event.type, event.data);
}
+216
View File
@@ -0,0 +1,216 @@
<template>
<div class="axis" @click="showError = !showError;" v-bind:class="{ noError: !axisError, error: axisError}">
{{ axis.name }}
<div v-show="showError" class="error-popup card">
axis: <span v-bind:class="{ noError: !axisError, error: axisError}">{{axisErrorMsg}}</span> <br>
motor: <span v-bind:class="{ noError: !motorError, error: motorError}">{{motorErrorMsg}}</span> <br>
encoder: <span v-bind:class="{ noError: !encoderError, error: encoderError}">{{encoderErrorMsg}}</span> <br>
controller: <span v-bind:class="{ noError: !controllerError, error: controllerError}">{{controllerErrorMsg}}</span>
</div>
</div>
</template>
<script>
const axisErrors = {
0x00000000: "AXIS_ERROR_NONE",
0x00000001: "AXIS_ERROR_INVALID_STATE",
0x00000002: "AXIS_ERROR_DC_BUS_UNDER_VOLTAGE",
0x00000004: "AXIS_ERROR_DC_BUS_OVER_VOLTAGE",
0x00000008: "AXIS_ERROR_CURRENT_MEASUREMENT_TIMEOUT",
0x00000010: "AXIS_ERROR_BRAKE_RESISTOR_DISARMED",
0x00000020: "AXIS_ERROR_MOTOR_DISARMED",
0x00000040: "AXIS_ERROR_MOTOR_FAILED",
0x00000080: "AXIS_ERROR_SENSORLESS_ESTIMATOR_FAILED",
0x00000100: "AXIS_ERROR_ENCODER_FAILED",
0x00000200: "AXIS_ERROR_CONTROLLER_FAILED",
0x00000400: "AXIS_ERROR_POS_CTRL_DURING_SENSORLESS",
0x00000800: "AXIS_ERROR_WATCHDOG_TIMER_EXPIRED",
0x00001000: "AXIS_ERROR_MIN_ENDSTOP_PRESSED",
0x00002000: "AXIS_ERROR_MAX_ENDSTOP_PRESSED",
0x00004000: "AXIS_ERROR_ESTOP_REQUESTED",
0x00020000: "AXIS_ERROR_HOMING_WITHOUT_ENDSTOP",
0x00040000: "AXIS_ERROR_OVER_TEMP",
};
const motorErrors = {
0x00000000: "MOTOR_ERROR_NONE",
0x00000001: "MOTOR_ERROR_PHASE_RESISTANCE_OUT_OF_RANGE",
0x00000002: "MOTOR_ERROR_PHASE_INDUCTANCE_OUT_OF_RANGE",
0x00000004: "MOTOR_ERROR_ADC_FAILED",
0x00000008: "MOTOR_ERROR_DRV_FAULT",
0x00000010: "MOTOR_ERROR_CONTROL_DEADLINE_MISSED",
0x00000020: "MOTOR_ERROR_NOT_IMPLEMENTED_MOTOR_TYPE",
0x00000040: "MOTOR_ERROR_BRAKE_CURRENT_OUT_OF_RANGE",
0x00000080: "MOTOR_ERROR_MODULATION_MAGNITUDE",
0x00000100: "MOTOR_ERROR_BRAKE_DEADTIME_VIOLATION",
0x00000200: "MOTOR_ERROR_UNEXPECTED_TIMER_CALLBACK",
0x00000400: "MOTOR_ERROR_CURRENT_SENSE_SATURATION",
0x00001000: "MOTOR_ERROR_CURRENT_LIMIT_VIOLATION",
0x00002000: "MOTOR_ERROR_BRAKE_DUTY_CYCLE_NAN",
0x00004000: "MOTOR_ERROR_DC_BUS_OVER_REGEN_CURRENT",
0x00008000: "MOTOR_ERROR_DC_BUS_OVER_CURRENT",
};
let encoderErrors = {
0x00000000: "ENCODER_ERROR_NONE",
0x00000001: "ENCODER_ERROR_UNSTABLE_GAIN",
0x00000002: "ENCODER_ERROR_CPR_POLEPAIRS_MISMATCH",
0x00000004: "ENCODER_ERROR_NO_RESPONSE",
0x00000008: "ENCODER_ERROR_UNSUPPORTED_ENCODER_MODE",
0x00000010: "ENCODER_ERROR_ILLEGAL_HALL_STATE",
0x00000020: "ENCODER_ERROR_INDEX_NOT_FOUND_YET",
0x00000040: "ENCODER_ERROR_ABS_SPI_TIMEOUT",
0x00000080: "ENCODER_ERROR_ABS_SPI_COM_FAIL",
0x00000100: "ENCODER_ERROR_ABS_SPI_NOT_READY",
};
let controllerErrors = {
0x00000000: "ENCODER_ERROR_NONE",
0x00000001: "ENCODER_ERROR_UNSTABLE_GAIN",
0x00000002: "ENCODER_ERROR_CPR_POLEPAIRS_MISMATCH",
0x00000004: "ENCODER_ERROR_NO_RESPONSE",
0x00000008: "ENCODER_ERROR_UNSUPPORTED_ENCODER_MODE",
0x00000010: "ENCODER_ERROR_ILLEGAL_HALL_STATE",
0x00000020: "ENCODER_ERROR_INDEX_NOT_FOUND_YET",
0x00000040: "ENCODER_ERROR_ABS_SPI_TIMEOUT",
0x00000080: "ENCODER_ERROR_ABS_SPI_COM_FAIL",
0x00000100: "ENCODER_ERROR_ABS_SPI_NOT_READY",
}
export default {
name: "Axis",
props: ["axis", "odrives"],
data() {
return {
showError: false,
};
},
computed: {
axisErrorMsg() {
let retMsg = "none";
let errCode = parseInt(this.axis.ref.error.val);
if (errCode != 0) {
// we got an error!
let errs = []
for (const errKey of Object.keys(axisErrors)) {
if (errCode & errKey) {
errs.push(axisErrors[errKey]);
}
}
retMsg = "";
for (const err of errs){
retMsg = retMsg + " " + err;
}
}
return retMsg;
},
motorErrorMsg() {
let retMsg = "none";
let errCode = parseInt(this.axis.ref.motor.error.val);
if (errCode != 0) {
// we got an error!
let errs = []
for (const errKey of Object.keys(motorErrors)) {
if (errCode & errKey) {
errs.push(motorErrors[errKey]);
}
}
retMsg = "";
for (const err of errs){
retMsg = retMsg + " " + err;
}
}
return retMsg;
},
encoderErrorMsg() {
let retMsg = "none";
let errCode = parseInt(this.axis.ref.encoder.error.val);
if (errCode != 0) {
// we got an error!
let errs = []
for (const errKey of Object.keys(encoderErrors)) {
if (errCode & errKey) {
errs.push(encoderErrors[errKey]);
}
}
retMsg = "";
for (const err of errs){
retMsg = retMsg + " " + err;
}
}
return retMsg;
},
controllerErrorMsg() {
let retMsg = "none";
let errCode = parseInt(this.axis.ref.controller.error.val);
if (errCode != 0) {
// we got an error!
let errs = []
for (const errKey of Object.keys(controllerErrors)) {
if (errCode & errKey) {
errs.push(controllerErrors[errKey]);
}
}
retMsg = "";
for (const err of errs){
retMsg = retMsg + " " + err;
}
}
return retMsg;
},
axisError() {
return this.axis.ref.error.val !== "0";
},
motorError() {
return this.axis.ref.motor.error.val !== "0";
},
encoderError() {
return this.axis.ref.encoder.error.val !== "0";
},
controllerError() {
return this.axis.ref.controller.error.val !== "0";
}
}
}
</script>
<style scoped>
.axis{
padding: 5px 10px;
border: 2px black;
cursor: pointer;
}
.axis:active {
background-color: var(--bg-color);
}
.noError {
color: green;
}
.error {
color: red;
font-weight: bold;
}
.error-popup {
position: absolute;
bottom: 2rem;
color: black;
margin-left: 0px;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="card action-card">
<button class="close-button" @click=deleteAction>X</button>
<span class="ctrlName">{{path}}:</span>
<div class="right">
<input type="number" v-on:change="newVal" :placeholder="initVal"/>
<button class="action-button close-button" @click="putVal">Go</button>
</div>
</div>
</template>
<script>
const axios = require("axios");
export default {
name: "Action",
props: {
id: String,
path: String,
initVal: Number,
dashID: String
},
data: function () {
return {
value: 0,
};
},
mounted() {
if (this.initVal !== undefined) {
this.value = this.initVal;
}
},
methods: {
newVal: function (e) {
this.value = parseFloat(e.target.value);
// emit a signal - signal needs to float all the way up to Dash
// set value of dash.actions.id.val to this.value
// this way, action values will be kept when dashes are imported or exported
//this.$emit('set-action-val', {id: this.id, val: this.value});
this.$store.commit("setActionVal", {dashID: this.dashID, actionID: this.id, val: this.value});
},
putVal: function () {
var params = new URLSearchParams();
let keys = this.path.split(".");
keys.shift();
for (const key of keys) {
params.append("key", key);
}
params.append("val", this.value);
params.append("type", "numeric");
console.log(params.toString());
let request = {
params: params,
};
console.log(request);
axios.put(
this.$store.state.odriveServerAddress + "/api/property",
null,
request
);
},
deleteAction: function() {
// commit a mutation to remove this action from the dashboard
this.$store.commit("removeActionFromDash", {dashID: this.dashID, actionID: this.id});
}
},
};
</script>
<style scoped>
input {
border-style: none;
border-bottom: 1px solid grey;
width: 5rem;
margin-left: 0.5rem;
margin-right: 0.5rem;
text-align: center;
}
.action-card {
display: flex;
/* border: 1px solid lightcoral; */
box-sizing: border-box;
}
.action-button {
margin-right: 0;
}
.right {
margin-left: auto;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
/* display: none; <- Crashes Chrome on hover */
-webkit-appearance: none;
margin: 0; /* <-- Apparently some margin are still there even though it's hidden */
}
input[type=number] {
-moz-appearance:textfield; /* Firefox */
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<template>
<div class="card">
<button class="close-button" @click=deleteCtrl>X</button>
<span class="ctrlName">{{name}}:</span>
<div class="right">
<span class="ctrlVal">{{value}}</span>
<input
class="ctrlInput"
v-if="writeAccess"
type="checkbox"
v-bind:value="value"
@click="putVal"
/>
</div>
</div>
</template>
<script>
const axios = require("axios");
export default {
name: "CtrlBoolean",
//type checking here for properties
props: {
path: String,
odrives: Object,
dashID: String,
},
computed: {
value: function () {
let keys = this.path.split(".");
keys.shift(); // don't need first key here
let odriveObj = this.odrives;
for (const key of keys) {
odriveObj = odriveObj[key];
}
return odriveObj["val"];
},
name: function () {
let keys = this.path.split(".");
keys.shift();
return keys.join(".");
},
writeAccess: function () {
let keys = this.path.split(".");
keys.shift(); // don't need first key here
let odriveObj = this.odrives;
for (const key of keys) {
odriveObj = odriveObj[key];
}
return odriveObj["readonly"] == false;
},
},
methods: {
putVal: function (e) {
console.log(e.target.checked);
var params = new URLSearchParams();
let keys = this.path.split(".");
keys.shift();
for (const key of keys) {
params.append("key", key);
}
params.append("val", e.target.checked);
params.append("type", "boolean");
console.log(params.toString());
let request = {
params: params,
};
axios.put(
this.$store.state.odriveServerAddress + "/api/property",
null,
request
);
},
deleteCtrl: function() {
// commit a mutation in the store with the relevant information
this.$store.commit("removeCtrlFromDash", {dashID: this.dashID, path: this.path});
}
},
};
</script>
<style scoped>
.ctrlVal {
font-weight: bold;
}
.ctrlInput {
margin-left: 10px;
}
.right {
display: flex;
flex-direction: row;
margin-left: auto;
}
.card {
display: flex;
}
</style>
@@ -0,0 +1,89 @@
<template>
<div class="card" @click.self="executeFunction">
<button class="close-button" @click=deleteCtrl>X</button>
<button class="execute">{{name}}()</button>
</div>
</template>
<script>
const axios = require("axios");
export default {
name: "CtrlFunction",
//type checking here for properties
props: {
path: String,
odrives: Object,
dashID: String,
},
computed: {
name: function() {
let keys = this.path.split(".");
keys.shift();
return keys.join(".");
}
},
methods: {
putVal: function(e) {
var params = new URLSearchParams();
let keys = this.path.split(".");
keys.shift();
for (const key of keys) {
params.append("key", key);
}
params.append("val", e.target.value);
console.log(params.toString());
let request = {
params: params
};
console.log(request);
axios.put(
this.$store.state.odriveServerAddress + "/api/property",
null,
request
);
},
executeFunction: function(e) {
//execute this function on the odrive
console.log(e);
var params = new URLSearchParams();
let keys = this.path.split(".");
keys.shift();
for (const key of keys) {
params.append("key", key);
}
console.log(params.toString());
let request = {
params: params
};
axios.put(
this.$store.state.odriveServerAddress + "/api/function",
null,
request
);
},
deleteCtrl: function() {
// commit a mutation in the store with the relevant information
this.$store.commit("removeCtrlFromDash", {dashID: this.dashID, path: this.path});
}
}
};
</script>
<style scoped>
.card {
background-color: lightcyan;
}
.execute {
color: black;
font-family: "Roboto Mono", monospace;
margin: 0;
padding: 0;
background-color: rgba(0, 0, 0, 0);
}
.card:active {
background-color: lightblue;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div class="card">
<button class="close-button" @click=deleteCtrl>X</button>
<span class="ctrlName">{{name}}:</span>
<div class="right">
<span class="ctrlVal">{{value}}</span>
<input v-if="writeAccess" type="number" v-on:change="putVal" />
</div>
</div>
</template>
<script>
const axios = require("axios");
export default {
name: "CtrlNumeric",
//type checking here for properties
props: {
path: String,
odrives: Object,
dashID: String
},
computed: {
value: function () {
let keys = this.path.split(".");
keys.shift(); // don't need first key here
let odriveObj = this.$store.state.odrives;
for (const key of keys) {
odriveObj = odriveObj[key];
}
return parseFloat(odriveObj["val"]).toFixed(3);
},
name: function () {
let keys = this.path.split(".");
keys.shift();
return keys.join(".");
},
writeAccess: function () {
let keys = this.path.split(".");
keys.shift(); // don't need first key here
let odriveObj = this.odrives;
for (const key of keys) {
odriveObj = odriveObj[key];
}
return odriveObj["readonly"] === false;
},
},
methods: {
putVal: function (e) {
var params = new URLSearchParams();
let keys = this.path.split(".");
keys.shift();
for (const key of keys) {
params.append("key", key);
}
params.append("val", e.target.value);
params.append("type", "numeric");
console.log(params.toString());
let request = {
params: params,
};
console.log(request);
axios.put(
this.$store.state.odriveServerAddress + "/api/property",
null,
request
);
},
deleteCtrl: function() {
// commit a mutation in the store with the relevant information
this.$store.commit("removeCtrlFromDash", {dashID: this.dashID, path: this.path});
}
},
};
</script>
<style scoped>
.ctrlVal {
font-weight: bold;
}
input {
width: 5rem;
font-family: inherit;
border-style: none;
border-bottom: 1px solid grey;
text-align: center;
}
.right {
display: flex;
flex-direction: row;
margin-left: auto;
}
.card {
display: flex;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
/* display: none; <- Crashes Chrome on hover */
-webkit-appearance: none;
margin: 0; /* <-- Apparently some margin are still there even though it's hidden */
}
input[type=number] {
-moz-appearance:textfield; /* Firefox */
}
</style>
+153
View File
@@ -0,0 +1,153 @@
<template>
<div class="card">
<div>
<button class="close-button" @click=deleteCtrl>X</button>
<span class="ctrlName">{{name}}</span>
</div>
<div class="slider-container">
<input type="number" :value="min" v-on:change="setMin"/>
<!-- <vue-slider v-model="value" :min="min" :max="max" :interval="interval" /> -->
<vue-slider v-model="value" :data="data" @change="putVal"/>
<input type="number" :value="max" v-on:change="setMax" />
</div>
</div>
</template>
<script>
import VueSlider from "vue-slider-component";
import "vue-slider-component/theme/default.css";
const axios = require("axios");
export default {
name: "CtrlSlider",
components: {
VueSlider,
},
//type checking here for properties
props: {
path: String,
odrives: Object,
dashID: String,
},
data: function () {
return {
value: 0,
min: 0,
max: 1,
data: []
};
},
computed: {
name: function () {
let keys = this.path.split(".");
keys.shift();
return keys.join(".");
},
writeAccess: function () {
let keys = this.path.split(".");
keys.shift(); // don't need first key here
let odriveObj = this.odrives;
for (const key of keys) {
odriveObj = odriveObj[key];
}
return odriveObj["readonly"] === false;
},
interval: function () {
return (this.max - this.min) / 100;
},
sliderData: function () {
let interval = (this.max - this.min) / 100;
return Array.from(Array(101), (_, i) => this.min + interval * i);
}
},
methods: {
putVal: function (value, index) {
console.log(value);
console.log(index);
var params = new URLSearchParams();
let keys = this.path.split(".");
keys.shift();
for (const key of keys) {
params.append("key", key);
}
params.append("val", value);
params.append("type", "numeric");
console.log(params.toString());
let request = {
params: params,
};
console.log(request);
axios.put(
this.$store.state.odriveServerAddress + "/api/property",
null,
request
);
},
setMin: function (e) {
this.min = parseFloat(e.target.value);
this.data = Array.from(Array(101), (_, i) => this.min + (this.max-this.min) / 100 * i);
},
setMax: function (e) {
this.max = parseFloat(e.target.value);
this.data = Array.from(Array(101), (_, i) => this.min + (this.max-this.min) / 100 * i);
},
deleteCtrl: function() {
// commit a mutation in the store with the relevant information
this.$store.commit("removeCtrlFromDash", {dashID: this.dashID, path: this.path});
}
},
mounted() {
let initVal = () => {
let keys = this.path.split(".");
keys.shift(); // don't need first key here
let odriveObj = this.$store.state.odrives;
for (const key of keys) {
odriveObj = odriveObj[key];
}
return parseFloat(odriveObj["val"]);
};
this.value = initVal();
this.max = this.value * 4;
this.min = this.value / 4;
this.data = Array.from(Array(101), (_, i) => this.min + (this.max-this.min) / 100 * i);
},
};
</script>
<style scoped>
.ctrlVal {
font-weight: bold;
}
.slider-container {
display: flex;
flex-direction: row;
margin-top: 0.4rem;
}
.vue-slider {
flex-grow: 3;
margin-left: 0.4rem;
margin-right: 0.4rem;
z-index: 0;
}
input {
width: 5rem;
font-family: inherit;
border-style: none;
border-bottom: 1px solid grey;
text-align: center;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
/* display: none; <- Crashes Chrome on hover */
-webkit-appearance: none;
margin: 0; /* <-- Apparently some margin are still there even though it's hidden */
}
input[type=number] {
-moz-appearance:textfield; /* Firefox */
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import { Line, mixins } from 'vue-chartjs';
import 'chartjs-plugin-streaming';
const { reactiveProp } = mixins;
export default {
extends: Line,
mixins: [reactiveProp],
props: ['options'],
mounted () {
// this.chartData is created in the mixin.
// If you want to pass options please create a local options object
this.renderChart(this.chartData, this.options)
}
}
+173
View File
@@ -0,0 +1,173 @@
<template>
<div class="card plot">
<div class="plot-header">
<button class="close-button" @click="deletePlot">X</button>
<button class="close-button" @click="exportCSV">Export</button>
<button class="close-button" @click="$emit('add-var', name)">+</button>
</div>
<line-chart v-if="loaded" :chart-data="datacollection" :options="dataOptions"></line-chart>
</div>
</template>
<script>
import LineChart from "./LineChart.js";
import { saveAs } from "file-saver";
export default {
components: {
LineChart,
},
props: ["name", "plot", "dashID"],
data() {
return {
datacollection: null,
timeStart: null,
loaded: false,
dataOptions: {
animation: {
duration: 0, // general animation time
},
hover: {
animationDuration: 0, // duration of animations when hovering an item
},
responsiveAnimationDuration: 0, // animation duration after a resize
elements: {
point: {
radius: 0,
},
line: {
// borderColor: "rbga(0,0,0,0)",
fill: false,
borderWidth: 0,
tension: 0,
},
},
responsive: true,
maintainAspectRatio: false,
},
};
},
mounted() {
this.timeStart = Date.now();
//this.initData(); //set label for dataset and color
this.fillData();
this.liveData();
},
methods: {
fillData() {
let newData = {
labels: this.$store.state.propSamples["time"],
datasets: [],
};
for (const plotVar of this.plot.vars) {
let newPath = plotVar.path.split(".");
newPath.splice(0, 1);
newPath = newPath.join(".");
newData.datasets.push({
label: newPath,
borderColor: plotVar.color, //"rgba(0,0,0,0)",
data: this.$store.state.propSamples[newPath],
});
}
this.datacollection = newData;
this.loaded = true;
},
initData() {
this.datacollection = {
labels: [0],
datasets: [
{
label: "Sine wave",
backgroundColor: "rgba(0,0,0,0)", //"#f87979",
data: [0],
},
],
};
},
liveData() {
setTimeout(() => {
this.liveData();
}, 50);
//if (this.$store.state.sampling == true) {
// this.fillData();
//}
this.fillData();
},
deletePlot: function () {
// commit a mutation in the store with the relevant information
this.$store.commit("removePlotFromDash", {
dashID: this.dashID,
plotID: this.name,
});
},
exportCSV: function () {
// make a sensible data structure
let csvData = {};
csvData["time"] = this.$store.state.propSamples["time"];
for (const dataset of this.datacollection.datasets) {
csvData[dataset.label] = dataset.data;
}
let csvString = "";
let dataKeys = Object.keys(csvData);
// turn data structure into a string that's suitable for exporting
for (const label of dataKeys) {
csvString += label;
if (label != dataKeys.slice(-1)) {
csvString += ",";
} else {
csvString += "\n";
}
}
for (let idx = 0; idx < csvData["time"].length; idx = idx + 1) {
for (const label of dataKeys) {
csvString += csvData[label][idx];
if (label != dataKeys.slice(-1)) {
csvString += ",";
} else {
csvString += "\n";
}
}
}
var blob = new Blob([csvString], { type: "text/plain;charset=utf-8" });
saveAs(blob, "plot.csv");
console.log("exporting plot");
},
},
};
</script>
<style scoped>
div {
position: relative;
}
.plot {
z-index: 0;
}
.plot-header {
display: flex;
}
.plotname {
flex-grow: 10;
margin: auto 0;
}
.delete {
font-weight: bold;
cursor: pointer;
padding: 0 5px;
margin-right: 10px;
border: 1px solid black;
}
.add-var {
font-weight: bold;
cursor: pointer;
padding: 0 5px;
margin-right: 10px;
border: 1px solid black;
}
</style>
+57
View File
@@ -0,0 +1,57 @@
<template>
<div class="wizardAxis card">
<div class="title">Which axis are you configuring?</div>
<div class="choices">
<div class="choice card">
M0
</div>
<div class="choice card">
M1
</div>
<div class="choice card">
M0 and M1
</div>
</div>
</div>
</template>
<script>
export default {
name: "wizardAxis"
}
</script>
<style scoped>
.wizardAxis {
display: flex;
flex-direction: column;
text-align: center;
}
.choices {
display: flex;
flex-direction: row;
}
.choice {
text-align: center;
display: flex;
flex-direction: column;
margin: 2rem;
}
.chosen {
border: 2px solid black;
}
.unchosen {
border: 2px solid transparent;
}
img {
padding: 0px;
margin: auto;
}
</style>
@@ -0,0 +1,64 @@
<template>
<div class="wizardEncoder card">
<div class="title">What encoder are you using?</div>
<div class="choices">
<div class="choice card">
CUI AMT102-V
</div>
<div class="choice card">
Incremental
</div>
<div class="choice card">
Incremental with index
</div>
<div class="choice card">
Absolute
</div>
<div class="choice card">
Hall Effect
</div>
<div class="choice card">
Sin/Cos
</div>
</div>
</div>
</template>
<script>
export default {
name: "wizardEncoder",
}
</script>
<style scoped>
.wizardEncoder {
display: flex;
flex-direction: column;
text-align: center;
}
.choices {
display: flex;
flex-direction: row;
}
.choice {
text-align: center;
display: flex;
flex-direction: column;
margin: 2rem;
}
.chosen {
border: 2px solid black;
}
.unchosen {
border: 2px solid transparent;
}
img {
padding: 0px;
margin: auto;
}
</style>
@@ -0,0 +1,13 @@
<template>
<div class="wizardLimits card">What are your limits?</div>
</template>
<script>
export default {
name: "wizardLimits",
}
</script>
<style scoped>
</style>
+56
View File
@@ -0,0 +1,56 @@
<template>
<div class="wizardMotor card">
<div class="title">What motor are you using?</div>
<div class="choices">
<div class="choice card">
ODrive D5065
</div>
<div class="choice card">
ODrive D6374
</div>
<div class="choice card">
Other
</div>
</div>
</div>
</template>
<script>
export default {
name: "wizardMotor",
}
</script>
<style scoped>
.wizardMotor {
display: flex;
flex-direction: column;
text-align: center;
}
.choices {
display: flex;
flex-direction: row;
}
.choice {
text-align: center;
display: flex;
flex-direction: column;
margin: 2rem;
}
.chosen {
border: 2px solid black;
}
.unchosen {
border: 2px solid transparent;
}
img {
padding: 0px;
margin: auto;
}
</style>
@@ -0,0 +1,80 @@
<template>
<div class="wizardODrive card">
<div class="title">What ODrive do you have?</div>
<div class="choices">
<div class="choice card" v-bind:class="{ unchosen: !select_24V, chosen: select_24V}" @click="select('24V')">
<img alt="Odrive 24V" src="../../assets/images/24v_200_200.png" />
ODrive v3.6 24V
</div>
<div class="choice card" v-bind:class="{ unchosen: !select_56V, chosen: select_56V}" @click="select('56V')">
<img alt="Odrive 56V" src="../../assets/images/56v_200_200.png" />
ODrive v3.6 56V
</div>
</div>
</div>
</template>
<script>
export default {
name: "wizardODrive",
data: function() {
return {
select_24V: false,
select_56V: false,
}
},
methods: {
select(choice) {
this.select_24V = false;
this.select_56V = false;
if (choice == '24V') {
this.select_24V = true;
this.$emit('choice-odrive-24v');
}
if (choice == '56V') {
this.$emit('choice-odrive-56v');
this.select_56V = true;
}
}
}
};
</script>
<style scoped>
* {
box-sizing: border-box;
}
.wizardODrive {
display: flex;
flex-direction: column;
text-align: center;
}
.choices {
display: flex;
flex-direction: row;
}
.choice {
text-align: center;
display: flex;
flex-direction: column;
margin: 2rem;
}
.chosen {
border: 2px solid black;
}
.unchosen {
border: 2px solid transparent;
}
img {
padding: 0px;
margin: auto;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
import Vue from 'vue'
import App from './App.vue'
import store from './store';
Vue.config.productionTip = false
new Vue({
store,
render: h => h(App)
}).$mount('#app')
+266
View File
@@ -0,0 +1,266 @@
import Vue from 'vue';
import Vuex from 'vuex';
const axios = require('axios');
import * as socketio from "./comms/socketio";
//import { v4 as uuidv4 } from "uuid";
Vue.use(Vuex);
export default new Vuex.Store({
// state is the data for this app
state: {
odrives: Object,
odriveConfigs: Object,
axes: Array,
odriveServerAddress: String,
serverConnected: Boolean,
dashboards: [
{
name: "Start",
component: "Start",
},
{
name: "Wizard",
component: "Wizard",
},
//{ name: "Config", id: uuidv4(), component: "Dashboard", controls: [], actions: [], plots: [] }
],
timeSampleStart: 0,
sampledProperties: [], // make this an object where the full path is a key and the value is the sampled var
propSamples: { time: [] }, // {time: [time values], ...path: [path var values]}
newData: false,
sampling: false,
},
// mutations are functions that change the data
mutations: {
setOdrives(state, odrives) {
state.odrives = odrives;
},
setOdriveConfigs(state, odriveConfigs) {
state.odriveConfigs = odriveConfigs;
},
setAxes(state, axes) {
state.axes = axes;
},
setServerAddress(state, address) {
state.odriveServerAddress = address;
},
updateOdriveProp(state, payload) {
// need to use Vue.set!!!
// payload is {path, value}
//
const createNestedObject = (odrive, path) => {
let ref = odrive;
let keys = path.split('.');
for (const key of keys) {
ref = ref[key];
}
return ref;
};
Vue.set(createNestedObject(state.odrives, payload.path), "val", payload.value);
},
addSampledProperty(state, path) {
if (!(path in state.sampledProperties)) {
let newPath = path.split('.');
newPath.splice(0, 1);
state.sampledProperties.push(newPath.join('.'));
state.propSamples[newPath.join('.')] = [];
console.log(state.propSamples);
}
for (const path of state.sampledProperties) {
console.log(path);
}
socketio.sendEvent({
type: 'sampledVarNames',
data: {
paths: state.sampledProperties
}
});
},
removeSampledProperty(state, path) {
let newPath = path.split('.');
newPath.splice(0, 1);
const index = state.sampledProperties.indexOf(newPath.join('.'));
if (index > -1) {
state.sampledProperties.splice(index, 1);
}
},
updateSampledProperty(state, payload) {
// payload is object of paths and values
for (const path of Object.keys(payload)) {
state.propSamples[path].push(payload[path]);
if (state.propSamples[path].length > 250) {
state.propSamples[path].splice(0, 1); // emulate circular buffer
}
}
state.propSamples["time"].push((Date.now() - state.timeSampleStart) / 1000);
if (state.propSamples["time"].length > 250) {
state.propSamples["time"].splice(0, 1);
}
state.newData = true;
},
setServerStatus(state, val) {
state.serverConnected = val;
},
removeCtrlFromDash(state, obj) {
// obj is {dash: dashID, path: control path}
for (const dash of state.dashboards) {
if (obj.dashID == dash.id) {
for (const control of dash.controls) {
if (obj.path == control.path) {
dash.controls.splice(dash.controls.indexOf(control), 1);
break;
}
}
break;
}
}
},
removeActionFromDash(state, obj) {
// obj is {dashID: dashID, actionID: action ID}
for (const dash of state.dashboards) {
if (obj.dashID == dash.id) {
for (const action of dash.actions) {
if (obj.actionID == action.id) {
dash.actions.splice(dash.actions.indexOf(action), 1);
break;
}
}
break;
}
}
},
removePlotFromDash(state, obj) {
// obj is {dashID: dash ID, plotID: plot ID}
for (const dash of state.dashboards) {
if (obj.dashID == dash.id) {
for (const plot of dash.plots) {
if (obj.plotID == plot.name) {
dash.plots.splice(dash.plots.indexOf(plot), 1);
break;
}
}
break;
}
}
},
setActionVal(state, obj) {
// obj is {dashID: dash ID, actionID: action ID, val: val}
for (const dash of state.dashboards) {
if (obj.dashID == dash.id) {
for (const action of dash.actions) {
if (obj.actionID == action.id) {
action.val = obj.val;
console.log("Setting action val to " + obj.val);
break;
}
}
break;
}
}
},
},
// actions trigger mutations
actions: {
getOdrives(context) {
// grab ODrive JSON from odrive_server
axios.get(context.state.odriveServerAddress + '/api/odrives').then((response) => {
context.commit('setOdrives', JSON.parse(JSON.stringify(response.data)));
context.dispatch('getOdriveConfigs');
context.dispatch('getAxes');
});
},
getOdriveConfigs(context) {
// transform ODrive JSON
function treeParse(odriveObj) {
let retObj = {};
for (const key of Object.keys(odriveObj)) {
if (typeof odriveObj[key] === 'object' && odriveObj[key] !== null) {
// check if "val" is a valid key
if (Object.prototype.hasOwnProperty.call(odriveObj[key], "val")) {
// parse from string to a type that we care about
switch (odriveObj[key]["type"]) {
case "float":
retObj[key] = parseFloat(parseFloat(odriveObj[key]["val"]).toFixed(3));
break;
case "int":
retObj[key] = parseInt(odriveObj[key]["val"]);
break;
case "bool":
retObj[key] = odriveObj[key]["val"] == 'True';
break;
default:
retObj[key] = odriveObj[key]["val"];
}
}
else {
retObj[key] = treeParse(odriveObj[key]);
}
}
else if (odriveObj[key] == "function") {
retObj[key] = "function";
}
else {
retObj[key] = odriveObj[key];
}
}
return retObj;
}
context.commit('setOdriveConfigs', treeParse(context.state.odrives));
},
getAxes(context) {
let axes = [];
//for each connected odrive, collect axes and display them
for (const odrive of Object.keys(context.state.odrives)) {
if ('axis0' in context.state.odrives[odrive]) {
axes.push({
name: `${odrive}.axis0`,
ref: context.state.odrives[odrive]['axis0']
});
}
if ('axis1' in context.state.odrives[odrive]) {
axes.push({
name: `${odrive}.axis1`,
ref: context.state.odrives[odrive]['axis1']
});
}
}
context.commit('setAxes', axes);
},
setServerAddress(context, address) {
context.commit('setServerAddress', address);
socketio.setUrl(address);
socketio.addEventListener({
type: "connect",
callback: () => {
context.commit("setServerStatus", true);
console.log('connected to server');
}
});
socketio.addEventListener({
type: "disconnect",
callback: () => {
context.commit("setServerStatus", false);
console.log('server disconnect');
socketio.closeSocket();
context.commit('setAxes', []);
}
});
socketio.addEventListener({
type: "sampledData",
callback: message => {
context.commit("updateSampledProperty", JSON.parse(message));
}
});
socketio.addEventListener({
type: "samplingEnabled",
callback: () => {
socketio.sendEvent({
type: "startSampling"
});
}
});
}
}
})
+276
View File
@@ -0,0 +1,276 @@
<template>
<div class="dashboard">
<!-- PARAMETER DROPDOWN MENU -->
<div v-show="paramsVisible" class="dropdown">
<div class="card dropdown-content">
<div>
<button class="close-button" @click="hideTree">X</button>
Parameters
</div>
<json-view
v-bind:data="odriveConfigs"
v-bind:rootKey="'odrives'"
v-on:selected="addVarToElement"
/>
</div>
</div>
<div class="dashboard_container">
<div class="controls">
<template v-for="(control, index) in dash.controls">
<component
:is="control.controlType"
:key="index + '-control'"
:path="control.path"
:name="control.name"
:odrives="odrives"
:dashID="dash.id"
/>
</template>
<div class="control-buttons">
<div class="add-button card" @click="addComponent('control')">Add Control</div>
<div class="add-button card" @click="addComponent('slider')">Add Slider</div>
</div>
<template v-for="(action, index) in dash.actions">
<action
:id="action.id"
:key="index + '-action'"
:path="action.path"
:odrives="odrives"
:initVal="action.val"
:dashID="dash.id"
/>
</template>
<div class="add-button card" @click="addComponent('action')">Add Action</div>
</div>
<div class="plots">
<template v-for="(plot, index) in dash.plots">
<plot
:plot="plot"
:key="index"
:name="plot.name"
:dashID="dash.id"
v-on:add-var="currentPlot=plot.name;addComponent('plot')"
/>
</template>
<div class="add-button card" @click="addPlot">Add Plot</div>
</div>
</div>
</div>
</template>
<script>
//this component will get passed a list of controls and plots
//display controls on the left and plots on the right?
//leave full names for plots
//collate controls into individual cards based on the deepest common level
import CtrlBoolean from "../components/controls/CtrlBoolean.vue";
import CtrlNumeric from "../components/controls/CtrlNumeric.vue";
import CtrlFunction from "../components/controls/CtrlFunction.vue";
import CtrlSlider from "../components/controls/CtrlSlider.vue";
import Plot from "../components/plots/Plot.vue";
import Action from "../components/actions/Action.vue";
import { JSONView } from "vue-json-component";
import { v4 as uuidv4 } from "uuid";
let plotColors = [
"#195bd7", // blue
"#d6941a", // orange
"#1ad636", // green
"#d61aba", // purple
"#d5241a", // red
];
export default {
name: "Dashboard",
components: {
CtrlBoolean,
CtrlNumeric,
CtrlFunction,
CtrlSlider,
Plot,
Action,
"json-view": JSONView,
},
props: ["dash", "odrives"],
data() {
return {
paramsVisible: false,
addCompType: undefined,
currentPlot: undefined,
};
},
computed: {
odriveConfigs: function () {
return this.$store.state.odriveConfigs;
},
},
methods: {
deleteAction(e) {
this.$emit("delete-action", e);
console.log(e);
},
deletePlot(e) {
this.$emit("delete-plot", e);
},
addVar(e) {
this.$emit("add-var", e);
},
showTree() {
//show the parameter tree
this.paramsVisible = true;
},
hideTree() {
this.paramsVisible = false;
this.addCompType = undefined;
},
addComponent(componentType) {
this.addCompType = componentType;
this.paramsVisible = true;
},
addVarToElement(e) {
//when the parameter tree is open and a parameter is clicked,
//add the clicked parameter to the list of controls for the
//current dashboard
switch (this.addCompType) {
case "control":
switch (typeof e.value) {
case "boolean":
this.dash.controls.push({
controlType: "CtrlBoolean",
path: e.path,
});
//this.$store.commit("addSampledProperty", e.path);
break;
case "number":
this.dash.controls.push({
controlType: "CtrlNumeric",
path: e.path,
});
//this.$store.commit("addSampledProperty", e.path);
break;
case "string":
this.dash.controls.push({
controlType: "CtrlFunction",
path: e.path,
});
break;
default:
break;
}
break;
case "plot":
// add the selected element to the plot var list
// add the selected element to the sampling var list
// find the plot, append path to plot.vars
console.log(e);
for (const plot of this.dash.plots) {
if (plot.name == this.currentPlot) {
plot.vars.push({
path: e.path,
color: plotColors[plot.vars.length % plotColors.length],
});
this.$store.commit("addSampledProperty", e.path);
console.log(plot);
break;
}
}
break;
case "action":
{
// add an action to the current dash
let id = uuidv4();
this.dash.actions.push({
id: id,
path: e.path,
val: undefined,
});
}
break;
case "slider":
// add a slider to the list of controls if the selected item is valid (numeric)
switch (typeof e.value) {
case "number":
this.dash.controls.push({
controlType: "CtrlSlider",
path: e.path,
});
break;
default:
break;
}
}
},
addPlot() {
let plotId = uuidv4();
this.dash.plots.push({
name: plotId,
vars: [],
});
},
},
};
</script>
<style scoped>
.dashboard {
background-color: var(--bg-color);
height: 100vh;
max-height: 100vh;
width: 100vw;
padding-top: var(--top-height);
padding-bottom: var(--bottom-height);
}
.dashboard_container {
display: flex;
flex-direction: row;
height: 95vh;
}
.controls {
flex-grow: 1;
border-right: 1px solid lightgrey;
}
.add-button {
background-color: lightcyan;
width: 120px;
margin: 10px auto;
margin-left: 10px;
margin-right: 10px;
text-align: center;
border-radius: 20px;
cursor: pointer;
user-select: none;
}
.add-button:active {
background-color: lightblue;
}
.plots {
flex-grow: 1;
max-height: 94vh;
overflow-y: scroll;
display: flex;
flex-direction: column;
}
.control-buttons {
display: flex;
flex-direction: row;
}
.dropdown {
position: absolute;
padding: var(--top-height) 0;
display: inline-block;
}
.dropdown-content {
position: absolute;
z-index: 1;
padding: var(--top-height) 0;
max-height: 90vh;
overflow-y: scroll;
}
</style>
+89
View File
@@ -0,0 +1,89 @@
<template>
<div class="home">
<div class="logo">
<img alt="Odrive Logo" src="../assets/odrive_logo.png">
</div>
<div class="home_text">
To set up your ODrive, connect it and power it up.
</div>
<div class="connected-container">
<input type="text" v-bind:class="{ notConnected: notConnected, connected: connected}" v-on:change="setUrl" :value="serverAddress">
</div>
</div>
</template>
<script>
export default {
name: 'Home',
components: {
},
computed: {
connected() {
return this.$store.state.serverConnected == true;
},
notConnected() {
return this.$store.state.serverConnected != true;
},
serverAddress() {
return this.$store.state.odriveServerAddress;
}
},
methods: {
setUrl(e) {
console.log(e.target.value);
this.$store.dispatch("setServerAddress", e.target.value);
}
}
}
</script>
<style>
.home {
padding: 10% 0;
margin: auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--bg-color);
height: 100vh;
}
img {
margin: auto;
display: block;
max-width: 50%;
width: auto;
height: auto;
}
.home_text {
font-size: 1.5rem;
padding: 20px;
}
.connected, .notConnected {
width: 40vw;
margin: 0;
padding: 10px;
border: none;
border-bottom: 1px solid lightgrey;
font-size: 1.5rem;
background-color: var(--bg-color);
text-align: center;
}
input:focus {
outline: none;
}
.connected {
border-bottom: 1px solid lightgreen;
background-color: lightgreen;
}
.notConnected {
border-bottom: 1px solid red;
}
</style>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div class="wizard">
<div class="wizard-container">
<div class="wizard-page">
<div>
<!-- show the appropriate component -->
<component v-bind:is="currentPage"></component>
</div>
<div class="wizard-controls">
<!-- show breadcrumbs, back, apply, next buttons -->
<button class="wizBtn card" @click="back">BACK</button>
<button class="wizBtn card" @click="apply">APPLY</button>
<button class="wizBtn card" @click="next">NEXT</button>
</div>
</div>
</div>
</div>
</template>
<script>
import wizardODrive from "../components/wizard/wizardODrive.vue";
import wizardAxis from "../components/wizard/wizardAxis.vue";
import wizardMotor from "../components/wizard/wizardMotor.vue";
import wizardEncoder from "../components/wizard/wizardEncoder.vue";
import wizardLimits from "../components/wizard/wizardLimits.vue";
let wizard_states = {
PICK_ODRIVE: {
val: 0,
page: "wizardODrive"
},
PICK_AXIS: {
val: 1,
page: "wizardAxis",
},
MOTOR_0: {
val: 2,
page: "wizardMotor",
},
ENCODER_0: {
val: 3,
page: "wizardEncoder",
},
MOTOR_1: {
val: 4,
page: "wizardMotor",
},
ENCODER_1: {
val: 5,
page: "wizardEncoder",
},
LIMITS_0: {
val: 6,
page: "wizardLimits",
},
LIMITS_1: {
val: 7,
page: "wizardLimits",
},
};
export default {
name: "Wizard",
components: {
wizardODrive,
wizardAxis,
wizardMotor,
wizardEncoder,
wizardLimits,
},
props: [],
data: function () {
return {
currentStep: wizard_states.PICK_ODRIVE,
};
},
computed: {
currentPage() {
return this.currentStep.page;
},
},
methods: {
next() {
// go to the next page
let state = this.currentStep;
switch (this.currentStep) {
case wizard_states.PICK_ODRIVE:
this.currentStep = wizard_states.PICK_AXIS;
break;
case wizard_states.PICK_AXIS:
this.currentStep = wizard_states.MOTOR_0;
break;
case wizard_states.MOTOR_0:
this.currentStep = wizard_states.ENCODER_0;
break;
case wizard_states.ENCODER_0:
this.currentStep = wizard_states.LIMITS_0;
break;
case wizard_states.LIMITS_0:
break;
default:
break;
}
console.log("Going from " + state.val + " to " + this.currentStep.val);
},
apply() {
// apply settings to odrive
console.log("applying config");
},
back() {
//go to the previous page
let state = this.currentStep;
switch (this.currentStep) {
case wizard_states.PICK_ODRIVE:
break;
case wizard_states.PICK_AXIS:
this.currentStep = wizard_states.PICK_ODRIVE;
break;
case wizard_states.MOTOR_0:
this.currentStep = wizard_states.PICK_AXIS;
break;
case wizard_states.ENCODER_0:
this.currentStep = wizard_states.MOTOR_0;
break;
case wizard_states.LIMITS_0:
this.currentStep = wizard_states.ENCODER_0;
break;
default:
break;
}
console.log("Going from " + state.val + " to " + this.currentStep.val);
},
},
};
</script>
<style scoped>
.wizard {
background-color: var(--bg-color);
height: 100vh;
max-height: 100vh;
width: 100vw;
padding-top: var(--top-height);
padding-bottom: var(--bottom-height);
}
.wizard-container {
display: flex;
flex-direction: column;
height: 95vh;
}
.wizard-page {
margin: auto;
}
.wizard-controls {
margin-top: 0;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
// this file is used for configuring electron-builder
module.exports = {
pluginOptions: {
electronBuilder: {
builderOptions: {
"productName": "ODriveGUI",
"asar": false,
"extraResources": "server",
"artifactName": "${name}_${os}.${ext}",
"win" : {
"target" : [
{
"target": "portable",
}
]
},
"linux" : {
"target" : [
{
"target": "AppImage",
}
]
}
},
//mainProcessArgs: ['C:/Users/pajoh/Desktop/ODrive_work/ODrive/tools', 'C:/Users/pajoh/Desktop/ODrive_work/ODrive/Firmware']
}
}
}