UNPKG

homebridge-rpi

Version:
442 lines (431 loc) 14 kB
// homebridge-rpi/lib/RpiPlatform.js // Copyright © 2019-2026 Erik Baauw. All rights reserved. // // Homebridge plugin for Raspberry Pi. import { hostname } from 'node:os' import { timeout, toHexString } from 'homebridge-lib' import { OptionParser } from 'homebridge-lib/OptionParser' import { Platform } from 'homebridge-lib/Platform' import { SystemInfo } from 'homebridge-lib/SystemInfo' import { GpioClient } from 'hb-rpi-tools/GpioClient' import { RpiAccessory } from './RpiAccessory.js' class RpiPlatform extends Platform { constructor (log, configJson, homebridge) { super(log, configJson, homebridge) this.once('heartbeat', async (beat) => { await this.init(beat) }) this.config = { hosts: [ { host: '127.0.0.1' } ], resetTimeout: 500, timeout: 15 } const optionParser = new OptionParser(this.config, true) optionParser .stringKey('name') .stringKey('platform') .intKey('timeout', 1, 60) .arrayKey('hosts') .on('userInputError', (error) => { this.warn('config.json: %s', error) }) try { optionParser.parse(configJson) this.rpiAccessories = {} this.gpioButtonAccessories = {} this.pigpioClients = {} const validHosts = [] for (const i in this.config.hosts) { const host = this.config.hosts[i] const config = { port: 8889, user: process.env.LG_USER || 'homebridge-rpi', password: process.env.LG_PASS || '' } const optionParser = new OptionParser(config, true) optionParser .stringKey('name') .hostKey() .stringKey('user') .stringKey('password') .boolKey('hidden') .boolKey('noFan') .boolKey('noPowerLed') .boolKey('noSmokeSensor') .boolKey('usbPower') .arrayKey('devices') .on('userInputError', (error) => { this.warn('config.json: hosts[%d]: %s', i, error) }) optionParser.parse(host) if (config.hostname == null || config.port == null) { continue } if (config.name == null) { config.name = config.hostname === 'localhost' ? hostname().split('.')[0] : config.hostname } validHosts.push(config) const validDevices = [] for (const j in config.devices) { const device = config.devices[j] const result = {} const parser = new OptionParser(result, true) const mandatoryKeys = [] parser .stringKey('device') .stringKey('name') .on('userInputError', (error) => { this.warn('config.json: hosts[%d]: devices[%d]: %s', i, j, error) }) switch (device.device) { // Input devices. case 'button': result.doublePressTimeout = 500 // ms result.longPressTimeout = 1000 // ms parser.intKey('doublePressTimeout', 0, 1000) parser.intKey('longPressTimeout', 0, 2000) /* falls through */ case 'carbonmonoxide': case 'contact': case 'doorbell': case 'leak': case 'motion': case 'smoke': parser.boolKey('reversed') /* falls through */ case 'rocker': result.debounceTimeout = 20 // ms parser.intKey('debounceTimeout', 0, 300) result.pull = 'up' parser.enumKey('pull') parser.enumKeyValue('pull', 'off') parser.enumKeyValue('pull', 'down') parser.enumKeyValue('pull', 'up') mandatoryKeys.push('gpio') parser.intKey('gpio', 0, 31) break // Output devices. case 'blinkt': result.gpioClock = 24 result.gpioData = 23 result.nLeds = 8 /* falls through */ case 'p9813': if (device.device === 'p9813') { mandatoryKeys.push('gpioClock') mandatoryKeys.push('gpioData') result.nLeds = 1 } parser .intKey('gpioClock', 0, 31) .intKey('gpioData', 0, 31) .intKey('nLeds', 1, 8) break case 'switch': parser.boolKey('duration') /* falls through */ case 'lock': parser.intKey('pulse', 20, 5000) /* falls through */ case 'fan': case 'garage': case 'light': case 'valve': parser.boolKey('reversed') /* falls through */ case 'dht': case 'servo': mandatoryKeys.push('gpio') parser.intKey('gpio', 0, 31) break case 'fanshim': break default: this.warn( 'config.json: hosts[%d]: devices[%d]: device: invalid value', i, j ) continue } parser.parse(device) if (result.device === 'fanshim') { validDevices.push({ device: 'blinkt', name: 'FanShim LED', gpioClock: 14, gpioData: 15, nLeds: 1 }) validDevices.push({ device: 'button', name: 'FanShim Button', gpio: 17, pull: 'up', debounceTimeout: 20, doublePressTimeout: 500, longPressTimeout: 1000 }) validDevices.push({ device: 'switch', name: 'FanShim Fan', gpio: 18 }) continue } for (const key of mandatoryKeys) { if (result[key] == null) { this.warn( 'config.json: hosts[%d]: devices[%d]: %s: key missing', i, j, key ) continue } } if (result.name == null) { result.name = result.device[0].toUpperCase() + result.device.slice(1) } if (result.duration != null && result.pulse != null) { this.warn( 'config.json: hosts[%d]: devices[%d]: cannot specify both duration and pulse', i, j ) } validDevices.push(result) } config.devices = validDevices } this.config.hosts = validHosts if (this.config.hosts.length === 0) { this.config.hosts.push({ host: 'localhost', name: hostname().split('.')[0] }) } } catch (error) { this.fatal(error) } this.debug('config: %j', this.config) } async init (beat) { const jobs = [] for (const host of this.config.hosts) { jobs.push(this.checkDevice(host)) } for (const job of jobs) { await job } this.debug('initialised') this.emit('initialised') } async checkDevice (host) { this.debug('check %s at %s:%d', host.name, host.hostname, host.port) let pi if (host.port === 8888) { if (GpioClient.Pigpio == null) { await import('hb-rpi-tools/GpioClient/Pigpio') } pi = new GpioClient.Pigpio({ host: host.hostname + ':' + host.port, timeout: this.config.timeout }) } else { if (GpioClient.Rgpio == null) { await import('hb-rpi-tools/GpioClient/Rgpio') } pi = new GpioClient.Rgpio({ host: host.hostname + ':' + host.port, user: host.user, password: host.password, timeout: this.config.timeout }) } pi .on('error', (error) => { this.warn('%s: %s', host.name, error) }) .on('warning', (error) => { this.warn('%s: %s', host.name, error) }) .on('connect', (hostname, port) => { this.log('%s: connected to %s:%s', host.name, hostname, port) }) .on('disconnect', (hostname, port) => { this.log('%s: disconnected from %s:%s', host.name, hostname, port) }) .on('command', (cmd, params) => { this.debug( '%s: %s %j', host.name, pi.commandName(cmd), params ) }) .on('response', (cmd, result) => { this.debug( '%s: %s => %s', host.name, pi.commandName(cmd), result ) }) .on('send', (data) => { this.vdebug('%s: send: %j', host.name, toHexString(data)) }) .on('data', (data) => { this.vdebug('%s: recv: %j', host.name, toHexString(data)) }) .on('message', (message) => { this.debug('%s: %s', host.name, message) }) .on('listen', (map) => { this.debug('%s: listen map: [%s]', host.name, pi.vmap(map)) }) .on('notification', (payload) => { this.vdebug( '%s: gpio map: [%s]%s%s%s', host.name, pi.vmap(payload.map), payload.tick == null ? '' : ', tick: ' + payload.tick, payload.flags == null ? '' : ', flags: 0x' + toHexString(payload.flags, 2), payload.seqno == null ? '' : ', seqno: ' + payload.seqno ) }) let cpuInfo if (host.hostname === 'localhost' || host.hostname === '127.0.0.1') { if (!this.systemInfo.hwInfo.isRpi) { this.warn('localhost: not a Rapsberry Pi') return } try { const sched = await this.systemInfo.readTextFile('/proc/1/sched') if (!sched.startsWith('systemd (1, #threads: 1)')) { this.warn('localhost: runnning inside a container') return } } catch (error) {} try { await this.systemInfo.exec('vcgencmd', 'measure_temp') } catch (error) { this.warn('localhost: %s', error) return } this.localId = this.systemInfo.hwInfo.id cpuInfo = this.systemInfo.hwInfo if (host.devices.length > 0) { try { await pi.connect() } catch (error) { this.warn('%s: %s', host.name, error) } } } else { try { const text = await pi.readFile('/proc/cpuinfo') cpuInfo = SystemInfo.parseRpiCpuInfo(text) } catch (error) { this.warn('%s: %s', host.name, error) await pi.disconnect() await timeout(15000) return this.checkDevice(host) } } if (this.pigpioClients[cpuInfo.id] != null) { // Already found under another hostname. await pi.disconnect() return } this.pigpioClients[cpuInfo.id] = pi if (this.rpiAccessories[cpuInfo.id] == null) { this.log( '%s: %s - %s', host.name, cpuInfo.prettyName, cpuInfo.id ) if (cpuInfo.id === this.localId && host.name !== 'localhost') { this.log('%s: localhost', host.name) } if (host.usbPower && !cpuInfo.supportsUsbPower) { this.warn( '%s: Raspberry Pi %s: no USB power support', host.name, cpuInfo.model ) host.usbPower = false } if (!cpuInfo.supportsPowerLed) { host.noPowerLed = true } if (!cpuInfo.supportsFan) { host.noFan = true } const rpiAccessory = new RpiAccessory(this, { name: host.name, id: cpuInfo.id, manufacturer: cpuInfo.manufacturer, model: 'Raspberry Pi ' + cpuInfo.model, // firmware: rpi.revision, hardware: cpuInfo.revision, category: this.Accessory.Categories.Other, pi, gpioMask: cpuInfo.gpioMask, hidden: host.hidden, noFan: host.noFan, noPowerLed: host.noPowerLed, noSmokeSensor: host.noSmokeSensor, usbPower: host.usbPower }) this.rpiAccessories[cpuInfo.id] = rpiAccessory for (const device of host.devices) { try { switch (device.device) { case 'blinkt': case 'p9813': await rpiAccessory.addLedChain(device) break case 'button': await rpiAccessory.addButton(device) break case 'carbonmonoxide': await rpiAccessory.addCarbonMonoxide(device) break case 'contact': await rpiAccessory.addContact(device) break case 'dht': await rpiAccessory.addDht(device) break case 'doorbell': await rpiAccessory.addDoorBell(device) break case 'fan': await rpiAccessory.addFan(device) break case 'garage': await rpiAccessory.addGarage(device) break case 'leak': await rpiAccessory.addLeak(device) break case 'light': await rpiAccessory.addLight(device) break case 'lock': await rpiAccessory.addLock(device) break case 'motion': await rpiAccessory.addMotion(device) break case 'rocker': await rpiAccessory.addRocker(device) break case 'servo': await rpiAccessory.addServo(device) break case 'smoke': await rpiAccessory.addSmoke(device) break case 'switch': await rpiAccessory.addSwitch(device) break case 'valve': await rpiAccessory.addValve(device) break } } catch (error) { this.warn('ignoring %s %s: %s', host.name, device.name, error) } } await rpiAccessory.init() } } } export { RpiPlatform }