hm2mqtt 2.5.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -99
- package/config.js +119 -58
- package/example-names.json +6 -0
- package/index.js +616 -974
- package/lib/cast.js +105 -0
- package/lib/compare.js +125 -0
- package/lib/install.js +17 -0
- package/lib/interfaces.js +110 -0
- package/lib/metadata.js +360 -0
- package/lib/rega.js +380 -0
- package/lib/rpc.js +497 -0
- package/lib/topics.js +211 -0
- package/lib/values.js +283 -0
- package/names.schema.json +8 -0
- package/package.json +54 -36
- package/paramsets.json +428781 -0
- package/scripts/clean-retained.js +69 -0
- package/scripts/compare-trees.js +46 -0
- package/.npmignore +0 -4
- package/.travis.yml +0 -40
- package/Dockerfile +0 -10
- package/Dockerfile.armhf +0 -11
- package/discover.js +0 -24
- package/firstip.js +0 -16
- package/regascripts/devices.fn +0 -43
- package/regascripts/programs.fn +0 -27
- package/regascripts/variables.fn +0 -78
- package/test.js +0 -201
package/lib/cast.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Casting of incoming values to what the interface process expects, driven by the paramset
|
|
3
|
+
* description of the datapoint (node-red-contrib-ccu's paramCast, with the bugs fixed).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** OPERATIONS bit 2 = write */
|
|
7
|
+
export function isWriteable(description) {
|
|
8
|
+
return !description || typeof description.OPERATIONS !== 'number' || Boolean(description.OPERATIONS & 2);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function enumIndex(value, description) {
|
|
12
|
+
const list = description.VALUE_LIST || description.ENUM;
|
|
13
|
+
if (typeof value === 'string' && Array.isArray(list)) {
|
|
14
|
+
const index = list.indexOf(value);
|
|
15
|
+
if (index !== -1) {
|
|
16
|
+
return index;
|
|
17
|
+
}
|
|
18
|
+
const lower = list.findIndex((v) => String(v).toLowerCase() === value.toLowerCase());
|
|
19
|
+
if (lower !== -1) {
|
|
20
|
+
return lower;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Casts a value for setValue/putParamset. FLOATs are wrapped as {explicitDouble} so the XML-RPC
|
|
28
|
+
* serializer emits <double> even for integral numbers. MIN/MAX are not enforced
|
|
29
|
+
* (https://github.com/rdmtc/node-red-contrib-ccu/issues/74).
|
|
30
|
+
* @param {*} value parsed payload (number, boolean, string, object)
|
|
31
|
+
* @param {object} [description] paramset description of the parameter
|
|
32
|
+
* @returns {*}
|
|
33
|
+
*/
|
|
34
|
+
export function castValue(value, description) {
|
|
35
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
36
|
+
return value; // already typed, e.g. {explicitDouble: 1}
|
|
37
|
+
}
|
|
38
|
+
switch (description && description.TYPE) {
|
|
39
|
+
case 'ACTION':
|
|
40
|
+
case 'BOOL':
|
|
41
|
+
return toBool(value);
|
|
42
|
+
case 'FLOAT': {
|
|
43
|
+
const n = typeof value === 'boolean' ? Number(value) : Number.parseFloat(value);
|
|
44
|
+
return {explicitDouble: Number.isFinite(n) ? n : 0};
|
|
45
|
+
}
|
|
46
|
+
case 'ENUM':
|
|
47
|
+
return toInt(enumIndex(value, description));
|
|
48
|
+
case 'INTEGER':
|
|
49
|
+
return toInt(value);
|
|
50
|
+
case 'STRING':
|
|
51
|
+
return String(value);
|
|
52
|
+
default:
|
|
53
|
+
// unknown description: numbers as strings work for both double and integer datapoints
|
|
54
|
+
return typeof value === 'number' ? String(value) : value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toBool(value) {
|
|
59
|
+
if (typeof value === 'string') {
|
|
60
|
+
const s = value.trim().toLowerCase();
|
|
61
|
+
if (['false', 'off', 'no', '0', ''].includes(s)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
if (['true', 'on', 'yes', '1'].includes(s)) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
const n = Number(s);
|
|
68
|
+
return Number.isNaN(n) ? true : n !== 0;
|
|
69
|
+
}
|
|
70
|
+
return Boolean(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function toInt(value) {
|
|
74
|
+
if (typeof value === 'boolean') {
|
|
75
|
+
return Number(value);
|
|
76
|
+
}
|
|
77
|
+
const n = Number.parseInt(value, 10);
|
|
78
|
+
return Number.isFinite(n) ? n : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Casts a value for a ReGa variable (State()).
|
|
83
|
+
* @param {*} value
|
|
84
|
+
* @param {{type: 'boolean' | 'number' | 'string', enum?: string[]}} variable
|
|
85
|
+
* @returns {boolean | number | string}
|
|
86
|
+
*/
|
|
87
|
+
export function castVariable(value, variable) {
|
|
88
|
+
switch (variable && variable.type) {
|
|
89
|
+
case 'boolean':
|
|
90
|
+
if (typeof value === 'string' && variable.enum && variable.enum.includes(value)) {
|
|
91
|
+
return variable.enum.indexOf(value) === 1;
|
|
92
|
+
}
|
|
93
|
+
return toBool(value);
|
|
94
|
+
case 'string':
|
|
95
|
+
return String(value);
|
|
96
|
+
default:
|
|
97
|
+
if (typeof value === 'string' && variable && variable.enum && variable.enum.includes(value)) {
|
|
98
|
+
return variable.enum.indexOf(value);
|
|
99
|
+
}
|
|
100
|
+
if (typeof value === 'boolean') {
|
|
101
|
+
return Number(value);
|
|
102
|
+
}
|
|
103
|
+
return Number.parseFloat(value) || 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
package/lib/compare.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comparison of two status trees (the Node-RED flow's `hm/...` and hm2mqtt's `hm3/...` during
|
|
3
|
+
* the parallel run before the cutover, ROADMAP §9): which items exist on one side only, and
|
|
4
|
+
* where `val` or the `hm` fields differ.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** hm fields that legitimately differ between the two implementations */
|
|
8
|
+
export const IGNORED_HM_FIELDS = new Set([
|
|
9
|
+
'ts',
|
|
10
|
+
'tsPrevious',
|
|
11
|
+
'lc',
|
|
12
|
+
'lcPrevious',
|
|
13
|
+
'cache',
|
|
14
|
+
'uncertain',
|
|
15
|
+
'change',
|
|
16
|
+
'valuePrevious',
|
|
17
|
+
'valueEnumPrevious',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** hm fields hm2mqtt adds (missing on the flow side is expected) */
|
|
21
|
+
export const ADDED_HM_FIELDS = new Set(['datapointUnit', 'datapointEnum', 'valueEnum']);
|
|
22
|
+
|
|
23
|
+
/** items hm2mqtt publishes that the flow never had */
|
|
24
|
+
export const ADDED_ITEMS = /^(interface\/|.*\/(CARRIER_SENSE_LEVEL|CONNECTED)$)/;
|
|
25
|
+
|
|
26
|
+
function parse(raw) {
|
|
27
|
+
try {
|
|
28
|
+
const p = JSON.parse(raw);
|
|
29
|
+
return p && typeof p === 'object' && 'val' in p ? p : {val: p};
|
|
30
|
+
} catch {
|
|
31
|
+
return {val: raw};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function same(a, b) {
|
|
36
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Map<string, string>} left item → raw payload (the reference, e.g. the flow)
|
|
41
|
+
* @param {Map<string, string>} right item → raw payload (hm2mqtt)
|
|
42
|
+
* @returns {{leftOnly: string[], rightOnly: string[], differences: Array<{item: string, field: string, left: *, right: *}>, same: number}}
|
|
43
|
+
*/
|
|
44
|
+
export function compareTrees(left, right) {
|
|
45
|
+
const leftOnly = [];
|
|
46
|
+
const rightOnly = [];
|
|
47
|
+
const differences = [];
|
|
48
|
+
let sameCount = 0;
|
|
49
|
+
for (const item of left.keys()) {
|
|
50
|
+
if (!right.has(item)) {
|
|
51
|
+
leftOnly.push(item);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const item of right.keys()) {
|
|
55
|
+
if (!left.has(item) && !ADDED_ITEMS.test(item)) {
|
|
56
|
+
rightOnly.push(item);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const [item, rawLeft] of left) {
|
|
60
|
+
if (!right.has(item)) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const l = parse(rawLeft);
|
|
64
|
+
const r = parse(right.get(item));
|
|
65
|
+
let differs = false;
|
|
66
|
+
if (!same(l.val, r.val)) {
|
|
67
|
+
differences.push({item, field: 'val', left: l.val, right: r.val});
|
|
68
|
+
differs = true;
|
|
69
|
+
}
|
|
70
|
+
const hl = l.hm || {};
|
|
71
|
+
const hr = r.hm || {};
|
|
72
|
+
for (const field of new Set([...Object.keys(hl), ...Object.keys(hr)])) {
|
|
73
|
+
if (IGNORED_HM_FIELDS.has(field)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (!(field in hl) && ADDED_HM_FIELDS.has(field)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (!same(hl[field], hr[field])) {
|
|
80
|
+
differences.push({item, field: 'hm.' + field, left: hl[field], right: hr[field]});
|
|
81
|
+
differs = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!differs) {
|
|
85
|
+
sameCount += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
leftOnly.sort();
|
|
89
|
+
rightOnly.sort();
|
|
90
|
+
return {leftOnly, rightOnly, differences, same: sameCount};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function formatReport(
|
|
94
|
+
{leftOnly, rightOnly, differences, same},
|
|
95
|
+
{leftName = 'left', rightName = 'right', limit = 50} = {},
|
|
96
|
+
) {
|
|
97
|
+
const lines = [];
|
|
98
|
+
lines.push(
|
|
99
|
+
`${same} items identical, ${differences.length} differences, ${leftOnly.length} only in ${leftName}, ${rightOnly.length} only in ${rightName}`,
|
|
100
|
+
);
|
|
101
|
+
const list = (title, items) => {
|
|
102
|
+
if (items.length === 0) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
lines.push('', `${title} (${items.length}):`);
|
|
106
|
+
for (const i of items.slice(0, limit)) {
|
|
107
|
+
lines.push(' ' + i);
|
|
108
|
+
}
|
|
109
|
+
if (items.length > limit) {
|
|
110
|
+
lines.push(` … ${items.length - limit} more`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
list(`only in ${leftName}`, leftOnly);
|
|
114
|
+
list(`only in ${rightName}`, rightOnly);
|
|
115
|
+
if (differences.length > 0) {
|
|
116
|
+
lines.push('', `differences (${differences.length}):`);
|
|
117
|
+
for (const d of differences.slice(0, limit)) {
|
|
118
|
+
lines.push(` ${d.item} ${d.field}: ${JSON.stringify(d.left)} → ${JSON.stringify(d.right)}`);
|
|
119
|
+
}
|
|
120
|
+
if (differences.length > limit) {
|
|
121
|
+
lines.push(` … ${differences.length - limit} more`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --install / --uninstall: systemd template service hm2mqtt@<name> (mqtt-interfaces-core installer).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {createInstaller} from 'mqtt-interfaces-core';
|
|
6
|
+
|
|
7
|
+
export const SERVICE = 'hm2mqtt';
|
|
8
|
+
export const ENV_PREFIX = 'HM2MQTT';
|
|
9
|
+
|
|
10
|
+
const installer = createInstaller({
|
|
11
|
+
service: SERVICE,
|
|
12
|
+
envPrefix: ENV_PREFIX,
|
|
13
|
+
description: `${SERVICE} %i - Homematic CCU to MQTT bridge`,
|
|
14
|
+
documentation: 'https://github.com/hobbyquaker/hm2mqtt.js',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const {unitFile, envFile, installService, uninstallService, handle} = installer;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CCU interface processes: names as the CCU uses them, ports (plain / TLS), protocol,
|
|
3
|
+
* whether they want an init() subscription and answer pings.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
|
|
8
|
+
export const INTERFACES = {
|
|
9
|
+
'BidCos-RF': {
|
|
10
|
+
port: 2001,
|
|
11
|
+
tlsPort: 42001,
|
|
12
|
+
protocol: 'xmlrpc',
|
|
13
|
+
binrpc: true,
|
|
14
|
+
init: true,
|
|
15
|
+
ping: true,
|
|
16
|
+
dutyCycle: true,
|
|
17
|
+
},
|
|
18
|
+
'BidCos-Wired': {port: 2000, tlsPort: 42000, protocol: 'xmlrpc', binrpc: true, init: true, ping: true},
|
|
19
|
+
// https://github.com/eq-3/occu/issues/42 — HmIP-RF answers pings but events are rare, node-red used 600 s
|
|
20
|
+
'HmIP-RF': {
|
|
21
|
+
port: 2010,
|
|
22
|
+
tlsPort: 42010,
|
|
23
|
+
protocol: 'xmlrpc',
|
|
24
|
+
init: true,
|
|
25
|
+
ping: true,
|
|
26
|
+
pingTimeout: 600,
|
|
27
|
+
dutyCycle: true,
|
|
28
|
+
},
|
|
29
|
+
VirtualDevices: {port: 9292, tlsPort: 49292, path: '/groups', protocol: 'xmlrpc', init: true, ping: false},
|
|
30
|
+
CUxD: {port: 8701, protocol: 'binrpc', init: true, ping: true},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const INTERFACE_NAMES = Object.keys(INTERFACES);
|
|
34
|
+
export const DEFAULT_INTERFACES = ['BidCos-RF', 'HmIP-RF', 'VirtualDevices', 'BidCos-Wired'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parses the --interfaces option. Returns the list of names, or null for "auto".
|
|
38
|
+
* @param {string} value
|
|
39
|
+
* @returns {string[] | null}
|
|
40
|
+
*/
|
|
41
|
+
export function parseInterfaces(value) {
|
|
42
|
+
const raw = String(value || '').trim();
|
|
43
|
+
if (raw === '' || raw.toLowerCase() === 'auto') {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const names = raw.split(/[\s,]+/).filter(Boolean);
|
|
47
|
+
const lower = new Map(INTERFACE_NAMES.map((n) => [n.toLowerCase(), n]));
|
|
48
|
+
return names.map((n) => {
|
|
49
|
+
const name = lower.get(n.toLowerCase());
|
|
50
|
+
if (!name) {
|
|
51
|
+
throw new Error(`unknown interface "${n}" (known: ${INTERFACE_NAMES.join(', ')})`);
|
|
52
|
+
}
|
|
53
|
+
return name;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Connection parameters of an interface for the given CCU options.
|
|
59
|
+
* @param {string} name
|
|
60
|
+
* @param {{tls?: boolean, bidcosBinrpc?: boolean}} [options]
|
|
61
|
+
* @returns {{name: string, protocol: 'xmlrpc' | 'binrpc', port: number, path?: string, init: boolean,
|
|
62
|
+
* ping: boolean, pingTimeout?: number, dutyCycle: boolean}}
|
|
63
|
+
*/
|
|
64
|
+
export function interfaceConfig(name, {tls = false, bidcosBinrpc = false} = {}) {
|
|
65
|
+
const def = INTERFACES[name];
|
|
66
|
+
if (!def) {
|
|
67
|
+
throw new Error('unknown interface ' + name);
|
|
68
|
+
}
|
|
69
|
+
const binrpc = def.protocol === 'binrpc' || (def.binrpc && bidcosBinrpc && !tls);
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
protocol: binrpc ? 'binrpc' : 'xmlrpc',
|
|
73
|
+
port: tls && def.tlsPort ? def.tlsPort : def.port,
|
|
74
|
+
path: def.path,
|
|
75
|
+
init: def.init,
|
|
76
|
+
ping: def.ping,
|
|
77
|
+
pingTimeout: def.pingTimeout,
|
|
78
|
+
dutyCycle: Boolean(def.dutyCycle),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function portOpen(host, port, timeout) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const socket = net.connect({host, port});
|
|
85
|
+
const done = (result) => {
|
|
86
|
+
socket.destroy();
|
|
87
|
+
resolve(result);
|
|
88
|
+
};
|
|
89
|
+
socket.setTimeout(timeout, () => done(false));
|
|
90
|
+
socket.once('connect', () => done(true));
|
|
91
|
+
socket.once('error', () => done(false));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Probes which interface ports answer on the CCU (--interfaces auto).
|
|
97
|
+
* @param {string} host
|
|
98
|
+
* @param {{tls?: boolean, timeout?: number, connect?: Function}} [options]
|
|
99
|
+
* @returns {Promise<string[]>} interface names in table order
|
|
100
|
+
*/
|
|
101
|
+
export async function probeInterfaces(host, {tls = false, timeout = 2000, connect = portOpen} = {}) {
|
|
102
|
+
const found = [];
|
|
103
|
+
for (const name of INTERFACE_NAMES) {
|
|
104
|
+
const {port} = interfaceConfig(name, {tls});
|
|
105
|
+
if (await connect(host, port, timeout)) {
|
|
106
|
+
found.push(name);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return found;
|
|
110
|
+
}
|