UNPKG

matterbridge-bthome

Version:
350 lines (349 loc) 16.8 kB
#!/usr/bin/env node import { hasParameter, isValidNumber, isValidString } from 'matterbridge/utils'; import { AnsiLogger, nf, BLUE, GREEN, MAGENTA, YELLOW } from 'matterbridge/logger'; import { EventEmitter } from 'node:events'; import { decodeBTHome } from './BTHomeDecoder.js'; import { decodeShellyManufacturerData } from './BTHomeShellyMdDecoder.js'; import { CYAN } from 'node-ansi-logger'; const _blushellies = [ '38:39:8f:8b:d2:29', '28:68:47:fc:9a:6b', '28:db:a7:b5:d1:ca', '0c:ae:5f:5a:0b:fa', '0c:ef:f6:01:8d:b8', '0c:ef:f6:f1:d7:7b', '7c:c6:b6:58:b9:a0', '7c:c6:b6:65:2d:87', '7c:c6:b6:bd:7a:9a', '60:ef:ab:3f:c9:7b', '38:39:8f:99:58:49', '7c:c6:b6:2b:17:b6', '38:39:8f:a0:9e:34', ]; const _shellies = [ '34:cd:b0:77:bc:d6', 'b0:b2:1c:fa:ad:1a', 'ec:62:60:8c:9c:02', '8c:bf:ea:9d:e2:9e', 'cc:7b:5c:8a:ea:2e', '34:b7:da:ca:c8:32', '1c:69:20:44:f1:42', '42:27:b3:f0:fc:29', ]; export class BTHome extends EventEmitter { noble; log; isScanning = false; filterBle = false; filterBTHome = false; filterShellyBle = false; filterAddress = []; bthomePeripherals = new Map(); blePeripherals = new Map(); constructor(filterBle = false, filterBTHome = true, filterShellyBle = false, filterAddress = [], logLevel = "debug") { super(); this.log = new AnsiLogger({ logName: 'BTHome', logTimestampFormat: 4, logLevel }); this.filterBle = filterBle; this.filterBTHome = filterBTHome; this.filterShellyBle = filterShellyBle; this.filterAddress = filterAddress; for (const address of this.filterAddress) address.toLowerCase().trim(); this.log.debug('BTHome constructor called with parameters:'); this.log.debug(` - filterBTHome: ${filterBTHome}`); this.log.debug(` - filterShellyBle: ${filterShellyBle}`); this.log.debug(` - filterAddress: ${filterAddress.join(', ')}`); this.handleDiscovery = this.handleDiscovery.bind(this); } isShellyBlePeripheral(peripheral) { if (peripheral.advertisement.localName === undefined || peripheral.advertisement.localName === null || peripheral.advertisement.localName === '') return false; if (!peripheral.advertisement.localName.startsWith('Shelly') && peripheral.advertisement.localName !== 'WallDisplay') return false; return true; } isBTHomePeripheral(peripheral) { if (Array.from(this.bthomePeripherals.values()).find((device) => device.mac === peripheral.address)) return true; if (peripheral.advertisement.serviceData && peripheral.advertisement.serviceData.length) { return peripheral.advertisement.serviceData.find((entry) => entry.uuid === 'fcd2') !== undefined; } return false; } async handleDiscovery(peripheral) { if (this.filterBle) { let assignedNumber = undefined; let manufacturerData = undefined; if (peripheral.advertisement.manufacturerData && peripheral.advertisement.manufacturerData.length >= 2) { assignedNumber = '0x' + peripheral.advertisement.manufacturerData.readUInt16LE(0).toString(16).padStart(4, '0'); manufacturerData = '0x' + peripheral.advertisement.manufacturerData.toString('hex'); } let bleDevice = this.blePeripherals.get(peripheral.id); if (!bleDevice) { bleDevice = { id: peripheral.id, address: peripheral.address, addressType: peripheral.addressType, connectable: peripheral.connectable, advertisement: peripheral.advertisement, rssi: peripheral.rssi, mtu: null, services: [], state: 'disconnected', localName: peripheral.advertisement.localName ?? '', lastSeen: new Date(), }; this.blePeripherals.set(peripheral.id, bleDevice); this.log.info(`[${GREEN}New${nf}] Device ${MAGENTA}${peripheral.address}${nf} Rssi: ${CYAN}${peripheral.rssi}${nf} Name: ${CYAN}${bleDevice.localName}${nf}`); if (assignedNumber) this.log.debug(` ManufacturerData Key: ${assignedNumber} Value: ${manufacturerData}`); } else { bleDevice.address = peripheral.address; bleDevice.addressType = peripheral.addressType; bleDevice.connectable = peripheral.connectable; bleDevice.advertisement = peripheral.advertisement; bleDevice.rssi = peripheral.rssi; bleDevice.mtu = peripheral.mtu; bleDevice.services = peripheral.services; bleDevice.state = peripheral.state; bleDevice.localName = peripheral.advertisement.localName ?? ''; bleDevice.lastSeen = new Date(); this.log.info(`[${YELLOW}Chg${nf}] Device ${MAGENTA}${peripheral.address}${nf} Rssi: ${CYAN}${peripheral.rssi}${nf} Name: ${CYAN}${bleDevice.localName}${nf}`); if (assignedNumber) this.log.debug(` ManufacturerData Key: ${assignedNumber} Value: ${manufacturerData}`); } } const isShelly = this.isShellyBlePeripheral(peripheral); const isBTHome = this.isBTHomePeripheral(peripheral); if (this.filterBTHome && !isBTHome) return; if (this.filterShellyBle && !isShelly) return; if (this.filterAddress.length > 0 && !this.filterAddress.includes(peripheral.address.toLowerCase().trim())) return; if (isBTHome) { this.log.debug(`${BLUE}Message from Shelly BLU id ${peripheral.id}:`); } else if (isShelly) { this.log.debug(`${GREEN}Message from Shelly device id ${peripheral.id}:`); } else { this.log.debug(`Message from peripheral id ${peripheral.id}:`); } this.log.debug(` - Address: ${peripheral.address} (${peripheral.addressType})`); this.log.debug(` - Connectable: ${peripheral.connectable}`); this.log.debug(` - RSSI: ${peripheral.rssi}`); if (peripheral.advertisement.localName) { this.log.debug(` - Local Name: ${peripheral.advertisement.localName}`); } if (peripheral.advertisement.serviceUuids.length) { this.log.debug(` - Advertised Services: ${peripheral.advertisement.serviceUuids.join(', ')}`); } const serviceData = peripheral.advertisement.serviceData; if (serviceData && serviceData.length) { this.log.debug(' - Service Data:'); serviceData.forEach((entry) => { if (entry.uuid === 'fcd2') { const bthome = decodeBTHome(entry.data); this.log.debug(` BTHome Service Data (${entry.data.toString('hex')}): ${JSON.stringify(bthome)}`); let device; if (this.bthomePeripherals.has(peripheral.address)) { device = this.bthomePeripherals.get(peripheral.address); device.rssi = peripheral.rssi ?? device.rssi; device.localName = peripheral.advertisement.localName ?? device.localName; device.version = bthome.version ?? device.version; device.encrypted = bthome.encrypted ?? device.encrypted; device.trigger = bthome.trigger ?? device.trigger; device.data = Object.assign(device.data, bthome.readings); device.packetId = isValidNumber(bthome.readings.packetId, 0) ? bthome.readings.packetId : 0; device.lastSeen = new Date(); this.emit('update', device); } else { device = { mac: peripheral.address, rssi: peripheral.rssi, localName: isValidString(peripheral.advertisement.localName, 3) ? peripheral.advertisement.localName : 'BTHome ' + peripheral.address, version: bthome.version, encrypted: bthome.encrypted, trigger: bthome.trigger, data: bthome.readings, packetId: isValidNumber(bthome.readings.packetId, 0) ? bthome.readings.packetId : 0, lastSeen: new Date(), }; this.bthomePeripherals.set(peripheral.address, device); this.emit('discovered', device); } } else { this.log.debug(` ${entry.uuid}: ${entry.data.toString('hex')}`); } }); } if (peripheral.advertisement.manufacturerData && peripheral.advertisement.manufacturerData.length >= 2) { const assignedNumber = peripheral.advertisement.manufacturerData.readUInt16LE(0); if (assignedNumber === 0x0ba9) { const data = decodeShellyManufacturerData(peripheral.advertisement.manufacturerData); this.log.debug(` - Shelly Manufacturer Data:`); if (data) { this.log.debug(` - Flags: ${JSON.stringify(data.flags)}`); this.log.debug(` - Model ID: ${data.modelId} short name ${data.modelIdShortName ?? ''} long name ${data.modelIdLongName ?? ''}`); this.log.debug(` - MAC: ${data.mac}`); if (this.bthomePeripherals.has(peripheral.address)) { const device = this.bthomePeripherals.get(peripheral.address); device.modelId = data.modelId; device.modelIdShortName = data.modelIdShortName; device.modelIdLongName = data.modelIdLongName; this.emit('update', device); } } } else if (assignedNumber === 0x004c) { this.log.debug(` - Apple Manufacturer Data: ${peripheral.advertisement.manufacturerData.toString('hex')}`); } else { this.log.debug(` - Manufacturer Data: ${peripheral.advertisement.manufacturerData.toString('hex')}`); } } if (peripheral.advertisement.txPowerLevel) { this.log.debug(` - TX Power Level: ${peripheral.advertisement.txPowerLevel}`); } } async waitForPoweredOn() { if (!this.noble) throw new Error('Noble is not loaded'); if (this.noble.state === 'poweredOn') return; if (this.noble.state === 'unsupported' || this.noble.state === 'unauthorized') throw new Error(`Bluetooth adapter not usable (state=${this.noble.state})`); this.log.info(`Bluetooth adapter state is ${this.noble.state}`); this.log.info('Waiting 30 seconds for the Bluetooth adapter state to be poweredOn…'); return new Promise((resolve, reject) => { const onStateChange = (state) => { this.log.info(`Bluetooth adapter changed state to ${state}`); if (state === 'poweredOn') { clearTimeout(timeout); this.noble?.removeListener('stateChange', onStateChange); resolve(); } else if (state === 'unsupported' || state === 'unauthorized') { clearTimeout(timeout); this.noble?.removeListener('stateChange', onStateChange); reject(new Error(`Bluetooth adapter is not usable (state=${state})`)); } }; const timeout = setTimeout(() => { this.noble?.removeListener('stateChange', onStateChange); reject(new Error(`Timeout waiting for the Bluetooth adapter to be powered on (state=${this.noble?.state})`)); }, 30000); this.noble?.on('stateChange', onStateChange); }); } async start() { if (this.isScanning) { this.log.warn('BLE scan already started'); return; } this.noble = await import('@stoprocent/noble') .then((noble) => noble.default) .catch((err) => { this.log.error(`Error loading noble: ${err instanceof Error ? err.message : String(err)}`); throw err; }); this.log.info('Checking the Bluetooth adapter state…'); try { await this.waitForPoweredOn(); } catch (err) { this.log.error(`Adapter error: ${err instanceof Error ? err.message : String(err)}`); throw err; } this.log.info(`Bluetooth adapter state is ${this.noble.state}`); this.log.info('Starting BLE scan…'); try { await this.noble.startScanningAsync([], true); } catch (err) { this.log.error(`Scan start failed: ${err instanceof Error ? err.message : String(err)}`); throw err; } this.noble.on('discover', this.handleDiscovery); this.isScanning = true; this.log.info('BLE scan started'); } async stop() { if (!this.isScanning) { this.log.warn('BLE scan already stopped'); return; } if (!this.noble) { this.log.warn('Noble is not loaded'); return; } try { this.log.info('Stopping BLE scan…'); this.noble.removeListener('discover', this.handleDiscovery); await this.noble.stopScanningAsync(); this.isScanning = false; this.log.info('BLE scan stopped'); } catch (err) { this.log.error(`Error stopping BLE scan: ${err instanceof Error ? err.message : String(err)}`); } this.noble = undefined; this.blePeripherals.clear(); this.bthomePeripherals.clear(); } logDevices() { this.log.debug(`Discovered ${this.bthomePeripherals.size} BTHome devices:`); this.bthomePeripherals.forEach((device) => { this.log.debug(`- ${device.mac}:`); this.log.debug(` - RSSI: ${device.rssi}`); this.log.debug(` - Local Name: ${device.localName}`); this.log.debug(` - Version: ${device.version}`); this.log.debug(` - Encrypted: ${device.encrypted}`); this.log.debug(` - Trigger: ${device.trigger}`); this.log.debug(` - Packet ID: ${device.packetId}`); this.log.debug(` - Last Seen: ${device.lastSeen.toLocaleString()}`); this.log.debug(` - Model ID: ${device.modelId} short name ${device.modelIdShortName} long name ${device.modelIdLongName}`); this.log.debug(` - Data: ${JSON.stringify(device.data, null, 2)}`); }); } } function getStringArrayParameter(name) { const args = process.argv.slice(2); const idx = args.indexOf(`--${name}`) || args.indexOf(`-${name}`); if (idx < 0) return []; const values = []; for (let i = idx + 1; i < args.length && !args[i].startsWith('-'); i++) { values.push(args[i]); } return values; } if (process.argv.includes('--scan')) { const bthome = new BTHome(hasParameter('ble'), hasParameter('bthome'), hasParameter('shellyble'), hasParameter('address') ? getStringArrayParameter('address') : [], hasParameter('logger') ? process.argv[process.argv.indexOf('--logger') + 1] : "debug"); process.on('SIGINT', async () => { bthome.logDevices(); await bthome.stop(); process.exit(0); }); process.on('SIGTERM', async () => { bthome.logDevices(); await bthome.stop(); process.exit(0); }); process.on('uncaughtException', async (error) => { bthome.log.error('BTHome uncaught Exception:', error); await bthome.stop(); }); process.on('unhandledRejection', async (reason) => { bthome.log.error('BTHome unhandled Rejection:', reason); await bthome.stop(); }); bthome.start().catch((error) => { bthome.log.error('BTHome error starting BTHome discovery:', error); process.exit(1); }); }