Compare commits

..

25 Commits
test ... master

Author SHA1 Message Date
a12c95dcf8 Add layer controls to map 2024-06-26 13:18:19 -04:00
e9c2cd9e95 Serve map app at /map/, instead of manually enabling 2024-06-26 13:18:11 -04:00
1571086844 Bump dependencies 2024-06-26 13:17:57 -04:00
21d0c7cca3 Fix setting tacticals via message to TACTICAL 2023-07-15 08:20:22 -04:00
e14904e59b Add support for per-station timeoutLength/lowVoltage 2023-07-14 20:30:24 -04:00
2feabd4d87 Don't send notifications until log replay finished 2023-07-14 19:46:28 -04:00
6c015c322c Use human readable duration format in status_config.yaml 2023-07-14 18:28:28 -04:00
9426e2ead6 Pass lowVoltage/timeoutLength via props instead of re-importing config 2023-07-14 18:03:56 -04:00
269cdb65f1 Enable include-workspace-root setting for pnpm
This allows the root frontend app to run via `pnpm start` nicely. This
setting was not previously required, I think.
2023-07-14 17:29:59 -04:00
4ed100b97e Set tacticals for weird 2023 Friday 2023-07-13 18:10:51 -04:00
9d9a8b4068 Bump dependencies 2023-07-12 23:42:59 -04:00
c8325d992d More updated tacticals from Sat 2022 2022-07-09 10:31:42 -04:00
c8e047e23b Fix station status updating 2022-07-09 10:21:35 -04:00
170fee4fa5 Update tacticals for Prouty Saturday 2022 2022-07-09 07:08:38 -04:00
368b2a72f4 Add missing index.html 2022-07-08 18:13:52 -04:00
1b5c434afc Add button to request notification permission 2022-03-04 13:47:00 -05:00
6e5ae038b7 Move server to sub project 2022-03-04 13:46:44 -05:00
55bf9a43b3 Update to Vue3
Also switch from parcel to vite, from vuelayers to vue3-openlayers,
and update openlayers to v6
2022-03-04 13:46:44 -05:00
a7a5daeacf Remove unnecessary npx calls in package.json 2022-03-03 16:14:21 -05:00
e9d6d0a733 Switch to PNPM and bump dependencies 2022-03-03 16:04:20 -05:00
6f0a537a06 Add/apply .prettierrc and .editorconfig 2020-05-04 02:04:44 -04:00
dcb034dc10 Bump dependencies 2020-05-04 01:53:11 -04:00
a228b444ac Make some attempt to optimize packet path drawing 2019-07-16 11:15:37 -04:00
d4826c540c WIP: Almost all functionality of Map rewritten 2019-07-15 20:22:27 -04:00
87b8f9e9ba WIP: Migrate Map to Vue 2019-07-15 18:08:25 -04:00
24 changed files with 1983 additions and 8150 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
# EditorConfig is awesome: https://EditorConfig.org
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 2

4
.gitignore vendored
View File

@ -1,5 +1,7 @@
/.tern-port /.tern-port
/node_modules/ node_modules/
/dist/ /dist/
.tern-port .tern-port
/.cache/ /.cache/
/server/log/
/.log/

1
.npmrc Normal file
View File

@ -0,0 +1 @@
include-workspace-root=true

3
.prettierrc Normal file
View File

@ -0,0 +1,3 @@
trailingComma: es5
singleQuote: true
jsxBracketSameLine: true

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>APRS Notifier</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.js"></script>
</body>
</html>

12
map/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>APRS Map</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/map.js"></script>
</body>
</html>

7459
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,26 +3,25 @@
"version": "1.0.0", "version": "1.0.0",
"description": "", "description": "",
"private": true, "private": true,
"type": "module",
"dependencies": { "dependencies": {
"aprs-parser": "^1.0.4", "aprs-parser": "github:ad1217/npm-aprs-parser#no-dynamic-require",
"ol": "^5.3.3", "distinct-colors": "^3.0.0",
"vue": "^2.6.10", "ol": "^9.2.4",
"vue-hot-reload-api": "^2.3.3", "parse-duration": "^1.1.0",
"ws": "^5.2.2" "vue": "^3.4.30",
"vue3-openlayers": "^10.0.1"
}, },
"devDependencies": { "devDependencies": {
"@vue/component-compiler-utils": "^3.0.0", "@modyfi/vite-plugin-yaml": "^1.1.0",
"parcel": "^1.12.3", "@rollup/plugin-yaml": "^4.1.2",
"vue-template-compiler": "^2.6.10" "@vitejs/plugin-vue": "^5.0.5",
"vite": "^5.3.1"
}, },
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "start": "pnpm run -r --parallel dev",
"monkeyPatch": "sed -i '8s| APRSIS| //APRSIS|' node_modules/aprs-parser/lib/index.js", "dev": "vite",
"serve": "node src/server.js", "build": "vite build"
"prestart": "npm run monkeyPatch",
"start": "npx parcel src/index.html",
"prebuild": "npm run monkeyPatch",
"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"

1248
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

1
server/.pnpm-debug.log Normal file
View File

@ -0,0 +1 @@
{}

9
server/package.json Normal file
View File

@ -0,0 +1,9 @@
{
"scripts": {
"start": "npm run dev",
"dev": "node ./src/server.js"
},
"dependencies": {
"ws": "^8.17.1"
}
}

31
server/pnpm-lock.yaml Normal file
View File

@ -0,0 +1,31 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
ws:
specifier: ^8.17.1
version: 8.17.1
packages:
ws@8.17.1:
resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
ws@8.17.1: {}

59
server/src/server.js Normal file
View File

@ -0,0 +1,59 @@
const WebSocket = require('ws');
const net = require('net');
const fs = require('fs');
const client = new net.Socket();
const wss = new WebSocket.Server({ host: '127.0.0.1', port: 4321 });
wss.broadcast = function (data) {
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
};
client.connect(14580, 'rotate.aprs2.net', () =>
client.write('user KC1GDW pass -1 filter r/43.90/-72.15/75\r\n')
);
function datestamp(date) {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
client.on('data', function (data) {
let str = data.toString('utf8').replace(/^\s+|\s+$/g, '');
console.log(str);
// strip whitespace, then handle multiple APRS packets per TCP packet
str.split('\r\n').forEach((packet) => {
// ignore comments and empty lines
if (!packet.startsWith('#') || packet === '') {
let date = new Date();
// create log dir if it doesn't exist
if (!fs.existsSync('log')) fs.mkdirSync('log');
fs.appendFile(
`log/log${datestamp(date)}.json`,
JSON.stringify([date, packet]) + '\n',
(err) => {
if (err) throw err;
}
);
wss.broadcast(JSON.stringify([date, packet]));
}
});
});
wss.on('connection', (ws) => {
let date = new Date();
let filename = `log/log${datestamp(date)}.json`;
if (fs.existsSync(filename)) {
fs.readFileSync(filename)
.toString()
.split('\n')
.filter((line) => line !== '')
.forEach((line) => ws.send(line));
ws.send("FINISHED REPLAY");
}
});

276
src/Map.vue Normal file
View File

@ -0,0 +1,276 @@
<template>
<ol-map
:loadTilesWhileAnimating="true"
:loadTilesWhileInteracting="true"
class="map"
>
<ol-view :zoom="10" :center="[-72.15, 43.9]" projection="EPSG:4326" />
<ol-tile-layer title="Base Map">
<ol-source-osm />
</ol-tile-layer>
<ol-layer-group title="GPX Routes">
<ol-vector-layer v-for="gpxURL in routes" :key="gpxURL" :title="gpxURL">
<ol-source-vector :url="gpxURL" :format="new GPX()"> </ol-source-vector>
<ol-style>
<ol-style-stroke color="hsl(200, 90%, 30%)" :width="5">
</ol-style-stroke>
</ol-style>
</ol-vector-layer>
</ol-layer-group>
<!-- Station Paths -->
<div>
<ol-layer-group title="Paths">
<div v-for="(packets, callsign, idx) in stationPaths" :key="callsign">
<!--Paths -->
<ol-vector-layer render-mode="image" :title="callsign">
<ol-source-vector>
<ol-feature>
<ol-geom-line-string
:coordinates="packetsToStationPathPoints(packets)"
>
</ol-geom-line-string>
</ol-feature>
</ol-source-vector>
<ol-style>
<ol-style-stroke :color="stationColors[idx].hex()" :width="2">
</ol-style-stroke>
</ol-style>
</ol-vector-layer>
</div>
</ol-layer-group>
<ol-layer-group title="Points">
<div v-for="(packets, callsign, idx) in stationPaths" :key="callsign">
<!-- Points -->
<ol-vector-layer render-mode="image" :title="callsign">
<ol-source-vector>
<ol-feature>
<ol-geom-multi-point
:coordinates="packetsToStationPathPoints(packets)"
>
</ol-geom-multi-point>
</ol-feature>
</ol-source-vector>
<ol-style>
<ol-style-circle :radius="3">
<ol-style-fill :color="stationColors[idx].hex()">
</ol-style-fill>
</ol-style-circle>
</ol-style>
</ol-vector-layer>
</div>
</ol-layer-group>
</div>
<!-- Digipeater locations -->
<ol-vector-layer title="Digipeater Locations">
<ol-source-vector>
<ol-feature v-for="(position, callsign) in digiPos" :key="callsign">
<ol-geom-point :coordinates="position"> </ol-geom-point>
<ol-style>
<ol-style-circle>
<ol-style-fill :color="digiColors[callsign].hex()">
</ol-style-fill>
</ol-style-circle>
<ol-style-text :text="callsign" :offsetY="12"> </ol-style-text>
</ol-style>
</ol-feature>
</ol-source-vector>
</ol-vector-layer>
<!-- Packet Paths -->
<ol-vector-layer title="Packet Paths" :visible="false">
<ol-source-vector :features="packetPaths"> </ol-source-vector>
<!-- TODO: fix style -->
<!-- <ol-style :overrideStyleFunction="packetPathStyleFunc"> </ol-style> -->
</ol-vector-layer>
<ol-layerswitcher-control />
</ol-map>
</template>
<script setup>
import { computed, ref } from 'vue';
import APRSParser from 'aprs-parser/lib/APRSParser';
import distinctColors from 'distinct-colors';
import { GPX } from 'ol/format';
import Feature from 'ol/Feature';
import MultiLineString from 'ol/geom/MultiLineString';
import LineString from 'ol/geom/LineString';
import Style from 'ol/style/Style';
import Stroke from 'ol/style/Stroke';
import packetLog from '../IS_packets.txt?raw';
const routes = Object.values(
import.meta.glob('./gpx/*.gpx', { eager: true, import: 'default' })
);
const parser = new APRSParser();
const packets = packetLog
.trim()
.split('\n')
// parse to Date and APRS packet
.map((line) => {
let packet = parser.parse(line.slice(29));
packet.date = new Date(line.slice(0, 18));
return packet;
});
function packetsToStationPathPoints(packets) {
return packets.map((packet) => [packet.data.longitude, packet.data.latitude]);
}
function pathToString(path) {
return path
.filter(
(station) => !station.call.match(/WIDE[12]|qA?|UV[123]|.*\*$|UNCAN/)
)
.map((station) => station.toString().trim().replace(/\*$/, ''));
}
function groupByCall(acc, packet) {
let callsign = packet.from.toString().trim();
if (!(callsign in acc)) acc[callsign] = [];
acc[callsign].push(packet);
return acc;
}
function colorForDigi(digi) {
if (digi in digiColors.value) {
return digiColors.value[digi].hex();
} else {
return '#000000';
}
}
function packetPathStyleFunc(feature, resolution) {
let paths = feature.getProperties().properties.paths.slice(0);
let styles = [];
feature
.getGeometry()
.getLineStrings()
.forEach((ls) => {
let path = paths.shift().slice(0);
ls.forEachSegment((start, end) => {
let color = colorForDigi(path.shift());
styles.push(
new Style({
geometry: new LineString([start, end]),
stroke: new Stroke({ color: color, width: 2 }),
})
);
});
});
console.log(styles);
return styles;
}
const positionalPackets = computed(() => {
return (
packets
.filter(
(packet) =>
packet.date > new Date('2018-07-13') &&
packet.date < new Date('2018-07-14')
)
// filter to just positional data
.filter((packet) => 'data' in packet && 'latitude' in packet.data)
);
});
const stationPaths = computed(() => {
// group by callsign
return positionalPackets.value.reduce(groupByCall, {});
});
const digis = computed(() => {
let digiCalls = new Set(
packets
.map((packet) => pathToString(packet.via))
.reduce((acc, stations) => acc.concat(stations))
);
return (
packets
// filter to digis
.filter((packet) => digiCalls.has(packet.from.toString().trim()))
// filter to just positional data
.filter((packet) => 'data' in packet && 'latitude' in packet.data)
// group by call
.reduce(groupByCall, {})
);
});
const digiPos = computed(() => {
return Object.entries(digis.value).reduce((acc, [digi, packets]) => {
let lastPacket = packets[packets.length - 1];
acc[digi] = [lastPacket.data.longitude, lastPacket.data.latitude];
return acc;
}, {});
});
const packetPaths = computed(() => {
let digipeaterPostitions = digiPos.value;
return Object.entries(stationPaths.value).map(([station, packets]) => {
let lines = packets.map((packet) => {
let path = pathToString(packet.via);
return {
// first point in path is originating station
coords: [
[packet.data.longitude, packet.data.latitude],
...path.map((hop) => digipeaterPostitions[hop] || [0, 0]),
],
path: path,
};
});
return new Feature({
id: station,
geometry: new MultiLineString(lines.map((p) => p.coords)),
properties: { paths: lines.map((p) => p.path) },
});
});
});
const stationColors = computed(() => {
return distinctColors({
count: Object.keys(stationPaths.value).length,
lightMin: 20,
lightMax: 80,
});
});
const digiColors = computed(() => {
let colors = distinctColors({
count: Object.keys(digis.value).length,
lightMin: 20,
lightMax: 80,
});
return Object.keys(digis.value).reduce((acc, callsign, index) => {
acc[callsign] = colors[index];
return acc;
}, {});
});
</script>
<style>
html,
body {
height: 100%;
margin: 0;
}
.map {
width: 100vw;
height: 100vh;
}
</style>

View File

@ -1,14 +1,14 @@
<template> <template>
<tr :class="{ timedOut, lowVoltage, neverHeard: !status.lastHeard }"> <tr :class="{ timedOut, isLowVoltage, neverHeard: !stationStatus.lastHeard }">
<td :title="callsign">{{ tacticalAndOrCall }}</td> <td :title="callsign">{{ tacticalAndOrCall }}</td>
<template v-if="status.lastHeard"> <template v-if="stationStatus.lastHeard">
<td>{{ formatTime(status.lastHeard) }}</td> <td>{{ formatTime(stationStatus.lastHeard) }}</td>
<td>{{ formatTime(now - status.lastHeard, true) }}</td> <td>{{ formatTime(now - stationStatus.lastHeard, true) }}</td>
<td>{{ formatTime(Math.round(status.avgDelta), true) }}</td> <td>{{ formatTime(Math.round(stationStatus.avgDelta), true) }}</td>
<td>{{ status.lastMicE }}</td> <td>{{ stationStatus.lastMicE }}</td>
<td>{{ status.lastVoltage || "" }}</td> <td>{{ stationStatus.lastVoltage || '' }}</td>
<td>{{ status.lastTemperature || "" }}</td> <td>{{ stationStatus.lastTemperature || '' }}</td>
<td>{{ status.lastComment }}</td> <td>{{ stationStatus.lastComment }}</td>
</template> </template>
<template v-else> <template v-else>
<td>Never Heard</td> <td>Never Heard</td>
@ -22,125 +22,131 @@
</tr> </tr>
</template> </template>
<script> <script setup>
import config from "./status_config.yaml"; import { ref, computed, watch } from 'vue';
import parseDuration from 'parse-duration';
export default { const props = defineProps({
name: "StationRow", callsign: String,
props: { callsign: String, tactical: String, messages: Array, now: Date }, tactical: String,
timeoutLength: String,
lowVoltage: Number,
finishedReplay: Boolean,
messages: Array,
now: Date,
});
data() { function notify(title, body) {
return { if (props.finishedReplay) {
status: { return new Notification(title, { body: body, requireInteraction: true });
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, idx, arr) => {
acc.lastHeard = message.date.getTime();
if (idx === 0) {
acc.avgDelta = 0;
} else {
let delta = message.date.getTime() - arr[idx - 1].date.getTime();
acc.avgDelta = (acc.avgDelta * (idx - 1) + delta) / idx;
}
if ("data" in message) {
if ("analog" in message.data) {
acc.lastVoltage = message.data.analog[0] / 10;
acc.lastTemperature = message.data.analog[1];
}
acc.lastMicE = message.data.mice || acc.lastMicE;
acc.lastComment = message.data.comment || acc.lastComment;
}
return acc;
}, {})
);
},
lowVoltage(newVal) {
if (newVal) {
this.notify(
`${this.tacticalAndOrCall}'s battery has dropepd below ${config.lowVoltage}V`,
`Voltage: ${this.status.lastVoltage}`
);
}
},
timedOut(newVal) {
if (newVal) {
this.notify(
`${
this.tacticalAndOrCall
} 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: {
tacticalAndOrCall() {
return this.tactical
? `${this.tactical} [${this.callsign}]`
: 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
);
}
} }
}; }
function formatTime(time, isDuration = false) {
return new Date(time).toLocaleTimeString(
'en-GB',
isDuration ? { timeZone: 'UTC' } : {}
);
}
const timeoutLengthMs = computed(() => {
return parseDuration(props.timeoutLength);
});
function 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(' ');
}
const tacticalAndOrCall = computed(() => {
return props.tactical
? `${props.tactical} [${props.callsign}]`
: props.callsign;
});
const timedOut = computed(() => {
if (stationStatus.value.lastHeard === null) {
return false;
} else {
return (
props.now.getTime() - stationStatus.value.lastHeard >
timeoutLengthMs.value
);
}
});
const isLowVoltage = computed(() => {
return (
stationStatus.value.lastVoltage &&
stationStatus.value.lastVoltage < props.lowVoltage
);
});
const stationStatus = computed(() => {
const status = {
lastHeard: null,
delta: null,
lastVoltage: null,
lastTemperature: null,
};
Object.assign(
status,
props.messages.reduce((acc, message, idx, arr) => {
acc.lastHeard = message.date.getTime();
if (idx === 0) {
acc.avgDelta = 0;
} else {
let delta = message.date.getTime() - arr[idx - 1].date.getTime();
acc.avgDelta = (acc.avgDelta * (idx - 1) + delta) / idx;
}
if ('data' in message) {
if ('analog' in message.data) {
acc.lastVoltage = message.data.analog[0] / 10;
acc.lastTemperature = message.data.analog[1];
}
acc.lastMicE = message.data.mice || acc.lastMicE;
acc.lastComment = message.data.comment || acc.lastComment;
}
return acc;
}, {})
);
return status;
});
watch(isLowVoltage, (newVal) => {
if (newVal) {
notify(
`${tacticalAndOrCall}'s battery has dropepd below ${props.lowVoltage}V`,
`Voltage: ${stationStatus.value.lastVoltage}`
);
}
});
watch(timedOut, (newVal) => {
if (newVal) {
notify(
`${tacticalAndOrCall.value} has not been heard for over ${prettyDuration(
timeoutLengthMs.value
)}!`,
`Last Heard: ${formatTime(
stationStatus.value.lastHeard
)} (${prettyDuration(
props.now.getTime() - stationStatus.value.lastHeard
)} ago!)`
);
}
});
</script> </script>
<style> <style>
@ -148,7 +154,7 @@ tr.timedOut {
background-color: red; background-color: red;
} }
tr.lowVoltage { tr.isLowVoltage {
background-color: yellow; background-color: yellow;
} }

View File

@ -1,5 +1,13 @@
<template> <template>
<div> <div>
<button
class="notification-request"
v-show="!canNotify"
@click="requestNotification"
>
Enable Notifications
</button>
<table> <table>
<tr> <tr>
<th>Callsign</th> <th>Callsign</th>
@ -13,10 +21,11 @@
</tr> </tr>
<StationRow <StationRow
v-for="(tactical, callsign) in trackedStations" v-for="(stationProps, callsign) in trackedStations"
:key="callsign" :key="callsign"
:callsign="callsign" :callsign="callsign"
:tactical="tactical" v-bind="{ ...config.default, ...stationProps }"
:finishedReplay="finishedReplay"
:messages="messagesFromStation[callsign] || []" :messages="messagesFromStation[callsign] || []"
:now="now" :now="now"
> >
@ -25,94 +34,121 @@
</div> </div>
</template> </template>
<script> <script setup>
import aprs from "aprs-parser"; import { ref, onMounted } from 'vue';
import APRSParser from 'aprs-parser/lib/APRSParser';
import StationRow from "./StationRow.vue"; import StationRow from './StationRow.vue';
import config from './status_config.yaml';
import config from "./status_config.yaml"; const parser = new APRSParser();
let aprsStream = null;
const finishedReplay = ref(false);
const messages = ref([]);
const messagesFromStation = ref({});
const now = ref(new Date());
const trackedStations = ref(normalizeConfigStations());
const canNotify = ref(Notification.permission === 'granted');
export default { function normalizeConfigStations() {
name: "StationStatus", return [...Object.entries(config.trackedStations)]
components: { StationRow }, .map(([callsign, tacticalOrProps]) => {
data() { if (typeof tacticalOrProps === 'string') {
return { return [callsign, { tactical: tacticalOrProps }];
aprsStream: null,
parser: new aprs.APRSParser(),
messages: [],
messagesFromStation: {},
now: new Date(),
trackedStations: config.trackedStations
};
},
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(packet[0]);
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 { } else {
this.$set(this.messagesFromStation, callsign, [message]); return [callsign, tacticalOrProps];
} }
})
.reduce((acc, [callsign, props]) => {
console.log(callsign, props);
acc[callsign] = props;
return acc;
}, {});
}
// message to TACTICAL setting a tactical nickname from an onMounted(() => {
// authorized call, so add/update it in trackedStations // Connect to websocket aprs stream
if ( connectToStream();
message.data &&
message.data.addressee && // update shared current time every second
message.data.addressee.call === "TACTICAL" && window.setInterval(() => (now.value = new Date()), 1000);
config.TACTICAL_whitelist.includes(message.from.toString()) });
) {
message.data.text.split(";").map(tac_assoc => { function connectToStream() {
let [call, tac] = tac_assoc.split("=", 2); aprsStream = new WebSocket('ws://localhost:4321');
if (tac) { aprsStream.onclose = () => {
this.trackedStations[call] = tac; // Try to reconnect every 5 seconds
} else { let interval = window.setTimeout(() => {
delete this.trackedStations[call]; window.clearInterval(interval);
} connectToStream();
}); }, 5000);
} };
aprsStream.onmessage = (event) => {
if (event.data === 'FINISHED REPLAY') {
finishedReplay.value = true;
} else if (event.data !== '') {
handleMessage(JSON.parse(event.data));
} }
};
}
function handleMessage(packet) {
let message = parser.parse(packet[1]);
message.date = new Date(packet[0]);
console.info(message);
messages.value.push(message);
let callsign = message.from && message.from.toString();
if (callsign in messagesFromStation.value) {
messagesFromStation.value[callsign].push(message);
} else {
messagesFromStation.value[callsign] = [message];
} }
};
// message to TACTICAL setting a tactical nickname from an
// authorized call, so add/update it in trackedStations
if (
message.data &&
message.data.addressee &&
message.data.addressee.call === 'TACTICAL' &&
config.TACTICAL_whitelist.includes(message.from.toString())
) {
message.data.text.split(';').map((tac_assoc) => {
let [call, tac] = tac_assoc.split('=', 2);
if (tac) {
if (!Object.hasOwn(trackedStations.value, call)) {
trackedStations.value[call] = {};
}
trackedStations.value[call].tactical = tac;
} else {
delete trackedStations.value[call];
}
});
}
}
function requestNotification() {
// request notification permissions
if (Notification.permission !== 'granted') {
Notification.requestPermission((permission) => {
canNotify.value = permission;
if (permission === 'granted') {
new Notification('Test notification', { body: 'whatever' });
}
});
}
}
</script> </script>
<style> <style>
.notification-request {
position: fixed;
right: 1em;
bottom: 1em;
z-index: 1;
font-size: 1.5em;
}
table { table {
border-collapse: collapse; border-collapse: collapse;
} }
@ -132,7 +168,7 @@ table th {
/* border magic for sticky header */ /* border magic for sticky header */
/* https://stackoverflow.com/questions/50361698/border-style-do-not-work-with-sticky-position-element */ /* https://stackoverflow.com/questions/50361698/border-style-do-not-work-with-sticky-position-element */
th::before { th::before {
content: ""; content: '';
position: absolute; position: absolute;
left: 0; left: 0;
width: 100%; width: 100%;
@ -142,7 +178,7 @@ th::before {
top: 1px; top: 1px;
} }
th::after { th::after {
content: ""; content: '';
position: absolute; position: absolute;
left: 0; left: 0;
width: 100%; width: 100%;

View File

@ -1,7 +0,0 @@
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app"> </div>
<script src="./index.js"></script>
</body>

View File

@ -1,7 +1,7 @@
import Vue from 'vue'; import * as Vue from 'vue';
import App from './StatusScreen.vue';
new Vue({ import StatusScreen from './StatusScreen.vue';
el: '#app',
render: h => h(App), const app = Vue.createApp(StatusScreen);
});
app.mount('#app');

View File

@ -1,48 +0,0 @@
html, body {
height: 100%;
margin: 0;
}
.map {
height: 100%;
width: 100%;
}
.ol-control.layer-toggles {
top: 0.5em;
right: 0.5em;
background-color: rgba(70, 115, 164, 0.7);
color: #eee;
white-space: nowrap;
max-height: calc(100vh - 1em);
overflow-y: auto;
}
.layer-toggles > div {
margin: 0.5em;
margin-right: 1em;
}
.ol-control.layer-toggles:hover {
background-color: rgba(0,60,136,0.7);
}
.layer-toggles > div > label {
display: block;
}
.expand {
display: none;
}
.expand + span::before {
content: '\25B6';
}
.expand:checked + span::before {
content: '\25BC';
}
.expand ~ .collapsible-content {
display: none;
}
.expand:checked ~ .collapsible-content {
display: block;
}
.collapsible-content {
margin-left: 0.8em;
}
.collapsible-content > label {
display: block;
}

View File

@ -1,11 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<link rel="stylesheet" type="text/css" href="map.css">
<title>OpenLayers example</title>
</head>
<body>
<div id="map" class="map"></div>
<script src="./map.js"></script>
</body>
</html>

View File

@ -1,314 +1,11 @@
import 'ol/ol.css'; import * as Vue from 'vue';
import {Map as olMap, View} from 'ol';
import {Control} from 'ol/control';
import {Group as LayerGroup, Tile as TileLayer, Vector as VectorLayer} from 'ol/layer';
import {OSM, Vector as VectorSource} from 'ol/source';
import {GPX} from 'ol/format';
import Feature from 'ol/Feature';
import {fromLonLat} from 'ol/proj';
import {Circle as CircleStyle, Icon, Fill, Stroke, Style, Text} from 'ol/style';
import Projection from 'ol/proj/Projection';
import {LineString, Point} from 'ol/geom';
import {readFileSync} from 'fs'; import OpenLayersMap from 'vue3-openlayers';
const packetLog = readFileSync(__dirname + '/../IS_packets.txt', 'utf-8'); import 'vue3-openlayers/dist/vue3-openlayers.css';
import {APRSParser} from 'aprs-parser'; import Map from './Map.vue';
let tile_layer = new TileLayer({source: new OSM()}); const app = Vue.createApp(Map);
app.use(OpenLayersMap);
import route_data from 'gpx/*.gpx'; app.mount('#app');
let routes = new LayerGroup({
title: "Routes",
layers: Object.keys(route_data).map(name => new VectorLayer({
title: name.replace(/_/g, " "),
source: new VectorSource({
url: route_data[name],
format: new GPX()
}),
style: new Style({
stroke: new Stroke(
{color: 'hsl(200, 90%, 30%)', width: 5}
)})
}))
});
let map = new olMap({
target: 'map',
layers: [
tile_layer,
routes
],
view: new View({
center: fromLonLat([-72.15, 43.90]),
zoom: 10
})
});
class ColorGenerator {
constructor(count) {
let mult = Math.floor(360 / count);
this.hues = Array.from(Array(count).keys()).map(x => x * mult);
// Shuffle (this is not a great shuffle, but I don't care)
this.hues.forEach((current, index, arr) => {
let randomIndex = Math.floor(Math.random() * index);
[arr[index], arr[randomIndex]] = [arr[randomIndex], arr[index]];
});
}
get() {
return this.hues.pop();
}
};
function transformGeometry(geometry) {
return geometry.transform(new Projection({code: "EPSG:4326"}),
tile_layer.getSource().getProjection());
}
function plotPaths(packets) {
let path_layers = new LayerGroup({title: "Station Paths"});
map.addLayer(path_layers);
let paths = packets
.filter(packet => packet.date > new Date("2018-07-14") && packet.date < new Date("2018-07-15"))
// filter to just positional data
.filter(packet => 'data' in packet && 'latitude' in packet.data)
// join into Arrays of points
.reduce((acc, packet) => {
let callsign = packet.from.toString().trim();
if (!acc.has(callsign)) acc.set(callsign, []);
acc.get(callsign).push([packet.data.longitude, packet.data.latitude, packet]);
return acc;
}, new Map());
let colorGen = new ColorGenerator(paths.size);
// plot on map
paths.forEach((points, callsign) => {
let color = 'hsl(' + colorGen.get() + ', 75%, 50%)';
let path_layer = new VectorLayer({
title: "Path",
source: new VectorSource({features: [
new Feature({geometry: transformGeometry(new LineString(points))})
]}),
style: new Style({stroke: new Stroke({color: color, width: 2})})
});
let points_layer = new VectorLayer({
title: "Points",
source: new VectorSource({
features: points.map(point => new Feature({
geometry: transformGeometry(new Point(point)),
packet: point[3] // TODO: this seems a bit bad
}))
}),
style: new Style({
image: new CircleStyle({
radius: 4,
fill: new Fill({color: color})
})
})
});
let callsign_layer = new LayerGroup({
title: callsign,
layers: [path_layer, points_layer]
});
path_layers.getLayers().push(callsign_layer);
});
}
function plotPacketPaths(packets) {
function pathToString(path) {
return path
.filter(station => !station.call.match(/WIDE[12]|qA?|UV[123]|.*\*$|UNCAN/))
.map(station => station.toString().trim().replace(/\*$/, ""));
}
let digiCalls =
new Set(packets
.map(packet => pathToString(packet.via))
.reduce((acc, stations) => acc.concat(stations)));
let digiPos = packets
// filter to digis
.filter(packet => digiCalls.has(packet.from.toString().trim()))
// filter to just positional data
.filter(packet => 'data' in packet && 'latitude' in packet.data)
// convert to callsign -> position mapping
.reduce((stations, packet) =>
stations.set(packet.from.toString().trim(),
[packet.data.longitude, packet.data.latitude]),
new Map());
let colorGen = new ColorGenerator(digiPos.size);
let colorMap = Array.from(digiPos.keys()).reduce(
(acc, callsign, index) =>
acc.set(callsign, colorGen.hues[index]), new Map());
// plot digis
// TODO: icons
let digi_style = new Style({
image: new CircleStyle({
radius: 5,
fill: new Fill({color: 'grey'})
}),
text: new Text({
font: 'bold 11px "Open Sans", "Arial Unicode MS", "sans-serif"',
overflow: true,
fill: new Fill({color: 'black'})
})
});
let digi_layer = new VectorLayer({
title: "Digipeater Labels",
zIndex: 1, // TODO: probably not the best way to do this
source: new VectorSource({
features: Array.from(digiPos.keys()).map(callsign =>
new Feature({
geometry: transformGeometry(new Point(digiPos.get(callsign))),
callsign: callsign
})
)}),
style: feature => {
digi_style.setText(new Text({
text: feature.get('callsign'),
offsetY: 12
}));
return digi_style;
}
});
map.addLayer(digi_layer);
let packet_path_layers = new LayerGroup({title: "Packet Paths"});
let layers_map = new Map();
map.addLayer(packet_path_layers);
packets
.filter(packet => packet.date > new Date("2018-07-14") && packet.date < new Date("2018-07-15"))
// filter by callsign
//.filter(packet => packet.from.toString() === "W1HS-9")
// filter to just positional data
.filter(packet => 'data' in packet && 'latitude' in packet.data)
.forEach(packet => {
pathToString(packet.via)
.forEach((station, index, stations) => {
if (digiPos.get(station) === undefined) {
console.log(station);
}
// first point in path is originating station
let previous = (index === 0) ?
[packet.data.longitude, packet.data.latitude] :
digiPos.get(stations[index - 1]) || [0, 0];
let pathFeature = new Feature(transformGeometry(
new LineString([previous, digiPos.get(station) || [0, 0]])));
// TODO: want to color per station that hears it, probably means
// making a lot more features
let color = colorMap.get(station);
pathFeature.setStyle(new Style({
stroke: new Stroke(
{color: 'hsl(' + color + ', 60%, 60%)', width: 2}
)}));
if (!layers_map.has(station)) {
layers_map.set(station, new VectorLayer({
title: station,
source: new VectorSource(),
renderMode: 'image'
}));
packet_path_layers.getLayers().push(layers_map.get(station));
}
layers_map.get(station).getSource().addFeature(pathFeature);
});
});
}
function layer_toggle(layer) {
if (layer.toggle_element === undefined) {
layer.toggle_element = document.createElement('label');
let checkbox = layer.toggle_element.appendChild(
document.createElement('input'));
checkbox.type = "checkbox";
checkbox.checked = layer.getVisible();
checkbox.addEventListener('change', event => {
layer.setVisible(event.target.checked);
});
layer.toggle_element.appendChild(
document.createTextNode(layer.get('title')));
}
return layer.toggle_element;
}
function layer_toggles(layer, parentElement) {
if (layer instanceof LayerGroup) {
if (layer.group_toggle === undefined) {
let label = layer.group_toggle = parentElement.appendChild(
document.createElement('label'));
let input = label.appendChild(document.createElement('input'));
input.type = 'checkbox';
input.className = "expand";
label.appendChild(document.createElement('span'));
label.appendChild(layer_toggle(layer)); // whole LayerGroup
let child_toggle_element = label.appendChild(
document.createElement('input'));
child_toggle_element.type = 'checkbox';
child_toggle_element.checked = true;
child_toggle_element.addEventListener('change', event => {
layer.getLayers().forEach(subLayer => {
subLayer.setVisible(event.target.checked);
subLayer.toggle_element.querySelector("input").checked =
event.target.checked;
});
});
let container = label.appendChild(document.createElement('div'));
container.className = 'collapsible-content';
layer.getLayers().forEach(
subLayer => layer_toggles(subLayer, container));
return label;
}
}
else {
parentElement.appendChild(layer_toggle(layer));
}
}
function render_layer_toggles(event, element) {
event.map.getLayers().getArray()
.filter(layer => layer.get('title') !== undefined)
.forEach(layer => layer_toggles(layer, element));
}
(function makeLayerToogleControl() {
let element = document.createElement('div');
element.className = 'layer-toggles ol-unselectable ol-control';
let inner = element.appendChild(document.createElement('div'));
let control = new Control(
{element: element,
render: event => render_layer_toggles(event, inner)});
map.addControl(control);
})();
function parsePackets(packetLog) {
let parser = new APRSParser();
return packetLog.trim().split("\n")
// parse to Date and APRS packet
.map(line => {
let packet = parser.parse(line.slice(29));
packet.date = new Date(line.slice(0,18));
return packet;
});
}
let packets = parsePackets(packetLog);
plotPaths(packets);
plotPacketPaths(packets);

View File

@ -1,57 +0,0 @@
const WebSocket = require("ws");
const net = require("net");
const fs = require("fs");
const client = new net.Socket();
const wss = new WebSocket.Server({ host: "127.0.0.1", port: 4321 });
wss.broadcast = function(data) {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
});
};
client.connect(14580, "rotate.aprs2.net", () =>
client.write("user KC1GDW pass -1 filter r/43.90/-72.15/75\r\n")
);
function datestamp(date) {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
client.on("data", function(data) {
let str = data.toString("utf8").replace(/^\s+|\s+$/g, "");
console.log(str);
// strip whitespace, then handle multiple APRS packets per TCP packet
str.split("\r\n").forEach(packet => {
if (!packet.startsWith("#")) {
// ignore comments
let date = new Date();
// create log dir if it doesn't exist
if (!fs.existsSync("log")) fs.mkdirSync("log");
fs.appendFile(
`log/log${datestamp(date)}.json`,
JSON.stringify([date, packet]) + "\n",
err => {
if (err) throw err;
}
);
wss.broadcast(JSON.stringify([date, packet]));
}
});
});
wss.on("connection", ws => {
let date = new Date();
let filename = `log/log${datestamp(date)}.json`;
if (fs.existsSync(filename)) {
fs.readFileSync(filename)
.toString()
.split("\n")
.forEach(line => ws.send(line));
}
});

View File

@ -1,42 +1,38 @@
timeoutLength: 600000 # 10 * 60 * 1000 = 10 minutes
lowVoltage: 11.9
TACTICAL_whitelist: TACTICAL_whitelist:
- KC1GDW-7 - KC1GDW-7
- W1FN - W1FN
default:
timeoutLength: 10 minutes
lowVoltage: 11.9
trackedStations: trackedStations:
# Digis/iGates # Digis/iGates
W1FN-1: W1FN-1:
W1FN-3: tactical: Moose Mt
timeoutLength: 25 minutes
W1FN-5: W1FN-5:
W1FN-6: tactical: Hanover
W1FN-7: timeoutLength: 25 minutes
W1FN-8:
W1FN-9: W1FN-9:
W1FN-10: tactical: Bath
timeoutLength: 25 minutes
N1GMC-1: N1GMC-1:
tactical: Dame Hill Rd, Orford
timeoutLength: 25 minutes
N1GMC-2: N1GMC-2:
tactical: Sunday Mt, Orford
timeoutLength: 25 minutes
KB1FDA-1:
tactical: Corinth
timeoutLength: 25 minutes
# Vehicles # Vehicles
W1HS-9: Rover SF-1 K1EHZ-10: Recovery
KC1LFU-5: Rover 1-2 NA1T-9: Ride Manager
KC1LFU-1: Rover 2-1 KC1LFU-1: Logistics Coord.
KC1LFU-2: Rover 2-3 KC1GDW-9: Bike Tech
N1EMF-7: Rover 3-4 WA1ZCN-11: Trouble
K1DSP-9: Rover 4-5 KC1LFU-2: Rover 1
AB1XQ-9: Rover 5-8 K1EHZ-4: Rover 2
N5IEP-1: Rover 5-6 W1TGA-9: Rover 3
KC1BOS-2: Rover 6-7
K1EHZ-4: Rover 7-8
KC1LFU-6: Rover 2-8
KC1LFU-7: Rover 8-2
KC1LFU-3: Rover FPL
WB1BRE-14: Metric Rover
KC1LFU-9: Recovery 1
W1LKS-9: Recovery 2
WB1BRE-15: Shuttle 1
KC1LFU-10: Supply 1
NA1T-9: Safety 1
AE1H-9: Trouble 1
AE1H-8: Trouble 2

18
vite.config.js Normal file
View File

@ -0,0 +1,18 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import yaml from '@rollup/plugin-yaml';
// https://vitejs.dev/config/
export default defineConfig({
assetsInclude: ['**/*.gpx'],
plugins: [vue(), yaml()],
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
map: resolve(__dirname, 'map/index.html'),
},
},
},
});