@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 +69 -0
- package/README.md +9 -5
- package/cli.js +64 -15
- package/index.js +2 -1
- package/lib/csv-loader.js +28 -7
- package/lib/path-mapper.js +104 -12
- package/package.json +24 -5
- package/registers/ac200p.csv +32 -24
- package/registers/el100v2.csv +21 -7
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
|
[](https://www.npmjs.com/package/@rhizomatics/signalk-bluetti-plugin)
|
|
4
4
|
[](https://www.npmjs.com/package/@rhizomatics/signalk-bluetti-plugin)
|
|
5
|
-
[](https://github.com/rhizomatics/signalk-bluetti-plugin/actions/workflows/signalk-ci.yml)
|
|
6
6
|

|
|
7
7
|
[](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
|
+

|
|
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
|
|
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
|
|
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
|
|
34
|
-
services/characteristics
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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)
|
|
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
|
-
|
|
307
|
+
let mac = args._[0];
|
|
308
|
+
const timeoutMs = (args.timeout ? parseFloat(args.timeout) : 20) * 1000;
|
|
309
|
+
|
|
266
310
|
if (!mac) {
|
|
267
|
-
console.error(`
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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);
|
package/lib/path-mapper.js
CHANGED
|
@@ -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
|
-
//
|
|
33
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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}.
|
|
69
|
-
if (f.includes("voltage")) return `electrical.chargers.${name}.
|
|
70
|
-
if (f.includes("current")) return `electrical.chargers.${name}.
|
|
71
|
-
return `electrical.chargers.${name}
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
"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
|
-
"
|
|
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
|
}
|
package/registers/ac200p.csv
CHANGED
|
@@ -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 —
|
|
5
|
-
#
|
|
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 —
|
|
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
|
|
16
|
-
battery_voltage,101,1,uint16,0.1,0,V
|
|
17
|
-
battery_current,102,1,int16,0.1,0,A
|
|
18
|
-
battery_power,103,1,int16,1,0,W
|
|
19
|
-
battery_temperature,104,1,uint16,0.1,0,°C
|
|
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
|
|
24
|
-
dc_input_voltage,108,1,uint16,0.1,0,V
|
|
25
|
-
dc_input_current,109,1,uint16,0.1,0,A
|
|
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
|
|
28
|
-
ac_output_voltage,111,1,uint16,0.1,0,V
|
|
29
|
-
ac_output_current,112,1,uint16,0.1,0,A
|
|
30
|
-
ac_output_frequency,113,1,uint16,0.1,0,Hz
|
|
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
|
|
33
|
-
ac_input_voltage,115,1,uint16,0.1,0,V
|
|
34
|
-
ac_input_current,116,1,uint16,0.1,0,A
|
|
35
|
-
ac_input_frequency,117,1,uint16,0.1,0,Hz
|
|
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
|
|
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
|
|
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
|
package/registers/el100v2.csv
CHANGED
|
@@ -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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|