UNPKG

@homebridge-plugins/homebridge-govee

Version:

Homebridge plugin to integrate Govee devices into HomeKit.

439 lines (377 loc) 13.2 kB
import { Buffer } from 'node:buffer' import process from 'node:process' import btClient from '@stoprocent/noble' import { UUID } from '../utils/ble-protocol.js' import { decodeAny } from '../utils/decode.js' import { base64ToHex, generateCodeFromHexValues, hexToTwoItems, sleep } from '../utils/functions.js' import platformLang from '../utils/lang-en.js' import { isValidPeripheral } from '../utils/validation.js' const HYPHEN_REGEX = /-/g process.env.NOBLE_REPORT_ALL_HCI_EVENTS = '1' // needed on Linux including Raspberry Pi const H5075_UUID = 'ec88' const H5101_UUID = '0001' const CONNECTION_TIMEOUT = 10000 // 10 seconds const WRITE_TIMEOUT = 5000 // 5 seconds /* The necessary commands to send and functions are taken from and credit to: https://www.npmjs.com/package/govee-led-client */ export default class BLEConnection { constructor(platform) { this.log = platform.log this.platform = platform this.btState = 'unknown' this.isScanning = false this.isConnecting = false this.activeConnection = null this.discoverCallback = null this.scanTimeoutId = null this.isShuttingDown = false // Store event handler references for cleanup this.eventHandlers = { stateChange: null, scanStart: null, scanStop: null, warning: null, discover: null, } this.setupEventListeners() } setupEventListeners() { try { // Store references to event handlers for later removal this.eventHandlers.stateChange = (state) => { if (this.isShuttingDown) { return } this.btState = state this.log.debug('[BLE] adapter state changed to: %s.', state) // If adapter loses power while operations are in progress, clean up if (state !== 'poweredOn') { this.handleAdapterPowerLoss() } } this.eventHandlers.scanStart = () => { if (this.isShuttingDown) { return } this.isScanning = true this.log.debug('[BLE] scanning started.') } this.eventHandlers.scanStop = () => { if (this.isShuttingDown) { return } this.isScanning = false this.log.debug('[BLE] scanning stopped.') } this.eventHandlers.warning = (message) => { if (this.isShuttingDown) { return } this.log.warn('[BLE] adapter warning: %s.', message) } this.eventHandlers.discover = (peripheral) => { if (this.isShuttingDown) { return } this.handleDiscoveredPeripheral(peripheral) } // Attach event listeners with error handling btClient.on('stateChange', this.eventHandlers.stateChange) btClient.on('scanStart', this.eventHandlers.scanStart) btClient.on('scanStop', this.eventHandlers.scanStop) btClient.on('warning', this.eventHandlers.warning) btClient.on('discover', this.eventHandlers.discover) // Handle Noble-specific errors without installing global process handlers btClient.on('error', (err) => { if (err.message && (err.message.includes('BLEManager') || err.message.includes('SIGABRT'))) { this.log.error('[BLE] native ble crash detected, ble functionality disabled for this session.') this.isShuttingDown = true } }) } catch (err) { this.log.warn('[BLE] failed to setup event listeners:', err.message) // Don't throw - allow plugin to continue without BLE } } handleDiscoveredPeripheral(peripheral) { try { const { uuid, address, rssi, advertisement } = peripheral // Skip if not a valid Govee sensor if (!isValidPeripheral(peripheral)) { return } const { localName, manufacturerData } = advertisement if (!manufacturerData) { return } const streamUpdate = manufacturerData.toString('hex') this.log.debug('[BLE] sensor data from %s: %s.', address, streamUpdate) // Decode sensor values const decodedValues = decodeAny(streamUpdate) // Pass to callback if registered if (this.discoverCallback) { this.discoverCallback({ uuid, address, model: localName, battery: decodedValues.battery, humidity: decodedValues.humidity, tempInC: decodedValues.tempInC, tempInF: decodedValues.tempInF, rssi, }) } } catch (err) { this.log.debug('[BLE] error processing discovered peripheral: %s.', err.message) } } handleAdapterPowerLoss() { // Clean up any active operations if adapter loses power if (this.isScanning) { this.isScanning = false this.discoverCallback = null } if (this.activeConnection) { this.activeConnection = null this.isConnecting = false } if (this.scanTimeoutId) { clearTimeout(this.scanTimeoutId) this.scanTimeoutId = null } } async waitForPowerOn(timeout = 5000) { if (this.btState === 'poweredOn') { return true } let timeoutId try { await Promise.race([ btClient.waitForPoweredOnAsync(), new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('timeout waiting for bluetooth adapter')), timeout) }), ]) return true } catch (err) { this.log.warn('[BLE] failed to power on adapter: %s.', err.message) return false } finally { clearTimeout(timeoutId) } } async updateDevice(accessory, params) { accessory.logDebug(`starting ble update with params [${JSON.stringify(params)}]`) // Ensure adapter is ready if (!(await this.waitForPowerOn())) { throw new Error(`${platformLang.bleWrongState} [${this.btState}]`) } // Pause sensor scanning if active const wasScanning = this.isScanning const savedDiscoverCallback = this.discoverCallback if (wasScanning) { accessory.logDebug('pausing sensor scan for device update') await this.stopDiscovery() } this.isConnecting = true let peripheral = null try { // Reset adapter to clear any stale connections btClient.reset() // Connect with timeout accessory.logDebug(`connecting to device at ${accessory.context.bleAddress}`) peripheral = await this.connectWithTimeout(accessory.context.bleAddress, CONNECTION_TIMEOUT) this.activeConnection = peripheral accessory.logDebug('connected successfully') // Discover services and characteristics accessory.logDebug('discovering services and characteristics') const { characteristics } = await peripheral.discoverAllServicesAndCharacteristicsAsync() // Find control characteristic const writeUuid = accessory.context.bleWriteUuid || UUID.WRITE_DEFAULT const characteristic = Object.values(characteristics).find( char => char.uuid.replace(HYPHEN_REGEX, '') === writeUuid, ) if (!characteristic) { const discoveredUuids = Object.values(characteristics).map(c => c.uuid) accessory.logDebug(`discovered characteristics: ${JSON.stringify(discoveredUuids)}`) throw new Error('Control characteristic not found') } accessory.logDebug('found control characteristic') // Prepare command buffer const finalBuffer = this.prepareCommandBuffer(params) accessory.logDebug(`sending command: ${finalBuffer.toString('hex')}`) // Write without response then allow time for the BLE controller to transmit before disconnecting await this.writeWithTimeout(characteristic, finalBuffer, WRITE_TIMEOUT) accessory.logDebug('command sent successfully') await sleep(100) } catch (err) { accessory.logWarn(`BLE update failed: ${err.message}`) throw err } finally { // Always cleanup this.isConnecting = false this.activeConnection = null // Disconnect if connected if (peripheral) { try { accessory.logDebug('disconnecting from device') await peripheral.disconnectAsync() accessory.logDebug('disconnected') } catch (err) { accessory.logDebug(`disconnect error (non-critical): ${err.message}`) } } // Resume scanning if it was active before if (wasScanning && savedDiscoverCallback) { setTimeout(() => { this.startDiscovery(savedDiscoverCallback).catch(err => this.log.debug('[BLE] failed to resume scanning: %s.', err.message), ) }, 1000) } } } async connectWithTimeout(address, timeout) { let timeoutId try { return await Promise.race([ btClient.connectAsync(address), new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Connection timeout')), timeout) }), ]) } finally { clearTimeout(timeoutId) } } async writeWithTimeout(characteristic, buffer, timeout) { let timeoutId try { return await Promise.race([ characteristic.writeAsync(buffer, true), new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error('Write timeout')), timeout) }), ]) } finally { clearTimeout(timeoutId) } } prepareCommandBuffer(params) { if (params.cmd === 'ptReal') { // Base64 action code return Buffer.from( hexToTwoItems(base64ToHex(params.data)).map(byte => `0x${byte}`), ) } // Array of hex values return generateCodeFromHexValues([0x33, params.cmd, params.data], true) } async startDiscovery(callback) { // Skip if already connecting to a device if (this.isConnecting) { this.log.debug('[BLE] skipping sensor scan - device connection in progress.') return } // Skip if already scanning if (this.isScanning) { this.log.debug('[BLE] already scanning.') return } // Ensure adapter is ready if (!(await this.waitForPowerOn())) { throw new Error('bluetooth adapter not ready') } this.discoverCallback = callback try { // Wrap in additional try-catch for native crashes await btClient.startScanningAsync([H5075_UUID, H5101_UUID], true) this.log.debug('[BLE] started scanning for sensors.') } catch (err) { this.discoverCallback = null // Check for native BLE crashes if (err.message && (err.message.includes('BLEManager') || err.message.includes('SIGABRT'))) { this.log.error('[BLE] native ble crash detected, ble functionality disabled for this session.') this.isShuttingDown = true // Prevent further BLE operations } throw err } } async stopDiscovery() { this.discoverCallback = null if (this.scanTimeoutId) { clearTimeout(this.scanTimeoutId) this.scanTimeoutId = null } if (this.isScanning) { try { await btClient.stopScanningAsync() this.log.debug('[BLE] stopped scanning.') } catch (err) { this.log.debug('[BLE] error stopping scan: %s.', err.message) } } } // Comprehensive shutdown for clean termination shutdown() { this.log('[BLE] shutting down.') this.isShuttingDown = true // Remove all event listeners first to prevent any more events try { if (this.eventHandlers.stateChange) { btClient.removeListener('stateChange', this.eventHandlers.stateChange) } if (this.eventHandlers.scanStart) { btClient.removeListener('scanStart', this.eventHandlers.scanStart) } if (this.eventHandlers.scanStop) { btClient.removeListener('scanStop', this.eventHandlers.scanStop) } if (this.eventHandlers.warning) { btClient.removeListener('warning', this.eventHandlers.warning) } if (this.eventHandlers.discover) { btClient.removeListener('discover', this.eventHandlers.discover) } // Clear all event handler references this.eventHandlers = {} } catch (err) { this.log('[BLE] error removing event listeners: %s.', err.message) } // Clear any timeouts if (this.scanTimeoutId) { clearTimeout(this.scanTimeoutId) this.scanTimeoutId = null } // Stop scanning immediately (synchronous) try { if (btClient) { btClient.stopScanning() } } catch (err) { this.log('[BLE] error stopping scan during shutdown: %s.', err.message) } // Clear all state this.discoverCallback = null this.isScanning = false this.isConnecting = false this.activeConnection = null // Reset adapter to disconnect any connections try { btClient.reset() } catch (err) { this.log('[BLE] error resetting adapter during shutdown: %s.', err.message) } // Try to remove all listeners from Noble completely try { if (btClient.removeAllListeners) { btClient.removeAllListeners() } } catch (err) { this.log('[BLE] error removing all listeners during shutdown: %s.', err.message) } this.log('[BLE] shutdown complete.') } }