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

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
@@ -49,7 +49,7 @@ Configure the plugin in the Signal K administration interface:
49
49
 
50
50
  Select Aurora Blue ABOARD Connector Status under Signal K Webapps for the cross-version runtime view. The page refreshes every five seconds and reads a plugin-local endpoint governed by the Signal K server's access controls. The response omits device identity and credentials, and the page does not send data to another host.
51
51
 
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.
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
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.
55
55
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0-dev.21
1
+ 0.2.0-dev.22
@@ -35,7 +35,7 @@ export declare const pluginSchema: {
35
35
  readonly targetUrl: {
36
36
  readonly type: "string";
37
37
  readonly title: "Aurora Blue target URL";
38
- readonly description: "Base URL of Aurora Blue Server or Aurora Blue Cloud. [Open connector status](/@aurora-blue/signalk-connector/).";
38
+ readonly description: "Base URL of Aurora Blue ABOARD Server or Aurora Blue ABOARD Cloud. Changing this address requires a new pairing code. [Open connector status](/@aurora-blue/signalk-connector/).";
39
39
  };
40
40
  readonly deviceName: {
41
41
  readonly type: "string";
@@ -45,7 +45,7 @@ export declare const pluginSchema: {
45
45
  readonly pairingCode: {
46
46
  readonly type: "string";
47
47
  readonly title: "Pairing code";
48
- readonly description: "Short-lived code from Aurora Blue. Cleared after successful pairing.";
48
+ readonly description: "Short-lived code from Aurora Blue ABOARD. Entering a code while already paired changes the server pairing. After the new code is accepted, all data buffered for the previous pairing is permanently discarded. The code is cleared after successful pairing.";
49
49
  };
50
50
  readonly allowInsecureHttp: {
51
51
  readonly type: "boolean";
@@ -154,7 +154,7 @@ exports.pluginSchema = {
154
154
  targetUrl: {
155
155
  type: "string",
156
156
  title: "Aurora Blue target URL",
157
- description: "Base URL of Aurora Blue Server or Aurora Blue Cloud. [Open connector status](/@aurora-blue/signalk-connector/).",
157
+ description: "Base URL of Aurora Blue ABOARD Server or Aurora Blue ABOARD Cloud. Changing this address requires a new pairing code. [Open connector status](/@aurora-blue/signalk-connector/).",
158
158
  },
159
159
  deviceName: {
160
160
  type: "string",
@@ -164,7 +164,7 @@ exports.pluginSchema = {
164
164
  pairingCode: {
165
165
  type: "string",
166
166
  title: "Pairing code",
167
- description: "Short-lived code from Aurora Blue. Cleared after successful pairing.",
167
+ description: "Short-lived code from Aurora Blue ABOARD. Entering a code while already paired changes the server pairing. After the new code is accepted, all data buffered for the previous pairing is permanently discarded. The code is cleared after successful pairing.",
168
168
  },
169
169
  allowInsecureHttp: {
170
170
  type: "boolean",
@@ -29,6 +29,7 @@ export declare class ConnectorRuntime {
29
29
  private droppedEvents;
30
30
  private suppressedEvents;
31
31
  private transferNotice;
32
+ private pairingState;
32
33
  private operatingMode;
33
34
  private runtimeConfigurationPollMs;
34
35
  private runtimeConfigurationState;
@@ -60,6 +61,7 @@ export declare class ConnectorRuntime {
60
61
  private schedule;
61
62
  private scheduleRuntimeConfigurationPoll;
62
63
  private pollRuntimeConfiguration;
64
+ private requirePairing;
63
65
  private applyRuntimeConfiguration;
64
66
  private updateStatus;
65
67
  private storageBudgetPolicy;
@@ -39,6 +39,7 @@ class ConnectorRuntime {
39
39
  droppedEvents = 0;
40
40
  suppressedEvents = 0;
41
41
  transferNotice = null;
42
+ pairingState = "unpaired";
42
43
  operatingMode = "active";
43
44
  runtimeConfigurationPollMs = 60_000;
44
45
  runtimeConfigurationState = "cached";
@@ -77,15 +78,53 @@ class ConnectorRuntime {
77
78
  ? this.config.compressionThresholdBytes
78
79
  : null);
79
80
  let state = await this.store.summary();
80
- if (state.device_id === null && this.config.pairingCode !== undefined) {
81
+ if (this.config.pairingCode !== undefined) {
81
82
  const pairing = await this.client.pair(this.config.pairingCode, this.config.deviceName, version_1.CONNECTOR_VERSION);
82
- await this.store.setPairing(pairing);
83
+ if (state.device_id === null && state.bufferedEvents === 0) {
84
+ await this.store.setPairing(pairing, this.config.targetUrl);
85
+ }
86
+ else {
87
+ await this.store.replacePairing(pairing, this.config.targetUrl);
88
+ }
83
89
  state = await this.store.summary();
84
90
  }
85
91
  if (state.device_id === null) {
92
+ this.pairingState = "unpaired";
86
93
  this.app.setPluginStatus("Unpaired - enter an Aurora Blue pairing code in plugin settings.");
87
94
  return;
88
95
  }
96
+ if (state.target_url !== null &&
97
+ state.target_url !== this.config.targetUrl) {
98
+ this.pairingState = "pairing-required";
99
+ this.configureSubscriptions();
100
+ this.publishAnchorState(this.anchorAlarm.snapshot());
101
+ this.startSafetyTimer();
102
+ await this.updateStatus();
103
+ return;
104
+ }
105
+ if (state.target_url === null) {
106
+ this.pairingState = "verifying";
107
+ try {
108
+ const response = await this.client.fetchRuntimeConfiguration(state.access_token);
109
+ await this.store.bindTarget(this.config.targetUrl);
110
+ this.pairingState = "paired";
111
+ this.operatingMode = state.operating_mode;
112
+ await this.applyRuntimeConfiguration(response.runtime_configuration);
113
+ state = await this.store.summary();
114
+ }
115
+ catch (error) {
116
+ this.runtimeConfigurationState = "unavailable";
117
+ if (error instanceof http_client_1.AuroraBlueHttpError && error.status === 401) {
118
+ this.pairingState = "pairing-required";
119
+ }
120
+ else {
121
+ this.scheduleRuntimeConfigurationPoll(this.config.retryMinimumMs);
122
+ }
123
+ await this.updateStatus();
124
+ return;
125
+ }
126
+ }
127
+ this.pairingState = "paired";
89
128
  this.operatingMode = state.operating_mode;
90
129
  this.configureSubscriptions();
91
130
  this.publishAnchorState(this.anchorAlarm.snapshot());
@@ -134,7 +173,9 @@ class ConnectorRuntime {
134
173
  this.publishAnchorTransition(transition);
135
174
  }
136
175
  }
137
- if (this.operatingMode !== "active" || events.length === 0) {
176
+ if (this.pairingState !== "paired" ||
177
+ this.operatingMode !== "active" ||
178
+ events.length === 0) {
138
179
  return;
139
180
  }
140
181
  const admitted = events.slice(0, MAXIMUM_PENDING_CAPTURE_EVENTS - this.pendingCaptureEvents);
@@ -183,8 +224,12 @@ class ConnectorRuntime {
183
224
  const state = await this.store.summary();
184
225
  const emittedPerHour = this.emittedPerHour();
185
226
  return {
186
- paired: state.device_id !== null,
227
+ paired: state.device_id !== null && this.pairingState === "paired",
228
+ pairingState: this.pairingState,
187
229
  deviceId: state.device_id,
230
+ pairedTargetUrl: state.target_url,
231
+ lastPairingChangeAt: state.last_pairing_change_at,
232
+ discardedEventsOnLastPairing: state.last_pairing_discarded_events,
188
233
  bufferedEvents: state.bufferedEvents,
189
234
  acceptedThrough: state.accepted_through,
190
235
  lastSuccessAt: state.last_success_at,
@@ -203,11 +248,13 @@ class ConnectorRuntime {
203
248
  ? null
204
249
  : budget.remainingEvents / emittedPerHour,
205
250
  operatingMode: state.operating_mode,
206
- transferState: this.transferNotice !== null
207
- ? "delayed"
208
- : state.last_success_at === null
209
- ? "waiting"
210
- : "healthy",
251
+ transferState: this.pairingState !== "paired"
252
+ ? "waiting"
253
+ : this.transferNotice !== null
254
+ ? "delayed"
255
+ : state.last_success_at === null
256
+ ? "waiting"
257
+ : "healthy",
211
258
  transferMessage: this.transferNotice,
212
259
  runtimeConfigurationState: this.runtimeConfigurationState,
213
260
  runtimeConfigurationUpdatedAt: this.runtimeConfigurationUpdatedAt,
@@ -249,6 +296,10 @@ class ConnectorRuntime {
249
296
  }
250
297
  }
251
298
  catch (error) {
299
+ if (error instanceof http_client_1.AuroraBlueHttpError && error.status === 401) {
300
+ await this.requirePairing();
301
+ return;
302
+ }
252
303
  this.retryAttempt += 1;
253
304
  const delay = (0, backoff_1.retryDelay)(this.retryAttempt, this.config.retryMinimumMs, this.config.retryMaximumMs, this.random);
254
305
  const reason = error instanceof http_client_1.AuroraBlueHttpError
@@ -266,6 +317,12 @@ class ConnectorRuntime {
266
317
  if (this.operatingMode === "disabled") {
267
318
  return;
268
319
  }
320
+ if (this.pairingState !== "paired") {
321
+ if (this.config.anchorAlarm.enabled) {
322
+ this.subscribeToContext("vessels.self", ["navigation.position"], this.config.safetySamplePeriodMs);
323
+ }
324
+ return;
325
+ }
269
326
  if (this.operatingMode === "standby") {
270
327
  if (this.config.anchorAlarm.enabled) {
271
328
  this.subscribeToContext("vessels.self", ["navigation.position"], this.config.safetySamplePeriodMs);
@@ -390,16 +447,62 @@ class ConnectorRuntime {
390
447
  if (state.access_token === null) {
391
448
  return;
392
449
  }
450
+ if (state.target_url !== null &&
451
+ state.target_url !== this.config.targetUrl) {
452
+ await this.requirePairing();
453
+ return;
454
+ }
393
455
  try {
394
456
  const response = await this.client.fetchRuntimeConfiguration(state.access_token);
457
+ if (state.target_url === null) {
458
+ await this.store.bindTarget(this.config.targetUrl);
459
+ }
460
+ this.pairingState = "paired";
395
461
  await this.applyRuntimeConfiguration(response.runtime_configuration);
462
+ if (this.unsubscribes.length === 0) {
463
+ this.operatingMode = response.runtime_configuration.operating_mode;
464
+ this.configureSubscriptions();
465
+ this.publishAnchorState(this.anchorAlarm.snapshot());
466
+ this.startSafetyTimer();
467
+ if (this.operatingMode !== "disabled") {
468
+ this.schedule(0);
469
+ }
470
+ }
396
471
  }
397
- catch {
472
+ catch (error) {
398
473
  this.runtimeConfigurationState = "unavailable";
474
+ if (error instanceof http_client_1.AuroraBlueHttpError && error.status === 401) {
475
+ await this.requirePairing();
476
+ return;
477
+ }
399
478
  }
400
479
  await this.updateStatus();
401
480
  this.scheduleRuntimeConfigurationPoll(this.runtimeConfigurationPollMs);
402
481
  }
482
+ async requirePairing() {
483
+ this.pairingState = "pairing-required";
484
+ this.transferNotice = null;
485
+ this.runtimeConfigurationState = "unavailable";
486
+ if (this.timer !== null) {
487
+ clearTimeout(this.timer);
488
+ this.timer = null;
489
+ }
490
+ if (this.runtimeConfigurationTimer !== null) {
491
+ clearTimeout(this.runtimeConfigurationTimer);
492
+ this.runtimeConfigurationTimer = null;
493
+ }
494
+ if (this.safetyTimer !== null) {
495
+ clearInterval(this.safetyTimer);
496
+ this.safetyTimer = null;
497
+ }
498
+ for (const unsubscribe of this.unsubscribes.splice(0)) {
499
+ unsubscribe();
500
+ }
501
+ this.configureSubscriptions();
502
+ this.publishAnchorState(this.anchorAlarm.snapshot());
503
+ this.startSafetyTimer();
504
+ await this.updateStatus();
505
+ }
403
506
  async applyRuntimeConfiguration(configuration) {
404
507
  const previousMode = this.operatingMode;
405
508
  this.runtimeConfigurationPollMs = configuration.poll_after_seconds * 1_000;
@@ -431,7 +534,13 @@ class ConnectorRuntime {
431
534
  ? `, suppressed ${status.suppressedEvents}`
432
535
  : "";
433
536
  const lastSuccess = status.lastSuccessAt ?? "never";
434
- const prefix = this.transferNotice ?? "Paired";
537
+ const prefix = this.pairingState === "pairing-required"
538
+ ? "Pairing required - enter a new code; buffered data is discarded after successful pairing"
539
+ : this.pairingState === "verifying"
540
+ ? "Verifying pairing"
541
+ : this.pairingState === "unpaired"
542
+ ? "Unpaired"
543
+ : (this.transferNotice ?? "Paired");
435
544
  const utilization = (status.bufferUtilization * 100).toFixed(1);
436
545
  const remaining = status.estimatedRemainingBufferHours === null
437
546
  ? "estimating remaining time"
@@ -25,7 +25,9 @@ export declare class StateStore {
25
25
  }>;
26
26
  journalMetrics(): Promise<JournalMetrics>;
27
27
  filesystemCapacity(): Promise<FilesystemCapacity | null>;
28
- setPairing(pairing: PairingResponse): Promise<void>;
28
+ setPairing(pairing: PairingResponse, targetUrl: string): Promise<void>;
29
+ bindTarget(targetUrl: string): Promise<void>;
30
+ replacePairing(pairing: PairingResponse, targetUrl: string): Promise<number>;
29
31
  setOperatingMode(operatingMode: OperatingMode): Promise<void>;
30
32
  append(event: EventInput, eventLimit: number, byteLimit?: number): Promise<BufferedEvent | null>;
31
33
  pendingBatch(maximumEvents: number): Promise<IngestBatch | null>;
@@ -39,6 +41,8 @@ export declare class StateStore {
39
41
  private migrateLegacy;
40
42
  private preserveLegacyBackup;
41
43
  private removeStaleTemporaryFiles;
44
+ private journalDirectory;
45
+ private removeObsoleteJournals;
42
46
  private persistMetadata;
43
47
  private exists;
44
48
  private readFilesystemCapacity;
@@ -10,16 +10,21 @@ const METADATA_FILE = "metadata.json";
10
10
  const LEGACY_STATE_FILE = "state.json";
11
11
  const LEGACY_BACKUP_FILE = "state.v1.backup.json";
12
12
  const SEGMENT_PATTERN = /^segment-(\d{12})\.jsonl$/;
13
+ const JOURNAL_DIRECTORY_PATTERN = /^journal-([a-zA-Z0-9-]+)$/;
13
14
  const initialState = () => ({
14
- version: 2,
15
+ version: 3,
15
16
  protocol_version: types_1.PROTOCOL_VERSION,
16
17
  device_id: null,
17
18
  access_token: null,
19
+ target_url: null,
20
+ journal_generation: null,
18
21
  next_sequence: 1,
19
22
  accepted_through: 0,
20
23
  events: [],
21
24
  pending_batch: null,
22
25
  last_success_at: null,
26
+ last_pairing_change_at: null,
27
+ last_pairing_discarded_events: 0,
23
28
  operating_mode: "active",
24
29
  });
25
30
  const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
@@ -27,12 +32,18 @@ const isSupportedProtocolVersion = (value) => typeof value === "string" &&
27
32
  types_1.SUPPORTED_PROTOCOL_VERSIONS.some((version) => version === value);
28
33
  const isOperatingMode = (value) => value === "active" || value === "standby" || value === "disabled";
29
34
  const assertState = (state) => {
30
- if (state.version !== 2 ||
35
+ if (state.version !== 3 ||
31
36
  !isSupportedProtocolVersion(state.protocol_version) ||
32
37
  !isPositiveInteger(state.accepted_through) ||
33
38
  !isPositiveInteger(state.next_sequence) ||
34
39
  state.next_sequence < 1 ||
35
40
  !Array.isArray(state.events) ||
41
+ (state.target_url !== null && typeof state.target_url !== "string") ||
42
+ (state.journal_generation !== null &&
43
+ !/^[a-zA-Z0-9-]+$/.test(state.journal_generation)) ||
44
+ (state.last_pairing_change_at !== null &&
45
+ typeof state.last_pairing_change_at !== "string") ||
46
+ !isPositiveInteger(state.last_pairing_discarded_events) ||
36
47
  !isOperatingMode(state.operating_mode) ||
37
48
  (state.device_id === null) !== (state.access_token === null)) {
38
49
  throw new Error("Aurora Blue connector state is invalid.");
@@ -63,7 +74,11 @@ const assertState = (state) => {
63
74
  const assertLegacyState = (state) => {
64
75
  assertState({
65
76
  ...state,
66
- version: 2,
77
+ version: 3,
78
+ target_url: null,
79
+ journal_generation: null,
80
+ last_pairing_change_at: null,
81
+ last_pairing_discarded_events: 0,
67
82
  operating_mode: isOperatingMode(state.operating_mode)
68
83
  ? state.operating_mode
69
84
  : "active",
@@ -72,14 +87,18 @@ const assertLegacyState = (state) => {
72
87
  const checksum = (event) => (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(event)).digest("hex");
73
88
  const encodeRecord = (event) => `${JSON.stringify({ checksum: checksum(event), event })}\n`;
74
89
  const metadataFrom = (state) => ({
75
- version: 2,
90
+ version: 3,
76
91
  protocol_version: state.protocol_version,
77
92
  device_id: state.device_id,
78
93
  access_token: state.access_token,
94
+ target_url: state.target_url,
95
+ journal_generation: state.journal_generation,
79
96
  next_sequence: state.next_sequence,
80
97
  accepted_through: state.accepted_through,
81
98
  pending_batch: state.pending_batch,
82
99
  last_success_at: state.last_success_at,
100
+ last_pairing_change_at: state.last_pairing_change_at,
101
+ last_pairing_discarded_events: state.last_pairing_discarded_events,
83
102
  operating_mode: state.operating_mode,
84
103
  });
85
104
  class StateStore {
@@ -116,7 +135,7 @@ class StateStore {
116
135
  }
117
136
  }
118
137
  const metadata = await this.readMetadata();
119
- const journal = await this.readJournal(metadata.accepted_through);
138
+ const journal = await this.readJournal(metadata.accepted_through, metadata.journal_generation);
120
139
  this.state = {
121
140
  ...metadata,
122
141
  protocol_version: types_1.PROTOCOL_VERSION,
@@ -128,9 +147,8 @@ class StateStore {
128
147
  await this.removeAcknowledgedSegments(this.state.accepted_through);
129
148
  await this.preserveLegacyBackup();
130
149
  await this.removeStaleTemporaryFiles();
131
- if (metadata.protocol_version !== types_1.PROTOCOL_VERSION) {
132
- await this.persistMetadata();
133
- }
150
+ await this.removeObsoleteJournals(metadata.journal_generation);
151
+ await this.persistMetadata();
134
152
  }
135
153
  async snapshot() {
136
154
  await this.tail;
@@ -155,17 +173,56 @@ class StateStore {
155
173
  await this.tail;
156
174
  return this.capacityProvider();
157
175
  }
158
- async setPairing(pairing) {
176
+ async setPairing(pairing, targetUrl) {
159
177
  await this.serialized(async () => {
160
178
  if (this.state.device_id !== null || this.state.events.length > 0) {
161
179
  throw new Error("Connector is already paired or contains buffered events.");
162
180
  }
163
181
  this.state.device_id = pairing.device_id;
164
182
  this.state.access_token = pairing.access_token;
183
+ this.state.target_url = targetUrl;
184
+ this.state.last_pairing_change_at = this.now();
185
+ this.state.last_pairing_discarded_events = 0;
165
186
  this.state.operating_mode = pairing.runtime_configuration.operating_mode;
166
187
  await this.persistMetadata();
167
188
  });
168
189
  }
190
+ async bindTarget(targetUrl) {
191
+ await this.serialized(async () => {
192
+ if (this.state.device_id === null ||
193
+ this.state.access_token === null ||
194
+ this.state.target_url !== null) {
195
+ throw new Error("Connector target binding is not available.");
196
+ }
197
+ this.state.target_url = targetUrl;
198
+ await this.persistMetadata();
199
+ });
200
+ }
201
+ async replacePairing(pairing, targetUrl) {
202
+ let discardedEvents = 0;
203
+ await this.serialized(async () => {
204
+ discardedEvents = this.state.events.length;
205
+ const journalGeneration = this.uuid();
206
+ const nextJournalDirectory = this.journalDirectory(journalGeneration);
207
+ await (0, promises_1.mkdir)(nextJournalDirectory, { recursive: false, mode: 0o700 });
208
+ await (0, promises_1.chmod)(nextJournalDirectory, 0o700);
209
+ this.state = {
210
+ ...initialState(),
211
+ device_id: pairing.device_id,
212
+ access_token: pairing.access_token,
213
+ target_url: targetUrl,
214
+ journal_generation: journalGeneration,
215
+ last_pairing_change_at: this.now(),
216
+ last_pairing_discarded_events: discardedEvents,
217
+ operating_mode: pairing.runtime_configuration.operating_mode,
218
+ };
219
+ this.segments = [];
220
+ this.journalBytes = 0;
221
+ await this.persistMetadata();
222
+ await this.removeObsoleteJournals(journalGeneration).catch(() => undefined);
223
+ });
224
+ return discardedEvents;
225
+ }
169
226
  async setOperatingMode(operatingMode) {
170
227
  await this.serialized(async () => {
171
228
  this.state.operating_mode = operatingMode;
@@ -262,7 +319,7 @@ class StateStore {
262
319
  if (segment === undefined ||
263
320
  (segment.bytes > 0 &&
264
321
  segment.bytes + payloadBytes > this.segmentMaximumBytes)) {
265
- const path = (0, node_path_1.join)(this.directory, `segment-${String(event.sequence).padStart(12, "0")}.jsonl`);
322
+ const path = (0, node_path_1.join)(this.journalDirectory(this.state.journal_generation), `segment-${String(event.sequence).padStart(12, "0")}.jsonl`);
266
323
  segment = {
267
324
  path,
268
325
  firstSequence: event.sequence,
@@ -297,22 +354,41 @@ class StateStore {
297
354
  const parsedMetadata = parsed;
298
355
  const metadata = {
299
356
  ...parsedMetadata,
357
+ version: 3,
358
+ target_url: parsedMetadata.version === 3 ? parsedMetadata.target_url : null,
359
+ journal_generation: parsedMetadata.version === 3 ? parsedMetadata.journal_generation : null,
360
+ last_pairing_change_at: parsedMetadata.version === 3
361
+ ? parsedMetadata.last_pairing_change_at
362
+ : null,
363
+ last_pairing_discarded_events: parsedMetadata.version === 3
364
+ ? parsedMetadata.last_pairing_discarded_events
365
+ : 0,
300
366
  operating_mode: isOperatingMode(parsedMetadata.operating_mode)
301
367
  ? parsedMetadata.operating_mode
302
368
  : "active",
303
369
  };
304
- if (metadata.version !== 2 ||
370
+ if (![2, 3].includes(parsedMetadata.version) ||
305
371
  !isSupportedProtocolVersion(metadata.protocol_version) ||
306
372
  !isPositiveInteger(metadata.accepted_through) ||
307
373
  !isPositiveInteger(metadata.next_sequence) ||
308
374
  !isOperatingMode(metadata.operating_mode) ||
375
+ (metadata.target_url !== null &&
376
+ typeof metadata.target_url !== "string") ||
377
+ (metadata.journal_generation !== null &&
378
+ !/^[a-zA-Z0-9-]+$/.test(metadata.journal_generation)) ||
379
+ (metadata.last_pairing_change_at !== null &&
380
+ typeof metadata.last_pairing_change_at !== "string") ||
381
+ !isPositiveInteger(metadata.last_pairing_discarded_events) ||
309
382
  (metadata.device_id === null) !== (metadata.access_token === null)) {
310
383
  throw new Error("Aurora Blue connector metadata is invalid.");
311
384
  }
312
385
  return metadata;
313
386
  }
314
- async readJournal(acceptedThrough) {
315
- const names = (await (0, promises_1.readdir)(this.directory))
387
+ async readJournal(acceptedThrough, journalGeneration) {
388
+ const directory = this.journalDirectory(journalGeneration);
389
+ await (0, promises_1.mkdir)(directory, { recursive: true, mode: 0o700 });
390
+ await (0, promises_1.chmod)(directory, 0o700);
391
+ const names = (await (0, promises_1.readdir)(directory))
316
392
  .filter((name) => SEGMENT_PATTERN.test(name))
317
393
  .sort();
318
394
  const events = [];
@@ -323,7 +399,7 @@ class StateStore {
323
399
  if (match === null) {
324
400
  continue;
325
401
  }
326
- const path = (0, node_path_1.join)(this.directory, name);
402
+ const path = (0, node_path_1.join)(directory, name);
327
403
  const segmentEvents = await this.readSegment(path);
328
404
  const details = await (0, promises_1.stat)(path);
329
405
  const firstSequence = Number(match[1]);
@@ -404,8 +480,12 @@ class StateStore {
404
480
  this.journalBytes = 0;
405
481
  this.state = {
406
482
  ...legacy,
407
- version: 2,
483
+ version: 3,
408
484
  protocol_version: types_1.PROTOCOL_VERSION,
485
+ target_url: null,
486
+ journal_generation: null,
487
+ last_pairing_change_at: null,
488
+ last_pairing_discarded_events: 0,
409
489
  operating_mode: isOperatingMode(legacy.operating_mode)
410
490
  ? legacy.operating_mode
411
491
  : "active",
@@ -431,6 +511,23 @@ class StateStore {
431
511
  }
432
512
  }
433
513
  }
514
+ journalDirectory(generation) {
515
+ return generation === null
516
+ ? this.directory
517
+ : (0, node_path_1.join)(this.directory, `journal-${generation}`);
518
+ }
519
+ async removeObsoleteJournals(currentGeneration) {
520
+ const currentDirectory = currentGeneration === null ? null : `journal-${currentGeneration}`;
521
+ for (const name of await (0, promises_1.readdir)(this.directory)) {
522
+ if (SEGMENT_PATTERN.test(name) && currentGeneration !== null) {
523
+ await (0, promises_1.unlink)((0, node_path_1.join)(this.directory, name));
524
+ continue;
525
+ }
526
+ if (JOURNAL_DIRECTORY_PATTERN.test(name) && name !== currentDirectory) {
527
+ await (0, promises_1.rm)((0, node_path_1.join)(this.directory, name), { recursive: true, force: true });
528
+ }
529
+ }
530
+ }
434
531
  async persistMetadata() {
435
532
  const temporaryPath = (0, node_path_1.join)(this.directory, `.metadata-${this.uuid()}.tmp`);
436
533
  const payload = `${JSON.stringify(metadataFrom(this.state))}\n`;
@@ -3,6 +3,7 @@ export declare const PROTOCOL_VERSION: "1.1";
3
3
  export declare const SUPPORTED_PROTOCOL_VERSIONS: readonly ["1.0", "1.1"];
4
4
  export type ProtocolVersion = (typeof SUPPORTED_PROTOCOL_VERSIONS)[number];
5
5
  export type OperatingMode = "active" | "standby" | "disabled";
6
+ export type PairingState = "unpaired" | "verifying" | "paired" | "pairing-required";
6
7
  export interface RuntimeConfiguration {
7
8
  version: number;
8
9
  operating_mode: OperatingMode;
@@ -67,31 +68,43 @@ export interface RuntimeConfigurationResponse {
67
68
  server_time: string;
68
69
  }
69
70
  export interface PersistedState {
70
- version: 2;
71
+ version: 3;
71
72
  protocol_version: ProtocolVersion;
72
73
  device_id: string | null;
73
74
  access_token: string | null;
75
+ target_url: string | null;
76
+ journal_generation: string | null;
74
77
  next_sequence: number;
75
78
  accepted_through: number;
76
79
  events: BufferedEvent[];
77
80
  pending_batch: IngestBatch | null;
78
81
  last_success_at: string | null;
82
+ last_pairing_change_at: string | null;
83
+ last_pairing_discarded_events: number;
79
84
  operating_mode: OperatingMode;
80
85
  }
81
86
  export interface PersistedMetadata {
82
- version: 2;
87
+ version: 3;
83
88
  protocol_version: ProtocolVersion;
84
89
  device_id: string | null;
85
90
  access_token: string | null;
91
+ target_url: string | null;
92
+ journal_generation: string | null;
86
93
  next_sequence: number;
87
94
  accepted_through: number;
88
95
  pending_batch: IngestBatch | null;
89
96
  last_success_at: string | null;
97
+ last_pairing_change_at: string | null;
98
+ last_pairing_discarded_events: number;
90
99
  operating_mode: OperatingMode;
91
100
  }
92
101
  export interface ConnectorStatus {
93
102
  paired: boolean;
103
+ pairingState: PairingState;
94
104
  deviceId: string | null;
105
+ pairedTargetUrl: string | null;
106
+ lastPairingChangeAt: string | null;
107
+ discardedEventsOnLastPairing: number;
95
108
  bufferedEvents: number;
96
109
  acceptedThrough: number;
97
110
  lastSuccessAt: string | null;
@@ -1 +1 @@
1
- export declare const CONNECTOR_VERSION = "0.2.0-dev.21";
1
+ export declare const CONNECTOR_VERSION = "0.2.0-dev.22";
@@ -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.21";
4
+ exports.CONNECTOR_VERSION = "0.2.0-dev.22";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurora-blue/signalk-connector",
3
- "version": "0.2.0-dev.21",
3
+ "version": "0.2.0-dev.22",
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",
package/public/index.html CHANGED
@@ -72,7 +72,7 @@
72
72
  aria-label="Connector summary"
73
73
  data-i18n-aria-label="summary.label"
74
74
  >
75
- <article class="card">
75
+ <article id="pairing-card" class="card">
76
76
  <span class="label" data-i18n="summary.pairing">Pairing</span>
77
77
  <strong id="pairing" data-i18n="status.unknown">Unknown</strong>
78
78
  <span
@@ -38,6 +38,9 @@
38
38
  "status.updated": "Aktualisiert {time}",
39
39
  "status.paired": "Gekoppelt",
40
40
  "status.unpaired": "Nicht gekoppelt",
41
+ "status.pairing_required_detail": "Geben Sie in der Konfiguration einen neuen Kopplungscode ein. Nach erfolgreicher Kopplung wird der Offline-Puffer des vorherigen Servers dauerhaft gelöscht.",
42
+ "status.pairing_verifying_detail": "Die bestehende Kopplung wird für diesen Server geprüft.",
43
+ "status.pairing_discarded": "Beim letzten Serverwechsel wurden {count} gepufferte Ereignisse gelöscht.",
41
44
  "status.runtime_configuration": "Laufzeitkonfiguration {state}",
42
45
  "status.last_success": "Letzter Erfolg: {time}",
43
46
  "status.events": "{current} / {limit} Ereignisse",
@@ -57,6 +60,10 @@
57
60
  "value.configuration.cached": "zwischengespeichert",
58
61
  "value.configuration.current": "aktuell",
59
62
  "value.configuration.unavailable": "nicht verfügbar",
63
+ "value.pairing.unpaired": "Nicht gekoppelt",
64
+ "value.pairing.verifying": "Wird geprüft",
65
+ "value.pairing.paired": "Gekoppelt",
66
+ "value.pairing.pairing_required": "Kopplung erforderlich",
60
67
  "value.transfer.waiting": "Wartet",
61
68
  "value.transfer.healthy": "Fehlerfrei",
62
69
  "value.transfer.delayed": "Verzögert",
@@ -38,6 +38,9 @@
38
38
  "status.updated": "Updated {time}",
39
39
  "status.paired": "Paired",
40
40
  "status.unpaired": "Unpaired",
41
+ "status.pairing_required_detail": "Enter a new pairing code in the configuration. After successful pairing, the offline buffer for the previous server is permanently discarded.",
42
+ "status.pairing_verifying_detail": "The existing pairing is being verified for this server.",
43
+ "status.pairing_discarded": "The last server change discarded {count} buffered events.",
41
44
  "status.runtime_configuration": "Runtime configuration {state}",
42
45
  "status.last_success": "Last success: {time}",
43
46
  "status.events": "{current} / {limit} events",
@@ -57,6 +60,10 @@
57
60
  "value.configuration.cached": "cached",
58
61
  "value.configuration.current": "current",
59
62
  "value.configuration.unavailable": "unavailable",
63
+ "value.pairing.unpaired": "Unpaired",
64
+ "value.pairing.verifying": "Verifying",
65
+ "value.pairing.paired": "Paired",
66
+ "value.pairing.pairing_required": "Pairing Required",
60
67
  "value.transfer.waiting": "Waiting",
61
68
  "value.transfer.healthy": "Healthy",
62
69
  "value.transfer.delayed": "Delayed",
@@ -38,6 +38,9 @@
38
38
  "status.updated": "Actualizado {time}",
39
39
  "status.paired": "Emparejado",
40
40
  "status.unpaired": "No emparejado",
41
+ "status.pairing_required_detail": "Introduzca un nuevo código de emparejamiento en la configuración. Después de emparejar correctamente, el búfer sin conexión del servidor anterior se elimina de forma permanente.",
42
+ "status.pairing_verifying_detail": "Se está verificando el emparejamiento existente para este servidor.",
43
+ "status.pairing_discarded": "El último cambio de servidor eliminó {count} eventos almacenados en el búfer.",
41
44
  "status.runtime_configuration": "Configuración de ejecución {state}",
42
45
  "status.last_success": "Último éxito: {time}",
43
46
  "status.events": "{current} / {limit} eventos",
@@ -57,6 +60,10 @@
57
60
  "value.configuration.cached": "en caché",
58
61
  "value.configuration.current": "actual",
59
62
  "value.configuration.unavailable": "no disponible",
63
+ "value.pairing.unpaired": "No emparejado",
64
+ "value.pairing.verifying": "Verificando",
65
+ "value.pairing.paired": "Emparejado",
66
+ "value.pairing.pairing_required": "Emparejamiento necesario",
60
67
  "value.transfer.waiting": "Esperando",
61
68
  "value.transfer.healthy": "Correcta",
62
69
  "value.transfer.delayed": "Retrasada",
@@ -38,6 +38,9 @@
38
38
  "status.updated": "Mis à jour {time}",
39
39
  "status.paired": "Appairé",
40
40
  "status.unpaired": "Non appairé",
41
+ "status.pairing_required_detail": "Saisissez un nouveau code d'appairage dans la configuration. Après un appairage réussi, le tampon hors ligne de l'ancien serveur est définitivement supprimé.",
42
+ "status.pairing_verifying_detail": "L'appairage existant est en cours de vérification pour ce serveur.",
43
+ "status.pairing_discarded": "Le dernier changement de serveur a supprimé {count} événements mis en mémoire tampon.",
41
44
  "status.runtime_configuration": "Configuration d'exécution {state}",
42
45
  "status.last_success": "Dernier succès : {time}",
43
46
  "status.events": "{current} / {limit} événements",
@@ -57,6 +60,10 @@
57
60
  "value.configuration.cached": "en cache",
58
61
  "value.configuration.current": "actuelle",
59
62
  "value.configuration.unavailable": "indisponible",
63
+ "value.pairing.unpaired": "Non appairé",
64
+ "value.pairing.verifying": "Vérification",
65
+ "value.pairing.paired": "Appairé",
66
+ "value.pairing.pairing_required": "Appairage requis",
60
67
  "value.transfer.waiting": "En attente",
61
68
  "value.transfer.healthy": "Opérationnel",
62
69
  "value.transfer.delayed": "Retardé",
package/public/status.css CHANGED
@@ -154,6 +154,10 @@ h2 {
154
154
  min-height: 9.5rem;
155
155
  padding: 1.5rem;
156
156
  }
157
+
158
+ .card.attention {
159
+ border-color: var(--aboard-warning);
160
+ }
157
161
  .card strong {
158
162
  margin-top: 1.15rem;
159
163
  font-size: var(--aboard-font-section);
package/public/status.js CHANGED
@@ -119,13 +119,37 @@ const render = (payload) => {
119
119
  translatedValue("mode", status.operatingMode),
120
120
  toneForMode(status.operatingMode),
121
121
  );
122
- setText("pairing", t(status.paired ? "status.paired" : "status.unpaired"));
123
- setText(
124
- "configuration-state",
125
- t("status.runtime_configuration", {
126
- state: translatedValue("configuration", status.runtimeConfigurationState),
127
- }),
122
+ const pairingState =
123
+ status.pairingState ?? (status.paired ? "paired" : "unpaired");
124
+ const pairingCard = element("pairing-card");
125
+ pairingCard.classList.toggle(
126
+ "attention",
127
+ pairingState === "pairing-required",
128
128
  );
129
+ setText("pairing", translatedValue("pairing", pairingState));
130
+
131
+ if (pairingState === "pairing-required") {
132
+ setText("configuration-state", t("status.pairing_required_detail"));
133
+ } else if (pairingState === "verifying") {
134
+ setText("configuration-state", t("status.pairing_verifying_detail"));
135
+ } else if (status.discardedEventsOnLastPairing > 0) {
136
+ setText(
137
+ "configuration-state",
138
+ t("status.pairing_discarded", {
139
+ count: number.format(status.discardedEventsOnLastPairing),
140
+ }),
141
+ );
142
+ } else {
143
+ setText(
144
+ "configuration-state",
145
+ t("status.runtime_configuration", {
146
+ state: translatedValue(
147
+ "configuration",
148
+ status.runtimeConfigurationState,
149
+ ),
150
+ }),
151
+ );
152
+ }
129
153
  setText("transfer", translatedValue("transfer", status.transferState));
130
154
  const delayed = status.transferMessage?.match(
131
155
  /^Transfer delayed \((HTTP (\d+)|target unavailable)\) - retry in (\d+)s$/,