node-red-contrib-victron-ble
Version:
node-red node to parse Instant Readout advertisement data from Victron BLE devices
231 lines (230 loc) • 8.68 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.DbusBleAdapter = void 0;
const events_1 = require("events");
const module_1 = require("module");
const requireOptional = (0, module_1.createRequire)(__filename);
const BLUEZ_SERVICE = 'org.bluez';
const DBUS_OBJECT_MANAGER = 'org.freedesktop.DBus.ObjectManager';
const DBUS_PROPERTIES = 'org.freedesktop.DBus.Properties';
const BLUEZ_ADAPTER = 'org.bluez.Adapter1';
const BLUEZ_DEVICE = 'org.bluez.Device1';
const DISCOVERY_POLL_MS = 500;
class DbusBleAdapter extends events_1.EventEmitter {
scanning = false;
bus = null;
adapter = null;
objectManager = null;
pollTimer = null;
polling = false;
discovered = new Map();
pathAddresses = new Map();
deviceWatchers = new Map();
lastManufacturerData = new Map();
onInterfacesAdded = (path, interfaces) => {
this.processInterfaces(path, interfaces);
};
async startScan() {
if (this.scanning)
return;
if (process.platform !== 'linux')
throw new Error('BlueZ DBus adapter is only available on Linux.');
const { systemBus, Variant } = loadDbusNext();
this.bus = systemBus();
try {
const { objectManager, adapter } = await openBluezAdapter(this.bus);
this.objectManager = objectManager;
this.adapter = adapter;
this.objectManager.on('InterfacesAdded', this.onInterfacesAdded);
await setBluezDiscoveryFilter(adapter, Variant);
await adapter.StartDiscovery();
this.scanning = true;
await this.pollManagedObjects();
this.pollTimer = setInterval(() => {
void this.pollManagedObjects();
}, DISCOVERY_POLL_MS);
}
catch (error) {
await this.stopScan();
throw error;
}
}
async stopScan() {
this.scanning = false;
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
if (this.objectManager) {
this.objectManager.removeListener('InterfacesAdded', this.onInterfacesAdded);
this.objectManager = null;
}
for (const { properties, listener } of this.deviceWatchers.values()) {
properties.removeListener('PropertiesChanged', listener);
}
this.deviceWatchers.clear();
if (this.adapter) {
await this.adapter.StopDiscovery().catch((error) => {
console.debug(`Warning: could not stop BlueZ discovery: ${errorMessage(error)}`);
});
this.adapter = null;
}
if (this.bus) {
this.bus.disconnect();
this.bus = null;
}
}
getDiscoveredDevices() {
return Array.from(this.discovered.values());
}
async pollManagedObjects() {
if (!this.objectManager || this.polling)
return;
this.polling = true;
try {
const objects = await this.objectManager.GetManagedObjects();
for (const [path, interfaces] of Object.entries(objects)) {
this.processInterfaces(path, interfaces);
}
}
catch (error) {
console.debug(`Warning: could not poll BlueZ managed objects: ${errorMessage(error)}`);
}
finally {
this.polling = false;
}
}
processInterfaces(path, interfaces) {
const device = interfaces[BLUEZ_DEVICE];
if (!device)
return;
void this.watchDeviceProperties(path);
this.processDeviceProperties(path, device);
}
async watchDeviceProperties(path) {
if (!this.bus || this.deviceWatchers.has(path))
return;
try {
const object = await this.bus.getProxyObject(BLUEZ_SERVICE, path);
const properties = object.getInterface(DBUS_PROPERTIES);
const listener = (interfaceName, changed) => {
if (interfaceName === BLUEZ_DEVICE)
this.processDeviceProperties(path, changed);
};
properties.on('PropertiesChanged', listener);
this.deviceWatchers.set(path, { properties, listener });
}
catch (error) {
console.debug(`Warning: could not watch BlueZ properties for ${path}: ${errorMessage(error)}`);
}
}
processDeviceProperties(path, properties) {
const propertyAddress = nullableString(unboxBluezValue(properties.Address));
const address = (propertyAddress || this.pathAddresses.get(path))?.toLowerCase();
if (!address)
return;
this.pathAddresses.set(path, address);
const existing = this.discovered.get(address) || { address };
const name = nullableString(unboxBluezValue(properties.Name)) || nullableString(unboxBluezValue(properties.Alias));
const rssi = nullableNumber(unboxBluezValue(properties.RSSI));
const device = { ...existing, address };
if (name)
device.name = name;
if (typeof rssi === 'number')
device.rssi = rssi;
this.discovered.set(address, device);
const manufacturerData = readManufacturerData(properties.ManufacturerData);
for (const payload of manufacturerData) {
this.emitManufacturerData(path, device, payload);
}
}
emitManufacturerData(path, device, payload) {
if (this.listenerCount('raw') === 0)
return;
const hex = payload.data.toString('hex');
const key = `${path}:${payload.companyId}`;
if (this.lastManufacturerData.get(key) === hex)
return;
this.lastManufacturerData.set(key, hex);
const packet = {
...device,
rssi: device.rssi ?? 0,
rawData: payload.data,
};
this.emit('raw', packet);
}
}
exports.DbusBleAdapter = DbusBleAdapter;
async function openBluezAdapter(bus) {
const objectManagerObject = await bus.getProxyObject(BLUEZ_SERVICE, '/');
const objectManager = objectManagerObject.getInterface(DBUS_OBJECT_MANAGER);
const objects = await objectManager.GetManagedObjects();
const adapterPath = findBluezAdapterPath(objects);
if (!adapterPath)
throw new Error('Could not find a BlueZ adapter via org.bluez ObjectManager.');
const adapterObject = await bus.getProxyObject(BLUEZ_SERVICE, adapterPath);
const adapter = adapterObject.getInterface(BLUEZ_ADAPTER);
return { objectManager, adapter };
}
async function setBluezDiscoveryFilter(adapter, Variant) {
const filter = {
Transport: new Variant('s', 'le'),
DuplicateData: new Variant('b', true),
};
await adapter.SetDiscoveryFilter(filter).catch((error) => {
console.debug(`Warning: could not set BlueZ discovery filter: ${errorMessage(error)}`);
});
}
function findBluezAdapterPath(objects) {
for (const [path, interfaces] of Object.entries(objects)) {
if (interfaces[BLUEZ_ADAPTER])
return path;
}
return null;
}
function readManufacturerData(value) {
const data = unboxBluezValue(value);
if (!data || typeof data !== 'object')
return [];
const entries = data instanceof Map ? Array.from(data.entries()) : Object.entries(data);
const payloads = [];
for (const [companyId, bytes] of entries) {
const buffer = bytesToBuffer(bytes);
if (buffer && buffer.length > 0) {
payloads.push({ companyId: String(companyId), data: buffer });
}
}
return payloads;
}
function bytesToBuffer(value) {
const unboxed = unboxBluezValue(value);
if (Buffer.isBuffer(unboxed))
return unboxed;
if (unboxed instanceof Uint8Array)
return Buffer.from(unboxed);
if (Array.isArray(unboxed) && unboxed.every((byte) => typeof byte === 'number'))
return Buffer.from(unboxed);
return null;
}
function nullableString(value) {
return typeof value === 'string' && value ? value : undefined;
}
function nullableNumber(value) {
return typeof value === 'number' ? value : undefined;
}
function unboxBluezValue(value) {
if (value && typeof value === 'object' && 'value' in value)
return value.value;
return value;
}
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
function loadDbusNext() {
try {
return requireOptional('dbus-next');
}
catch (error) {
throw new Error(`BlueZ DBus adapter requires the "dbus-next" dependency. ${errorMessage(error)}`);
}
}