@meri-imperiumi/signalk-aprs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "airbnb-base",
3
+ "parserOptions": {
4
+ "ecmaVersion": 11
5
+ }
6
+ }
@@ -0,0 +1,30 @@
1
+ name: Publish Node.js Package
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v3.0.2
13
+ - uses: actions/setup-node@v3.1.1
14
+ with:
15
+ node-version: 22
16
+ - run: npm install
17
+ - run: npm test
18
+
19
+ publish-npm:
20
+ needs: build
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - uses: actions/checkout@v3.0.2
24
+ - uses: actions/setup-node@v3.1.1
25
+ with:
26
+ node-version: 22
27
+ registry-url: https://registry.npmjs.org/
28
+ - run: npm publish
29
+ env:
30
+ NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
@@ -0,0 +1,21 @@
1
+ name: Node CI
2
+
3
+ on: [push, pull_request]
4
+
5
+ jobs:
6
+ test:
7
+ name: Run test suite
8
+ runs-on: ubuntu-latest
9
+ strategy:
10
+ matrix:
11
+ node-version: [22.x]
12
+ steps:
13
+ - uses: actions/checkout@v3.0.2
14
+ - name: Use Node.js ${{ matrix.node-version }}
15
+ uses: actions/setup-node@v3.1.1
16
+ with:
17
+ node-version: ${{ matrix.node-version }}
18
+ - run: npm install
19
+ - run: npm test
20
+ env:
21
+ CI: true
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ Signal K APRS Plugin
2
+ ====================
3
+
4
+ This plugin integrates [Signal K](https://signalk.org) with the [Automatic Packet Reporting System](https://www.aprs.org) (APRS), a packet radio system for Radio Amateurs. The plugin connects to various APRS-capable radio systems using the KISS TNC protocol.
5
+
6
+ You need to be a licensed radio amateur to use APRS. For everybody else, [Signal K Meshtastic Plugin](https://github.com/meri-imperiumi/signalk-meshtastic) is the way to go.
7
+
8
+ APRS is a registered trademark Bob Bruninga, WB4APR.
9
+
10
+ ## Status
11
+
12
+ Very early stages, being tested with the [LoRa APRS iGate](https://github.com/richonguzman/LoRa_APRS_iGate) firmware.
13
+
14
+ ## Features
15
+
16
+ * Support for connecting to multiple KISS TNCs
17
+ - This allows connecting to both LoRa APRS and VHF APRS for instance
18
+ - TX can be enabled separately for each TNC, allowing listen-only connections
19
+ - The plugin will keep trying to reconnect to TNCs that are offline
20
+ * Periodically sending vessel position as a beacon to TX-enabled TNCs
21
+ * Populating received WX stations into Signal K data structure
22
+
23
+ ## Planned features
24
+
25
+ * Transmit WX data from boat sensors (wind, temperature, etc)
26
+ * Send telemetry (battery status, water depth, anchor distance) over APRS
27
+ * Show other APRS beacons as vessels in Freeboard etc
28
+ * Send alerts to crew over APRS
29
+ * Get a [dedicated TOCALL for this plugin](https://github.com/aprsorg/aprs-deviceid/issues/244)
30
+ * Figure out how to handle APRS messaging from/to boat
31
+
32
+ ## Changes
33
+
34
+ * 0.1.0 (2025-10-29)
35
+ - Initial release, can beacon vessel position
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@meri-imperiumi/signalk-aprs",
3
+ "version": "0.1.0",
4
+ "description": "Signal K plugin for interacting with the Automatic Packet Reporting System for radio amateurs",
5
+ "main": "plugin/index.js",
6
+ "scripts": {
7
+ "test": "eslint ."
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git://github.com/meri-imperiumi/signalk-aprs.git"
12
+ },
13
+ "keywords": [
14
+ "aprs",
15
+ "signalk-node-server-plugin",
16
+ "signalk-category-hardware"
17
+ ],
18
+ "signalk-plugin-enabled-by-default": false,
19
+ "author": "Henri Bergius <henri.bergius@iki.fi>",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "kiss-tnc": "^2.0.1",
26
+ "utils-for-aprs": "^3.1.0"
27
+ },
28
+ "devDependencies": {
29
+ "eslint": "^8.57.1",
30
+ "eslint-config-airbnb-base": "^15.0.0",
31
+ "eslint-plugin-import": "^2.32.0"
32
+ }
33
+ }
package/plugin/aprs.js ADDED
@@ -0,0 +1,34 @@
1
+ function formatLatitude(coord) {
2
+ const degFloat = Math.abs(coord);
3
+ const deg = String(Math.floor(degFloat)).padStart(2, '0');
4
+ const minFloat = 60 * (degFloat - Math.floor(degFloat));
5
+ const min = String(Math.floor(minFloat)).padStart(2, '0');
6
+ const secFloat = 60 * (minFloat - Math.floor(minFloat));
7
+ const sec = String(Math.floor(secFloat)).padStart(2, '0');
8
+ const sign = coord > 0 ? 'N' : 'S';
9
+ return `${deg}${min}.${sec}${sign}`;
10
+ }
11
+
12
+ function formatLongitude(coord) {
13
+ const degFloat = Math.abs(coord);
14
+ const deg = String(Math.floor(degFloat)).padStart(3, '0');
15
+ const minFloat = 60 * (degFloat - Math.floor(degFloat));
16
+ const min = String(Math.floor(minFloat)).padStart(2, '0');
17
+ const secFloat = 60 * (minFloat - Math.floor(minFloat));
18
+ const sec = String(Math.floor(secFloat)).padStart(2, '0');
19
+ const sign = coord > 0 ? 'E' : 'W';
20
+ return `${deg}${min}.${sec}${sign}`;
21
+ }
22
+
23
+ function formatAddress(obj) {
24
+ if (!obj.ssid) {
25
+ return obj.callsign;
26
+ }
27
+ return `${obj.callsign}-${obj.ssid}`;
28
+ }
29
+
30
+ module.exports = {
31
+ formatLatitude,
32
+ formatLongitude,
33
+ formatAddress,
34
+ };
@@ -0,0 +1,501 @@
1
+ const { Socket } = require('node:net');
2
+ const { KISSSender } = require('kiss-tnc');
3
+ const { APRSProcessor, newKISSFrame } = require('utils-for-aprs');
4
+ const { formatLatitude, formatLongitude, formatAddress } = require('./aprs');
5
+
6
+ module.exports = (app) => {
7
+ const plugin = {};
8
+ let unsubscribes = [];
9
+ let beacons = {};
10
+ let connections = [];
11
+ let publishInterval;
12
+ let state = '';
13
+ plugin.id = 'signalk-aprs';
14
+ plugin.name = 'APRS';
15
+ plugin.description = 'Connect Signal K with the Automatic Packet Reporting System for Radio Amateurs';
16
+
17
+ function beaconsOnline() {
18
+ // LoRa APRS iGate uses 30min
19
+ const onlineSecs = 60 * 60 * 0.5;
20
+ const now = new Date();
21
+ return Object
22
+ .keys(beacons)
23
+ .filter((b) => {
24
+ if (beacons[b] > now.getTime() - (onlineSecs * 1000)) {
25
+ return true;
26
+ }
27
+ return false;
28
+ })
29
+ .length;
30
+ }
31
+
32
+ function setConnectionStatus() {
33
+ const connected = connections.filter((c) => c.online);
34
+ if (connected.length === 0) {
35
+ app.setPluginStatus('No TNC connection');
36
+ }
37
+ const connectedStr = connected
38
+ .map((c) => c.address).join(', ');
39
+ app.setPluginStatus(`Connected to TNC ${connectedStr}. ${beaconsOnline()} beacons online.`);
40
+ }
41
+
42
+ plugin.start = (settings) => {
43
+ if (!settings.connections || !settings.connections.length) {
44
+ // Not much to do here
45
+ app.setPluginStatus('No TNC connections configured');
46
+ return;
47
+ }
48
+ const processor = new APRSProcessor();
49
+ processor.on('aprsData', (data) => {
50
+ app.setPluginStatus(`RX ${data.info}`);
51
+ app.debug('RX', data);
52
+ beacons[formatAddress(data.source)] = new Date();
53
+ if (data.weather) {
54
+ // WX station, populate to Signal K
55
+ const values = [];
56
+ if (data.position
57
+ && data.position.coords
58
+ && Number.isFinite(data.position.coords.latitude)) {
59
+ values.push({
60
+ path: 'navigation.position',
61
+ value: {
62
+ latitude: data.position.coords.latitude,
63
+ longitude: data.position.coords.longitude,
64
+ },
65
+ });
66
+ }
67
+ if (Number.isFinite(data.weather.temperature)) {
68
+ values.push({
69
+ path: 'environment.outside.temperature',
70
+ value: (data.weather.temperature + 459.67) * (5 / 9), // APRS uses Fahrenheit
71
+ });
72
+ }
73
+ if (Number.isFinite(data.weather.windSpeed)) {
74
+ values.push({
75
+ path: 'environment.wind.speedTrue',
76
+ value: data.weather.windSpeed * 0.44704, // APRS uses mph
77
+ });
78
+ }
79
+ if (Number.isFinite(data.weather.windDirection)) {
80
+ values.push({
81
+ path: 'environment.wind.directionTrue',
82
+ value: data.weather.windDirection * (Math.PI / 180),
83
+ });
84
+ }
85
+ if (Number.isFinite(data.weather.barometer)) {
86
+ values.push({
87
+ path: 'environment.outside.pressure',
88
+ value: data.weather.barometer * 1000, // APRS uses tenths of a mb
89
+ });
90
+ }
91
+ if (Number.isFinite(data.weather.humidity)) {
92
+ let humidity = data.weather.humidity / 100;
93
+ if (data.weather.humidity === 0) {
94
+ // 00 is 100
95
+ humidity = 1;
96
+ }
97
+ values.push({
98
+ path: 'environment.outside.absoluteHumidity',
99
+ value: humidity,
100
+ });
101
+ }
102
+ if (values.length) {
103
+ values.push({
104
+ path: '',
105
+ value: {
106
+ name: data.source.callsign,
107
+ },
108
+ });
109
+ values.push({
110
+ path: 'communication.aprs.callsign',
111
+ value: data.source.callsign,
112
+ });
113
+ values.push({
114
+ path: 'communication.aprs.ssid',
115
+ value: data.source.ssid,
116
+ });
117
+ values.push({
118
+ path: 'communication.aprs.route',
119
+ value: data.repeaterPath.map((r) => formatAddress(r)),
120
+ });
121
+ values.push({
122
+ path: 'communication.aprs.comment',
123
+ value: data.comment || '',
124
+ });
125
+
126
+ app.handleMessage('signalk-aprs', {
127
+ context: `meteo.${data.source.callsign}`,
128
+ updates: [
129
+ {
130
+ source: {
131
+ label: 'signalk-aprs',
132
+ src: formatAddress(data.source),
133
+ },
134
+ timestamp: new Date(data.position.timestamp).toISOString(),
135
+ values,
136
+ },
137
+ ],
138
+ });
139
+ }
140
+ }
141
+ setTimeout(() => {
142
+ setConnectionStatus();
143
+ }, 3000);
144
+ });
145
+ settings.connections.forEach((connectionSetting) => {
146
+ if (!connectionSetting.enabled) {
147
+ return;
148
+ }
149
+ let attempt = 0;
150
+ const connectionStr = `${connectionSetting.host}:${connectionSetting.port}`;
151
+ const socket = new Socket();
152
+ const conn = {
153
+ address: connectionStr,
154
+ socket,
155
+ tx: connectionSetting.tx || false,
156
+ online: false,
157
+ reconnect: undefined,
158
+ };
159
+ connections.push(conn);
160
+
161
+ const connect = () => {
162
+ attempt += 1;
163
+ app.debug(`${connectionStr} connect attempt ${attempt}`);
164
+ app.setPluginStatus(`Connecting to TNC ${connectionStr}, attempt ${attempt}`);
165
+ socket.connect(connectionSetting.port, connectionSetting.host);
166
+ };
167
+ const onConnectionError = (e) => {
168
+ app.error(e);
169
+ app.setPluginError(`Failed to connect to ${connectionStr}: ${e.message}`);
170
+ if (conn.reconnect) {
171
+ return;
172
+ }
173
+ app.debug(`Setting eventual reconnect for ${connectionStr}`);
174
+ conn.reconnect = setTimeout(() => {
175
+ conn.reconnect = undefined;
176
+ // Retry to connect
177
+ connect();
178
+ }, 10000);
179
+ };
180
+
181
+ socket.setTimeout(10000);
182
+ socket.once('error', onConnectionError);
183
+ socket.on('ready', () => {
184
+ app.debug(`${connectionStr} connected`);
185
+ const mySendStream = new KISSSender();
186
+ mySendStream.pipe(socket);
187
+ conn.sender = mySendStream;
188
+ conn.online = true;
189
+ socket.removeListener('error', onConnectionError);
190
+ setConnectionStatus();
191
+ });
192
+ socket.on('data', (data) => {
193
+ app.debug(`${connectionStr} RX`, data);
194
+ if (data.length < 4) {
195
+ // We don't want to parse empty frames
196
+ return;
197
+ }
198
+ // Remove FEND and FEND before processing
199
+ processor.data(data.slice(1, -1));
200
+ });
201
+ socket.on('timeout', () => {
202
+ app.debug(`${connectionStr} connection timeout`);
203
+ socket.end();
204
+ });
205
+ socket.on('error', (e) => {
206
+ app.error(e);
207
+ app.setPluginError(`Error with ${connectionStr}: ${e.message}`);
208
+ });
209
+ socket.on('close', () => {
210
+ app.debug(`${connectionStr} connection closed`);
211
+ conn.online = false;
212
+ socket.once('error', onConnectionError);
213
+ if (conn.reconnect) {
214
+ return;
215
+ }
216
+ app.debug(`Setting eventual reconnect for ${connectionStr}`);
217
+ conn.reconnect = setTimeout(() => {
218
+ conn.reconnect = undefined;
219
+ // Retry to connect
220
+ connect();
221
+ }, 10000);
222
+ });
223
+ connect();
224
+ });
225
+ const minutes = settings.beacon.interval || 15;
226
+ app.subscriptionmanager.subscribe(
227
+ {
228
+ context: 'vessels.self',
229
+ subscribe: [
230
+ {
231
+ path: 'navigation.position',
232
+ period: minutes * 60 * 1000,
233
+ },
234
+ {
235
+ path: 'navigation.state',
236
+ period: 60 * 1000,
237
+ },
238
+ ],
239
+ },
240
+ unsubscribes,
241
+ (subscriptionError) => {
242
+ app.error(subscriptionError);
243
+ },
244
+ (delta) => {
245
+ if (!delta.updates) {
246
+ return;
247
+ }
248
+ // Record inputs
249
+ delta.updates.forEach((u) => {
250
+ if (!u.values) {
251
+ return;
252
+ }
253
+ u.values.forEach((v) => {
254
+ if (v.path === 'navigation.state') {
255
+ state = v.value;
256
+ }
257
+ if (v.path !== 'navigation.position') {
258
+ return;
259
+ }
260
+ const payload = `=${formatLatitude(v.value.latitude)}${settings.beacon.symbol[0]}${formatLongitude(v.value.longitude)}${settings.beacon.symbol[1]} ${app.getSelfPath('name')} ${state} ${settings.beacon.note}`;
261
+ const frame = newKISSFrame().fromFrame({
262
+ destination: {
263
+ callsign: 'APZ42', // FIXME: https://github.com/aprsorg/aprs-deviceid/issues/244
264
+ },
265
+ source: {
266
+ callsign: settings.beacon.callsign,
267
+ ssid: settings.beacon.ssid,
268
+ },
269
+ repeaters: [
270
+ {
271
+ callsign: 'WIDE1',
272
+ ssid: '1',
273
+ },
274
+ ],
275
+ info: payload,
276
+ });
277
+
278
+ const frameBuffer = frame.build();
279
+ app.debug('TX', frame);
280
+ connections.forEach((conn) => {
281
+ if (!conn.tx || !conn.online || !conn.sender) {
282
+ return;
283
+ }
284
+ app.debug(`${conn.address} TX`, frameBuffer);
285
+ conn.sender.write(frameBuffer.slice(1));
286
+ app.setPluginStatus(`TX ${payload}`);
287
+ });
288
+ setTimeout(() => {
289
+ setConnectionStatus();
290
+ }, 3000);
291
+ app.handleMessage('signalk-aprs', {
292
+ context: 'vessels.self',
293
+ updates: [
294
+ {
295
+ source: {
296
+ label: 'signalk-aprs',
297
+ src: formatAddress(settings.beacon),
298
+ },
299
+ timestamp: new Date().toISOString(),
300
+ values: [
301
+ {
302
+ path: 'communication.aprs.callsign',
303
+ value: settings.beacon.callsign,
304
+ },
305
+ {
306
+ path: 'communication.aprs.ssid',
307
+ value: settings.beacon.ssid,
308
+ },
309
+ {
310
+ path: 'communication.aprs.symbol',
311
+ value: settings.beacon.symbol,
312
+ },
313
+ ],
314
+ },
315
+ ],
316
+ });
317
+ });
318
+ });
319
+ },
320
+ );
321
+ };
322
+
323
+ plugin.stop = () => {
324
+ if (publishInterval) {
325
+ clearInterval(publishInterval);
326
+ }
327
+ unsubscribes.forEach((f) => f());
328
+ unsubscribes = [];
329
+ connections.forEach((c) => {
330
+ c.socket.removeAllListeners();
331
+ c.socket.destroy();
332
+ });
333
+ connections = [];
334
+ beacons = {};
335
+ };
336
+
337
+ plugin.schema = {
338
+ type: 'object',
339
+ properties: {
340
+ beacon: {
341
+ type: 'object',
342
+ title: 'APRS Beacon settings',
343
+ properties: {
344
+ callsign: {
345
+ type: 'string',
346
+ description: 'Callsign',
347
+ default: 'NOCALL',
348
+ },
349
+ ssid: {
350
+ type: 'integer',
351
+ description: 'SSID',
352
+ default: 8,
353
+ oneOf: [
354
+ {
355
+ const: 0,
356
+ title: '0: Primary station',
357
+ },
358
+ {
359
+ const: 1,
360
+ title: '1: Generic additional station',
361
+ },
362
+ {
363
+ const: 2,
364
+ title: '1: Generic additional station',
365
+ },
366
+ {
367
+ const: 3,
368
+ title: '3: Generic additional station',
369
+ },
370
+ {
371
+ const: 4,
372
+ title: '4: Generic additional station',
373
+ },
374
+ {
375
+ const: 5,
376
+ title: '5: Other network (D-Star, 3G)',
377
+ },
378
+ {
379
+ const: 6,
380
+ title: '6: Satellite',
381
+ },
382
+ {
383
+ const: 7,
384
+ title: '7: Handheld radion',
385
+ },
386
+ {
387
+ const: 8,
388
+ title: '8: Boat / ship',
389
+ },
390
+ {
391
+ const: 9,
392
+ title: '9: Mobile station',
393
+ },
394
+ {
395
+ const: 10,
396
+ title: '10: APRS-IS (no radio)',
397
+ },
398
+ {
399
+ const: 11,
400
+ title: '11: Balloon, aircraft, spacecraft',
401
+ },
402
+ {
403
+ const: 12,
404
+ title: '12: APRStt, DTMF, ... (one-way)',
405
+ },
406
+ {
407
+ const: 13,
408
+ title: '13: Weather station',
409
+ },
410
+ {
411
+ const: 14,
412
+ title: '14: Freight vehicle',
413
+ },
414
+ {
415
+ const: 15,
416
+ title: '15: Generic additional station',
417
+ },
418
+ ],
419
+ },
420
+ symbol: {
421
+ type: 'string',
422
+ description: 'APRS symbol',
423
+ default: '/Y',
424
+ oneOf: [
425
+ {
426
+ const: '/Y',
427
+ title: '/Y: Yacht (sailboat)',
428
+ },
429
+ {
430
+ const: '/s',
431
+ title: '/s: Ship (power boat)',
432
+ },
433
+ {
434
+ const: '/C',
435
+ title: '/C: Canoe',
436
+ },
437
+ {
438
+ const: '\\C',
439
+ title: '\\C: Coastguard',
440
+ },
441
+ {
442
+ const: '\\N',
443
+ title: '\\N: Navigation Buoy',
444
+ },
445
+ {
446
+ const: '/i',
447
+ title: '/i: Island',
448
+ },
449
+ ],
450
+ },
451
+ note: {
452
+ type: 'string',
453
+ description: 'Personal note',
454
+ default: 'https://signalk.org',
455
+ },
456
+ interval: {
457
+ type: 'integer',
458
+ description: 'Beacon transmission interval (in minutes)',
459
+ default: 15,
460
+ },
461
+ },
462
+ },
463
+ connections: {
464
+ type: 'array',
465
+ title: 'KISS-TNC connections',
466
+ minItems: 0,
467
+ items: {
468
+ type: 'object',
469
+ required: ['host', 'port'],
470
+ properties: {
471
+ description: {
472
+ type: 'string',
473
+ description: 'TNC description',
474
+ },
475
+ host: {
476
+ type: 'string',
477
+ description: 'TNC host address',
478
+ },
479
+ port: {
480
+ type: 'integer',
481
+ description: 'TNC host port',
482
+ default: 8001,
483
+ },
484
+ enabled: {
485
+ type: 'boolean',
486
+ description: 'Enable TNC connection',
487
+ default: true,
488
+ },
489
+ tx: {
490
+ type: 'boolean',
491
+ description: 'Transmit beacon to this TNC',
492
+ default: false,
493
+ },
494
+ },
495
+ },
496
+ },
497
+ },
498
+ };
499
+
500
+ return plugin;
501
+ };