@aurora-blue/signalk-connector 0.2.0-dev.20

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -0
  3. package/THIRD_PARTY_NOTICES.md +11 -0
  4. package/VERSION +1 -0
  5. package/dist/src/backoff.d.ts +1 -0
  6. package/dist/src/backoff.js +9 -0
  7. package/dist/src/config.d.ts +192 -0
  8. package/dist/src/config.js +317 -0
  9. package/dist/src/connector.d.ts +69 -0
  10. package/dist/src/connector.js +487 -0
  11. package/dist/src/delta.d.ts +4 -0
  12. package/dist/src/delta.js +137 -0
  13. package/dist/src/geometry.d.ts +3 -0
  14. package/dist/src/geometry.js +25 -0
  15. package/dist/src/http-client.d.ts +22 -0
  16. package/dist/src/http-client.js +158 -0
  17. package/dist/src/index.d.ts +3 -0
  18. package/dist/src/index.js +93 -0
  19. package/dist/src/safety/anchor-alarm.d.ts +33 -0
  20. package/dist/src/safety/anchor-alarm.js +79 -0
  21. package/dist/src/state-store.d.ts +45 -0
  22. package/dist/src/state-store.js +491 -0
  23. package/dist/src/storage-budget.d.ts +35 -0
  24. package/dist/src/storage-budget.js +88 -0
  25. package/dist/src/telemetry-catalog.d.ts +19 -0
  26. package/dist/src/telemetry-catalog.js +93 -0
  27. package/dist/src/telemetry-reducer.d.ts +22 -0
  28. package/dist/src/telemetry-reducer.js +150 -0
  29. package/dist/src/types.d.ts +115 -0
  30. package/dist/src/types.js +5 -0
  31. package/dist/src/version.d.ts +1 -0
  32. package/dist/src/version.js +4 -0
  33. package/package.json +60 -0
  34. package/public/aurora-blue-aboard-mark.svg +19 -0
  35. package/public/aurora-blue-archipelago-dark.svg +32 -0
  36. package/public/aurora-blue-archipelago-light.svg +81 -0
  37. package/public/ci-brand.css +77 -0
  38. package/public/ci-fonts.css +28 -0
  39. package/public/ci-tokens.css +139 -0
  40. package/public/favicon.svg +19 -0
  41. package/public/fonts/LICENSE +96 -0
  42. package/public/fonts/ubuntu-latin-300-normal.woff2 +0 -0
  43. package/public/fonts/ubuntu-latin-400-normal.woff2 +0 -0
  44. package/public/fonts/ubuntu-latin-500-normal.woff2 +0 -0
  45. package/public/fonts/ubuntu-latin-700-normal.woff2 +0 -0
  46. package/public/index.html +177 -0
  47. package/public/locales/de.json +82 -0
  48. package/public/locales/en.json +82 -0
  49. package/public/locales/es.json +82 -0
  50. package/public/locales/fr.json +82 -0
  51. package/public/status.css +262 -0
  52. package/public/status.js +237 -0
@@ -0,0 +1,487 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConnectorRuntime = exports.CONNECTOR_VERSION = void 0;
4
+ const node_crypto_1 = require("node:crypto");
5
+ const backoff_1 = require("./backoff");
6
+ const delta_1 = require("./delta");
7
+ const http_client_1 = require("./http-client");
8
+ const storage_budget_1 = require("./storage-budget");
9
+ const anchor_alarm_1 = require("./safety/anchor-alarm");
10
+ const telemetry_catalog_1 = require("./telemetry-catalog");
11
+ const telemetry_reducer_1 = require("./telemetry-reducer");
12
+ // Bound work waiting for flash writes in the shared Signal K process.
13
+ const MAXIMUM_PENDING_CAPTURE_EVENTS = 256;
14
+ const version_1 = require("./version");
15
+ var version_2 = require("./version");
16
+ Object.defineProperty(exports, "CONNECTOR_VERSION", { enumerable: true, get: function () { return version_2.CONNECTOR_VERSION; } });
17
+ const formatBytes = (bytes) => {
18
+ if (bytes < 1024) {
19
+ return `${bytes} B`;
20
+ }
21
+ if (bytes < 1024 * 1024) {
22
+ return `${(bytes / 1024).toFixed(1)} KiB`;
23
+ }
24
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
25
+ };
26
+ class ConnectorRuntime {
27
+ app;
28
+ store;
29
+ config;
30
+ bootId;
31
+ now;
32
+ random;
33
+ unsubscribes = [];
34
+ timer = null;
35
+ safetyTimer = null;
36
+ runtimeConfigurationTimer = null;
37
+ stopped = false;
38
+ retryAttempt = 0;
39
+ droppedEvents = 0;
40
+ suppressedEvents = 0;
41
+ transferNotice = null;
42
+ operatingMode = "active";
43
+ runtimeConfigurationPollMs = 60_000;
44
+ runtimeConfigurationState = "cached";
45
+ runtimeConfigurationUpdatedAt = null;
46
+ client;
47
+ autoSchedule;
48
+ anchorAlarm;
49
+ telemetryReducer;
50
+ monotonicNow;
51
+ filesystemCapacity = null;
52
+ nextCapacityRefreshAt = 0;
53
+ storageBudget;
54
+ emissionTimes = [];
55
+ captureTail = Promise.resolve();
56
+ pendingCaptureEvents = 0;
57
+ nextCaptureStatusAt = 0;
58
+ constructor(app, store, config, options = {}) {
59
+ this.app = app;
60
+ this.store = store;
61
+ this.config = config;
62
+ this.now = options.now ?? (() => new Date().toISOString());
63
+ this.bootId = (options.uuid ?? node_crypto_1.randomUUID)();
64
+ this.random = options.random ?? Math.random;
65
+ this.monotonicNow = options.monotonicNow ?? Date.now;
66
+ this.client = options.client ?? null;
67
+ this.autoSchedule = options.autoSchedule ?? true;
68
+ const startedAt = Date.parse(this.now());
69
+ this.anchorAlarm = new anchor_alarm_1.AnchorAlarmEngine(config.anchorAlarm, Number.isFinite(startedAt) ? startedAt : Date.now());
70
+ this.telemetryReducer = new telemetry_reducer_1.TelemetryReducer(config.adaptiveTelemetryEnabled, (0, telemetry_reducer_1.rulesForProfile)(config.operatingProfile));
71
+ this.storageBudget = (0, storage_budget_1.resolveStorageBudget)(this.storageBudgetPolicy(), { events: 0, bytes: 0, averageRecordBytes: null }, null);
72
+ }
73
+ async start() {
74
+ await this.store.load();
75
+ await this.refreshStorageBudget(true);
76
+ this.client ??= new http_client_1.AuroraBlueHttpClient(this.config.targetUrl, this.config.requestTimeoutMs, fetch, this.config.compressionEnabled
77
+ ? this.config.compressionThresholdBytes
78
+ : null);
79
+ let state = await this.store.summary();
80
+ if (state.device_id === null && this.config.pairingCode !== undefined) {
81
+ const pairing = await this.client.pair(this.config.pairingCode, this.config.deviceName, version_1.CONNECTOR_VERSION);
82
+ await this.store.setPairing(pairing);
83
+ state = await this.store.summary();
84
+ }
85
+ if (state.device_id === null) {
86
+ this.app.setPluginStatus("Unpaired - enter an Aurora Blue pairing code in plugin settings.");
87
+ return;
88
+ }
89
+ this.operatingMode = state.operating_mode;
90
+ this.configureSubscriptions();
91
+ this.publishAnchorState(this.anchorAlarm.snapshot());
92
+ this.startSafetyTimer();
93
+ await this.updateStatus();
94
+ this.scheduleRuntimeConfigurationPoll(0);
95
+ if (this.operatingMode !== "disabled") {
96
+ this.schedule(0);
97
+ }
98
+ }
99
+ async stop() {
100
+ this.stopped = true;
101
+ if (this.timer !== null) {
102
+ clearTimeout(this.timer);
103
+ this.timer = null;
104
+ }
105
+ if (this.safetyTimer !== null) {
106
+ clearInterval(this.safetyTimer);
107
+ this.safetyTimer = null;
108
+ }
109
+ if (this.runtimeConfigurationTimer !== null) {
110
+ clearTimeout(this.runtimeConfigurationTimer);
111
+ this.runtimeConfigurationTimer = null;
112
+ }
113
+ for (const unsubscribe of this.unsubscribes.splice(0)) {
114
+ unsubscribe();
115
+ }
116
+ await this.captureTail;
117
+ await this.store.summary();
118
+ }
119
+ async capture(delta, subscriptionScope = "self") {
120
+ if (this.stopped) {
121
+ return;
122
+ }
123
+ const receivedAt = this.now();
124
+ const events = (0, delta_1.eventsFromDelta)(delta, this.bootId, receivedAt, subscriptionScope);
125
+ // Safety observes current positions immediately, even when historical
126
+ // telemetry admission is saturated by slow storage.
127
+ for (const event of events) {
128
+ if (event.context === "vessels.self" &&
129
+ event.path === "navigation.position" &&
130
+ typeof event.value === "object" &&
131
+ "latitude" in event.value) {
132
+ const observedAt = Date.parse(event.received_at);
133
+ const transition = this.anchorAlarm.observe(event.value, Number.isFinite(observedAt) ? observedAt : Date.now());
134
+ this.publishAnchorTransition(transition);
135
+ }
136
+ }
137
+ if (this.operatingMode !== "active" || events.length === 0) {
138
+ return;
139
+ }
140
+ const admitted = events.slice(0, MAXIMUM_PENDING_CAPTURE_EVENTS - this.pendingCaptureEvents);
141
+ this.droppedEvents += events.length - admitted.length;
142
+ if (admitted.length === 0) {
143
+ return;
144
+ }
145
+ this.pendingCaptureEvents += admitted.length;
146
+ const current = this.captureTail.then(() => this.captureEvents(admitted));
147
+ this.captureTail = current.catch(() => undefined);
148
+ try {
149
+ await current;
150
+ }
151
+ finally {
152
+ this.pendingCaptureEvents -= admitted.length;
153
+ }
154
+ }
155
+ async captureEvents(events) {
156
+ let budget = await this.refreshStorageBudget();
157
+ for (const event of events) {
158
+ if (this.stopped || this.operatingMode !== "active") {
159
+ return;
160
+ }
161
+ if (!this.telemetryReducer.shouldEmit(event, budget.pressure)) {
162
+ this.suppressedEvents += 1;
163
+ continue;
164
+ }
165
+ const buffered = await this.store.append(event, budget.eventLimit, budget.byteLimit);
166
+ if (buffered === null) {
167
+ this.droppedEvents += 1;
168
+ }
169
+ else {
170
+ this.telemetryReducer.record(event);
171
+ this.recordEmission(event.received_at);
172
+ }
173
+ budget = await this.refreshStorageBudget();
174
+ }
175
+ const now = this.monotonicNow();
176
+ if (now >= this.nextCaptureStatusAt) {
177
+ this.nextCaptureStatusAt = now + 1_000;
178
+ await this.updateStatus();
179
+ }
180
+ }
181
+ async status() {
182
+ const budget = await this.refreshStorageBudget();
183
+ const state = await this.store.summary();
184
+ const emittedPerHour = this.emittedPerHour();
185
+ return {
186
+ paired: state.device_id !== null,
187
+ deviceId: state.device_id,
188
+ bufferedEvents: state.bufferedEvents,
189
+ acceptedThrough: state.accepted_through,
190
+ lastSuccessAt: state.last_success_at,
191
+ droppedEvents: this.droppedEvents,
192
+ suppressedEvents: this.suppressedEvents,
193
+ anchorAlarmState: this.anchorAlarm.snapshot().state,
194
+ bufferedBytes: budget.byteLimit - budget.remainingBytes,
195
+ bufferByteLimit: budget.byteLimit,
196
+ bufferEventLimit: budget.eventLimit,
197
+ bufferUtilization: budget.utilization,
198
+ bufferPressure: budget.pressure,
199
+ storageProfile: budget.requestedProfile,
200
+ resolvedStorageProfile: budget.resolvedProfile,
201
+ filesystemFreeBytes: budget.filesystem?.freeBytes ?? null,
202
+ estimatedRemainingBufferHours: emittedPerHour === null
203
+ ? null
204
+ : budget.remainingEvents / emittedPerHour,
205
+ operatingMode: state.operating_mode,
206
+ transferState: this.transferNotice !== null
207
+ ? "delayed"
208
+ : state.last_success_at === null
209
+ ? "waiting"
210
+ : "healthy",
211
+ transferMessage: this.transferNotice,
212
+ runtimeConfigurationState: this.runtimeConfigurationState,
213
+ runtimeConfigurationUpdatedAt: this.runtimeConfigurationUpdatedAt,
214
+ };
215
+ }
216
+ async flush() {
217
+ if (this.stopped ||
218
+ this.client === null ||
219
+ this.operatingMode === "disabled") {
220
+ return;
221
+ }
222
+ const state = await this.store.summary();
223
+ if (state.device_id === null || state.access_token === null) {
224
+ return;
225
+ }
226
+ const batch = await this.store.pendingBatch(this.config.batchSize);
227
+ if (batch === null) {
228
+ this.retryAttempt = 0;
229
+ await this.updateStatus();
230
+ if (this.operatingMode === "active") {
231
+ this.schedule(this.config.flushIntervalMs);
232
+ }
233
+ return;
234
+ }
235
+ try {
236
+ const acknowledgement = await this.client.sendBatch(batch, state.access_token);
237
+ await this.store.acknowledge(acknowledgement.accepted_through);
238
+ await this.applyRuntimeConfiguration(acknowledgement.runtime_configuration);
239
+ this.retryAttempt = 0;
240
+ this.transferNotice = null;
241
+ this.app.reportOutputMessages(batch.events.length);
242
+ await this.updateStatus();
243
+ const remaining = (await this.store.summary()).bufferedEvents;
244
+ if (this.operatingMode === "active") {
245
+ this.schedule(remaining > 0 ? 0 : this.config.flushIntervalMs);
246
+ }
247
+ else if (this.operatingMode === "standby" && remaining > 0) {
248
+ this.schedule(0);
249
+ }
250
+ }
251
+ catch (error) {
252
+ this.retryAttempt += 1;
253
+ const delay = (0, backoff_1.retryDelay)(this.retryAttempt, this.config.retryMinimumMs, this.config.retryMaximumMs, this.random);
254
+ const reason = error instanceof http_client_1.AuroraBlueHttpError
255
+ ? `HTTP ${error.status}`
256
+ : "target unavailable";
257
+ this.transferNotice = `Transfer delayed (${reason}) - retry in ${Math.ceil(delay / 1000)}s`;
258
+ await this.updateStatus();
259
+ this.schedule(delay);
260
+ }
261
+ }
262
+ configureSubscriptions() {
263
+ for (const unsubscribe of this.unsubscribes.splice(0)) {
264
+ unsubscribe();
265
+ }
266
+ if (this.operatingMode === "disabled") {
267
+ return;
268
+ }
269
+ if (this.operatingMode === "standby") {
270
+ if (this.config.anchorAlarm.enabled) {
271
+ this.subscribeToContext("vessels.self", ["navigation.position"], this.config.safetySamplePeriodMs);
272
+ }
273
+ return;
274
+ }
275
+ this.subscribeToContext("vessels.self", telemetry_catalog_1.SELF_SUBSCRIPTION_PATHS, this.config.samplePeriodMs);
276
+ if (this.config.aisEnabled) {
277
+ this.subscribeToContext("vessels.*", telemetry_catalog_1.AIS_SUBSCRIPTION_PATHS, this.config.aisSamplePeriodMs);
278
+ }
279
+ }
280
+ subscribeToContext(context, paths, period) {
281
+ this.app.subscriptionmanager.subscribe({
282
+ context,
283
+ subscribe: paths.map((path) => ({
284
+ path: path,
285
+ period: context === "vessels.self" &&
286
+ path === "navigation.position" &&
287
+ this.config.anchorAlarm.enabled
288
+ ? Math.min(this.config.samplePeriodMs, this.config.safetySamplePeriodMs)
289
+ : period,
290
+ })),
291
+ }, this.unsubscribes, (error) => {
292
+ this.app.setPluginError("Signal K subscription failed.");
293
+ this.app.error(error instanceof Error ? error.message : String(error));
294
+ }, (delta) => {
295
+ void this.capture(delta, context === "vessels.self" ? "self" : "ais").catch((error) => {
296
+ this.app.setPluginError("Signal K data could not be buffered safely.");
297
+ this.app.error(error instanceof Error ? error.message : String(error));
298
+ });
299
+ });
300
+ }
301
+ startSafetyTimer() {
302
+ if (this.safetyTimer !== null) {
303
+ clearInterval(this.safetyTimer);
304
+ this.safetyTimer = null;
305
+ }
306
+ if (!this.autoSchedule ||
307
+ !this.config.anchorAlarm.enabled ||
308
+ this.operatingMode === "disabled") {
309
+ return;
310
+ }
311
+ this.safetyTimer = setInterval(() => {
312
+ const now = Date.parse(this.now());
313
+ const transition = this.anchorAlarm.checkStale(Number.isFinite(now) ? now : Date.now());
314
+ this.publishAnchorTransition(transition);
315
+ }, Math.min(1_000, this.config.anchorAlarm.stalePositionMs));
316
+ }
317
+ publishAnchorTransition(transition) {
318
+ if (transition === null) {
319
+ return;
320
+ }
321
+ this.publishAnchorState(transition);
322
+ }
323
+ publishAnchorState(snapshot) {
324
+ if (snapshot.state === "inactive") {
325
+ return;
326
+ }
327
+ const alarm = snapshot.state === "alarm" || snapshot.state === "position-lost";
328
+ const warning = snapshot.state === "warning";
329
+ const message = snapshot.state === "position-lost"
330
+ ? "Anchor watch has lost position data."
331
+ : snapshot.state === "alarm"
332
+ ? "Vessel remains outside the configured anchor radius."
333
+ : snapshot.state === "warning"
334
+ ? "Vessel is outside the configured anchor radius."
335
+ : "Anchor watch is armed.";
336
+ const value = {
337
+ state: alarm ? "alarm" : warning ? "warn" : "normal",
338
+ method: alarm ? ["visual", "sound"] : ["visual"],
339
+ message,
340
+ data: {
341
+ distanceMeters: snapshot.distanceMeters,
342
+ lastPositionAt: snapshot.lastPositionAt,
343
+ },
344
+ };
345
+ this.app.handleMessage("aurora-blue-signalk-connector", {
346
+ updates: [
347
+ {
348
+ timestamp: snapshot.changedAt,
349
+ values: [
350
+ {
351
+ path: "notifications.navigation.anchor",
352
+ value,
353
+ },
354
+ ],
355
+ },
356
+ ],
357
+ });
358
+ }
359
+ schedule(delay) {
360
+ if (this.stopped ||
361
+ !this.autoSchedule ||
362
+ this.operatingMode === "disabled") {
363
+ return;
364
+ }
365
+ if (this.timer !== null) {
366
+ clearTimeout(this.timer);
367
+ }
368
+ this.timer = setTimeout(() => {
369
+ this.timer = null;
370
+ void this.flush();
371
+ }, delay);
372
+ }
373
+ scheduleRuntimeConfigurationPoll(delay) {
374
+ if (this.stopped || !this.autoSchedule) {
375
+ return;
376
+ }
377
+ if (this.runtimeConfigurationTimer !== null) {
378
+ clearTimeout(this.runtimeConfigurationTimer);
379
+ }
380
+ this.runtimeConfigurationTimer = setTimeout(() => {
381
+ this.runtimeConfigurationTimer = null;
382
+ void this.pollRuntimeConfiguration();
383
+ }, delay);
384
+ }
385
+ async pollRuntimeConfiguration() {
386
+ if (this.stopped || this.client === null) {
387
+ return;
388
+ }
389
+ const state = await this.store.summary();
390
+ if (state.access_token === null) {
391
+ return;
392
+ }
393
+ try {
394
+ const response = await this.client.fetchRuntimeConfiguration(state.access_token);
395
+ await this.applyRuntimeConfiguration(response.runtime_configuration);
396
+ }
397
+ catch {
398
+ this.runtimeConfigurationState = "unavailable";
399
+ }
400
+ await this.updateStatus();
401
+ this.scheduleRuntimeConfigurationPoll(this.runtimeConfigurationPollMs);
402
+ }
403
+ async applyRuntimeConfiguration(configuration) {
404
+ const previousMode = this.operatingMode;
405
+ this.runtimeConfigurationPollMs = configuration.poll_after_seconds * 1_000;
406
+ this.runtimeConfigurationState = "current";
407
+ this.runtimeConfigurationUpdatedAt = this.now();
408
+ if (configuration.operating_mode === previousMode) {
409
+ return;
410
+ }
411
+ this.operatingMode = configuration.operating_mode;
412
+ await this.store.setOperatingMode(configuration.operating_mode);
413
+ this.configureSubscriptions();
414
+ this.startSafetyTimer();
415
+ if (this.operatingMode === "disabled") {
416
+ if (this.timer !== null) {
417
+ clearTimeout(this.timer);
418
+ this.timer = null;
419
+ }
420
+ return;
421
+ }
422
+ const bufferedEvents = (await this.store.summary()).bufferedEvents;
423
+ if (this.operatingMode === "active" || bufferedEvents > 0) {
424
+ this.schedule(0);
425
+ }
426
+ }
427
+ async updateStatus() {
428
+ const status = await this.status();
429
+ const dropped = status.droppedEvents > 0 ? `, dropped ${status.droppedEvents}` : "";
430
+ const suppressed = status.suppressedEvents > 0
431
+ ? `, suppressed ${status.suppressedEvents}`
432
+ : "";
433
+ const lastSuccess = status.lastSuccessAt ?? "never";
434
+ const prefix = this.transferNotice ?? "Paired";
435
+ const utilization = (status.bufferUtilization * 100).toFixed(1);
436
+ const remaining = status.estimatedRemainingBufferHours === null
437
+ ? "estimating remaining time"
438
+ : `${status.estimatedRemainingBufferHours.toFixed(1)} h remaining`;
439
+ this.app.setPluginStatus(`${prefix} - mode ${status.operatingMode}, buffer ${status.bufferedEvents}/${status.bufferEventLimit} events, ${formatBytes(status.bufferedBytes)}/${formatBytes(status.bufferByteLimit)} (${utilization}%, ${status.bufferPressure}, ${status.storageProfile}:${status.resolvedStorageProfile}, ${remaining}), accepted ${status.acceptedThrough}${suppressed}${dropped}, anchor ${status.anchorAlarmState}, last transfer ${lastSuccess}.`);
440
+ }
441
+ storageBudgetPolicy() {
442
+ return {
443
+ requestedProfile: this.config.storageProfile,
444
+ customMaximumBytes: this.config.maxBufferBytes,
445
+ customReserveBytes: this.config.storageReserveBytes,
446
+ customMaximumFraction: this.config.storageMaximumFraction,
447
+ eventSafetyLimit: this.config.storageProfile === "custom"
448
+ ? this.config.maxBufferEvents
449
+ : 1_000_000,
450
+ };
451
+ }
452
+ async refreshStorageBudget(forceCapacity = false) {
453
+ const now = this.monotonicNow();
454
+ if (forceCapacity || now >= this.nextCapacityRefreshAt) {
455
+ this.filesystemCapacity = await this.store.filesystemCapacity();
456
+ this.nextCapacityRefreshAt = now + 60_000;
457
+ }
458
+ this.storageBudget = (0, storage_budget_1.resolveStorageBudget)(this.storageBudgetPolicy(), await this.store.journalMetrics(), this.filesystemCapacity);
459
+ return this.storageBudget;
460
+ }
461
+ recordEmission(receivedAt) {
462
+ const parsed = Date.parse(receivedAt);
463
+ const emittedAt = Number.isFinite(parsed) ? parsed : this.monotonicNow();
464
+ const previous = this.emissionTimes.at(-1);
465
+ if (previous !== undefined && emittedAt < previous) {
466
+ this.emissionTimes.length = 0;
467
+ }
468
+ this.emissionTimes.push(emittedAt);
469
+ const cutoff = emittedAt - 15 * 60_000;
470
+ while (this.emissionTimes.length > 1 &&
471
+ (this.emissionTimes[0] ?? emittedAt) < cutoff) {
472
+ this.emissionTimes.shift();
473
+ }
474
+ }
475
+ emittedPerHour() {
476
+ const first = this.emissionTimes[0];
477
+ const last = this.emissionTimes.at(-1);
478
+ if (first === undefined ||
479
+ last === undefined ||
480
+ this.emissionTimes.length < 2 ||
481
+ last - first < 60_000) {
482
+ return null;
483
+ }
484
+ return ((this.emissionTimes.length - 1) * 3_600_000) / (last - first);
485
+ }
486
+ }
487
+ exports.ConnectorRuntime = ConnectorRuntime;
@@ -0,0 +1,4 @@
1
+ import type { Delta } from "@signalk/server-api";
2
+ import type { EventInput } from "./types";
3
+ export type SubscriptionScope = "self" | "ais";
4
+ export declare const eventsFromDelta: (delta: Delta, bootId: string, receivedAt: string, subscriptionScope?: SubscriptionScope) => EventInput[];
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.eventsFromDelta = void 0;
4
+ const telemetry_catalog_1 = require("./telemetry-catalog");
5
+ const isoTimestamp = (value) => {
6
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
7
+ return null;
8
+ }
9
+ return new Date(value).toISOString();
10
+ };
11
+ const sourceReference = (update) => {
12
+ if (typeof update.$source === "string" && update.$source !== "") {
13
+ return update.$source.slice(0, 255);
14
+ }
15
+ if (typeof update.source === "string" && update.source !== "") {
16
+ return update.source.slice(0, 255);
17
+ }
18
+ if (update.source !== null && typeof update.source === "object") {
19
+ const source = update.source;
20
+ const parts = [source.label, source.src]
21
+ .filter((part) => typeof part === "string" || typeof part === "number")
22
+ .map(String);
23
+ if (parts.length > 0) {
24
+ return parts.join(".").slice(0, 255);
25
+ }
26
+ }
27
+ return null;
28
+ };
29
+ const validValue = (context, path, value) => {
30
+ const definition = (0, telemetry_catalog_1.telemetryDefinition)(context, path);
31
+ if (definition === null) {
32
+ return null;
33
+ }
34
+ if (definition.valueKind === "position") {
35
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
36
+ return null;
37
+ }
38
+ const position = value;
39
+ const latitude = position.latitude;
40
+ const longitude = position.longitude;
41
+ if (typeof latitude !== "number" ||
42
+ !Number.isFinite(latitude) ||
43
+ latitude < -90 ||
44
+ latitude > 90 ||
45
+ typeof longitude !== "number" ||
46
+ !Number.isFinite(longitude) ||
47
+ longitude < -180 ||
48
+ longitude > 180) {
49
+ return null;
50
+ }
51
+ return { latitude, longitude };
52
+ }
53
+ if (definition.valueKind === "number") {
54
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
55
+ }
56
+ if (definition.valueKind === "string") {
57
+ if (path === "mmsi" && !/^[0-9]{9}$/.test(String(value))) {
58
+ return null;
59
+ }
60
+ return typeof value === "string" && value.length > 0
61
+ ? value.slice(0, 255)
62
+ : null;
63
+ }
64
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
65
+ return null;
66
+ }
67
+ const shipType = value;
68
+ return typeof shipType.id === "number" &&
69
+ Number.isInteger(shipType.id) &&
70
+ shipType.id >= 0 &&
71
+ shipType.id <= 99 &&
72
+ typeof shipType.name === "string" &&
73
+ shipType.name.length > 0
74
+ ? { id: shipType.id, name: shipType.name.slice(0, 100) }
75
+ : null;
76
+ };
77
+ const eventContext = (delta, subscriptionScope) => {
78
+ if (subscriptionScope === "self") {
79
+ return "vessels.self";
80
+ }
81
+ const context = delta.context === undefined ? "" : String(delta.context);
82
+ return (0, telemetry_catalog_1.isAisContext)(context) ? context : null;
83
+ };
84
+ const flattenedStaticValues = (context, path, value) => {
85
+ if (context === "vessels.self" ||
86
+ path !== "" ||
87
+ value === null ||
88
+ typeof value !== "object" ||
89
+ Array.isArray(value)) {
90
+ return [{ path, value }];
91
+ }
92
+ const root = value;
93
+ const communication = root.communication;
94
+ const design = root.design;
95
+ const navigation = root.navigation;
96
+ return [
97
+ { path: "name", value: root.name },
98
+ { path: "mmsi", value: root.mmsi },
99
+ { path: "communication.callsignVhf", value: communication?.callsignVhf },
100
+ { path: "design.aisShipType", value: design?.aisShipType },
101
+ { path: "navigation.state", value: navigation?.state },
102
+ ];
103
+ };
104
+ const eventsFromDelta = (delta, bootId, receivedAt, subscriptionScope = "self") => {
105
+ const events = [];
106
+ const context = eventContext(delta, subscriptionScope);
107
+ if (context === null) {
108
+ return events;
109
+ }
110
+ for (const update of delta.updates) {
111
+ if (!("values" in update) || !Array.isArray(update.values)) {
112
+ continue;
113
+ }
114
+ for (const pathValue of update.values) {
115
+ if (pathValue.state?.timedOut === true) {
116
+ continue;
117
+ }
118
+ for (const candidate of flattenedStaticValues(context, String(pathValue.path), pathValue.value)) {
119
+ const value = validValue(context, candidate.path, candidate.value);
120
+ if (value === null) {
121
+ continue;
122
+ }
123
+ events.push({
124
+ boot_id: bootId,
125
+ context,
126
+ path: candidate.path,
127
+ value,
128
+ source_timestamp: isoTimestamp(update.timestamp),
129
+ received_at: receivedAt,
130
+ source: sourceReference(update),
131
+ });
132
+ }
133
+ }
134
+ }
135
+ return events;
136
+ };
137
+ exports.eventsFromDelta = eventsFromDelta;
@@ -0,0 +1,3 @@
1
+ import type { PositionValue } from "./types";
2
+ export declare const distanceMeters: (from: PositionValue, to: PositionValue) => number;
3
+ export declare const circularDistance: (from: number, to: number) => number;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.circularDistance = exports.distanceMeters = void 0;
4
+ const EARTH_RADIUS_METERS = 6_371_008.8;
5
+ const radians = (degrees) => (degrees * Math.PI) / 180;
6
+ const distanceMeters = (from, to) => {
7
+ const latitudeDelta = radians(to.latitude - from.latitude);
8
+ const longitudeDelta = radians(to.longitude - from.longitude);
9
+ const fromLatitude = radians(from.latitude);
10
+ const toLatitude = radians(to.latitude);
11
+ const haversine = Math.sin(latitudeDelta / 2) ** 2 +
12
+ Math.cos(fromLatitude) *
13
+ Math.cos(toLatitude) *
14
+ Math.sin(longitudeDelta / 2) ** 2;
15
+ return (2 *
16
+ EARTH_RADIUS_METERS *
17
+ Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)));
18
+ };
19
+ exports.distanceMeters = distanceMeters;
20
+ const circularDistance = (from, to) => {
21
+ const fullTurn = Math.PI * 2;
22
+ const difference = Math.abs(from - to) % fullTurn;
23
+ return Math.min(difference, fullTurn - difference);
24
+ };
25
+ exports.circularDistance = circularDistance;
@@ -0,0 +1,22 @@
1
+ import { type IngestAcknowledgement, type IngestBatch, type PairingResponse, type RuntimeConfigurationResponse } from "./types";
2
+ export interface AuroraBlueClient {
3
+ pair(pairingCode: string, deviceName: string, connectorVersion: string): Promise<PairingResponse>;
4
+ sendBatch(batch: IngestBatch, accessToken: string): Promise<IngestAcknowledgement>;
5
+ fetchRuntimeConfiguration(accessToken: string): Promise<RuntimeConfigurationResponse>;
6
+ }
7
+ export declare class AuroraBlueHttpError extends Error {
8
+ readonly status: number;
9
+ constructor(status: number, message: string);
10
+ }
11
+ export declare class AuroraBlueHttpClient implements AuroraBlueClient {
12
+ private readonly baseUrl;
13
+ private readonly timeoutMs;
14
+ private readonly fetchImplementation;
15
+ private readonly compressionThresholdBytes;
16
+ constructor(baseUrl: string, timeoutMs: number, fetchImplementation?: typeof fetch, compressionThresholdBytes?: number | null);
17
+ pair(pairingCode: string, deviceName: string, connectorVersion: string): Promise<PairingResponse>;
18
+ sendBatch(batch: IngestBatch, accessToken: string): Promise<IngestAcknowledgement>;
19
+ fetchRuntimeConfiguration(accessToken: string): Promise<RuntimeConfigurationResponse>;
20
+ private request;
21
+ private readJson;
22
+ }