signalk-aiscast 0.0.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/README.md +44 -0
- package/dist/downlink.d.ts +47 -0
- package/dist/downlink.js +187 -0
- package/dist/downlink.js.map +1 -0
- package/dist/identity.d.ts +15 -0
- package/dist/identity.js +51 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +263 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +33 -0
- package/dist/link.js +134 -0
- package/dist/link.js.map +1 -0
- package/dist/n2k.d.ts +10 -0
- package/dist/n2k.js +112 -0
- package/dist/n2k.js.map +1 -0
- package/dist/nmea.d.ts +6 -0
- package/dist/nmea.js +33 -0
- package/dist/nmea.js.map +1 -0
- package/dist/uplink.d.ts +44 -0
- package/dist/uplink.js +257 -0
- package/dist/uplink.js.map +1 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# signalk-aiscast
|
|
2
|
+
|
|
3
|
+
Signal K plugin for [aiscast](https://github.com/openwatersio/aiscast), the open AIS network from [Open Waters](https://openwaters.io). It does two things over one connection:
|
|
4
|
+
|
|
5
|
+
- **Share**: every AIS sentence your receiver hears (`!AIVDM` on NMEA 0183, or NMEA 2000 AIS PGNs re-encoded as sentences) is sent to aiscast as it arrives, so the places only boats can hear get coverage. Your own transponder's position (`!AIVDO`) is shared too; it has its own switch if you would rather share only what you hear from others.
|
|
6
|
+
- **Receive**: when the boat hears no AIS of its own (no receiver, receiver off, server running ashore), the plugin subscribes to aiscast around your position and injects the traffic as Signal K targets with `$source` `signalk-aiscast.net`, so Freeboard and friends show them. `Always` mode also fills in beyond VHF range; locally heard targets win.
|
|
7
|
+
|
|
8
|
+
No account. On first start the plugin generates an Ed25519 keypair in its data directory and requests its own access token from aiscast (sent as an `Authorization: Bearer` header); receptions are credited to that key. Paste an operator-issued token into the config to publish as a named station with higher limits.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Signal K App Store → `signalk-aiscast`, or `npm install signalk-aiscast` in `~/.signalk`. Enable it in Plugin Config.
|
|
13
|
+
|
|
14
|
+
## Settings
|
|
15
|
+
|
|
16
|
+
| Setting | Default | Meaning |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| Share → Other vessels | on | publish AIS heard by the receiver (NMEA 0183 `!AIVDM`, NMEA 2000 AIS) |
|
|
19
|
+
| Share → Own ship | on | publish `!AIVDO`; your position becomes public open data on aiscast (it is already broadcast on VHF) |
|
|
20
|
+
| Receive → Show traffic from aiscast | auto | `Off`, `Auto` (only while nothing is heard locally for 90 s), `Always` (also beyond local VHF range; local reception wins per target) |
|
|
21
|
+
| Receive → Radius | 50 nm | subscription box around the vessel (5–200) |
|
|
22
|
+
| Advanced → Server | `https://ais.openwaters.io` | aiscast base URL |
|
|
23
|
+
| Advanced → Access token | empty | optional operator-issued token; empty = self-minted personal token |
|
|
24
|
+
|
|
25
|
+
## Behaviour worth knowing
|
|
26
|
+
|
|
27
|
+
- Sentences go out as received, each stamped with a NMEA TAG block carrying the receive time. Anything not acknowledged, or heard while offline, waits in `<data dir>/queue/` (cap 100 MB) and replays oldest-first on reconnect; aiscast archives replayed sentences older than a minute without putting them on the live map.
|
|
28
|
+
- Reconnects with jittered backoff (5 s → 5 min; 30 min after a refusal) and a 60 s silence watchdog. Status line in Plugin Config shows the key prefix, send rate, queue depth, targets, and link state.
|
|
29
|
+
- Injected targets come through the server's own AIS parser, so they are shaped exactly like VHF-received ones; the server's *Maximum age of inactive vessels* setting expires them. Your own vessel and echoes of your own receptions are never injected, and payloads received from aiscast are never published back.
|
|
30
|
+
- NMEA 2000 AIS needs nothing extra: PGNs 129038/129039/129041/129794/129809/129810 are re-encoded to `!AIVDM` (own ship to `!AIVDO`) and tagged `s:n2k`, since N2K carries decoded fields rather than the VHF bits. `signalk-n2kais-to-nmea0183` is not required and not listened to.
|
|
31
|
+
|
|
32
|
+
## Data and licensing
|
|
33
|
+
|
|
34
|
+
What you share is published by aiscast under its open data terms (see the [aiscast README](https://github.com/openwatersio/aiscast#readme)). The plugin never shares anything other than AIS sentences and, if you enable it, your own AIS position. Removal requests and the privacy policy are on the aiscast site.
|
|
35
|
+
|
|
36
|
+
## Development
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npm install
|
|
40
|
+
npm test # vitest against a fake aiscast server
|
|
41
|
+
npm run build # dist/
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Released to npm by the repo's `release.yml` when a GitHub release tagged `signalk-plugin-v*` is created.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ServerAPI } from "@signalk/server-api";
|
|
2
|
+
import type { Link } from "./link.js";
|
|
3
|
+
export type ReceiveMode = "off" | "auto" | "always";
|
|
4
|
+
export interface DownlinkOptions {
|
|
5
|
+
mode: ReceiveMode;
|
|
6
|
+
radiusNm: number;
|
|
7
|
+
source: string;
|
|
8
|
+
selfSource: string | null;
|
|
9
|
+
onReceived?: (sentence: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface DownlinkStats {
|
|
12
|
+
targets: number;
|
|
13
|
+
events: number;
|
|
14
|
+
subscribed: boolean;
|
|
15
|
+
}
|
|
16
|
+
interface Box {
|
|
17
|
+
lat: number;
|
|
18
|
+
lon: number;
|
|
19
|
+
radiusNm: number;
|
|
20
|
+
}
|
|
21
|
+
export declare class Downlink {
|
|
22
|
+
private readonly app;
|
|
23
|
+
private readonly link;
|
|
24
|
+
private opts;
|
|
25
|
+
private readonly log;
|
|
26
|
+
private parser;
|
|
27
|
+
private lastLocal;
|
|
28
|
+
private box;
|
|
29
|
+
private subscribed;
|
|
30
|
+
private timer;
|
|
31
|
+
private targets;
|
|
32
|
+
private selfMmsi;
|
|
33
|
+
stats: DownlinkStats;
|
|
34
|
+
constructor(app: ServerAPI, link: Link, opts: DownlinkOptions, log?: (msg: string) => void);
|
|
35
|
+
start(): void;
|
|
36
|
+
stop(): void;
|
|
37
|
+
localHeard(now?: number): void;
|
|
38
|
+
private wanted;
|
|
39
|
+
private ownPosition;
|
|
40
|
+
tick(now?: number): void;
|
|
41
|
+
private onFrame;
|
|
42
|
+
private vhfIsFresh;
|
|
43
|
+
private pruneTargets;
|
|
44
|
+
}
|
|
45
|
+
export declare function bbox(b: Box): [number, number, number, number];
|
|
46
|
+
export declare function distanceNm(lat1: number, lon1: number, lat2: number, lon2: number): number;
|
|
47
|
+
export {};
|
package/dist/downlink.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { Parser } from "@signalk/nmea0183-signalk";
|
|
2
|
+
import { stripTag } from "./nmea.js";
|
|
3
|
+
const LOCAL_QUIET = 90_000; // auto: no local AIS for this long → subscribe
|
|
4
|
+
const RESUBSCRIBE_FRACTION = 0.25; // re-send the box after moving this fraction of the radius
|
|
5
|
+
const VHF_WINS = 60_000; // always: leave a target alone when another source updated it this recently
|
|
6
|
+
const TARGET_TTL = 10 * 60_000;
|
|
7
|
+
// Subscribes to aiscast around the boat and injects the events as Signal K deltas through the server's own
|
|
8
|
+
// AIS parser, so injected targets are indistinguishable in shape from VHF-received ones except by $source.
|
|
9
|
+
export class Downlink {
|
|
10
|
+
app;
|
|
11
|
+
link;
|
|
12
|
+
opts;
|
|
13
|
+
log;
|
|
14
|
+
parser = new Parser();
|
|
15
|
+
lastLocal = 0;
|
|
16
|
+
box = null;
|
|
17
|
+
subscribed = false;
|
|
18
|
+
timer = null;
|
|
19
|
+
targets = new Map();
|
|
20
|
+
selfMmsi;
|
|
21
|
+
stats = { targets: 0, events: 0, subscribed: false };
|
|
22
|
+
constructor(app, link, opts, log = () => { }) {
|
|
23
|
+
this.app = app;
|
|
24
|
+
this.link = link;
|
|
25
|
+
this.opts = opts;
|
|
26
|
+
this.log = log;
|
|
27
|
+
const mmsi = app.getSelfPath("mmsi");
|
|
28
|
+
this.selfMmsi = mmsi == null ? null : String(mmsi);
|
|
29
|
+
}
|
|
30
|
+
start() {
|
|
31
|
+
this.link.on("open", () => {
|
|
32
|
+
this.subscribed = false;
|
|
33
|
+
this.tick();
|
|
34
|
+
});
|
|
35
|
+
this.link.on("close", () => {
|
|
36
|
+
this.subscribed = false;
|
|
37
|
+
this.stats.subscribed = false;
|
|
38
|
+
});
|
|
39
|
+
this.link.on("error", (message) => {
|
|
40
|
+
if (/bbox/.test(message))
|
|
41
|
+
this.subscribed = this.stats.subscribed = false; // aiscast kept the old subscription (none)
|
|
42
|
+
});
|
|
43
|
+
this.link.on("frame", (f) => this.onFrame(f));
|
|
44
|
+
this.timer = setInterval(() => this.tick(), 10_000);
|
|
45
|
+
this.tick();
|
|
46
|
+
}
|
|
47
|
+
stop() {
|
|
48
|
+
if (this.timer)
|
|
49
|
+
clearInterval(this.timer);
|
|
50
|
+
this.timer = null;
|
|
51
|
+
}
|
|
52
|
+
// The boat's own receiver heard AIS; in `auto` mode that silences the network feed.
|
|
53
|
+
localHeard(now = Date.now()) {
|
|
54
|
+
this.lastLocal = now;
|
|
55
|
+
if (this.subscribed && this.opts.mode === "auto")
|
|
56
|
+
this.tick(now);
|
|
57
|
+
}
|
|
58
|
+
wanted(now) {
|
|
59
|
+
switch (this.opts.mode) {
|
|
60
|
+
case "off":
|
|
61
|
+
return false;
|
|
62
|
+
case "always":
|
|
63
|
+
return true;
|
|
64
|
+
case "auto":
|
|
65
|
+
return now - this.lastLocal > LOCAL_QUIET;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
ownPosition() {
|
|
69
|
+
const p = this.app.getSelfPath("navigation.position");
|
|
70
|
+
const v = p && "value" in p ? p.value : p;
|
|
71
|
+
if (!v || typeof v.latitude !== "number" || typeof v.longitude !== "number")
|
|
72
|
+
return null;
|
|
73
|
+
if (Math.abs(v.latitude) < 1e-6 && Math.abs(v.longitude) < 1e-6)
|
|
74
|
+
return null; // Null Island
|
|
75
|
+
return v;
|
|
76
|
+
}
|
|
77
|
+
tick(now = Date.now()) {
|
|
78
|
+
if (!this.link.open)
|
|
79
|
+
return;
|
|
80
|
+
if (!this.wanted(now)) {
|
|
81
|
+
if (this.subscribed && this.link.send({ type: "unsubscribe" })) {
|
|
82
|
+
this.subscribed = false;
|
|
83
|
+
this.stats.subscribed = false;
|
|
84
|
+
this.log("unsubscribed");
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const pos = this.ownPosition();
|
|
89
|
+
if (!pos)
|
|
90
|
+
return; // never subscribe without a position: an empty bbox is the whole world
|
|
91
|
+
const moved = this.box
|
|
92
|
+
? distanceNm(pos.latitude, pos.longitude, this.box.lat, this.box.lon) > this.box.radiusNm * RESUBSCRIBE_FRACTION ||
|
|
93
|
+
this.box.radiusNm !== this.opts.radiusNm
|
|
94
|
+
: true;
|
|
95
|
+
if (this.subscribed && !moved)
|
|
96
|
+
return;
|
|
97
|
+
const box = { lat: pos.latitude, lon: pos.longitude, radiusNm: this.opts.radiusNm };
|
|
98
|
+
if (this.link.send({ type: "subscribe", bbox: [bbox(box)] })) {
|
|
99
|
+
this.box = box;
|
|
100
|
+
this.subscribed = true;
|
|
101
|
+
this.stats.subscribed = true;
|
|
102
|
+
this.log(`subscribed ${box.radiusNm} nm around ${box.lat.toFixed(3)},${box.lon.toFixed(3)}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
onFrame(f) {
|
|
106
|
+
if (f.type !== "event")
|
|
107
|
+
return;
|
|
108
|
+
const ev = f;
|
|
109
|
+
if (!Array.isArray(ev.nmea) || ev.nmea.length === 0)
|
|
110
|
+
return;
|
|
111
|
+
for (const s of ev.nmea)
|
|
112
|
+
this.opts.onReceived?.(s);
|
|
113
|
+
if (this.opts.selfSource && ev.source === this.opts.selfSource)
|
|
114
|
+
return;
|
|
115
|
+
if (!ev.mmsi || String(ev.mmsi) === this.selfMmsi)
|
|
116
|
+
return;
|
|
117
|
+
if (POSITION_TYPES.has(ev.msg_type ?? "") && (ev.lat == null || ev.lon == null))
|
|
118
|
+
return; // aiscast rejected the position
|
|
119
|
+
let delta = null;
|
|
120
|
+
for (const s of ev.nmea) {
|
|
121
|
+
try {
|
|
122
|
+
delta = this.parser.parse(stripTag(s)) ?? delta;
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
this.log(`parse failed for ${s}: ${err.message}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!delta?.context || !delta.updates?.length)
|
|
130
|
+
return;
|
|
131
|
+
if (delta.context === this.app.selfContext)
|
|
132
|
+
return;
|
|
133
|
+
if (this.opts.mode === "always" && this.vhfIsFresh(delta.context, Date.now()))
|
|
134
|
+
return;
|
|
135
|
+
for (const u of delta.updates) {
|
|
136
|
+
delete u.source;
|
|
137
|
+
u.$source = this.opts.source;
|
|
138
|
+
if (ev.time)
|
|
139
|
+
u.timestamp = ev.time;
|
|
140
|
+
}
|
|
141
|
+
this.app.handleMessage(this.opts.source, delta);
|
|
142
|
+
this.stats.events++;
|
|
143
|
+
this.targets.set(delta.context, Date.now());
|
|
144
|
+
if (this.stats.events % 100 === 0)
|
|
145
|
+
this.pruneTargets();
|
|
146
|
+
this.stats.targets = this.targets.size;
|
|
147
|
+
}
|
|
148
|
+
// Another source (the boat's receiver) updated this target recently: do not overwrite it.
|
|
149
|
+
vhfIsFresh(context, now) {
|
|
150
|
+
const pos = this.app.getPath(`${context}.navigation.position`);
|
|
151
|
+
if (!pos?.$source || pos.$source === this.opts.source || !pos.timestamp)
|
|
152
|
+
return false;
|
|
153
|
+
return now - Date.parse(pos.timestamp) < VHF_WINS;
|
|
154
|
+
}
|
|
155
|
+
pruneTargets() {
|
|
156
|
+
const cutoff = Date.now() - TARGET_TTL;
|
|
157
|
+
for (const [k, t] of this.targets)
|
|
158
|
+
if (t < cutoff)
|
|
159
|
+
this.targets.delete(k);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const POSITION_TYPES = new Set([
|
|
163
|
+
"PositionReport",
|
|
164
|
+
"StandardClassBPositionReport",
|
|
165
|
+
"ExtendedClassBPositionReport",
|
|
166
|
+
"LongRangeAisBroadcastMessage",
|
|
167
|
+
"StandardSearchAndRescueAircraftReport",
|
|
168
|
+
]);
|
|
169
|
+
// [minLat, minLon, maxLat, maxLon] around a centre. ponytail: clamped at the poles and antimeridian rather than split.
|
|
170
|
+
export function bbox(b) {
|
|
171
|
+
const dLat = b.radiusNm / 60;
|
|
172
|
+
const dLon = b.radiusNm / (60 * Math.max(0.05, Math.cos((b.lat * Math.PI) / 180)));
|
|
173
|
+
return [
|
|
174
|
+
Math.max(-90, b.lat - dLat),
|
|
175
|
+
Math.max(-180, b.lon - dLon),
|
|
176
|
+
Math.min(90, b.lat + dLat),
|
|
177
|
+
Math.min(180, b.lon + dLon),
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
export function distanceNm(lat1, lon1, lat2, lon2) {
|
|
181
|
+
const toRad = Math.PI / 180;
|
|
182
|
+
const dLat = (lat2 - lat1) * toRad;
|
|
183
|
+
const dLon = (lon2 - lon1) * toRad;
|
|
184
|
+
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2;
|
|
185
|
+
return 2 * 3440.065 * Math.asin(Math.sqrt(a));
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=downlink.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"downlink.js","sourceRoot":"","sources":["../src/downlink.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type JsonWebKey } from "node:crypto";
|
|
2
|
+
export interface Identity {
|
|
3
|
+
pubkey: string;
|
|
4
|
+
jwk: JsonWebKey;
|
|
5
|
+
}
|
|
6
|
+
export declare function loadIdentity(dir: string): Promise<Identity>;
|
|
7
|
+
export interface Token {
|
|
8
|
+
token: string;
|
|
9
|
+
exp: number;
|
|
10
|
+
pubkey: string;
|
|
11
|
+
server: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function loadToken(dir: string, server: string, pubkey: string, now?: number): Promise<Token>;
|
|
14
|
+
export declare function forgetToken(dir: string): Promise<void>;
|
|
15
|
+
export declare function mintToken(server: string, pubkey: string): Promise<Token>;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
2
|
+
import { readFile, writeFile, unlink } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
// The keypair is the boat's identity with aiscast. It is generated once and never leaves the data dir.
|
|
5
|
+
export async function loadIdentity(dir) {
|
|
6
|
+
const file = join(dir, "identity.json");
|
|
7
|
+
try {
|
|
8
|
+
const jwk = JSON.parse(await readFile(file, "utf8"));
|
|
9
|
+
if (jwk.kty === "OKP" && jwk.crv === "Ed25519" && jwk.x && jwk.d)
|
|
10
|
+
return { pubkey: jwk.x, jwk };
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// first start, or unreadable: generate below
|
|
14
|
+
}
|
|
15
|
+
const { privateKey } = generateKeyPairSync("ed25519");
|
|
16
|
+
const jwk = privateKey.export({ format: "jwk" });
|
|
17
|
+
await writeFile(file, JSON.stringify(jwk), { mode: 0o600 });
|
|
18
|
+
return { pubkey: jwk.x, jwk };
|
|
19
|
+
}
|
|
20
|
+
const RENEW_BEFORE = 3 * 24 * 3600; // seconds before expiry at which a new token is requested
|
|
21
|
+
// A personal token from POST /v1/keys, cached in the data dir and renewed a few days before it expires.
|
|
22
|
+
export async function loadToken(dir, server, pubkey, now = Date.now()) {
|
|
23
|
+
const file = join(dir, "token.json");
|
|
24
|
+
try {
|
|
25
|
+
const t = JSON.parse(await readFile(file, "utf8"));
|
|
26
|
+
if (t.pubkey === pubkey && t.server === server && t.exp - now / 1000 > RENEW_BEFORE)
|
|
27
|
+
return t;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// none cached
|
|
31
|
+
}
|
|
32
|
+
const t = await mintToken(server, pubkey);
|
|
33
|
+
await writeFile(file, JSON.stringify(t), { mode: 0o600 });
|
|
34
|
+
return t;
|
|
35
|
+
}
|
|
36
|
+
export async function forgetToken(dir) {
|
|
37
|
+
await unlink(join(dir, "token.json")).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
export async function mintToken(server, pubkey) {
|
|
40
|
+
const res = await fetch(new URL("/v1/keys", server), {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "content-type": "application/json" },
|
|
43
|
+
body: JSON.stringify({ pubkey }),
|
|
44
|
+
signal: AbortSignal.timeout(15_000),
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok)
|
|
47
|
+
throw new Error(`POST /v1/keys: ${res.status} ${(await res.text()).trim()}`);
|
|
48
|
+
const body = (await res.json());
|
|
49
|
+
return { token: body.token, exp: body.claims.exp, pubkey, server };
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.js","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Plugin, ServerAPI } from "@signalk/server-api";
|
|
2
|
+
import { type ReceiveMode } from "./downlink.js";
|
|
3
|
+
export declare const PLUGIN_ID = "signalk-aiscast";
|
|
4
|
+
export interface Config {
|
|
5
|
+
share?: {
|
|
6
|
+
targets?: boolean;
|
|
7
|
+
ownShip?: boolean;
|
|
8
|
+
};
|
|
9
|
+
receive?: {
|
|
10
|
+
mode?: ReceiveMode;
|
|
11
|
+
radiusNm?: number;
|
|
12
|
+
};
|
|
13
|
+
advanced?: {
|
|
14
|
+
server?: string;
|
|
15
|
+
token?: string;
|
|
16
|
+
};
|
|
17
|
+
server?: string;
|
|
18
|
+
token?: string;
|
|
19
|
+
}
|
|
20
|
+
export default function (app: ServerAPI): Plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { Downlink } from "./downlink.js";
|
|
2
|
+
import { forgetToken, loadIdentity, loadToken, } from "./identity.js";
|
|
3
|
+
import { Link } from "./link.js";
|
|
4
|
+
import { n2kToSentence } from "./n2k.js";
|
|
5
|
+
import { isAis, isOwnShip } from "./nmea.js";
|
|
6
|
+
import { Uplink } from "./uplink.js";
|
|
7
|
+
export const PLUGIN_ID = "signalk-aiscast";
|
|
8
|
+
const DEFAULT_SERVER = "https://ais.openwaters.io";
|
|
9
|
+
const STATUS_EVERY = 5_000;
|
|
10
|
+
const TOKEN_CHECK_EVERY = 6 * 3600_000;
|
|
11
|
+
export default function (app) {
|
|
12
|
+
const events = app;
|
|
13
|
+
let generation = 0; // bumped by stop(); a start() still in its awaits checks it and gives up
|
|
14
|
+
let teardown = null;
|
|
15
|
+
const plugin = {
|
|
16
|
+
id: PLUGIN_ID,
|
|
17
|
+
name: "AIScast",
|
|
18
|
+
description: "Show nearby traffic without an AIS receiver and share your AIS data with the world.",
|
|
19
|
+
schema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
share: {
|
|
23
|
+
type: "object",
|
|
24
|
+
title: "Share",
|
|
25
|
+
properties: {
|
|
26
|
+
targets: {
|
|
27
|
+
type: "boolean",
|
|
28
|
+
title: "Other vessels (NMEA 0183 !AIVDM and NMEA 2000 AIS)",
|
|
29
|
+
default: true,
|
|
30
|
+
},
|
|
31
|
+
ownShip: {
|
|
32
|
+
type: "boolean",
|
|
33
|
+
title: "Own ship (!AIVDO)",
|
|
34
|
+
default: true,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
receive: {
|
|
39
|
+
type: "object",
|
|
40
|
+
title: "Receive",
|
|
41
|
+
properties: {
|
|
42
|
+
mode: {
|
|
43
|
+
type: "string",
|
|
44
|
+
title: "Show traffic from aiscast",
|
|
45
|
+
enum: ["off", "auto", "always"],
|
|
46
|
+
enumNames: [
|
|
47
|
+
"Off",
|
|
48
|
+
"Auto (when no local receiver available)",
|
|
49
|
+
"Always (fills in beyond local VHF range)",
|
|
50
|
+
],
|
|
51
|
+
default: "auto",
|
|
52
|
+
},
|
|
53
|
+
radiusNm: {
|
|
54
|
+
type: "number",
|
|
55
|
+
title: "Radius around the vessel (nautical miles)",
|
|
56
|
+
default: 50,
|
|
57
|
+
minimum: 5,
|
|
58
|
+
maximum: 200,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
advanced: {
|
|
63
|
+
type: "object",
|
|
64
|
+
title: "Advanced",
|
|
65
|
+
properties: {
|
|
66
|
+
server: {
|
|
67
|
+
type: "string",
|
|
68
|
+
title: "Server",
|
|
69
|
+
default: DEFAULT_SERVER,
|
|
70
|
+
},
|
|
71
|
+
token: {
|
|
72
|
+
type: "string",
|
|
73
|
+
title: "Access token (optional)",
|
|
74
|
+
description: "Leave empty: the plugin creates a keypair on first start and requests its own personal token. Paste a token from the aiscast operator to publish with a named station and higher limits.",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
uiSchema: {
|
|
81
|
+
"ui:order": ["share", "receive", "advanced"],
|
|
82
|
+
advanced: { token: { "ui:widget": "password" } },
|
|
83
|
+
},
|
|
84
|
+
start(config) {
|
|
85
|
+
const gen = ++generation;
|
|
86
|
+
startAsync(config, gen).catch((err) => {
|
|
87
|
+
app.setPluginError(`start failed: ${err.message}`);
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
async stop() {
|
|
91
|
+
generation++;
|
|
92
|
+
const t = teardown;
|
|
93
|
+
teardown = null;
|
|
94
|
+
await t?.();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
async function startAsync(config, gen) {
|
|
98
|
+
const server = (config.advanced?.server ||
|
|
99
|
+
config.server ||
|
|
100
|
+
DEFAULT_SERVER).replace(/\/+$/, "");
|
|
101
|
+
const configuredToken = config.advanced?.token || config.token;
|
|
102
|
+
const wsBase = server.replace(/^http/, "ws");
|
|
103
|
+
const shareTargets = config.share?.targets ?? true;
|
|
104
|
+
const shareOwn = config.share?.ownShip ?? true;
|
|
105
|
+
const mode = config.receive?.mode ?? "auto";
|
|
106
|
+
const radiusNm = Math.min(200, Math.max(5, config.receive?.radiusNm ?? 50));
|
|
107
|
+
const dir = app.getDataDirPath();
|
|
108
|
+
const log = (msg) => app.debug(msg);
|
|
109
|
+
const live = () => gen === generation;
|
|
110
|
+
const identity = await loadIdentity(dir);
|
|
111
|
+
if (!live())
|
|
112
|
+
return;
|
|
113
|
+
let token = null;
|
|
114
|
+
let publishRefused = false;
|
|
115
|
+
const selfSource = `v1:ed25519:${identity.pubkey}`;
|
|
116
|
+
const refreshToken = async () => {
|
|
117
|
+
if (configuredToken) {
|
|
118
|
+
token = {
|
|
119
|
+
token: configuredToken,
|
|
120
|
+
exp: Infinity,
|
|
121
|
+
pubkey: identity.pubkey,
|
|
122
|
+
server,
|
|
123
|
+
};
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
token = await loadToken(dir, server, identity.pubkey);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
token = null;
|
|
131
|
+
app.setPluginError(`No token: ${err.message}. Receiving only; nothing is shared.`);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
await refreshToken();
|
|
135
|
+
if (!live())
|
|
136
|
+
return;
|
|
137
|
+
// Token in a header, not the URL, so it stays out of proxy and server logs.
|
|
138
|
+
const l = new Link(() => `${wsBase}/v1/stream`, () => token ? { Authorization: `Bearer ${token.token}` } : {}, log);
|
|
139
|
+
const up = new Uplink(l, dir, log);
|
|
140
|
+
const down = new Downlink(app, l, {
|
|
141
|
+
mode,
|
|
142
|
+
radiusNm,
|
|
143
|
+
source: `${PLUGIN_ID}.net`,
|
|
144
|
+
selfSource,
|
|
145
|
+
onReceived: (s) => up.noteReceived(s),
|
|
146
|
+
}, log);
|
|
147
|
+
const canShare = () => token !== null && !publishRefused && (shareTargets || shareOwn);
|
|
148
|
+
up.enabled = canShare();
|
|
149
|
+
l.on("open", () => {
|
|
150
|
+
publishRefused = false;
|
|
151
|
+
up.enabled = canShare();
|
|
152
|
+
});
|
|
153
|
+
l.on("error", (message) => {
|
|
154
|
+
if (/token/i.test(message) && !/publish/.test(message)) {
|
|
155
|
+
app.setPluginError(`aiscast refused the token: ${message}`);
|
|
156
|
+
if (!configuredToken) {
|
|
157
|
+
const refused = token?.token;
|
|
158
|
+
// A fresh token reconnects at once; the same token again waits out the refusal backoff.
|
|
159
|
+
forgetToken(dir)
|
|
160
|
+
.then(refreshToken)
|
|
161
|
+
.then(() => {
|
|
162
|
+
up.enabled = canShare();
|
|
163
|
+
if (token && token.token !== refused)
|
|
164
|
+
l.reconnect();
|
|
165
|
+
})
|
|
166
|
+
.catch((err) => log(`token refresh: ${err.message}`));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (/publish/.test(message)) {
|
|
170
|
+
publishRefused = true;
|
|
171
|
+
up.enabled = false;
|
|
172
|
+
app.setPluginError(`aiscast refused publishing: ${message}`);
|
|
173
|
+
}
|
|
174
|
+
else if (/bbox/.test(message)) {
|
|
175
|
+
app.setPluginError(`aiscast refused the subscription: ${message} (reduce the radius)`);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
log(`aiscast: ${message}`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
await up.start();
|
|
182
|
+
if (!live()) {
|
|
183
|
+
await up.stop();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
down.start();
|
|
187
|
+
l.start();
|
|
188
|
+
const onSentence = (sentence, source) => {
|
|
189
|
+
if (typeof sentence !== "string" || !isAis(sentence))
|
|
190
|
+
return;
|
|
191
|
+
const own = isOwnShip(sentence);
|
|
192
|
+
if (!own)
|
|
193
|
+
down.localHeard();
|
|
194
|
+
if (own ? shareOwn : shareTargets)
|
|
195
|
+
up.hear(sentence, Date.now(), source);
|
|
196
|
+
};
|
|
197
|
+
const onNmea = (sentence) => onSentence(sentence);
|
|
198
|
+
const onN2k = (msg) => {
|
|
199
|
+
const m = msg;
|
|
200
|
+
if (!m || typeof m.pgn !== "number")
|
|
201
|
+
return;
|
|
202
|
+
try {
|
|
203
|
+
onSentence(n2kToSentence(app, m), "n2k");
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
log(`N2K PGN ${m.pgn}: ${err.message}`);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
events.on("nmea0183", onNmea);
|
|
210
|
+
events.on("N2KAnalyzerOut", onN2k);
|
|
211
|
+
let lastSent = 0;
|
|
212
|
+
let lastAt = Date.now();
|
|
213
|
+
const timers = [
|
|
214
|
+
setInterval(() => {
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
const perMin = Math.round(((up.stats.sent - lastSent) * 60_000) / (now - lastAt));
|
|
217
|
+
lastSent = up.stats.sent;
|
|
218
|
+
lastAt = now;
|
|
219
|
+
const key = identity.pubkey.slice(0, 8);
|
|
220
|
+
const upText = up.enabled
|
|
221
|
+
? `↑ ${perMin}/min (queue ${up.stats.queued})`
|
|
222
|
+
: "↑ off";
|
|
223
|
+
const downText = mode === "off"
|
|
224
|
+
? "↓ off"
|
|
225
|
+
: down.stats.subscribed
|
|
226
|
+
? `↓ ${down.stats.targets} targets`
|
|
227
|
+
: "↓ idle";
|
|
228
|
+
const linkText = l.open
|
|
229
|
+
? "connected"
|
|
230
|
+
: l.lastRefusal
|
|
231
|
+
? `refused: ${l.lastRefusal}`
|
|
232
|
+
: "reconnecting";
|
|
233
|
+
app.setPluginStatus(`key ${key}… ${upText} ${downText} ${linkText}`);
|
|
234
|
+
}, STATUS_EVERY),
|
|
235
|
+
setInterval(() => {
|
|
236
|
+
const before = token?.token;
|
|
237
|
+
refreshToken()
|
|
238
|
+
.then(() => {
|
|
239
|
+
up.enabled = canShare();
|
|
240
|
+
if (token?.token !== before)
|
|
241
|
+
l.reconnect();
|
|
242
|
+
})
|
|
243
|
+
.catch((err) => log(`token refresh: ${err.message}`));
|
|
244
|
+
}, TOKEN_CHECK_EVERY),
|
|
245
|
+
];
|
|
246
|
+
teardown = async () => {
|
|
247
|
+
for (const t of timers)
|
|
248
|
+
clearInterval(t);
|
|
249
|
+
events.removeListener("nmea0183", onNmea);
|
|
250
|
+
events.removeListener("N2KAnalyzerOut", onN2k);
|
|
251
|
+
down.stop();
|
|
252
|
+
await up.stop();
|
|
253
|
+
l.stop();
|
|
254
|
+
};
|
|
255
|
+
if (!live()) {
|
|
256
|
+
const t = teardown;
|
|
257
|
+
teardown = null;
|
|
258
|
+
await t();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return plugin;
|
|
262
|
+
}
|
|
263
|
+
//# sourceMappingURL=index.js.map
|