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

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
@@ -51,7 +51,7 @@ Select Aurora Blue ABOARD Connector Status under Signal K Webapps for the cross-
51
51
 
52
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
- The default configuration sends up to 250 events per batch, waits up to five seconds to combine normal telemetry, samples own-vessel Signal K paths every second, samples selected AIS paths every five seconds, and enables the preliminary underway change-or-heartbeat profile. Navigation, performance, steering observations, wind, depth, current, tide, outside weather, water and heave are captured independently of displayed fields. Additional scoped family paths preserve bounded structured source values, with a two-second minimum change interval and 60-second heartbeat under normal storage pressure. Ground- and water-referenced true wind remain separate. Deploy compatible Server acceptance before this Connector expansion; older Servers reject unsupported paths. See the [canonical acquisition decision](../aurora-blue-documentation/docs/decisions/ADR-0153-display-independent-navigation-capture.md). 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.
54
+ The default configuration sends up to 250 events per batch, waits up to one second to combine normal telemetry, samples own-vessel Signal K paths every second, samples selected AIS paths every five seconds, and enables the preliminary underway change-or-heartbeat profile. Navigation, performance, steering observations, wind, depth, current, tide, outside weather, water and heave are captured independently of displayed fields. Additional scoped family paths preserve bounded structured source values, with a two-second minimum change interval and 60-second heartbeat under normal storage pressure. Ground- and water-referenced true wind remain separate. Deploy compatible Server acceptance before this Connector expansion; older Servers reject unsupported paths. See the [canonical acquisition decision](../aurora-blue-documentation/docs/decisions/ADR-0153-display-independent-navigation-capture.md). Critical DC voltage/current/power changes use a one-second minimum admission interval with thresholds of 0.005 V, 0.01 A and 0.5 W. Unchanged DC observations retain a 20-second real-observation heartbeat. Empty flushes send no HTTP batch, explicit operator transfer intervals remain respected, and source timestamps are never refreshed from cached values. 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
 
56
56
  The connector also subscribes to scalar values below `electrical.switches.*`, including separate channels on multi-output devices. Switch paths use the instant subscription policy so a periodic retransmission of a cached Signal K value is not mistaken for fresh device evidence. The server may use selected `.state` leaves for conservative equipment-runtime estimates. A silent or unpowered device stops contributing runtime after a short bounded interval; accurate long-running counters require a qualified device-originated liveness source.
57
57
 
@@ -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. This local implementation is not a published release or physical equipment qualification. Canonical policy: ADR-0266 in aurora-blue-documentation.
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.40
1
+ 0.2.0-dev.42
@@ -137,7 +137,7 @@ const resolveConfig = (input) => {
137
137
  : undefined,
138
138
  allowInsecureHttp,
139
139
  batchSize: integer(raw.batchSize, 250, 1, 500, "Batch size"),
140
- flushIntervalMs: integer(raw.flushIntervalMs, 5_000, 250, 60_000, "Flush interval"),
140
+ flushIntervalMs: integer(raw.flushIntervalMs, 1_000, 250, 60_000, "Flush interval"),
141
141
  samplePeriodMs: integer(raw.samplePeriodMs, 1_000, 250, 60_000, "Sample period"),
142
142
  aisEnabled: raw.aisEnabled !== false,
143
143
  aisSamplePeriodMs: integer(raw.aisSamplePeriodMs, 5_000, 1_000, 120_000, "AIS sample period"),
@@ -24,6 +24,16 @@ export declare class ConnectorRuntime {
24
24
  private readonly now;
25
25
  private readonly random;
26
26
  private readonly unsubscribes;
27
+ private identitySupported;
28
+ private nmeaEnergySupported;
29
+ private readonly nmeaEnergy;
30
+ private acTotalSupported;
31
+ private victronInputLimitSupported;
32
+ private stopVictronInputLimit;
33
+ private identityStarted;
34
+ private identityPolls;
35
+ private identityTimer;
36
+ private readonly identityValues;
27
37
  private timer;
28
38
  private safetyTimer;
29
39
  private runtimeConfigurationTimer;
@@ -77,11 +87,13 @@ export declare class ConnectorRuntime {
77
87
  private publishAnchorTransition;
78
88
  private publishAnchorState;
79
89
  private schedule;
90
+ private startEquipmentIdentity;
80
91
  private startEnergyControl;
81
92
  private pollEnergyControl;
82
93
  private scheduleRuntimeConfigurationPoll;
83
94
  private pollRuntimeConfiguration;
84
95
  private requirePairing;
96
+ private startVictronInputLimit;
85
97
  private applyRuntimeConfiguration;
86
98
  private updateStatus;
87
99
  private storageBudgetPolicy;
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ConnectorRuntime = exports.CONNECTOR_VERSION = void 0;
4
+ const nmea_energy_1 = require("./nmea-energy");
5
+ const victron_input_limit_1 = require("./victron-input-limit");
6
+ const equipment_identity_1 = require("./equipment-identity");
4
7
  const energy_control_1 = require("./energy-control");
5
8
  const node_path_1 = require("node:path");
6
9
  const noforeignland_1 = require("./noforeignland");
@@ -37,6 +40,16 @@ class ConnectorRuntime {
37
40
  now;
38
41
  random;
39
42
  unsubscribes = [];
43
+ identitySupported = false;
44
+ nmeaEnergySupported = false;
45
+ nmeaEnergy = new nmea_energy_1.NmeaEnergy();
46
+ acTotalSupported = false;
47
+ victronInputLimitSupported = false;
48
+ stopVictronInputLimit = null;
49
+ identityStarted = false;
50
+ identityPolls = 0;
51
+ identityTimer = null;
52
+ identityValues = new Map();
40
53
  timer = null;
41
54
  safetyTimer = null;
42
55
  runtimeConfigurationTimer = null;
@@ -185,6 +198,8 @@ class ConnectorRuntime {
185
198
  await this.updateStatus();
186
199
  this.scheduleRuntimeConfigurationPoll(0);
187
200
  this.startEnergyControl();
201
+ this.startEquipmentIdentity();
202
+ this.startVictronInputLimit();
188
203
  if (this.operatingMode !== "disabled") {
189
204
  this.schedule(0);
190
205
  }
@@ -194,6 +209,10 @@ class ConnectorRuntime {
194
209
  }
195
210
  async stop() {
196
211
  this.stopped = true;
212
+ this.stopVictronInputLimit?.();
213
+ this.stopVictronInputLimit = null;
214
+ if (this.identityTimer)
215
+ clearTimeout(this.identityTimer);
197
216
  this.energyExecutor?.stop();
198
217
  if (this.energyTimer)
199
218
  clearTimeout(this.energyTimer);
@@ -221,7 +240,13 @@ class ConnectorRuntime {
221
240
  return;
222
241
  }
223
242
  const receivedAt = this.now();
224
- const events = (0, delta_1.eventsFromDelta)(delta, this.bootId, receivedAt, subscriptionScope);
243
+ const events = (0, delta_1.eventsFromDelta)(delta, this.bootId, receivedAt, subscriptionScope).filter((event) => (this.identitySupported || !(0, equipment_identity_1.isEquipmentIdentityPath)(event.path)) &&
244
+ (this.nmeaEnergySupported ||
245
+ !/^electrical\.(?:solar|inverters)\.n2k_[a-f0-9]{32}\./.test(event.path)) &&
246
+ (this.victronInputLimitSupported ||
247
+ !event.path.endsWith(".activeInputCurrentLimit")) &&
248
+ (this.acTotalSupported ||
249
+ !/^electrical\.ac\.[A-Za-z0-9_:-]+\.total\.realPower$/.test(event.path)));
225
250
  for (const event of events)
226
251
  this.publisher?.observe(event);
227
252
  // Safety observes current positions immediately, even when historical
@@ -549,6 +574,54 @@ class ConnectorRuntime {
549
574
  void this.flush();
550
575
  }, delay);
551
576
  }
577
+ startEquipmentIdentity() {
578
+ if (!this.identitySupported ||
579
+ this.identityStarted ||
580
+ !this.autoSchedule ||
581
+ this.stopped)
582
+ return;
583
+ this.identityStarted = true;
584
+ const poll = async () => {
585
+ try {
586
+ if (this.identitySupported &&
587
+ this.pairingState === "paired" &&
588
+ this.operatingMode === "active") {
589
+ const identities = await (0, equipment_identity_1.equipmentIdentities)(this.app.getSelfPath("electrical"), typeof this.app.getPath === "function"
590
+ ? this.app.getPath("/sources")
591
+ : {});
592
+ for (const identity of identities) {
593
+ if (this.stopped ||
594
+ !this.identitySupported ||
595
+ this.operatingMode !== "active")
596
+ break;
597
+ const key = identity.path + "\0" + identity.source;
598
+ if (this.identityValues.get(key) === identity.value)
599
+ continue;
600
+ await this.capture({
601
+ context: "vessels.self",
602
+ updates: [
603
+ {
604
+ $source: identity.source,
605
+ values: [{ path: identity.path, value: identity.value }],
606
+ },
607
+ ],
608
+ });
609
+ this.identityValues.set(key, identity.value);
610
+ if (this.identityValues.size > 384)
611
+ this.identityValues.delete(this.identityValues.keys().next().value);
612
+ }
613
+ }
614
+ }
615
+ catch {
616
+ /* Optional identity reads never interrupt ordinary telemetry. */
617
+ }
618
+ if (!this.stopped)
619
+ this.identityTimer = setTimeout(() => {
620
+ void poll();
621
+ }, this.identityPolls++ < 5 ? 30_000 : 600_000);
622
+ };
623
+ void poll();
624
+ }
552
625
  startEnergyControl() {
553
626
  if (this.stopped ||
554
627
  this.energyExecutor ||
@@ -623,6 +696,7 @@ class ConnectorRuntime {
623
696
  this.pairingState = "paired";
624
697
  await this.applyRuntimeConfiguration(response.runtime_configuration);
625
698
  this.startEnergyControl();
699
+ this.startEquipmentIdentity();
626
700
  if (this.unsubscribes.length === 0) {
627
701
  this.operatingMode = response.runtime_configuration.operating_mode;
628
702
  this.configureSubscriptions();
@@ -667,7 +741,58 @@ class ConnectorRuntime {
667
741
  this.startSafetyTimer();
668
742
  await this.updateStatus();
669
743
  }
744
+ startVictronInputLimit() {
745
+ const events = this.app;
746
+ if (!events.on || !events.removeListener || this.stopVictronInputLimit)
747
+ return;
748
+ const listener = (message) => {
749
+ if (this.stopped ||
750
+ (!this.victronInputLimitSupported && !this.nmeaEnergySupported) ||
751
+ this.pairingState !== "paired" ||
752
+ this.operatingMode !== "active")
753
+ return;
754
+ if (this.nmeaEnergySupported) {
755
+ for (const observation of this.nmeaEnergy.observe(message, this.app.getPath("/sources"), Date.parse(this.now()))) {
756
+ this.app.handleMessage("aurora-blue-signalk-connector", {
757
+ context: "vessels.self",
758
+ updates: [
759
+ {
760
+ timestamp: observation.at,
761
+ $source: observation.source,
762
+ values: [
763
+ { path: observation.path, value: observation.value },
764
+ ],
765
+ },
766
+ ],
767
+ });
768
+ }
769
+ }
770
+ if (!this.victronInputLimitSupported)
771
+ return;
772
+ const sample = (0, victron_input_limit_1.victronInputLimit)(message, this.app.getPath("/sources"), this.app.getSelfPath("electrical"), Date.parse(this.now()));
773
+ if (!sample)
774
+ return;
775
+ this.app.handleMessage("aurora-blue-signalk-connector", {
776
+ context: "vessels.self",
777
+ updates: [
778
+ {
779
+ timestamp: sample.at,
780
+ $source: sample.source,
781
+ values: [{ path: sample.path, value: sample.value }],
782
+ },
783
+ ],
784
+ });
785
+ };
786
+ events.on("N2KAnalyzerOut", listener);
787
+ this.stopVictronInputLimit = () => events.removeListener("N2KAnalyzerOut", listener);
788
+ }
670
789
  async applyRuntimeConfiguration(configuration) {
790
+ this.identitySupported = configuration.equipment_identity === true;
791
+ this.nmeaEnergySupported = configuration.nmea_energy === true;
792
+ this.acTotalSupported = configuration.ac_total_power === true;
793
+ this.victronInputLimitSupported =
794
+ configuration.victron_input_limit === true;
795
+ this.startVictronInputLimit();
671
796
  const previousMode = this.operatingMode;
672
797
  this.runtimeConfigurationPollMs = configuration.poll_after_seconds * 1_000;
673
798
  this.runtimeConfigurationState = "current";
@@ -0,0 +1,12 @@
1
+ export interface EquipmentIdentity {
2
+ path: string;
3
+ value: string;
4
+ source: string;
5
+ }
6
+ /** Protocol NAME, not model text or bus address, identifies duplicate receive paths. */
7
+ export declare function nmeaPhysicalIdentity(value: unknown): string | null;
8
+ /** Optional GX adapter. Fixed executable, fixed read-only method, no shell or writes. */
9
+ export declare function readVictronProductName(service: string): Promise<string | null>;
10
+ /** Capture identity only for received electrical sources, plus identified GX controllers. */
11
+ export declare function equipmentIdentities(electrical: unknown, sources: unknown, readProduct?: typeof readVictronProductName): Promise<EquipmentIdentity[]>;
12
+ export declare const isEquipmentIdentityPath: (path: string) => boolean;
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isEquipmentIdentityPath = void 0;
4
+ exports.nmeaPhysicalIdentity = nmeaPhysicalIdentity;
5
+ exports.readVictronProductName = readVictronProductName;
6
+ exports.equipmentIdentities = equipmentIdentities;
7
+ const node_child_process_1 = require("node:child_process");
8
+ const node_crypto_1 = require("node:crypto");
9
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
10
+ ? value
11
+ : {};
12
+ const text = (value) => typeof value === "string" &&
13
+ value.trim() &&
14
+ value.length <= 120 &&
15
+ !/[\x00-\x1f]/.test(value)
16
+ ? value.trim()
17
+ : null;
18
+ const productText = (value) => {
19
+ const name = text(value);
20
+ return name && !/^[A-Za-z0-9]{12,}$/.test(name) ? name : null;
21
+ };
22
+ const servicePattern = /^com\.victronenergy\.(?:vebus|solarcharger|battery|charger|alternator)\.[A-Za-z0-9_]{1,100}$/;
23
+ /** Protocol NAME, not model text or bus address, identifies duplicate receive paths. */
24
+ function nmeaPhysicalIdentity(value) {
25
+ const name = text(value);
26
+ if (!name)
27
+ return null;
28
+ let normalized;
29
+ try {
30
+ if (/^(?:0x)?[0-9a-fA-F]{16}$/.test(name))
31
+ normalized = BigInt("0x" + name.replace(/^0x/, ""));
32
+ else if (/^[0-9]{1,20}$/.test(name))
33
+ normalized = BigInt(name);
34
+ else
35
+ return null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ if (normalized <= 0n || normalized > 0xffffffffffffffffn)
41
+ return null;
42
+ return (0, node_crypto_1.createHash)("sha256")
43
+ .update("nmea2000:" + normalized.toString(16).padStart(16, "0"))
44
+ .digest("hex")
45
+ .slice(0, 32);
46
+ }
47
+ /** Optional GX adapter. Fixed executable, fixed read-only method, no shell or writes. */
48
+ function readVictronProductName(service) {
49
+ if (process.platform !== "linux" || !servicePattern.test(service))
50
+ return Promise.resolve(null);
51
+ return new Promise((resolve) => {
52
+ (0, node_child_process_1.execFile)("/usr/bin/dbus-send", [
53
+ "--system",
54
+ "--print-reply",
55
+ `--dest=${service}`,
56
+ "/ProductName",
57
+ "com.victronenergy.BusItem.GetValue",
58
+ ], { timeout: 1500, maxBuffer: 8192 }, (error, stdout) => {
59
+ if (error)
60
+ return resolve(null);
61
+ const match = /variant\s+string\s+("(?:[^"\\]|\\.)*")/.exec(stdout);
62
+ try {
63
+ resolve(match ? productText(JSON.parse(match[1])) : null);
64
+ }
65
+ catch {
66
+ resolve(null);
67
+ }
68
+ });
69
+ });
70
+ }
71
+ /** Capture identity only for received electrical sources, plus identified GX controllers. */
72
+ async function equipmentIdentities(electrical, sources, readProduct = readVictronProductName) {
73
+ const found = [];
74
+ const nmea = new Map();
75
+ for (const [provider, collection] of Object.entries(record(sources)).slice(0, 64)) {
76
+ for (const [address, device] of Object.entries(record(collection)).slice(0, 128)) {
77
+ const identity = record(record(device).n2k);
78
+ const model = productText(identity.modelId ?? identity["Model ID"]);
79
+ if (!model)
80
+ continue;
81
+ nmea.set(`${provider}.${address}`, {
82
+ model,
83
+ manufacturer: text(identity.manufacturerCode ?? identity["Manufacturer Code"]),
84
+ stable: `${provider}:${text(identity.canName) ?? address}`,
85
+ functionCode: identity.deviceFunction,
86
+ physicalIdentity: nmeaPhysicalIdentity(identity.canName),
87
+ });
88
+ }
89
+ }
90
+ const products = new Map();
91
+ for (const group of [
92
+ "inverters",
93
+ "chargers",
94
+ "solar",
95
+ "batteries",
96
+ "alternators",
97
+ ]) {
98
+ for (const [instance, raw] of Object.entries(record(record(electrical)[group])).slice(0, 32)) {
99
+ if (!/^[A-Za-z0-9_:-]{1,80}$/.test(instance))
100
+ continue;
101
+ const upstream = new Set();
102
+ const visit = (node, depth) => {
103
+ if (depth > 3)
104
+ return;
105
+ const item = record(node);
106
+ if (typeof item.$source === "string")
107
+ upstream.add(item.$source);
108
+ for (const child of Object.values(item))
109
+ if (child && typeof child === "object")
110
+ visit(child, depth + 1);
111
+ };
112
+ visit(raw, 0);
113
+ // An aggregate with competing providers cannot establish one product identity.
114
+ if (upstream.size !== 1)
115
+ continue;
116
+ const source = [...upstream][0];
117
+ const native = nmea.get(source);
118
+ let model = native?.model ?? null;
119
+ let manufacturer = native?.manufacturer ?? null;
120
+ let provenance = `aboard.metadata.nmea2000:${source}`;
121
+ const service = source.startsWith("venus.") ? source.slice(6) : "";
122
+ if (servicePattern.test(service)) {
123
+ if (!products.has(service) && products.size < 16)
124
+ products.set(service, readProduct(service));
125
+ model = productText(await products.get(service));
126
+ manufacturer =
127
+ model &&
128
+ /^(?:Quattro|MultiPlus|EasySolar|SmartSolar|BlueSolar|Lynx Shunt|SmartShunt|BMV(?:-|\s))/i.test(model)
129
+ ? "Victron Energy"
130
+ : null;
131
+ provenance = `aboard.metadata.dbus:${service}`;
132
+ }
133
+ if (!model)
134
+ continue;
135
+ const base = `electrical.${group}.${instance}`;
136
+ found.push({
137
+ path: `${base}.productName`,
138
+ value: model,
139
+ source: provenance,
140
+ });
141
+ if (manufacturer)
142
+ found.push({
143
+ path: `${base}.manufacturer`,
144
+ value: manufacturer,
145
+ source: provenance,
146
+ });
147
+ }
148
+ }
149
+ // Reuse the existing AC name contract; never infer input/output from bus numbers.
150
+ for (const [instance, raw] of Object.entries(record(record(electrical).ac)).slice(0, 32)) {
151
+ if (!/^[A-Za-z0-9_:-]{1,80}$/.test(instance))
152
+ continue;
153
+ const node = record(raw);
154
+ if (text(record(node.name).value))
155
+ continue;
156
+ const upstream = new Set();
157
+ const visit = (value, depth) => {
158
+ if (depth > 3)
159
+ return;
160
+ const item = record(value);
161
+ if (typeof item.$source === "string")
162
+ upstream.add(item.$source);
163
+ for (const child of Object.values(item))
164
+ if (child && typeof child === "object")
165
+ visit(child, depth + 1);
166
+ };
167
+ visit(raw, 0);
168
+ if (upstream.size !== 1)
169
+ continue;
170
+ const source = [...upstream][0];
171
+ const identity = nmea.get(source);
172
+ if (!identity || identity.manufacturer !== "Victron Energy")
173
+ continue;
174
+ // Victron's established function mapping reverses the generic NMEA labels.
175
+ const role = identity.functionCode === 153
176
+ ? "AC output"
177
+ : identity.functionCode === 154
178
+ ? "AC input"
179
+ : null;
180
+ if (!role)
181
+ continue;
182
+ found.push({
183
+ path: `electrical.ac.${instance}.name`,
184
+ value: `Victron Energy - ${identity.model.slice(0, 85)} - ${role}`,
185
+ source: `aboard.metadata.nmea2000:${identity.functionCode === 153 ? "output" : "input"}:${identity.physicalIdentity ? "n2k_" + identity.physicalIdentity + ":" : ""}${source}`,
186
+ });
187
+ }
188
+ for (const [source, identity] of nmea) {
189
+ if (identity.manufacturer !== "Victron Energy" ||
190
+ !/^(?:Ekrano GX|Cerbo GX(?: MK2)?|Venus GX|Color Control GX|Octo GX)$/i.test(identity.model))
191
+ continue;
192
+ const id = (0, node_crypto_1.createHash)("sha256")
193
+ .update(identity.stable)
194
+ .digest("hex")
195
+ .slice(0, 16);
196
+ const base = `electrical.controllers.n2k_${id}`;
197
+ found.push({
198
+ path: `${base}.productName`,
199
+ value: identity.model,
200
+ source: `aboard.metadata.nmea2000:${source}`,
201
+ });
202
+ found.push({
203
+ path: `${base}.manufacturer`,
204
+ value: identity.manufacturer,
205
+ source: `aboard.metadata.nmea2000:${source}`,
206
+ });
207
+ }
208
+ return found.slice(0, 192);
209
+ }
210
+ const isEquipmentIdentityPath = (path) => /^electrical\.(?:inverters|chargers|solar|batteries|alternators|controllers)\.[A-Za-z0-9_:-]+\.(?:productName|manufacturer)$/.test(path);
211
+ exports.isEquipmentIdentityPath = isEquipmentIdentityPath;
@@ -14,7 +14,15 @@ const runtimeConfigurationFrom = (value) => {
14
14
  return defaultRuntimeConfiguration();
15
15
  }
16
16
  const configuration = value;
17
- if (typeof configuration.version !== "number" ||
17
+ if ((configuration.nmea_energy !== undefined &&
18
+ typeof configuration.nmea_energy !== "boolean") ||
19
+ (configuration.victron_input_limit !== undefined &&
20
+ typeof configuration.victron_input_limit !== "boolean") ||
21
+ (configuration.ac_total_power !== undefined &&
22
+ typeof configuration.ac_total_power !== "boolean") ||
23
+ (configuration.equipment_identity !== undefined &&
24
+ typeof configuration.equipment_identity !== "boolean") ||
25
+ typeof configuration.version !== "number" ||
18
26
  !Number.isInteger(configuration.version) ||
19
27
  configuration.version < 1 ||
20
28
  !["active", "standby", "disabled"].includes(String(configuration.operating_mode)) ||
@@ -0,0 +1,13 @@
1
+ export interface NmeaEnergyObservation {
2
+ path: string;
3
+ value: number | string;
4
+ source: string;
5
+ at: string;
6
+ }
7
+ /** Receive-only normalization. Device NAME and DC type separate shared data instances. */
8
+ export declare class NmeaEnergy {
9
+ private readonly dcTypes;
10
+ private readonly inverterDcInstances;
11
+ private readonly states;
12
+ observe(message: unknown, sources: unknown, now: number): NmeaEnergyObservation[];
13
+ }
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NmeaEnergy = void 0;
4
+ const equipment_identity_1 = require("./equipment-identity");
5
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
6
+ ? value
7
+ : {};
8
+ const victron = (value) => value === 358 || value === "Victron Energy";
9
+ /** Receive-only normalization. Device NAME and DC type separate shared data instances. */
10
+ class NmeaEnergy {
11
+ dcTypes = new Map();
12
+ inverterDcInstances = new Map();
13
+ states = new Map();
14
+ observe(message, sources, now) {
15
+ const pgn = object(message), fields = object(pgn.fields);
16
+ if (![127506, 127508, 127507, 127509].includes(Number(pgn.pgn)) ||
17
+ pgn.direction !== "R" ||
18
+ pgn.dst !== 255 ||
19
+ !Number.isInteger(pgn.src) ||
20
+ Number(pgn.src) < 0 ||
21
+ Number(pgn.src) > 252)
22
+ return [];
23
+ const at = typeof pgn.timestamp === "string" ? pgn.timestamp : "", clock = Date.parse(at);
24
+ if (!Number.isFinite(clock) || clock > now + 30000 || now - clock > 120000)
25
+ return [];
26
+ const providers = Object.entries(object(sources))
27
+ .slice(0, 64)
28
+ .flatMap(([provider, collection]) => {
29
+ const raw = object(object(collection)[String(pgn.src)]);
30
+ return Object.keys(raw).length
31
+ ? [{ provider, identity: object(raw.n2k) }]
32
+ : [];
33
+ });
34
+ if (providers.length !== 1)
35
+ return [];
36
+ const { provider, identity } = providers[0];
37
+ const physical = (0, equipment_identity_1.nmeaPhysicalIdentity)(identity.canName);
38
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(provider) ||
39
+ !physical ||
40
+ !victron(identity.manufacturerCode))
41
+ return [];
42
+ const source = `${provider}.${pgn.src}`, id = `n2k_${physical}`, key = `${provider}:${physical}`;
43
+ const observations = [];
44
+ const emit = (path, value) => observations.push({ path, value, source, at });
45
+ if (identity.deviceFunction === 160 &&
46
+ /^(?:SmartSolar|BlueSolar)\b/.test(String(identity.modelId))) {
47
+ if (!Number.isInteger(fields.instance) ||
48
+ Number(fields.instance) < 0 ||
49
+ Number(fields.instance) > 252)
50
+ return [];
51
+ const instance = `${key}:${fields.instance}`;
52
+ if (pgn.pgn === 127506 &&
53
+ ["Battery", "Solar cell"].includes(String(fields.dcType))) {
54
+ const previous = this.dcTypes.get(instance);
55
+ if (!previous || clock >= previous.at)
56
+ this.dcTypes.set(instance, {
57
+ type: String(fields.dcType),
58
+ at: clock,
59
+ });
60
+ }
61
+ if (pgn.pgn === 127508) {
62
+ const type = this.dcTypes.get(instance);
63
+ const outputs = [...this.dcTypes].filter(([entry, value]) => entry.startsWith(key + ":") &&
64
+ value.type === "Battery" &&
65
+ clock - value.at <= 120000);
66
+ if (type?.type === "Battery" &&
67
+ Math.abs(clock - type.at) <= 120000 &&
68
+ outputs.length === 1) {
69
+ for (const field of ["voltage", "current"]) {
70
+ const value = fields[field];
71
+ if (typeof value === "number" &&
72
+ Number.isFinite(value) &&
73
+ (field !== "voltage" || value >= 0))
74
+ emit(`electrical.solar.${id}.${field}`, value);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ if (identity.deviceFunction === 153 &&
80
+ /^(?:Quattro|MultiPlus|Phoenix|EasySolar)\b/.test(String(identity.modelId))) {
81
+ if (pgn.pgn === 127509) {
82
+ const previous = this.inverterDcInstances.get(key);
83
+ if (!previous || clock >= previous.at) {
84
+ if (Number.isInteger(fields.dcInstance) &&
85
+ Number(fields.dcInstance) >= 0 &&
86
+ Number(fields.dcInstance) <= 252)
87
+ this.inverterDcInstances.set(key, {
88
+ instance: Number(fields.dcInstance),
89
+ at: clock,
90
+ });
91
+ else
92
+ this.inverterDcInstances.delete(key);
93
+ }
94
+ }
95
+ if (pgn.pgn === 127508) {
96
+ const channel = this.inverterDcInstances.get(key);
97
+ if (channel &&
98
+ fields.instance === channel.instance &&
99
+ Math.abs(clock - channel.at) <= 120000) {
100
+ for (const field of ["voltage", "current"]) {
101
+ const value = fields[field];
102
+ if (typeof value === "number" &&
103
+ Number.isFinite(value) &&
104
+ (field !== "voltage" || value >= 0))
105
+ emit(`electrical.inverters.${id}.dc.${field}`, value);
106
+ }
107
+ }
108
+ }
109
+ const state = typeof fields.operatingState === "string"
110
+ ? fields.operatingState
111
+ : null;
112
+ if (state && (pgn.pgn === 127507 || pgn.pgn === 127509)) {
113
+ const entry = `${key}:${pgn.pgn}`, previous = this.states.get(entry);
114
+ if (!previous || clock >= previous.at)
115
+ this.states.set(entry, { value: state, at: clock });
116
+ const inverter = this.states.get(`${key}:127509`), charger = this.states.get(`${key}:127507`);
117
+ if (inverter && Math.abs(clock - inverter.at) <= 10000) {
118
+ const inverterModes = {
119
+ "Inverter on": "inverting",
120
+ Inverting: "inverting",
121
+ "AC passthru": "passthrough",
122
+ Off: "disabled",
123
+ Standby: "standby",
124
+ Fault: "faulted",
125
+ };
126
+ const chargerModes = {
127
+ Bulk: "bulk",
128
+ Absorption: "absorption",
129
+ Float: "float",
130
+ Equalize: "equalize",
131
+ Overcharge: "overcharge",
132
+ };
133
+ let mode = inverterModes[inverter.value];
134
+ if (mode === "passthrough" &&
135
+ charger &&
136
+ Math.abs(clock - charger.at) <= 10000)
137
+ mode = chargerModes[charger.value] ?? mode;
138
+ if (mode)
139
+ emit(`electrical.inverters.${id}.inverterMode`, mode);
140
+ }
141
+ }
142
+ }
143
+ for (const cache of [this.dcTypes, this.states, this.inverterDcInstances])
144
+ while (cache.size > 256)
145
+ cache.delete(cache.keys().next().value);
146
+ return observations;
147
+ }
148
+ }
149
+ exports.NmeaEnergy = NmeaEnergy;
@@ -20,6 +20,7 @@ export declare class NoForeignLandPublisher {
20
20
  private readonly emit;
21
21
  private readonly request;
22
22
  private readonly now;
23
+ private readonly availableBytes;
23
24
  private journal;
24
25
  private state;
25
26
  private message;
@@ -36,7 +37,7 @@ export declare class NoForeignLandPublisher {
36
37
  private nextSendAt;
37
38
  private readonly file;
38
39
  private journalBytes;
39
- constructor(directory: string, config: NoForeignLandConfig, emit: (status: ReturnType<NoForeignLandPublisher["status"]>) => void, request?: typeof fetch, now?: () => number);
40
+ constructor(directory: string, config: NoForeignLandConfig, emit: (status: ReturnType<NoForeignLandPublisher["status"]>) => void, request?: typeof fetch, now?: () => number, availableBytes?: () => Promise<number>);
40
41
  status(): {
41
42
  state: PublisherState;
42
43
  message: string;
@@ -16,6 +16,7 @@ class NoForeignLandPublisher {
16
16
  emit;
17
17
  request;
18
18
  now;
19
+ availableBytes;
19
20
  journal;
20
21
  state = "disabled";
21
22
  message = "Disabled";
@@ -32,12 +33,16 @@ class NoForeignLandPublisher {
32
33
  nextSendAt = 0;
33
34
  file;
34
35
  journalBytes = 0;
35
- constructor(directory, config, emit, request = fetch, now = Date.now) {
36
+ constructor(directory, config, emit, request = fetch, now = Date.now, availableBytes = async () => {
37
+ const capacity = await (0, promises_1.statfs)(directory);
38
+ return capacity.bavail * capacity.bsize;
39
+ }) {
36
40
  this.directory = directory;
37
41
  this.config = config;
38
42
  this.emit = emit;
39
43
  this.request = request;
40
44
  this.now = now;
45
+ this.availableBytes = availableBytes;
41
46
  this.file = (0, node_path_1.join)(directory, "publisher.jsonl");
42
47
  this.journal = {
43
48
  version: 1,
@@ -348,8 +353,7 @@ class NoForeignLandPublisher {
348
353
  }
349
354
  }
350
355
  async save(updated) {
351
- const capacity = await (0, promises_1.statfs)(this.directory);
352
- if (capacity.bavail * capacity.bsize < 32 * 1024 * 1024)
356
+ if ((await this.availableBytes()) < 32 * 1024 * 1024)
353
357
  throw new Error("reserve");
354
358
  if (this.journalBytes === 0 || this.journalBytes > 2_000_000) {
355
359
  const payload = JSON.stringify(updated) + "\n";
@@ -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),
@@ -65,36 +73,38 @@ exports.SELF_TELEMETRY = [
65
73
  self("electrical.inverters.*.acout.voltage", "number", "scalar", 5_000, 60_000, 1),
66
74
  self("electrical.inverters.*.acout.current", "number", "scalar", 5_000, 60_000, 1),
67
75
  self("electrical.inverters.*.acout.frequency", "number", "scalar", 5_000, 60_000, 1),
68
- self("electrical.venus.dcPower", "number", "scalar", 5_000, 60_000, 1),
76
+ self("electrical.venus.dcPower", "number", "scalar", 1_000, 20_000, 0.5),
69
77
  self("electrical.venus.acSource", "string", "identity", 5_000, 60_000, 0),
70
78
  self("electrical.venus.state", "string", "identity", 5_000, 60_000, 0),
71
- self("electrical.batteries.*.power", "number", "scalar", 5_000, 60_000, 1),
79
+ self("electrical.batteries.*.power", "number", "scalar", 1_000, 20_000, 0.5),
72
80
  self("electrical.batteries.*.capacity.remaining", "number", "scalar", 5_000, 60_000, 1),
73
81
  self("electrical.batteries.*.capacity.nominal", "number", "scalar", 5_000, 60_000, 1),
74
- self("electrical.solar.*.voltage", "number", "scalar", 5_000, 60_000, 1),
75
- self("electrical.solar.*.current", "number", "scalar", 5_000, 60_000, 1),
76
- self("electrical.solar.*.power", "number", "scalar", 5_000, 60_000, 1),
77
- self("electrical.chargers.*.voltage", "number", "scalar", 5_000, 60_000, 1),
78
- self("electrical.chargers.*.current", "number", "scalar", 5_000, 60_000, 1),
79
- self("electrical.chargers.*.power", "number", "scalar", 5_000, 60_000, 1),
80
- self("electrical.alternators.*.voltage", "number", "scalar", 5_000, 60_000, 1),
81
- self("electrical.alternators.*.current", "number", "scalar", 5_000, 60_000, 1),
82
- self("electrical.alternators.*.power", "number", "scalar", 5_000, 60_000, 1),
82
+ self("electrical.solar.*.voltage", "number", "scalar", 1_000, 20_000, 0.005),
83
+ self("electrical.solar.*.current", "number", "scalar", 1_000, 20_000, 0.01),
84
+ self("electrical.solar.*.power", "number", "scalar", 1_000, 20_000, 0.5),
85
+ self("electrical.chargers.*.voltage", "number", "scalar", 1_000, 20_000, 0.005),
86
+ self("electrical.chargers.*.current", "number", "scalar", 1_000, 20_000, 0.01),
87
+ self("electrical.chargers.*.power", "number", "scalar", 1_000, 20_000, 0.5),
88
+ self("electrical.alternators.*.voltage", "number", "scalar", 1_000, 20_000, 0.005),
89
+ self("electrical.alternators.*.current", "number", "scalar", 1_000, 20_000, 0.01),
90
+ self("electrical.alternators.*.power", "number", "scalar", 1_000, 20_000, 0.5),
83
91
  self("electrical.solar.*.panelPower", "number", "scalar", 5_000, 60_000, 1),
84
92
  self("electrical.solar.*.yieldToday", "number", "scalar", 5_000, 60_000, 1),
85
- self("electrical.inverters.*.dc.voltage", "number", "scalar", 5_000, 60_000, 1),
86
- self("electrical.inverters.*.dc.current", "number", "scalar", 5_000, 60_000, 1),
87
- self("electrical.inverters.*.dc.power", "number", "scalar", 5_000, 60_000, 1),
93
+ self("electrical.inverters.*.dc.voltage", "number", "scalar", 1_000, 20_000, 0.005),
94
+ self("electrical.inverters.*.dc.current", "number", "scalar", 1_000, 20_000, 0.01),
95
+ self("electrical.inverters.*.dc.power", "number", "scalar", 1_000, 20_000, 0.5),
88
96
  self("electrical.inverters.*.ac.realPower", "number", "scalar", 5_000, 60_000, 1),
89
97
  self("electrical.inverters.*.ac.lineNeutralVoltage", "number", "scalar", 5_000, 60_000, 1),
90
98
  self("electrical.inverters.*.ac.frequency", "number", "scalar", 5_000, 60_000, 1),
99
+ self("electrical.ac.*.activeInputCurrentLimit", "number", "scalar", 5_000, 60_000, 0.1),
100
+ self("electrical.ac.*.total.realPower", "number", "scalar", 5_000, 60_000, 1),
91
101
  self("electrical.ac.*.phase.*.realPower", "number", "scalar", 5_000, 60_000, 1),
92
102
  self("electrical.ac.*.phase.*.lineNeutralVoltage", "number", "scalar", 5_000, 60_000, 1),
93
103
  self("electrical.ac.*.phase.*.current", "number", "scalar", 5_000, 60_000, 1),
94
104
  self("electrical.ac.*.phase.*.frequency", "number", "scalar", 5_000, 60_000, 1),
95
- self("electrical.dc.*.power", "number", "scalar", 5_000, 60_000, 1),
96
- self("electrical.dc.*.voltage", "number", "scalar", 5_000, 60_000, 1),
97
- self("electrical.dc.*.current", "number", "scalar", 5_000, 60_000, 1),
105
+ self("electrical.dc.*.power", "number", "scalar", 1_000, 20_000, 0.5),
106
+ self("electrical.dc.*.voltage", "number", "scalar", 1_000, 20_000, 0.005),
107
+ self("electrical.dc.*.current", "number", "scalar", 1_000, 20_000, 0.01),
98
108
  self("electrical.batteries.*.name", "string", "identity", 30_000, 600_000, 0),
99
109
  self("electrical.solar.*.name", "string", "identity", 30_000, 600_000, 0),
100
110
  self("electrical.chargers.*.name", "string", "identity", 30_000, 600_000, 0),
@@ -104,8 +114,8 @@ exports.SELF_TELEMETRY = [
104
114
  self("electrical.dc.*.name", "string", "identity", 30_000, 600_000, 0),
105
115
  self("electrical.inverters.*.inverterMode", "string", "identity", 30_000, 60_000, 0),
106
116
  self("electrical.chargers.*.chargingMode", "string", "identity", 30_000, 60_000, 0),
107
- self("electrical.batteries.*.voltage", "number", "scalar", 10_000, 300_000, 0.1),
108
- self("electrical.batteries.*.current", "number", "scalar", 5_000, 120_000, 1),
117
+ self("electrical.batteries.*.voltage", "number", "scalar", 1_000, 20_000, 0.005),
118
+ self("electrical.batteries.*.current", "number", "scalar", 1_000, 20_000, 0.01),
109
119
  self("electrical.batteries.*.temperature", "number", "scalar", 30_000, 600_000, 0.5),
110
120
  self("electrical.batteries.*.capacity.stateOfCharge", "number", "scalar", 30_000, 600_000, 0.01),
111
121
  self("electrical.batteries.*.capacity.timeRemaining", "number", "scalar", 300_000, 1_800_000, 60),
@@ -5,6 +5,10 @@ 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;
9
+ nmea_energy?: boolean;
10
+ ac_total_power?: boolean;
11
+ victron_input_limit?: boolean;
8
12
  version: number;
9
13
  operating_mode: OperatingMode;
10
14
  poll_after_seconds: number;
@@ -1 +1 @@
1
- export declare const CONNECTOR_VERSION = "0.2.0-dev.40";
1
+ export declare const CONNECTOR_VERSION = "0.2.0-dev.42";
@@ -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.40";
4
+ exports.CONNECTOR_VERSION = "0.2.0-dev.42";
@@ -0,0 +1,7 @@
1
+ /** Decode received broadcast data only. This adapter never sends a CAN request or command. */
2
+ export declare function victronInputLimit(message: unknown, sources: unknown, electrical: unknown, now: number): {
3
+ path: string;
4
+ value: number;
5
+ source: string;
6
+ at: string;
7
+ } | null;
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.victronInputLimit = victronInputLimit;
4
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
5
+ ? value
6
+ : {};
7
+ const victron = (value) => value === 358 || value === "Victron Energy";
8
+ /** Decode received broadcast data only. This adapter never sends a CAN request or command. */
9
+ function victronInputLimit(message, sources, electrical, now) {
10
+ const pgn = object(message), fields = object(pgn.fields);
11
+ if (pgn.pgn !== 61184 ||
12
+ pgn.dst !== 255 ||
13
+ pgn.direction !== "R" ||
14
+ !victron(fields.manufacturerCode) ||
15
+ fields.industryCode !== "Marine Industry" ||
16
+ fields.registerId !== 0x0203 ||
17
+ !Number.isInteger(fields.payload) ||
18
+ Number(fields.payload) < 0 ||
19
+ Number(fields.payload) >= 0xffff ||
20
+ !Number.isInteger(pgn.src) ||
21
+ Number(pgn.src) < 0 ||
22
+ Number(pgn.src) > 252)
23
+ return null;
24
+ const at = typeof pgn.timestamp === "string" ? pgn.timestamp : "", clock = Date.parse(at);
25
+ if (!Number.isFinite(clock) || clock > now + 30000 || now - clock > 120000)
26
+ return null;
27
+ // The global parser event has no provider label. Ambiguous receiver addresses stay unresolved.
28
+ const providers = Object.entries(object(sources))
29
+ .slice(0, 64)
30
+ .flatMap(([provider, collection]) => {
31
+ const device = object(object(collection)[String(pgn.src)]);
32
+ return Object.keys(device).length
33
+ ? [{ provider, n2k: object(device.n2k) }]
34
+ : [];
35
+ });
36
+ if (providers.length !== 1)
37
+ return null;
38
+ const { provider, n2k } = providers[0];
39
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(provider) ||
40
+ !victron(n2k.manufacturerCode) ||
41
+ n2k.deviceFunction !== 153)
42
+ return null;
43
+ const source = `${provider}.${pgn.src}`;
44
+ const meters = Object.entries(object(object(electrical).ac))
45
+ .slice(0, 100)
46
+ .filter(([id, value]) => {
47
+ if (!/^[A-Za-z0-9_:-]{1,100}$/.test(id))
48
+ return false;
49
+ const phase = object(object(value).phase), total = object(object(value).total);
50
+ return [
51
+ total.realPower,
52
+ ...Object.values(phase).flatMap((value) => Object.values(object(value))),
53
+ ].some((value) => object(value).$source === source);
54
+ });
55
+ if (meters.length !== 1)
56
+ return null;
57
+ return {
58
+ path: `electrical.ac.${meters[0][0]}.activeInputCurrentLimit`,
59
+ value: Number(fields.payload) / 10,
60
+ source,
61
+ at,
62
+ };
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aurora-blue/signalk-connector",
3
- "version": "0.2.0-dev.40",
3
+ "version": "0.2.0-dev.42",
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",
@@ -37,7 +37,7 @@
37
37
  "prepack": "npm run build",
38
38
  "replay:signalk": "npm run build && node tools/replay-signalk.mjs",
39
39
  "simulate:offline": "npm run build && node tools/simulate-offline-buffer.mjs",
40
- "test": "npm run build && node --test dist/test/*.test.js",
40
+ "test": "npm run build && node --test --test-concurrency=1 dist/test/*.test.js",
41
41
  "test:tools": "node --test tools/test/*.test.mjs",
42
42
  "typecheck": "tsc -p tsconfig.json --noEmit",
43
43
  "verify:package": "npm pack --dry-run",
@@ -48,8 +48,8 @@
48
48
  "release:verify": "node tools/release.mjs verify"
49
49
  },
50
50
  "devDependencies": {
51
- "@signalk/server-api": "2.35.0",
52
- "@types/node": "24.19.2",
51
+ "@signalk/server-api": "2.37.0",
52
+ "@types/node": "26.6.6",
53
53
  "prettier": "3.9.10",
54
54
  "typescript": "7.0.2"
55
55
  },
@@ -1,6 +1,8 @@
1
1
  /* Aurora Blue Web UI CI 1.46 implementation tokens. */
2
2
  :root {
3
+ --aboard-map-seamark-night: #79b8ea;
3
4
  color-scheme: light;
5
+ --aboard-product-gradient: radial-gradient(ellipse farthest-corner at 0% 0%,rgb(var(--aboard-surface-rgb)) 0%,rgb(var(--aboard-control-rgb) / .9) 48%,rgb(var(--aboard-accent-rgb) / .42) 100%);
4
6
  --aboard-font-xs: .75rem;
5
7
  --aboard-font-body: .875rem;
6
8
  --aboard-font-base: 1rem;
@@ -34,6 +36,7 @@
34
36
  --aboard-border-rgb: 79 119 126;
35
37
  --aboard-text: #17343c;
36
38
  --aboard-heading: #0d2a32;
39
+ --aboard-weather-label: #000;
37
40
  --aboard-muted: #60777d;
38
41
  --aboard-muted-strong: #486a72;
39
42
  --aboard-accent: #167f88;
@@ -44,6 +47,8 @@
44
47
  --aboard-success-rgb: 24 118 80;
45
48
  --aboard-warning: #9a6508;
46
49
  --aboard-danger: #b83245;
50
+ --aboard-weather-danger: #a41d34;
51
+ --aboard-weather-danger-fill: #ed344b;
47
52
  --aboard-scrim-rgb: 15 34 39;
48
53
  --aboard-shadow-rgb: 25 51 57;
49
54
  --aboard-map-departure: #f0c76e;
@@ -53,6 +58,7 @@
53
58
  --aboard-map-land: #e6ebe1;
54
59
  --aboard-map-coast: #789197;
55
60
  --aboard-map-grid: #6f8c92;
61
+ --aboard-map-ais: #076f2d;
56
62
  --aboard-map-label: #345860;
57
63
  --aboard-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='m5 7.5 5 5 5-5' fill='none' stroke='%2360777d' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.7'/%3E%3C/svg%3E");
58
64
  --aboard-button-bg: rgb(var(--aboard-control-rgb) / .58);
@@ -61,8 +67,12 @@
61
67
  background: var(--aboard-bg);
62
68
  }
63
69
 
64
- :root[data-theme='dark'] {
70
+ :root[data-theme='dark'],
71
+ .aboard-provisioning-stock-card--photo,
72
+ .aboard-marketing [data-theme='dark'],
73
+ .aboard-marketing.home .aboard-website-header-shell {
65
74
  color-scheme: dark;
75
+ --aboard-product-gradient: radial-gradient(ellipse farthest-corner at 0% 0%,rgb(var(--aboard-accent-rgb) / .38) 0%,rgb(var(--aboard-control-rgb)) 58%,rgb(var(--aboard-surface-rgb)) 100%);
66
76
  --aboard-bg: #061019;
67
77
  --aboard-bg-rgb: 6 16 25;
68
78
  --aboard-surface: #071923;
@@ -76,6 +86,7 @@
76
86
  --aboard-border-rgb: 129 190 200;
77
87
  --aboard-text: #e8f5f8;
78
88
  --aboard-heading: #f4fbfc;
89
+ --aboard-weather-label: #fff;
79
90
  --aboard-muted: #78959d;
80
91
  --aboard-muted-strong: #9ecbd0;
81
92
  --aboard-accent: #76e8e8;
@@ -86,12 +97,14 @@
86
97
  --aboard-success-rgb: 118 232 194;
87
98
  --aboard-warning: #f3d27f;
88
99
  --aboard-danger: #f1a2a2;
100
+ --aboard-weather-danger: #ff8989;
89
101
  --aboard-scrim-rgb: 0 7 11;
90
102
  --aboard-shadow-rgb: 0 0 0;
91
103
  --aboard-map-water: #102f3b;
92
104
  --aboard-map-land: #1d3435;
93
105
  --aboard-map-coast: #78959d;
94
106
  --aboard-map-grid: #66858e;
107
+ --aboard-map-ais: #35ad51;
95
108
  --aboard-map-label: #bce5e9;
96
109
  --aboard-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='m5 7.5 5 5 5-5' fill='none' stroke='%2378959d' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.7'/%3E%3C/svg%3E");
97
110
  }
@@ -99,6 +112,7 @@
99
112
  @media (prefers-color-scheme: dark) {
100
113
  :root:not([data-theme]) {
101
114
  color-scheme: dark;
115
+ --aboard-product-gradient: radial-gradient(ellipse farthest-corner at 0% 0%,rgb(var(--aboard-accent-rgb) / .38) 0%,rgb(var(--aboard-control-rgb)) 58%,rgb(var(--aboard-surface-rgb)) 100%);
102
116
  --aboard-bg: #061019;
103
117
  --aboard-bg-rgb: 6 16 25;
104
118
  --aboard-surface: #071923;
@@ -112,6 +126,7 @@
112
126
  --aboard-border-rgb: 129 190 200;
113
127
  --aboard-text: #e8f5f8;
114
128
  --aboard-heading: #f4fbfc;
129
+ --aboard-weather-label: #fff;
115
130
  --aboard-muted: #78959d;
116
131
  --aboard-muted-strong: #9ecbd0;
117
132
  --aboard-accent: #76e8e8;
@@ -122,12 +137,14 @@
122
137
  --aboard-success-rgb: 118 232 194;
123
138
  --aboard-warning: #f3d27f;
124
139
  --aboard-danger: #f1a2a2;
140
+ --aboard-weather-danger: #ff8989;
125
141
  --aboard-scrim-rgb: 0 7 11;
126
142
  --aboard-shadow-rgb: 0 0 0;
127
143
  --aboard-map-water: #102f3b;
128
144
  --aboard-map-land: #1d3435;
129
145
  --aboard-map-coast: #78959d;
130
146
  --aboard-map-grid: #66858e;
147
+ --aboard-map-ais: #35ad51;
131
148
  --aboard-map-label: #bce5e9;
132
149
  --aboard-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='m5 7.5 5 5 5-5' fill='none' stroke='%2378959d' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.7'/%3E%3C/svg%3E");
133
150
  }