fp2-proxy
Version:
Proxy for Aqara Presence Sensor FP2
680 lines (601 loc) • 20.1 kB
JavaScript
// fp2-proxy/lib/Fp2Proxy.js
// Copyright © 2023-2025 Erik Baauw. All rights reserved.
//
// Proxy for Aqara Presence Sensor FP2.
import { readFile, watch, writeFile } from 'node:fs/promises'
import { CommandLineParser } from 'hb-lib-tools/CommandLineParser'
import { CommandLineTool } from 'hb-lib-tools/CommandLineTool'
import { JsonFormatter } from 'hb-lib-tools/JsonFormatter'
import { OptionParser } from 'hb-lib-tools/OptionParser'
import { ApiClient } from 'hb-deconz-tools/ApiClient'
import { Discovery } from 'hb-deconz-tools/Discovery'
import { Fp2Client } from '../lib/Fp2Client.js'
import { Fp2Discovery } from '../lib/Fp2Discovery.js'
const configFile = process.env.HOME + '/.fp2-proxy'
const fp2 = {
manufacturername: 'Aqara',
modelid: 'PS-S02D'
}
const { b, u } = CommandLineTool
const { UsageError } = CommandLineParser
const usage = {
'fp2-proxy': `${b('fp2-proxy')} [${b('-hVD')}] [${b('-H')} ${u('hostname')}[${b(':')}${u('port')}]] [${b('-K')} ${u('apiKey')}] [${b('-t')} ${u('timeout')}] [${b('-s')} | ${u('command')} [${u('argument')} ...]]`,
getApiKey: `${b('getApiKey')}`,
discover: `${b('disover')} [${b('-h')}]`,
identify: `${b('identify')} [${b('-h')}] ${u('id')}`,
pair: `${b('pair')} [${b('-h')}] ${u('id')} ${u('setupCode')}`,
info: `${b('info')} [${b('-h')}] ${u('id')}`,
unpair: `${b('unpair')} [${b('-h')}] ${u('id')}`
}
const description = {
'fp2-proxy': 'Proxy for Aqara Presence Sensor FP2.',
getApiKey: 'Obtain an API key for the deCONZ gateway.',
discover: 'Discover FP2 devices.',
identify: 'Identify FP2 device.',
pair: 'Add FP2 device.',
info: 'Get FP2 device information.',
unpair: 'Remove FP2 device.'
}
const help = {
'fp2-proxy': `${description['fp2-proxy']}
Usage: ${usage['fp2-proxy']}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.
${b('-V')}, ${b('--version')}
Print version and exit.
${b('-D')}, ${b('--debug')}
Print debug messages for communication with the gateway.
${b('-H')} ${u('hostname')}[${b(':')}${u('port')}], ${b('--host=')}${u('hostname')}[${b(':')}${u('port')}]
Connect to the deCONZ gateway at ${u('hostname')}${b(':80')} or ${u('hostname')}${b(':')}${u('port')} instead of the default ${b('localhost:80')}.
The hostname and port can also be specified by setting ${b('DECONZ_HOST')}.
${b('-K')} ${u('API key')}, ${b('--apiKey=')}${u('apiKey')}
Use ${u('API key')} to connect to deCONZ gateway, instead of the API key saved in ${b('~/.fp2-proxy')}.
The API key can also be specified by setting ${b('DECONZ_API_KEY')}.
${b('-s')}, ${b('--service')}
Do not output timestamps (useful when running as service).
${b('-t')} ${u('timeout')}, ${b('--timeout=')}${u('timeout')}
Set timeout to ${u('timeout')} seconds instead of default ${b(5)}.
Commands:
(none)
Proxy paired FP2 devices to deCONZ gateway.
${usage.discover}
${description.discover}
${usage.getApiKey}
${description.getApiKey}
${usage.discover}
${description.discover}
${usage.identify}
${description.identify}
${usage.pair}
${description.pair}
${usage.info}
${description.info}
${usage.unpair}
${description.unpair}
For more help, issue: ${b('fp2-proxy')} ${u('command')} ${b('-h')}`,
getApiKey: `${description['fp2-proxy']}
Usage: ${b('fp2-proxy')} ${usage.getApiKey}
${description.getApiKey}
The API key is stored in ${b('~/.fp2-proxy')}.
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.`,
discover: `${description['fp2-proxy']}
Usage: ${b('fp2-proxy')} ${usage.discover}
${description.discover}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.`,
identify: `${description['fp2-proxy']}
Usage: ${b('fp2-proxy')} ${usage.identify}
${description.identify}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.
${u('id')}
The ID of the FP2 device, as returned by ${b('fp2-proxy discover')},
typically "Presence-Sensor-FP2-xxxx".`,
pair: `${description['fp2-proxy']}
Usage: ${b('fp2-proxy')} ${usage.pair}
${description.pair}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.
${u('id')}
The ID of the FP2 device, as returned by ${b('fp2-proxy discover')},
typically "Presence-Sensor-FP2-xxxx".
${u('setupCode')}
The HomeKit setup code of the FP2 device.`,
info: `${description['fp2-proxy']}
Usage: ${b('fp2-proxy')} ${usage.info}
${description.info}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.
${u('id')}
The ID of the FP2 device, as returned by ${b('fp2-proxy discover')}.`,
unpair: `${description['fp2-proxy']}
Usage: ${b('fp2-proxy')} ${usage.unpair}
${description.unpair}
Parameters:
${b('-h')}, ${b('--help')}
Print this help and exit.
${u('id')}
The ID of the FP2 device, as returned by ${b('fp2-proxy discover')}.`
}
class Fp2Proxy extends CommandLineTool {
constructor (pkgJson) {
super({ mode: 'command', debug: false })
this.pkgJson = pkgJson
this.usage = usage['fp2-proxy']
}
async main () {
try {
this.clargs = this.parseArguments()
try {
await this.readConfig()
} catch (error) {
if (error.code !== 'ENOENT') {
this.error(error)
}
this.config = {}
}
if (this.clargs.command !== 'fp2-proxy') {
this.name = 'fp2-proxy ' + this.clargs.command
this.usage = `${b('fp2-proxy')} ${usage[this.clargs.command]}`
}
await this[this.clargs.command](this.clargs.args)
} catch (error) {
if (error.request == null) {
this.error(error)
}
}
}
async proxy () {
for (const id in this.config) {
try {
if (id.startsWith('00212E')) {
const sensors = await this.deconzClient.get('/sensors')
for (const id in sensors) {
const sensor = sensors[id]
if (sensor.manufacturername === fp2.manufacturername && sensor.modelid === fp2.modelid) {
this.log('/sensors/%d: %s %s', id, sensor.uniqueid, sensor.type)
}
}
continue
}
const fp2Client = await this.createFp2Client(id)
await fp2Client.subscribe()
this.fp2clients[id] = fp2Client
// await timeout(1000)
} catch (error) {
this.warn('%s: %s', id, error)
}
}
}
async 'fp2-proxy' (...args) {
const parser = new CommandLineParser(this.pkgJson)
let mode = 'daemon'
// let mode = 'service'
const options = {}
parser
.help('h', 'help', help['fp2-proxy'])
.flag('n', 'noRetry', () => { options.retryTime = 0 })
.flag('s', 'service', () => { mode = 'service' })
.parse(...args)
this.jsonFormatter = new JsonFormatter(
mode === 'service' ? { noWhiteSpace: true } : {}
)
this.setOptions({ mode })
this.createDeconzDiscovery()
try {
this.gatewayConfig = await this.deconzDiscovery.config(
this.clargs.options.host
)
} catch (error) {
if (error.request == null) {
await this.fatal('%s: %s', this.clargs.options.host, error)
}
await this.fatal('%s: deCONZ gateway not found', this.clargs.options.host)
}
await this.createDeconzClient()
this.configController = new AbortController()
this.fp2clients = {}
await this.proxy()
try {
const watcher = watch(configFile, { signal: this.configController.signal })
for await (const event of watcher) {
this.vdebug('%s: %s event', configFile, event.eventType)
if (this.configTimer != null) {
continue
}
// Writing the file seems to generate two events (on open and on close?),
// and reading it while it's still open for writing returns null.
this.configTimer = setTimeout(async () => {
delete this.configTimer
try {
this.readConfig()
await this.proxy()
} catch (error) {
this.error(error)
}
}, 1000)
}
} catch (error) {
if (error.name === 'AbortError') {
return
}
throw error
}
}
async destroy () {
if (this.configController != null) {
await this.configController.abort()
}
for (const id in this.fp2Clients) {
this.fp2Clients[id].disconect()
}
}
async getApiKey (...args) {
const parser = new CommandLineParser(this.pkgJson)
const jsonFormatter = new JsonFormatter(
{ noWhiteSpace: true, sortKeys: true }
)
parser
.help('h', 'help', help.getApiKey)
.parse(...args)
const apiKey = await this.deconzClient.getApiKey('fp2-proxy')
this.print(jsonFormatter.stringify(apiKey))
this.config[this.bridgeid] = { apiKey }
if (this.deconzClient.fingerprint != null) {
this.config[this.bridgeid].fingerprint = this.deconzClient.fingerprint
}
await this.writeConfig()
}
async discover (...args) {
const parser = new CommandLineParser(this.pkgJson)
parser
.help('h', 'help', help.discover)
.parse(...args)
await this.createFp2Discovery()
const fp2s = {}
this.fp2Discovery.on('alive', (service) => {
// Note: service.id is the fake mac address from the HAP advertisement,
// not the WiFI MAC address.
fp2s[service.id] = {
host: service.address + ':' + service.port,
modelid: service.md,
name: service.name,
pairable: service.availableToPair,
paired: this.config[service.id]?.pairData != null
}
})
await this.fp2Discovery.search()
const jsonFormatter = new JsonFormatter()
this.print(jsonFormatter.stringify(fp2s))
}
async identify (...args) {
let id
const parser = new CommandLineParser(this.pkgJson)
parser
.help('h', 'help', help.identify)
.parameter('fp2', (value) => {
id = this.checkId(value)
})
.parse(...args)
const fp2Client = await this.createFp2Client(id)
try {
await fp2Client.identify()
} finally {
await fp2Client.disconnect()
}
}
async pair (...args) {
let id, setupCode
const parser = new CommandLineParser(this.pkgJson)
parser
.help('h', 'help', help.pair)
.parameter('fp2', (value) => {
id = this.checkId(value)
})
.parameter('setupCode', (value) => {
setupCode = OptionParser.toString('setupCode', value, true, true)
})
.parse(...args)
const fp2Client = await this.createFp2Client(id)
const pairData = await fp2Client.pair(setupCode)
// const accessories = await fp2Client.accessories()
// id = await fp2Client.getId()
this.config[id] = { service: fp2Client.service, pairData }
await this.writeConfig()
}
async unpair (...args) {
let id
const parser = new CommandLineParser(this.pkgJson)
parser
.help('h', 'help', help.unpair)
.parameter('fp2', (value) => {
id = OptionParser.toString('fp2', value, true, true)
})
.parse(...args)
try {
const fp2Client = await this.createFp2Client(id)
await fp2Client.unpair()
} catch (error) {
this.warn('%s: %s', id, error)
}
delete this.config[id]
await this.writeConfig()
}
async info (...args) {
let id
const parser = new CommandLineParser(this.pkgJson)
parser
.help('h', 'help', help.pair)
.parameter('fp2', (value) => {
id = this.checkId(value)
})
.parse(...args)
const fp2Client = await this.createFp2Client(id)
const a = await fp2Client.accessories()
fp2Client.disconnect()
const jsonFormatter = new JsonFormatter()
this.print(jsonFormatter.stringify(a))
}
async readConfig () {
this.debug('%s: read', configFile)
const text = await readFile(configFile)
this.vdebug('%s: read: %s', configFile, text)
try {
this.config = JSON.parse(text)
} catch (error) {
this.warn('%s: file corrupted', configFile)
this.config = {}
}
}
async writeConfig () {
const jsonFormatter = new JsonFormatter(
{ noWhiteSpace: true, sortKeys: true }
)
this.vdebug('%s: write: %j', configFile, this.config)
const text = jsonFormatter.stringify(this.config)
await writeFile(configFile, text, { mode: 0o600 })
this.debug('%s: write done', configFile)
}
parseArguments () {
const parser = new CommandLineParser(this.pkgJson)
const clargs = {
command: 'fp2-proxy',
options: {
host: process.env.DECONZ_HOST || 'localhost',
timeout: 5
}
}
parser
.help('h', 'help', help['fp2-proxy'])
.version('V', 'version')
.option('H', 'host', (value) => {
OptionParser.toHost('host', value, false, true)
clargs.options.host = value
})
.option('K', 'apiKey', (value) => {
clargs.options.apiKey = OptionParser.toString(
'apiKey', value, true, true
)
})
.flag('D', 'debug', () => {
if (this.debugEnabled) {
this.setOptions({ vdebug: true })
} else {
this.setOptions({ debug: true, chalk: true })
}
})
.option('t', 'timeout', (value) => {
clargs.options.timeout = OptionParser.toInt(
'timeout', value, 1, 60, true
)
})
.parameter('command', (value) => {
if (usage[value] == null || typeof this[value] !== 'function') {
throw new UsageError(`${value}: unknown command`)
}
clargs.command = value
}, true)
.remaining((list) => { clargs.args = list })
parser
.parse()
return clargs
}
checkId (id) {
if (id == null || id === '') {
throw new UsageError('missing FP2')
} else if (!OptionParser.patterns.mac.test(id)) {
throw new UsageError(`${id}: invalid FP2`)
}
return id.toUpperCase()
}
createFp2Discovery () {
if (this.fp2Discovery != null) {
return
}
this.fp2Discovery = new Fp2Discovery({ timeout: this.clargs.options.timeout })
this.fp2Discovery
.on('alive', (service) => {
this.debug(
'mDNS: found %s [%s] at %s:%d',
service.name, service.id, service.address, service.port
)
})
.on('search', (id) => {
this.debug('mDNS: searching%s...', id == null ? '' : ' for ' + id)
})
.on('searchDone', () => {
this.debug('mDNS: search done')
})
}
async createFp2Client (id) {
if (id == null || id === '') {
throw new UsageError('missing FP2')
} else if (!OptionParser.patterns.mac.test(id)) {
throw new UsageError(`${id}: invalid FP2`)
}
id = id.toUpperCase()
this.createFp2Discovery()
const params = {
service: await this.fp2Discovery.find(id),
timeout: this.clargs.options.timeout
}
if (this.config[id]?.pairData != null) {
params.pairData = this.config[id]?.pairData
}
const fp2Client = new Fp2Client(params)
fp2Client
.on('event', (event) => { this.debug('%s: %j', id, event) })
.on('identify', () => { this.debug('%s: identify', id) })
.on('get', (list) => { this.debug('%s: get %j', id, list) })
.on('put', (body) => { this.debug('%s: put %j', id, body) })
.on('subscribe', (list) => { this.debug('%s: subscribe %j', id, list) })
return fp2Client
}
createDeconzDiscovery () {
this.deconzDiscovery = new Discovery({ timeout: this.clargs.options.timeout })
this.deconzDiscovery
.on('error', (error) => {
if (error.request != null) {
this.log(
'%s: request %d: %s %s', error.request.name,
error.request.id, error.request.method, error.request.resource
)
this.warn(
'%s: request %d: %s', error.request.name, error.request.id, error
)
return
}
this.warn(error)
})
.on('request', (request) => {
this.debug(
'%s: request %d: %s %s', request.name,
request.id, request.method, request.resource
)
this.vdebug(
'%s: request %d: %s %s', request.name,
request.id, request.method, request.url
)
})
.on('response', (response) => {
this.vdebug(
'%s: request %d: response: %j', response.request.name,
response.request.id, response.body
)
this.debug(
'%s: request %d: %d %s', response.request.name,
response.request.id, response.statusCode, response.statusMessage
)
})
.on('found', (name, id, address) => {
this.debug('%s: found %s at %s', name, id, address)
})
.on('searching', (host) => {
this.debug('upnp: listening on %s', host)
})
.on('searchDone', () => { this.debug('upnp: search done') })
}
async createDeconzClient () {
const { bridgeid, name } = this.gatewayConfig
if (this.clargs.options.apiKey == null) {
if (this.config[bridgeid]?.apiKey != null) {
this.clargs.options.apiKey = this.config[bridgeid].apiKey
} else if (process.env.DECONZ_API_KEY != null) {
this.clargs.options.apiKey = process.env.DECONZ_API_KEY
}
}
if (this.clargs.options.apiKey == null && this.clargs.command !== 'getApiKey') {
let args = ''
if (
this.clargs.options.host !== 'localhost' &&
this.clargs.options.host !== process.env.DECONZ_HOST
) {
args += ' -H ' + this.clargs.options.host
}
await this.fatal(
'missing API key - unlock gateway and run "fp2-proxy%s getApiKey"', args
)
}
this.deconzClient = new ApiClient(this.clargs.options)
this.deconzClient
.on('error', (error) => {
if (error.request.id !== this.requestId) {
if (error.request.body == null) {
this.log(
'%s: request %d: %s %s', name, error.request.id,
error.request.method, error.request.resource
)
} else {
this.log(
'%s: request %d: %s %s %s', name, error.request.id,
error.request.method, error.request.resource, error.request.body
)
}
this.requestId = error.request.id
}
if (error.nonCritical) {
this.warn('%s: request %d: %s', name, error.request.id, error)
} else {
this.error('%s: request %d: %s', name, error.request.id, error)
}
})
.on('request', (request) => {
if (request.body == null) {
this.debug(
'%s: request %d: %s %s', name, request.id, request.method, request.resource
)
this.vdebug(
'%s: request %d: %s %s', name, request.id, request.method, request.url
)
} else {
this.debug(
'%s: request %d: %s %s %s', name, request.id,
request.method, request.resource, request.body
)
this.vdebug(
'%s: request %d: %s %s %s', name, request.id,
request.method, request.url, request.body
)
}
})
.on('response', (response) => {
this.vdebug(
'%s: request %d: response: %j', name, response.request.id, response.body
)
this.debug(
'%s: request %d: %d %s', name, response.request.id,
response.statusCode, response.statusMessage
)
})
}
}
export { Fp2Proxy }
// const manufacturername = 'Aqara'
// const modelid = 'PS-S02D'
// const swversion = '1.1.6_0005'
// const mac = '54:ef:44:ff:fe:4a:85:0f'
// const name = 'Dining Room Presence'
// const presenceId = await client.post('/sensors', {
// type: 'CLIPPresence',
// manufacturername,
// modelid,
// swversion,
// uniqueid: [mac, '01', '0406'].join('-'),
// name
// })
// const lightLevelId = await client.post('/sensors', {
// type: 'CLIPLightLevel',
// manufacturername,
// modelid,
// swversion,
// uniqueid: [mac, '01', '0400'].join('-'),
// name
// })
// console.log('presence: %d, lightlevel: %d', presenceId, lightLevelId)