UNPKG

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
"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)}`); } }