UNPKG

homebridge-otgw

Version:

Homebridge plugin for OpenTherm Gateway

343 lines (324 loc) 10.7 kB
// homebridge-otgw/lib/OtgwPlatform.js // Copyright © 2019-2026 Erik Baauw. All rights reserved. // // Homebridege plugin for OpenTherm Gateway. import { once } from 'node:events' import { createRequire } from 'node:module' import { OptionParser } from 'homebridge-lib/OptionParser' import { Platform } from 'homebridge-lib/Platform' import { OtgwAccessory } from './OtgwAccessory.js' import { OtgwClient } from './OtgwClient.js' import { OtmClient } from './OtmClient.js' import { OtgwMessageParser } from './OtgwMessageParser.js' const require = createRequire(import.meta.url) const packageJson = require('../package.json') class OtgwPlatform extends Platform { constructor (log, configJson, homebridge) { super(log, configJson, homebridge) this.config = { name: 'OTGW', hostname: 'localhost', port: 8080 } const optionParser = new OptionParser(this.config, true) optionParser .hostKey() .hostKey('otgw', 'otgwHostname', 'otgwPort') .stringKey('name') .stringKey('platform') .on('userInputError', (message) => { this.warn('config.json: %s', message) }) try { optionParser.parse(configJson) } catch (error) { this.fatal(error) } // Parser for OTGW messages. this.parser = new OtgwMessageParser() this.parser.on('error', (error) => { this.warn(error) }) // Client for OpenTherm Gateway NodeMCU serial server. if (this.config.otgwHostname != null && this.config.otgwPort != null) { const otgwClientOptions = { hostname: this.config.otgwHostname, port: this.config.otgwPort } this.otgwClient = new OtgwClient(otgwClientOptions) this.otgwClient .on('error', (error) => { this.warn(error) }) .on('data', (data) => { this.vdebug('data: %j', data) }) .on('message', this.onMessage.bind(this)) .on('command', (command, retry) => { this.debug('send command: %s%s', command, retry > 0 ? ' [' + retry + ']' : '') }) .on('response', (command, response) => { this.debug('send command: %s, response: %s', command, response) }) .on('close', () => { if (this.client != null) { this.log('connection to OpenTherm Gateway closed') delete this.client } }) } // Client for OpenTherm monitor. const otmClientOptions = { hostname: this.config.hostname, port: this.config.port } this.otmClient = new OtmClient(otmClientOptions) this.otmClient .on('error', (error) => { if (error.request != null) { this.log( 'request %d: %s %s', error.request.id, error.request.method, error.request.resouce ) this.warn( 'request %d: %s', error.request.id, error ) return } this.warn(error) }) .on('message', this.onMessage.bind(this)) .on('command', (command, retry) => { this.debug('send command: %s%s', command, retry > 0 ? ' [' + retry + ']' : '') }) .on('commandResponse', (command, response) => { this.debug('send command: %s, response: %s', command, response) }) .on('request', (request) => { this.debug( 'request %d: %s %s', request.id, request.method, request.resource ) this.vdebug( 'request %d: %s %s', request.id, request.method, request.url ) }) .on('response', (response) => { this.debug( 'request %d: %d %s', response.request.id, response.statusCode, response.statusMessage ) this.vdebug( 'request %d: response: %j', response.request.id, response.body ) }) .on('close', () => { if (this.client != null) { this.log('connection to OpenTherm Monitor closed') delete this.client } }) this.otgwAccessories = {} this.on('body', (message, body) => { this.debug('%s: %j', message, body) for (const service in this.otgwAccessories) { this.otgwAccessories[service].checkState(body, true) } }) this._connectBeat = 0 this.on('heartbeat', async (beat) => { await this.heartbeat(beat) }) } async heartbeat (beat) { if (this.client == null) { if (this._connectBeat == null) { this._connectBeat = beat + 15 } else if (beat === this._connectBeat) { delete this._connectBeat await this.connect() } } else if (this._summary != null) { if (this.loggingActive > 0) { this.loggingActive-- } else if (!this.inHeartbeat) { this.inHeartbeat = true this.warn('logging seems to be disabled - re-enabling...') try { await this.client.command('PS=0') // Resume logging } catch (error) { this.warn(error) } this.inHeartbeat = false } } } async shutdown () { this.client?.close() } async init () { if (this.client == null) { return } try { if (this.model == null && this.version == null) { const response = await this.client.command('PR=A') // Print Report const idString = response.split('=')[1] this.log(idString) const a = idString.split(' ') this.version = a.pop() this.model = a.join(' ') if (this.version !== packageJson.engines.otgw) { this.warn( 'recommended version: OpenTherm Gateway v%s', packageJson.engines.otgw ) } } const state = await this.summary() for (const accessory of ['Thermostat', 'Boiler', 'HotWater', 'OutsideTemperature']) { const context = { name: accessory, model: this.model, version: this.version, state } const otgwAccessory = new OtgwAccessory(this, context) this.otgwAccessories[accessory] = otgwAccessory this.otgwAccessories[accessory].checkState(state) } } catch (error) { this.warn(error) return } this._state = 'T' this.debug('initialised') this.emit('initialised') } async connect () { try { this.log('connecting to OpenTherm Monitor...') const host = await this.otmClient.connect() this.log('connected to OpenTherm Monitor at %s', host) this.client = this.otmClient return await this.init() } catch (error) { this.warn(error) } if (this.config.otgwHostname == null || this.config.otgwPort == null) { return } try { this.log('connecting to OpenTherm Gateway...') const host = await this.otgwClient.connect() this.log('connected to OpenTherm Gateway at %s', host) this.client = this.otgwClient return await this.init() } catch (error) { this.warn(error) } } async summary () { delete this._summary await this.client.command('PS=1') // Print Summary if (this._summary == null) { await once(this, 'summary') } await this.client.command('PS=0') // Resume logging return this._summary } async priorityMessage (id) { // todo: set timeout if (this._idP != null) { return Promise.reject(new Error('other priority message pending')) } this._idP = ('00' + id.toString(16)).slice(-2) await this.client.command('PM=' + id) // Priority Message const a = await once(this, 'priority') const body = a[1] delete this._idP return body } onMessage (message) { this.vdebug('message: %s', message) if (this.parser.isOtMessage(message)) { this.loggingActive = 5 const m = this.parser.parseOtMessage(message) if (m == null) { this.warn('%s: ignore invalid OpenTherm message', message) return } if (m.origin === 'T') { // Request by thermostat. if (this._state !== 'T' && this._state !== 'TA') { this.warn('%s: out of sequence OpenTherm message - reset sequence', m.message) } if (m.type === this.parser.messageTypes.WRITE_DATA) { this.emit('body', m.message, m.body) } this._idT = m.id this._typeT = m.type this._state = 'RB' return } if (m.origin === 'R' && this._state === 'RB') { // Subsibute request by OTGW (as master) to boiler. this._idR = m.id this._typeR = m.type this._state = 'B' return } if (m.origin === 'B' && this._state === 'RB' && m.id === this._idT) { // Response from boiler to T. if ( m.type === this.parser.messageTypes.DATA_INVALID || m.type === this.parser.messageTypes.UNKNOWN_DATAID ) { this._state = 'TA' return } if (m.type === this.parser.messageTypes.READ_ACK) { this.emit('body', m.message, m.body) } this._state = 'T' return } if (m.origin === 'B' && this._state === 'B' && m.id === this._idR) { // Response from boiler to R. if (m.id === this._idP) { this.debug('priority: %s: %j', m.message, m.body) this.emit('priority', m.message, m.body) } if (m.type === (this._typeR | 0x04)) { this.emit('body', m.message, m.body) } this._state = 'A' return } if ( m.origin === 'A' && (this._state === 'A' || this._state === 'TA') && m.id === this._idT ) { // Subsitute response from OTGW to T. if (m.type === this.parser.messageTypes.READ_ACK && m.id !== this._idR) { this.emit('body', m.message, m.body) } this._state = 'T' return } this.warn('%s: ignore out of sequence OpenTherm message', m.message) this._state = 'T' } else if (this.parser.isSummary(message)) { const body = this.parser.parseSummary(message) if (body == null) { this.warn('%s: ignore invalid summary message', message) } else { this._summary = body this.debug('summary: %j', body) this.emit('summary', body) } } else if (/^Command(?: \(.*\))?: /.test(message)) { // this.debug('command: %s', message.split(': ')[1]) } else if (/^[A-Z]{2}: /.test(message)) { // this.debug('response: %s', message) } else if (/^Error 0[1-4]/.test(message)) { this.warn(message) } else if (/^.* power/.test(message)) { // this.debug(message) } else { this.warn('ignore unknown message %s', message) } } } export { OtgwPlatform }