UI bug fixes and feature refinements (#59)

* Rework settings page; touch up contour, output, and 3D tabs; font sizing

No stream placeholder; driver mode refined; cameras page

Make settings snackbar work

Lint fix

Fix settings page padding

Actually hide settings fields if unsupported

* Make toggle buttons less confusing; fix driver toggle; form validation

* Make eyedropper work and make input/select styling more consistent

* Fix color picker and tabbing bugs

* Set up camera and settings pages to talk to the backend

* Add auto reconnect

* Add lots of tooltips and improve related thematic consistency

* Only show output stream while color picking

* Unbreak robot offset

* Increase tooltip delay and refactor tooltip label into a component

* Remove toggle button switching behavior

* Fix PnP tab and add a flag to disable FOV configuration

* Move FPS indicator

* Make GPU acceleration status use one value in the store

* Only allow IPv4 static IPs and remove accidentally committed index
This commit is contained in:
Declan Freeman-Gleason
2020-07-31 13:50:50 -07:00
committed by GitHub
parent 0b98dc3c9f
commit 19b57235fe
34 changed files with 1099 additions and 566 deletions

View File

@@ -1,343 +0,0 @@
<template>
<div>
<div>
<CVselect
v-model="currentCameraIndex"
name="Camera"
:list="$store.getters.cameraList"
@input="handleInput('currentCamera',currentCameraIndex)"
/>
<CVnumberinput
v-model="cameraSettings.fov"
name="Diagonal FOV"
/>
<br>
<CVnumberinput
v-model="cameraSettings.tilt"
name="Camera pitch"
:step="0.01"
/>
<br>
<v-btn
style="margin-top:10px"
small
color="#ffd843"
@click="sendCameraSettings"
>
Save Camera Settings
</v-btn>
</div>
<div style="margin-top: 15px">
<span>3D Calibration</span>
<v-divider
color="white"
style="margin-bottom: 10px"
/>
<v-row>
<v-col>
<CVselect
v-model="resolutionIndex"
name="Resolution"
:list="stringResolutionList"
/>
</v-col>
<v-col>
<CVnumberinput
v-model="squareSize"
name="Square Size (in)"
/>
</v-col>
</v-row>
<v-row>
<v-col>
<v-btn
small
:color="calibrationModeButton.color"
:disabled="checkResolution"
@click="sendCalibrationMode"
>
{{ calibrationModeButton.text }}
</v-btn>
</v-col>
<v-col>
<v-btn
small
:color="cancellationModeButton.color"
:disabled="checkCancellation"
@click="sendCalibrationFinish"
>
{{ cancellationModeButton.text }}
</v-btn>
</v-col>
<v-col>
<v-btn
color="whitesmoke"
small
@click="downloadBoard"
>
Download Checkerboard
</v-btn>
<a
ref="calibrationFile"
style="color: black; text-decoration: none; display: none"
:href="require('../../assets/chessboard.png')"
download="Calibration Board.png"
/>
</v-col>
</v-row>
<v-row v-if="isCalibrating">
<v-col>
<span>Snapshot Amount: {{ snapshotAmount }}</span>
</v-col>
</v-row>
<div v-if="isCalibrating">
<v-checkbox
v-model="isAdvanced"
label="Advanced Menu"
dark
/>
<div v-if="isAdvanced">
<CVslider
v-model="$store.getters.pipeline.exposure"
name="Exposure"
:min="0"
:max="100"
@input="e=> handleInput('exposure', e)"
/>
<CVslider
v-model="$store.getters.pipeline.brightness"
name="Brightness"
:min="0"
:max="100"
@input="e=> handleInput('brightness', e)"
/>
<CVslider
v-if="$store.getters.pipeline.gain !== -1"
v-model="$store.getters.pipeline.gain"
name="Gain"
:min="0"
:max="100"
@input="e=> handleInput('gain', e)"
/>
<CVselect
v-model="$store.getters.pipeline.videoModeIndex"
name="FPS"
:list="stringFpsList"
@input="changeFps"
/>
</div>
</div>
</div>
<v-snackbar
v-model="snack"
top
:color="snackbar.color"
>
<span>{{ snackbar.text }}</span>
</v-snackbar>
</div>
</template>
<script>
import CVselect from '../../components/common/cv-select'
import CVnumberinput from '../../components/common/cv-number-input'
import CVslider from '../../components/common/cv-slider'
export default {
name: 'CameraSettings',
components: {
CVselect,
CVnumberinput,
CVslider
},
data() {
return {
isCalibrating: false,
resolutionIndex: undefined,
calibrationModeButton: {
text: "Start Calibration",
color: "green"
},
cancellationModeButton: {
text: "Cancel Calibration",
color: "red"
},
snackbar: {
color: "success",
text: ""
},
squareSize: 1.0,
snapshotAmount: 0,
hasEnough: false,
snack: false,
isAdvanced: false
}
},
computed: {
checkResolution() {
return this.resolutionIndex === undefined;
},
checkCancellation() {
if (this.isCalibrating) {
return false
} else if (this.checkResolution) {
return true;
} else {
return true
}
},
currentCameraIndex: {
get() {
return this.$store.state.currentCameraIndex;
},
set(value) {
this.$store.commit('currentCameraIndex', value);
}
},
filteredResolutionList: {
get() {
let tmp_list = [];
for (let i in this.$store.state.resolutionList) {
if (this.$store.state.resolutionList.hasOwnProperty(i)) {
let res = JSON.parse(JSON.stringify(this.$store.state.resolutionList[i]));
if (!tmp_list.some(e => e.width === res.width && e.height === res.height)) {
res['actualIndex'] = parseInt(i);
tmp_list.push(res);
}
}
}
return tmp_list;
}
},
filteredFpsList() {
let selectedRes = this.$store.state.resolutionList[this.resolutionIndex];
let tmpList = [];
for (let i in this.$store.state.resolutionList) {
if (this.$store.state.resolutionList.hasOwnProperty(i)) {
let res = JSON.parse(JSON.stringify(this.$store.state.resolutionList[i]));
if (!tmpList.some(e => e['fps'] === res['fps'])) {
if (res.width === selectedRes.width && res.height === selectedRes.height) {
res['actualIndex'] = parseInt(i);
tmpList.push(res);
}
}
}
}
return tmpList;
},
stringFpsList() {
let tmp = [];
for (let i of this.filteredFpsList) {
tmp.push(i['fps']);
}
return tmp;
},
stringResolutionList: {
get() {
let tmp = [];
for (let i of this.filteredResolutionList) {
tmp.push(`${i['width']} X ${i['height']}`)
}
return tmp
}
},
cameraSettings: {
get() {
return this.$store.getters.cameraSettings;
},
set(value) {
this.$store.commit('cameraSettings', value);
}
}
},
methods: {
downloadBoard() {
this.axios.get("http://" + this.$address + require('../../assets/chessboard.png'), {responseType: 'blob'}).then((response) => {
require('downloadjs')(response.data, "Calibration Board", "image/png")
})
},
changeFps() {
this.handleInput('videoModeIndex', this.filteredFpsList[this.$store.getters.pipeline['videoModeIndex']]['actualIndex']);
},
sendCameraSettings() {
const self = this;
this.axios.post("http://" + this.$address + "/api/settings/camera", this.cameraSettings).then(
function (response) {
if (response.status === 200) {
self.$store.state.saveBar = true;
}
}
)
},
sendCalibrationMode() {
const self = this;
let data = {};
let connection_string = "/api/settings/";
if (self.isCalibrating === true) {
connection_string += "snapshot"
} else {
connection_string += "startCalibration";
data['resolution'] = this.filteredResolutionList[this.resolutionIndex].actualIndex;
data['squareSize'] = this.squareSize;
self.hasEnough = false;
}
this.axios.post("http://" + this.$address + connection_string, data).then(
function (response) {
if (response.status === 200) {
if (self.isCalibrating) {
self.snapshotAmount = response.data['snapshotCount'];
self.hasEnough = response.data['hasEnough'];
if (self.hasEnough === true) {
self.cancellationModeButton.text = "Finish Calibration";
self.cancellationModeButton.color = "green";
}
} else {
self.calibrationModeButton.text = "Take Snapshot";
self.isCalibrating = true;
}
}
}
);
},
sendCalibrationFinish() {
const self = this;
let connection_string = "/api/settings/endCalibration";
let data = {};
data['squareSize'] = this.squareSize;
self.axios.post("http://" + this.$address + connection_string, data).then((response) => {
if (response.status === 200) {
self.snackbar = {
color: "success",
text: "calibration successful. \n" +
"accuracy: " + response.data['accuracy'].toFixed(5)
};
self.snack = true;
}
self.isCalibrating = false;
self.hasEnough = false;
self.snapshotAmount = 0;
self.calibrationModeButton.text = "Start Calibration";
self.cancellationModeButton.text = "Cancel Calibration";
self.cancellationModeButton.color = "red";
}
).catch(() => {
self.snackbar = {
color: "error",
text: "calibration failed"
};
self.snack = true;
self.isCalibrating = false;
self.hasEnough = false;
self.snapshotAmount = 0;
self.calibrationModeButton.text = "Start Calibration";
self.cancellationModeButton.text = "Cancel Calibration";
self.cancellationModeButton.color = "red";
});
}
}
}
</script>
<style lang="" scoped>
</style>

View File

@@ -1,86 +1,55 @@
<template>
<div>
<div style="margin-top: 15px">
<span>General Settings:</span>
<v-divider color="white" />
</div>
<CVnumberinput
v-model="settings.teamNumber"
name="Team Number"
/>
<CVradio
v-model="settings.connectionType"
:list="['DHCP','Static']"
/>
<v-divider color="white" />
<CVinput
v-model="settings.ip"
name="IP"
:disabled="isDisabled"
/>
<CVinput
v-model="settings.netmask"
name="NetMask"
:disabled="isDisabled"
/>
<CVinput
v-model="settings.gateway"
name="Gateway"
:disabled="isDisabled"
/>
<v-divider color="white" />
<CVinput
v-model="settings.hostname"
name="Hostname"
/>
<v-btn
style="margin-top:10px"
small
color="#ffd843"
@click="sendGeneralSettings"
>
Save General Settings
</v-btn>
<div style="margin-top: 20px">
<span>Install or Update:</span>
<v-divider color="white" />
</div>
<div v-if="!isLoading">
<v-row
dense
align="center"
<span>Version: {{ settings.version }}</span>
&mdash;
<span>Hardware model: {{ settings.hardwareModel }}</span>
&mdash;
<span>Platform: {{ settings.hardwarePlatform }}</span>
&mdash;
<span>GPU Acceleration: {{ settings.gpuAcceleration ? "Enabled" : "Unsupported" }}{{ settings.gpuAcceleration ? " (" + settings.gpuAcceleration + " mode)" : "" }}</span>
<v-row>
<v-col
cols="12"
sm="6"
lg="4"
>
<v-col :cols="3">
<span>Choose a newer version: </span>
</v-col>
<v-col :cols="6">
<v-file-input
v-model="file"
accept=".jar"
dark
/>
</v-col>
</v-row>
<v-btn
small
@click="installOrUpdate"
<v-btn
color="secondary"
@click="$refs.exportSettings.click()"
>
<v-icon left>
mdi-download
</v-icon> Export Settings
</v-btn>
</v-col>
<v-col
cols="12"
sm="6"
lg="4"
>
{{ fileUploadText }}
</v-btn>
</div>
<div
v-else
style="text-align: center; margin-top: 20px"
>
<v-progress-circular
color="white"
:indeterminate="true"
size="32"
width="4"
/>
<br>
<span>Please wait this may take a while</span>
</div>
<v-btn
color="secondary"
@click="$refs.importSettings.click()"
>
<v-icon left>
mdi-upload
</v-icon> Import Settings
</v-btn>
</v-col>
<v-col
cols="12"
lg="4"
>
<v-btn
color="red"
@click="restartDevice"
>
<v-icon left>
mdi-restart
</v-icon> Restart Device
</v-btn>
</v-col>
</v-row>
<v-snackbar
v-model="snack"
top
@@ -88,101 +57,70 @@
>
<span>{{ snackbar.text }}</span>
</v-snackbar>
<!-- Special hidden upload input that gets 'clicked' when the user imports settings -->
<input
ref="importSettings"
type="file"
accept=".zip"
style="display: none;"
@change="readImportedSettings"
>
<!-- Special hidden link that gets 'clicked' when the user exports settings -->
<a
ref="exportSettings"
style="color: black; text-decoration: none; display: none"
href="/api/settings/export"
download="photonvision-settings.zip"
/>
</div>
</template>
<script>
import CVnumberinput from '../../components/common/cv-number-input'
import CVradio from '../../components/common/cv-radio'
import CVinput from '../../components/common/cv-input'
export default {
name: 'General',
components: {
CVnumberinput,
CVradio,
CVinput
},
data() {
return {
file: undefined,
snackbar: {
color: "success",
text: ""
},
snack: false,
isLoading: false
}
return {
snack: false,
snackbar: {
color: "success",
text: ""
},
}
},
computed: {
fileUploadText() {
if (this.file !== undefined) {
return "Update and run at startup"
} else {
return "Run current version at startup"
}
},
isDisabled() {
return this.settings.connectionType === 0;
},
settings: {
get() {
return this.$store.state.settings;
}
}
settings() {
return this.$store.state.settings.general;
}
},
methods: {
sendGeneralSettings() {
const self = this;
this.axios.post("http://" + this.$address + "/api/settings/general", this.settings).then(
function (response) {
if (response.status === 200) {
self.snackbar = {
color: "success",
text: "Save successful, Please restart for changes to take action"
};
self.snack = true;
}
},
function (error) {
self.snackbar = {
color: "error",
text: error.response.data
};
self.snack = true;
}
)
},
installOrUpdate() {
let formData = new FormData();
formData.append('file', this.file);
if (this.file !== undefined) {
this.isLoading = true;
}
this.axios.post("http://" + this.$address + "/api/install", formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
}).then(() => {
this.snackbar = {
color: "success",
text: "Installation successful"
};
this.isLoading = false;
this.snack = true;
}).catch(error => {
this.snackbar = {
color: "error",
text: error.response.data
};
this.isLoading = false;
this.snack = true;
})
}
readImportedSettings(event) {
let formData = new FormData();
formData.append("zipData", event.target.files[0]);
this.axios.post("http://" + this.$address + "/api/settings/import", formData,
{headers: {"Content-Type": "multipart/form-data"}}).then(() => {
this.snackbar = {
color: "success",
text: "Settings imported successfully",
};
}).catch(() => {
this.snackbar = {
color: "error",
text: "Couldn't import settings",
}
});
this.snack = true;
},
restartDevice() {
this.axios.post("http://" + this.$address + "/api/restart");
}
}
}
</script>
<style lang="" scoped>
<style lang="css" scoped>
.v-btn {
width: 100%;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div>
<CVslider
v-model="settings.brightness"
class="pt-2"
slider-cols="12"
name="Brightness"
min="0"
max="100"
@input="handleData('accuracy')"
@rollback="e => rollback('accuracy', e)"
/>
</div>
</template>
<script>
import CVslider from "../../components/common/cv-slider";
export default {
name: 'LEDs',
components: {
CVslider,
},
computed: {
isDHCP() {
return this.settings.connectionType === 0;
},
settings() {
return this.$store.state.settings.lighting;
}
},
}
</script>
<style lang="" scoped>
</style>

View File

@@ -0,0 +1,107 @@
<template>
<div>
<CVnumberinput
v-model="settings.teamNumber"
name="Team Number"
:rules="[v => (v > 0) || 'Team number must be greater than zero', v => (v < 10000) || 'Team number must have fewer than five digits']"
/>
<template v-if="$store.state.settings.networking.supported">
<CVradio
v-model="settings.connectionType"
:list="['DHCP','Static']"
/>
<template v-if="!isDHCP">
<CVinput
v-model="settings.ip"
:input-cols="inputCols"
:rules="[v => isIPv4(v) || 'Invalid IPv4 address']"
name="IP"
/>
<CVinput
v-model="settings.netmask"
:input-cols="inputCols"
:rules="[v => isSubnetMask(v) || 'Invalid subnet mask']"
name="Subnet Mask"
/>
</template>
</template>
<CVinput
v-model="settings.hostname"
:input-cols="inputCols"
:rules="[v => isHostname(v) || 'Invalid hostname']"
name="Hostname"
/>
</div>
</template>
<script>
import CVnumberinput from '../../components/common/cv-number-input'
import CVradio from '../../components/common/cv-radio'
import CVinput from '../../components/common/cv-input'
// https://stackoverflow.com/a/17871737
const ipv4Regex = /^((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])$/;
// https://stackoverflow.com/a/18494710
const hostnameRegex = /^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)+(\.([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*))*$/;
export default {
name: 'Networking',
components: {
CVnumberinput,
CVradio,
CVinput
},
data() {
return {
file: undefined,
snackbar: {
color: "success",
text: ""
},
snack: false,
isLoading: false
}
},
computed: {
inputCols() {
return this.$vuetify.breakpoint.smAndUp ? 10 : 7;
},
isDHCP() {
return this.settings.connectionType === 0;
},
settings() {
return this.$store.state.settings.networking;
}
},
methods: {
isIPv4(v) {
return ipv4Regex.test(v);
},
isHostname(v) {
return hostnameRegex.test(v);
},
// https://www.freesoft.org/CIE/Course/Subnet/6.htm
// https://stackoverflow.com/a/13957228
isSubnetMask(v) {
// Has to be valid IPv4 so we'll start here
if (!this.isIPv4(v)) return false;
let octets = v.split(".").map(it => Number(it));
let restAreOnes = false;
for (let i = 3; i >= 0; i--) {
for (let j = 0; j < 8; j++) {
let bitValue = (octets[i] >>> j & 1) == 1;
if (restAreOnes && !bitValue)
return false;
restAreOnes = bitValue;
}
}
return true;
}
},
}
</script>
<style lang="" scoped>
</style>