@aurora-blue/signalk-connector 0.2.0-dev.40 → 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 +5 -1
- package/VERSION +1 -1
- package/dist/src/connector.d.ts +6 -0
- package/dist/src/connector.js +60 -1
- package/dist/src/equipment-identity.d.ts +10 -0
- package/dist/src/equipment-identity.js +145 -0
- package/dist/src/http-client.js +3 -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
|
@@ -135,4 +135,8 @@ See the canonical [integration and handover documentation](../aurora-blue-docume
|
|
|
135
135
|
|
|
136
136
|
## Unpublished AC input-limit control
|
|
137
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.
|
|
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/connector.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export declare class ConnectorRuntime {
|
|
|
24
24
|
private readonly now;
|
|
25
25
|
private readonly random;
|
|
26
26
|
private readonly unsubscribes;
|
|
27
|
+
private identitySupported;
|
|
28
|
+
private identityStarted;
|
|
29
|
+
private identityPolls;
|
|
30
|
+
private identityTimer;
|
|
31
|
+
private readonly identityValues;
|
|
27
32
|
private timer;
|
|
28
33
|
private safetyTimer;
|
|
29
34
|
private runtimeConfigurationTimer;
|
|
@@ -77,6 +82,7 @@ export declare class ConnectorRuntime {
|
|
|
77
82
|
private publishAnchorTransition;
|
|
78
83
|
private publishAnchorState;
|
|
79
84
|
private schedule;
|
|
85
|
+
private startEquipmentIdentity;
|
|
80
86
|
private startEnergyControl;
|
|
81
87
|
private pollEnergyControl;
|
|
82
88
|
private scheduleRuntimeConfigurationPoll;
|
package/dist/src/connector.js
CHANGED
|
@@ -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 equipment_identity_1 = require("./equipment-identity");
|
|
4
5
|
const energy_control_1 = require("./energy-control");
|
|
5
6
|
const node_path_1 = require("node:path");
|
|
6
7
|
const noforeignland_1 = require("./noforeignland");
|
|
@@ -37,6 +38,11 @@ class ConnectorRuntime {
|
|
|
37
38
|
now;
|
|
38
39
|
random;
|
|
39
40
|
unsubscribes = [];
|
|
41
|
+
identitySupported = false;
|
|
42
|
+
identityStarted = false;
|
|
43
|
+
identityPolls = 0;
|
|
44
|
+
identityTimer = null;
|
|
45
|
+
identityValues = new Map();
|
|
40
46
|
timer = null;
|
|
41
47
|
safetyTimer = null;
|
|
42
48
|
runtimeConfigurationTimer = null;
|
|
@@ -185,6 +191,7 @@ class ConnectorRuntime {
|
|
|
185
191
|
await this.updateStatus();
|
|
186
192
|
this.scheduleRuntimeConfigurationPoll(0);
|
|
187
193
|
this.startEnergyControl();
|
|
194
|
+
this.startEquipmentIdentity();
|
|
188
195
|
if (this.operatingMode !== "disabled") {
|
|
189
196
|
this.schedule(0);
|
|
190
197
|
}
|
|
@@ -194,6 +201,8 @@ class ConnectorRuntime {
|
|
|
194
201
|
}
|
|
195
202
|
async stop() {
|
|
196
203
|
this.stopped = true;
|
|
204
|
+
if (this.identityTimer)
|
|
205
|
+
clearTimeout(this.identityTimer);
|
|
197
206
|
this.energyExecutor?.stop();
|
|
198
207
|
if (this.energyTimer)
|
|
199
208
|
clearTimeout(this.energyTimer);
|
|
@@ -221,7 +230,7 @@ class ConnectorRuntime {
|
|
|
221
230
|
return;
|
|
222
231
|
}
|
|
223
232
|
const receivedAt = this.now();
|
|
224
|
-
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));
|
|
225
234
|
for (const event of events)
|
|
226
235
|
this.publisher?.observe(event);
|
|
227
236
|
// Safety observes current positions immediately, even when historical
|
|
@@ -549,6 +558,54 @@ class ConnectorRuntime {
|
|
|
549
558
|
void this.flush();
|
|
550
559
|
}, delay);
|
|
551
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
|
+
}
|
|
552
609
|
startEnergyControl() {
|
|
553
610
|
if (this.stopped ||
|
|
554
611
|
this.energyExecutor ||
|
|
@@ -623,6 +680,7 @@ class ConnectorRuntime {
|
|
|
623
680
|
this.pairingState = "paired";
|
|
624
681
|
await this.applyRuntimeConfiguration(response.runtime_configuration);
|
|
625
682
|
this.startEnergyControl();
|
|
683
|
+
this.startEquipmentIdentity();
|
|
626
684
|
if (this.unsubscribes.length === 0) {
|
|
627
685
|
this.operatingMode = response.runtime_configuration.operating_mode;
|
|
628
686
|
this.configureSubscriptions();
|
|
@@ -668,6 +726,7 @@ class ConnectorRuntime {
|
|
|
668
726
|
await this.updateStatus();
|
|
669
727
|
}
|
|
670
728
|
async applyRuntimeConfiguration(configuration) {
|
|
729
|
+
this.identitySupported = configuration.equipment_identity === true;
|
|
671
730
|
const previousMode = this.operatingMode;
|
|
672
731
|
this.runtimeConfigurationPollMs = configuration.poll_after_seconds * 1_000;
|
|
673
732
|
this.runtimeConfigurationState = "current";
|
|
@@ -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;
|
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)) ||
|
|
@@ -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