Port StatusScreen (new name) to Vue
This commit is contained in:
parent
31c0c3be1c
commit
c669fde8bd
9
.dir-locals.el
Normal file
9
.dir-locals.el
Normal file
@ -0,0 +1,9 @@
|
||||
;;; Directory Local Variables
|
||||
;;; For more information see (info "(emacs) Directory Variables")
|
||||
|
||||
((web-mode . ((eval . (progn (prettier-js-mode t)
|
||||
(editorconfig-mode t)))
|
||||
(web-mode-script-padding . 0)
|
||||
(web-mode-style-padding . 0)))
|
||||
(js2-mode . ((eval . (progn (prettier-js-mode t)
|
||||
(editorconfig-mode t))))))
|
6192
package-lock.json
generated
6192
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -5,20 +5,24 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"aprs-parser": "^1.0.4",
|
||||
"ol": "^5.0.3",
|
||||
"ol": "^5.3.3",
|
||||
"vue": "^2.6.10",
|
||||
"vue-hot-reload-api": "^2.3.3",
|
||||
"ws": "^5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"parcel": "^1.9.7"
|
||||
"@vue/component-compiler-utils": "^3.0.0",
|
||||
"parcel": "^1.12.3",
|
||||
"vue-template-compiler": "^2.6.10"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"monkeyPatch": "sed -i '8s| APRSIS| //APRSIS|' node_modules/aprs-parser/lib/index.js",
|
||||
"serve": "node src/server.js",
|
||||
"prestart": "npm run monkeyPatch",
|
||||
"start": "node_modules/.bin/parcel src/index.html",
|
||||
"start": "npx parcel src/index.html",
|
||||
"prebuild": "npm run monkeyPatch",
|
||||
"build": "node_modules/.bin/parcel build --public-url ./ src/index.html"
|
||||
"build": "npx parcel build --public-url ./ src/index.html"
|
||||
},
|
||||
"author": "Adam Goldsmith <contact@adamgoldsmith.name>",
|
||||
"license": "ISC"
|
||||
|
142
src/StationRow.vue
Normal file
142
src/StationRow.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<tr :class="{ timedOut, lowVoltage, neverHeard: !status.lastHeard }">
|
||||
<td :title="callsign">{{ tacticalOrCall }}</td>
|
||||
<template v-if="status.lastHeard">
|
||||
<td>{{ formatTime(status.lastHeard) }}</td>
|
||||
<td>{{ formatTime(now - status.lastHeard, true) }}</td>
|
||||
<td>{{ status.lastVoltage || "" }}</td>
|
||||
<td>{{ status.lastTemperature || "" }}</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td>Never Heard</td>
|
||||
<td>Never Heard</td>
|
||||
<td>Never Heard</td>
|
||||
<td>Never Heard</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import config from "./status_config.yaml";
|
||||
|
||||
export default {
|
||||
name: "StationRow",
|
||||
props: { callsign: String, tactical: String, messages: Array, now: Date },
|
||||
|
||||
data() {
|
||||
return {
|
||||
status: {
|
||||
lastHeard: null,
|
||||
delta: null,
|
||||
lastVoltage: null,
|
||||
lastTemperature: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
notify(title, body) {
|
||||
return new Notification(title, { body: body, requireInteraction: true });
|
||||
},
|
||||
|
||||
formatTime(time, isDuration = false) {
|
||||
return new Date(time).toLocaleTimeString(
|
||||
"en-GB",
|
||||
isDuration ? { timeZone: "UTC" } : {}
|
||||
);
|
||||
},
|
||||
|
||||
prettyDuration(duration) {
|
||||
let date = new Date(duration);
|
||||
return [
|
||||
...Object.entries({
|
||||
hours: date.getUTCHours(),
|
||||
minutes: date.getUTCMinutes(),
|
||||
seconds: date.getUTCSeconds(),
|
||||
milliseconds: date.getUTCMilliseconds()
|
||||
})
|
||||
]
|
||||
.filter(([suffix, num]) => num > 0)
|
||||
.map(([suffix, num]) => num + " " + suffix)
|
||||
.join(" ");
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
messages() {
|
||||
Object.assign(
|
||||
this.status,
|
||||
this.messages.reduce((acc, message) => {
|
||||
acc.lastHeard = message.date.getTime();
|
||||
acc.delta = message.date - acc.lastHeard;
|
||||
if ("data" in message && "analog" in message.data) {
|
||||
acc.lastVoltage = message.data.analog[0] / 10;
|
||||
acc.lastTemperature = message.data.analog[1];
|
||||
}
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
},
|
||||
|
||||
lowVoltage(newVal) {
|
||||
if (newVal) {
|
||||
this.notify(
|
||||
`${this.tacticalOrCall}'s battery has dropepd below ${config.lowVoltage}V`,
|
||||
`Voltage: ${this.status.lastVoltage}`
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
timedOut(newVal) {
|
||||
if (newVal) {
|
||||
this.notify(
|
||||
`${
|
||||
this.tacticalOrCall
|
||||
} has not been heard for over ${this.prettyDuration(
|
||||
config.timeoutLength
|
||||
)}!`,
|
||||
`Last Heard: ${this.formatTime(
|
||||
this.status.lastHeard
|
||||
)} (${this.prettyDuration(this.now - this.status.lastHeard)} ago!)`
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
tacticalOrCall() {
|
||||
return this.tactical || this.callsign;
|
||||
},
|
||||
|
||||
timedOut() {
|
||||
if (!this.status.lastHeard) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let nowDelta = new Date(this.now - this.status.lastHeard);
|
||||
return nowDelta.getTime() > config.timeoutLength;
|
||||
},
|
||||
|
||||
lowVoltage() {
|
||||
return (
|
||||
this.status.lastVoltage && this.status.lastVoltage < config.lowVoltage
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
tr.timedOut {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
tr.lowVoltage {
|
||||
background-color: yellow;
|
||||
}
|
||||
|
||||
tr.neverHeard {
|
||||
background-color: purple;
|
||||
color: #eee;
|
||||
}
|
||||
</style>
|
104
src/StatusScreen.vue
Normal file
104
src/StatusScreen.vue
Normal file
@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Callsign</th>
|
||||
<th>Last Heard</th>
|
||||
<th>Time since Last Heard</th>
|
||||
<th>Last Voltage</th>
|
||||
<th>Last Temperature</th>
|
||||
</tr>
|
||||
|
||||
<StationRow
|
||||
v-for="(tactical, callsign) in config.trackedStations"
|
||||
:key="callsign"
|
||||
:callsign="callsign"
|
||||
:tactical="tactical"
|
||||
:messages="messagesFromStation[callsign] || []"
|
||||
:now="now"
|
||||
>
|
||||
</StationRow>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import aprs from "aprs-parser";
|
||||
|
||||
import StationRow from "./StationRow.vue";
|
||||
|
||||
import config from "./status_config.yaml";
|
||||
|
||||
export default {
|
||||
name: "StationStatus",
|
||||
components: { StationRow },
|
||||
data() {
|
||||
return {
|
||||
config: config,
|
||||
aprsStream: null,
|
||||
parser: new aprs.APRSParser(),
|
||||
messages: [],
|
||||
messagesFromStation: {},
|
||||
now: new Date()
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// request notification permissions
|
||||
if (Notification.permission !== "granted") {
|
||||
Notification.requestPermission(permission => {
|
||||
if (permission === "granted") {
|
||||
new Notification("Test notification", { body: "whatever" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Connect to websocket aprs stream
|
||||
this.connectToStream();
|
||||
|
||||
// update shared current time every second
|
||||
window.setInterval(() => (this.now = new Date()), 1000);
|
||||
},
|
||||
|
||||
methods: {
|
||||
connectToStream() {
|
||||
this.aprsStream = new WebSocket("ws://localhost:4321");
|
||||
this.aprsStream.onclose = () => {
|
||||
// Try to reconnect every 5 seconds
|
||||
let interval = window.setTimeout(() => {
|
||||
window.clearInterval(interval);
|
||||
this.connectToStream();
|
||||
}, 5000);
|
||||
};
|
||||
this.aprsStream.onmessage = event =>
|
||||
this.handleMessage(JSON.parse(event.data));
|
||||
},
|
||||
|
||||
handleMessage(packet) {
|
||||
let message = this.parser.parse(packet[1]);
|
||||
message.date = new Date(); // TODO: use data[0] instead
|
||||
|
||||
console.log(message);
|
||||
this.messages.push(message);
|
||||
let callsign = message.from && message.from.toString();
|
||||
if (callsign in this.messagesFromStation) {
|
||||
this.messagesFromStation[callsign].push(message);
|
||||
} else {
|
||||
this.$set(this.messagesFromStation, callsign, [message]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table td,
|
||||
table th {
|
||||
border: 1px solid black;
|
||||
padding: 2px;
|
||||
}
|
||||
</style>
|
@ -1,21 +0,0 @@
|
||||
table.stations {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table.stations td, table.stations th{
|
||||
border: 1px solid black;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
table.stations tr.timedOut {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
table.stations tr.lowVoltage {
|
||||
background-color: yellow;
|
||||
}
|
||||
|
||||
table.stations tr.neverHeard {
|
||||
background-color: purple;
|
||||
color: #eee;
|
||||
}
|
@ -1,12 +1,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<script src="./index.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<table class="stations">
|
||||
</table>
|
||||
<a href="map.html">Map</a>
|
||||
</div>
|
||||
<div id="app"> </div>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
|
193
src/index.js
193
src/index.js
@ -1,190 +1,7 @@
|
||||
const aprs = require('aprs-parser');
|
||||
import Vue from 'vue';
|
||||
import App from './StatusScreen.vue';
|
||||
|
||||
// null here means just use the original callsign
|
||||
const trackedStations = {
|
||||
// Digis/iGates
|
||||
"W1FN-1": null,
|
||||
"W1FN-3": null,
|
||||
"W1FN-5": null,
|
||||
"W1FN-6": null,
|
||||
"W1FN-7": null,
|
||||
"W1FN-8": null,
|
||||
"W1FN-9": null,
|
||||
"W1FN-10": null,
|
||||
"N1GMC-1": null,
|
||||
"N1GMC-2": null,
|
||||
|
||||
// Vehicles
|
||||
"KC1GDW-6": "Metric Rover",
|
||||
"WB1BRE-10": "Recovery 1",
|
||||
"KC1GDW-10": "Recovery 2",
|
||||
"W1LKS-9": "Recovery 3",
|
||||
"WB1BRE-11": "Rover 1-2 (VT)",
|
||||
"KC1GDW-5": "Rover 2-1 (NH)",
|
||||
"WB1BRE-9": "Rover 2-3",
|
||||
"N0JSR-9": "Rover 2-8 (VT)",
|
||||
"N1EMF-7": "Rover 3-4",
|
||||
"K1DSP-9": "Rover 4-5",
|
||||
"WB1BRE-15": "Rover 5-6",
|
||||
"AB1XQ-9": "Rover 5-8",
|
||||
"KC1BOS-2": "Rover 6-7",
|
||||
"K1EHZ-3": "Rover 7-8",
|
||||
"KC1GDW-8": "Rover 8-2 (NH)",
|
||||
"WB1BRE-13": "Rover FPL",
|
||||
"W1HS-9": "Rover SF-1",
|
||||
"W1KUA-9": "Safety 1",
|
||||
"KC1GDW-12": "Shuttle 1",
|
||||
"KC1GDW-13": "Shuttle 2",
|
||||
"KC1GDW-14": "Supply 1",
|
||||
"KC1GDW-11": "Supply 2",
|
||||
"WB1BRE-14": "Supply 3",
|
||||
"WB1BRE-8": "Trouble 1 (North)",
|
||||
"AE1H-8": "Trouble 2 (South)",
|
||||
};
|
||||
|
||||
const timeoutLength = 10 * 60 * 1000; // 10 Minutes
|
||||
const lowVoltage = 11.9;
|
||||
|
||||
|
||||
///////// End of Config /////////
|
||||
|
||||
window.stations = {};
|
||||
window.messages = [];
|
||||
let aprsStream;
|
||||
let parser = new aprs.APRSParser();
|
||||
|
||||
if (Notification.permission !== "granted") {
|
||||
Notification.requestPermission(permission => {
|
||||
if (permission === "granted") {
|
||||
new Notification("Test notification", {body: "whatever"});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getTactical(callsign) {
|
||||
if (trackedStations[callsign])
|
||||
return `${trackedStations[callsign]} [${callsign}]`;
|
||||
else
|
||||
return callsign;
|
||||
}
|
||||
|
||||
function prettyDuration(duration) {
|
||||
let date = new Date(timeoutLength);
|
||||
return date.getUTCHours() > 0 ? date.getUTCHours() + " Hours": "" +
|
||||
date.getUTCMinutes() > 0 ? date.getUTCMinutes() + " Minutes": "" +
|
||||
date.getUTCSeconds() > 0 ? date.getUTCSeconds() + " Seconds": "" +
|
||||
date.getUTCMilliseconds() > 0 ? date.getUTCMilliseconds() + " Milliseconds" : "";
|
||||
}
|
||||
|
||||
function redrawTable() {
|
||||
let table = document.querySelector('table.stations');
|
||||
table.innerHTML =
|
||||
`<tr><th>Callsign</th>` +
|
||||
`<th>Last Heard</th>` +
|
||||
`<th>Time since Last Heard</th>` +
|
||||
`<th>Last Voltage</th>` +
|
||||
`<th>Last Temperature</th>`;
|
||||
for (let callsign in trackedStations) {
|
||||
let tr = table.appendChild(document.createElement('tr'));
|
||||
tr.innerHTML = `<td>${getTactical(callsign)}</td>`;
|
||||
if (!(callsign in stations)) {
|
||||
tr.classList.add('neverHeard');
|
||||
tr.innerHTML +=
|
||||
'<td>Never Heard</td>' +
|
||||
'<td>Never Heard</td>' +
|
||||
'<td>Never Heard</td>' +
|
||||
'<td>Never Heard</td>';
|
||||
}
|
||||
|
||||
else {
|
||||
let station = stations[callsign];
|
||||
let lastHeard = new Date(station.lastHeard);
|
||||
let nowDelta = new Date(new Date() - lastHeard);
|
||||
|
||||
// TODO: should be set by same thing that sends alert
|
||||
if (nowDelta.getTime() > timeoutLength) {
|
||||
tr.classList.add('timedOut');
|
||||
}
|
||||
if (station.lastVoltage < lowVoltage) {
|
||||
tr.classList.add('lowVoltage');
|
||||
}
|
||||
tr.innerHTML +=
|
||||
`<td>${lastHeard.toLocaleTimeString('en-GB')}</td>` +
|
||||
`<td>${nowDelta.toLocaleTimeString('en-GB', {timeZone: "UTC"})}</td>` +
|
||||
`<td>${station.lastVoltage||''}</td>` +
|
||||
`<td>${station.lastTemperature||''}</td>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notify(title, body) {
|
||||
return new Notification(title, {body: body, requireInteraction: true});
|
||||
}
|
||||
|
||||
function alertNotHeard(callsign) {
|
||||
notify(`${getTactical(callsign)} has not been heard for ${prettyDuration(timeoutLength)}!`,
|
||||
`Last Heard: ${new Date(stations[callsign].lastHeard).toLocaleTimeString('en-GB')}`);
|
||||
}
|
||||
|
||||
function alertVoltage(callsign) {
|
||||
notify(`${getTactical(callsign)}'s battery has dropepd below ${lowVoltage}V`,
|
||||
`Voltage: ${stations[callsign].lastVoltage}`);
|
||||
}
|
||||
|
||||
function handleMessage(packet) {
|
||||
let message = parser.parse(packet[1]);
|
||||
let callsign = message.from.toString();
|
||||
let date = new Date(); // TODO: use data[0] instead
|
||||
|
||||
console.log(message);
|
||||
messages.push(message);
|
||||
|
||||
// TODO: hacky filter
|
||||
if (callsign in trackedStations) {
|
||||
|
||||
if (!(callsign in stations)) {
|
||||
stations[callsign] = {};
|
||||
}
|
||||
else {
|
||||
window.clearTimeout(stations[callsign].timeout);
|
||||
}
|
||||
|
||||
stations[callsign].lastHeard = date.getTime();
|
||||
stations[callsign].delta = date - stations[callsign].lastHeard;
|
||||
stations[callsign].timeout = window.setTimeout(
|
||||
alertNotHeard, timeoutLength, callsign);
|
||||
|
||||
if ('data' in message && 'analog' in message.data) {
|
||||
stations[callsign].lastVoltage = message.data.analog[0] / 10;
|
||||
stations[callsign].lastTemperature = message.data.analog[1];
|
||||
|
||||
if (stations[callsign].lastVoltage <= lowVoltage) {
|
||||
alertVoltage(callsign);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("stations", JSON.stringify(stations));
|
||||
|
||||
redrawTable();
|
||||
}
|
||||
}
|
||||
|
||||
function connectToStream() {
|
||||
aprsStream = new WebSocket("ws://localhost:1234");
|
||||
aprsStream.onclose = () => {
|
||||
// Try to reconnect every 5 seconds
|
||||
let interval = window.setInterval(() => {
|
||||
window.clearInterval(interval);
|
||||
connectToStream();
|
||||
}, 5000);
|
||||
};
|
||||
aprsStream.onmessage = event => handleMessage(JSON.parse(event.data));
|
||||
}
|
||||
|
||||
connectToStream();
|
||||
|
||||
window.addEventListener("load", redrawTable);
|
||||
window.addEventListener("load", () => {
|
||||
stations = JSON.parse(localStorage.getItem("stations") || '{}');
|
||||
new Vue({
|
||||
el: '#app',
|
||||
render: h => h(App),
|
||||
});
|
||||
window.setInterval(redrawTable, 1000);
|
||||
|
@ -3,7 +3,7 @@ const net = require('net');
|
||||
const fs = require('fs');
|
||||
|
||||
const client = new net.Socket();
|
||||
const wss = new WebSocket.Server({host: "127.0.0.1", port: 1234});
|
||||
const wss = new WebSocket.Server({host: "127.0.0.1", port: 4321});
|
||||
|
||||
wss.broadcast = function(data) {
|
||||
wss.clients.forEach(client => {
|
||||
|
42
src/status_config.yaml
Normal file
42
src/status_config.yaml
Normal file
@ -0,0 +1,42 @@
|
||||
timeoutLength: 600000 # 10 * 60 * 1000 = 10 minutes
|
||||
lowVoltage: 11.9
|
||||
|
||||
trackedStations:
|
||||
# Digis/iGates
|
||||
W1FN-1:
|
||||
W1FN-3:
|
||||
W1FN-5:
|
||||
W1FN-6:
|
||||
W1FN-7:
|
||||
W1FN-8:
|
||||
W1FN-9:
|
||||
W1FN-10:
|
||||
N1GMC-1:
|
||||
N1GMC-2:
|
||||
|
||||
# Vehicles
|
||||
KC1GDW-6: Metric Rover
|
||||
WB1BRE-10: Recovery 1
|
||||
KC1GDW-10: Recovery 2
|
||||
W1LKS-9: Recovery 3
|
||||
WB1BRE-11: Rover 1-2 (VT)
|
||||
KC1GDW-5: Rover 2-1 (NH)
|
||||
WB1BRE-9: Rover 2-3
|
||||
N0JSR-9: Rover 2-8 (VT)
|
||||
N1EMF-7: Rover 3-4
|
||||
K1DSP-9: Rover 4-5
|
||||
WB1BRE-15: Rover 5-6
|
||||
AB1XQ-9: Rover 5-8
|
||||
KC1BOS-2: Rover 6-7
|
||||
K1EHZ-3: Rover 7-8
|
||||
KC1GDW-8: Rover 8-2 (NH)
|
||||
WB1BRE-13: Rover FPL
|
||||
W1HS-9: Rover SF-1
|
||||
W1KUA-9: Safety 1
|
||||
KC1GDW-12: Shuttle 1
|
||||
KC1GDW-13: Shuttle 2
|
||||
KC1GDW-14: Supply 1
|
||||
KC1GDW-11: Supply 2
|
||||
WB1BRE-14: Supply 3
|
||||
WB1BRE-8: Trouble 1 (North)
|
||||
AE1H-8: Trouble 2 (South)
|
Loading…
Reference in New Issue
Block a user