@aurora-blue/signalk-connector 0.2.0-dev.38 → 0.2.0-dev.41
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 +8 -0
- package/VERSION +1 -1
- package/dist/src/config.d.ts +7 -0
- package/dist/src/config.js +7 -0
- package/dist/src/connector.d.ts +11 -0
- package/dist/src/connector.js +110 -1
- package/dist/src/energy-control.d.ts +47 -0
- package/dist/src/energy-control.js +128 -0
- package/dist/src/equipment-identity.d.ts +10 -0
- package/dist/src/equipment-identity.js +145 -0
- package/dist/src/http-client.d.ts +3 -0
- package/dist/src/http-client.js +15 -1
- package/dist/src/telemetry-catalog.js +8 -0
- package/dist/src/types.d.ts +1 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -132,3 +132,11 @@ 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. Physical equipment control qualification remains separate. Canonical policy: ADR-0271 in aurora-blue-documentation.
|
|
139
|
+
|
|
140
|
+
### Equipment identity enrichment
|
|
141
|
+
|
|
142
|
+
An updated paired Server must advertise equipment_identity=true before this optional collection starts. The Connector reads existing NMEA 2000 product information and may use the existing GX D-Bus ProductName reader on Linux. It associates names by received source, never by numeric instance guesses. Queries are read-only, bounded and optional; no new native dependency is installed. Only bounded product/manufacturer string fields are added to the usual durable batch, with explicit metadata provenance and a null source timestamp. Serial/firmware fields and raw frames are not collected, and opaque identifier-like names are excluded. New/changed values are captured after startup; unchanged values are not repeatedly emitted by this adapter. Standard telemetry and default-off equipment control remain independent. See ADR-0272 in the canonical documentation.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.2.0-dev.
|
|
1
|
+
0.2.0-dev.41
|
package/dist/src/config.d.ts
CHANGED
|
@@ -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";
|
package/dist/src/config.js
CHANGED
|
@@ -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",
|
package/dist/src/connector.d.ts
CHANGED
|
@@ -17,10 +17,18 @@ 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;
|
|
23
26
|
private readonly unsubscribes;
|
|
27
|
+
private identitySupported;
|
|
28
|
+
private identityStarted;
|
|
29
|
+
private identityPolls;
|
|
30
|
+
private identityTimer;
|
|
31
|
+
private readonly identityValues;
|
|
24
32
|
private timer;
|
|
25
33
|
private safetyTimer;
|
|
26
34
|
private runtimeConfigurationTimer;
|
|
@@ -74,6 +82,9 @@ export declare class ConnectorRuntime {
|
|
|
74
82
|
private publishAnchorTransition;
|
|
75
83
|
private publishAnchorState;
|
|
76
84
|
private schedule;
|
|
85
|
+
private startEquipmentIdentity;
|
|
86
|
+
private startEnergyControl;
|
|
87
|
+
private pollEnergyControl;
|
|
77
88
|
private scheduleRuntimeConfigurationPoll;
|
|
78
89
|
private pollRuntimeConfiguration;
|
|
79
90
|
private requirePairing;
|
package/dist/src/connector.js
CHANGED
|
@@ -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 equipment_identity_1 = require("./equipment-identity");
|
|
5
|
+
const energy_control_1 = require("./energy-control");
|
|
4
6
|
const node_path_1 = require("node:path");
|
|
5
7
|
const noforeignland_1 = require("./noforeignland");
|
|
6
8
|
const node_crypto_1 = require("node:crypto");
|
|
@@ -29,10 +31,18 @@ class ConnectorRuntime {
|
|
|
29
31
|
app;
|
|
30
32
|
store;
|
|
31
33
|
config;
|
|
34
|
+
energyTimer = null;
|
|
35
|
+
energyExecutor = null;
|
|
36
|
+
energyResult = null;
|
|
32
37
|
bootId;
|
|
33
38
|
now;
|
|
34
39
|
random;
|
|
35
40
|
unsubscribes = [];
|
|
41
|
+
identitySupported = false;
|
|
42
|
+
identityStarted = false;
|
|
43
|
+
identityPolls = 0;
|
|
44
|
+
identityTimer = null;
|
|
45
|
+
identityValues = new Map();
|
|
36
46
|
timer = null;
|
|
37
47
|
safetyTimer = null;
|
|
38
48
|
runtimeConfigurationTimer = null;
|
|
@@ -180,6 +190,8 @@ class ConnectorRuntime {
|
|
|
180
190
|
this.startSafetyTimer();
|
|
181
191
|
await this.updateStatus();
|
|
182
192
|
this.scheduleRuntimeConfigurationPoll(0);
|
|
193
|
+
this.startEnergyControl();
|
|
194
|
+
this.startEquipmentIdentity();
|
|
183
195
|
if (this.operatingMode !== "disabled") {
|
|
184
196
|
this.schedule(0);
|
|
185
197
|
}
|
|
@@ -189,6 +201,11 @@ class ConnectorRuntime {
|
|
|
189
201
|
}
|
|
190
202
|
async stop() {
|
|
191
203
|
this.stopped = true;
|
|
204
|
+
if (this.identityTimer)
|
|
205
|
+
clearTimeout(this.identityTimer);
|
|
206
|
+
this.energyExecutor?.stop();
|
|
207
|
+
if (this.energyTimer)
|
|
208
|
+
clearTimeout(this.energyTimer);
|
|
192
209
|
if (this.timer !== null) {
|
|
193
210
|
clearTimeout(this.timer);
|
|
194
211
|
this.timer = null;
|
|
@@ -213,7 +230,7 @@ class ConnectorRuntime {
|
|
|
213
230
|
return;
|
|
214
231
|
}
|
|
215
232
|
const receivedAt = this.now();
|
|
216
|
-
const events = (0, delta_1.eventsFromDelta)(delta, this.bootId, receivedAt, subscriptionScope);
|
|
233
|
+
const events = (0, delta_1.eventsFromDelta)(delta, this.bootId, receivedAt, subscriptionScope).filter((event) => this.identitySupported || !(0, equipment_identity_1.isEquipmentIdentityPath)(event.path));
|
|
217
234
|
for (const event of events)
|
|
218
235
|
this.publisher?.observe(event);
|
|
219
236
|
// Safety observes current positions immediately, even when historical
|
|
@@ -541,6 +558,95 @@ class ConnectorRuntime {
|
|
|
541
558
|
void this.flush();
|
|
542
559
|
}, delay);
|
|
543
560
|
}
|
|
561
|
+
startEquipmentIdentity() {
|
|
562
|
+
if (!this.identitySupported ||
|
|
563
|
+
this.identityStarted ||
|
|
564
|
+
!this.autoSchedule ||
|
|
565
|
+
this.stopped)
|
|
566
|
+
return;
|
|
567
|
+
this.identityStarted = true;
|
|
568
|
+
const poll = async () => {
|
|
569
|
+
try {
|
|
570
|
+
if (this.identitySupported &&
|
|
571
|
+
this.pairingState === "paired" &&
|
|
572
|
+
this.operatingMode === "active") {
|
|
573
|
+
const identities = await (0, equipment_identity_1.equipmentIdentities)(this.app.getSelfPath("electrical"), typeof this.app.getPath === "function"
|
|
574
|
+
? this.app.getPath("/sources")
|
|
575
|
+
: {});
|
|
576
|
+
for (const identity of identities) {
|
|
577
|
+
if (this.stopped ||
|
|
578
|
+
!this.identitySupported ||
|
|
579
|
+
this.operatingMode !== "active")
|
|
580
|
+
break;
|
|
581
|
+
const key = identity.path + "\0" + identity.source;
|
|
582
|
+
if (this.identityValues.get(key) === identity.value)
|
|
583
|
+
continue;
|
|
584
|
+
await this.capture({
|
|
585
|
+
context: "vessels.self",
|
|
586
|
+
updates: [
|
|
587
|
+
{
|
|
588
|
+
$source: identity.source,
|
|
589
|
+
values: [{ path: identity.path, value: identity.value }],
|
|
590
|
+
},
|
|
591
|
+
],
|
|
592
|
+
});
|
|
593
|
+
this.identityValues.set(key, identity.value);
|
|
594
|
+
if (this.identityValues.size > 384)
|
|
595
|
+
this.identityValues.delete(this.identityValues.keys().next().value);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
/* Optional identity reads never interrupt ordinary telemetry. */
|
|
601
|
+
}
|
|
602
|
+
if (!this.stopped)
|
|
603
|
+
this.identityTimer = setTimeout(() => {
|
|
604
|
+
void poll();
|
|
605
|
+
}, this.identityPolls++ < 5 ? 30_000 : 600_000);
|
|
606
|
+
};
|
|
607
|
+
void poll();
|
|
608
|
+
}
|
|
609
|
+
startEnergyControl() {
|
|
610
|
+
if (this.stopped ||
|
|
611
|
+
this.energyExecutor ||
|
|
612
|
+
!this.config.inputLimitControlEnabled ||
|
|
613
|
+
!this.autoSchedule)
|
|
614
|
+
return;
|
|
615
|
+
this.energyExecutor = new energy_control_1.InputLimitExecutor(this.app, this.bootId, () => !this.stopped &&
|
|
616
|
+
this.pairingState === "paired" &&
|
|
617
|
+
this.operatingMode === "active");
|
|
618
|
+
void this.pollEnergyControl();
|
|
619
|
+
}
|
|
620
|
+
async pollEnergyControl() {
|
|
621
|
+
if (this.stopped ||
|
|
622
|
+
!this.energyExecutor ||
|
|
623
|
+
!this.client?.exchangeEnergyControl)
|
|
624
|
+
return;
|
|
625
|
+
try {
|
|
626
|
+
const state = await this.store.summary();
|
|
627
|
+
if (state.access_token &&
|
|
628
|
+
state.target_url === this.config.targetUrl &&
|
|
629
|
+
this.pairingState === "paired") {
|
|
630
|
+
const requestedAt = Date.now();
|
|
631
|
+
const response = await this.client.exchangeEnergyControl(state.access_token, {
|
|
632
|
+
session: this.bootId,
|
|
633
|
+
capability: this.energyExecutor.capability(),
|
|
634
|
+
result: this.energyResult,
|
|
635
|
+
});
|
|
636
|
+
this.energyResult = null;
|
|
637
|
+
if (response.command && !this.stopped) {
|
|
638
|
+
this.energyResult = await this.energyExecutor.execute(response.command, new Date(Date.parse(response.server_time) + Date.now() - requestedAt).toISOString());
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
/* Never repeat an equipment write after uncertain transport. */
|
|
644
|
+
}
|
|
645
|
+
if (!this.stopped)
|
|
646
|
+
this.energyTimer = setTimeout(() => {
|
|
647
|
+
void this.pollEnergyControl();
|
|
648
|
+
}, 5000);
|
|
649
|
+
}
|
|
544
650
|
scheduleRuntimeConfigurationPoll(delay) {
|
|
545
651
|
if (this.stopped || !this.autoSchedule) {
|
|
546
652
|
return;
|
|
@@ -573,6 +679,8 @@ class ConnectorRuntime {
|
|
|
573
679
|
}
|
|
574
680
|
this.pairingState = "paired";
|
|
575
681
|
await this.applyRuntimeConfiguration(response.runtime_configuration);
|
|
682
|
+
this.startEnergyControl();
|
|
683
|
+
this.startEquipmentIdentity();
|
|
576
684
|
if (this.unsubscribes.length === 0) {
|
|
577
685
|
this.operatingMode = response.runtime_configuration.operating_mode;
|
|
578
686
|
this.configureSubscriptions();
|
|
@@ -618,6 +726,7 @@ class ConnectorRuntime {
|
|
|
618
726
|
await this.updateStatus();
|
|
619
727
|
}
|
|
620
728
|
async applyRuntimeConfiguration(configuration) {
|
|
729
|
+
this.identitySupported = configuration.equipment_identity === true;
|
|
621
730
|
const previousMode = this.operatingMode;
|
|
622
731
|
this.runtimeConfigurationPollMs = configuration.poll_after_seconds * 1_000;
|
|
623
732
|
this.runtimeConfigurationState = "current";
|
|
@@ -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;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface EquipmentIdentity {
|
|
2
|
+
path: string;
|
|
3
|
+
value: string;
|
|
4
|
+
source: string;
|
|
5
|
+
}
|
|
6
|
+
/** Optional GX adapter. Fixed executable, fixed read-only method, no shell or writes. */
|
|
7
|
+
export declare function readVictronProductName(service: string): Promise<string | null>;
|
|
8
|
+
/** Capture identity only for received electrical sources, plus identified GX controllers. */
|
|
9
|
+
export declare function equipmentIdentities(electrical: unknown, sources: unknown, readProduct?: typeof readVictronProductName): Promise<EquipmentIdentity[]>;
|
|
10
|
+
export declare const isEquipmentIdentityPath: (path: string) => boolean;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isEquipmentIdentityPath = void 0;
|
|
4
|
+
exports.readVictronProductName = readVictronProductName;
|
|
5
|
+
exports.equipmentIdentities = equipmentIdentities;
|
|
6
|
+
const node_child_process_1 = require("node:child_process");
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
9
|
+
? value
|
|
10
|
+
: {};
|
|
11
|
+
const text = (value) => typeof value === "string" &&
|
|
12
|
+
value.trim() &&
|
|
13
|
+
value.length <= 120 &&
|
|
14
|
+
!/[\x00-\x1f]/.test(value)
|
|
15
|
+
? value.trim()
|
|
16
|
+
: null;
|
|
17
|
+
const productText = (value) => {
|
|
18
|
+
const name = text(value);
|
|
19
|
+
return name && !/^[A-Za-z0-9]{12,}$/.test(name) ? name : null;
|
|
20
|
+
};
|
|
21
|
+
const servicePattern = /^com\.victronenergy\.(?:vebus|solarcharger|battery|charger|alternator)\.[A-Za-z0-9_]{1,100}$/;
|
|
22
|
+
/** Optional GX adapter. Fixed executable, fixed read-only method, no shell or writes. */
|
|
23
|
+
function readVictronProductName(service) {
|
|
24
|
+
if (process.platform !== "linux" || !servicePattern.test(service))
|
|
25
|
+
return Promise.resolve(null);
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
(0, node_child_process_1.execFile)("/usr/bin/dbus-send", [
|
|
28
|
+
"--system",
|
|
29
|
+
"--print-reply",
|
|
30
|
+
`--dest=${service}`,
|
|
31
|
+
"/ProductName",
|
|
32
|
+
"com.victronenergy.BusItem.GetValue",
|
|
33
|
+
], { timeout: 1500, maxBuffer: 8192 }, (error, stdout) => {
|
|
34
|
+
if (error)
|
|
35
|
+
return resolve(null);
|
|
36
|
+
const match = /variant\s+string\s+("(?:[^"\\]|\\.)*")/.exec(stdout);
|
|
37
|
+
try {
|
|
38
|
+
resolve(match ? productText(JSON.parse(match[1])) : null);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
resolve(null);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Capture identity only for received electrical sources, plus identified GX controllers. */
|
|
47
|
+
async function equipmentIdentities(electrical, sources, readProduct = readVictronProductName) {
|
|
48
|
+
const found = [];
|
|
49
|
+
const nmea = new Map();
|
|
50
|
+
for (const [provider, collection] of Object.entries(record(sources)).slice(0, 64)) {
|
|
51
|
+
for (const [address, device] of Object.entries(record(collection)).slice(0, 128)) {
|
|
52
|
+
const identity = record(record(device).n2k);
|
|
53
|
+
const model = productText(identity.modelId ?? identity["Model ID"]);
|
|
54
|
+
if (!model)
|
|
55
|
+
continue;
|
|
56
|
+
nmea.set(`${provider}.${address}`, {
|
|
57
|
+
model,
|
|
58
|
+
manufacturer: text(identity.manufacturerCode ?? identity["Manufacturer Code"]),
|
|
59
|
+
stable: `${provider}:${text(identity.canName) ?? address}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const products = new Map();
|
|
64
|
+
for (const group of [
|
|
65
|
+
"inverters",
|
|
66
|
+
"chargers",
|
|
67
|
+
"solar",
|
|
68
|
+
"batteries",
|
|
69
|
+
"alternators",
|
|
70
|
+
]) {
|
|
71
|
+
for (const [instance, raw] of Object.entries(record(record(electrical)[group])).slice(0, 32)) {
|
|
72
|
+
if (!/^[A-Za-z0-9_:-]{1,80}$/.test(instance))
|
|
73
|
+
continue;
|
|
74
|
+
const upstream = new Set();
|
|
75
|
+
const visit = (node, depth) => {
|
|
76
|
+
if (depth > 3)
|
|
77
|
+
return;
|
|
78
|
+
const item = record(node);
|
|
79
|
+
if (typeof item.$source === "string")
|
|
80
|
+
upstream.add(item.$source);
|
|
81
|
+
for (const child of Object.values(item))
|
|
82
|
+
if (child && typeof child === "object")
|
|
83
|
+
visit(child, depth + 1);
|
|
84
|
+
};
|
|
85
|
+
visit(raw, 0);
|
|
86
|
+
// An aggregate with competing providers cannot establish one product identity.
|
|
87
|
+
if (upstream.size !== 1)
|
|
88
|
+
continue;
|
|
89
|
+
const source = [...upstream][0];
|
|
90
|
+
const native = nmea.get(source);
|
|
91
|
+
let model = native?.model ?? null;
|
|
92
|
+
let manufacturer = native?.manufacturer ?? null;
|
|
93
|
+
let provenance = `aboard.metadata.nmea2000:${source}`;
|
|
94
|
+
const service = source.startsWith("venus.") ? source.slice(6) : "";
|
|
95
|
+
if (servicePattern.test(service)) {
|
|
96
|
+
if (!products.has(service) && products.size < 16)
|
|
97
|
+
products.set(service, readProduct(service));
|
|
98
|
+
model = productText(await products.get(service));
|
|
99
|
+
manufacturer =
|
|
100
|
+
model &&
|
|
101
|
+
/^(?:Quattro|MultiPlus|EasySolar|SmartSolar|BlueSolar|Lynx Shunt|SmartShunt|BMV(?:-|\s))/i.test(model)
|
|
102
|
+
? "Victron Energy"
|
|
103
|
+
: null;
|
|
104
|
+
provenance = `aboard.metadata.dbus:${service}`;
|
|
105
|
+
}
|
|
106
|
+
if (!model)
|
|
107
|
+
continue;
|
|
108
|
+
const base = `electrical.${group}.${instance}`;
|
|
109
|
+
found.push({
|
|
110
|
+
path: `${base}.productName`,
|
|
111
|
+
value: model,
|
|
112
|
+
source: provenance,
|
|
113
|
+
});
|
|
114
|
+
if (manufacturer)
|
|
115
|
+
found.push({
|
|
116
|
+
path: `${base}.manufacturer`,
|
|
117
|
+
value: manufacturer,
|
|
118
|
+
source: provenance,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const [source, identity] of nmea) {
|
|
123
|
+
if (identity.manufacturer !== "Victron Energy" ||
|
|
124
|
+
!/^(?:Ekrano GX|Cerbo GX(?: MK2)?|Venus GX|Color Control GX|Octo GX)$/i.test(identity.model))
|
|
125
|
+
continue;
|
|
126
|
+
const id = (0, node_crypto_1.createHash)("sha256")
|
|
127
|
+
.update(identity.stable)
|
|
128
|
+
.digest("hex")
|
|
129
|
+
.slice(0, 16);
|
|
130
|
+
const base = `electrical.controllers.n2k_${id}`;
|
|
131
|
+
found.push({
|
|
132
|
+
path: `${base}.productName`,
|
|
133
|
+
value: identity.model,
|
|
134
|
+
source: `aboard.metadata.nmea2000:${source}`,
|
|
135
|
+
});
|
|
136
|
+
found.push({
|
|
137
|
+
path: `${base}.manufacturer`,
|
|
138
|
+
value: identity.manufacturer,
|
|
139
|
+
source: `aboard.metadata.nmea2000:${source}`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return found.slice(0, 192);
|
|
143
|
+
}
|
|
144
|
+
const isEquipmentIdentityPath = (path) => /^electrical\.(?:inverters|chargers|solar|batteries|alternators|controllers)\.[A-Za-z0-9_:-]+\.(?:productName|manufacturer)$/.test(path);
|
|
145
|
+
exports.isEquipmentIdentityPath = isEquipmentIdentityPath;
|
|
@@ -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
|
}
|
package/dist/src/http-client.js
CHANGED
|
@@ -14,7 +14,9 @@ const runtimeConfigurationFrom = (value) => {
|
|
|
14
14
|
return defaultRuntimeConfiguration();
|
|
15
15
|
}
|
|
16
16
|
const configuration = value;
|
|
17
|
-
if (
|
|
17
|
+
if ((configuration.equipment_identity !== undefined &&
|
|
18
|
+
typeof configuration.equipment_identity !== "boolean") ||
|
|
19
|
+
typeof configuration.version !== "number" ||
|
|
18
20
|
!Number.isInteger(configuration.version) ||
|
|
19
21
|
configuration.version < 1 ||
|
|
20
22
|
!["active", "standby", "disabled"].includes(String(configuration.operating_mode)) ||
|
|
@@ -104,6 +106,18 @@ class AuroraBlueHttpClient {
|
|
|
104
106
|
server_time: response.server_time,
|
|
105
107
|
};
|
|
106
108
|
}
|
|
109
|
+
async exchangeEnergyControl(accessToken, data) {
|
|
110
|
+
const response = await this.request("/api/v1/devices/self/energy-control", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
113
|
+
body: JSON.stringify(data),
|
|
114
|
+
});
|
|
115
|
+
if (typeof response.server_time !== "string" ||
|
|
116
|
+
!Number.isFinite(Date.parse(response.server_time))) {
|
|
117
|
+
throw new Error("Invalid energy-control response.");
|
|
118
|
+
}
|
|
119
|
+
return response;
|
|
120
|
+
}
|
|
107
121
|
async request(path, init) {
|
|
108
122
|
const abort = new AbortController();
|
|
109
123
|
const timeout = setTimeout(() => abort.abort(), this.timeoutMs);
|
|
@@ -21,6 +21,14 @@ const ais = (path, valueKind, changeKind, minimumIntervalMs, maximumHeartbeatMs,
|
|
|
21
21
|
changeThreshold,
|
|
22
22
|
});
|
|
23
23
|
exports.SELF_TELEMETRY = [
|
|
24
|
+
...[
|
|
25
|
+
"inverters",
|
|
26
|
+
"chargers",
|
|
27
|
+
"solar",
|
|
28
|
+
"batteries",
|
|
29
|
+
"alternators",
|
|
30
|
+
"controllers",
|
|
31
|
+
].flatMap((group) => ["productName", "manufacturer"].map((field) => self(`electrical.${group}.*.${field}`, "string", "identity", 0, 600_000, 0))),
|
|
24
32
|
// GX-owned sensor names and SI readings need no per-installation path mapping.
|
|
25
33
|
self("environment.venus.*.name", "string", "identity", 0, 600_000, 0),
|
|
26
34
|
self("environment.venus.*.temperature", "number", "scalar", 30_000, 60_000, 0.2),
|
package/dist/src/types.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type ProtocolVersion = (typeof SUPPORTED_PROTOCOL_VERSIONS)[number];
|
|
|
5
5
|
export type OperatingMode = "active" | "standby" | "disabled";
|
|
6
6
|
export type PairingState = "unpaired" | "verifying" | "paired" | "pairing-required";
|
|
7
7
|
export interface RuntimeConfiguration {
|
|
8
|
+
equipment_identity?: boolean;
|
|
8
9
|
version: number;
|
|
9
10
|
operating_mode: OperatingMode;
|
|
10
11
|
poll_after_seconds: number;
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CONNECTOR_VERSION = "0.2.0-dev.
|
|
1
|
+
export declare const CONNECTOR_VERSION = "0.2.0-dev.41";
|
package/dist/src/version.js
CHANGED
package/package.json
CHANGED