matterbridge-bthome
Version:
Matterbridge BTHome plugin
350 lines (349 loc) • 16.8 kB
JavaScript
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);
});
}