homebridge-p1
Version:
Homebridge plugin for DMSR end-consumer (P1) interface
691 lines (646 loc) • 22.6 kB
JavaScript
// homebridge-p1/lib/P1Client.js
// Copyright © 2018-2025 Erik Baauw. All rights reserved.
//
// Homebridge plugin for DSMR end-consumer (P1) interface.
import { EventEmitter } from 'node:events'
import { createConnection } from 'node:net'
import { SerialPort, ReadlineParser } from 'serialport'
import { toHexString } from 'homebridge-lib'
import { OptionParser } from 'homebridge-lib/OptionParser'
// ===== Telegram Parsing ======================================================
function parseBreaker (value) {
value = parseValue(value)
switch (value) {
case 0:
return 'disconnected'
case 1:
return 'connected'
case 2:
return 'ready'
default:
throw new Error(`${value}: unknown breaker value`)
}
}
function parseVersion (value) {
const a = value.match(/^\((\d)(\d)(?:\d){0,3}\)$/)
return a[1] + '.' + a[2]
}
function parseTimestamp (value) {
const a = value.match(/^\((\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(S|W)?\)$/)
// const tz = a[7] === 'S' ? 2 : 1
return `20${a[1]}-${a[2]}-${a[3]}T${a[4]}:${a[5]}:${a[6]}`
// return `20${a[1]}-${a[2]}-${a[3]}T${a[4]}:${a[5]}:${a[6]}+0${tz}:00`
}
function parseString (value) {
const a = value.match(/\d\d/g)
return a == null
? ''
: a.reduce((s, c) => {
return s + String.fromCharCode(parseInt(c, 16))
}, '')
}
function parseValue (value) {
const a = value.match(/^\((\d*(?:\.\d+)?)(?:\*(.+))?\)$/)
switch (a[2]) {
case undefined:
case 's':
return parseInt(a[1])
case 'kWh':
case 'V':
case 'A':
case 'm3':
return parseFloat(a[1])
case 'kW':
return Math.round(parseFloat(a[1]) * 1000)
default:
throw new Error(`${a[2]}: unknown unit`)
}
}
function parseType (value) {
const type = parseValue(value)
switch (type) {
case 3: return 'gas'
case 7: return 'water'
default: return 'd' + type
}
}
function parseLog (values) {
const log = {}
const entries = parseValue(values[0])
for (let i = 1; i <= entries; i++) {
const date = parseTimestamp(values[2 * i])
const duration = parseValue(values[2 * i + 1])
if (date != null && duration != null) {
log[date] = duration
}
}
return log
}
function parseAvgPowerPeak (values) {
return {
power: parseValue(values[1]),
time: parseTimestamp(values[0])
}
}
function parseAvgPowerPeaks (values) {
const peaks = []
const entries = parseValue(values[0])
for (let i = 1; i <= entries; i++) {
peaks.push({
power: parseValue(values[3 * i + 2]),
time: parseTimestamp(values[3 * i + 1])
})
}
return peaks
}
const p1Keys = {
'0-0:1.0.0': [{ key: 'lastupdated', f: parseTimestamp }],
'0-0:17.0.0': [{ key: 'max_power', f: parseValue }],
'0-0:96.1.1': [{ key: 'id', f: parseString }],
'0-0:96.1.4': [{ key: 'version_be', f: parseVersion }],
'0-0:96.3.10': [{ key: 'breaker', f: parseBreaker }],
'0-0:96.7.9': [{ key: 'failures_long', f: parseValue }],
'0-0:96.7.21': [{ key: 'failures_short', f: parseValue }],
'0-0:96.13.0': [{ key: 'msg_text', f: parseString }],
'0-0:96.13.1': [{ key: 'msg_num', f: parseString }],
'0-0:96.14.0': [{ key: 'tariff', f: parseValue }],
'0-0:98.1.0': [{ key: 'avg_power_peaks', f: parseAvgPowerPeaks, allValues: true }],
'0-1:24.1.0': [{ key: 'd1_type', f: parseType }],
'0-1:24.2.1': [
{ key: 'd1_lastupdated', f: parseTimestamp },
{ key: 'd1_consumption', f: parseValue }
],
'0-1:24.2.3': [
{ key: 'd1_lastupdated', f: parseTimestamp },
{ key: 'd1_consumption', f: parseValue }
],
'0-1:24.3.0': [
{ key: 'd1_lastupdated', f: parseTimestamp },
{},
{},
{},
{},
{},
{ key: 'd1_consumption', f: parseValue }
],
'0-1:24.4.0': [{ key: 'd1_breaker', f: parseBreaker }],
'0-1:96.1.0': [{ key: 'd1_id', f: parseString }],
'0-1:96.1.1': [{ key: 'd1_id', f: parseString }],
'0-2:24.1.0': [{ key: 'd2_type', f: parseType }],
'0-2:24.2.1': [
{ key: 'd2_lastupdated', f: parseTimestamp },
{ key: 'd2_consumption', f: parseValue }
],
'0-2:24.2.3': [
{ key: 'd2_lastupdated', f: parseTimestamp },
{ key: 'd2_consumption', f: parseValue }
],
'0-2:24.3.0': [
{ key: 'd2_lastupdated', f: parseTimestamp },
{},
{},
{},
{},
{},
{ key: 'd2_consumption', f: parseValue }
],
'0-2:24.4.0': [{ key: 'd2_breaker', f: parseBreaker }],
'0-2:96.1.0': [{ key: 'd2_id', f: parseString }],
'0-2:96.1.1': [{ key: 'd2_id', f: parseString }],
'0-3:24.1.0': [{ key: 'd3_type', f: parseType }],
'0-3:24.2.1': [
{ key: 'd3_lastupdated', f: parseTimestamp },
{ key: 'd3_consumption', f: parseValue }
],
'0-3:24.2.3': [
{ key: 'd3_lastupdated', f: parseTimestamp },
{ key: 'd3_consumption', f: parseValue }
],
'0-3:24.3.0': [
{ key: 'd3_lastupdated', f: parseTimestamp },
{},
{},
{},
{},
{},
{ key: 'd3_consumption', f: parseValue }
],
'0-3:24.4.0': [{ key: 'd3_breaker', f: parseBreaker }],
'0-3:96.1.0': [{ key: 'd3_id', f: parseString }],
'0-3:96.1.1': [{ key: 'd3_id', f: parseString }],
'0-4:24.1.0': [{ key: 'd4_type', f: parseType }],
'0-4:24.2.1': [
{ key: 'd4_lastupdated', f: parseTimestamp },
{ key: 'd4_consumption', f: parseValue }
],
'0-4:24.2.3': [
{ key: 'd4_lastupdated', f: parseTimestamp },
{ key: 'd4_consumption', f: parseValue }
],
'0-4:24.3.0': [
{ key: 'd4_lastupdated', f: parseTimestamp },
{},
{},
{},
{},
{},
{ key: 'd4_consumption', f: parseValue }
],
'0-4:24.4.0': [{ key: 'd4_breaker', f: parseBreaker }],
'0-4:96.1.0': [{ key: 'd4_id', f: parseString }],
'0-4:96.1.1': [{ key: 'd4_id', f: parseString }],
'1-0:1.4.0': [{ key: 'avg_power', f: parseValue }],
'1-0:1.6.0': [{ key: 'avg_power_peak', f: parseAvgPowerPeak, allValues: true }],
'1-0:1.7.0': [{ key: 'power', f: parseValue }],
'1-0:1.8.1': [{ key: 'consumption_t1', f: parseValue }],
'1-0:1.8.2': [{ key: 'consumption_t2', f: parseValue }],
'1-0:2.7.0': [{ key: 'power_back', f: parseValue }],
'1-0:2.8.1': [{ key: 'consumption_back_t1', f: parseValue }],
'1-0:2.8.2': [{ key: 'consumption_back_t2', f: parseValue }],
'1-0:21.7.0': [{ key: 'l1_power', f: parseValue }],
'1-0:22.7.0': [{ key: 'l1_power_back', f: parseValue }],
'1-0:31.4.0': [{ key: 'max_current', f: parseValue }],
'1-0:31.7.0': [{ key: 'l1_current', f: parseValue }],
'1-0:32.7.0': [{ key: 'l1_voltage', f: parseValue }],
'1-0:32.32.0': [{ key: 'l1_sags', f: parseValue }],
'1-0:32.36.0': [{ key: 'l1_swells', f: parseValue }],
'1-0:41.7.0': [{ key: 'l2_power', f: parseValue }],
'1-0:42.7.0': [{ key: 'l2_power_back', f: parseValue }],
'1-0:51.7.0': [{ key: 'l2_current', f: parseValue }],
'1-0:52.7.0': [{ key: 'l2_voltage', f: parseValue }],
'1-0:52.32.0': [{ key: 'l2_sags', f: parseValue }],
'1-0:52.36.0': [{ key: 'l2_swells', f: parseValue }],
'1-0:61.7.0': [{ key: 'l3_power', f: parseValue }],
'1-0:62.7.0': [{ key: 'l3_power_back', f: parseValue }],
'1-0:71.7.0': [{ key: 'l3_current', f: parseValue }],
'1-0:72.7.0': [{ key: 'l3_voltage', f: parseValue }],
'1-0:72.32.0': [{ key: 'l3_sags', f: parseValue }],
'1-0:72.36.0': [{ key: 'l3_swells', f: parseValue }],
'1-0:99.97.0': [{ key: 'log', f: parseLog, allValues: true }],
'1-3:0.2.8': [{ key: 'version', f: parseVersion }]
}
// ===== P1Client ==============================================================
/** Class for P1 serial port client.
*
* The DSMR standard defines the P1 interface for end consumers of smart
* electricity meters provided by electricity network companies in the
* Netherlands.
* For details, see the
* [P1 Companion Standard](https://www.netbeheernederland.nl/_upload/Files/Slimme_meter_15_a727fce1f1.pdf).
*
* In essence, the P1 interface is a serial port interface over an RJ11 socket,
* with some non-standard pin assignments.
* The easiest way to connect the smart meter to your computer is through a
* USB P1 converter cable with a built-in FTDI serial port.
* `P1Client` interfaces to the P1 using this serial port device.
*
* To open a connection to the serial port, call {@link P1Client#open open()}.
* When `options.serialPort` hasn't been set,
* {@link P1Client#_findPort _findPort()} is called to attempt automatic
* discovery of the serial port.
* In this case, a {@link P1Client#event:ports ports} event is emitted with
* all serial ports found on the system.
* When the connection has been established, an
*{@link P1Client#event:open open} event is emitted.
*
* The connection is closed automatically when no telegram is received
* for `options.timeout` seconds, since the connection was opened or the
* last telegram was received.
* The connection can be closed explicitly by calling
* {@link P1Client#close close()}.
* A {@link P1Client#event:close close} event is emitted after the connection
* has been closed.
*
* Once connected, the smart meter sends a telegram on the P1 interface
* every one (DSMR 5.0) to ten seconds (older DSMR versions).
* `P1Client` validates and parses these telegrams into JavaScript objects,
* emitting {@link P1Client#event:telegram telegram},
* {@link P1Client#event:data data},
* and {@link P1Client#event:rawData rawData} events as it proceeds.
* On invalid telegrams, unsupported keys, or unsupported values,
* {@link P1Client#event:warning warning} events are emitted.
*
* Note that the serial port only supports one connection at a time.
* For multiple concurrent clients, setup an instance of {@link P1WsServer}
* linked to the `P1Client` instance, and use multuple instances of
* {@link P1WsClient} to connect the clients.
*
* `P1Client` extends [EventEmitter](https://nodejs.org/dist/latest-v12.x/docs/api/events.html#events_class_eventemitter).
* @extends EventEmitter
*/
class P1Client extends EventEmitter {
/** Find serial ports on the system.
* @returns {Port[]} - A list of found serial ports.
*/
static async findPorts () {
const ports = await SerialPort.list()
return ports
}
/** Check whether a discovered serial port could be a USB P1 converter cable.
* @param {Port} port - The port object as returned by
* {@link P1Client.findPorts findPorts()}.
* @returns {boolean} - True if the USB signature matches.
*/
static isP1 (port) {
if (
(port.vendorId === '0403' && port.productId === '6001') ||
(port.vendorId === '067b' && port.productId === '2303') // Issue #7
) {
return true
}
return false
}
/** Create a new instance P1 serial port client.
* @param {?object} options - Optional parameters.
* @param {?string} options.serialPort - The path to the serial port device,
* corresponding to the USB P1 converter cable, e.g. `'/dev/USB0'`.
* When not specified, automatic discovery is attempted, using the USB
* signature of the converter cable.
* @param {boolean} [options.dsmr22=false] - Use DSMR v2.2 settings for the
* serial interface: 7N1 at 9600 baud instead of 8N1 at 115200 baud.
* @param {int} [options.timeout=5] - Timeout in seconds to wait for next
* telegram from the P1. Must be between 5 and 120. When `options.dsmr22`
* has been set, a default of 50 is used.<br>
* When no telegram has been received in `timeout` seconds, the connection
* to the serial port is closed automatically.
* @throws `TypeError` - When a parameter has an invalid type.
* @throws `RangeError` - When a parameter has an invalid value.
* @throws `SyntaxError` - When a mandatory parameter is missing or an
* optional parameter is not applicable.
*/
constructor (options = {}) {
super()
this._options = {
timeout: 5
}
const optionParser = new OptionParser(this._options)
optionParser.stringKey('serialPort')
optionParser.boolKey('dsmr22')
optionParser.intKey('timeout', 5, 120)
optionParser.parse(options)
if (this._options.dsmr22 && this._options.timeout < 50) {
this._options.timeout = 50
}
}
/** Find the path to the serial port device of the USB P1 converter cable.
* @returns {string} The path to the (first) serial port device that
* matches the USB signature of a USB P1 converter cable.
* @throws `Error` - When no USB P1 converter cable was found.
*/
async _findPort () {
if (this._options.serialPort != null) {
return this._options.serialPort
}
const ports = await P1Client.findPorts()
/** Emitted when serial port devices are discovered.
* @event P1Client#ports
* @param {Object[]} ports - The discovered serial ports.
*/
this.emit('ports', ports)
for (const port of ports) {
if (P1Client.isP1(port)) {
return port.path
}
}
throw new Error('USB P1 converter cable not found')
}
_toHost (serialPort) {
try {
return OptionParser.toHost('serialPort', serialPort)
} catch (error) {
return {}
}
}
/** Open the connection to the serial port.
*
* When the connection has been established, an `open` event is emitted.
* When no data has been received for `options.timeout` seconds, the
* connection is closed automatically.
* @throws `Error` - When serial port cannot be found or opened.
*/
async open () {
const serialPort = this._options.serialPort != null
? this._options.serialPort
: await this._findPort()
return new Promise((resolve, reject) => {
const { hostname, port } = this._toHost(serialPort)
if (hostname != null && port != null) {
this.p1 = createConnection(port, hostname, () => {
this.emit('open', serialPort)
this._setTimeout()
resolve(serialPort)
})
this.p1.close = this.p1.destroy
} else {
const options = this._options.dsmr22
? { baudRate: 9600, dataBits: 7, parity: 'even' } // 9600 7E1
: { baudRate: 115200 } // 115200 8N1
options.path = serialPort
this.p1 = new SerialPort(options, (error) => {
if (error) {
return reject(error)
}
/** Emitted when the connection to the serial port is opened.
* @event P1Client#open
* @param {string} serialPort
*/
this.emit('open', serialPort)
this._setTimeout()
resolve(serialPort)
})
}
this.parser = new ReadlineParser({ delimiter: '/' })
this.parser.on('data', (telegram) => { this.parseTelegram(telegram) })
this.p1
.on('data', () => { this._setTimeout() })
.on('close', () => {
this.p1.unpipe(this.parser)
this.p1.removeAllListeners()
delete this.p1
this.parser.removeAllListeners()
delete this.parser
/** Emitted when the connection to the serial port is closed.
* @event P1Client#close
* @param {string} serialPort
*/
this.emit('close', serialPort)
})
.on('error', (error) => {
/** Emitted when an error occurs.
* @event P1Client#error
* @param {Error} error
*/
this.emit('error', error)
})
.pipe(this.parser)
})
}
/** Check if the connection to the serial port is open.
*/
isOpen () {
return this.p1 != null
}
/** Close the connection to the serial port.
*/
async close () {
if (this.p1 != null) {
this.p1.close()
}
}
/** Set a new timeout on receiving the next telegram.
*
* Any existing timeout is cancelled, before the new timeout is set.
* When no telegram has been received for `options.timeout` seconds, the
* connection to the serial port is closed automatically.
*/
_setTimeout () {
if (this.timeout != null) {
clearTimeout(this.timeout)
}
this.timeout = setTimeout(() => {
if (this.p1 != null) {
this.emit(
'error', new Error(`no data received in ${this._options.timeout}s`)
)
this.p1.close()
}
}, this._options.timeout * 1000)
}
crc16 (s) {
const b = Buffer.from(s, 'utf8')
let crc = 0
for (let i = 0; i < b.length; i++) {
crc ^= b[i]
for (let j = 8; j > 0; j--) {
if ((crc & 0x0001) !== 0) {
crc >>= 1
crc ^= 0xA001
} else {
crc >>= 1
}
}
}
return crc
}
/** Parse a telegram.
*
* Normally this method is called automatically, when a telegram is received
* from the P1 interface.
* It can also be called manually to have P1Cient emit events for a fake
* telegram (e.g. for testing).
* @params {!string} telegram - The telegram.
*/
parseTelegram (telegram) {
try {
// Validate telegram.
if (telegram == null || typeof telegram !== 'string') {
this.emit('warning', 'ignoring unsupported telegram')
return
}
const header = telegram.match(/(.+)\r\n\r\n/)
const footer = telegram.match(/(!)([0-9A-F]{4})?\r\n/)
if (header == null || footer == null) {
this.emit('warning', 'ignoring invalid telegram')
return
}
if (footer[2] != null) {
const checksum = parseInt(footer[2], 16)
const crc = this.crc16('/' + telegram.slice(0, -6))
if (checksum !== crc) {
const got = toHexString(checksum, 4)
const expected = toHexString(crc, 4)
this.emit(
'warning',
`ignoring telegram with crc error (got: ${got}, expected: ${expected})`
)
return
}
}
/** Emitted when a telegram has been validated.
* @event P1Client#telegram
* @param {string} telegram - The validated telegram.
*/
this.emit('telegram', telegram)
// Convert telegram into a raw object.
const obj = { type: header[1], checksum: footer[2] }
const lines = telegram.match(/\d+-\d+:\d+\.\d+\.\d+(\(.*\)\r?\n?)+\r\n/g)
for (const line of lines) {
const keys = line.match(/\d+-\d+:\d+\.\d+\.\d+/)
const values = line.match(/\([^)]*\)/g)
const a = p1Keys[keys[0]]
if (a == null) {
/** Emitted when the telegram contains an unknown key.
* @event P1Client#warning
* @param {string} message - The warning message.
*/
this.emit('warning', `${keys[0]}: ignoring unknown key`)
continue
}
for (const id in a) {
if (a[id].key != null) {
try {
obj[a[id].key] = a[id].f(a[id].allValues ? values : values[id])
} catch (error) {
const value = a[id].allValues ? values.join('') : values[id]
this.emit(
'warning', `${keys[0]}: ignoring unknown value ${value}`
)
}
}
}
}
/** Emitted when a telegram has been parsed.
* @event P1Client#rawData
* @param {Object} rawData - The telegram converted 1:1 to a flat obect.
*/
this.emit('rawData', obj)
// Convert the flat object into a cooked object.
const be = obj.version_be != null
const low = be ? 2 : 1
const normal = be ? 1 : 2
const tariff = obj.tariff === low ? 'low' : 'normal'
if (obj.avg_power_peak != null) {
if (obj.avg_power_peaks == null) {
obj.avg_power_peaks = []
}
obj.avg_power_peaks.push(obj.avg_power_peak)
}
const result = {
type: obj.type,
version: be ? obj.version_be : obj.version == null ? '2.2' : obj.version,
msg_text: obj.msg_text,
msg_num: obj.msg_num,
electricity: {
id: obj.id.trim(),
lastupdated: obj.lastupdated,
tariff,
consumption: {
low: obj['consumption_t' + low],
normal: obj['consumption_t' + normal]
},
power: obj.power,
avg_power: obj.avg_power,
avg_power_peaks: obj.avg_power_peaks,
failures: {
short: obj.failures_short,
long: obj.failures_long,
log: obj.log
},
l1: {
voltage: obj.l1_voltage,
sags: obj.l1_sags,
swells: obj.l1_swells,
current: obj.l1_current,
power: obj.l1_power
}
},
electricityBack: {
id: obj.id.trim() + 'B',
lastupdated: obj.lastupdated,
tariff,
consumption: {
low: obj['consumption_back_t' + low],
normal: obj['consumption_back_t' + normal]
},
power: obj.power_back,
l1: { power: obj.l1_power_back }
}
}
if (obj.l2_power != null && obj.l3_power != null) {
result.electricity.l2 = {
voltage: obj.l2_voltage,
sags: obj.l2_sags,
swells: obj.l2_swells,
current: obj.l2_current,
power: obj.l2_power
}
result.electricityBack.l2 = { power: obj.l2_power_back }
result.electricity.l3 = {
voltage: obj.l3_voltage,
sags: obj.l3_sags,
swells: obj.l3_swells,
current: obj.l3_current,
power: obj.l3_power
}
result.electricityBack.l3 = { power: obj.l3_power_back }
}
if (obj.d1_type != null) {
result[obj.d1_type] = {
id: obj.d1_id.trim(),
lastupdated: obj.d1_lastupdated,
consumption: obj.d1_consumption
}
}
if (obj.d2_type != null) {
result[obj.d2_type] = {
id: obj.d2_id.trim(),
lastupdated: obj.d2_lastupdated,
consumption: obj.d2_consumption
}
}
if (obj.d3_type != null) {
result[obj.d3_type] = {
id: obj.d3_id.trim(),
lastupdated: obj.d3_lastupdated,
consumption: obj.d3_consumption
}
}
if (obj.d4_type != null) {
result[obj.d4_type] = {
id: obj.d4_id.trim(),
lastupdated: obj.d4_lastupdated,
consumption: obj.d4_consumption
}
}
/** Emitted when a telegram has been parsed.
* @event P1Client#data
* @param {Object} data - The telegram converted an object with
* nested objects.
*/
this.emit('data', result)
} catch (error) {
this.emit('error', error)
}
}
}
export { P1Client }