@aurora-blue/signalk-connector 0.2.0-dev.41 → 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
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0-dev.41
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"),
@@ -25,6 +25,11 @@ export declare class ConnectorRuntime {
25
25
  private readonly random;
26
26
  private readonly unsubscribes;
27
27
  private identitySupported;
28
+ private nmeaEnergySupported;
29
+ private readonly nmeaEnergy;
30
+ private acTotalSupported;
31
+ private victronInputLimitSupported;
32
+ private stopVictronInputLimit;
28
33
  private identityStarted;
29
34
  private identityPolls;
30
35
  private identityTimer;
@@ -88,6 +93,7 @@ export declare class ConnectorRuntime {
88
93
  private scheduleRuntimeConfigurationPoll;
89
94
  private pollRuntimeConfiguration;
90
95
  private requirePairing;
96
+ private startVictronInputLimit;
91
97
  private applyRuntimeConfiguration;
92
98
  private updateStatus;
93
99
  private storageBudgetPolicy;
@@ -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 nmea_energy_1 = require("./nmea-energy");
5
+ const victron_input_limit_1 = require("./victron-input-limit");
4
6
  const equipment_identity_1 = require("./equipment-identity");
5
7
  const energy_control_1 = require("./energy-control");
6
8
  const node_path_1 = require("node:path");
@@ -39,6 +41,11 @@ class ConnectorRuntime {
39
41
  random;
40
42
  unsubscribes = [];
41
43
  identitySupported = false;
44
+ nmeaEnergySupported = false;
45
+ nmeaEnergy = new nmea_energy_1.NmeaEnergy();
46
+ acTotalSupported = false;
47
+ victronInputLimitSupported = false;
48
+ stopVictronInputLimit = null;
42
49
  identityStarted = false;
43
50
  identityPolls = 0;
44
51
  identityTimer = null;
@@ -192,6 +199,7 @@ class ConnectorRuntime {
192
199
  this.scheduleRuntimeConfigurationPoll(0);
193
200
  this.startEnergyControl();
194
201
  this.startEquipmentIdentity();
202
+ this.startVictronInputLimit();
195
203
  if (this.operatingMode !== "disabled") {
196
204
  this.schedule(0);
197
205
  }
@@ -201,6 +209,8 @@ class ConnectorRuntime {
201
209
  }
202
210
  async stop() {
203
211
  this.stopped = true;
212
+ this.stopVictronInputLimit?.();
213
+ this.stopVictronInputLimit = null;
204
214
  if (this.identityTimer)
205
215
  clearTimeout(this.identityTimer);
206
216
  this.energyExecutor?.stop();
@@ -230,7 +240,13 @@ class ConnectorRuntime {
230
240
  return;
231
241
  }
232
242
  const receivedAt = this.now();
233
- const events = (0, delta_1.eventsFromDelta)(delta, this.bootId, receivedAt, subscriptionScope).filter((event) => this.identitySupported || !(0, equipment_identity_1.isEquipmentIdentityPath)(event.path));
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)));
234
250
  for (const event of events)
235
251
  this.publisher?.observe(event);
236
252
  // Safety observes current positions immediately, even when historical
@@ -725,8 +741,58 @@ class ConnectorRuntime {
725
741
  this.startSafetyTimer();
726
742
  await this.updateStatus();
727
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
+ }
728
789
  async applyRuntimeConfiguration(configuration) {
729
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();
730
796
  const previousMode = this.operatingMode;
731
797
  this.runtimeConfigurationPollMs = configuration.poll_after_seconds * 1_000;
732
798
  this.runtimeConfigurationState = "current";
@@ -3,6 +3,8 @@ export interface EquipmentIdentity {
3
3
  value: string;
4
4
  source: string;
5
5
  }
6
+ /** Protocol NAME, not model text or bus address, identifies duplicate receive paths. */
7
+ export declare function nmeaPhysicalIdentity(value: unknown): string | null;
6
8
  /** Optional GX adapter. Fixed executable, fixed read-only method, no shell or writes. */
7
9
  export declare function readVictronProductName(service: string): Promise<string | null>;
8
10
  /** Capture identity only for received electrical sources, plus identified GX controllers. */
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isEquipmentIdentityPath = void 0;
4
+ exports.nmeaPhysicalIdentity = nmeaPhysicalIdentity;
4
5
  exports.readVictronProductName = readVictronProductName;
5
6
  exports.equipmentIdentities = equipmentIdentities;
6
7
  const node_child_process_1 = require("node:child_process");
@@ -19,6 +20,30 @@ const productText = (value) => {
19
20
  return name && !/^[A-Za-z0-9]{12,}$/.test(name) ? name : null;
20
21
  };
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
+ }
22
47
  /** Optional GX adapter. Fixed executable, fixed read-only method, no shell or writes. */
23
48
  function readVictronProductName(service) {
24
49
  if (process.platform !== "linux" || !servicePattern.test(service))
@@ -57,6 +82,8 @@ async function equipmentIdentities(electrical, sources, readProduct = readVictro
57
82
  model,
58
83
  manufacturer: text(identity.manufacturerCode ?? identity["Manufacturer Code"]),
59
84
  stable: `${provider}:${text(identity.canName) ?? address}`,
85
+ functionCode: identity.deviceFunction,
86
+ physicalIdentity: nmeaPhysicalIdentity(identity.canName),
60
87
  });
61
88
  }
62
89
  }
@@ -119,6 +146,45 @@ async function equipmentIdentities(electrical, sources, readProduct = readVictro
119
146
  });
120
147
  }
121
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
+ }
122
188
  for (const [source, identity] of nmea) {
123
189
  if (identity.manufacturer !== "Victron Energy" ||
124
190
  !/^(?:Ekrano GX|Cerbo GX(?: MK2)?|Venus GX|Color Control GX|Octo GX)$/i.test(identity.model))
@@ -14,8 +14,14 @@ const runtimeConfigurationFrom = (value) => {
14
14
  return defaultRuntimeConfiguration();
15
15
  }
16
16
  const configuration = value;
17
- if ((configuration.equipment_identity !== undefined &&
18
- typeof configuration.equipment_identity !== "boolean") ||
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") ||
19
25
  typeof configuration.version !== "number" ||
20
26
  !Number.isInteger(configuration.version) ||
21
27
  configuration.version < 1 ||
@@ -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";
@@ -73,36 +73,38 @@ exports.SELF_TELEMETRY = [
73
73
  self("electrical.inverters.*.acout.voltage", "number", "scalar", 5_000, 60_000, 1),
74
74
  self("electrical.inverters.*.acout.current", "number", "scalar", 5_000, 60_000, 1),
75
75
  self("electrical.inverters.*.acout.frequency", "number", "scalar", 5_000, 60_000, 1),
76
- self("electrical.venus.dcPower", "number", "scalar", 5_000, 60_000, 1),
76
+ self("electrical.venus.dcPower", "number", "scalar", 1_000, 20_000, 0.5),
77
77
  self("electrical.venus.acSource", "string", "identity", 5_000, 60_000, 0),
78
78
  self("electrical.venus.state", "string", "identity", 5_000, 60_000, 0),
79
- self("electrical.batteries.*.power", "number", "scalar", 5_000, 60_000, 1),
79
+ self("electrical.batteries.*.power", "number", "scalar", 1_000, 20_000, 0.5),
80
80
  self("electrical.batteries.*.capacity.remaining", "number", "scalar", 5_000, 60_000, 1),
81
81
  self("electrical.batteries.*.capacity.nominal", "number", "scalar", 5_000, 60_000, 1),
82
- self("electrical.solar.*.voltage", "number", "scalar", 5_000, 60_000, 1),
83
- self("electrical.solar.*.current", "number", "scalar", 5_000, 60_000, 1),
84
- self("electrical.solar.*.power", "number", "scalar", 5_000, 60_000, 1),
85
- self("electrical.chargers.*.voltage", "number", "scalar", 5_000, 60_000, 1),
86
- self("electrical.chargers.*.current", "number", "scalar", 5_000, 60_000, 1),
87
- self("electrical.chargers.*.power", "number", "scalar", 5_000, 60_000, 1),
88
- self("electrical.alternators.*.voltage", "number", "scalar", 5_000, 60_000, 1),
89
- self("electrical.alternators.*.current", "number", "scalar", 5_000, 60_000, 1),
90
- 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),
91
91
  self("electrical.solar.*.panelPower", "number", "scalar", 5_000, 60_000, 1),
92
92
  self("electrical.solar.*.yieldToday", "number", "scalar", 5_000, 60_000, 1),
93
- self("electrical.inverters.*.dc.voltage", "number", "scalar", 5_000, 60_000, 1),
94
- self("electrical.inverters.*.dc.current", "number", "scalar", 5_000, 60_000, 1),
95
- 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),
96
96
  self("electrical.inverters.*.ac.realPower", "number", "scalar", 5_000, 60_000, 1),
97
97
  self("electrical.inverters.*.ac.lineNeutralVoltage", "number", "scalar", 5_000, 60_000, 1),
98
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),
99
101
  self("electrical.ac.*.phase.*.realPower", "number", "scalar", 5_000, 60_000, 1),
100
102
  self("electrical.ac.*.phase.*.lineNeutralVoltage", "number", "scalar", 5_000, 60_000, 1),
101
103
  self("electrical.ac.*.phase.*.current", "number", "scalar", 5_000, 60_000, 1),
102
104
  self("electrical.ac.*.phase.*.frequency", "number", "scalar", 5_000, 60_000, 1),
103
- self("electrical.dc.*.power", "number", "scalar", 5_000, 60_000, 1),
104
- self("electrical.dc.*.voltage", "number", "scalar", 5_000, 60_000, 1),
105
- 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),
106
108
  self("electrical.batteries.*.name", "string", "identity", 30_000, 600_000, 0),
107
109
  self("electrical.solar.*.name", "string", "identity", 30_000, 600_000, 0),
108
110
  self("electrical.chargers.*.name", "string", "identity", 30_000, 600_000, 0),
@@ -112,8 +114,8 @@ exports.SELF_TELEMETRY = [
112
114
  self("electrical.dc.*.name", "string", "identity", 30_000, 600_000, 0),
113
115
  self("electrical.inverters.*.inverterMode", "string", "identity", 30_000, 60_000, 0),
114
116
  self("electrical.chargers.*.chargingMode", "string", "identity", 30_000, 60_000, 0),
115
- self("electrical.batteries.*.voltage", "number", "scalar", 10_000, 300_000, 0.1),
116
- 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),
117
119
  self("electrical.batteries.*.temperature", "number", "scalar", 30_000, 600_000, 0.5),
118
120
  self("electrical.batteries.*.capacity.stateOfCharge", "number", "scalar", 30_000, 600_000, 0.01),
119
121
  self("electrical.batteries.*.capacity.timeRemaining", "number", "scalar", 300_000, 1_800_000, 60),
@@ -6,6 +6,9 @@ export type OperatingMode = "active" | "standby" | "disabled";
6
6
  export type PairingState = "unpaired" | "verifying" | "paired" | "pairing-required";
7
7
  export interface RuntimeConfiguration {
8
8
  equipment_identity?: boolean;
9
+ nmea_energy?: boolean;
10
+ ac_total_power?: boolean;
11
+ victron_input_limit?: boolean;
9
12
  version: number;
10
13
  operating_mode: OperatingMode;
11
14
  poll_after_seconds: number;
@@ -1 +1 @@
1
- export declare const CONNECTOR_VERSION = "0.2.0-dev.41";
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.41";
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.41",
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
  }