@rhizomatics/signalk-bluetti-plugin 1.3.2 → 1.4.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,69 @@
1
+ # Changelog
2
+
3
+ All notable changes to this plugin are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/).
4
+
5
+ ## [1.4.1]
6
+
7
+ - Basic test suite
8
+
9
+ ## [1.4.0]
10
+
11
+ - Normalize and extend paths for SignalK
12
+ - Elite 100 v2 now publishes 12 paths, the AC200P 22
13
+ - Metadata update for package, including git repo transfer
14
+ - README and CHANGELOG now included in package
15
+ - CLI commands now guess MAC address if one not provided
16
+ - CSV files now can provide static defaults, e.g. for DC output voltage, where the unit itself does not expose this over BLE
17
+
18
+ ## [1.3.2]
19
+
20
+ - Correct SignalK paths so state of charge shows as a percentage
21
+ - From `electrical.batteries.<id>.stateOfCharge` to `electrical.batteries.<id>.capacity.stateOfCharge`
22
+
23
+ ## [1.3.1]
24
+
25
+ - Back off if bluetooth daemon not available at startup
26
+ - Fix scaling of current state of charge value
27
+
28
+ ## [1.3.0]
29
+
30
+ - Fix handling of node-ble 128-bit UUIDs
31
+ - Fix encrypted reads for Elite v2 BLE
32
+ - Confirmed successful values read from an Elite 100 V2
33
+ - CLI now has separate `dump` and `info` commands for raw and interpreted data
34
+
35
+ ## [1.2.0]
36
+
37
+ - Stop scanner going into continuous scan loop if configured device not found
38
+ - Add discovery timeout
39
+ - Added CLI to scan and inspect devices
40
+ - Build tools added oxfmt and oxlint to improve cod quality
41
+ - Fix upstream EventEmitter leak in `@naugehyde/node-ble`
42
+
43
+ ## [1.1.2-alpha]
44
+
45
+ - Fix crash on scan completion
46
+
47
+ ## [1.1.1-alpha]
48
+
49
+ - Add timeout handling for BLE connect/polling
50
+
51
+ ## [1.1.0-alpha]
52
+
53
+ - Switch BLE backend from `@stoprocent/noble` to `@naugehyde/node-ble` (BlueZ D-Bus), so connections persist across disconnects without needing a rescan
54
+
55
+ ## [1.0.8-alpha]
56
+
57
+ - Improve connect/reconnect behaviour
58
+
59
+ ## [1.0.7-alpha]
60
+
61
+ - Validate the encryption key file; stop scanning once a device connection starts
62
+
63
+ ## [1.0.6-alpha] and earlier
64
+
65
+ - Initial register map support, BLE scanning, and encryption-key auto-detection, plus assorted fixes leading up to the first alpha releases
66
+
67
+ ## [1.0.0]
68
+
69
+ - Initial release
package/README.md CHANGED
@@ -2,12 +2,14 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@rhizomatics/signalk-bluetti-plugin.svg)](https://www.npmjs.com/package/@rhizomatics/signalk-bluetti-plugin)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@rhizomatics/signalk-bluetti-plugin.svg)](https://www.npmjs.com/package/@rhizomatics/signalk-bluetti-plugin)
5
- [![SignalK Plugin CI](https://github.com/jeyrb/signalk-bluetti-plugin/actions/workflows/signalk-ci.yml/badge.svg)](https://github.com/jeyrb/signalk-bluetti-plugin/actions/workflows/signalk-ci.yml)
5
+ [![SignalK Plugin CI](https://github.com/rhizomatics/signalk-bluetti-plugin/actions/workflows/signalk-ci.yml/badge.svg)](https://github.com/rhizomatics/signalk-bluetti-plugin/actions/workflows/signalk-ci.yml)
6
6
  ![code style: oxfmt](https://img.shields.io/badge/code_style-oxfmt-blue.svg)
7
7
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/rhizomatics/signalk-bluetti-plugin/blob/main/LICENSE)
8
8
 
9
9
  A SignalK plugin to display data from Bluetti power stations over a Bluetooth Low Energy (BLE) connection. BETA status
10
10
 
11
+ ![Example Data](docs/assets/screenshots/bluetti_data.png)
12
+
11
13
  ## Pre-requisites
12
14
 
13
15
  The requirements below are only to make SignalK work with Bluetooth Low Energy, which is good thing to have anyway, since vendors like Victron, Switchbot, Ruuvi and others have BLE enabled hardware that's useful to have on a boat. Ignore if BLE already being used. [Direct BLE support](https://github.com/SignalK/signalk-server/issues/2411) in SignalK is being planned in 2026 and this plugin will support that when it comes.
@@ -56,22 +58,24 @@ node cli.js scan --all # show every BLE device, not just Bluetti-ma
56
58
  node cli.js scan --timeout 30 # scan duration in seconds (default: 15)
57
59
  ```
58
60
 
59
- ### `dump <mac>`
61
+ ### `dump [mac]`
60
62
 
61
- Connect to a single device by MAC address and print low-level details: name, alias, RSSI, pairing state, manufacturer data, and its raw GATT services/characteristics. Useful for figuring out a new/unknown device's UUIDs before wiring up a register map.
63
+ Connect to a single device by MAC address and print low-level details: name, alias, RSSI, pairing state, manufacturer data, and its raw GATT services/characteristics — including each characteristic's flags (read/write/notify/…) and, for readable ones, its current value as hex. Useful for figuring out a new/unknown device's UUIDs before wiring up a register map. If `mac` is omitted, scans and connects to the first Bluetti-matching device found.
62
64
 
63
65
  ```bash
64
66
  node cli.js dump aa:bb:cc:dd:ee:ff
67
+ node cli.js dump # scan and use the first Bluetti device found
65
68
  node cli.js dump aa:bb:cc:dd:ee:ff --timeout 30 # discovery timeout if device isn't already known to BlueZ (default: 20)
66
69
  ```
67
70
 
68
- ### `info <mac>`
71
+ ### `info [mac]`
69
72
 
70
- Connect to a device and decode its live register values the same way the plugin itself does — same GATT UUIDs, same encryption handshake (including the AES/ECDH handshake used by "V2"-protocol models like the EL100V2), same register parsing. Requires `--registers`.
73
+ Connect to a device and decode its live register values the same way the plugin itself does — same GATT UUIDs, same encryption handshake (including the AES/ECDH handshake used by "V2"-protocol models like the EL100V2), same register parsing. Requires `--registers`. If `mac` is omitted, scans and connects to the first Bluetti-matching device found.
71
74
 
72
75
  ```bash
73
76
  # Decode live registers using a bundled register map (see registers/*.csv for available models)
74
77
  node cli.js info aa:bb:cc:dd:ee:ff --registers ac200p
78
+ node cli.js info --registers ac200p # scan and use the first Bluetti device found
75
79
 
76
80
  # Or a custom CSV, plus an encryption key file for legacy models that XOR-scramble frames
77
81
  # (not needed for V2-protocol models — those are auto-detected and handshake automatically)
package/cli.js CHANGED
@@ -30,16 +30,21 @@ Commands:
30
30
  --all Also show non-Bluetti BLE devices
31
31
  --timeout <seconds> Scan duration (default: 15)
32
32
 
33
- dump <mac> Connect to a device and print its raw GATT
34
- services/characteristics (low-level inspection)
35
- --timeout <seconds> Discovery timeout if not already known (default: 20)
36
-
37
- info <mac> Connect and decode live registers the same way
33
+ dump [mac] Connect to a device and print its raw GATT
34
+ services/characteristics, with flags and
35
+ readable values (low-level inspection)
36
+ If mac is omitted, scans and uses the first
37
+ Bluetti device found.
38
+ --timeout <seconds> Discovery/scan timeout if not already known (default: 20)
39
+
40
+ info [mac] Connect and decode live registers the same way
38
41
  the plugin does
42
+ If mac is omitted, scans and uses the first
43
+ Bluetti device found.
39
44
  --registers <model|path> Built-in model (${builtins.join(", ") || "none bundled"}) or a CSV path (required)
40
45
  --encryption-key <path> Path to the Bluetti-provided encryption CSV
41
46
  (only needed for legacy XOR-scrambled models)
42
- --timeout <seconds> Discovery timeout if not already known (default: 20)
47
+ --timeout <seconds> Discovery/scan timeout if not already known (default: 20)
43
48
 
44
49
  help Show this help
45
50
 
@@ -47,7 +52,9 @@ Examples:
47
52
  bluetti-cli scan
48
53
  bluetti-cli scan --all --timeout 30
49
54
  bluetti-cli dump aa:bb:cc:dd:ee:ff
55
+ bluetti-cli dump
50
56
  bluetti-cli info aa:bb:cc:dd:ee:ff --registers ac200p
57
+ bluetti-cli info --registers ac200p
51
58
  `);
52
59
  }
53
60
 
@@ -183,7 +190,21 @@ async function printGattTree(bleDevice) {
183
190
  console.log(`Service ${uuid}`);
184
191
  const service = await gatt.getPrimaryService(uuid);
185
192
  const charUuids = await service.characteristics();
186
- for (const cUuid of charUuids) console.log(` Characteristic ${cUuid}`);
193
+ for (const cUuid of charUuids) {
194
+ const characteristic = await service.getCharacteristic(cUuid);
195
+ const flags = await characteristic.getFlags().catch(() => []);
196
+
197
+ let valueStr = "";
198
+ if (flags.includes("read")) {
199
+ try {
200
+ const value = await characteristic.readValue();
201
+ valueStr = ` = ${value.length ? value.toString("hex") : "(empty)"}`;
202
+ } catch (err) {
203
+ valueStr = ` = (read failed: ${err.message})`;
204
+ }
205
+ }
206
+ console.log(` Characteristic ${cUuid} [${flags.join(", ") || "?"}]${valueStr}`);
207
+ }
187
208
  }
188
209
  } finally {
189
210
  if (!alreadyConnected) await bleDevice.disconnect().catch(() => {});
@@ -261,17 +282,45 @@ async function printRegisterValues(bleDevice, mac, args) {
261
282
  }
262
283
  }
263
284
 
285
+ // Scans for the first Bluetti-matching device and resolves its address, or
286
+ // null if none turned up before durationMs. Used to default `[mac]` CLI
287
+ // arguments when the user doesn't already know the address.
288
+ async function findFirstBluettiAddress(durationMs, log) {
289
+ const Scanner = require("./lib/scanner");
290
+ const scanner = new Scanner(log, { includeAll: false });
291
+ return new Promise((resolve) => {
292
+ let settled = false;
293
+ const finish = (result) => {
294
+ if (settled) return;
295
+ settled = true;
296
+ scanner.stopAll();
297
+ resolve(result);
298
+ };
299
+ scanner.once("discovered", ({ address, name }) => finish({ address, name }));
300
+ scanner.once("scanComplete", () => finish(null));
301
+ scanner.once("error", () => finish(null));
302
+ void scanner.startScan(durationMs);
303
+ });
304
+ }
305
+
264
306
  async function withConnectedDevice(args, usageLine, body) {
265
- const mac = args._[0];
307
+ let mac = args._[0];
308
+ const timeoutMs = (args.timeout ? parseFloat(args.timeout) : 20) * 1000;
309
+
266
310
  if (!mac) {
267
- console.error(`Error: MAC address required. Usage: ${usageLine}`);
268
- process.exitCode = 1;
269
- return;
311
+ console.error(`No MAC address given scanning for a Bluetti device (up to ${timeoutMs / 1000}s)…`);
312
+ const found = await findFirstBluettiAddress(timeoutMs, (msg) => console.error(`[scan] ${msg}`));
313
+ if (!found) {
314
+ console.error(`No Bluetti device found. Usage: ${usageLine}`);
315
+ process.exitCode = 1;
316
+ return;
317
+ }
318
+ mac = found.address;
319
+ console.error(`Found ${found.name || "(unnamed)"} [${mac}]`);
270
320
  }
271
321
 
272
322
  const { createBluetooth } = require("@naugehyde/node-ble");
273
323
  const bt = createBluetooth();
274
- const timeoutMs = (args.timeout ? parseFloat(args.timeout) : 20) * 1000;
275
324
 
276
325
  try {
277
326
  const adapter = await bt.bluetooth.defaultAdapter();
@@ -292,16 +341,16 @@ async function withConnectedDevice(args, usageLine, body) {
292
341
  }
293
342
 
294
343
  async function cmdDump(args) {
295
- await withConnectedDevice(args, "bluetti-cli dump <mac>", (bleDevice) => printGattTree(bleDevice));
344
+ await withConnectedDevice(args, "bluetti-cli dump [mac]", (bleDevice) => printGattTree(bleDevice));
296
345
  }
297
346
 
298
347
  async function cmdInfo(args) {
299
348
  if (!args.registers) {
300
- console.error("Error: --registers <model|path> is required. Usage: bluetti-cli info <mac> --registers <model|path>");
349
+ console.error("Error: --registers <model|path> is required. Usage: bluetti-cli info [mac] --registers <model|path>");
301
350
  process.exitCode = 1;
302
351
  return;
303
352
  }
304
- await withConnectedDevice(args, "bluetti-cli info <mac> --registers <model|path>", (bleDevice, mac) =>
353
+ await withConnectedDevice(args, "bluetti-cli info [mac] --registers <model|path>", (bleDevice, mac) =>
305
354
  printRegisterValues(bleDevice, mac, args),
306
355
  );
307
356
  }
package/index.js CHANGED
@@ -271,6 +271,7 @@ module.exports = function (app) {
271
271
 
272
272
  function startDevice(cfg, bleDevice, bleName, { BluettiDevice, loadCsv, buildDelta, readEncryptionKey }) {
273
273
  const { address, name, encryptionCsvPath = "", pollIntervalSeconds = 10 } = cfg;
274
+ const registerCache = new Map(); // last-known value per field_name, across polls — see buildDelta
274
275
 
275
276
  let registerPath;
276
277
  try {
@@ -324,7 +325,7 @@ module.exports = function (app) {
324
325
  });
325
326
 
326
327
  device.on("registers", (registers) => {
327
- const delta = buildDelta(registers, fields, name, PLUGIN_ID);
328
+ const delta = buildDelta(registers, fields, name, PLUGIN_ID, { cache: registerCache });
328
329
  if (delta) app.handleMessage(PLUGIN_ID, delta);
329
330
  });
330
331
 
package/lib/csv-loader.js CHANGED
@@ -14,6 +14,7 @@ const COL_ALIASES = {
14
14
  unit: ["unit", "units", "单位"],
15
15
  signalk_path: ["signalk_path", "signalk", "sk_path", "path"],
16
16
  register_count: ["register_count", "count", "length", "bytes", "数据长度"],
17
+ constant_value: ["constant_value", "constant", "const", "fixed_value", "static_value"],
17
18
  };
18
19
 
19
20
  function resolveColumn(headers, key) {
@@ -26,7 +27,8 @@ function resolveColumn(headers, key) {
26
27
  }
27
28
 
28
29
  // Parse the CSV and return an array of field descriptors.
29
- // Each descriptor: { fieldName, register, count, dataType, scale, offset, unit, signalkPath }
30
+ // Each descriptor is either register-backed: { fieldName, register, count, dataType, scale, offset, unit, signalkPath }
31
+ // or a fixed constant (register_address left blank): { fieldName, register: null, count: 0, dataType: "const", ..., constantValue }
30
32
  function loadCsv(filePath) {
31
33
  const raw = fs.readFileSync(filePath, "utf8");
32
34
 
@@ -38,6 +40,7 @@ function loadCsv(filePath) {
38
40
  skip_empty_lines: true,
39
41
  trim: true,
40
42
  comment: "#",
43
+ relax_column_count: true,
41
44
  });
42
45
 
43
46
  if (records.length === 0) throw new Error(`CSV file ${filePath} has no data rows`);
@@ -54,9 +57,30 @@ function loadCsv(filePath) {
54
57
 
55
58
  const fields = [];
56
59
  for (const row of records) {
60
+ const fieldName = (row[col.field_name] || "").trim();
61
+ if (!fieldName) continue;
62
+
63
+ const signalkPath = col.signalk_path ? (row[col.signalk_path] || "").trim() : "";
64
+ const constantRaw = col.constant_value ? (row[col.constant_value] || "").trim() : "";
65
+
57
66
  const registerRaw = row[col.register_address];
58
67
  const register = parseInt(registerRaw, 10);
59
- if (isNaN(register)) continue; // skip header-like rows embedded in data
68
+
69
+ if (isNaN(register)) {
70
+ // No register address — only valid if this row instead supplies a fixed
71
+ // constant (e.g. a model's nominal battery capacity or chemistry, which
72
+ // isn't a live register on every device). Otherwise it's a header-like
73
+ // row embedded in the data and gets skipped.
74
+ if (!constantRaw) continue;
75
+ // Numeric constants (e.g. a nominal capacity in Wh) need to go through
76
+ // the same unit conversion as register-backed fields; text constants
77
+ // (chemistry, manufacturer name) are left as-is.
78
+ const asNumber = Number(constantRaw);
79
+ const constantValue = Number.isNaN(asNumber) ? constantRaw : asNumber;
80
+ const unit = col.unit ? (row[col.unit] || "").trim() : "";
81
+ fields.push({ fieldName, register: null, count: 0, dataType: "const", scale: 1, offset: 0, unit, signalkPath, constantValue });
82
+ continue;
83
+ }
60
84
 
61
85
  const dataType = col.data_type ? (row[col.data_type] || "uint16").toLowerCase().trim() : "uint16";
62
86
  const scale = col.scale ? parseFloat(row[col.scale]) || 1 : 1;
@@ -67,11 +91,6 @@ function loadCsv(filePath) {
67
91
  let count = col.register_count ? parseInt(row[col.register_count], 10) : NaN;
68
92
  if (isNaN(count)) count = registersForType(dataType);
69
93
 
70
- const signalkPath = col.signalk_path ? (row[col.signalk_path] || "").trim() : "";
71
- const fieldName = (row[col.field_name] || "").trim();
72
-
73
- if (!fieldName) continue;
74
-
75
94
  fields.push({ fieldName, register, count, dataType, scale, offset, unit, signalkPath });
76
95
  }
77
96
 
@@ -98,6 +117,8 @@ function registersForType(dataType) {
98
117
  function decodeValue(field, rawRegs) {
99
118
  const { register, count, dataType, scale, offset } = field;
100
119
 
120
+ if (dataType === "const") return field.constantValue;
121
+
101
122
  let raw;
102
123
  if (count === 1) {
103
124
  raw = rawRegs.get(register);
@@ -23,23 +23,72 @@ const UNIT_CONVERSIONS = {
23
23
  };
24
24
 
25
25
  function convertUnits(value, unit) {
26
- if (unit === null || unit === undefined) return value;
26
+ if (unit === null || unit === undefined || typeof value !== "number") return value;
27
27
  const key = unit.toLowerCase().trim();
28
28
  const fn = UNIT_CONVERSIONS[key];
29
29
  return fn ? fn(value) : value;
30
30
  }
31
31
 
32
- // Build a SignalK path for a field, substituting {name} with the device name.
33
- // Falls back to auto-generating from fieldName if signalkPath is not set in CSV.
32
+ // Canonical field_name SignalK path for the register names Bluetti's own
33
+ // documentation and the bundled CSVs use. A CSV's `signalk_path` column is an
34
+ // override on top of this — only needed for a register the code doesn't know
35
+ // about — so bundled/well-known CSVs can leave it blank and just list
36
+ // field_name + register info.
37
+ //
38
+ // ac_input_power, ac_input_voltage, and dc_output_power are intentionally
39
+ // absent here — they don't have a direct standard path of their own, they're
40
+ // only ever consumed as inputs to the derived charger/battery current in
41
+ // buildDelta() below.
42
+ const STANDARD_FIELD_PATHS = {
43
+ // Battery
44
+ battery_soc: "electrical.batteries.{name}.capacity.stateOfCharge",
45
+ battery_percent: "electrical.batteries.{name}.capacity.stateOfCharge",
46
+ battery_voltage: "electrical.batteries.{name}.voltage",
47
+ battery_current: "electrical.batteries.{name}.current",
48
+ battery_power: "electrical.batteries.{name}.power",
49
+ battery_temperature: "electrical.batteries.{name}.temperature",
50
+ battery_chemistry: "electrical.batteries.{name}.chemistry",
51
+ battery_manufacturer_name: "electrical.batteries.{name}.manufacturer.name",
52
+ battery_manufacturer_model: "electrical.batteries.{name}.manufacturer.model",
53
+ total_capacity: "electrical.batteries.{name}.capacity.nominal",
54
+ time_remaining_minutes: "electrical.batteries.{name}.capacity.timeRemaining",
55
+ internal_temperature: "electrical.batteries.{name}.controllerTemperature",
56
+
57
+ // Solar / DC input
58
+ dc_input_power: "electrical.solar.{name}.panelPower",
59
+ dc_input_voltage: "electrical.solar.{name}.panelVoltage",
60
+ dc_input_current: "electrical.solar.{name}.panelCurrent",
61
+
62
+ // AC output (inverter)
63
+ ac_output_power: "electrical.inverters.{name}.ac.realPower",
64
+ ac_output_voltage: "electrical.inverters.{name}.ac.voltage",
65
+ ac_output_current: "electrical.inverters.{name}.ac.current",
66
+ ac_output_frequency: "electrical.inverters.{name}.ac.frequency",
67
+
68
+ // AC input (charger) — current/frequency map directly when a device has real
69
+ // registers for them; if not, current is derived (see buildDelta below).
70
+ ac_input_current: "electrical.chargers.{name}.current",
71
+ ac_input_frequency: "electrical.chargers.{name}.frequency",
72
+ };
73
+
74
+ // Fields that feed a derived computation in buildDelta() rather than being
75
+ // published under their own path directly.
76
+ const DERIVED_SOURCE_FIELDS = new Set(["ac_input_power", "ac_input_voltage", "dc_output_power", "dc_output_voltage"]);
77
+
78
+ // Build a SignalK path for a field: explicit CSV override first, then the
79
+ // standard field_name registry above, then a best-effort keyword guess.
34
80
  function resolvePath(field, deviceName) {
35
81
  if (field.signalkPath) {
36
82
  return field.signalkPath.replace(/\{name\}/gi, deviceName);
37
83
  }
84
+ const standard = STANDARD_FIELD_PATHS[field.fieldName.toLowerCase()];
85
+ if (standard) return standard.replace(/\{name\}/g, deviceName);
38
86
  return autoPath(field.fieldName, deviceName);
39
87
  }
40
88
 
41
89
  // Best-effort automatic path generation for common Bluetti field names.
42
- // Users with a properly-annotated CSV will never hit this; it's a safety net.
90
+ // Only reached for fields not in STANDARD_FIELD_PATHS above i.e. a custom
91
+ // CSV using field names the code doesn't recognise and no explicit override.
43
92
  function autoPath(fieldName, name) {
44
93
  const f = fieldName.toLowerCase();
45
94
  if (f.includes("battery") || f.includes("batt") || f.includes("soc")) {
@@ -58,17 +107,17 @@ function autoPath(fieldName, name) {
58
107
  return `electrical.solar.${name}.${fieldName}`;
59
108
  }
60
109
  if (f.includes("ac_output") || f.includes("inverter") || f.includes("ac_out")) {
61
- if (f.includes("power")) return `electrical.inverters.${name}.ac.power`;
110
+ if (f.includes("power")) return `electrical.inverters.${name}.ac.realPower`;
62
111
  if (f.includes("voltage")) return `electrical.inverters.${name}.ac.voltage`;
63
112
  if (f.includes("current")) return `electrical.inverters.${name}.ac.current`;
64
113
  if (f.includes("freq")) return `electrical.inverters.${name}.ac.frequency`;
65
114
  return `electrical.inverters.${name}.ac.${fieldName}`;
66
115
  }
67
116
  if (f.includes("ac_input") || f.includes("mains") || f.includes("grid") || f.includes("charger")) {
68
- if (f.includes("power")) return `electrical.chargers.${name}.input.power`;
69
- if (f.includes("voltage")) return `electrical.chargers.${name}.input.voltage`;
70
- if (f.includes("current")) return `electrical.chargers.${name}.input.current`;
71
- return `electrical.chargers.${name}.input.${fieldName}`;
117
+ if (f.includes("power")) return `electrical.chargers.${name}.power`;
118
+ if (f.includes("voltage")) return `electrical.chargers.${name}.voltage`;
119
+ if (f.includes("current")) return `electrical.chargers.${name}.current`;
120
+ return `electrical.chargers.${name}.${fieldName}`;
72
121
  }
73
122
  // Catch-all
74
123
  return `electrical.${name}.${fieldName}`;
@@ -76,17 +125,60 @@ function autoPath(fieldName, name) {
76
125
 
77
126
  // Given a Map of register→uint16 values and the full field list,
78
127
  // produce a SignalK delta values array ready for app.handleMessage().
79
- function buildDelta(registers, fields, deviceName, source) {
128
+ //
129
+ // opts.cache: a Map the caller keeps alive for the lifetime of the device,
130
+ // used to remember the last-known value of every field across polls. This is
131
+ // needed because a single call here only ever sees one batch's worth of
132
+ // registers (see groupRegisters in protocol.js) — e.g. ac_input_power and
133
+ // ac_input_voltage typically land in different batches, so deriving current
134
+ // from their ratio requires remembering whichever arrived most recently.
135
+ function buildDelta(registers, fields, deviceName, source, opts = {}) {
136
+ const { cache = new Map() } = opts;
80
137
  const values = [];
138
+ const hasRawAcInputCurrent = fields.some((f) => f.fieldName.toLowerCase() === "ac_input_current");
81
139
 
82
140
  for (const field of fields) {
83
141
  const raw = decodeValue(field, registers);
84
142
  if (raw === null) continue;
85
143
 
86
144
  const converted = convertUnits(raw, field.unit);
87
- const path = resolvePath(field, deviceName);
145
+ const key = field.fieldName.toLowerCase();
146
+ cache.set(key, converted);
147
+
148
+ if (!field.signalkPath && DERIVED_SOURCE_FIELDS.has(key)) continue; // published below instead
149
+
150
+ values.push({ path: resolvePath(field, deviceName), value: converted });
151
+ }
152
+
153
+ // AC mains charger: Bluetti reports power+voltage on this input, not
154
+ // current — SignalK's electrical.chargers.<id> wants voltage/current, so
155
+ // derive it (unless the device has a real current register — see above).
156
+ if (cache.has("ac_input_voltage")) {
157
+ values.push({ path: `electrical.chargers.${deviceName}.voltage`, value: cache.get("ac_input_voltage") });
158
+ }
159
+ if (!hasRawAcInputCurrent && cache.has("ac_input_power") && cache.has("ac_input_voltage")) {
160
+ const voltage = cache.get("ac_input_voltage");
161
+ const power = cache.get("ac_input_power");
162
+ values.push({ path: `electrical.chargers.${deviceName}.current`, value: voltage ? power / voltage : 0 });
163
+ }
164
+
165
+ // DC output port (e.g. a 12V accessory socket): Bluetti reports power only,
166
+ // at a fixed nominal voltage that isn't itself a register — the CSV supplies
167
+ // it as a constant (dc_output_voltage), and current is derived from that.
168
+ if (cache.has("dc_output_power") && cache.has("dc_output_voltage")) {
169
+ const power = cache.get("dc_output_power");
170
+ const voltage = cache.get("dc_output_voltage");
171
+ values.push({ path: `electrical.batteries.${deviceName}.voltage`, value: voltage });
172
+ values.push({ path: `electrical.batteries.${deviceName}.current`, value: voltage ? power / voltage : 0 });
173
+ }
88
174
 
89
- values.push({ path, value: converted });
175
+ // Remaining capacity = nominal capacity × state of charge. Bluetti's own
176
+ // "remaining capacity" register drifts with cell aging and isn't available
177
+ // on every model; deriving it from a fixed nominal spec (see registers/*.csv)
178
+ // plus the reported SoC is simpler and consistent across models.
179
+ const soc = cache.has("battery_soc") ? cache.get("battery_soc") : cache.has("battery_percent") ? cache.get("battery_percent") : null;
180
+ if (soc !== null && cache.has("total_capacity")) {
181
+ values.push({ path: `electrical.batteries.${deviceName}.capacity.remaining`, value: cache.get("total_capacity") * soc });
90
182
  }
91
183
 
92
184
  if (values.length === 0) return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-bluetti-plugin",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "description": "SignalK plugin for Bluetti power station monitoring via Bluetooth LE. Beta, use with caution.",
5
5
  "keywords": [
6
6
  "battery",
@@ -12,7 +12,16 @@
12
12
  "signalk-node-plugin",
13
13
  "signalk-node-server-plugin"
14
14
  ],
15
- "license": "Apache-2.0",
15
+ "homepage": "https://github.com/rhizomatics/signalk-bluetti-plugin.git",
16
+ "bugs": {
17
+ "url": "https://github.com/rhizomatics/signalk-bluetti-plugin/issues"
18
+ },
19
+ "license": "APACHE-2.0",
20
+ "author": "jey burrows",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/rhizomatics/signalk-bluetti-plugin.git"
24
+ },
16
25
  "bin": {
17
26
  "bluetti-cli": "cli.js"
18
27
  },
@@ -20,7 +29,9 @@
20
29
  "index.js",
21
30
  "cli.js",
22
31
  "lib/",
23
- "registers/"
32
+ "registers/",
33
+ "README.md",
34
+ "CHANGELOG.md"
24
35
  ],
25
36
  "main": "index.js",
26
37
  "scripts": {
@@ -28,7 +39,9 @@
28
39
  "cli": "node cli.js",
29
40
  "fmt": "oxfmt",
30
41
  "fmt:check": "oxfmt --check",
31
- "lint": "oxlint"
42
+ "lint": "oxlint",
43
+ "test": "node --test",
44
+ "test:coverage": "node --experimental-test-coverage --test-coverage-include=\"lib/**/*.js\" --test-coverage-exclude=\"lib/device.js\" --test-coverage-exclude=\"lib/scanner.js\" --test-coverage-lines=80 --test-coverage-branches=80 --test-coverage-functions=80 --test"
32
45
  },
33
46
  "dependencies": {
34
47
  "@naugehyde/node-ble": "^1.13.5",
@@ -44,7 +57,13 @@
44
57
  "signalk-server": "*"
45
58
  },
46
59
  "signalk": {
47
- "displayName": "Bluetti Monitoring"
60
+ "displayName": "Bluetti Monitoring",
61
+ "recommends": [
62
+ "@rhizomatics/signalk-einklabel-plugin"
63
+ ],
64
+ "screenshots": [
65
+ "docs/assets/screenshots/bluetti_data.png"
66
+ ]
48
67
  },
49
68
  "signalk-plugin-enabled-by-default": false
50
69
  }
@@ -1,39 +1,47 @@
1
1
  # Bluetti register map example — based on AC200P / AC200MAX community reverse-engineering.
2
2
  # Replace with your device's actual CSV. Columns the plugin recognises:
3
3
  #
4
- # field_name — internal identifier (no spaces)
5
- # register_address Modbus holding register number (decimal)
4
+ # field_name — a standard Bluetti register name (see lib/path-mapper.js
5
+ # for the ones the code already maps to a SignalK path),
6
+ # or any identifier you like if you're supplying signalk_path
7
+ # register_address — Modbus holding register number (decimal); leave blank
8
+ # for a fixed constant_value row instead (e.g. model specs)
6
9
  # register_count — number of 16-bit registers this field spans (1 for uint16/int16, 2 for int32/float32)
7
10
  # data_type — uint16 | int16 | uint32 | int32 | float32 | bool
8
11
  # scale — multiply raw value by this to get physical value
9
12
  # offset — add after scale (usually 0)
10
13
  # unit — V | A | W | Wh | % | °C | Hz | min — drives SignalK unit conversion
11
- # signalk_path — full SignalK path; use {name} where the device name goes
14
+ # signalk_path — override; only needed for a register the code doesn't
15
+ # recognise by field_name. Use {name} where the device name goes
16
+ # constant_value — fixed value for a non-register row (register_address blank)
12
17
  #
13
- field_name,register_address,register_count,data_type,scale,offset,unit,signalk_path
18
+ field_name,register_address,register_count,data_type,scale,offset,unit,signalk_path,constant_value
14
19
  # ── Battery ────────────────────────────────────────────────────────────────
15
- battery_percent,100,1,uint16,1,0,%,electrical.batteries.{name}.capacity.stateOfCharge
16
- battery_voltage,101,1,uint16,0.1,0,V,electrical.batteries.{name}.voltage
17
- battery_current,102,1,int16,0.1,0,A,electrical.batteries.{name}.current
18
- battery_power,103,1,int16,1,0,W,electrical.batteries.{name}.power
19
- battery_temperature,104,1,uint16,0.1,0,°C,electrical.batteries.{name}.temperature
20
- total_capacity,105,1,uint16,1,0,Wh,electrical.batteries.{name}.capacity.nominal
21
- remaining_capacity,106,1,uint16,1,0,Wh,electrical.batteries.{name}.capacity.remaining
20
+ battery_percent,100,1,uint16,1,0,%,,
21
+ battery_voltage,101,1,uint16,0.1,0,V,,
22
+ battery_current,102,1,int16,0.1,0,A,,
23
+ battery_power,103,1,int16,1,0,W,,
24
+ battery_temperature,104,1,uint16,0.1,0,°C,,
22
25
  # ── DC / Solar input ────────────────────────────────────────────────────────
23
- dc_input_power,107,1,uint16,1,0,W,electrical.solar.{name}.panelPower
24
- dc_input_voltage,108,1,uint16,0.1,0,V,electrical.solar.{name}.panelVoltage
25
- dc_input_current,109,1,uint16,0.1,0,A,electrical.solar.{name}.panelCurrent
26
+ dc_input_power,107,1,uint16,1,0,W,,
27
+ dc_input_voltage,108,1,uint16,0.1,0,V,,
28
+ dc_input_current,109,1,uint16,0.1,0,A,,
26
29
  # ── AC output (inverter) ────────────────────────────────────────────────────
27
- ac_output_power,110,1,uint16,1,0,W,electrical.inverters.{name}.ac.power
28
- ac_output_voltage,111,1,uint16,0.1,0,V,electrical.inverters.{name}.ac.voltage
29
- ac_output_current,112,1,uint16,0.1,0,A,electrical.inverters.{name}.ac.current
30
- ac_output_frequency,113,1,uint16,0.1,0,Hz,electrical.inverters.{name}.ac.frequency
30
+ ac_output_power,110,1,uint16,1,0,W,,
31
+ ac_output_voltage,111,1,uint16,0.1,0,V,,
32
+ ac_output_current,112,1,uint16,0.1,0,A,,
33
+ ac_output_frequency,113,1,uint16,0.1,0,Hz,,
31
34
  # ── AC input (charger / mains) ──────────────────────────────────────────────
32
- ac_input_power,114,1,uint16,1,0,W,electrical.chargers.{name}.input.power
33
- ac_input_voltage,115,1,uint16,0.1,0,V,electrical.chargers.{name}.input.voltage
34
- ac_input_current,116,1,uint16,0.1,0,A,electrical.chargers.{name}.input.current
35
- ac_input_frequency,117,1,uint16,0.1,0,Hz,electrical.chargers.{name}.input.frequency
35
+ ac_input_power,114,1,uint16,1,0,W,,
36
+ ac_input_voltage,115,1,uint16,0.1,0,V,,
37
+ ac_input_current,116,1,uint16,0.1,0,A,,
38
+ ac_input_frequency,117,1,uint16,0.1,0,Hz,,
36
39
  # ── Internal temperature ────────────────────────────────────────────────────
37
- internal_temperature,118,1,uint16,0.1,0,°C,electrical.batteries.{name}.controllerTemperature
40
+ internal_temperature,118,1,uint16,0.1,0,°C,,
38
41
  # ── Estimated time remaining ────────────────────────────────────────────────
39
- time_remaining_minutes,119,1,uint16,1,0,min,electrical.batteries.{name}.capacity.timeRemaining
42
+ time_remaining_minutes,119,1,uint16,1,0,min,,
43
+ # ── Fixed model facts ────────────────────────────────────────────────────────
44
+ battery_chemistry,,,,,,,,LiFePO4
45
+ total_capacity,,,,,,Wh,,2000
46
+ battery_manufacturer_name,,,,,,,,Bluetti
47
+ battery_manufacturer_model,,,,,,,,AC200P
@@ -4,10 +4,24 @@
4
4
  # EL100V2-specific fields: 140, 142, 144, 146, 1314
5
5
  # Note: DEVICE_TYPE (110) and DEVICE_SN (116) are string/serial fields — omitted.
6
6
  #
7
- field_name,register_address,register_count,data_type,scale,offset,unit,signalk_path
8
- battery_soc,102,1,uint16,1,0,%,electrical.batteries.{name}.capacity.stateOfCharge
9
- dc_output_power,140,1,uint16,1,0,W,electrical.dc.{name}.power
10
- ac_output_power,142,1,uint16,1,0,W,electrical.inverters.{name}.ac.power
11
- dc_input_power,144,1,uint16,1,0,W,electrical.solar.{name}.panelPower
12
- ac_input_power,146,1,uint16,1,0,W,electrical.chargers.{name}.input.power
13
- ac_input_voltage,1314,1,uint16,0.1,0,V,electrical.chargers.{name}.input.voltage
7
+ # field_name is a standard Bluetti register name — the code maps these to
8
+ # SignalK paths on its own (see lib/path-mapper.js). signalk_path is an
9
+ # override, only needed if you're exposing a register the code doesn't
10
+ # recognise. constant_value is for fixed, non-register facts about this model
11
+ # (register_address left blank for those rows).
12
+ #
13
+ field_name,register_address,register_count,data_type,scale,offset,unit,signalk_path,constant_value
14
+ battery_soc,102,1,uint16,1,0,%,,
15
+ dc_output_power,140,1,uint16,1,0,W,,
16
+ ac_output_power,142,1,uint16,1,0,W,,
17
+ dc_input_power,144,1,uint16,1,0,W,,
18
+ ac_input_power,146,1,uint16,1,0,W,,
19
+ ac_input_voltage,1314,1,uint16,0.1,0,V,,
20
+ # ── Fixed model facts ────────────────────────────────────────────────────────
21
+ battery_chemistry,,,,,,,,LiFePO4
22
+ total_capacity,,,,,,Wh,,1024
23
+ battery_manufacturer_name,,,,,,,,Bluetti
24
+ battery_manufacturer_model,,,,,,,,Elite 100 V2
25
+ # dc_output_power (140) only reports power, not current — this fixed nominal
26
+ # voltage (12V accessory port) is what lib/path-mapper.js derives current from.
27
+ dc_output_voltage,,,,,,V,,12