@rhizomatics/signalk-bluetti-plugin 1.0.1-alpha
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/.claude/settings.local.json +14 -0
- package/index.js +300 -0
- package/lib/csv-loader.js +136 -0
- package/lib/device.js +195 -0
- package/lib/encryption.js +19 -0
- package/lib/path-mapper.js +103 -0
- package/lib/protocol.js +101 -0
- package/lib/scanner.js +108 -0
- package/package.json +31 -0
- package/registers/ac200p.csv +39 -0
- package/registers/el100v2.csv +13 -0
- package/registers-example.csv +39 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('engines' in d, 'signalk' in d\\)\")",
|
|
5
|
+
"Bash(node -e \"const p = require\\('/Users/jey/Code/sailing/signalk-bluetti-plugin/package.json'\\); console.log\\(JSON.stringify\\(p, null, 2\\)\\)\")",
|
|
6
|
+
"Read(//usr/local/lib/**)",
|
|
7
|
+
"Bash(npm ls *)",
|
|
8
|
+
"Bash(mkdir -p /Users/jey/Code/sailing/signalk-bluetti-plugin/registers)"
|
|
9
|
+
],
|
|
10
|
+
"additionalDirectories": [
|
|
11
|
+
"/Users/jey/Code/sailing/signalk-bluetti-plugin"
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
const PLUGIN_ID = 'signalk-bluetti-plugin';
|
|
8
|
+
const REGISTERS_DIR = path.join(__dirname, 'registers');
|
|
9
|
+
|
|
10
|
+
// Bluetti device BLE name prefixes (mirrors scanner.js).
|
|
11
|
+
const BLUETTI_PREFIXES = ['BT-TH-', 'BLUETTI', 'AC', 'EP', 'EB', 'EL'];
|
|
12
|
+
|
|
13
|
+
// Search $HOME for exactly one CSV whose stem looks like a Bluetti device ID.
|
|
14
|
+
// Returns the full path if exactly one match, empty string otherwise.
|
|
15
|
+
function findBluettiEncryptionCsvInHome() {
|
|
16
|
+
try {
|
|
17
|
+
const homeDir = os.homedir();
|
|
18
|
+
const matches = fs.readdirSync(homeDir).filter(f => {
|
|
19
|
+
if (!f.toLowerCase().endsWith('.csv')) return false;
|
|
20
|
+
const stem = f.slice(0, -4).toUpperCase();
|
|
21
|
+
return BLUETTI_PREFIXES.some(p => stem.startsWith(p));
|
|
22
|
+
});
|
|
23
|
+
return matches.length === 1 ? path.join(os.homedir(), matches[0]) : '';
|
|
24
|
+
} catch (_) {
|
|
25
|
+
return '';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function builtinModelNames() {
|
|
30
|
+
try {
|
|
31
|
+
return fs.readdirSync(REGISTERS_DIR)
|
|
32
|
+
.filter(f => f.endsWith('.csv'))
|
|
33
|
+
.map(f => f.replace(/\.csv$/, ''))
|
|
34
|
+
.sort();
|
|
35
|
+
} catch (_) {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = function (app) {
|
|
41
|
+
const log = (msg) => app.debug(msg);
|
|
42
|
+
|
|
43
|
+
let scanner = null;
|
|
44
|
+
let activeDevices = [];
|
|
45
|
+
let scanResultCache = [];
|
|
46
|
+
|
|
47
|
+
const builtins = builtinModelNames();
|
|
48
|
+
|
|
49
|
+
// ── Plugin metadata ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
const plugin = {
|
|
52
|
+
id: PLUGIN_ID,
|
|
53
|
+
name: 'Bluetti Power Station (BLE)',
|
|
54
|
+
description: 'Monitors Bluetti power devices via Bluetooth LE and publishes to electrical.* paths',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ── Config schema ──────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
plugin.schema = {
|
|
60
|
+
type: 'object',
|
|
61
|
+
required: [],
|
|
62
|
+
properties: {
|
|
63
|
+
scanOnStart: {
|
|
64
|
+
type: 'boolean',
|
|
65
|
+
title: 'Scan for new devices on plugin start',
|
|
66
|
+
description: 'Runs a 15-second BLE scan and logs discovered Bluetti devices. Useful for finding device addresses.',
|
|
67
|
+
default: true,
|
|
68
|
+
},
|
|
69
|
+
devices: {
|
|
70
|
+
type: 'array',
|
|
71
|
+
title: 'Devices',
|
|
72
|
+
description: 'One entry per Bluetti device you want to monitor.',
|
|
73
|
+
items: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
required: ['address', 'name'],
|
|
76
|
+
properties: {
|
|
77
|
+
enabled: {
|
|
78
|
+
type: 'boolean',
|
|
79
|
+
title: 'Enabled',
|
|
80
|
+
default: true,
|
|
81
|
+
},
|
|
82
|
+
address: {
|
|
83
|
+
type: 'string',
|
|
84
|
+
title: 'BLE MAC address',
|
|
85
|
+
description: 'e.g. aa:bb:cc:dd:ee:ff — run a scan to find this',
|
|
86
|
+
},
|
|
87
|
+
name: {
|
|
88
|
+
type: 'string',
|
|
89
|
+
title: 'Device name (used in SignalK path)',
|
|
90
|
+
description: 'e.g. "house" → electrical.batteries.house.voltage',
|
|
91
|
+
},
|
|
92
|
+
builtinModel: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
title: 'Built-in register map',
|
|
95
|
+
description: 'Select a bundled register map for your device model, or "custom" to supply your own CSV path below.',
|
|
96
|
+
enum: ['custom', ...builtins],
|
|
97
|
+
default: builtins.length > 0 ? builtins[0] : 'custom',
|
|
98
|
+
},
|
|
99
|
+
csvPath: {
|
|
100
|
+
type: 'string',
|
|
101
|
+
title: 'Custom register map CSV path',
|
|
102
|
+
description: 'Absolute path to a register definition CSV. Only used when "custom" is selected above.',
|
|
103
|
+
default: '',
|
|
104
|
+
},
|
|
105
|
+
encryptionCsvPath: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
title: 'Bluetti encryption CSV path (optional)',
|
|
108
|
+
description: 'Path to the encrypted CSV file provided by Bluetti for your device. Leave blank for unencrypted devices.',
|
|
109
|
+
default: findBluettiEncryptionCsvInHome(),
|
|
110
|
+
},
|
|
111
|
+
pollIntervalSeconds: {
|
|
112
|
+
type: 'number',
|
|
113
|
+
title: 'Poll interval (seconds)',
|
|
114
|
+
default: 10,
|
|
115
|
+
minimum: 2,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
default: [],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
plugin.uiSchema = {
|
|
125
|
+
devices: {
|
|
126
|
+
items: {
|
|
127
|
+
address: { 'ui:placeholder': 'aa:bb:cc:dd:ee:ff' },
|
|
128
|
+
name: { 'ui:placeholder': 'house' },
|
|
129
|
+
csvPath: { 'ui:placeholder': '/home/pi/my-device-registers.csv' },
|
|
130
|
+
encryptionCsvPath: { 'ui:placeholder': '/home/pi/19e1646709e0421b755fa9dda74.csv' },
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ── Start ──────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
plugin.start = function (options) {
|
|
138
|
+
let Scanner, BluettiDevice, loadCsv, buildDelta, readEncryptionKey;
|
|
139
|
+
try {
|
|
140
|
+
Scanner = require('./lib/scanner');
|
|
141
|
+
BluettiDevice = require('./lib/device');
|
|
142
|
+
({ loadCsv } = require('./lib/csv-loader'));
|
|
143
|
+
({ buildDelta } = require('./lib/path-mapper'));
|
|
144
|
+
({ readEncryptionKey } = require('./lib/encryption'));
|
|
145
|
+
} catch (err) {
|
|
146
|
+
app.setPluginError(`Dependency load failed: ${err.message}. Run: npm install inside the plugin directory.`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
scanner = new Scanner(log);
|
|
151
|
+
|
|
152
|
+
const devices = (options.devices || []).filter(d => d.enabled !== false);
|
|
153
|
+
|
|
154
|
+
if (devices.length === 0) {
|
|
155
|
+
// Discovery-only mode: log anything Bluetti-shaped that appears
|
|
156
|
+
scanner.on('discovered', ({ address, name }) => {
|
|
157
|
+
scanResultCache.push({ address, name });
|
|
158
|
+
app.setPluginStatus(`Discovered: ${name} [${address}] — copy address into plugin config`);
|
|
159
|
+
});
|
|
160
|
+
scanner.on('scanComplete', (found) => {
|
|
161
|
+
if (found.length === 0) app.setPluginStatus('Scan complete — no Bluetti devices found nearby.');
|
|
162
|
+
});
|
|
163
|
+
if (options.scanOnStart !== false) {
|
|
164
|
+
app.setPluginStatus('No devices configured — scanning for Bluetti devices …');
|
|
165
|
+
scanner.startScan(15000);
|
|
166
|
+
} else {
|
|
167
|
+
app.setPluginStatus('No devices configured. Add a device in plugin settings.');
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Build address → cfg lookup (normalise to lowercase, no colons)
|
|
173
|
+
const normalise = (addr) => addr.toLowerCase().replace(/:/g, '');
|
|
174
|
+
const pending = new Map(devices.map(cfg => [normalise(cfg.address), cfg]));
|
|
175
|
+
const deps = { BluettiDevice, loadCsv, buildDelta, readEncryptionKey };
|
|
176
|
+
|
|
177
|
+
scanner.on('discovered', ({ address, name, peripheral }) => {
|
|
178
|
+
scanResultCache.push({ address, name });
|
|
179
|
+
const cfg = pending.get(normalise(address));
|
|
180
|
+
if (cfg) {
|
|
181
|
+
pending.delete(normalise(address));
|
|
182
|
+
app.setPluginStatus(`Found ${name} [${address}] — connecting …`);
|
|
183
|
+
startDevice(cfg, peripheral, name, deps);
|
|
184
|
+
} else {
|
|
185
|
+
log(`Discovered unconfigured Bluetti device: ${name} [${address}]`);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
scanner.on('scanComplete', () => {
|
|
190
|
+
if (pending.size > 0) {
|
|
191
|
+
const missing = [...pending.values()].map(c => `${c.name} [${c.address}]`).join(', ');
|
|
192
|
+
app.setPluginStatus(`Waiting for device(s): ${missing} — rescanning …`);
|
|
193
|
+
setTimeout(() => {
|
|
194
|
+
if (pending.size > 0 && scanner) scanner.startScan(30000);
|
|
195
|
+
}, 5000);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
app.setPluginStatus(`Scanning for ${devices.length} configured device(s) …`);
|
|
200
|
+
scanner.startScan(30000);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Search $HOME for a file named <bleName>.csv (case-insensitive).
|
|
204
|
+
function findEncryptionCsvInHome(bleName) {
|
|
205
|
+
const homeDir = os.homedir();
|
|
206
|
+
const target = `${bleName.toLowerCase()}.csv`;
|
|
207
|
+
try {
|
|
208
|
+
const match = fs.readdirSync(homeDir).find(f => f.toLowerCase() === target);
|
|
209
|
+
return match ? path.join(homeDir, match) : null;
|
|
210
|
+
} catch (_) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function resolveRegisterMapPath(cfg) {
|
|
216
|
+
const { builtinModel, csvPath } = cfg;
|
|
217
|
+
if (!builtinModel || builtinModel === 'custom') {
|
|
218
|
+
if (!csvPath) throw new Error('No register map: select a built-in model or provide a custom CSV path');
|
|
219
|
+
return csvPath;
|
|
220
|
+
}
|
|
221
|
+
return path.join(REGISTERS_DIR, `${builtinModel}.csv`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function startDevice(cfg, peripheral, bleName, { BluettiDevice, loadCsv, buildDelta, readEncryptionKey }) {
|
|
225
|
+
const { address, name, encryptionCsvPath = '', pollIntervalSeconds = 10 } = cfg;
|
|
226
|
+
|
|
227
|
+
let registerPath;
|
|
228
|
+
try {
|
|
229
|
+
registerPath = resolveRegisterMapPath(cfg);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
app.setPluginError(`[${name}] ${err.message}`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let fields;
|
|
236
|
+
try {
|
|
237
|
+
fields = loadCsv(registerPath);
|
|
238
|
+
log(`[${name}] Loaded ${fields.length} registers from ${registerPath}`);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
app.setPluginError(`[${name}] Failed to load register map "${registerPath}": ${err.message}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let effectiveEncryptionPath = encryptionCsvPath;
|
|
245
|
+
if (!effectiveEncryptionPath && bleName) {
|
|
246
|
+
const autoPath = findEncryptionCsvInHome(bleName);
|
|
247
|
+
if (autoPath) {
|
|
248
|
+
effectiveEncryptionPath = autoPath;
|
|
249
|
+
log(`[${name}] Auto-detected encryption CSV for ${bleName}: ${autoPath}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let xorKey = null;
|
|
254
|
+
if (effectiveEncryptionPath) {
|
|
255
|
+
try {
|
|
256
|
+
xorKey = readEncryptionKey(effectiveEncryptionPath);
|
|
257
|
+
log(`[${name}] Loaded encryption key from ${effectiveEncryptionPath}`);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
app.setPluginError(`[${name}] Failed to read encryption key: ${err.message}`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const device = new BluettiDevice({
|
|
265
|
+
address,
|
|
266
|
+
name,
|
|
267
|
+
peripheral,
|
|
268
|
+
fields,
|
|
269
|
+
pollIntervalMs: pollIntervalSeconds * 1000,
|
|
270
|
+
xorKey,
|
|
271
|
+
log,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
device.on('connected', () => {
|
|
275
|
+
app.setPluginStatus(`Connected to ${name} [${address}]`);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
device.on('registers', (registers) => {
|
|
279
|
+
const delta = buildDelta(registers, fields, name, PLUGIN_ID);
|
|
280
|
+
if (delta) app.handleMessage(PLUGIN_ID, delta);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
device.start();
|
|
284
|
+
activeDevices.push(device);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Stop ───────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
plugin.stop = function () {
|
|
290
|
+
activeDevices.forEach(d => d.stop());
|
|
291
|
+
activeDevices = [];
|
|
292
|
+
if (scanner) {
|
|
293
|
+
scanner.stopAll();
|
|
294
|
+
scanner = null;
|
|
295
|
+
}
|
|
296
|
+
scanResultCache = [];
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
return plugin;
|
|
300
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
};
|
|
18
|
+
|
|
19
|
+
function resolveColumn(headers, key) {
|
|
20
|
+
const aliases = COL_ALIASES[key];
|
|
21
|
+
for (const alias of aliases) {
|
|
22
|
+
const found = headers.find(h => h.trim().toLowerCase() === alias.toLowerCase());
|
|
23
|
+
if (found) return found;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Parse the CSV and return an array of field descriptors.
|
|
29
|
+
// Each descriptor: { fieldName, register, count, dataType, scale, offset, unit, signalkPath }
|
|
30
|
+
function loadCsv(filePath) {
|
|
31
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
32
|
+
|
|
33
|
+
// Strip BOM if present
|
|
34
|
+
const content = raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw;
|
|
35
|
+
|
|
36
|
+
const records = parse(content, {
|
|
37
|
+
columns: true,
|
|
38
|
+
skip_empty_lines: true,
|
|
39
|
+
trim: true,
|
|
40
|
+
comment: '#',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (records.length === 0) throw new Error(`CSV file ${filePath} has no data rows`);
|
|
44
|
+
|
|
45
|
+
const headers = Object.keys(records[0]);
|
|
46
|
+
|
|
47
|
+
const col = {};
|
|
48
|
+
for (const key of Object.keys(COL_ALIASES)) {
|
|
49
|
+
col[key] = resolveColumn(headers, key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!col.register_address) throw new Error('CSV must have a register address column');
|
|
53
|
+
if (!col.field_name) throw new Error('CSV must have a field name column');
|
|
54
|
+
|
|
55
|
+
const fields = [];
|
|
56
|
+
for (const row of records) {
|
|
57
|
+
const registerRaw = row[col.register_address];
|
|
58
|
+
const register = parseInt(registerRaw, 10);
|
|
59
|
+
if (isNaN(register)) continue; // skip header-like rows embedded in data
|
|
60
|
+
|
|
61
|
+
const dataType = col.data_type ? (row[col.data_type] || 'uint16').toLowerCase().trim() : 'uint16';
|
|
62
|
+
const scale = col.scale ? parseFloat(row[col.scale]) || 1 : 1;
|
|
63
|
+
const offset = col.offset ? parseFloat(row[col.offset]) || 0 : 0;
|
|
64
|
+
const unit = col.unit ? (row[col.unit] || '').trim() : '';
|
|
65
|
+
|
|
66
|
+
// register_count: for multi-register types (int32, uint32, float32 = 2 regs; int64 = 4 regs)
|
|
67
|
+
let count = col.register_count ? parseInt(row[col.register_count], 10) : NaN;
|
|
68
|
+
if (isNaN(count)) count = registersForType(dataType);
|
|
69
|
+
|
|
70
|
+
const signalkPath = col.signalk_path ? (row[col.signalk_path] || '').trim() : '';
|
|
71
|
+
const fieldName = (row[col.field_name] || '').trim();
|
|
72
|
+
|
|
73
|
+
if (!fieldName) continue;
|
|
74
|
+
|
|
75
|
+
fields.push({ fieldName, register, count, dataType, scale, offset, unit, signalkPath });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (fields.length === 0) throw new Error(`No valid register rows found in ${filePath}`);
|
|
79
|
+
return fields;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function registersForType(dataType) {
|
|
83
|
+
switch (dataType) {
|
|
84
|
+
case 'int32':
|
|
85
|
+
case 'uint32':
|
|
86
|
+
case 'float32': return 2;
|
|
87
|
+
case 'int64':
|
|
88
|
+
case 'uint64': return 4;
|
|
89
|
+
default: return 1; // uint16, int16, bool, enum
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Decode a raw register value (or pair of registers for 32-bit types) into a number.
|
|
94
|
+
// rawRegs: Map<addr, uint16>, startAddr: the register address for this field
|
|
95
|
+
function decodeValue(field, rawRegs) {
|
|
96
|
+
const { register, count, dataType, scale, offset } = field;
|
|
97
|
+
|
|
98
|
+
let raw;
|
|
99
|
+
if (count === 1) {
|
|
100
|
+
raw = rawRegs.get(register);
|
|
101
|
+
if (raw === undefined) return null;
|
|
102
|
+
} else if (count === 2) {
|
|
103
|
+
const hi = rawRegs.get(register);
|
|
104
|
+
const lo = rawRegs.get(register + 1);
|
|
105
|
+
if (hi === undefined || lo === undefined) return null;
|
|
106
|
+
raw = (hi << 16) | lo;
|
|
107
|
+
} else {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let value;
|
|
112
|
+
switch (dataType) {
|
|
113
|
+
case 'int16':
|
|
114
|
+
value = raw > 0x7FFF ? raw - 0x10000 : raw;
|
|
115
|
+
break;
|
|
116
|
+
case 'int32': {
|
|
117
|
+
const signed = raw > 0x7FFFFFFF ? raw - 0x100000000 : raw;
|
|
118
|
+
value = signed;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case 'float32': {
|
|
122
|
+
const tmpBuf = Buffer.alloc(4);
|
|
123
|
+
tmpBuf.writeUInt32BE(raw, 0);
|
|
124
|
+
value = tmpBuf.readFloatBE(0);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'bool':
|
|
128
|
+
return raw !== 0 ? 1 : 0;
|
|
129
|
+
default:
|
|
130
|
+
value = raw;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return value * scale + offset;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { loadCsv, decodeValue };
|
package/lib/device.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const { buildReadRequest, completeFrameLength, parseReadResponse, applyXor, groupRegisters } = require('./protocol');
|
|
5
|
+
|
|
6
|
+
// UUIDs used by most Bluetti devices (short form — noble strips dashes and lowercases)
|
|
7
|
+
const SERVICE_UUID = 'ff00';
|
|
8
|
+
const NOTIFY_UUID = 'ff01';
|
|
9
|
+
const WRITE_UUID = 'ff02';
|
|
10
|
+
|
|
11
|
+
// Fallback UUIDs seen on some older/alternate firmwares
|
|
12
|
+
const ALT_SERVICE_UUID = 'ffe0';
|
|
13
|
+
const ALT_CHAR_UUID = 'ffe1';
|
|
14
|
+
|
|
15
|
+
const RECONNECT_DELAYS = [5000, 10000, 20000, 30000, 60000]; // ms, capped at last value
|
|
16
|
+
|
|
17
|
+
class BluettiDevice extends EventEmitter {
|
|
18
|
+
constructor({ address, name, peripheral, fields, pollIntervalMs = 10000, xorKey = null, log }) {
|
|
19
|
+
super();
|
|
20
|
+
this._address = address;
|
|
21
|
+
this._name = name;
|
|
22
|
+
this._peripheral = peripheral;
|
|
23
|
+
this._fields = fields;
|
|
24
|
+
this._pollIntervalMs = pollIntervalMs;
|
|
25
|
+
this._xorKey = xorKey ? Buffer.from(xorKey, 'hex') : null;
|
|
26
|
+
this._log = log;
|
|
27
|
+
|
|
28
|
+
this._connected = false;
|
|
29
|
+
this._notifyChar = null;
|
|
30
|
+
this._writeChar = null;
|
|
31
|
+
this._rxBuf = Buffer.alloc(0);
|
|
32
|
+
this._currentBatch = null; // { start, count } being awaited
|
|
33
|
+
this._batchQueue = [];
|
|
34
|
+
this._pollTimer = null;
|
|
35
|
+
this._reconnectAttempt = 0;
|
|
36
|
+
this._stopped = false;
|
|
37
|
+
|
|
38
|
+
// Pre-compute register poll batches from field list
|
|
39
|
+
const addrs = [...new Set(fields.flatMap(f => Array.from({ length: f.count }, (_, i) => f.register + i)))];
|
|
40
|
+
this._batches = groupRegisters(addrs);
|
|
41
|
+
this._log(`[${name}] Poll plan: ${this._batches.length} batch(es) covering ${addrs.length} registers`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
start() {
|
|
45
|
+
this._stopped = false;
|
|
46
|
+
this._connect();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stop() {
|
|
50
|
+
this._stopped = true;
|
|
51
|
+
this._stopPolling();
|
|
52
|
+
if (this._peripheral && this._connected) {
|
|
53
|
+
try { this._peripheral.disconnect(); } catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Connection lifecycle ─────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
_connect() {
|
|
60
|
+
if (this._stopped) return;
|
|
61
|
+
this._log(`[${this._name}] Connecting to ${this._address} …`);
|
|
62
|
+
|
|
63
|
+
this._peripheral.connect((err) => {
|
|
64
|
+
if (err) {
|
|
65
|
+
this._log(`[${this._name}] Connect error: ${err.message}`);
|
|
66
|
+
this._scheduleReconnect();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this._log(`[${this._name}] Connected`);
|
|
70
|
+
this._reconnectAttempt = 0;
|
|
71
|
+
this._discoverServices();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
this._peripheral.once('disconnect', () => {
|
|
75
|
+
if (this._stopped) return;
|
|
76
|
+
this._connected = false;
|
|
77
|
+
this._notifyChar = null;
|
|
78
|
+
this._writeChar = null;
|
|
79
|
+
this._log(`[${this._name}] Disconnected`);
|
|
80
|
+
this._stopPolling();
|
|
81
|
+
this._scheduleReconnect();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_discoverServices() {
|
|
86
|
+
this._peripheral.discoverAllServicesAndCharacteristics((err, _services, chars) => {
|
|
87
|
+
if (err) {
|
|
88
|
+
this._log(`[${this._name}] Service discovery error: ${err.message}`);
|
|
89
|
+
this._scheduleReconnect();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const notifyChar = chars.find(c => c.uuid === NOTIFY_UUID) ||
|
|
94
|
+
chars.find(c => c.uuid === ALT_CHAR_UUID);
|
|
95
|
+
const writeChar = chars.find(c => c.uuid === WRITE_UUID) ||
|
|
96
|
+
chars.find(c => c.uuid === ALT_CHAR_UUID);
|
|
97
|
+
|
|
98
|
+
if (!notifyChar || !writeChar) {
|
|
99
|
+
const uuids = chars.map(c => c.uuid).join(', ');
|
|
100
|
+
this._log(`[${this._name}] Could not find required characteristics. Found: ${uuids}`);
|
|
101
|
+
this._scheduleReconnect();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this._notifyChar = notifyChar;
|
|
106
|
+
this._writeChar = writeChar;
|
|
107
|
+
|
|
108
|
+
notifyChar.subscribe((err) => {
|
|
109
|
+
if (err) {
|
|
110
|
+
this._log(`[${this._name}] Subscribe error: ${err.message}`);
|
|
111
|
+
this._scheduleReconnect();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
notifyChar.on('data', (data) => this._onData(data));
|
|
115
|
+
this._connected = true;
|
|
116
|
+
this.emit('connected');
|
|
117
|
+
this._startPolling();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_scheduleReconnect() {
|
|
123
|
+
if (this._stopped) return;
|
|
124
|
+
const delay = RECONNECT_DELAYS[Math.min(this._reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
|
125
|
+
this._reconnectAttempt++;
|
|
126
|
+
this._log(`[${this._name}] Reconnecting in ${delay / 1000}s (attempt ${this._reconnectAttempt})`);
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
if (!this._stopped) this._connect();
|
|
129
|
+
}, delay);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Polling ──────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
_startPolling() {
|
|
135
|
+
this._stopPolling();
|
|
136
|
+
this._poll();
|
|
137
|
+
this._pollTimer = setInterval(() => this._poll(), this._pollIntervalMs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_stopPolling() {
|
|
141
|
+
if (this._pollTimer) {
|
|
142
|
+
clearInterval(this._pollTimer);
|
|
143
|
+
this._pollTimer = null;
|
|
144
|
+
}
|
|
145
|
+
this._batchQueue = [];
|
|
146
|
+
this._currentBatch = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_poll() {
|
|
150
|
+
if (!this._connected || this._batches.length === 0) return;
|
|
151
|
+
// Queue all batches; they'll be sent one at a time as responses arrive
|
|
152
|
+
this._batchQueue = [...this._batches];
|
|
153
|
+
this._rxBuf = Buffer.alloc(0);
|
|
154
|
+
this._sendNextBatch();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
_sendNextBatch() {
|
|
158
|
+
if (!this._connected || this._batchQueue.length === 0) return;
|
|
159
|
+
this._currentBatch = this._batchQueue.shift();
|
|
160
|
+
const { start, count } = this._currentBatch;
|
|
161
|
+
let frame = buildReadRequest(start, count);
|
|
162
|
+
if (this._xorKey) frame = applyXor(frame, this._xorKey);
|
|
163
|
+
this._rxBuf = Buffer.alloc(0);
|
|
164
|
+
this._writeChar.write(frame, false, (err) => {
|
|
165
|
+
if (err) this._log(`[${this._name}] Write error: ${err.message}`);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Receive ──────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
_onData(data) {
|
|
172
|
+
// Undo XOR on incoming data if encryption is in use
|
|
173
|
+
const chunk = this._xorKey ? applyXor(data, this._xorKey) : data;
|
|
174
|
+
this._rxBuf = Buffer.concat([this._rxBuf, chunk]);
|
|
175
|
+
|
|
176
|
+
const frameLen = completeFrameLength(this._rxBuf);
|
|
177
|
+
if (frameLen === 0) return; // waiting for more BLE packets
|
|
178
|
+
|
|
179
|
+
const frame = this._rxBuf.slice(0, frameLen);
|
|
180
|
+
this._rxBuf = this._rxBuf.slice(frameLen);
|
|
181
|
+
|
|
182
|
+
if (!this._currentBatch) return;
|
|
183
|
+
const result = parseReadResponse(frame, this._currentBatch.start);
|
|
184
|
+
if (result) {
|
|
185
|
+
this.emit('registers', result.registers);
|
|
186
|
+
} else {
|
|
187
|
+
this._log(`[${this._name}] CRC error or unexpected frame, skipping batch`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Move to next batch
|
|
191
|
+
this._sendNextBatch();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = BluettiDevice;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
// Parse the Bluetti-provided encryption CSV file and return the XOR key hex string.
|
|
6
|
+
// Format: line 1 = "bluetti", line 2 = device timestamp, line 3 = short ID, line 4 = key.
|
|
7
|
+
function readEncryptionKey(csvPath) {
|
|
8
|
+
const lines = fs.readFileSync(csvPath, 'utf8')
|
|
9
|
+
.split('\n')
|
|
10
|
+
.map(l => l.trim())
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
|
|
13
|
+
if (lines[0] !== 'bluetti') throw new Error(`Not a Bluetti encryption file: ${csvPath}`);
|
|
14
|
+
if (lines.length < 4) throw new Error(`Encryption file missing key line: ${csvPath}`);
|
|
15
|
+
|
|
16
|
+
return lines[3];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { readEncryptionKey };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { decodeValue } = require('./csv-loader');
|
|
4
|
+
|
|
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.
|
|
7
|
+
const UNIT_CONVERSIONS = {
|
|
8
|
+
// temperature: Bluetti reports in °C or 0.1°C (handled by scale), SignalK wants K
|
|
9
|
+
'c': (v) => v + 273.15,
|
|
10
|
+
'°c': (v) => v + 273.15,
|
|
11
|
+
'degc':(v) => v + 273.15,
|
|
12
|
+
|
|
13
|
+
// energy: Bluetti reports Wh, SignalK wants J
|
|
14
|
+
'wh': (v) => v * 3600,
|
|
15
|
+
'kwh': (v) => v * 3600000,
|
|
16
|
+
|
|
17
|
+
// state of charge: Bluetti is 0–100, SignalK wants 0.0–1.0
|
|
18
|
+
'%': (v) => v / 100,
|
|
19
|
+
'pct': (v) => v / 100,
|
|
20
|
+
'soc': (v) => v / 100,
|
|
21
|
+
|
|
22
|
+
// everything else (V, A, W, Ah, Hz, min, s) is already SI — pass through
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function convertUnits(value, unit) {
|
|
26
|
+
if (unit === null || unit === undefined) return value;
|
|
27
|
+
const key = unit.toLowerCase().trim();
|
|
28
|
+
const fn = UNIT_CONVERSIONS[key];
|
|
29
|
+
return fn ? fn(value) : value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Build a SignalK path for a field, substituting {name} with the device name.
|
|
33
|
+
// Falls back to auto-generating from fieldName if signalkPath is not set in CSV.
|
|
34
|
+
function resolvePath(field, deviceName) {
|
|
35
|
+
if (field.signalkPath) {
|
|
36
|
+
return field.signalkPath.replace(/\{name\}/gi, deviceName);
|
|
37
|
+
}
|
|
38
|
+
return autoPath(field.fieldName, deviceName);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Best-effort automatic path generation for common Bluetti field names.
|
|
42
|
+
// Users with a properly-annotated CSV will never hit this; it's a safety net.
|
|
43
|
+
function autoPath(fieldName, name) {
|
|
44
|
+
const f = fieldName.toLowerCase();
|
|
45
|
+
if (f.includes('battery') || f.includes('batt') || f.includes('soc')) {
|
|
46
|
+
if (f.includes('voltage') || f.includes('volt')) return `electrical.batteries.${name}.voltage`;
|
|
47
|
+
if (f.includes('current') || f.includes('curr')) return `electrical.batteries.${name}.current`;
|
|
48
|
+
if (f.includes('percent') || f.includes('soc') || f.includes('capacity')) return `electrical.batteries.${name}.stateOfCharge`;
|
|
49
|
+
if (f.includes('temp')) return `electrical.batteries.${name}.temperature`;
|
|
50
|
+
if (f.includes('power')) return `electrical.batteries.${name}.power`;
|
|
51
|
+
if (f.includes('remain')) return `electrical.batteries.${name}.capacity.remaining`;
|
|
52
|
+
return `electrical.batteries.${name}.${fieldName}`;
|
|
53
|
+
}
|
|
54
|
+
if (f.includes('dc_input') || f.includes('solar') || f.includes('pv')) {
|
|
55
|
+
if (f.includes('power')) return `electrical.solar.${name}.panelPower`;
|
|
56
|
+
if (f.includes('voltage')) return `electrical.solar.${name}.panelVoltage`;
|
|
57
|
+
if (f.includes('current')) return `electrical.solar.${name}.panelCurrent`;
|
|
58
|
+
return `electrical.solar.${name}.${fieldName}`;
|
|
59
|
+
}
|
|
60
|
+
if (f.includes('ac_output') || f.includes('inverter') || f.includes('ac_out')) {
|
|
61
|
+
if (f.includes('power')) return `electrical.inverters.${name}.ac.power`;
|
|
62
|
+
if (f.includes('voltage')) return `electrical.inverters.${name}.ac.voltage`;
|
|
63
|
+
if (f.includes('current')) return `electrical.inverters.${name}.ac.current`;
|
|
64
|
+
if (f.includes('freq')) return `electrical.inverters.${name}.ac.frequency`;
|
|
65
|
+
return `electrical.inverters.${name}.ac.${fieldName}`;
|
|
66
|
+
}
|
|
67
|
+
if (f.includes('ac_input') || f.includes('mains') || f.includes('grid') || f.includes('charger')) {
|
|
68
|
+
if (f.includes('power')) return `electrical.chargers.${name}.input.power`;
|
|
69
|
+
if (f.includes('voltage')) return `electrical.chargers.${name}.input.voltage`;
|
|
70
|
+
if (f.includes('current')) return `electrical.chargers.${name}.input.current`;
|
|
71
|
+
return `electrical.chargers.${name}.input.${fieldName}`;
|
|
72
|
+
}
|
|
73
|
+
// Catch-all
|
|
74
|
+
return `electrical.${name}.${fieldName}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Given a Map of register→uint16 values and the full field list,
|
|
78
|
+
// produce a SignalK delta values array ready for app.handleMessage().
|
|
79
|
+
function buildDelta(registers, fields, deviceName, source) {
|
|
80
|
+
const values = [];
|
|
81
|
+
|
|
82
|
+
for (const field of fields) {
|
|
83
|
+
const raw = decodeValue(field, registers);
|
|
84
|
+
if (raw === null) continue;
|
|
85
|
+
|
|
86
|
+
const converted = convertUnits(raw, field.unit);
|
|
87
|
+
const path = resolvePath(field, deviceName);
|
|
88
|
+
|
|
89
|
+
values.push({ path, value: converted });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (values.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
updates: [{
|
|
96
|
+
source: { label: source },
|
|
97
|
+
timestamp: new Date().toISOString(),
|
|
98
|
+
values,
|
|
99
|
+
}],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { buildDelta, resolvePath, convertUnits };
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Modbus CRC16 (standard table-driven implementation)
|
|
4
|
+
function crc16(buf) {
|
|
5
|
+
let crc = 0xFFFF;
|
|
6
|
+
for (let i = 0; i < buf.length; i++) {
|
|
7
|
+
crc ^= buf[i];
|
|
8
|
+
for (let j = 0; j < 8; j++) {
|
|
9
|
+
crc = (crc & 0x0001) ? (crc >> 1) ^ 0xA001 : crc >> 1;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return crc;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Build a Modbus RTU "read holding registers" request (function code 0x03)
|
|
16
|
+
function buildReadRequest(startRegister, count) {
|
|
17
|
+
const buf = Buffer.alloc(8);
|
|
18
|
+
buf[0] = 0x01; // device address (Bluetti uses 0x01)
|
|
19
|
+
buf[1] = 0x03; // FC: read holding registers
|
|
20
|
+
buf.writeUInt16BE(startRegister, 2);
|
|
21
|
+
buf.writeUInt16BE(count, 4);
|
|
22
|
+
const crc = crc16(buf.slice(0, 6));
|
|
23
|
+
buf[6] = crc & 0xFF;
|
|
24
|
+
buf[7] = (crc >> 8) & 0xFF;
|
|
25
|
+
return buf;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check if an accumulated buffer contains a complete Modbus response.
|
|
29
|
+
// Returns byte length of the complete frame, or 0 if incomplete.
|
|
30
|
+
function completeFrameLength(buf) {
|
|
31
|
+
if (buf.length < 5) return 0;
|
|
32
|
+
if (buf[0] !== 0x01) return 0;
|
|
33
|
+
if (buf[1] === 0x03) {
|
|
34
|
+
const expected = 3 + buf[2] + 2; // hdr(3) + data + crc(2)
|
|
35
|
+
return buf.length >= expected ? expected : 0;
|
|
36
|
+
}
|
|
37
|
+
// Error response: FC | 0x80, error code, 2-byte CRC = 5 bytes
|
|
38
|
+
if ((buf[1] & 0x80) !== 0) return buf.length >= 5 ? 5 : 0;
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Parse a complete Modbus RTU read-response frame.
|
|
43
|
+
// Returns { registers: Map<addr, uint16>, startRegister } or null on CRC error.
|
|
44
|
+
function parseReadResponse(buf, startRegister) {
|
|
45
|
+
const frameLen = completeFrameLength(buf);
|
|
46
|
+
if (frameLen === 0) return null;
|
|
47
|
+
|
|
48
|
+
// CRC check
|
|
49
|
+
const receivedCrc = buf[frameLen - 2] | (buf[frameLen - 1] << 8);
|
|
50
|
+
const computedCrc = crc16(buf.slice(0, frameLen - 2));
|
|
51
|
+
if (receivedCrc !== computedCrc) return null;
|
|
52
|
+
|
|
53
|
+
// Error frame
|
|
54
|
+
if ((buf[1] & 0x80) !== 0) return null;
|
|
55
|
+
|
|
56
|
+
const byteCount = buf[2];
|
|
57
|
+
const registers = new Map();
|
|
58
|
+
for (let i = 0; i < byteCount / 2; i++) {
|
|
59
|
+
const addr = startRegister + i;
|
|
60
|
+
const val = buf.readUInt16BE(3 + i * 2);
|
|
61
|
+
registers.set(addr, val);
|
|
62
|
+
}
|
|
63
|
+
return { registers, startRegister };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Apply the optional XOR-based frame scrambling used by some Bluetti models.
|
|
67
|
+
// key is a Buffer. If key is null/empty, returns buf unchanged.
|
|
68
|
+
function applyXor(buf, key) {
|
|
69
|
+
if (!key || key.length === 0) return buf;
|
|
70
|
+
const out = Buffer.from(buf);
|
|
71
|
+
for (let i = 0; i < out.length; i++) {
|
|
72
|
+
out[i] ^= key[i % key.length];
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Group a sorted list of register addresses into contiguous batches for polling.
|
|
78
|
+
// maxGap: if two adjacent addresses are more than this apart, start a new batch.
|
|
79
|
+
// maxCount: max registers per request.
|
|
80
|
+
function groupRegisters(addresses, maxGap = 10, maxCount = 50) {
|
|
81
|
+
if (addresses.length === 0) return [];
|
|
82
|
+
const sorted = [...addresses].sort((a, b) => a - b);
|
|
83
|
+
const batches = [];
|
|
84
|
+
let start = sorted[0];
|
|
85
|
+
let end = sorted[0];
|
|
86
|
+
|
|
87
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
88
|
+
const addr = sorted[i];
|
|
89
|
+
if (addr - end <= maxGap && addr - start < maxCount) {
|
|
90
|
+
end = addr;
|
|
91
|
+
} else {
|
|
92
|
+
batches.push({ start, count: end - start + 1 });
|
|
93
|
+
start = addr;
|
|
94
|
+
end = addr;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
batches.push({ start, count: end - start + 1 });
|
|
98
|
+
return batches;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { crc16, buildReadRequest, completeFrameLength, parseReadResponse, applyXor, groupRegisters };
|
package/lib/scanner.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
|
|
5
|
+
// Bluetti devices advertise with names matching these prefixes.
|
|
6
|
+
// BT-TH-XXXXXXXXXX is the most common; some models use BLUETTI or model names directly.
|
|
7
|
+
const BLUETTI_NAME_PREFIXES = ['BT-TH-', 'BLUETTI', 'AC', 'EP', 'EB', 'EL'];
|
|
8
|
+
|
|
9
|
+
function isBluettiDevice(peripheral) {
|
|
10
|
+
const name = (peripheral.advertisement && peripheral.advertisement.localName) || '';
|
|
11
|
+
return BLUETTI_NAME_PREFIXES.some(p => name.toUpperCase().startsWith(p.toUpperCase()));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class Scanner extends EventEmitter {
|
|
15
|
+
constructor(log) {
|
|
16
|
+
super();
|
|
17
|
+
this._log = log;
|
|
18
|
+
this._noble = null;
|
|
19
|
+
this._scanning = false;
|
|
20
|
+
this._found = new Map(); // address → peripheral
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Returns a promise that resolves once noble is powered on.
|
|
24
|
+
_initNoble() {
|
|
25
|
+
if (this._noble) return Promise.resolve();
|
|
26
|
+
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
let noble;
|
|
29
|
+
try {
|
|
30
|
+
noble = require('@abandonware/noble');
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return reject(new Error('@abandonware/noble not installed — run: npm install'));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this._noble = noble;
|
|
36
|
+
|
|
37
|
+
noble.on('discover', (peripheral) => {
|
|
38
|
+
if (!isBluettiDevice(peripheral)) return;
|
|
39
|
+
const addr = peripheral.address || peripheral.id;
|
|
40
|
+
const name = (peripheral.advertisement.localName || 'unknown').trim();
|
|
41
|
+
if (!this._found.has(addr)) {
|
|
42
|
+
this._found.set(addr, peripheral);
|
|
43
|
+
this._log(`Discovered Bluetti device: ${name} [${addr}]`);
|
|
44
|
+
this.emit('discovered', { address: addr, name, peripheral });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
noble.on('stateChange', (state) => {
|
|
49
|
+
this._log(`BLE adapter state: ${state}`);
|
|
50
|
+
if (state === 'poweredOn') {
|
|
51
|
+
resolve();
|
|
52
|
+
} else if (state === 'poweredOff' || state === 'unsupported') {
|
|
53
|
+
reject(new Error(`BLE adapter state: ${state}`));
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Already powered on (noble initialised before our listener)
|
|
58
|
+
if (noble.state === 'poweredOn') resolve();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async startScan(durationMs = 15000) {
|
|
63
|
+
if (this._scanning) return;
|
|
64
|
+
await this._initNoble();
|
|
65
|
+
this._found.clear();
|
|
66
|
+
this._scanning = true;
|
|
67
|
+
this._log(`Scanning for Bluetti devices for ${durationMs / 1000}s …`);
|
|
68
|
+
this._noble.startScanning([], false);
|
|
69
|
+
this._scanTimer = setTimeout(() => this.stopScan(), durationMs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
stopScan() {
|
|
73
|
+
if (!this._scanning) return;
|
|
74
|
+
clearTimeout(this._scanTimer);
|
|
75
|
+
this._scanning = false;
|
|
76
|
+
if (this._noble) this._noble.stopScanning();
|
|
77
|
+
this._log(`Scan complete. Found ${this._found.size} Bluetti device(s).`);
|
|
78
|
+
this.emit('scanComplete', [...this._found.values()].map(p => ({
|
|
79
|
+
address: p.address || p.id,
|
|
80
|
+
name: (p.advertisement.localName || 'unknown').trim(),
|
|
81
|
+
})));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Retrieve a previously-discovered peripheral by address (for reconnect).
|
|
85
|
+
getPeripheral(address) {
|
|
86
|
+
return this._found.get(address) || null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Start a persistent background scan (noble keeps discovering; don't auto-stop).
|
|
90
|
+
startPassiveScan() {
|
|
91
|
+
this._initNoble().then(() => {
|
|
92
|
+
this._scanning = true;
|
|
93
|
+
this._noble.startScanning([], true); // allowDuplicates=true so we see RSSI updates
|
|
94
|
+
}).catch(err => {
|
|
95
|
+
this._log(`BLE init failed: ${err.message}`);
|
|
96
|
+
this.emit('error', err);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
stopAll() {
|
|
101
|
+
this.stopScan();
|
|
102
|
+
if (this._noble) {
|
|
103
|
+
try { this._noble.stopScanning(); } catch (_) {}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = Scanner;
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rhizomatics/signalk-bluetti-plugin",
|
|
3
|
+
"version": "1.0.1-alpha",
|
|
4
|
+
"signalk": {
|
|
5
|
+
"displayName": "Bluetti Monitoring"
|
|
6
|
+
},
|
|
7
|
+
"description": "SignalK plugin for Bluetti power station monitoring via Bluetooth LE. ",
|
|
8
|
+
"main": "index.js",
|
|
9
|
+
"license": "Apache-2.0",
|
|
10
|
+
"keywords": [
|
|
11
|
+
"signalk-node-server-plugin",
|
|
12
|
+
"signalk-node-plugin",
|
|
13
|
+
"bluetti",
|
|
14
|
+
"battery",
|
|
15
|
+
"electrical",
|
|
16
|
+
"bluetooth",
|
|
17
|
+
"ble",
|
|
18
|
+
"marine"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"signalk-server": "*"
|
|
22
|
+
},
|
|
23
|
+
"signalk-plugin-enabled-by-default": false,
|
|
24
|
+
"scripts": {
|
|
25
|
+
"start": "node index.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@abandonware/noble": "^1.9.2-15",
|
|
29
|
+
"csv-parse": "^5.5.6"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Bluetti register map example — based on AC200P / AC200MAX community reverse-engineering.
|
|
2
|
+
# Replace with your device's actual CSV. Columns the plugin recognises:
|
|
3
|
+
#
|
|
4
|
+
# field_name — internal identifier (no spaces)
|
|
5
|
+
# register_address — Modbus holding register number (decimal)
|
|
6
|
+
# register_count — number of 16-bit registers this field spans (1 for uint16/int16, 2 for int32/float32)
|
|
7
|
+
# data_type — uint16 | int16 | uint32 | int32 | float32 | bool
|
|
8
|
+
# scale — multiply raw value by this to get physical value
|
|
9
|
+
# offset — add after scale (usually 0)
|
|
10
|
+
# unit — V | A | W | Wh | % | °C | Hz | min — drives SignalK unit conversion
|
|
11
|
+
# signalk_path — full SignalK path; use {name} where the device name goes
|
|
12
|
+
#
|
|
13
|
+
field_name,register_address,register_count,data_type,scale,offset,unit,signalk_path
|
|
14
|
+
# ── Battery ────────────────────────────────────────────────────────────────
|
|
15
|
+
battery_percent,100,1,uint16,1,0,%,electrical.batteries.{name}.stateOfCharge
|
|
16
|
+
battery_voltage,101,1,uint16,0.1,0,V,electrical.batteries.{name}.voltage
|
|
17
|
+
battery_current,102,1,int16,0.1,0,A,electrical.batteries.{name}.current
|
|
18
|
+
battery_power,103,1,int16,1,0,W,electrical.batteries.{name}.power
|
|
19
|
+
battery_temperature,104,1,uint16,0.1,0,°C,electrical.batteries.{name}.temperature
|
|
20
|
+
total_capacity,105,1,uint16,1,0,Wh,electrical.batteries.{name}.capacity.nominal
|
|
21
|
+
remaining_capacity,106,1,uint16,1,0,Wh,electrical.batteries.{name}.capacity.remaining
|
|
22
|
+
# ── DC / Solar input ────────────────────────────────────────────────────────
|
|
23
|
+
dc_input_power,107,1,uint16,1,0,W,electrical.solar.{name}.panelPower
|
|
24
|
+
dc_input_voltage,108,1,uint16,0.1,0,V,electrical.solar.{name}.panelVoltage
|
|
25
|
+
dc_input_current,109,1,uint16,0.1,0,A,electrical.solar.{name}.panelCurrent
|
|
26
|
+
# ── AC output (inverter) ────────────────────────────────────────────────────
|
|
27
|
+
ac_output_power,110,1,uint16,1,0,W,electrical.inverters.{name}.ac.power
|
|
28
|
+
ac_output_voltage,111,1,uint16,0.1,0,V,electrical.inverters.{name}.ac.voltage
|
|
29
|
+
ac_output_current,112,1,uint16,0.1,0,A,electrical.inverters.{name}.ac.current
|
|
30
|
+
ac_output_frequency,113,1,uint16,0.1,0,Hz,electrical.inverters.{name}.ac.frequency
|
|
31
|
+
# ── AC input (charger / mains) ──────────────────────────────────────────────
|
|
32
|
+
ac_input_power,114,1,uint16,1,0,W,electrical.chargers.{name}.input.power
|
|
33
|
+
ac_input_voltage,115,1,uint16,0.1,0,V,electrical.chargers.{name}.input.voltage
|
|
34
|
+
ac_input_current,116,1,uint16,0.1,0,A,electrical.chargers.{name}.input.current
|
|
35
|
+
ac_input_frequency,117,1,uint16,0.1,0,Hz,electrical.chargers.{name}.input.frequency
|
|
36
|
+
# ── Internal temperature ────────────────────────────────────────────────────
|
|
37
|
+
internal_temperature,118,1,uint16,0.1,0,°C,electrical.batteries.{name}.controllerTemperature
|
|
38
|
+
# ── Estimated time remaining ────────────────────────────────────────────────
|
|
39
|
+
time_remaining_minutes,119,1,uint16,1,0,min,electrical.batteries.{name}.capacity.timeRemaining
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Bluetti Elite 100 V2 (EL100V2) register map
|
|
2
|
+
# Source: https://github.com/Patrick762/bluetti-bt-lib
|
|
3
|
+
# BaseDeviceV2 fields: BATTERY_SOC (102)
|
|
4
|
+
# EL100V2-specific fields: 140, 142, 144, 146, 1314
|
|
5
|
+
# Note: DEVICE_TYPE (110) and DEVICE_SN (116) are string/serial fields — omitted.
|
|
6
|
+
#
|
|
7
|
+
field_name,register_address,register_count,data_type,scale,offset,unit,signalk_path
|
|
8
|
+
battery_soc,102,1,uint16,0.01,0,%,electrical.batteries.{name}.stateOfCharge
|
|
9
|
+
dc_output_power,140,1,uint16,1,0,W,electrical.dc.{name}.power
|
|
10
|
+
ac_output_power,142,1,uint16,1,0,W,electrical.inverters.{name}.ac.power
|
|
11
|
+
dc_input_power,144,1,uint16,1,0,W,electrical.solar.{name}.panelPower
|
|
12
|
+
ac_input_power,146,1,uint16,1,0,W,electrical.chargers.{name}.input.power
|
|
13
|
+
ac_input_voltage,1314,1,uint16,0.1,0,V,electrical.chargers.{name}.input.voltage
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Bluetti register map example — based on AC200P / AC200MAX community reverse-engineering.
|
|
2
|
+
# Replace with your device's actual CSV. Columns the plugin recognises:
|
|
3
|
+
#
|
|
4
|
+
# field_name — internal identifier (no spaces)
|
|
5
|
+
# register_address — Modbus holding register number (decimal)
|
|
6
|
+
# register_count — number of 16-bit registers this field spans (1 for uint16/int16, 2 for int32/float32)
|
|
7
|
+
# data_type — uint16 | int16 | uint32 | int32 | float32 | bool
|
|
8
|
+
# scale — multiply raw value by this to get physical value
|
|
9
|
+
# offset — add after scale (usually 0)
|
|
10
|
+
# unit — V | A | W | Wh | % | °C | Hz | min — drives SignalK unit conversion
|
|
11
|
+
# signalk_path — full SignalK path; use {name} where the device name goes
|
|
12
|
+
#
|
|
13
|
+
field_name,register_address,register_count,data_type,scale,offset,unit,signalk_path
|
|
14
|
+
# ── Battery ────────────────────────────────────────────────────────────────
|
|
15
|
+
battery_percent,100,1,uint16,1,0,%,electrical.batteries.{name}.stateOfCharge
|
|
16
|
+
battery_voltage,101,1,uint16,0.1,0,V,electrical.batteries.{name}.voltage
|
|
17
|
+
battery_current,102,1,int16,0.1,0,A,electrical.batteries.{name}.current
|
|
18
|
+
battery_power,103,1,int16,1,0,W,electrical.batteries.{name}.power
|
|
19
|
+
battery_temperature,104,1,uint16,0.1,0,°C,electrical.batteries.{name}.temperature
|
|
20
|
+
total_capacity,105,1,uint16,1,0,Wh,electrical.batteries.{name}.capacity.nominal
|
|
21
|
+
remaining_capacity,106,1,uint16,1,0,Wh,electrical.batteries.{name}.capacity.remaining
|
|
22
|
+
# ── DC / Solar input ────────────────────────────────────────────────────────
|
|
23
|
+
dc_input_power,107,1,uint16,1,0,W,electrical.solar.{name}.panelPower
|
|
24
|
+
dc_input_voltage,108,1,uint16,0.1,0,V,electrical.solar.{name}.panelVoltage
|
|
25
|
+
dc_input_current,109,1,uint16,0.1,0,A,electrical.solar.{name}.panelCurrent
|
|
26
|
+
# ── AC output (inverter) ────────────────────────────────────────────────────
|
|
27
|
+
ac_output_power,110,1,uint16,1,0,W,electrical.inverters.{name}.ac.power
|
|
28
|
+
ac_output_voltage,111,1,uint16,0.1,0,V,electrical.inverters.{name}.ac.voltage
|
|
29
|
+
ac_output_current,112,1,uint16,0.1,0,A,electrical.inverters.{name}.ac.current
|
|
30
|
+
ac_output_frequency,113,1,uint16,0.1,0,Hz,electrical.inverters.{name}.ac.frequency
|
|
31
|
+
# ── AC input (charger / mains) ──────────────────────────────────────────────
|
|
32
|
+
ac_input_power,114,1,uint16,1,0,W,electrical.chargers.{name}.input.power
|
|
33
|
+
ac_input_voltage,115,1,uint16,0.1,0,V,electrical.chargers.{name}.input.voltage
|
|
34
|
+
ac_input_current,116,1,uint16,0.1,0,A,electrical.chargers.{name}.input.current
|
|
35
|
+
ac_input_frequency,117,1,uint16,0.1,0,Hz,electrical.chargers.{name}.input.frequency
|
|
36
|
+
# ── Internal temperature ────────────────────────────────────────────────────
|
|
37
|
+
internal_temperature,118,1,uint16,0.1,0,°C,electrical.batteries.{name}.controllerTemperature
|
|
38
|
+
# ── Estimated time remaining ────────────────────────────────────────────────
|
|
39
|
+
time_remaining_minutes,119,1,uint16,1,0,min,electrical.batteries.{name}.capacity.timeRemaining
|