homebridge-otgw
Version:
Homebridge plugin for OpenTherm Gateway
321 lines (300 loc) • 10.7 kB
JavaScript
// homebridge-otgw/lib/OtgwMessageParser.js
// Copyright © 2019-2026 Erik Baauw. All rights reserved.
//
// Homebridege plugin for OpenTherm Gateway.
import { EventEmitter } from 'node:events'
// Check parity of 32-bit OpenTherm message
function checkParity (n) {
let parity = 0
for (let bit = 0; bit < 32; bit++) {
if ((n & (1 << bit)) !== 0) {
parity = 1 - parity
}
}
return parity === 0
}
// OpenTherm message types.
const messageTypes = Object.freeze({
READ_DATA: 0,
WRITE_DATA: 1,
INVALID_DATA: 2,
READ_ACK: 4,
WRITE_ACK: 5,
DATA_INVALID: 6,
UNKNOWN_DATAID: 7
})
const messageTypeDescriptions = Object.freeze([
'Read-Data', 'Write-Data', 'Invalid-Data', null,
'Read-Ack', 'Write-Ack', 'Data-Invalid', 'Unknown-DataId'
])
// Conversion functions for OpenTherm Data Values
function bit (v, b) { return (v & (0x01 << b)) !== 0 }
function u8 (v) { return parseInt(v, 16) }
function u8hi (v) { return u8(v.slice(0, 2)) }
function u8lo (v) { return u8(v.slice(2, 4)) }
function s8 (v) {
v = parseInt(v, 16)
return (v & 0x80) === 0 ? v : v - 0x100
}
function s8hi (v) { return s8(v.slice(0, 2)) }
function s8lo (v) { return s8(v.slice(2, 4)) }
function u16 (v) { return parseInt(v, 16) }
function s16 (v) {
v = parseInt(v, 16)
return (v & 0x8000) === 0 ? v : v - 0x10000
}
function f88 (v) {
v = parseInt(v, 16)
return Math.round(((v & 0x8000) === 0 ? v : v - 0x10000) / 2.56) / 100
}
// OpenTherm v2.2 Data IDs.
const dataIds = Object.freeze({
// class 1 - Control and Status Information
'00': [
{ key: 'master_status_ch_enable', f: (v) => { return bit(u8hi(v), 0) } },
{ key: 'master_status_dhw_enable', f: (v) => { return bit(u8hi(v), 1) } },
// { key: 'master_status_cooling_enable', f: (v) => { return bit(u8hi(v), 2) } },
// { key: 'master_status_otc_active', f: (v) => { return bit(u8hi(v), 3) } },
// { key: 'master_status_ch2_enable', f: (v) => { return bit(u8hi(v), 4) } },
{ key: 'slave_status_fault', f: (v) => { return bit(u8lo(v), 0) } },
{ key: 'slave_status_ch_mode', f: (v) => { return bit(u8lo(v), 1) } },
{ key: 'slave_status_dhw_mode', f: (v) => { return bit(u8lo(v), 2) } },
{ key: 'slave_status_flame_status', f: (v) => { return bit(u8lo(v), 3) } },
// { key: 'slave_status_cooling_status', f: (v) => { return bit(u8lo(v), 4) } },
// { key: 'slave_status_ch2_mode', f: (v) => { return bit(u8lo(v), 5) } },
{ key: 'slave_status_diagnostic_indication', f: (v) => { return bit(u8lo(v), 6) } }
],
'01': [{ key: 'control_setpoint', f: f88 }],
'05': [
{ key: 'application_flags_service_request', f: (v) => { return bit(u8hi(v), 0) } },
{ key: 'application_flags_lockout_reset', f: (v) => { return bit(u8hi(v), 1) } },
{ key: 'application_flags_low_water_pressure', f: (v) => { return bit(u8hi(v), 2) } },
{ key: 'application_flags_flame_fault', f: (v) => { return bit(u8hi(v), 3) } },
{ key: 'application_flags_air_pressure_fault', f: (v) => { return bit(u8hi(v), 4) } },
{ key: 'application_flags_water_over_temperature', f: (v) => { return bit(u8hi(v), 5) } },
{ key: 'oem_fault_code', f: u8lo }
],
'08': [{ key: 'control_setpoint2', f: f88 }],
73: [{ key: 'oem_diagnostic_code', f: u16 }],
// class 2 - Configuration Information
'02': [{ key: 'master_memberid', f: u8lo }],
'03': [
{ key: 'slave_configuration_dwh_present', f: (v) => { return bit(u8hi(v), 0) } },
{ key: 'slave_configuration_control_type_onoff', f: (v) => { return bit(u8hi(v), 1) } },
{ key: 'slave_configuration_cooling_config', f: (v) => { return bit(u8hi(v), 2) } },
{ key: 'slave_configuration_dwh_config', f: (v) => { return bit(u8hi(v), 3) } },
{ key: 'slave_configuration_master_control_disallowed', f: (v) => { return bit(u8hi(v), 4) } },
{ key: 'slave_configuration_ch2_present', f: (v) => { return bit(u8hi(v), 5) } },
{ key: 'slave_memberid', f: u8lo }
],
'7C': [{ key: 'master_opentherm_version', f: f88 }],
'7D': [{ key: 'slave_opentherm_version', f: f88 }],
'7E': [
{ key: 'master_product_type', f: u8hi },
{ key: 'master_product_version', f: u8lo }
],
'7F': [
{ key: 'slave_product_type', f: u8hi },
{ key: 'slave_product_version', f: u8lo }
],
// class 3 - Remote Commands
'04': [
{ key: 'command_code', f: u8hi },
{ key: 'command_response_code', f: u8lo }
],
// class 4 - Sensor and Informational Data
10: [{ key: 'room_setpoint', f: f88 }],
11: [{ key: 'relative_modulation_level', f: f88 }],
12: [{ key: 'ch_water_pressure', f: f88 }],
13: [{ key: 'dhw_flow_rate', f: f88 }],
14: [
{ key: 'weekday', f: (v) => { return (u8hi(v) & 0xE0) >> 5 } },
{ key: 'hour', f: (v) => { return u8hi(v) & 0x1F } },
{ key: 'minute', f: u8lo }
],
15: [
{ key: 'month', f: u8hi },
{ key: 'day', f: u8lo }
],
16: [{ key: 'year', f: u16 }],
17: [{ key: 'room_setpoint2', f: f88 }],
18: [{ key: 'room_temperature', f: f88 }],
19: [{ key: 'boiler_water_temperature', f: f88 }],
'1A': [{ key: 'dhw_temperature', f: f88 }],
'1B': [{ key: 'outside_temperature', f: f88 }],
'1C': [{ key: 'return_water_temperature', f: f88 }],
'1D': [{ key: 'solar_storage_temperature', f: f88 }],
'1E': [{ key: 'solar_collector_temperature', f: s16 }],
'1F': [{ key: 'flow_temperature_ch2', f: f88 }],
20: [{ key: 'dwh2_temperature', f: f88 }],
21: [{ key: 'exhaust_temperature', f: s16 }],
74: [{ key: 'burner_starts', f: u16 }],
75: [{ key: 'ch_pump_starts', f: u16 }],
76: [{ key: 'dhw_pump_starts', f: u16 }],
77: [{ key: 'dhw_burner_starts', f: u16 }],
78: [{ key: 'burner_operation_hours', f: u16 }],
79: [{ key: 'ch_pump_operation_hours', f: u16 }],
'7A': [{ key: 'dhw_pump_operation_hours', f: u16 }],
'7B': [{ key: 'dhw_burner_operation_hours', f: u16 }],
// class 5 - Pre-Definied Remote Boiler Parameters
'06': [
{ key: 'remote_parameter_enable_dwh_setpoint', f: (v) => { return bit(u8hi(v), 0) } },
{ key: 'remote_parameter_enable_max_ch_setpoint', f: (v) => { return bit(u8hi(v), 1) } },
{ key: 'remote_parameter_write_dwh_setpoint', f: (v) => { return bit(u8lo(v), 0) } },
{ key: 'remote_parameter_write_max_ch_setpoint', f: (v) => { return bit(u8lo(v), 1) } }
],
30: [
{ key: 'dhw_setpoint_max', f: s8hi },
{ key: 'dhw_setpoint_min', f: s8lo }
],
31: [
{ key: 'max_ch_setpoint_max', f: s8hi },
{ key: 'max_ch_setpoint_min', f: s8lo }
],
38: [{ key: 'dhw_setpoint', f: f88 }],
39: [{ key: 'max_ch_setpoint', f: f88 }],
// Class 6 - Transparent Slave Parameters
'0A': [{ key: 'tsp_number', f: u8hi }],
'0B': [
{ key: 'tsp_index', f: u8hi },
{ key: 'tsp_value', f: u8lo }
],
// Class 7 - Fault History Data
'0C': [{ key: 'fault_buffer_size', f: u8hi }],
'0D': [
{ key: 'fault_index', f: u8hi },
{ key: 'fault_value', f: u8lo }
],
// Class 8 - Control o Special Applications
'07': [{ key: 'cooling_control', f: f88 }],
'0E': [{ key: 'max_relative_modulation_setting', f: f88 }],
'0F': [
{ key: 'max_boiler_capacity', f: u8hi },
{ key: 'min_modulation_level', f: u8lo }
],
'09': [{ key: 'room_setpoint_remote_override', f: f88 }],
64: [
{ key: 'remote_override_manual_change_priority', f: (v) => { return bit(u8lo(v), 0) } },
{ key: 'remote_override_programme_change_priority', f: (v) => { return bit(u8lo(v), 1) } }
]
})
// Conversion functions for summary message values.
function hex (n) {
return ('0000' + Number(n).toString(16).toUpperCase()).slice(-4)
}
function twoBytes (v) {
const a = v.split('/')
return hex((Number(a[0]) << 8) + Number(a[1]))
}
function twoBitFields (v) {
const a = v.split('/').map((v) => { return parseInt(v, 2) })
return hex((Number(a[0]) << 8) + Number(a[1]))
}
// Data IDs included in the summary message.
const summary = Object.freeze([
{ id: '00', f: twoBitFields },
{ id: '01' },
{ id: '06', f: twoBitFields },
{ id: '07' },
{ id: '08' },
{ id: '0E' },
{ id: '0F', f: twoBytes },
{ id: '10' },
{ id: '11' },
{ id: '12' },
{ id: '13' },
{ id: '17' },
{ id: '18' },
{ id: '19' },
{ id: '1A' },
{ id: '1B' },
{ id: '1C' },
{ id: '1F' },
{ id: '21' },
{ id: '30', f: twoBytes },
{ id: '31', f: twoBytes },
{ id: '38' },
{ id: '39' },
{ id: '46', f: twoBitFields },
{ id: '47' },
{ id: '4D' },
{ id: '74' },
{ id: '75' },
{ id: '76' },
{ id: '77' },
{ id: '78' },
{ id: '79' },
{ id: '7A' },
{ id: '7B' }
])
// Class to parse OpenTherm Gateway messages.
class OtgwMessageParser extends EventEmitter {
get messageTypes () { return messageTypes }
isOtMessage (s) {
if (typeof s !== 'string') {
return false
}
return /^[TBRA][0-9A-F]0[0-9A-F]{6}/.test(s)
}
isSummary (s) {
if (typeof s !== 'string') {
return false
}
return s.split(',').length === summary.length
}
parseOtMessage (s) {
if (typeof s !== 'string') {
throw new TypeError(`${s}: invalid string`)
}
const a = s.match(/^([TBRA])([0-9A-F]0[0-9A-F]{2}[0-9A-F]{4})/)
if (a == null) {
throw new RangeError(`${s}: invalid OpenTherm message`)
}
const message = a[0]
const origin = a[1]
if (!checkParity(parseInt(a[2], 16))) {
this.emit('error', `${message}: parity error`)
return null
}
const type = parseInt(a[2].slice(0, 1), 16) & 0x7
if (messageTypeDescriptions[type] == null) {
this.emit('error', `${message}: invalid OpenTherm message type`)
return null
}
if (
((origin === 'T' || origin === 'R') && (type & 0x04) !== 0) ||
((origin === 'B' || origin === 'A') && (type & 0x04) === 0)
) {
this.emit('error', `${a[0]}: origin / message type mismatch`)
return null
}
const id = a[2].slice(2, 4)
const value = a[2].slice(4, 8)
const body = {}
const definitions = dataIds[id] || []
for (const definition of definitions) {
body[definition.key] = definition.f(value)
}
return { message, origin, type, id, value, body }
}
parseSummary (s) {
if (typeof s !== 'string') {
throw new TypeError(`${s}: invalid string`)
}
const a = s.split(',')
if (a.length !== summary.length) {
throw new RangeError(`${s}: invalid summary message`)
}
const body = {}
for (const i in summary) {
const definitions = dataIds[summary[i].id] || []
for (const definition of definitions) {
body[definition.key] = summary[i].f == null
? Number(a[i])
: definition.f(summary[i].f(a[i]))
}
}
return body
}
}
export { OtgwMessageParser }