@rhizomatics/signalk-bluetti-plugin 1.4.1 → 1.5.0

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 +7 -0
  2. package/README.md +40 -4
  3. package/cli.js +69 -9
  4. package/docs/assets/icon.svg +36 -0
  5. package/docs/assets/screenshots/bluetti_data.png +0 -0
  6. package/docs/examples/model_definition.yaml +112 -0
  7. package/index.js +78 -25
  8. package/lib/path-mapper.js +51 -9
  9. package/lib/register-loader.js +120 -0
  10. package/lib/scanner.js +1 -1
  11. package/package.json +12 -5
  12. package/registers/ac180.yaml +58 -0
  13. package/registers/ac180p.yaml +34 -0
  14. package/registers/ac180t.yaml +58 -0
  15. package/registers/ac200l.yaml +48 -0
  16. package/registers/ac200m.yaml +48 -0
  17. package/registers/ac200p.yaml +89 -0
  18. package/registers/ac200pl.yaml +48 -0
  19. package/registers/ac2a.yaml +34 -0
  20. package/registers/ac2p.yaml +34 -0
  21. package/registers/ac300.yaml +57 -0
  22. package/registers/ac500.yaml +56 -0
  23. package/registers/ac50b.yaml +31 -0
  24. package/registers/ac60.yaml +33 -0
  25. package/registers/ac60p.yaml +33 -0
  26. package/registers/ac70.yaml +62 -0
  27. package/registers/ac70p.yaml +58 -0
  28. package/registers/ap300.yaml +34 -0
  29. package/registers/eb3a.yaml +41 -0
  30. package/registers/el100v2.yaml +43 -0
  31. package/registers/el30v2.yaml +38 -0
  32. package/registers/ep2000.yaml +75 -0
  33. package/registers/ep500.yaml +57 -0
  34. package/registers/ep500p.yaml +57 -0
  35. package/registers/ep600.yaml +151 -0
  36. package/registers/ep760.yaml +72 -0
  37. package/registers/ep800.yaml +21 -0
  38. package/registers/handsfree1.yaml +62 -0
  39. package/registers/pr100v2.yaml +34 -0
  40. package/registers/pr30v2.yaml +34 -0
  41. package/lib/csv-loader.js +0 -160
  42. package/registers/ac200p.csv +0 -47
  43. package/registers/el100v2.csv +0 -27
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this plugin are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/).
4
4
 
5
+ ## [1.5.0]
6
+
7
+ - Expanded Bluetti model support
8
+ - Configuration moved from CSV to YAML
9
+ - Configurable directory to add YAML files for unsupported Bluetti models
10
+ - CLI `models` command lists supported register maps with field/constant counts
11
+
5
12
  ## [1.4.1]
6
13
 
7
14
  - Basic test suite
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  ![code style: oxfmt](https://img.shields.io/badge/code_style-oxfmt-blue.svg)
7
7
  [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/rhizomatics/signalk-bluetti-plugin/blob/main/LICENSE)
8
8
 
9
- A SignalK plugin to display data from Bluetti power stations over a Bluetooth Low Energy (BLE) connection. BETA status
9
+ A SignalK plugin to display data from Bluetti power stations over a Bluetooth Low Energy (BLE) connection. Supports 20+ stations, extensible by configuration. Also offers a CLI for easier exploring and debugging.
10
10
 
11
11
  ![Example Data](docs/assets/screenshots/bluetti_data.png)
12
12
 
@@ -73,19 +73,44 @@ node cli.js dump aa:bb:cc:dd:ee:ff --timeout 30 # discovery timeout if device
73
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.
74
74
 
75
75
  ```bash
76
- # Decode live registers using a bundled register map (see registers/*.csv for available models)
76
+ # Decode live registers using a bundled register map (see registers/*.yaml for available models)
77
77
  node cli.js info aa:bb:cc:dd:ee:ff --registers ac200p
78
78
  node cli.js info --registers ac200p # scan and use the first Bluetti device found
79
79
 
80
- # Or a custom CSV, plus an encryption key file for legacy models that XOR-scramble frames
80
+ # Or a custom YAML, plus an encryption key file for legacy models that XOR-scramble frames
81
81
  # (not needed for V2-protocol models — those are auto-detected and handshake automatically)
82
- node cli.js info aa:bb:cc:dd:ee:ff --registers ./my-device-registers.csv --encryption-key ~/19e1646709e0421b755fa9dda74.csv
82
+ node cli.js info aa:bb:cc:dd:ee:ff --registers ./my-device-registers.yaml --encryption-key ~/19e1646709e0421b755fa9dda74.csv
83
83
 
84
84
  node cli.js info aa:bb:cc:dd:ee:ff --registers ac200p --timeout 30 # discovery timeout if device isn't already known to BlueZ (default: 20)
85
85
  ```
86
86
 
87
+ ### `models`
88
+
89
+ List every register map the CLI (and the plugin) can find — bundled ones plus anything dropped into the custom directory (`~/.signalk/bluetti` by default) — with a field and constant count per model. No device connection needed.
90
+
91
+ ```bash
92
+ node cli.js models
93
+ ```
94
+
87
95
  ## FAQ
88
96
 
97
+ ### Capacity figures show in Joules, how do I make it useful?
98
+
99
+ SignalK stores all data in SI units rather than any customary units used anywhere, for example temperatures are all held in Kelvin and unit preferences allow them to be converted in the display to C or F.
100
+
101
+ Unfortunately the default sets of preset unit preferences don't include the `energy` category used for capacity and more unfortunately as of SignalK v2.30.0 there's no easy way to override individual paths.
102
+
103
+ The fiddly way to do it should be as below (however there's a current [misconfiguration](https://github.com/SignalK/signalk-server/issues/2878) in the SignalK code which gets in the way):
104
+
105
+ 1. Download your preferred set of unit preferences from the [SignalK repo](https://github.com/SignalK/signalk-server/tree/ac368c548fba923db0acf8a314cbdc6552b2b8cf/unitpreferences/presets)
106
+ 2. Edit the downloaded file
107
+
108
+ - Change the `targetUnit` of `energy` category to `Wh`
109
+ - Change the `name` at the top of the file to reflect the change, for example `"Nautical (Metric, Wh capacity)"`
110
+
111
+ 3. Go to the _Unit Preferences_ section of the _Data_ menu in SignalK
112
+ 4. Use the _Upload_ button to upload your amended preferences preset and select it in the preset dropdown
113
+
89
114
  ### SignalK starts before the Bluetooth daemon — does the plugin need `bluetoothd` running at boot?
90
115
 
91
116
  The plugin retries BLE adapter initialisation with backoff (starting at 2s, capping at 30s) if `bluetoothd`/D-Bus isn't up yet when the plugin starts, so a slow-starting Bluetooth stack on boot will no longer strand it — it keeps retrying until the adapter appears rather than failing once and giving up. You'll see `BLE adapter not ready … — retrying in Ns …` in the SignalK logs in the meantime.
@@ -112,3 +137,14 @@ sudo systemctl restart signalk
112
137
  ```
113
138
 
114
139
  This tells systemd to start `bluetoothd` first and wait for it before starting SignalK, rather than relying on both racing to start in parallel at boot.
140
+
141
+ ### My station isn't supported
142
+
143
+ Additional products can be added using a YAML file mapping modbus registers and providing static values — see [`docs/examples/model_definition.yaml`](docs/examples/model_definition.yaml) in the repo for an annotated template, and `registers/*.yaml` for real examples.
144
+
145
+ Drop your file into the plugin's **Custom register maps directory** (a plugin setting, defaulting to `~/.signalk/bluetti`) and it'll appear in the **Register map** dropdown for a device, named after the file (without `.yaml`). A file there takes priority over a bundled one of the same name, so it also doubles as a way to override a built-in map. If you'd rather not touch plugin settings, point a device's **Custom register map YAML path** at the file directly instead — either way works, use whichever is more convenient.
146
+
147
+ Check one of these projects to see if some of the mapping work has been done:
148
+
149
+ - https://github.com/Patrick762/bluetti-bt-lib
150
+ - https://github.com/warhammerkid/bluetti_mqtt
package/cli.js CHANGED
@@ -2,22 +2,58 @@
2
2
  "use strict";
3
3
 
4
4
  const fs = require("fs");
5
+ const os = require("os");
5
6
  const path = require("path");
6
7
 
7
8
  const REGISTERS_DIR = path.join(__dirname, "registers");
9
+ // Same default the plugin itself uses when running under a real SignalK
10
+ // server (see index.js) — lets a user's custom register maps be found by the
11
+ // CLI too, without needing to pass --registers-dir.
12
+ const DEFAULT_USER_REGISTERS_DIR = path.join(os.homedir(), ".signalk", "bluetti");
8
13
 
9
- function listBuiltinModels() {
14
+ function listModels(dir) {
10
15
  try {
11
16
  return fs
12
- .readdirSync(REGISTERS_DIR)
13
- .filter((f) => f.endsWith(".csv"))
14
- .map((f) => f.replace(/\.csv$/, ""))
17
+ .readdirSync(dir)
18
+ .filter((f) => /\.ya?ml$/.test(f))
19
+ .map((f) => f.replace(/\.ya?ml$/, ""))
15
20
  .sort();
16
21
  } catch {
17
22
  return [];
18
23
  }
19
24
  }
20
25
 
26
+ function listBuiltinModels() {
27
+ return [...new Set([...listModels(REGISTERS_DIR), ...listModels(DEFAULT_USER_REGISTERS_DIR)])].sort((a, b) => a.localeCompare(b));
28
+ }
29
+
30
+ // One row per model, checking the custom directory before the bundled one so
31
+ // a user's own file overriding a built-in shows up as "custom" — same
32
+ // resolution order as resolveRegistersArg()/the plugin itself.
33
+ function listModelDetails() {
34
+ const { loadRegisters } = require("./lib/register-loader");
35
+
36
+ return listBuiltinModels().map((model) => {
37
+ for (const [dir, source] of [
38
+ [DEFAULT_USER_REGISTERS_DIR, "custom"],
39
+ [REGISTERS_DIR, "built-in"],
40
+ ]) {
41
+ for (const ext of [".yaml", ".yml"]) {
42
+ const p = path.join(dir, `${model}${ext}`);
43
+ if (!fs.existsSync(p)) continue;
44
+ try {
45
+ const fields = loadRegisters(p);
46
+ const constants = fields.filter((f) => f.dataType === "const").length;
47
+ return { model, fields: fields.length - constants, constants, source, path: p };
48
+ } catch (err) {
49
+ return { model, fields: "?", constants: "?", source, path: `${p} (failed to load: ${err.message})` };
50
+ }
51
+ }
52
+ }
53
+ return { model, fields: "?", constants: "?", source: "?", path: "" };
54
+ });
55
+ }
56
+
21
57
  function usage() {
22
58
  const builtins = listBuiltinModels();
23
59
  console.log(`
@@ -41,11 +77,16 @@ Commands:
41
77
  the plugin does
42
78
  If mac is omitted, scans and uses the first
43
79
  Bluetti device found.
44
- --registers <model|path> Built-in model (${builtins.join(", ") || "none bundled"}) or a CSV path (required)
80
+ --registers <model|path> Built-in model (${builtins.join(", ") || "none bundled"}) or a register map
81
+ YAML path (required). Also checks
82
+ ${DEFAULT_USER_REGISTERS_DIR} for custom models.
45
83
  --encryption-key <path> Path to the Bluetti-provided encryption CSV
46
84
  (only needed for legacy XOR-scrambled models)
47
85
  --timeout <seconds> Discovery/scan timeout if not already known (default: 20)
48
86
 
87
+ models List supported register maps, with a field and
88
+ constant count per model
89
+
49
90
  help Show this help
50
91
 
51
92
  Examples:
@@ -55,6 +96,7 @@ Examples:
55
96
  bluetti-cli dump
56
97
  bluetti-cli info aa:bb:cc:dd:ee:ff --registers ac200p
57
98
  bluetti-cli info --registers ac200p
99
+ bluetti-cli models
58
100
  `);
59
101
  }
60
102
 
@@ -138,8 +180,12 @@ async function findDevice(adapter, mac, timeoutMs) {
138
180
  }
139
181
 
140
182
  function resolveRegistersArg(value) {
141
- const builtinPath = path.join(REGISTERS_DIR, `${value}.csv`);
142
- if (fs.existsSync(builtinPath)) return builtinPath;
183
+ for (const dir of [DEFAULT_USER_REGISTERS_DIR, REGISTERS_DIR]) {
184
+ for (const ext of [".yaml", ".yml"]) {
185
+ const p = path.join(dir, `${value}${ext}`);
186
+ if (fs.existsSync(p)) return p;
187
+ }
188
+ }
143
189
  if (fs.existsSync(value)) return value;
144
190
  const builtins = listBuiltinModels();
145
191
  throw new Error(`Register map not found: "${value}" (not a built-in model [${builtins.join(", ")}] or an existing file path)`);
@@ -212,14 +258,14 @@ async function printGattTree(bleDevice) {
212
258
  }
213
259
 
214
260
  async function printRegisterValues(bleDevice, mac, args) {
215
- const { loadCsv, decodeValue } = require("./lib/csv-loader");
261
+ const { loadRegisters, decodeValue } = require("./lib/register-loader");
216
262
  const { resolvePath, convertUnits } = require("./lib/path-mapper");
217
263
  const { readEncryptionKey } = require("./lib/encryption");
218
264
  const { groupRegisters } = require("./lib/protocol");
219
265
  const BluettiDevice = require("./lib/device");
220
266
 
221
267
  const registerPath = resolveRegistersArg(args.registers);
222
- const fields = loadCsv(registerPath);
268
+ const fields = loadRegisters(registerPath);
223
269
  console.log(`\nLoaded ${fields.length} register field(s) from ${registerPath}`);
224
270
 
225
271
  const addrs = [...new Set(fields.flatMap((f) => Array.from({ length: f.count }, (_, i) => f.register + i)))];
@@ -355,6 +401,17 @@ async function cmdInfo(args) {
355
401
  );
356
402
  }
357
403
 
404
+ // ── models ───────────────────────────────────────────────────────────────
405
+
406
+ function cmdModels() {
407
+ const rows = listModelDetails();
408
+ if (rows.length === 0) {
409
+ console.log(`No register maps found (checked ${REGISTERS_DIR} and ${DEFAULT_USER_REGISTERS_DIR}).`);
410
+ return;
411
+ }
412
+ printTable(rows, ["model", "fields", "constants", "source"]);
413
+ }
414
+
358
415
  // ── main ─────────────────────────────────────────────────────────────────
359
416
 
360
417
  async function main() {
@@ -371,6 +428,9 @@ async function main() {
371
428
  case "info":
372
429
  await cmdInfo(args);
373
430
  break;
431
+ case "models":
432
+ cmdModels();
433
+ break;
374
434
  case "help":
375
435
  case "--help":
376
436
  case "-h":
@@ -0,0 +1,36 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
2
+ <defs>
3
+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
4
+ <stop offset="0" stop-color="#0f4c5c"/>
5
+ <stop offset="1" stop-color="#093542"/>
6
+ </linearGradient>
7
+ <linearGradient id="body" x1="0" y1="0" x2="0" y2="1">
8
+ <stop offset="0" stop-color="#f4f6f7"/>
9
+ <stop offset="1" stop-color="#d7dde0"/>
10
+ </linearGradient>
11
+ </defs>
12
+
13
+ <!-- app icon background -->
14
+ <rect x="0" y="0" width="256" height="256" rx="48" fill="url(#bg)"/>
15
+
16
+ <!-- power station body -->
17
+ <rect x="56" y="58" width="144" height="132" rx="14" fill="url(#body)" stroke="#093542" stroke-width="4"/>
18
+
19
+ <!-- display -->
20
+ <rect x="74" y="74" width="56" height="34" rx="6" fill="#0f4c5c"/>
21
+ <!-- battery glyph inside display -->
22
+ <rect x="82" y="85" width="30" height="12" rx="2" fill="none" stroke="#7fd8c4" stroke-width="2.5"/>
23
+ <rect x="113" y="88.5" width="4" height="5" fill="#7fd8c4"/>
24
+ <rect x="85" y="88" width="18" height="6" fill="#7fd8c4"/>
25
+
26
+ <!-- lightning bolt -->
27
+ <path d="M156 72 L138 104 h14 l-6 30 26 -38 h-15 z" fill="#f4a340" stroke="#093542" stroke-width="3" stroke-linejoin="round"/>
28
+
29
+ <!-- ports along the bottom edge -->
30
+ <circle cx="80" cy="166" r="10" fill="none" stroke="#093542" stroke-width="4"/>
31
+ <circle cx="80" cy="166" r="3.5" fill="#093542"/>
32
+ <rect x="106" y="158" width="20" height="16" rx="3" fill="none" stroke="#093542" stroke-width="4"/>
33
+ <rect x="142" y="156" width="26" height="20" rx="3" fill="none" stroke="#093542" stroke-width="4"/>
34
+ <rect x="150" y="162" width="3.5" height="8" fill="#093542"/>
35
+ <rect x="157" y="162" width="3.5" height="8" fill="#093542"/>
36
+ </svg>
@@ -0,0 +1,112 @@
1
+ # Example register map for a Bluetti device this plugin doesn't bundle yet.
2
+ #
3
+ # Drop a copy of this file (renamed, e.g. my-device.yaml) into your custom
4
+ # register maps directory — see the plugin's "Custom register maps directory"
5
+ # setting, which defaults to <SignalK home>/bluetti (e.g. ~/.signalk/bluetti)
6
+ # — and it'll show up in the "Register map" dropdown for a device, keyed by
7
+ # its filename (without .yaml).
8
+ #
9
+ # Two top-level keys:
10
+ #
11
+ # fields: one entry per live Modbus holding register on the device.
12
+ # The key is the field name; standard names (see lib/path-mapper.js's
13
+ # STANDARD_FIELD_PATHS) are resolved to a SignalK path automatically —
14
+ # use whichever of these match what your device actually reports.
15
+ # Any other name gets a best-effort guess, or set `path:` explicitly.
16
+ #
17
+ # <field_name>:
18
+ # register: <number> # Modbus holding register address (required)
19
+ # type: uint16 # uint16 (default) | int16 | uint32 | int32 | float32 | bool
20
+ # count: <number> # registers this field spans; inferred from `type` if omitted
21
+ # scale: <number> # multiply the raw value by this (default 1)
22
+ # offset: <number> # add after scaling (default 0)
23
+ # unit: V # V | A | W | Wh | kWh | % | °C | Hz | min — drives SignalK unit conversion
24
+ # path: electrical.batteries.{name}.voltage # optional override; {name} is the per-device name from config
25
+ #
26
+ # constants: fixed, non-register facts about this model — a bare scalar
27
+ # (string or number) or, for a number that needs unit conversion
28
+ # (e.g. a capacity in Wh), an expanded { value, unit } form.
29
+ #
30
+ # Standard field names worth knowing: battery_soc, battery_voltage,
31
+ # battery_current, battery_power, battery_temperature, dc_input_power/voltage/current
32
+ # (solar), ac_output_power/voltage/current/frequency (inverter), ac_input_power/
33
+ # voltage/current/frequency (mains charger), time_remaining_minutes,
34
+ # total_capacity, battery_chemistry, battery_manufacturer_name, battery_manufacturer_model.
35
+
36
+ fields:
37
+ battery_percent:
38
+ register: 100
39
+ unit: "%"
40
+ battery_voltage:
41
+ register: 101
42
+ scale: 0.1
43
+ unit: V
44
+ battery_current:
45
+ register: 102
46
+ type: int16
47
+ scale: 0.1
48
+ unit: A
49
+ battery_power:
50
+ register: 103
51
+ type: int16
52
+ unit: W
53
+ battery_temperature:
54
+ register: 104
55
+ scale: 0.1
56
+ unit: "°C"
57
+ dc_input_power:
58
+ register: 107
59
+ unit: W
60
+ dc_input_voltage:
61
+ register: 108
62
+ scale: 0.1
63
+ unit: V
64
+ dc_input_current:
65
+ register: 109
66
+ scale: 0.1
67
+ unit: A
68
+ ac_output_power:
69
+ register: 110
70
+ unit: W
71
+ ac_output_voltage:
72
+ register: 111
73
+ scale: 0.1
74
+ unit: V
75
+ ac_output_current:
76
+ register: 112
77
+ scale: 0.1
78
+ unit: A
79
+ ac_output_frequency:
80
+ register: 113
81
+ scale: 0.1
82
+ unit: Hz
83
+ ac_input_power:
84
+ register: 114
85
+ unit: W
86
+ ac_input_voltage:
87
+ register: 115
88
+ scale: 0.1
89
+ unit: V
90
+ ac_input_current:
91
+ register: 116
92
+ scale: 0.1
93
+ unit: A
94
+ ac_input_frequency:
95
+ register: 117
96
+ scale: 0.1
97
+ unit: Hz
98
+ internal_temperature:
99
+ register: 118
100
+ scale: 0.1
101
+ unit: "°C"
102
+ time_remaining_minutes:
103
+ register: 119
104
+ unit: min
105
+
106
+ constants:
107
+ battery_chemistry: LiFePO4
108
+ battery_manufacturer_name: Bluetti
109
+ battery_manufacturer_model: My Device
110
+ total_capacity:
111
+ value: 2000
112
+ unit: Wh
package/index.js CHANGED
@@ -8,7 +8,7 @@ const PLUGIN_ID = "signalk-bluetti-plugin";
8
8
  const REGISTERS_DIR = path.join(__dirname, "registers");
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 register map 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(REGISTERS_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
 
@@ -68,6 +86,12 @@ module.exports = function (app) {
68
86
  description: "Runs a 15-second 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 register maps directory",
92
+ description: `Directory to scan for your own register map 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,15 +117,16 @@ 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",
120
+ title: "Register map",
121
+ description:
122
+ 'Select a register map for your device model — bundled with the plugin, or dropped into the custom register maps directory above — or "custom" to supply an explicit YAML file path below.',
123
+ enum: ["custom", ...allModels],
124
+ default: allModels.length > 0 ? allModels[0] : "custom",
100
125
  },
101
- csvPath: {
126
+ registerMapPath: {
102
127
  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.',
128
+ title: "Custom register map YAML path",
129
+ description: 'Absolute path to a register map YAML file. Only used when "custom" is selected above.',
105
130
  default: "",
106
131
  },
107
132
  encryptionCsvPath: {
@@ -124,11 +149,12 @@ module.exports = function (app) {
124
149
  };
125
150
 
126
151
  plugin.uiSchema = {
152
+ registersDir: { "ui:placeholder": defaultUserDir || "e.g. /home/pi/.signalk/bluetti" },
127
153
  devices: {
128
154
  items: {
129
155
  address: { "ui:placeholder": "aa:bb:cc:dd:ee:ff" },
130
156
  name: { "ui:placeholder": "house" },
131
- csvPath: { "ui:placeholder": "e.g. /path/to/my-device-registers.csv" },
157
+ registerMapPath: { "ui:placeholder": "e.g. /path/to/my-device-registers.yaml" },
132
158
  encryptionCsvPath: { "ui:placeholder": "e.g. /path/to/19e1646709e0421b755fa9dda74.csv" },
133
159
  },
134
160
  },
@@ -137,11 +163,11 @@ module.exports = function (app) {
137
163
  // ── Start ──────────────────────────────────────────────────────────────
138
164
 
139
165
  plugin.start = function (options) {
140
- let Scanner, BluettiDevice, loadCsv, buildDelta, readEncryptionKey;
166
+ let Scanner, BluettiDevice, loadRegisters, buildDelta, readEncryptionKey;
141
167
  try {
142
168
  Scanner = require("./lib/scanner");
143
169
  BluettiDevice = require("./lib/device");
144
- ({ loadCsv } = require("./lib/csv-loader"));
170
+ ({ loadRegisters } = require("./lib/register-loader"));
145
171
  ({ buildDelta } = require("./lib/path-mapper"));
146
172
  ({ readEncryptionKey } = require("./lib/encryption"));
147
173
  } catch (err) {
@@ -149,6 +175,15 @@ module.exports = function (app) {
149
175
  return;
150
176
  }
151
177
 
178
+ const userRegistersDir = options.registersDir || defaultUserDir;
179
+ if (userRegistersDir) {
180
+ try {
181
+ fs.mkdirSync(userRegistersDir, { recursive: true });
182
+ } catch (err) {
183
+ log(`Could not create custom register maps directory "${userRegistersDir}": ${err.message}`);
184
+ }
185
+ }
186
+
152
187
  scanner = new Scanner(log);
153
188
 
154
189
  const devices = (options.devices || []).filter((d) => d.enabled !== false);
@@ -185,7 +220,7 @@ module.exports = function (app) {
185
220
  // Build address → cfg lookup (normalise to lowercase, no colons)
186
221
  const normalise = (addr) => addr.toLowerCase().replace(/:/g, "");
187
222
  const pending = new Map(devices.map((cfg) => [normalise(cfg.address), cfg]));
188
- const deps = { BluettiDevice, loadCsv, buildDelta, readEncryptionKey };
223
+ const deps = { BluettiDevice, loadRegisters, buildDelta, readEncryptionKey, userRegistersDir };
189
224
 
190
225
  scanner.on("discovered", ({ address, name, device: bleDevice }) => {
191
226
  scanResultCache.push({ address, name });
@@ -260,22 +295,40 @@ module.exports = function (app) {
260
295
  return null;
261
296
  }
262
297
 
263
- function resolveRegisterMapPath(cfg) {
264
- const { builtinModel, csvPath } = cfg;
298
+ // Finds <model>.yaml (or .yml), checking the custom directory before the
299
+ // bundled one so a user's own file can override a built-in of the same name.
300
+ function findModelFile(model, userRegistersDir) {
301
+ const dirs = [userRegistersDir, REGISTERS_DIR].filter(Boolean);
302
+ for (const dir of dirs) {
303
+ for (const ext of [".yaml", ".yml"]) {
304
+ const p = path.join(dir, `${model}${ext}`);
305
+ if (fs.existsSync(p)) return p;
306
+ }
307
+ }
308
+ return null;
309
+ }
310
+
311
+ function resolveRegisterMapPath(cfg, userRegistersDir) {
312
+ const { builtinModel, registerMapPath } = cfg;
265
313
  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;
314
+ if (!registerMapPath) throw new Error("No register map: select a model or provide a custom YAML path");
315
+ return registerMapPath;
268
316
  }
269
- return path.join(REGISTERS_DIR, `${builtinModel}.csv`);
317
+ const found = findModelFile(builtinModel, userRegistersDir);
318
+ if (!found)
319
+ throw new Error(
320
+ `Register map "${builtinModel}" not found (checked ${userRegistersDir ? `${userRegistersDir} and ` : ""}${REGISTERS_DIR})`,
321
+ );
322
+ return found;
270
323
  }
271
324
 
272
- function startDevice(cfg, bleDevice, bleName, { BluettiDevice, loadCsv, buildDelta, readEncryptionKey }) {
325
+ function startDevice(cfg, bleDevice, bleName, { BluettiDevice, loadRegisters, buildDelta, readEncryptionKey, userRegistersDir }) {
273
326
  const { address, name, encryptionCsvPath = "", pollIntervalSeconds = 10 } = cfg;
274
327
  const registerCache = new Map(); // last-known value per field_name, across polls — see buildDelta
275
328
 
276
329
  let registerPath;
277
330
  try {
278
- registerPath = resolveRegisterMapPath(cfg);
331
+ registerPath = resolveRegisterMapPath(cfg, userRegistersDir);
279
332
  } catch (err) {
280
333
  app.setPluginError(`[${name}] ${err.message}`);
281
334
  return;
@@ -283,7 +336,7 @@ module.exports = function (app) {
283
336
 
284
337
  let fields;
285
338
  try {
286
- fields = loadCsv(registerPath);
339
+ fields = loadRegisters(registerPath);
287
340
  log(`[${name}] Loaded ${fields.length} registers from ${registerPath}`);
288
341
  } catch (err) {
289
342
  app.setPluginError(`[${name}] Failed to load register map "${registerPath}": ${err.message}`);