@rhizomatics/signalk-bluetti-plugin 1.4.1 → 1.5.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 +13 -0
- package/README.md +40 -4
- package/cli.js +71 -11
- package/devices/ac180.yaml +58 -0
- package/devices/ac180p.yaml +34 -0
- package/devices/ac180t.yaml +58 -0
- package/devices/ac200l.yaml +48 -0
- package/devices/ac200m.yaml +48 -0
- package/devices/ac200p.yaml +89 -0
- package/devices/ac200pl.yaml +48 -0
- package/devices/ac2a.yaml +34 -0
- package/devices/ac2p.yaml +34 -0
- package/devices/ac300.yaml +57 -0
- package/devices/ac500.yaml +56 -0
- package/devices/ac50b.yaml +31 -0
- package/devices/ac60.yaml +33 -0
- package/devices/ac60p.yaml +33 -0
- package/devices/ac70.yaml +62 -0
- package/devices/ac70p.yaml +58 -0
- package/devices/ap300.yaml +34 -0
- package/devices/eb3a.yaml +41 -0
- package/devices/el100v2.yaml +43 -0
- package/devices/el30v2.yaml +38 -0
- package/devices/ep2000.yaml +75 -0
- package/devices/ep500.yaml +57 -0
- package/devices/ep500p.yaml +57 -0
- package/devices/ep600.yaml +151 -0
- package/devices/ep760.yaml +72 -0
- package/devices/ep800.yaml +21 -0
- package/devices/handsfree1.yaml +62 -0
- package/devices/pr100v2.yaml +34 -0
- package/devices/pr30v2.yaml +34 -0
- package/docs/assets/icon.svg +36 -0
- package/docs/assets/screenshots/bluetti_data.png +0 -0
- package/docs/examples/model_definition.yaml +112 -0
- package/index.js +75 -32
- package/lib/path-mapper.js +51 -9
- package/lib/register-loader.js +120 -0
- package/lib/scanner.js +1 -1
- package/package.json +13 -6
- package/lib/csv-loader.js +0 -160
- package/registers/ac200p.csv +0 -47
- package/registers/el100v2.csv +0 -27
package/index.js
CHANGED
|
@@ -5,10 +5,10 @@ const path = require("path");
|
|
|
5
5
|
const fs = require("fs");
|
|
6
6
|
|
|
7
7
|
const PLUGIN_ID = "signalk-bluetti-plugin";
|
|
8
|
-
const
|
|
8
|
+
const DEVICES_DIR = path.join(__dirname, "devices");
|
|
9
9
|
|
|
10
10
|
// Bluetti device BLE name prefixes (mirrors scanner.js).
|
|
11
|
-
const BLUETTI_PREFIXES = ["BT-TH-", "BLUETTI", "AC", "EP", "EB", "EL"];
|
|
11
|
+
const BLUETTI_PREFIXES = ["BT-TH-", "BLUETTI", "AC", "EP", "EB", "EL", "PR", "AP"];
|
|
12
12
|
|
|
13
13
|
// Search $HOME for exactly one CSV whose stem looks like a Bluetti device ID.
|
|
14
14
|
// Returns the full path if exactly one match, empty string otherwise.
|
|
@@ -26,18 +26,28 @@ function findBluettiEncryptionCsvInHome() {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function
|
|
29
|
+
function yamlModelNames(dir) {
|
|
30
30
|
try {
|
|
31
31
|
return fs
|
|
32
|
-
.readdirSync(
|
|
33
|
-
.filter((f) => f
|
|
34
|
-
.map((f) => f.replace(/\.
|
|
32
|
+
.readdirSync(dir)
|
|
33
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
34
|
+
.map((f) => f.replace(/\.ya?ml$/, ""))
|
|
35
35
|
.sort();
|
|
36
36
|
} catch {
|
|
37
37
|
return [];
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// The directory a user can drop their own device configuration YAML files into,
|
|
42
|
+
// without needing to fork/publish the plugin — defaults to a `bluetti`
|
|
43
|
+
// subdirectory of the SignalK home directory (e.g. ~/.signalk/bluetti).
|
|
44
|
+
// `app.config.configPath` isn't part of the documented plugin API but is the
|
|
45
|
+
// long-established way plugins locate the SignalK home directory.
|
|
46
|
+
function defaultUserRegistersDir(app) {
|
|
47
|
+
const home = app && app.config && app.config.configPath;
|
|
48
|
+
return home ? path.join(home, "bluetti") : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
module.exports = function (app) {
|
|
42
52
|
const log = (msg) => app.debug(msg);
|
|
43
53
|
|
|
@@ -46,7 +56,15 @@ module.exports = function (app) {
|
|
|
46
56
|
let scanResultCache = [];
|
|
47
57
|
let waitingStatusTimer = null;
|
|
48
58
|
|
|
49
|
-
const
|
|
59
|
+
const defaultUserDir = defaultUserRegistersDir(app);
|
|
60
|
+
const builtins = yamlModelNames(DEVICES_DIR);
|
|
61
|
+
// Models available for the dropdown at schema-render time — bundled plus
|
|
62
|
+
// whatever's already sitting in the default user directory. If `registersDir`
|
|
63
|
+
// is overridden away from the default, models unique to that directory won't
|
|
64
|
+
// appear here until the plugin restarts with the new setting saved.
|
|
65
|
+
const allModels = [...new Set([...builtins, ...(defaultUserDir ? yamlModelNames(defaultUserDir) : [])])].sort((a, b) =>
|
|
66
|
+
a.localeCompare(b),
|
|
67
|
+
);
|
|
50
68
|
|
|
51
69
|
// ── Plugin metadata ────────────────────────────────────────────────────
|
|
52
70
|
|
|
@@ -65,9 +83,15 @@ module.exports = function (app) {
|
|
|
65
83
|
scanOnStart: {
|
|
66
84
|
type: "boolean",
|
|
67
85
|
title: "Scan for new devices on plugin start",
|
|
68
|
-
description: "Runs a
|
|
86
|
+
description: "Runs a short BLE scan and logs discovered Bluetti devices. Useful for finding device addresses.",
|
|
69
87
|
default: true,
|
|
70
88
|
},
|
|
89
|
+
registersDir: {
|
|
90
|
+
type: "string",
|
|
91
|
+
title: "Custom Device Configuration Directory",
|
|
92
|
+
description: `Directory to scan for your own device configuration YAML files (e.g. for a model this plugin doesn't bundle yet), in addition to the ones built in. Leave blank to use the default: ${defaultUserDir || "<SignalK home>/bluetti"}.`,
|
|
93
|
+
default: "",
|
|
94
|
+
},
|
|
71
95
|
devices: {
|
|
72
96
|
type: "array",
|
|
73
97
|
title: "Devices",
|
|
@@ -93,16 +117,11 @@ module.exports = function (app) {
|
|
|
93
117
|
},
|
|
94
118
|
builtinModel: {
|
|
95
119
|
type: "string",
|
|
96
|
-
title: "
|
|
97
|
-
description:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
csvPath: {
|
|
102
|
-
type: "string",
|
|
103
|
-
title: "Custom register map CSV path",
|
|
104
|
-
description: 'Absolute path to a register definition CSV. Only used when "custom" is selected above.',
|
|
105
|
-
default: "",
|
|
120
|
+
title: "Device Configuration",
|
|
121
|
+
description:
|
|
122
|
+
"Select a device configuration for your model — bundled with the plugin, or dropped into the custom device configuration directory above.",
|
|
123
|
+
enum: allModels,
|
|
124
|
+
default: allModels.length > 0 ? allModels[0] : "",
|
|
106
125
|
},
|
|
107
126
|
encryptionCsvPath: {
|
|
108
127
|
type: "string",
|
|
@@ -124,11 +143,11 @@ module.exports = function (app) {
|
|
|
124
143
|
};
|
|
125
144
|
|
|
126
145
|
plugin.uiSchema = {
|
|
146
|
+
registersDir: { "ui:placeholder": defaultUserDir || "e.g. /home/pi/.signalk/bluetti" },
|
|
127
147
|
devices: {
|
|
128
148
|
items: {
|
|
129
149
|
address: { "ui:placeholder": "aa:bb:cc:dd:ee:ff" },
|
|
130
150
|
name: { "ui:placeholder": "house" },
|
|
131
|
-
csvPath: { "ui:placeholder": "e.g. /path/to/my-device-registers.csv" },
|
|
132
151
|
encryptionCsvPath: { "ui:placeholder": "e.g. /path/to/19e1646709e0421b755fa9dda74.csv" },
|
|
133
152
|
},
|
|
134
153
|
},
|
|
@@ -137,11 +156,11 @@ module.exports = function (app) {
|
|
|
137
156
|
// ── Start ──────────────────────────────────────────────────────────────
|
|
138
157
|
|
|
139
158
|
plugin.start = function (options) {
|
|
140
|
-
let Scanner, BluettiDevice,
|
|
159
|
+
let Scanner, BluettiDevice, loadRegisters, buildDelta, readEncryptionKey;
|
|
141
160
|
try {
|
|
142
161
|
Scanner = require("./lib/scanner");
|
|
143
162
|
BluettiDevice = require("./lib/device");
|
|
144
|
-
({
|
|
163
|
+
({ loadRegisters } = require("./lib/register-loader"));
|
|
145
164
|
({ buildDelta } = require("./lib/path-mapper"));
|
|
146
165
|
({ readEncryptionKey } = require("./lib/encryption"));
|
|
147
166
|
} catch (err) {
|
|
@@ -149,6 +168,15 @@ module.exports = function (app) {
|
|
|
149
168
|
return;
|
|
150
169
|
}
|
|
151
170
|
|
|
171
|
+
const userRegistersDir = options.registersDir || defaultUserDir;
|
|
172
|
+
if (userRegistersDir) {
|
|
173
|
+
try {
|
|
174
|
+
fs.mkdirSync(userRegistersDir, { recursive: true });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
log(`Could not create custom device configuration directory "${userRegistersDir}": ${err.message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
152
180
|
scanner = new Scanner(log);
|
|
153
181
|
|
|
154
182
|
const devices = (options.devices || []).filter((d) => d.enabled !== false);
|
|
@@ -185,7 +213,7 @@ module.exports = function (app) {
|
|
|
185
213
|
// Build address → cfg lookup (normalise to lowercase, no colons)
|
|
186
214
|
const normalise = (addr) => addr.toLowerCase().replace(/:/g, "");
|
|
187
215
|
const pending = new Map(devices.map((cfg) => [normalise(cfg.address), cfg]));
|
|
188
|
-
const deps = { BluettiDevice,
|
|
216
|
+
const deps = { BluettiDevice, loadRegisters, buildDelta, readEncryptionKey, userRegistersDir };
|
|
189
217
|
|
|
190
218
|
scanner.on("discovered", ({ address, name, device: bleDevice }) => {
|
|
191
219
|
scanResultCache.push({ address, name });
|
|
@@ -260,22 +288,37 @@ module.exports = function (app) {
|
|
|
260
288
|
return null;
|
|
261
289
|
}
|
|
262
290
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
291
|
+
// Finds <model>.yaml (or .yml), checking the custom directory before the
|
|
292
|
+
// bundled one so a user's own file can override a built-in of the same name.
|
|
293
|
+
function findModelFile(model, userRegistersDir) {
|
|
294
|
+
const dirs = [userRegistersDir, DEVICES_DIR].filter(Boolean);
|
|
295
|
+
for (const dir of dirs) {
|
|
296
|
+
for (const ext of [".yaml", ".yml"]) {
|
|
297
|
+
const p = path.join(dir, `${model}${ext}`);
|
|
298
|
+
if (fs.existsSync(p)) return p;
|
|
299
|
+
}
|
|
268
300
|
}
|
|
269
|
-
return
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function resolveRegisterMapPath(cfg, userRegistersDir) {
|
|
305
|
+
const { builtinModel } = cfg;
|
|
306
|
+
if (!builtinModel) throw new Error("No device configuration: select a model");
|
|
307
|
+
const found = findModelFile(builtinModel, userRegistersDir);
|
|
308
|
+
if (!found)
|
|
309
|
+
throw new Error(
|
|
310
|
+
`Device configuration "${builtinModel}" not found (checked ${userRegistersDir ? `${userRegistersDir} and ` : ""}${DEVICES_DIR})`,
|
|
311
|
+
);
|
|
312
|
+
return found;
|
|
270
313
|
}
|
|
271
314
|
|
|
272
|
-
function startDevice(cfg, bleDevice, bleName, { BluettiDevice,
|
|
315
|
+
function startDevice(cfg, bleDevice, bleName, { BluettiDevice, loadRegisters, buildDelta, readEncryptionKey, userRegistersDir }) {
|
|
273
316
|
const { address, name, encryptionCsvPath = "", pollIntervalSeconds = 10 } = cfg;
|
|
274
317
|
const registerCache = new Map(); // last-known value per field_name, across polls — see buildDelta
|
|
275
318
|
|
|
276
319
|
let registerPath;
|
|
277
320
|
try {
|
|
278
|
-
registerPath = resolveRegisterMapPath(cfg);
|
|
321
|
+
registerPath = resolveRegisterMapPath(cfg, userRegistersDir);
|
|
279
322
|
} catch (err) {
|
|
280
323
|
app.setPluginError(`[${name}] ${err.message}`);
|
|
281
324
|
return;
|
|
@@ -283,10 +326,10 @@ module.exports = function (app) {
|
|
|
283
326
|
|
|
284
327
|
let fields;
|
|
285
328
|
try {
|
|
286
|
-
fields =
|
|
329
|
+
fields = loadRegisters(registerPath);
|
|
287
330
|
log(`[${name}] Loaded ${fields.length} registers from ${registerPath}`);
|
|
288
331
|
} catch (err) {
|
|
289
|
-
app.setPluginError(`[${name}] Failed to load
|
|
332
|
+
app.setPluginError(`[${name}] Failed to load device configuration "${registerPath}": ${err.message}`);
|
|
290
333
|
return;
|
|
291
334
|
}
|
|
292
335
|
|
package/lib/path-mapper.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
-
const { decodeValue } = require("./
|
|
3
|
+
const { decodeValue } = require("./register-loader");
|
|
4
4
|
|
|
5
5
|
// SignalK requires specific SI units. These conversions handle the most common
|
|
6
|
-
// cases found in Bluetti register maps. The
|
|
6
|
+
// cases found in Bluetti register maps. The YAML field's `unit` key drives the choice.
|
|
7
7
|
const UNIT_CONVERSIONS = {
|
|
8
8
|
// temperature: Bluetti reports in °C or 0.1°C (handled by scale), SignalK wants K
|
|
9
9
|
c: (v) => v + 273.15,
|
|
@@ -30,9 +30,9 @@ function convertUnits(value, unit) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// Canonical field_name → SignalK path for the register names Bluetti's own
|
|
33
|
-
// documentation and the bundled
|
|
33
|
+
// documentation and the bundled register maps use. A field's `path` key is an
|
|
34
34
|
// override on top of this — only needed for a register the code doesn't know
|
|
35
|
-
// about — so bundled/well-known
|
|
35
|
+
// about — so bundled/well-known register maps can leave it out and just list
|
|
36
36
|
// field_name + register info.
|
|
37
37
|
//
|
|
38
38
|
// ac_input_power, ac_input_voltage, and dc_output_power are intentionally
|
|
@@ -69,13 +69,55 @@ const STANDARD_FIELD_PATHS = {
|
|
|
69
69
|
// registers for them; if not, current is derived (see buildDelta below).
|
|
70
70
|
ac_input_current: "electrical.chargers.{name}.current",
|
|
71
71
|
ac_input_frequency: "electrical.chargers.{name}.frequency",
|
|
72
|
+
grid_frequency: "electrical.chargers.{name}.frequency", // alias used by 3-phase/home-backup models
|
|
73
|
+
|
|
74
|
+
// Lifetime energy generation counter (some models only)
|
|
75
|
+
power_generation: "electrical.solar.{name}.energyGeneratedTotal",
|
|
76
|
+
|
|
77
|
+
// Second PV string (dual-MPPT models) — the primary string uses dc_input_* above
|
|
78
|
+
pv_s2_power: "electrical.solar.{name}.pv2.panelPower",
|
|
79
|
+
pv_s2_voltage: "electrical.solar.{name}.pv2.panelVoltage",
|
|
80
|
+
pv_s2_current: "electrical.solar.{name}.pv2.panelCurrent",
|
|
81
|
+
|
|
82
|
+
// Per-phase grid (mains) input — split-phase/3-phase home-backup models
|
|
83
|
+
grid_p1_power: "electrical.chargers.{name}.L1.power",
|
|
84
|
+
grid_p1_voltage: "electrical.chargers.{name}.L1.voltage",
|
|
85
|
+
grid_p1_current: "electrical.chargers.{name}.L1.current",
|
|
86
|
+
grid_p2_power: "electrical.chargers.{name}.L2.power",
|
|
87
|
+
grid_p2_voltage: "electrical.chargers.{name}.L2.voltage",
|
|
88
|
+
grid_p2_current: "electrical.chargers.{name}.L2.current",
|
|
89
|
+
grid_p3_power: "electrical.chargers.{name}.L3.power",
|
|
90
|
+
grid_p3_voltage: "electrical.chargers.{name}.L3.voltage",
|
|
91
|
+
grid_p3_current: "electrical.chargers.{name}.L3.current",
|
|
92
|
+
|
|
93
|
+
// Per-phase AC output — split-phase/3-phase home-backup models
|
|
94
|
+
ac_p1_power: "electrical.inverters.{name}.ac.L1.realPower",
|
|
95
|
+
ac_p1_voltage: "electrical.inverters.{name}.ac.L1.voltage",
|
|
96
|
+
ac_p1_current: "electrical.inverters.{name}.ac.L1.current",
|
|
97
|
+
ac_p2_power: "electrical.inverters.{name}.ac.L2.realPower",
|
|
98
|
+
ac_p2_voltage: "electrical.inverters.{name}.ac.L2.voltage",
|
|
99
|
+
ac_p2_current: "electrical.inverters.{name}.ac.L2.current",
|
|
100
|
+
ac_p3_power: "electrical.inverters.{name}.ac.L3.realPower",
|
|
101
|
+
ac_p3_voltage: "electrical.inverters.{name}.ac.L3.voltage",
|
|
102
|
+
ac_p3_current: "electrical.inverters.{name}.ac.L3.current",
|
|
103
|
+
|
|
104
|
+
// Smart meter (external CT clamp on the home panel) — home-backup models only
|
|
105
|
+
sm_p1_power: "electrical.inverters.{name}.smartMeter.L1.power",
|
|
106
|
+
sm_p1_voltage: "electrical.inverters.{name}.smartMeter.L1.voltage",
|
|
107
|
+
sm_p1_current: "electrical.inverters.{name}.smartMeter.L1.current",
|
|
108
|
+
sm_p2_power: "electrical.inverters.{name}.smartMeter.L2.power",
|
|
109
|
+
sm_p2_voltage: "electrical.inverters.{name}.smartMeter.L2.voltage",
|
|
110
|
+
sm_p2_current: "electrical.inverters.{name}.smartMeter.L2.current",
|
|
111
|
+
sm_p3_power: "electrical.inverters.{name}.smartMeter.L3.power",
|
|
112
|
+
sm_p3_voltage: "electrical.inverters.{name}.smartMeter.L3.voltage",
|
|
113
|
+
sm_p3_current: "electrical.inverters.{name}.smartMeter.L3.current",
|
|
72
114
|
};
|
|
73
115
|
|
|
74
116
|
// Fields that feed a derived computation in buildDelta() rather than being
|
|
75
117
|
// published under their own path directly.
|
|
76
118
|
const DERIVED_SOURCE_FIELDS = new Set(["ac_input_power", "ac_input_voltage", "dc_output_power", "dc_output_voltage"]);
|
|
77
119
|
|
|
78
|
-
// Build a SignalK path for a field: explicit
|
|
120
|
+
// Build a SignalK path for a field: explicit `path` override first, then the
|
|
79
121
|
// standard field_name registry above, then a best-effort keyword guess.
|
|
80
122
|
function resolvePath(field, deviceName) {
|
|
81
123
|
if (field.signalkPath) {
|
|
@@ -88,7 +130,7 @@ function resolvePath(field, deviceName) {
|
|
|
88
130
|
|
|
89
131
|
// Best-effort automatic path generation for common Bluetti field names.
|
|
90
132
|
// Only reached for fields not in STANDARD_FIELD_PATHS above — i.e. a custom
|
|
91
|
-
//
|
|
133
|
+
// register map using field names the code doesn't recognise and no explicit override.
|
|
92
134
|
function autoPath(fieldName, name) {
|
|
93
135
|
const f = fieldName.toLowerCase();
|
|
94
136
|
if (f.includes("battery") || f.includes("batt") || f.includes("soc")) {
|
|
@@ -163,8 +205,8 @@ function buildDelta(registers, fields, deviceName, source, opts = {}) {
|
|
|
163
205
|
}
|
|
164
206
|
|
|
165
207
|
// 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
|
|
167
|
-
// it as a constant (dc_output_voltage), and current is derived from that.
|
|
208
|
+
// at a fixed nominal voltage that isn't itself a register — the register map
|
|
209
|
+
// supplies it as a constant (dc_output_voltage), and current is derived from that.
|
|
168
210
|
if (cache.has("dc_output_power") && cache.has("dc_output_voltage")) {
|
|
169
211
|
const power = cache.get("dc_output_power");
|
|
170
212
|
const voltage = cache.get("dc_output_voltage");
|
|
@@ -174,7 +216,7 @@ function buildDelta(registers, fields, deviceName, source, opts = {}) {
|
|
|
174
216
|
|
|
175
217
|
// Remaining capacity = nominal capacity × state of charge. Bluetti's own
|
|
176
218
|
// "remaining capacity" register drifts with cell aging and isn't available
|
|
177
|
-
// on every model; deriving it from a fixed nominal spec (see
|
|
219
|
+
// on every model; deriving it from a fixed nominal spec (see devices/*.yaml)
|
|
178
220
|
// plus the reported SoC is simpler and consistent across models.
|
|
179
221
|
const soc = cache.has("battery_soc") ? cache.get("battery_soc") : cache.has("battery_percent") ? cache.get("battery_percent") : null;
|
|
180
222
|
if (soc !== null && cache.has("total_capacity")) {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const YAML = require("yaml");
|
|
5
|
+
|
|
6
|
+
// Parse a register map YAML file and return an array of field descriptors.
|
|
7
|
+
// Each descriptor is either register-backed: { fieldName, register, count, dataType, scale, offset, unit, signalkPath }
|
|
8
|
+
// or a fixed constant (no register on the device): { fieldName, register: null, count: 0, dataType: "const", ..., constantValue }
|
|
9
|
+
//
|
|
10
|
+
// Expected shape:
|
|
11
|
+
//
|
|
12
|
+
// fields:
|
|
13
|
+
// battery_soc: { register: 100, type: uint16, unit: "%" }
|
|
14
|
+
// battery_voltage: { register: 101, type: uint16, scale: 0.1, unit: V }
|
|
15
|
+
// constants:
|
|
16
|
+
// battery_chemistry: LiFePO4
|
|
17
|
+
// total_capacity: { value: 2000, unit: Wh }
|
|
18
|
+
//
|
|
19
|
+
// `type` defaults to uint16, `scale` to 1, `offset` to 0, `count` is inferred
|
|
20
|
+
// from `type` when absent. `path` on a field/constant is an explicit SignalK
|
|
21
|
+
// path override (see lib/path-mapper.js). A constant may be a bare scalar
|
|
22
|
+
// (string or number) or an object with `value` (+ optional `unit`, `path`).
|
|
23
|
+
function loadRegisters(filePath) {
|
|
24
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
25
|
+
const doc = YAML.parse(raw);
|
|
26
|
+
if (!doc || typeof doc !== "object") throw new Error(`${filePath}: empty or invalid YAML register map`);
|
|
27
|
+
|
|
28
|
+
const fields = [];
|
|
29
|
+
|
|
30
|
+
for (const [fieldName, spec] of Object.entries(doc.fields || {})) {
|
|
31
|
+
if (spec === null || typeof spec !== "object") {
|
|
32
|
+
throw new Error(`${filePath}: field "${fieldName}" must be a mapping with at least a "register" key`);
|
|
33
|
+
}
|
|
34
|
+
const register = parseInt(spec.register, 10);
|
|
35
|
+
if (isNaN(register)) throw new Error(`${filePath}: field "${fieldName}" is missing a numeric "register" address`);
|
|
36
|
+
|
|
37
|
+
const dataType = String(spec.type || "uint16").toLowerCase();
|
|
38
|
+
const count = Number.isInteger(spec.count) ? spec.count : registersForType(dataType);
|
|
39
|
+
const scale = spec.scale !== undefined ? Number(spec.scale) : 1;
|
|
40
|
+
const offset = spec.offset !== undefined ? Number(spec.offset) : 0;
|
|
41
|
+
const unit = spec.unit !== undefined ? String(spec.unit) : "";
|
|
42
|
+
const signalkPath = spec.path !== undefined ? String(spec.path) : "";
|
|
43
|
+
|
|
44
|
+
fields.push({ fieldName, register, count, dataType, scale, offset, unit, signalkPath });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
for (const [fieldName, spec] of Object.entries(doc.constants || {})) {
|
|
48
|
+
const isExpanded = spec !== null && typeof spec === "object" && "value" in spec;
|
|
49
|
+
const value = isExpanded ? spec.value : spec;
|
|
50
|
+
const unit = isExpanded && spec.unit !== undefined ? String(spec.unit) : "";
|
|
51
|
+
const signalkPath = isExpanded && spec.path !== undefined ? String(spec.path) : "";
|
|
52
|
+
const constantValue = typeof value === "number" ? value : String(value);
|
|
53
|
+
|
|
54
|
+
fields.push({ fieldName, register: null, count: 0, dataType: "const", scale: 1, offset: 0, unit, signalkPath, constantValue });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (fields.length === 0) throw new Error(`No fields or constants found in ${filePath}`);
|
|
58
|
+
return fields;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function registersForType(dataType) {
|
|
62
|
+
switch (dataType) {
|
|
63
|
+
case "int32":
|
|
64
|
+
case "uint32":
|
|
65
|
+
case "float32":
|
|
66
|
+
return 2;
|
|
67
|
+
case "int64":
|
|
68
|
+
case "uint64":
|
|
69
|
+
return 4;
|
|
70
|
+
default:
|
|
71
|
+
return 1; // uint16, int16, bool, enum
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Decode a raw register value (or pair of registers for 32-bit types) into a number.
|
|
76
|
+
// rawRegs: Map<addr, uint16>, startAddr: the register address for this field
|
|
77
|
+
function decodeValue(field, rawRegs) {
|
|
78
|
+
const { register, count, dataType, scale, offset } = field;
|
|
79
|
+
|
|
80
|
+
if (dataType === "const") return field.constantValue;
|
|
81
|
+
|
|
82
|
+
let raw;
|
|
83
|
+
if (count === 1) {
|
|
84
|
+
raw = rawRegs.get(register);
|
|
85
|
+
if (raw === undefined) return null;
|
|
86
|
+
} else if (count === 2) {
|
|
87
|
+
const hi = rawRegs.get(register);
|
|
88
|
+
const lo = rawRegs.get(register + 1);
|
|
89
|
+
if (hi === undefined || lo === undefined) return null;
|
|
90
|
+
raw = (hi << 16) | lo;
|
|
91
|
+
} else {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let value;
|
|
96
|
+
switch (dataType) {
|
|
97
|
+
case "int16":
|
|
98
|
+
value = raw > 0x7fff ? raw - 0x10000 : raw;
|
|
99
|
+
break;
|
|
100
|
+
case "int32": {
|
|
101
|
+
const signed = raw > 0x7fffffff ? raw - 0x100000000 : raw;
|
|
102
|
+
value = signed;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case "float32": {
|
|
106
|
+
const tmpBuf = Buffer.alloc(4);
|
|
107
|
+
tmpBuf.writeUInt32BE(raw, 0);
|
|
108
|
+
value = tmpBuf.readFloatBE(0);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "bool":
|
|
112
|
+
return raw !== 0 ? 1 : 0;
|
|
113
|
+
default:
|
|
114
|
+
value = raw;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return value * scale + offset;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { loadRegisters, decodeValue };
|
package/lib/scanner.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const EventEmitter = require("events");
|
|
4
4
|
|
|
5
|
-
const BLUETTI_NAME_PREFIXES = ["BT-TH-", "BLUETTI", "AC", "EP", "EB", "EL"];
|
|
5
|
+
const BLUETTI_NAME_PREFIXES = ["BT-TH-", "BLUETTI", "AC", "EP", "EB", "EL", "PR", "AP"];
|
|
6
6
|
|
|
7
7
|
// Retry/backoff for BLE adapter init — covers the case where SignalK (and this
|
|
8
8
|
// plugin) starts before bluetoothd/D-Bus is up, e.g. on boot when service
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhizomatics/signalk-bluetti-plugin",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "SignalK plugin for Bluetti power station monitoring via Bluetooth LE.
|
|
3
|
+
"version": "1.5.1",
|
|
4
|
+
"description": "SignalK plugin for Bluetti power station monitoring via Bluetooth LE. Comes with configuration for 20+ models.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"battery",
|
|
7
7
|
"ble",
|
|
8
8
|
"bluetooth",
|
|
9
9
|
"bluetti",
|
|
10
10
|
"electrical",
|
|
11
|
+
"generator",
|
|
11
12
|
"marine",
|
|
13
|
+
"signalk",
|
|
14
|
+
"signalk-category-hardware",
|
|
12
15
|
"signalk-node-plugin",
|
|
13
|
-
"signalk-node-server-plugin"
|
|
16
|
+
"signalk-node-server-plugin",
|
|
17
|
+
"solar"
|
|
14
18
|
],
|
|
15
19
|
"homepage": "https://github.com/rhizomatics/signalk-bluetti-plugin.git",
|
|
16
20
|
"bugs": {
|
|
@@ -29,7 +33,9 @@
|
|
|
29
33
|
"index.js",
|
|
30
34
|
"cli.js",
|
|
31
35
|
"lib/",
|
|
32
|
-
"
|
|
36
|
+
"docs/assets/",
|
|
37
|
+
"docs/examples/",
|
|
38
|
+
"devices/",
|
|
33
39
|
"README.md",
|
|
34
40
|
"CHANGELOG.md"
|
|
35
41
|
],
|
|
@@ -45,11 +51,11 @@
|
|
|
45
51
|
},
|
|
46
52
|
"dependencies": {
|
|
47
53
|
"@naugehyde/node-ble": "^1.13.5",
|
|
48
|
-
"
|
|
54
|
+
"yaml": "^2.9.0"
|
|
49
55
|
},
|
|
50
56
|
"devDependencies": {
|
|
51
57
|
"oxfmt": "^0.61.0",
|
|
52
|
-
"oxlint": "^1.
|
|
58
|
+
"oxlint": "^1.75.0",
|
|
53
59
|
"oxlint-tsgolint": "^7.0.2001"
|
|
54
60
|
},
|
|
55
61
|
"engines": {
|
|
@@ -58,6 +64,7 @@
|
|
|
58
64
|
},
|
|
59
65
|
"signalk": {
|
|
60
66
|
"displayName": "Bluetti Monitoring",
|
|
67
|
+
"appIcon": "docs/assets/icon.svg",
|
|
61
68
|
"recommends": [
|
|
62
69
|
"@rhizomatics/signalk-einklabel-plugin"
|
|
63
70
|
],
|
package/lib/csv-loader.js
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
const fs = require("fs");
|
|
4
|
-
const { parse } = require("csv-parse/sync");
|
|
5
|
-
|
|
6
|
-
// Column name aliases — Bluetti CSVs have been seen in English and Chinese headers.
|
|
7
|
-
// Maps our internal key → list of possible header strings (case-insensitive match).
|
|
8
|
-
const COL_ALIASES = {
|
|
9
|
-
field_name: ["field_name", "fieldname", "name", "字段名", "field"],
|
|
10
|
-
register_address: ["register_address", "register", "address", "addr", "reg", "no", "no.", "寄存器", "地址"],
|
|
11
|
-
data_type: ["data_type", "datatype", "type", "数据type", "数据类型"],
|
|
12
|
-
scale: ["scale", "倍率", "factor", "multiplier"],
|
|
13
|
-
offset: ["offset", "偏移"],
|
|
14
|
-
unit: ["unit", "units", "单位"],
|
|
15
|
-
signalk_path: ["signalk_path", "signalk", "sk_path", "path"],
|
|
16
|
-
register_count: ["register_count", "count", "length", "bytes", "数据长度"],
|
|
17
|
-
constant_value: ["constant_value", "constant", "const", "fixed_value", "static_value"],
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function resolveColumn(headers, key) {
|
|
21
|
-
const aliases = COL_ALIASES[key];
|
|
22
|
-
for (const alias of aliases) {
|
|
23
|
-
const found = headers.find((h) => h.trim().toLowerCase() === alias.toLowerCase());
|
|
24
|
-
if (found) return found;
|
|
25
|
-
}
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Parse the CSV and return an array of field descriptors.
|
|
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 }
|
|
32
|
-
function loadCsv(filePath) {
|
|
33
|
-
const raw = fs.readFileSync(filePath, "utf8");
|
|
34
|
-
|
|
35
|
-
// Strip BOM if present
|
|
36
|
-
const content = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
37
|
-
|
|
38
|
-
const records = parse(content, {
|
|
39
|
-
columns: true,
|
|
40
|
-
skip_empty_lines: true,
|
|
41
|
-
trim: true,
|
|
42
|
-
comment: "#",
|
|
43
|
-
relax_column_count: true,
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
if (records.length === 0) throw new Error(`CSV file ${filePath} has no data rows`);
|
|
47
|
-
|
|
48
|
-
const headers = Object.keys(records[0]);
|
|
49
|
-
|
|
50
|
-
const col = {};
|
|
51
|
-
for (const key of Object.keys(COL_ALIASES)) {
|
|
52
|
-
col[key] = resolveColumn(headers, key);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
if (!col.register_address) throw new Error("CSV must have a register address column");
|
|
56
|
-
if (!col.field_name) throw new Error("CSV must have a field name column");
|
|
57
|
-
|
|
58
|
-
const fields = [];
|
|
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
|
-
|
|
66
|
-
const registerRaw = row[col.register_address];
|
|
67
|
-
const register = parseInt(registerRaw, 10);
|
|
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
|
-
}
|
|
84
|
-
|
|
85
|
-
const dataType = col.data_type ? (row[col.data_type] || "uint16").toLowerCase().trim() : "uint16";
|
|
86
|
-
const scale = col.scale ? parseFloat(row[col.scale]) || 1 : 1;
|
|
87
|
-
const offset = col.offset ? parseFloat(row[col.offset]) || 0 : 0;
|
|
88
|
-
const unit = col.unit ? (row[col.unit] || "").trim() : "";
|
|
89
|
-
|
|
90
|
-
// register_count: for multi-register types (int32, uint32, float32 = 2 regs; int64 = 4 regs)
|
|
91
|
-
let count = col.register_count ? parseInt(row[col.register_count], 10) : NaN;
|
|
92
|
-
if (isNaN(count)) count = registersForType(dataType);
|
|
93
|
-
|
|
94
|
-
fields.push({ fieldName, register, count, dataType, scale, offset, unit, signalkPath });
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (fields.length === 0) throw new Error(`No valid register rows found in ${filePath}`);
|
|
98
|
-
return fields;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function registersForType(dataType) {
|
|
102
|
-
switch (dataType) {
|
|
103
|
-
case "int32":
|
|
104
|
-
case "uint32":
|
|
105
|
-
case "float32":
|
|
106
|
-
return 2;
|
|
107
|
-
case "int64":
|
|
108
|
-
case "uint64":
|
|
109
|
-
return 4;
|
|
110
|
-
default:
|
|
111
|
-
return 1; // uint16, int16, bool, enum
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Decode a raw register value (or pair of registers for 32-bit types) into a number.
|
|
116
|
-
// rawRegs: Map<addr, uint16>, startAddr: the register address for this field
|
|
117
|
-
function decodeValue(field, rawRegs) {
|
|
118
|
-
const { register, count, dataType, scale, offset } = field;
|
|
119
|
-
|
|
120
|
-
if (dataType === "const") return field.constantValue;
|
|
121
|
-
|
|
122
|
-
let raw;
|
|
123
|
-
if (count === 1) {
|
|
124
|
-
raw = rawRegs.get(register);
|
|
125
|
-
if (raw === undefined) return null;
|
|
126
|
-
} else if (count === 2) {
|
|
127
|
-
const hi = rawRegs.get(register);
|
|
128
|
-
const lo = rawRegs.get(register + 1);
|
|
129
|
-
if (hi === undefined || lo === undefined) return null;
|
|
130
|
-
raw = (hi << 16) | lo;
|
|
131
|
-
} else {
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
let value;
|
|
136
|
-
switch (dataType) {
|
|
137
|
-
case "int16":
|
|
138
|
-
value = raw > 0x7fff ? raw - 0x10000 : raw;
|
|
139
|
-
break;
|
|
140
|
-
case "int32": {
|
|
141
|
-
const signed = raw > 0x7fffffff ? raw - 0x100000000 : raw;
|
|
142
|
-
value = signed;
|
|
143
|
-
break;
|
|
144
|
-
}
|
|
145
|
-
case "float32": {
|
|
146
|
-
const tmpBuf = Buffer.alloc(4);
|
|
147
|
-
tmpBuf.writeUInt32BE(raw, 0);
|
|
148
|
-
value = tmpBuf.readFloatBE(0);
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
case "bool":
|
|
152
|
-
return raw !== 0 ? 1 : 0;
|
|
153
|
-
default:
|
|
154
|
-
value = raw;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return value * scale + offset;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
module.exports = { loadCsv, decodeValue };
|