Port StatusScreen (new name) to Vue

This commit is contained in:
Adam Goldsmith 2019-07-09 23:51:24 -04:00
parent 31c0c3be1c
commit c669fde8bd
10 changed files with 3680 additions and 3046 deletions

9
.dir-locals.el Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@ -5,20 +5,24 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"aprs-parser": "^1.0.4", "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" "ws": "^5.2.2"
}, },
"devDependencies": { "devDependencies": {
"parcel": "^1.9.7" "@vue/component-compiler-utils": "^3.0.0",
"parcel": "^1.12.3",
"vue-template-compiler": "^2.6.10"
}, },
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"monkeyPatch": "sed -i '8s| APRSIS| //APRSIS|' node_modules/aprs-parser/lib/index.js", "monkeyPatch": "sed -i '8s| APRSIS| //APRSIS|' node_modules/aprs-parser/lib/index.js",
"serve": "node src/server.js", "serve": "node src/server.js",
"prestart": "npm run monkeyPatch", "prestart": "npm run monkeyPatch",
"start": "node_modules/.bin/parcel src/index.html", "start": "npx parcel src/index.html",
"prebuild": "npm run monkeyPatch", "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>", "author": "Adam Goldsmith <contact@adamgoldsmith.name>",
"license": "ISC" "license": "ISC"

142
src/StationRow.vue Normal file
View 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
View 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>

View File

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

View File

@ -1,12 +1,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<script src="./index.js"></script>
<link rel="stylesheet" type="text/css" href="index.css">
</head> </head>
<body> <body>
<div class="wrapper"> <div id="app"> </div>
<table class="stations"> <script src="./index.js"></script>
</table>
<a href="map.html">Map</a>
</div>
</body> </body>

View File

@ -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 new Vue({
const trackedStations = { el: '#app',
// Digis/iGates render: h => h(App),
"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") || '{}');
}); });
window.setInterval(redrawTable, 1000);

View File

@ -3,7 +3,7 @@ const net = require('net');
const fs = require('fs'); const fs = require('fs');
const client = new net.Socket(); 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.broadcast = function(data) {
wss.clients.forEach(client => { wss.clients.forEach(client => {

42
src/status_config.yaml Normal file
View 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)