node-red-contrib-victron-ble
Version:
node-red node to parse Instant Readout advertisement data from Victron BLE devices
216 lines (215 loc) • 8.63 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BluetoothctlBleAdapter = void 0;
const events_1 = require("events");
const child_process_1 = require("child_process");
class BluetoothctlBleAdapter extends events_1.EventEmitter {
scanning = false;
discovered = new Map();
process = null;
buffer = '';
pendingValue = null;
lastDevicesCommand = 0;
lineQueue = [];
processing = false;
valueParsing = null;
async startScan() {
this.scanning = true;
return new Promise((resolve, reject) => {
this.process = (0, child_process_1.spawn)('bluetoothctl', []);
let errorHandled = false;
if (this.process.stdout) {
this.process.stdout.setEncoding('utf8');
this.process.stdout.on('data', (data) => {
// Split data into lines and enqueue them as fast as possible
this.buffer += data;
let lines = this.buffer.split(/\r?\n/);
this.buffer = lines.pop() || '';
for (const line of lines) {
this.lineQueue.push(line);
}
this.startProcessingLoop();
});
}
if (this.process.stderr) {
this.process.stderr.on('data', (data) => {
// Optionally log errors
});
}
this.process.on('close', () => {
this.scanning = false;
});
this.process.on('error', (err) => {
if (!errorHandled) {
errorHandled = true;
reject(err);
}
});
// Send 'devices' command at the start to populate name cache
setTimeout(() => {
if (this.process && this.process.stdin) {
this.process.stdin.write('devices\n');
}
}, 800);
// Do send 'scan on' (even if it fails, it does not hurt)
setTimeout(() => {
if (this.process && this.process.stdin) {
this.process.stdin.write('scan on\nscan.duplicate-data off\n');
this.lastDevicesCommand = Date.now();
}
}, 300);
// Resolve immediately for compatibility (or after a short delay if you want to ensure process started)
setTimeout(() => {
if (!errorHandled) {
resolve();
}
}, 100); // 100ms delay to catch immediate spawn errors
});
}
async startProcessingLoop() {
if (this.processing)
return;
this.processing = true;
while (this.lineQueue.length > 0) {
const line = this.lineQueue.shift();
if (line !== undefined) {
this.handleBluetoothctlLine(line);
}
// Optionally yield to event loop for fairness
await new Promise((r) => setImmediate(r));
}
this.processing = false;
}
handleBluetoothctlLine(line) {
// Clean up the line before processing
line = stripAnsiCodes(line);
if (!line)
return;
// 1. If a line starts with 'Device <MAC>', it's a device line
const deviceLineMatch = line.match(/^Device ([0-9A-F:]{17})(.*)$/);
if (deviceLineMatch) {
// If we were collecting value lines, emit the event now
if (this.valueParsing && this.valueParsing.valueLines.length > 0) {
// Inline parsing of value lines: split by spaces, take valid hex tokens, concatenate
let hexString = '';
for (const valueLine of this.valueParsing.valueLines) {
const tokens = valueLine.split(' ');
for (const token of tokens) {
if (/^[0-9a-fA-F]{2}$/.test(token)) {
hexString += token;
}
}
}
if (hexString.length > 0) {
const rawData = Buffer.from(hexString, 'hex');
const address = this.valueParsing.address.toLowerCase();
const dev = this.discovered.get(address) || { address };
const packet = {
...dev,
rssi: dev.rssi || 0,
rawData,
};
this.emit('raw', packet);
}
this.valueParsing = null;
}
// Parse device metadata (RSSI, name, etc.)
const address = deviceLineMatch[1].toLowerCase();
const rest = deviceLineMatch[2].trim();
// RSSI (support both old and new formats)
let rssi = undefined;
// Old: RSSI: -78, New: RSSI: 0xffffffb2 (-78)
const rssiMatch = rest.match(/RSSI: (?:0x[0-9a-fA-F]+ )?\(?(-?\d+)\)?|RSSI: (-?\d+)/);
if (rssiMatch) {
if (rssiMatch[1] !== undefined) {
rssi = parseInt(rssiMatch[1], 10);
}
else if (rssiMatch[2] !== undefined) {
rssi = parseInt(rssiMatch[2], 10);
}
}
// Accept both ManufacturerData Value: and ManufacturerData.Value:
const manufacturerDataValueRegex = /ManufacturerData[ .]Value:/;
if (rest && manufacturerDataValueRegex.test(rest)) {
this.valueParsing = { address, valueLines: [] };
}
// Name (if present and not ManufacturerData or RSSI or AdvertisingFlags)
let name = undefined;
const manufacturerDataKeyRegex = /ManufacturerData[ .]Key:/;
if (rest &&
!manufacturerDataValueRegex.test(rest) &&
!manufacturerDataKeyRegex.test(rest) &&
!rest.startsWith('RSSI:') &&
!rest.startsWith('AdvertisingFlags:')) {
name = rest;
}
// Update discovered map
let dev = this.discovered.get(address) || { address };
if (typeof rssi === 'number')
dev.rssi = rssi;
if (name)
dev.name = name;
this.discovered.set(address, dev);
return;
}
// 2. If we are in valueParsing mode, collect value lines
if (this.valueParsing) {
// Only collect lines that are not empty and not a device line
if (line && !/^Device [0-9A-F:]{17}/.test(line)) {
this.valueParsing.valueLines.push(line);
}
// Do not emit yet; will emit when a new device line is encountered
return;
}
}
async stopScan() {
this.scanning = false;
if (this.process) {
if (this.process.stdin) {
this.process.stdin.write('exit\n');
}
this.process.kill();
this.process = null;
}
}
getDiscoveredDevices() {
return Array.from(this.discovered.values());
}
}
exports.BluetoothctlBleAdapter = BluetoothctlBleAdapter;
// Extract up to 16 valid hex byte pairs from a line, skipping leading non-hex tokens and trailing ASCII
function extractHexBytes(line) {
// Remove ANSI codes
const ansiStripped = line.replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\[K/g, '');
// Split by spaces
const tokens = ansiStripped.trim().split(/\s+/);
const hexPairs = [];
for (const token of tokens) {
if (/^[0-9a-fA-F]{2}$/.test(token)) {
hexPairs.push(token);
if (hexPairs.length === 16)
break;
}
else if (hexPairs.length > 0) {
// Stop at first non-hex token after collecting some
break;
}
// else: skip non-hex tokens at the start
}
if (hexPairs.length > 0) {
return hexPairs.join('');
}
return null;
}
// Utility function to strip ANSI escape codes, control characters, and bluetooth prompt
function stripAnsiCodes(text) {
return text
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // ANSI codes
.replace(/[\x00-\x1F\x7F]/g, '') // All control characters
.replace(/\[NEW\] /g, '')
.replace(/\[CHG\] /g, '')
.replace(/\[DEL\] /g, '')
.replace(/\[bluetooth\]# \r/g, '')
.replace(/\[bluetooth\]#/g, '') // Bluetooth prompt
.trim();
}