@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +40 -4
  3. package/cli.js +71 -11
  4. package/devices/ac180.yaml +58 -0
  5. package/devices/ac180p.yaml +34 -0
  6. package/devices/ac180t.yaml +58 -0
  7. package/devices/ac200l.yaml +48 -0
  8. package/devices/ac200m.yaml +48 -0
  9. package/devices/ac200p.yaml +89 -0
  10. package/devices/ac200pl.yaml +48 -0
  11. package/devices/ac2a.yaml +34 -0
  12. package/devices/ac2p.yaml +34 -0
  13. package/devices/ac300.yaml +57 -0
  14. package/devices/ac500.yaml +56 -0
  15. package/devices/ac50b.yaml +31 -0
  16. package/devices/ac60.yaml +33 -0
  17. package/devices/ac60p.yaml +33 -0
  18. package/devices/ac70.yaml +62 -0
  19. package/devices/ac70p.yaml +58 -0
  20. package/devices/ap300.yaml +34 -0
  21. package/devices/eb3a.yaml +41 -0
  22. package/devices/el100v2.yaml +43 -0
  23. package/devices/el30v2.yaml +38 -0
  24. package/devices/ep2000.yaml +75 -0
  25. package/devices/ep500.yaml +57 -0
  26. package/devices/ep500p.yaml +57 -0
  27. package/devices/ep600.yaml +151 -0
  28. package/devices/ep760.yaml +72 -0
  29. package/devices/ep800.yaml +21 -0
  30. package/devices/handsfree1.yaml +62 -0
  31. package/devices/pr100v2.yaml +34 -0
  32. package/devices/pr30v2.yaml +34 -0
  33. package/docs/assets/icon.svg +36 -0
  34. package/docs/assets/screenshots/bluetti_data.png +0 -0
  35. package/docs/examples/model_definition.yaml +112 -0
  36. package/index.js +75 -32
  37. package/lib/path-mapper.js +51 -9
  38. package/lib/register-loader.js +120 -0
  39. package/lib/scanner.js +1 -1
  40. package/package.json +13 -6
  41. package/lib/csv-loader.js +0 -160
  42. package/registers/ac200p.csv +0 -47
  43. 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 REGISTERS_DIR = path.join(__dirname, "registers");
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 builtinModelNames() {
29
+ function yamlModelNames(dir) {
30
30
  try {
31
31
  return fs
32
- .readdirSync(REGISTERS_DIR)
33
- .filter((f) => f.endsWith(".csv"))
34
- .map((f) => f.replace(/\.csv$/, ""))
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 builtins = builtinModelNames();
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 15-second BLE scan and logs discovered Bluetti devices. Useful for finding device addresses.",
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: "Built-in register map",
97
- description: 'Select a bundled register map for your device model, or "custom" to supply your own CSV path below.',
98
- enum: ["custom", ...builtins],
99
- default: builtins.length > 0 ? builtins[0] : "custom",
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, loadCsv, buildDelta, readEncryptionKey;
159
+ let Scanner, BluettiDevice, loadRegisters, buildDelta, readEncryptionKey;
141
160
  try {
142
161
  Scanner = require("./lib/scanner");
143
162
  BluettiDevice = require("./lib/device");
144
- ({ loadCsv } = require("./lib/csv-loader"));
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, loadCsv, buildDelta, readEncryptionKey };
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
- function resolveRegisterMapPath(cfg) {
264
- const { builtinModel, csvPath } = cfg;
265
- if (!builtinModel || builtinModel === "custom") {
266
- if (!csvPath) throw new Error("No register map: select a built-in model or provide a custom CSV path");
267
- return csvPath;
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 path.join(REGISTERS_DIR, `${builtinModel}.csv`);
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, loadCsv, buildDelta, readEncryptionKey }) {
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 = loadCsv(registerPath);
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 register map "${registerPath}": ${err.message}`);
332
+ app.setPluginError(`[${name}] Failed to load device configuration "${registerPath}": ${err.message}`);
290
333
  return;
291
334
  }
292
335
 
@@ -1,9 +1,9 @@
1
1
  "use strict";
2
2
 
3
- const { decodeValue } = require("./csv-loader");
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 CSV's `unit` column drives the choice.
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 CSVs use. A CSV's `signalk_path` column is an
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 CSVs can leave it blank and just list
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 CSV override first, then the
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
- // CSV using field names the code doesn't recognise and no explicit override.
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 CSV supplies
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 registers/*.csv)
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.1",
4
- "description": "SignalK plugin for Bluetti power station monitoring via Bluetooth LE. Beta, use with caution.",
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
- "registers/",
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
- "csv-parse": "^5.5.6"
54
+ "yaml": "^2.9.0"
49
55
  },
50
56
  "devDependencies": {
51
57
  "oxfmt": "^0.61.0",
52
- "oxlint": "^1.76.0",
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 };