homebridge-rpi
Version:
Homebridge plugin for Raspberry Pi
442 lines (431 loc) • 14 kB
JavaScript
// 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 }