@aurora-blue/signalk-connector 0.2.0-dev.22 → 0.2.0-dev.26

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 CHANGED
@@ -51,7 +51,9 @@ Select Aurora Blue ABOARD Connector Status under Signal K Webapps for the cross-
51
51
 
52
52
  The connector exchanges the pairing code once and clears it from the Signal K plugin configuration after successful pairing. The returned device token is stored only in the protected connector state file and is never written to logs. The pairing is bound to the configured target URL. Changing that URL requires a new pairing code and pauses collection and transfer until the replacement pairing succeeds. The configuration and status page warn that a successful replacement permanently discards telemetry buffered for the previous server. A failed pairing attempt retains the existing pairing and buffer.
53
53
 
54
- The default configuration sends up to 250 events per batch, waits up to five seconds to combine normal telemetry, samples selected own-vessel Signal K paths every second, samples selected AIS paths every five seconds, and enables the preliminary underway change-or-heartbeat profile. AIS can be disabled independently. The `auto` storage profile selects a constrained, standard, or extended byte budget from filesystem capacity while preserving a free-space reserve. Its effective event limit is derived from measured journal bytes per event instead of a fixed 10,000-event default. Protocol 1.1 permits no more than 500 events per batch, and the server continues to accept exact protocol 1.0 backlog batches during upgrades.
54
+ The default configuration sends up to 250 events per batch, waits up to five seconds to combine normal telemetry, samples own-vessel Signal K paths every second, samples selected AIS paths every five seconds, and enables the preliminary underway change-or-heartbeat profile. Navigation, performance, steering observations, wind, depth, current, tide, outside weather, water and heave are captured independently of displayed fields. Additional scoped family paths preserve bounded structured source values, with a two-second minimum change interval and 60-second heartbeat under normal storage pressure. Ground- and water-referenced true wind remain separate. Deploy compatible Server acceptance before this Connector expansion; older Servers reject unsupported paths. See the [canonical acquisition decision](../aurora-blue-documentation/docs/decisions/ADR-0153-display-independent-navigation-capture.md). AIS can be disabled independently. The `auto` storage profile selects a constrained, standard, or extended byte budget from filesystem capacity while preserving a free-space reserve. Its effective event limit is derived from measured journal bytes per event instead of a fixed 10,000-event default. Protocol 1.1 permits no more than 500 events per batch, and the server continues to accept exact protocol 1.0 backlog batches during upgrades.
55
+
56
+ The connector also subscribes to scalar values below `electrical.switches.*`, including separate channels on multi-output devices. Switch paths use the instant subscription policy so a periodic retransmission of a cached Signal K value is not mistaken for fresh device evidence. The server may use selected `.state` leaves for conservative equipment-runtime estimates. A silent or unpowered device stops contributing runtime after a short bounded interval; accurate long-running counters require a qualified device-originated liveness source.
55
57
 
56
58
  The optional anchor alarm is an experimental development scaffold. When enabled, position is requested every 250 milliseconds and every received position reaches the safety engine before telemetry reduction. The alarm publishes a local Signal K notification and detects stale position input without requiring the Aurora Blue target. It must not yet be used as the sole safety device.
57
59
 
@@ -117,6 +119,16 @@ The public package name is `@aurora-blue/signalk-connector`. Once its first rele
117
119
 
118
120
  When migrating from the unscoped development package `aurora-blue-signalk-connector`, back up Signal K configuration and plugin data first. Stop Signal K and replace only the installed package; preserve the existing plugin configuration and data directory. Do not install both package names together: they deliberately share one runtime plugin ID. Restart and verify pairing, buffered records and status before resuming normal use.
119
121
 
122
+ The Signal K Webapps tile uses `signalk.appIcon` pointing to the bundled canonical `public/favicon.svg`: the white ABOARD sailing mark on the approved variant B gradient. It loads locally without an external image service.
123
+
120
124
  Release maintainers use the protected GitLab pipeline described in the central Connector publication document. Package preparation does not itself publish a release or guarantee App Store indexing.
121
125
 
122
126
  The authoritative architecture and protocol documentation is maintained in the separate `aurora-blue-documentation` repository.
127
+
128
+ ## Optional NoForeignLand publisher (development)
129
+
130
+ The connector can publish observed own-vessel tracks through an independent, bounded offline queue using its existing Signal K observations. Enable the optional NoForeignLand settings only after obtaining dedicated ABOARD application credentials and a boat key, disabling the original plugin and completing queue handover. Existing official-plugin configuration blocks startup to prevent duplicate publication. The publisher is disabled by default.
131
+
132
+ This development integration is not yet qualified for live replacement. Provider approval, actual API delivery, settings UI verification, backlog handover and onboard resource measurements remain pending. Arbitrary cron expressions, automatic import of the original queue and unlimited sent-track archives are not implemented. Ambiguous acknowledgements can cause a replay; exactly-once delivery is not claimed.
133
+
134
+ See the canonical [integration and handover documentation](../aurora-blue-documentation/docs/NOFOREIGNLAND_CONNECTOR.md) and ADR-0151. Never reuse the official plugin's embedded application key or include credentials in diagnostic output.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0-dev.22
1
+ 0.2.0-dev.26
@@ -1,7 +1,9 @@
1
1
  import type { AnchorAlarmConfig } from "./safety/anchor-alarm";
2
2
  import type { StorageProfile } from "./storage-budget";
3
3
  import type { OperatingProfile } from "./telemetry-reducer";
4
+ import type { NoForeignLandConfig } from "./noforeignland";
4
5
  export interface ConnectorConfig {
6
+ noforeignland?: NoForeignLandConfig;
5
7
  targetUrl: string;
6
8
  deviceName: string;
7
9
  pairingCode?: string;
@@ -32,6 +34,75 @@ export declare const pluginSchema: {
32
34
  readonly type: "object";
33
35
  readonly required: readonly ["targetUrl"];
34
36
  readonly properties: {
37
+ readonly noforeignland: {
38
+ readonly type: "object";
39
+ readonly title: "NoForeignLand publishing";
40
+ readonly description: "Optional direct track publication. Requires dedicated ABOARD application credentials. Disable the separate NoForeignLand plugin before enabling this publisher.";
41
+ readonly properties: {
42
+ readonly enabled: {
43
+ readonly type: "boolean";
44
+ readonly title: "Publish to NoForeignLand";
45
+ readonly default: false;
46
+ };
47
+ readonly applicationKey: {
48
+ readonly type: "string";
49
+ readonly title: "ABOARD application key";
50
+ };
51
+ readonly boatKey: {
52
+ readonly type: "string";
53
+ readonly title: "Boat API key";
54
+ };
55
+ readonly source: {
56
+ readonly type: "string";
57
+ readonly title: "Position source (empty selects automatically)";
58
+ };
59
+ readonly sampleSeconds: {
60
+ readonly type: "integer";
61
+ readonly title: "Minimum sample interval (seconds)";
62
+ readonly minimum: 10;
63
+ readonly maximum: 3600;
64
+ readonly default: 60;
65
+ };
66
+ readonly minimumMoveMeters: {
67
+ readonly type: "integer";
68
+ readonly title: "Minimum movement (meters)";
69
+ readonly minimum: 0;
70
+ readonly maximum: 10000;
71
+ readonly default: 80;
72
+ };
73
+ readonly minimumSpeedKnots: {
74
+ readonly type: "number";
75
+ readonly title: "Minimum speed (knots)";
76
+ readonly minimum: 0;
77
+ readonly maximum: 100;
78
+ readonly default: 1.5;
79
+ };
80
+ readonly maximumVelocity: {
81
+ readonly type: "integer";
82
+ readonly title: "Maximum plausible velocity (meters per second)";
83
+ readonly minimum: 1;
84
+ readonly maximum: 500;
85
+ readonly default: 50;
86
+ };
87
+ readonly sendWhileMoving: {
88
+ readonly type: "boolean";
89
+ readonly title: "Send while moving";
90
+ readonly default: true;
91
+ };
92
+ readonly keepalive: {
93
+ readonly type: "boolean";
94
+ readonly title: "Publish a fresh position every 24 hours";
95
+ readonly default: true;
96
+ };
97
+ readonly sendIntervalSeconds: {
98
+ readonly type: "integer";
99
+ readonly title: "Send interval (seconds)";
100
+ readonly minimum: 60;
101
+ readonly maximum: 86400;
102
+ readonly default: 600;
103
+ };
104
+ };
105
+ };
35
106
  readonly targetUrl: {
36
107
  readonly type: "string";
37
108
  readonly title: "Aurora Blue target URL";
@@ -181,6 +252,14 @@ export declare const pluginSchema: {
181
252
  };
182
253
  };
183
254
  export declare const pluginUiSchema: {
255
+ readonly noforeignland: {
256
+ readonly applicationKey: {
257
+ readonly "ui:widget": "password";
258
+ };
259
+ readonly boatKey: {
260
+ readonly "ui:widget": "password";
261
+ };
262
+ };
184
263
  readonly targetUrl: {
185
264
  readonly "ui:options": {
186
265
  readonly enableMarkdownInDescription: true;
@@ -104,7 +104,32 @@ const resolveConfig = (input) => {
104
104
  if (anchorHysteresisMeters >= anchorRadiusMeters) {
105
105
  throw new Error("Anchor hysteresis must be smaller than the anchor radius.");
106
106
  }
107
+ if (raw.noforeignland !== undefined &&
108
+ (raw.noforeignland === null ||
109
+ typeof raw.noforeignland !== "object" ||
110
+ Array.isArray(raw.noforeignland))) {
111
+ throw new Error("NoForeignLand settings must be an object.");
112
+ }
113
+ const nfl = (raw.noforeignland ?? {});
114
+ const nflEnabled = nfl.enabled === true;
115
+ const secret = (value) => typeof value === "string" ? value.trim() : "";
116
+ if (nflEnabled && (!secret(nfl.applicationKey) || !secret(nfl.boatKey))) {
117
+ throw new Error("NoForeignLand requires an ABOARD application key and a boat key.");
118
+ }
107
119
  return {
120
+ noforeignland: {
121
+ enabled: nflEnabled,
122
+ applicationKey: secret(nfl.applicationKey),
123
+ boatKey: secret(nfl.boatKey),
124
+ source: secret(nfl.source),
125
+ sampleSeconds: integer(nfl.sampleSeconds, 60, 10, 3600, "NoForeignLand sample interval"),
126
+ minimumMoveMeters: integer(nfl.minimumMoveMeters, 80, 0, 10000, "NoForeignLand minimum movement"),
127
+ minimumSpeedKnots: finiteNumber(nfl.minimumSpeedKnots, 0, 100, "NoForeignLand minimum speed") ?? 1.5,
128
+ maximumVelocity: integer(nfl.maximumVelocity, 50, 1, 500, "NoForeignLand maximum velocity"),
129
+ sendWhileMoving: nfl.sendWhileMoving !== false,
130
+ keepalive: nfl.keepalive !== false,
131
+ sendIntervalSeconds: integer(nfl.sendIntervalSeconds, 600, 60, 86400, "NoForeignLand send interval"),
132
+ },
108
133
  targetUrl: parsedUrl.toString().replace(/\/$/, ""),
109
134
  deviceName: deviceName || "Aurora Blue Signal K",
110
135
  pairingCode: typeof raw.pairingCode === "string" && raw.pairingCode.trim() !== ""
@@ -151,6 +176,69 @@ exports.pluginSchema = {
151
176
  type: "object",
152
177
  required: ["targetUrl"],
153
178
  properties: {
179
+ noforeignland: {
180
+ type: "object",
181
+ title: "NoForeignLand publishing",
182
+ description: "Optional direct track publication. Requires dedicated ABOARD application credentials. Disable the separate NoForeignLand plugin before enabling this publisher.",
183
+ properties: {
184
+ enabled: {
185
+ type: "boolean",
186
+ title: "Publish to NoForeignLand",
187
+ default: false,
188
+ },
189
+ applicationKey: { type: "string", title: "ABOARD application key" },
190
+ boatKey: { type: "string", title: "Boat API key" },
191
+ source: {
192
+ type: "string",
193
+ title: "Position source (empty selects automatically)",
194
+ },
195
+ sampleSeconds: {
196
+ type: "integer",
197
+ title: "Minimum sample interval (seconds)",
198
+ minimum: 10,
199
+ maximum: 3600,
200
+ default: 60,
201
+ },
202
+ minimumMoveMeters: {
203
+ type: "integer",
204
+ title: "Minimum movement (meters)",
205
+ minimum: 0,
206
+ maximum: 10000,
207
+ default: 80,
208
+ },
209
+ minimumSpeedKnots: {
210
+ type: "number",
211
+ title: "Minimum speed (knots)",
212
+ minimum: 0,
213
+ maximum: 100,
214
+ default: 1.5,
215
+ },
216
+ maximumVelocity: {
217
+ type: "integer",
218
+ title: "Maximum plausible velocity (meters per second)",
219
+ minimum: 1,
220
+ maximum: 500,
221
+ default: 50,
222
+ },
223
+ sendWhileMoving: {
224
+ type: "boolean",
225
+ title: "Send while moving",
226
+ default: true,
227
+ },
228
+ keepalive: {
229
+ type: "boolean",
230
+ title: "Publish a fresh position every 24 hours",
231
+ default: true,
232
+ },
233
+ sendIntervalSeconds: {
234
+ type: "integer",
235
+ title: "Send interval (seconds)",
236
+ minimum: 60,
237
+ maximum: 86400,
238
+ default: 600,
239
+ },
240
+ },
241
+ },
154
242
  targetUrl: {
155
243
  type: "string",
156
244
  title: "Aurora Blue target URL",
@@ -306,6 +394,10 @@ exports.pluginSchema = {
306
394
  },
307
395
  };
308
396
  exports.pluginUiSchema = {
397
+ noforeignland: {
398
+ applicationKey: { "ui:widget": "password" },
399
+ boatKey: { "ui:widget": "password" },
400
+ },
309
401
  targetUrl: {
310
402
  "ui:options": {
311
403
  enableMarkdownInDescription: true,
@@ -43,11 +43,21 @@ export declare class ConnectorRuntime {
43
43
  private nextCapacityRefreshAt;
44
44
  private storageBudget;
45
45
  private readonly emissionTimes;
46
+ private publisher;
46
47
  private captureTail;
47
48
  private pendingCaptureEvents;
48
49
  private nextCaptureStatusAt;
49
50
  constructor(app: ServerAPI, store: StateStore, config: ConnectorConfig, options?: ConnectorRuntimeOptions);
50
51
  start(): Promise<void>;
52
+ publisherStatus(): {
53
+ state: import("./noforeignland").PublisherState;
54
+ message: string;
55
+ pending: number;
56
+ dropped: number;
57
+ savedAt: number | null;
58
+ sentAt: number | null;
59
+ source: string | null;
60
+ } | null;
51
61
  stop(): Promise<void>;
52
62
  capture(delta: Delta, subscriptionScope?: SubscriptionScope): Promise<void>;
53
63
  private captureEvents;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ConnectorRuntime = exports.CONNECTOR_VERSION = void 0;
4
+ const node_path_1 = require("node:path");
5
+ const noforeignland_1 = require("./noforeignland");
4
6
  const node_crypto_1 = require("node:crypto");
5
7
  const backoff_1 = require("./backoff");
6
8
  const delta_1 = require("./delta");
@@ -53,6 +55,7 @@ class ConnectorRuntime {
53
55
  nextCapacityRefreshAt = 0;
54
56
  storageBudget;
55
57
  emissionTimes = [];
58
+ publisher = null;
56
59
  captureTail = Promise.resolve();
57
60
  pendingCaptureEvents = 0;
58
61
  nextCaptureStatusAt = 0;
@@ -73,6 +76,47 @@ class ConnectorRuntime {
73
76
  }
74
77
  async start() {
75
78
  await this.store.load();
79
+ if (this.config.noforeignland?.enabled) {
80
+ this.publisher = new noforeignland_1.NoForeignLandPublisher((0, node_path_1.join)(this.app.getDataDirPath(), "noforeignland"), this.config.noforeignland, (status) => {
81
+ this.app.handleMessage("aurora-blue-signalk-connector", {
82
+ context: "vessels.self",
83
+ updates: [
84
+ {
85
+ timestamp: this.now(),
86
+ values: [
87
+ {
88
+ path: "noforeignland.status",
89
+ value: status.message,
90
+ },
91
+ {
92
+ path: "noforeignland.status_boolean",
93
+ value: status.state === "error" || status.state === "waiting"
94
+ ? 1
95
+ : 0,
96
+ },
97
+ ...(status.savedAt === null
98
+ ? []
99
+ : [
100
+ {
101
+ path: "noforeignland.savepoint",
102
+ value: new Date(status.savedAt).toISOString(),
103
+ },
104
+ ]),
105
+ ...(status.sentAt === null
106
+ ? []
107
+ : [
108
+ {
109
+ path: "noforeignland.sent_to_api",
110
+ value: new Date(status.sentAt).toISOString(),
111
+ },
112
+ ]),
113
+ ],
114
+ },
115
+ ],
116
+ });
117
+ });
118
+ await this.publisher.start(this.autoSchedule);
119
+ }
76
120
  await this.refreshStorageBudget(true);
77
121
  this.client ??= new http_client_1.AuroraBlueHttpClient(this.config.targetUrl, this.config.requestTimeoutMs, fetch, this.config.compressionEnabled
78
122
  ? this.config.compressionThresholdBytes
@@ -90,6 +134,7 @@ class ConnectorRuntime {
90
134
  }
91
135
  if (state.device_id === null) {
92
136
  this.pairingState = "unpaired";
137
+ this.configureSubscriptions();
93
138
  this.app.setPluginStatus("Unpaired - enter an Aurora Blue pairing code in plugin settings.");
94
139
  return;
95
140
  }
@@ -135,6 +180,9 @@ class ConnectorRuntime {
135
180
  this.schedule(0);
136
181
  }
137
182
  }
183
+ publisherStatus() {
184
+ return this.publisher?.status() ?? null;
185
+ }
138
186
  async stop() {
139
187
  this.stopped = true;
140
188
  if (this.timer !== null) {
@@ -152,6 +200,7 @@ class ConnectorRuntime {
152
200
  for (const unsubscribe of this.unsubscribes.splice(0)) {
153
201
  unsubscribe();
154
202
  }
203
+ await this.publisher?.stop();
155
204
  await this.captureTail;
156
205
  await this.store.summary();
157
206
  }
@@ -161,6 +210,8 @@ class ConnectorRuntime {
161
210
  }
162
211
  const receivedAt = this.now();
163
212
  const events = (0, delta_1.eventsFromDelta)(delta, this.bootId, receivedAt, subscriptionScope);
213
+ for (const event of events)
214
+ this.publisher?.observe(event);
164
215
  // Safety observes current positions immediately, even when historical
165
216
  // telemetry admission is saturated by slow storage.
166
217
  for (const event of events) {
@@ -318,14 +369,24 @@ class ConnectorRuntime {
318
369
  return;
319
370
  }
320
371
  if (this.pairingState !== "paired") {
321
- if (this.config.anchorAlarm.enabled) {
322
- this.subscribeToContext("vessels.self", ["navigation.position"], this.config.safetySamplePeriodMs);
372
+ if (this.config.anchorAlarm.enabled || this.publisher) {
373
+ this.subscribeToContext("vessels.self", [
374
+ "navigation.position",
375
+ ...(this.publisher ? ["navigation.speedOverGround"] : []),
376
+ ], this.config.anchorAlarm.enabled
377
+ ? this.config.safetySamplePeriodMs
378
+ : this.config.samplePeriodMs);
323
379
  }
324
380
  return;
325
381
  }
326
382
  if (this.operatingMode === "standby") {
327
- if (this.config.anchorAlarm.enabled) {
328
- this.subscribeToContext("vessels.self", ["navigation.position"], this.config.safetySamplePeriodMs);
383
+ if (this.config.anchorAlarm.enabled || this.publisher) {
384
+ this.subscribeToContext("vessels.self", [
385
+ "navigation.position",
386
+ ...(this.publisher ? ["navigation.speedOverGround"] : []),
387
+ ], this.config.anchorAlarm.enabled
388
+ ? this.config.safetySamplePeriodMs
389
+ : this.config.samplePeriodMs);
329
390
  }
330
391
  return;
331
392
  }
@@ -337,14 +398,16 @@ class ConnectorRuntime {
337
398
  subscribeToContext(context, paths, period) {
338
399
  this.app.subscriptionmanager.subscribe({
339
400
  context,
340
- subscribe: paths.map((path) => ({
341
- path: path,
342
- period: context === "vessels.self" &&
343
- path === "navigation.position" &&
344
- this.config.anchorAlarm.enabled
345
- ? Math.min(this.config.samplePeriodMs, this.config.safetySamplePeriodMs)
346
- : period,
347
- })),
401
+ subscribe: paths.map((path) => context === "vessels.self" && path === "electrical.switches.*"
402
+ ? { path: path, policy: "instant", minPeriod: 0 }
403
+ : {
404
+ path: path,
405
+ period: context === "vessels.self" &&
406
+ (path === "navigation.position" || path === "navigation.*") &&
407
+ this.config.anchorAlarm.enabled
408
+ ? Math.min(this.config.samplePeriodMs, this.config.safetySamplePeriodMs)
409
+ : period,
410
+ }),
348
411
  }, this.unsubscribes, (error) => {
349
412
  this.app.setPluginError("Signal K subscription failed.");
350
413
  this.app.error(error instanceof Error ? error.message : String(error));
package/dist/src/delta.js CHANGED
@@ -26,6 +26,28 @@ const sourceReference = (update) => {
26
26
  }
27
27
  return null;
28
28
  };
29
+ // Bound generic navigation payloads without changing their source shape.
30
+ const validNavigationValue = (value) => {
31
+ let nodes = 0;
32
+ const visit = (item, depth) => {
33
+ if (++nodes > 1024 || depth > 8)
34
+ return false;
35
+ if (item === null || typeof item === "boolean")
36
+ return true;
37
+ if (typeof item === "number")
38
+ return Number.isFinite(item);
39
+ if (typeof item === "string")
40
+ return !item.includes("\u0000");
41
+ if (Array.isArray(item))
42
+ return item.every((child) => visit(child, depth + 1));
43
+ if (typeof item !== "object")
44
+ return false;
45
+ return Object.entries(item).every(([key, child]) => !key.includes("\u0000") && visit(child, depth + 1));
46
+ };
47
+ return (value !== null &&
48
+ visit(value, 0) &&
49
+ Buffer.byteLength(JSON.stringify(value), "utf8") <= 16_384);
50
+ };
29
51
  const validValue = (context, path, value) => {
30
52
  const definition = (0, telemetry_catalog_1.telemetryDefinition)(context, path);
31
53
  if (definition === null) {
@@ -48,11 +70,27 @@ const validValue = (context, path, value) => {
48
70
  longitude > 180) {
49
71
  return null;
50
72
  }
51
- return { latitude, longitude };
73
+ return validNavigationValue(value)
74
+ ? { ...position, latitude, longitude }
75
+ : null;
76
+ }
77
+ if (definition.valueKind === "json") {
78
+ return validNavigationValue(value)
79
+ ? structuredClone(value)
80
+ : null;
52
81
  }
53
82
  if (definition.valueKind === "number") {
54
83
  return typeof value === "number" && Number.isFinite(value) ? value : null;
55
84
  }
85
+ if (definition.valueKind === "switch") {
86
+ if (typeof value === "boolean")
87
+ return value;
88
+ if (typeof value === "number" && Number.isFinite(value))
89
+ return value;
90
+ return typeof value === "string" && value.length > 0
91
+ ? value.slice(0, 255)
92
+ : null;
93
+ }
56
94
  if (definition.valueKind === "string") {
57
95
  if (path === "mmsi" && !/^[0-9]{9}$/.test(String(value))) {
58
96
  return null;
@@ -101,6 +139,32 @@ const flattenedStaticValues = (context, path, value) => {
101
139
  { path: "navigation.state", value: navigation?.state },
102
140
  ];
103
141
  };
142
+ const flattenedSwitchValues = (path, value) => {
143
+ if (path !== "electrical.switches" &&
144
+ !path.startsWith("electrical.switches.")) {
145
+ return [{ path, value }];
146
+ }
147
+ const leaves = [];
148
+ const visit = (currentPath, currentValue, depth) => {
149
+ if (leaves.length >= 100 || depth > 8)
150
+ return;
151
+ if (currentValue === null || Array.isArray(currentValue))
152
+ return;
153
+ if (typeof currentValue !== "object") {
154
+ leaves.push({ path: currentPath, value: currentValue });
155
+ return;
156
+ }
157
+ for (const [name, child] of Object.entries(currentValue)) {
158
+ if (!/^[A-Za-z0-9_:-]+$/.test(name))
159
+ continue;
160
+ visit(`${currentPath}.${name}`, child, depth + 1);
161
+ if (leaves.length >= 100)
162
+ break;
163
+ }
164
+ };
165
+ visit(path, value, 0);
166
+ return leaves;
167
+ };
104
168
  const eventsFromDelta = (delta, bootId, receivedAt, subscriptionScope = "self") => {
105
169
  const events = [];
106
170
  const context = eventContext(delta, subscriptionScope);
@@ -115,7 +179,11 @@ const eventsFromDelta = (delta, bootId, receivedAt, subscriptionScope = "self")
115
179
  if (pathValue.state?.timedOut === true) {
116
180
  continue;
117
181
  }
118
- for (const candidate of flattenedStaticValues(context, String(pathValue.path), pathValue.value)) {
182
+ const path = String(pathValue.path);
183
+ const candidates = context === "vessels.self"
184
+ ? flattenedSwitchValues(path, pathValue.value)
185
+ : flattenedStaticValues(context, path, pathValue.value);
186
+ for (const candidate of candidates) {
119
187
  const value = validValue(context, candidate.path, candidate.value);
120
188
  if (value === null) {
121
189
  continue;
package/dist/src/index.js CHANGED
@@ -27,11 +27,12 @@ const createPlugin = (app) => {
27
27
  generated_at: new Date().toISOString(),
28
28
  last_error: lastError,
29
29
  status,
30
+ noforeignland: runtime?.publisherStatus() ?? null,
30
31
  };
31
32
  };
32
33
  return {
33
34
  id: "aurora-blue-signalk-connector",
34
- name: "Aurora Blue ABOARD Connector",
35
+ name: "Aurora Blue ABOARD SK",
35
36
  description: "Buffers selected Signal K data and transfers it to Aurora Blue ABOARD.",
36
37
  schema: config_1.pluginSchema,
37
38
  uiSchema: config_1.pluginUiSchema,
@@ -0,0 +1,57 @@
1
+ import type { EventInput } from "./types";
2
+ export interface NoForeignLandConfig {
3
+ enabled: boolean;
4
+ applicationKey: string;
5
+ boatKey: string;
6
+ source: string;
7
+ sampleSeconds: number;
8
+ minimumMoveMeters: number;
9
+ minimumSpeedKnots: number;
10
+ maximumVelocity: number;
11
+ sendWhileMoving: boolean;
12
+ keepalive: boolean;
13
+ sendIntervalSeconds: number;
14
+ }
15
+ export type PublisherState = "disabled" | "ready" | "waiting" | "sending" | "error";
16
+ /** Independent, bounded publisher consuming the connector's existing observations. */
17
+ export declare class NoForeignLandPublisher {
18
+ private readonly directory;
19
+ private readonly config;
20
+ private readonly emit;
21
+ private readonly request;
22
+ private readonly now;
23
+ private journal;
24
+ private state;
25
+ private message;
26
+ private selectedSource;
27
+ private sourceSeenAt;
28
+ private speed;
29
+ private tail;
30
+ private outstanding;
31
+ private flight;
32
+ private controller;
33
+ private timer;
34
+ private stopped;
35
+ private dropped;
36
+ private nextSendAt;
37
+ private readonly file;
38
+ private journalBytes;
39
+ constructor(directory: string, config: NoForeignLandConfig, emit: (status: ReturnType<NoForeignLandPublisher["status"]>) => void, request?: typeof fetch, now?: () => number);
40
+ status(): {
41
+ state: PublisherState;
42
+ message: string;
43
+ pending: number;
44
+ dropped: number;
45
+ savedAt: number | null;
46
+ sentAt: number | null;
47
+ source: string | null;
48
+ };
49
+ start(schedule?: boolean): Promise<void>;
50
+ observe(event: EventInput): void;
51
+ idle(): Promise<void>;
52
+ flush(): Promise<void>;
53
+ private send;
54
+ private save;
55
+ private schedule;
56
+ stop(): Promise<void>;
57
+ }
@@ -0,0 +1,425 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NoForeignLandPublisher = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const promises_1 = require("node:fs/promises");
6
+ const node_path_1 = require("node:path");
7
+ const geometry_1 = require("./geometry");
8
+ const version_1 = require("./version");
9
+ const MAX_POINTS = 10_000;
10
+ const MAX_FILE_BYTES = 4_000_000;
11
+ const ENDPOINT = "https://www.noforeignland.com/home/api/v1/boat/tracking/track";
12
+ /** Independent, bounded publisher consuming the connector's existing observations. */
13
+ class NoForeignLandPublisher {
14
+ directory;
15
+ config;
16
+ emit;
17
+ request;
18
+ now;
19
+ journal;
20
+ state = "disabled";
21
+ message = "Disabled";
22
+ selectedSource = null;
23
+ sourceSeenAt = 0;
24
+ speed = null;
25
+ tail = Promise.resolve();
26
+ outstanding = 0;
27
+ flight = null;
28
+ controller = null;
29
+ timer = null;
30
+ stopped = false;
31
+ dropped = 0;
32
+ nextSendAt = 0;
33
+ file;
34
+ journalBytes = 0;
35
+ constructor(directory, config, emit, request = fetch, now = Date.now) {
36
+ this.directory = directory;
37
+ this.config = config;
38
+ this.emit = emit;
39
+ this.request = request;
40
+ this.now = now;
41
+ this.file = (0, node_path_1.join)(directory, "publisher.jsonl");
42
+ this.journal = {
43
+ version: 1,
44
+ account: (0, node_crypto_1.createHash)("sha256").update(config.boatKey).digest("hex"),
45
+ pending: [],
46
+ last: null,
47
+ sentAt: null,
48
+ };
49
+ }
50
+ status() {
51
+ return {
52
+ state: this.state,
53
+ message: this.message,
54
+ pending: this.journal.pending.length,
55
+ dropped: this.dropped,
56
+ savedAt: this.journal.last?.timestamp ?? null,
57
+ sentAt: this.journal.sentAt,
58
+ source: this.selectedSource,
59
+ };
60
+ }
61
+ async start(schedule = true) {
62
+ if (!this.config.enabled)
63
+ return;
64
+ try {
65
+ // Signal K stores the two historical plugin IDs alongside this plugin.
66
+ for (const name of [
67
+ "signalk-to-noforeignland.json",
68
+ "@noforeignland-signalk-to-noforeignland.json",
69
+ ]) {
70
+ try {
71
+ const other = JSON.parse(await (0, promises_1.readFile)((0, node_path_1.join)((0, node_path_1.dirname)((0, node_path_1.dirname)(this.directory)), name), "utf8"));
72
+ if (other.enabled === true) {
73
+ this.state = "error";
74
+ this.message =
75
+ "Disable the separate NoForeignLand plugin before enabling publication";
76
+ this.emit(this.status());
77
+ return;
78
+ }
79
+ }
80
+ catch (error) {
81
+ if (error.code !== "ENOENT")
82
+ throw error;
83
+ }
84
+ }
85
+ await (0, promises_1.mkdir)(this.directory, { recursive: true, mode: 0o700 });
86
+ try {
87
+ if ((await (0, promises_1.stat)(this.file)).size > MAX_FILE_BYTES)
88
+ throw new Error("oversized");
89
+ const contents = await (0, promises_1.readFile)(this.file, "utf8");
90
+ const lines = contents.trimEnd().split("\n");
91
+ const saved = JSON.parse(lines.shift() ?? "");
92
+ if (saved.version !== 1 ||
93
+ saved.account !== this.journal.account ||
94
+ !Array.isArray(saved.pending))
95
+ throw new Error("invalid journal or changed boat");
96
+ for (const line of lines) {
97
+ const record = JSON.parse(line);
98
+ if (record.point && validPoint(record.point)) {
99
+ if (saved.last && record.point.timestamp <= saved.last.timestamp)
100
+ throw new Error("invalid order");
101
+ saved.pending.push(record.point);
102
+ saved.last = record.point;
103
+ }
104
+ else if (Number.isFinite(record.ack) &&
105
+ Number.isFinite(record.sentAt)) {
106
+ saved.pending = saved.pending.filter((point) => point.timestamp > record.ack);
107
+ saved.sentAt = record.sentAt;
108
+ }
109
+ else
110
+ throw new Error("invalid record");
111
+ if (saved.pending.length > MAX_POINTS)
112
+ throw new Error("queue limit");
113
+ }
114
+ if (saved.pending.length > MAX_POINTS ||
115
+ !saved.pending.every(validPoint) ||
116
+ (saved.last !== null && !validPoint(saved.last)) ||
117
+ (saved.sentAt !== null && !Number.isFinite(saved.sentAt)))
118
+ throw new Error("invalid journal");
119
+ this.journalBytes = Buffer.byteLength(contents);
120
+ this.journal = saved;
121
+ }
122
+ catch (error) {
123
+ if (error.code !== "ENOENT")
124
+ throw error;
125
+ }
126
+ this.state = "ready";
127
+ this.message = "Waiting for current position";
128
+ this.nextSendAt = this.now() + this.config.sendIntervalSeconds * 1000;
129
+ if (schedule)
130
+ this.schedule();
131
+ }
132
+ catch {
133
+ this.state = "error";
134
+ this.message = "Publisher storage unavailable or belongs to another boat";
135
+ }
136
+ this.emit(this.status());
137
+ }
138
+ observe(event) {
139
+ if (!this.config.enabled ||
140
+ this.stopped ||
141
+ this.state === "disabled" ||
142
+ this.state === "error" ||
143
+ event.context !== "vessels.self")
144
+ return;
145
+ if (event.path !== "navigation.position" &&
146
+ event.path !== "navigation.speedOverGround")
147
+ return;
148
+ if (this.outstanding >= 32) {
149
+ this.dropped++;
150
+ return;
151
+ }
152
+ this.outstanding++;
153
+ this.tail = this.tail
154
+ .then(async () => {
155
+ if (this.stopped || this.state === "error")
156
+ return;
157
+ const time = event.source_timestamp === null
158
+ ? NaN
159
+ : Date.parse(event.source_timestamp);
160
+ if (!Number.isFinite(time) ||
161
+ time > this.now() + 5000 ||
162
+ time < this.now() - 120_000)
163
+ return;
164
+ if (this.config.source && event.source !== this.config.source)
165
+ return;
166
+ if (event.path === "navigation.speedOverGround") {
167
+ if (typeof event.value === "number" && event.value >= 0)
168
+ this.speed = { value: event.value, at: time, source: event.source };
169
+ return;
170
+ }
171
+ const position = event.value;
172
+ const point = {
173
+ ...position,
174
+ timestamp: time,
175
+ source: event.source,
176
+ };
177
+ if (!validPoint(point))
178
+ return;
179
+ if (this.selectedSource !== null &&
180
+ event.source !== this.selectedSource &&
181
+ this.now() - this.sourceSeenAt < 300_000)
182
+ return;
183
+ this.selectedSource = event.source;
184
+ this.sourceSeenAt = this.now();
185
+ const last = this.journal.last;
186
+ if (last && time <= last.timestamp)
187
+ return;
188
+ const distance = last ? (0, geometry_1.distanceMeters)(last, point) : 0;
189
+ if (last &&
190
+ distance / ((time - last.timestamp) / 1000) >
191
+ this.config.maximumVelocity)
192
+ return;
193
+ if (last && time - last.timestamp < this.config.sampleSeconds * 1000)
194
+ return;
195
+ const heartbeat = this.config.keepalive &&
196
+ last !== null &&
197
+ time - last.timestamp >= 86_400_000;
198
+ if (!heartbeat) {
199
+ if (last && distance < this.config.minimumMoveMeters)
200
+ return;
201
+ if (this.config.minimumSpeedKnots > 0 &&
202
+ (!this.speed ||
203
+ this.speed.source !== event.source ||
204
+ Math.abs(time - this.speed.at) > 120_000 ||
205
+ this.speed.value * 1.943844 < this.config.minimumSpeedKnots))
206
+ return;
207
+ }
208
+ if (this.journal.pending.length >= MAX_POINTS) {
209
+ this.dropped++;
210
+ this.message = "Offline queue full - new points omitted";
211
+ this.emit(this.status());
212
+ return;
213
+ }
214
+ const updated = {
215
+ ...this.journal,
216
+ pending: [...this.journal.pending, point],
217
+ last: point,
218
+ };
219
+ await this.save(updated);
220
+ this.journal = updated;
221
+ this.emit(this.status());
222
+ })
223
+ .catch(() => {
224
+ this.state = "error";
225
+ this.message = "Publisher storage failed; pending track retained";
226
+ this.emit(this.status());
227
+ })
228
+ .finally(() => {
229
+ this.outstanding--;
230
+ });
231
+ }
232
+ async idle() {
233
+ await this.tail;
234
+ }
235
+ async flush() {
236
+ if (this.flight)
237
+ return this.flight;
238
+ this.flight = this.send().finally(() => {
239
+ this.flight = null;
240
+ });
241
+ return this.flight;
242
+ }
243
+ async send() {
244
+ await this.tail;
245
+ if (this.stopped ||
246
+ !this.config.enabled ||
247
+ this.state === "error" ||
248
+ this.state === "disabled" ||
249
+ !this.journal.pending.length ||
250
+ this.now() < this.nextSendAt)
251
+ return;
252
+ if (!this.config.sendWhileMoving &&
253
+ this.journal.last &&
254
+ this.now() - this.journal.last.timestamp <
255
+ this.config.sampleSeconds * 2000)
256
+ return;
257
+ const batch = this.journal.pending.slice(0, 500);
258
+ this.state = "sending";
259
+ this.message = "Sending track";
260
+ this.emit(this.status());
261
+ this.controller = new AbortController();
262
+ const timeout = setTimeout(() => this.controller?.abort(), 30_000);
263
+ try {
264
+ const response = await this.request(ENDPOINT, {
265
+ method: "POST",
266
+ redirect: "error",
267
+ signal: this.controller.signal,
268
+ headers: {
269
+ "X-NFL-API-Key": this.config.applicationKey,
270
+ "Accept-Encoding": "identity",
271
+ },
272
+ body: new URLSearchParams({
273
+ timestamp: String(batch[batch.length - 1].timestamp),
274
+ track: JSON.stringify(batch.map((p) => [p.timestamp, p.latitude, p.longitude])),
275
+ boatApiKey: this.config.boatKey,
276
+ pluginVersion: `aurora-blue/${version_1.CONNECTOR_VERSION}`,
277
+ nodeVersion: process.version,
278
+ skVersion: "unknown",
279
+ }),
280
+ });
281
+ if (!response.ok) {
282
+ await response.body?.cancel();
283
+ if (response.status >= 400 &&
284
+ response.status < 500 &&
285
+ response.status !== 429) {
286
+ this.state = "error";
287
+ this.message = `Publication rejected (HTTP ${response.status}); check configuration`;
288
+ return;
289
+ }
290
+ throw new Error("retry");
291
+ }
292
+ // Require an explicit acknowledgement. An ambiguous response retains data;
293
+ // exactly-once delivery requires a provider-supported idempotency contract.
294
+ const reader = response.body?.getReader();
295
+ if (!reader)
296
+ throw new Error("missing response");
297
+ let body = "";
298
+ for (;;) {
299
+ const chunk = await reader.read();
300
+ if (chunk.done)
301
+ break;
302
+ if (body.length + chunk.value.length > 4096) {
303
+ await reader.cancel();
304
+ throw new Error("large response");
305
+ }
306
+ body += new TextDecoder().decode(chunk.value);
307
+ }
308
+ if (JSON.parse(body).status !== "ok") {
309
+ this.state = "error";
310
+ this.message = "Publication rejected by provider; check configuration";
311
+ return;
312
+ }
313
+ const cutoff = batch[batch.length - 1].timestamp;
314
+ // Serialize acknowledgement with writes that arrived during the request.
315
+ this.tail = this.tail.then(async () => {
316
+ const updated = {
317
+ ...this.journal,
318
+ pending: this.journal.pending.filter((p) => p.timestamp > cutoff),
319
+ sentAt: this.now(),
320
+ };
321
+ await this.save(updated);
322
+ this.journal = updated;
323
+ });
324
+ try {
325
+ await this.tail;
326
+ }
327
+ catch {
328
+ this.tail = Promise.resolve();
329
+ this.state = "error";
330
+ this.message =
331
+ "Publication acknowledged but checkpoint failed; inspect before retry";
332
+ return;
333
+ }
334
+ this.state = "ready";
335
+ this.message = "Track published";
336
+ }
337
+ catch {
338
+ if (this.state !== "error") {
339
+ this.state = "waiting";
340
+ this.message = "Transfer delayed; pending track retained";
341
+ }
342
+ }
343
+ finally {
344
+ clearTimeout(timeout);
345
+ this.controller = null;
346
+ this.nextSendAt = this.now() + this.config.sendIntervalSeconds * 1000;
347
+ this.emit(this.status());
348
+ }
349
+ }
350
+ async save(updated) {
351
+ const capacity = await (0, promises_1.statfs)(this.directory);
352
+ if (capacity.bavail * capacity.bsize < 32 * 1024 * 1024)
353
+ throw new Error("reserve");
354
+ if (this.journalBytes === 0 || this.journalBytes > 2_000_000) {
355
+ const payload = JSON.stringify(updated) + "\n";
356
+ if (Buffer.byteLength(payload) > MAX_FILE_BYTES)
357
+ throw new Error("limit");
358
+ const temporary = `${this.file}.tmp`;
359
+ const handle = await (0, promises_1.open)(temporary, "w", 0o600);
360
+ try {
361
+ await handle.writeFile(payload);
362
+ await handle.sync();
363
+ }
364
+ finally {
365
+ await handle.close();
366
+ }
367
+ await (0, promises_1.rename)(temporary, this.file);
368
+ const directory = await (0, promises_1.open)(this.directory, "r");
369
+ try {
370
+ await directory.sync();
371
+ }
372
+ finally {
373
+ await directory.close();
374
+ }
375
+ this.journalBytes = Buffer.byteLength(payload);
376
+ return;
377
+ }
378
+ const record = updated.last !== this.journal.last
379
+ ? { point: updated.last }
380
+ : {
381
+ ack: this.journal.pending[this.journal.pending.length - updated.pending.length - 1]?.timestamp,
382
+ sentAt: updated.sentAt,
383
+ };
384
+ const payload = JSON.stringify(record) + "\n";
385
+ const handle = await (0, promises_1.open)(this.file, "a", 0o600);
386
+ try {
387
+ await handle.writeFile(payload);
388
+ await handle.sync();
389
+ }
390
+ finally {
391
+ await handle.close();
392
+ }
393
+ this.journalBytes += Buffer.byteLength(payload);
394
+ }
395
+ schedule() {
396
+ this.timer = setTimeout(() => {
397
+ void this.flush().finally(() => {
398
+ if (!this.stopped)
399
+ this.schedule();
400
+ });
401
+ }, this.config.sendIntervalSeconds * 1000);
402
+ this.timer.unref();
403
+ }
404
+ async stop() {
405
+ this.stopped = true;
406
+ if (this.timer)
407
+ clearTimeout(this.timer);
408
+ this.controller?.abort();
409
+ await this.flight;
410
+ await this.tail;
411
+ }
412
+ }
413
+ exports.NoForeignLandPublisher = NoForeignLandPublisher;
414
+ function validPoint(value) {
415
+ if (!value || typeof value !== "object")
416
+ return false;
417
+ const p = value;
418
+ return (Number.isFinite(p.timestamp) &&
419
+ Number.isFinite(p.latitude) &&
420
+ Math.abs(p.latitude) <= 90 &&
421
+ Number.isFinite(p.longitude) &&
422
+ Math.abs(p.longitude) <= 180 &&
423
+ (p.source === null ||
424
+ (typeof p.source === "string" && p.source.length <= 255)));
425
+ }
@@ -1,6 +1,6 @@
1
1
  import type { EventContext } from "./types";
2
2
  export type TelemetryScope = "self" | "ais";
3
- export type ValueKind = "number" | "position" | "string" | "aisShipType";
3
+ export type ValueKind = "number" | "position" | "string" | "boolean" | "switch" | "aisShipType" | "json";
4
4
  export type ChangeKind = "scalar" | "angle" | "position" | "identity";
5
5
  export interface TelemetryDefinition {
6
6
  scope: TelemetryScope;
@@ -13,6 +13,8 @@ export interface TelemetryDefinition {
13
13
  }
14
14
  export declare const SELF_TELEMETRY: readonly TelemetryDefinition[];
15
15
  export declare const AIS_TELEMETRY: readonly TelemetryDefinition[];
16
+ export declare const NAVIGATION_SUBSCRIPTION_PATHS: readonly ["navigation.*", "environment.wind.*", "environment.depth.*", "environment.current", "environment.current.*", "environment.tide.*", "environment.outside.*", "environment.water.*", "environment.heave", "performance.*", "steering.*"];
17
+ export declare const isNavigationPath: (path: string) => boolean;
16
18
  export declare const SELF_SUBSCRIPTION_PATHS: string[];
17
19
  export declare const AIS_SUBSCRIPTION_PATHS: string[];
18
20
  export declare const isAisContext: (context: string) => context is EventContext;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.telemetryDefinition = exports.isAisContext = exports.AIS_SUBSCRIPTION_PATHS = exports.SELF_SUBSCRIPTION_PATHS = exports.AIS_TELEMETRY = exports.SELF_TELEMETRY = void 0;
3
+ exports.telemetryDefinition = exports.isAisContext = exports.AIS_SUBSCRIPTION_PATHS = exports.SELF_SUBSCRIPTION_PATHS = exports.isNavigationPath = exports.NAVIGATION_SUBSCRIPTION_PATHS = exports.AIS_TELEMETRY = exports.SELF_TELEMETRY = void 0;
4
4
  const degrees = (value) => (value * Math.PI) / 180;
5
5
  const self = (path, valueKind, changeKind, minimumIntervalMs, maximumHeartbeatMs, changeThreshold) => ({
6
6
  scope: "self",
@@ -72,7 +72,27 @@ exports.AIS_TELEMETRY = [
72
72
  ais("communication.callsignVhf", "string", "identity", 60_000, 21_600_000, 0),
73
73
  ais("design.aisShipType", "aisShipType", "identity", 60_000, 21_600_000, 0),
74
74
  ];
75
- exports.SELF_SUBSCRIPTION_PATHS = exports.SELF_TELEMETRY.map((definition) => definition.path);
75
+ exports.NAVIGATION_SUBSCRIPTION_PATHS = [
76
+ "navigation.*",
77
+ "environment.wind.*",
78
+ "environment.depth.*",
79
+ "environment.current",
80
+ "environment.current.*",
81
+ "environment.tide.*",
82
+ "environment.outside.*",
83
+ "environment.water.*",
84
+ "environment.heave",
85
+ "performance.*",
86
+ "steering.*",
87
+ ];
88
+ const isNavigationPath = (path) => path.length <= 255 &&
89
+ (/^(?:navigation|performance|steering|environment\.(?:wind|depth|current|tide|outside|water))(?:\.[A-Za-z0-9_:-]+)+$/.test(path) ||
90
+ path === "environment.current" ||
91
+ path === "environment.heave");
92
+ exports.isNavigationPath = isNavigationPath;
93
+ exports.SELF_SUBSCRIPTION_PATHS = exports.SELF_TELEMETRY.filter((definition) => !(0, exports.isNavigationPath)(definition.path))
94
+ .map((definition) => definition.path)
95
+ .concat(...exports.NAVIGATION_SUBSCRIPTION_PATHS, "electrical.switches.*");
76
96
  exports.AIS_SUBSCRIPTION_PATHS = exports.AIS_TELEMETRY.map((definition) => definition.path);
77
97
  const matchesPath = (pattern, path) => {
78
98
  const patternParts = pattern.split(".");
@@ -83,11 +103,20 @@ const matchesPath = (pattern, path) => {
83
103
  const isAisContext = (context) => /^vessels\.(?:urn:mrn:imo:mmsi:)?[0-9]{9}$/.test(context);
84
104
  exports.isAisContext = isAisContext;
85
105
  const telemetryDefinition = (context, path) => {
106
+ if (context === "vessels.self" &&
107
+ path.startsWith("electrical.switches.") &&
108
+ path.length <= 255 &&
109
+ /^electrical\.switches\.[A-Za-z0-9_:-]+(?:\.[A-Za-z0-9_:-]+)+$/.test(path)) {
110
+ return self(path, "switch", "identity", 0, 60_000, 0);
111
+ }
86
112
  const definitions = context === "vessels.self"
87
113
  ? exports.SELF_TELEMETRY
88
114
  : (0, exports.isAisContext)(context)
89
115
  ? exports.AIS_TELEMETRY
90
116
  : [];
91
- return (definitions.find((definition) => matchesPath(definition.path, path)) ?? null);
117
+ return (definitions.find((definition) => matchesPath(definition.path, path)) ??
118
+ (context === "vessels.self" && (0, exports.isNavigationPath)(path)
119
+ ? self(path, "json", "identity", 2_000, 60_000, 0)
120
+ : null));
92
121
  };
93
122
  exports.telemetryDefinition = telemetryDefinition;
@@ -51,7 +51,8 @@ const changedEnough = (kind, previous, current, threshold) => {
51
51
  !("latitude" in current)) {
52
52
  return true;
53
53
  }
54
- return (0, geometry_1.distanceMeters)(previous, current) >= threshold;
54
+ return ((0, geometry_1.distanceMeters)(previous, current) >=
55
+ threshold);
55
56
  }
56
57
  if (kind === "identity") {
57
58
  return JSON.stringify(previous) !== JSON.stringify(current);
@@ -12,6 +12,7 @@ export interface RuntimeConfiguration {
12
12
  export type EventContext = "vessels.self" | `vessels.${number}` | `vessels.urn:mrn:imo:mmsi:${number}`;
13
13
  export type AllowedPath = string;
14
14
  export interface PositionValue {
15
+ altitude?: number;
15
16
  latitude: number;
16
17
  longitude: number;
17
18
  }
@@ -19,7 +20,7 @@ export interface AisShipTypeValue {
19
20
  id: number;
20
21
  name: string;
21
22
  }
22
- export type EventValue = number | string | PositionValue | AisShipTypeValue;
23
+ export type EventValue = number | string | boolean | PositionValue | AisShipTypeValue | Record<string, unknown> | unknown[];
23
24
  export interface EventInput {
24
25
  boot_id: string;
25
26
  context: EventContext;
@@ -1 +1 @@
1
- export declare const CONNECTOR_VERSION = "0.2.0-dev.22";
1
+ export declare const CONNECTOR_VERSION = "0.2.0-dev.26";
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CONNECTOR_VERSION = void 0;
4
- exports.CONNECTOR_VERSION = "0.2.0-dev.22";
4
+ exports.CONNECTOR_VERSION = "0.2.0-dev.26";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurora-blue/signalk-connector",
3
- "version": "0.2.0-dev.22",
3
+ "version": "0.2.0-dev.26",
4
4
  "description": "Signal K edge connector for the Aurora Blue ABOARD ingest protocol",
5
5
  "private": false,
6
6
  "main": "dist/src/index.js",
@@ -18,7 +18,8 @@
18
18
  "signalk-category-cloud"
19
19
  ],
20
20
  "signalk": {
21
- "displayName": "Aurora Blue ABOARD Connector Status"
21
+ "appIcon": "./favicon.svg",
22
+ "displayName": "Aurora Blue ABOARD SK"
22
23
  },
23
24
  "signalk-plugin-enabled-by-default": false,
24
25
  "engines": {
@@ -43,6 +44,7 @@
43
44
  "verify:platforms": "sh tools/verify-platforms.sh",
44
45
  "release:build": "node tools/release.mjs build",
45
46
  "release:stage": "node tools/release.mjs stage",
47
+ "release:publish": "node tools/release.mjs publish",
46
48
  "release:verify": "node tools/release.mjs verify"
47
49
  },
48
50
  "devDependencies": {
@@ -46,6 +46,9 @@
46
46
  --aboard-danger: #b83245;
47
47
  --aboard-scrim-rgb: 15 34 39;
48
48
  --aboard-shadow-rgb: 25 51 57;
49
+ --aboard-map-departure: #f0c76e;
50
+ --aboard-map-arrival: #65e0c0;
51
+ --aboard-map-endpoint-ink: #12313c;
49
52
  --aboard-map-water: #cfe5ec;
50
53
  --aboard-map-land: #e6ebe1;
51
54
  --aboard-map-coast: #789197;
@@ -134,6 +137,9 @@
134
137
  :root { --aboard-font-title: 1.75rem; }
135
138
  }
136
139
 
140
+ /* Machine-readable codes retain a white quiet zone in every theme. */
141
+ :root { --aboard-qr-background: #fff; --aboard-qr-foreground: #000; }
142
+
137
143
  :root { --aboard-chart: url("aurora-blue-archipelago-light.svg"); }
138
144
  @media (prefers-color-scheme: dark) { :root:not([data-theme]) { --aboard-chart: url("aurora-blue-archipelago-dark.svg"); } }
139
145
  :root[data-theme="dark"] { --aboard-chart: url("aurora-blue-archipelago-dark.svg"); }
Binary file
@@ -1,7 +1,16 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 591 591" role="img" aria-labelledby="title">
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-labelledby="title">
3
3
  <title id="title">Aurora Blue ABOARD</title>
4
- <g fill="#7ee4e6" transform="matrix(1,0,0,1,-889,-2810)">
4
+ <defs>
5
+ <linearGradient id="aboard-icon-gradient" x1="100%" y1="0%" x2="0%" y2="100%">
6
+ <stop offset="0%" stop-color="#279fa8"/>
7
+ <stop offset="52%" stop-color="#167f88"/>
8
+ <stop offset="100%" stop-color="#123e56"/>
9
+ </linearGradient>
10
+ </defs>
11
+ <rect width="1024" height="1024" fill="url(#aboard-icon-gradient)"/>
12
+ <g transform="translate(13.319897172236494 13.319897172236494) scale(1.687580720229318)">
13
+ <g fill="#ffffff" transform="matrix(1,0,0,1,-889,-2810)">
5
14
  <g transform="matrix(1,0,0,1,-637,622)">
6
15
  <g transform="matrix(2.572721,0,0,2.572721,1472.604933,2173.221564)">
7
16
  <g transform="matrix(0.720795,0,0,0.720795,199.370511,207.270387)">
@@ -16,4 +25,6 @@
16
25
  </g>
17
26
  </g>
18
27
  </g>
28
+
29
+ </g>
19
30
  </svg>
package/public/index.html CHANGED
@@ -4,8 +4,9 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <meta name="color-scheme" content="dark light" />
7
- <title data-i18n="page.title">Aurora Blue ABOARD Connector Status</title>
8
- <link rel="icon" href="favicon.svg?v=3" type="image/svg+xml" />
7
+ <title data-i18n="page.title">Aurora Blue ABOARD SK</title>
8
+ <link rel="icon" href="favicon.ico?v=6" type="image/x-icon" />
9
+ <link rel="icon" href="favicon.svg?v=6" type="image/svg+xml" />
9
10
  <link rel="stylesheet" href="ci-fonts.css" />
10
11
  <link rel="stylesheet" href="ci-tokens.css" />
11
12
  <link rel="stylesheet" href="ci-brand.css?v=5" />
@@ -16,7 +17,7 @@
16
17
  <header class="brand-header">
17
18
  <div
18
19
  class="aurora-blue-brand"
19
- aria-label="Aurora Blue ABOARD, Signal K Connector"
20
+ aria-label="Aurora Blue ABOARD SK"
20
21
  data-i18n-aria-label="brand.label"
21
22
  style="
22
23
  --aurora-blue-mark-url: url(&quot;aurora-blue-aboard-mark.svg&quot;);
@@ -1,7 +1,7 @@
1
1
  {
2
- "page.title": "Aurora Blue ABOARD Connector-Status",
2
+ "page.title": "Aurora Blue ABOARD SK",
3
3
  "brand.context": "Signal K Connector",
4
- "brand.label": "Aurora Blue ABOARD, Signal K Connector",
4
+ "brand.label": "Aurora Blue ABOARD SK",
5
5
  "appearance.system": "Systemdarstellung: {appearance}",
6
6
  "appearance.dark": "dunkel",
7
7
  "appearance.light": "hell",
@@ -1,7 +1,7 @@
1
1
  {
2
- "page.title": "Aurora Blue ABOARD Connector Status",
2
+ "page.title": "Aurora Blue ABOARD SK",
3
3
  "brand.context": "Signal K Connector",
4
- "brand.label": "Aurora Blue ABOARD, Signal K Connector",
4
+ "brand.label": "Aurora Blue ABOARD SK",
5
5
  "appearance.system": "System appearance: {appearance}",
6
6
  "appearance.dark": "dark",
7
7
  "appearance.light": "light",
@@ -1,7 +1,7 @@
1
1
  {
2
- "page.title": "Estado del conector Aurora Blue ABOARD",
2
+ "page.title": "Aurora Blue ABOARD SK",
3
3
  "brand.context": "Conector Signal K",
4
- "brand.label": "Aurora Blue ABOARD, conector Signal K",
4
+ "brand.label": "Aurora Blue ABOARD SK",
5
5
  "appearance.system": "Apariencia del sistema: {appearance}",
6
6
  "appearance.dark": "oscura",
7
7
  "appearance.light": "clara",
@@ -1,7 +1,7 @@
1
1
  {
2
- "page.title": "État du connecteur Aurora Blue ABOARD",
2
+ "page.title": "Aurora Blue ABOARD SK",
3
3
  "brand.context": "Connecteur Signal K",
4
- "brand.label": "Aurora Blue ABOARD, connecteur Signal K",
4
+ "brand.label": "Aurora Blue ABOARD SK",
5
5
  "appearance.system": "Apparence du système : {appearance}",
6
6
  "appearance.dark": "sombre",
7
7
  "appearance.light": "claire",