@aurora-blue/signalk-connector 0.2.0-dev.37 → 0.2.0-dev.40

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
@@ -132,3 +132,7 @@ The connector can publish observed own-vessel tracks through an independent, bou
132
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
133
 
134
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.
135
+
136
+ ## Unpublished AC input-limit control
137
+
138
+ The local inputLimitControlEnabled option defaults to false. When explicitly enabled, a paired active Connector exchanges only bounded native AC input-current-limit operations. Eligibility requires one inverter, fresh Shore source/current-limit observations, source identity, supportsPut and explicit minValue/maxValue metadata. Missing provider capabilities disable control. The source-specific PUT is sent once and success requires new matching readback; callbacks, stale data or transport retries never manufacture success. Commands are not journaled or replayed after restart. This local implementation is not a published release or physical equipment qualification. Canonical policy: ADR-0266 in aurora-blue-documentation.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0-dev.37
1
+ 0.2.0-dev.40
@@ -3,6 +3,7 @@ import type { StorageProfile } from "./storage-budget";
3
3
  import type { OperatingProfile } from "./telemetry-reducer";
4
4
  import type { NoForeignLandConfig } from "./noforeignland";
5
5
  export interface ConnectorConfig {
6
+ inputLimitControlEnabled?: boolean;
6
7
  noforeignland?: NoForeignLandConfig;
7
8
  targetUrl: string;
8
9
  deviceName: string;
@@ -150,6 +151,12 @@ export declare const pluginSchema: {
150
151
  readonly maximum: 120000;
151
152
  readonly default: 5000;
152
153
  };
154
+ readonly inputLimitControlEnabled: {
155
+ readonly type: "boolean";
156
+ readonly default: false;
157
+ readonly title: "Allow confirmed AC input-limit changes from ABOARD";
158
+ readonly description: "Requires a writable provider with explicit current bounds. Never buffers or retries equipment commands.";
159
+ };
153
160
  readonly adaptiveTelemetryEnabled: {
154
161
  readonly type: "boolean";
155
162
  readonly title: "Reduce telemetry by change or heartbeat";
@@ -157,6 +157,7 @@ const resolveConfig = (input) => {
157
157
  bufferSegmentBytes: integer(raw.bufferSegmentKilobytes, 256, 64, 2_048, "Buffer segment size in kilobytes") * 1024,
158
158
  compressionEnabled: raw.compressionEnabled !== false,
159
159
  compressionThresholdBytes: integer(raw.compressionThresholdBytes, 1_024, 256, 65_536, "Compression threshold"),
160
+ inputLimitControlEnabled: raw.inputLimitControlEnabled === true,
160
161
  adaptiveTelemetryEnabled: raw.adaptiveTelemetryEnabled !== false,
161
162
  operatingProfile: operatingProfile,
162
163
  anchorAlarm: {
@@ -286,6 +287,12 @@ exports.pluginSchema = {
286
287
  maximum: 120000,
287
288
  default: 5000,
288
289
  },
290
+ inputLimitControlEnabled: {
291
+ type: "boolean",
292
+ default: false,
293
+ title: "Allow confirmed AC input-limit changes from ABOARD",
294
+ description: "Requires a writable provider with explicit current bounds. Never buffers or retries equipment commands.",
295
+ },
289
296
  adaptiveTelemetryEnabled: {
290
297
  type: "boolean",
291
298
  title: "Reduce telemetry by change or heartbeat",
@@ -17,6 +17,9 @@ export declare class ConnectorRuntime {
17
17
  private readonly app;
18
18
  private readonly store;
19
19
  private readonly config;
20
+ private energyTimer;
21
+ private energyExecutor;
22
+ private energyResult;
20
23
  private readonly bootId;
21
24
  private readonly now;
22
25
  private readonly random;
@@ -74,6 +77,8 @@ export declare class ConnectorRuntime {
74
77
  private publishAnchorTransition;
75
78
  private publishAnchorState;
76
79
  private schedule;
80
+ private startEnergyControl;
81
+ private pollEnergyControl;
77
82
  private scheduleRuntimeConfigurationPoll;
78
83
  private pollRuntimeConfiguration;
79
84
  private requirePairing;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ConnectorRuntime = exports.CONNECTOR_VERSION = void 0;
4
+ const energy_control_1 = require("./energy-control");
4
5
  const node_path_1 = require("node:path");
5
6
  const noforeignland_1 = require("./noforeignland");
6
7
  const node_crypto_1 = require("node:crypto");
@@ -29,6 +30,9 @@ class ConnectorRuntime {
29
30
  app;
30
31
  store;
31
32
  config;
33
+ energyTimer = null;
34
+ energyExecutor = null;
35
+ energyResult = null;
32
36
  bootId;
33
37
  now;
34
38
  random;
@@ -180,6 +184,7 @@ class ConnectorRuntime {
180
184
  this.startSafetyTimer();
181
185
  await this.updateStatus();
182
186
  this.scheduleRuntimeConfigurationPoll(0);
187
+ this.startEnergyControl();
183
188
  if (this.operatingMode !== "disabled") {
184
189
  this.schedule(0);
185
190
  }
@@ -189,6 +194,9 @@ class ConnectorRuntime {
189
194
  }
190
195
  async stop() {
191
196
  this.stopped = true;
197
+ this.energyExecutor?.stop();
198
+ if (this.energyTimer)
199
+ clearTimeout(this.energyTimer);
192
200
  if (this.timer !== null) {
193
201
  clearTimeout(this.timer);
194
202
  this.timer = null;
@@ -541,6 +549,47 @@ class ConnectorRuntime {
541
549
  void this.flush();
542
550
  }, delay);
543
551
  }
552
+ startEnergyControl() {
553
+ if (this.stopped ||
554
+ this.energyExecutor ||
555
+ !this.config.inputLimitControlEnabled ||
556
+ !this.autoSchedule)
557
+ return;
558
+ this.energyExecutor = new energy_control_1.InputLimitExecutor(this.app, this.bootId, () => !this.stopped &&
559
+ this.pairingState === "paired" &&
560
+ this.operatingMode === "active");
561
+ void this.pollEnergyControl();
562
+ }
563
+ async pollEnergyControl() {
564
+ if (this.stopped ||
565
+ !this.energyExecutor ||
566
+ !this.client?.exchangeEnergyControl)
567
+ return;
568
+ try {
569
+ const state = await this.store.summary();
570
+ if (state.access_token &&
571
+ state.target_url === this.config.targetUrl &&
572
+ this.pairingState === "paired") {
573
+ const requestedAt = Date.now();
574
+ const response = await this.client.exchangeEnergyControl(state.access_token, {
575
+ session: this.bootId,
576
+ capability: this.energyExecutor.capability(),
577
+ result: this.energyResult,
578
+ });
579
+ this.energyResult = null;
580
+ if (response.command && !this.stopped) {
581
+ this.energyResult = await this.energyExecutor.execute(response.command, new Date(Date.parse(response.server_time) + Date.now() - requestedAt).toISOString());
582
+ }
583
+ }
584
+ }
585
+ catch {
586
+ /* Never repeat an equipment write after uncertain transport. */
587
+ }
588
+ if (!this.stopped)
589
+ this.energyTimer = setTimeout(() => {
590
+ void this.pollEnergyControl();
591
+ }, 5000);
592
+ }
544
593
  scheduleRuntimeConfigurationPoll(delay) {
545
594
  if (this.stopped || !this.autoSchedule) {
546
595
  return;
@@ -573,6 +622,7 @@ class ConnectorRuntime {
573
622
  }
574
623
  this.pairingState = "paired";
575
624
  await this.applyRuntimeConfiguration(response.runtime_configuration);
625
+ this.startEnergyControl();
576
626
  if (this.unsubscribes.length === 0) {
577
627
  this.operatingMode = response.runtime_configuration.operating_mode;
578
628
  this.configureSubscriptions();
@@ -0,0 +1,47 @@
1
+ import type { ServerAPI } from "@signalk/server-api";
2
+ export interface InputLimitCapability {
3
+ path: string;
4
+ source: string;
5
+ value: number;
6
+ minimum: number;
7
+ maximum: number;
8
+ }
9
+ export interface InputLimitCommand {
10
+ installation_maximum: number;
11
+ configuration_version: number;
12
+ id: string;
13
+ session: string;
14
+ path: string;
15
+ source: string;
16
+ previous_value: number;
17
+ value: number;
18
+ expires_at: string;
19
+ }
20
+ export interface InputLimitResult {
21
+ id: string;
22
+ state: "completed" | "rejected" | "unconfirmed";
23
+ }
24
+ export interface EnergyControlExchange {
25
+ session: string;
26
+ capability: InputLimitCapability | null;
27
+ result: InputLimitResult | null;
28
+ }
29
+ export interface EnergyControlResponse {
30
+ command: InputLimitCommand | null;
31
+ server_time: string;
32
+ }
33
+ /** Only an explicitly writable, bounded, fresh native AC input is eligible. */
34
+ export declare function inputLimitCapability(app: ServerAPI, now?: number): InputLimitCapability | null;
35
+ export declare class InputLimitExecutor {
36
+ private readonly app;
37
+ private readonly session;
38
+ private readonly active;
39
+ private readonly now;
40
+ private readonly wait;
41
+ private stopped;
42
+ private readonly seen;
43
+ constructor(app: ServerAPI, session: string, active: () => boolean, now?: () => number, wait?: (ms: number) => Promise<void>);
44
+ stop(): void;
45
+ capability(): InputLimitCapability | null;
46
+ execute(command: InputLimitCommand, serverTime: string): Promise<InputLimitResult>;
47
+ }
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.InputLimitExecutor = void 0;
4
+ exports.inputLimitCapability = inputLimitCapability;
5
+ const presets = [6, 10, 16, 20, 25, 32];
6
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
7
+ ? value
8
+ : {};
9
+ /** Only an explicitly writable, bounded, fresh native AC input is eligible. */
10
+ function inputLimitCapability(app, now = Date.now()) {
11
+ const source = record(app.getSelfPath("electrical.venus.acSource"));
12
+ const fresh = (leaf) => typeof leaf.timestamp === "string" &&
13
+ Date.parse(leaf.timestamp) <= now + 30000 &&
14
+ now - Date.parse(leaf.timestamp) < 120000;
15
+ if (source.value !== "shore" || !fresh(source))
16
+ return null;
17
+ const inverters = Object.keys(record(app.getSelfPath("electrical.inverters")));
18
+ if (inverters.length !== 1 || !/^[A-Za-z0-9_:-]+$/.test(inverters[0]))
19
+ return null;
20
+ const path = `electrical.inverters.${inverters[0]}.acin.currentLimit`;
21
+ const leaf = record(app.getSelfPath(path));
22
+ const meta = record(leaf.meta);
23
+ if (meta.supportsPut !== true ||
24
+ !fresh(leaf) ||
25
+ typeof leaf.$source !== "string" ||
26
+ !leaf.$source ||
27
+ typeof leaf.value !== "number" ||
28
+ !Number.isFinite(leaf.value) ||
29
+ typeof meta.minValue !== "number" ||
30
+ !Number.isFinite(meta.minValue) ||
31
+ meta.minValue < 0 ||
32
+ typeof meta.maxValue !== "number" ||
33
+ !Number.isFinite(meta.maxValue) ||
34
+ meta.maxValue < meta.minValue ||
35
+ meta.maxValue > 1000 ||
36
+ leaf.value < meta.minValue ||
37
+ leaf.value > meta.maxValue)
38
+ return null;
39
+ return {
40
+ path,
41
+ source: leaf.$source,
42
+ value: leaf.value,
43
+ minimum: meta.minValue,
44
+ maximum: meta.maxValue,
45
+ };
46
+ }
47
+ class InputLimitExecutor {
48
+ app;
49
+ session;
50
+ active;
51
+ now;
52
+ wait;
53
+ stopped = false;
54
+ seen = new Set();
55
+ constructor(app, session, active, now = Date.now, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
56
+ this.app = app;
57
+ this.session = session;
58
+ this.active = active;
59
+ this.now = now;
60
+ this.wait = wait;
61
+ }
62
+ stop() {
63
+ this.stopped = true;
64
+ }
65
+ capability() {
66
+ return !this.stopped && this.active()
67
+ ? inputLimitCapability(this.app, this.now())
68
+ : null;
69
+ }
70
+ async execute(command, serverTime) {
71
+ const reject = { id: command.id, state: "rejected" };
72
+ const remaining = Date.parse(command.expires_at) - Date.parse(serverTime);
73
+ const cap = this.capability();
74
+ if (!cap ||
75
+ this.seen.has(command.id) ||
76
+ command.session !== this.session ||
77
+ !Number.isFinite(remaining) ||
78
+ remaining <= 0 ||
79
+ remaining > 20000 ||
80
+ cap.path !== command.path ||
81
+ cap.source !== command.source ||
82
+ Math.abs(cap.value - command.previous_value) > 0.01 ||
83
+ !Number.isFinite(command.installation_maximum) ||
84
+ command.installation_maximum <= 0 ||
85
+ !Number.isInteger(command.configuration_version) ||
86
+ command.configuration_version < 1 ||
87
+ command.value > command.installation_maximum ||
88
+ !(presets.includes(command.value) ||
89
+ Math.abs(command.value - Math.min(command.installation_maximum, cap.maximum)) < 0.01) ||
90
+ command.value < cap.minimum ||
91
+ command.value > cap.maximum)
92
+ return reject;
93
+ this.seen.add(command.id);
94
+ if (this.seen.size > 64)
95
+ this.seen.delete(this.seen.values().next().value);
96
+ const started = this.now();
97
+ let failed = false;
98
+ try {
99
+ // Dispatch exactly once. A callback alone never proves equipment readback.
100
+ void Promise.resolve(this.app.putPath(`vessels.self.${cap.path}`, command.value, (error) => {
101
+ if (error)
102
+ failed = true;
103
+ }, cap.source)).catch(() => {
104
+ failed = true;
105
+ });
106
+ }
107
+ catch {
108
+ return reject;
109
+ }
110
+ for (let attempt = 0; attempt < Math.min(32, Math.floor(remaining / 250)); attempt++) {
111
+ await this.wait(250);
112
+ if (this.stopped || !this.active() || failed)
113
+ return { id: command.id, state: "unconfirmed" };
114
+ const current = this.capability();
115
+ const leaf = record(this.app.getSelfPath(cap.path));
116
+ if (!current ||
117
+ current.path !== cap.path ||
118
+ current.source !== cap.source)
119
+ return { id: command.id, state: "unconfirmed" };
120
+ if (Math.abs(current.value - command.value) < 0.01 &&
121
+ typeof leaf.timestamp === "string" &&
122
+ Date.parse(leaf.timestamp) >= started)
123
+ return { id: command.id, state: "completed" };
124
+ }
125
+ return { id: command.id, state: "unconfirmed" };
126
+ }
127
+ }
128
+ exports.InputLimitExecutor = InputLimitExecutor;
@@ -1,5 +1,7 @@
1
+ import type { EnergyControlExchange, EnergyControlResponse } from "./energy-control";
1
2
  import { type IngestAcknowledgement, type IngestBatch, type PairingResponse, type RuntimeConfigurationResponse } from "./types";
2
3
  export interface AuroraBlueClient {
4
+ exchangeEnergyControl?(accessToken: string, data: EnergyControlExchange): Promise<EnergyControlResponse>;
3
5
  pair(pairingCode: string, deviceName: string, connectorVersion: string): Promise<PairingResponse>;
4
6
  sendBatch(batch: IngestBatch, accessToken: string): Promise<IngestAcknowledgement>;
5
7
  fetchRuntimeConfiguration(accessToken: string): Promise<RuntimeConfigurationResponse>;
@@ -17,6 +19,7 @@ export declare class AuroraBlueHttpClient implements AuroraBlueClient {
17
19
  pair(pairingCode: string, deviceName: string, connectorVersion: string): Promise<PairingResponse>;
18
20
  sendBatch(batch: IngestBatch, accessToken: string): Promise<IngestAcknowledgement>;
19
21
  fetchRuntimeConfiguration(accessToken: string): Promise<RuntimeConfigurationResponse>;
22
+ exchangeEnergyControl(accessToken: string, data: EnergyControlExchange): Promise<EnergyControlResponse>;
20
23
  private request;
21
24
  private readJson;
22
25
  }
@@ -104,6 +104,18 @@ class AuroraBlueHttpClient {
104
104
  server_time: response.server_time,
105
105
  };
106
106
  }
107
+ async exchangeEnergyControl(accessToken, data) {
108
+ const response = await this.request("/api/v1/devices/self/energy-control", {
109
+ method: "POST",
110
+ headers: { Authorization: `Bearer ${accessToken}` },
111
+ body: JSON.stringify(data),
112
+ });
113
+ if (typeof response.server_time !== "string" ||
114
+ !Number.isFinite(Date.parse(response.server_time))) {
115
+ throw new Error("Invalid energy-control response.");
116
+ }
117
+ return response;
118
+ }
107
119
  async request(path, init) {
108
120
  const abort = new AbortController();
109
121
  const timeout = setTimeout(() => abort.abort(), this.timeoutMs);
@@ -59,6 +59,7 @@ exports.SELF_TELEMETRY = [
59
59
  self("electrical.inverters.*.acin.power", "number", "scalar", 5_000, 60_000, 1),
60
60
  self("electrical.inverters.*.acin.voltage", "number", "scalar", 5_000, 60_000, 1),
61
61
  self("electrical.inverters.*.acin.current", "number", "scalar", 5_000, 60_000, 1),
62
+ self("electrical.inverters.*.acin.currentLimit", "number", "scalar", 5_000, 60_000, 0.1),
62
63
  self("electrical.inverters.*.acin.frequency", "number", "scalar", 5_000, 60_000, 1),
63
64
  self("electrical.inverters.*.acout.power", "number", "scalar", 5_000, 60_000, 1),
64
65
  self("electrical.inverters.*.acout.voltage", "number", "scalar", 5_000, 60_000, 1),
@@ -1 +1 @@
1
- export declare const CONNECTOR_VERSION = "0.2.0-dev.37";
1
+ export declare const CONNECTOR_VERSION = "0.2.0-dev.40";
@@ -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.37";
4
+ exports.CONNECTOR_VERSION = "0.2.0-dev.40";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurora-blue/signalk-connector",
3
- "version": "0.2.0-dev.37",
3
+ "version": "0.2.0-dev.40",
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",