hm2mqtt 2.4.0 → 3.0.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 +191 -96
- package/config.js +112 -58
- package/example-names.json +6 -0
- package/index.js +596 -952
- 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 -14
- 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/values.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Datapoint values: builds the message of an event exactly like node-red-contrib-ccu's
|
|
3
|
+
* createMessage() (the `hm` block of the payload), keeps the last message per datapoint
|
|
4
|
+
* (persisted as values.json) and applies the actuator "wait for WORKING" rule.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const WORKING_WAIT_MS = 300;
|
|
11
|
+
|
|
12
|
+
/** channel types whose STATE / ARMSTATE / LEVEL* come together with WORKING/DIRECTION */
|
|
13
|
+
export function waitsForWorking(datapoint, channelType) {
|
|
14
|
+
if (!channelType) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (datapoint === 'STATE') {
|
|
18
|
+
return /SIGNAL|SWITCH|RAINDETECTOR_HEAT|ALARMACTUATOR/.test(channelType);
|
|
19
|
+
}
|
|
20
|
+
if (datapoint === 'ARMSTATE') {
|
|
21
|
+
return channelType === 'ARMING';
|
|
22
|
+
}
|
|
23
|
+
if (datapoint.startsWith('LEVEL')) {
|
|
24
|
+
return /DIMMER|DUAL_WHITE|BLIND|SHUTTER|JALOUSIE|WINMATIC|KEYMATIC/.test(channelType);
|
|
25
|
+
}
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** the interface processes deliver "°C" as a lone latin1 byte which arrives as U+FFFD */
|
|
30
|
+
export function unit(description) {
|
|
31
|
+
const u = description && description.UNIT;
|
|
32
|
+
if (!u || u === '""') {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
return String(u).replace(/�/g, '°');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** true for datapoints that are events, not state: PRESS_* and every ACTION */
|
|
39
|
+
export function isEvent(datapoint, description) {
|
|
40
|
+
return datapoint.startsWith('PRESS_') || Boolean(description && description.TYPE === 'ACTION');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The `hm` block of a payload: the message without topic/payload/value. */
|
|
44
|
+
export function hmBlock(message) {
|
|
45
|
+
const hm = {...message};
|
|
46
|
+
delete hm.topic;
|
|
47
|
+
delete hm.payload;
|
|
48
|
+
delete hm.value;
|
|
49
|
+
return hm;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class ValueStore {
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} o
|
|
55
|
+
* @param {string} o.host CCU address (`ccu` field)
|
|
56
|
+
* @param {object} o.context lookups: device(iface, address), valueDescription(iface, address, datapoint),
|
|
57
|
+
* channelName(address), rooms(address), functions(address)
|
|
58
|
+
* @param {string} [o.stateDir]
|
|
59
|
+
* @param {object} o.log
|
|
60
|
+
* @param {object} [o.timers] {setTimeout, clearTimeout, now}
|
|
61
|
+
*/
|
|
62
|
+
constructor({host, context, stateDir, log, timers}) {
|
|
63
|
+
this.host = host;
|
|
64
|
+
this.context = context;
|
|
65
|
+
this.stateDir = stateDir;
|
|
66
|
+
this.log = log;
|
|
67
|
+
this.timers = {setTimeout, clearTimeout, now: Date.now, ...(timers || {})};
|
|
68
|
+
this.values = new Map();
|
|
69
|
+
this.workingTimers = new Map();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
file() {
|
|
73
|
+
return this.stateDir ? path.join(this.stateDir, 'values.json') : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
load() {
|
|
77
|
+
const file = this.file();
|
|
78
|
+
if (!file) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const {values} = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
83
|
+
for (const [name, message] of Object.entries(values || {})) {
|
|
84
|
+
this.values.set(name, {...message, cache: true, change: false, uncertain: true});
|
|
85
|
+
}
|
|
86
|
+
this.log.info('loaded', this.values.size, 'values from', file);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err.code !== 'ENOENT') {
|
|
89
|
+
this.log.warn('cannot read', file, '-', err.message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
save() {
|
|
95
|
+
const file = this.file();
|
|
96
|
+
if (!file) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
fs.mkdirSync(this.stateDir, {recursive: true});
|
|
101
|
+
fs.writeFileSync(file, JSON.stringify({values: Object.fromEntries(this.values)}));
|
|
102
|
+
this.log.debug('saved', this.values.size, 'values to', file);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
this.log.warn('cannot save', file, '-', err.message);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get(iface, channel, datapoint) {
|
|
109
|
+
return this.values.get(`${iface}.${channel}.${datapoint}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* node-red-contrib-ccu createMessage(): the full message of a datapoint value.
|
|
114
|
+
* @param {string} iface
|
|
115
|
+
* @param {string} channel
|
|
116
|
+
* @param {string} datapoint
|
|
117
|
+
* @param {*} value
|
|
118
|
+
* @param {object} [additions] cache, uncertain, working, direction, ts, lc, change overrides
|
|
119
|
+
*/
|
|
120
|
+
/**
|
|
121
|
+
* The static (value-independent) fields of a datapoint: device, channel, datapoint and
|
|
122
|
+
* description facts, rooms and functions — what item templates can use.
|
|
123
|
+
*/
|
|
124
|
+
fields(iface, channel, datapoint) {
|
|
125
|
+
const {context} = this;
|
|
126
|
+
const channelDevice = context.device(iface, channel);
|
|
127
|
+
const deviceAddress = channelDevice && channelDevice.PARENT;
|
|
128
|
+
const device = deviceAddress ? context.device(iface, deviceAddress) : undefined;
|
|
129
|
+
const description = context.valueDescription(iface, channel, datapoint) || {};
|
|
130
|
+
const rooms = context.rooms(channel) || [];
|
|
131
|
+
const functions = context.functions(channel) || [];
|
|
132
|
+
const list = description.VALUE_LIST || description.ENUM;
|
|
133
|
+
return {
|
|
134
|
+
ccu: this.host,
|
|
135
|
+
iface,
|
|
136
|
+
device: deviceAddress,
|
|
137
|
+
deviceName: deviceAddress ? context.channelName(deviceAddress) : undefined,
|
|
138
|
+
deviceType: device && device.TYPE,
|
|
139
|
+
channel,
|
|
140
|
+
channelName: context.channelName(channel),
|
|
141
|
+
channelType: channelDevice && channelDevice.TYPE,
|
|
142
|
+
channelIndex: channel.includes(':') ? Number.parseInt(channel.split(':')[1], 10) : undefined,
|
|
143
|
+
datapoint,
|
|
144
|
+
datapointName: `${iface}.${channel}.${datapoint}`,
|
|
145
|
+
datapointType: description.TYPE,
|
|
146
|
+
datapointMin: description.MIN,
|
|
147
|
+
datapointMax: description.MAX,
|
|
148
|
+
datapointEnum: list,
|
|
149
|
+
datapointDefault: description.DEFAULT,
|
|
150
|
+
datapointControl: description.CONTROL,
|
|
151
|
+
datapointUnit: unit(description),
|
|
152
|
+
rooms,
|
|
153
|
+
room: rooms.length > 0 ? rooms[0] : undefined,
|
|
154
|
+
functions,
|
|
155
|
+
function: functions.length > 0 ? functions[0] : undefined,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
message(iface, channel, datapoint, value, additions = {}) {
|
|
160
|
+
const {context} = this;
|
|
161
|
+
const fields = this.fields(iface, channel, datapoint);
|
|
162
|
+
const {datapointName} = fields;
|
|
163
|
+
const previous = this.values.get(datapointName) || {};
|
|
164
|
+
const description = context.valueDescription(iface, channel, datapoint) || {};
|
|
165
|
+
const ts = this.timers.now();
|
|
166
|
+
const valueStable = additions.working ? previous.valueStable : value;
|
|
167
|
+
const change =
|
|
168
|
+
description.TYPE === 'ACTION' ||
|
|
169
|
+
Boolean(previous.cache) ||
|
|
170
|
+
previous.payload !== value ||
|
|
171
|
+
previous.valueStable !== valueStable;
|
|
172
|
+
const list = fields.datapointEnum;
|
|
173
|
+
|
|
174
|
+
const message = {
|
|
175
|
+
topic: '',
|
|
176
|
+
payload: value,
|
|
177
|
+
...fields,
|
|
178
|
+
value,
|
|
179
|
+
valuePrevious: previous.value,
|
|
180
|
+
valueEnum: Array.isArray(list) ? list[Number(value)] : undefined,
|
|
181
|
+
valueStable,
|
|
182
|
+
ts,
|
|
183
|
+
tsPrevious: previous.ts,
|
|
184
|
+
lc: change ? ts : previous.lc,
|
|
185
|
+
change,
|
|
186
|
+
...additions,
|
|
187
|
+
};
|
|
188
|
+
message.stable = !message.working;
|
|
189
|
+
return message;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Applies an event from an interface process. `emit(message)` is called once the value is
|
|
194
|
+
* final — immediately, or 300 ms later for actuator datapoints whose WORKING/DIRECTION may
|
|
195
|
+
* follow in a separate call (node-red-contrib-ccu's publishEvent).
|
|
196
|
+
* @param {{iface: string, channel: string, datapoint: string, value: *, working?: boolean, direction?: number}} event
|
|
197
|
+
* @param {(message: object) => void} emit
|
|
198
|
+
*/
|
|
199
|
+
event({iface, channel, datapoint, value, working, direction}, emit) {
|
|
200
|
+
const message = this.message(iface, channel, datapoint, value, {
|
|
201
|
+
cache: false,
|
|
202
|
+
uncertain: false,
|
|
203
|
+
working,
|
|
204
|
+
direction,
|
|
205
|
+
});
|
|
206
|
+
if (working || !waitsForWorking(datapoint, message.channelType)) {
|
|
207
|
+
this.values.set(message.datapointName, message);
|
|
208
|
+
emit(message);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const key = message.datapointName;
|
|
212
|
+
this.timers.clearTimeout(this.workingTimers.get(key));
|
|
213
|
+
this.workingTimers.set(
|
|
214
|
+
key,
|
|
215
|
+
this.timers.setTimeout(() => {
|
|
216
|
+
this.workingTimers.delete(key);
|
|
217
|
+
const v = (dp) => this.get(iface, channel, dp);
|
|
218
|
+
const w = v('WORKING') || v('WORKING_SLATS');
|
|
219
|
+
if (w) {
|
|
220
|
+
message.working = Boolean(
|
|
221
|
+
(v('WORKING') && v('WORKING').value) || (v('WORKING_SLATS') && v('WORKING_SLATS').value),
|
|
222
|
+
);
|
|
223
|
+
} else if (v('PROCESS')) {
|
|
224
|
+
message.working = Boolean(v('PROCESS').value);
|
|
225
|
+
}
|
|
226
|
+
if (v('DIRECTION')) {
|
|
227
|
+
message.direction = v('DIRECTION').value;
|
|
228
|
+
} else if (v('ACTIVITY_STATE')) {
|
|
229
|
+
const a = v('ACTIVITY_STATE').value;
|
|
230
|
+
message.direction = a === 0 ? 3 : a === 3 ? 0 : a;
|
|
231
|
+
}
|
|
232
|
+
message.stable = !message.working;
|
|
233
|
+
this.values.set(key, message);
|
|
234
|
+
emit(message);
|
|
235
|
+
}, WORKING_WAIT_MS),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A value from the ReGa cache (getValues) — not an event: cache/uncertain flags, CCU timestamp.
|
|
241
|
+
* @param {{name: string, value: *, ts: number}} dp name = <iface>.<channel>.<datapoint>
|
|
242
|
+
* @returns {object | null} the message, null when the name does not parse
|
|
243
|
+
*/
|
|
244
|
+
cached({name, value, ts}) {
|
|
245
|
+
const [iface, channel, datapoint] = String(name).split('.');
|
|
246
|
+
if (!iface || !channel || !datapoint) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if ((datapoint === 'RSSI_DEVICE' || datapoint === 'RSSI_PEER') && typeof value === 'number' && value > 127) {
|
|
250
|
+
value -= 256; // the ReGa reports the unsigned byte
|
|
251
|
+
}
|
|
252
|
+
const now = this.timers.now();
|
|
253
|
+
const message = this.message(iface, channel, datapoint, value, {
|
|
254
|
+
cache: true,
|
|
255
|
+
change: false,
|
|
256
|
+
working: false,
|
|
257
|
+
uncertain: !ts,
|
|
258
|
+
ts: ts || now,
|
|
259
|
+
lc: ts || now,
|
|
260
|
+
});
|
|
261
|
+
this.values.set(message.datapointName, message);
|
|
262
|
+
return message;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The LEVEL_NOTWORKING / STATE_NOTWORKING companion of a message, or null. */
|
|
266
|
+
notWorking(message) {
|
|
267
|
+
if (message.working !== false || !['LEVEL', 'STATE'].includes(message.datapoint)) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
...message,
|
|
272
|
+
datapoint: message.datapoint + '_NOTWORKING',
|
|
273
|
+
datapointName: message.datapointName + '_NOTWORKING',
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
stop() {
|
|
278
|
+
for (const timer of this.workingTimers.values()) {
|
|
279
|
+
this.timers.clearTimeout(timer);
|
|
280
|
+
}
|
|
281
|
+
this.workingTimers.clear();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"title": "hm2mqtt device and channel names",
|
|
4
|
+
"description": "Maps device and channel addresses (e.g. OEQ1234567:1) to the names used in topics; overrides the ReGa names.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": {"type": "string", "minLength": 1},
|
|
7
|
+
"propertyNames": {"pattern": "^[A-Za-z0-9-]+(:[0-9]+)?$"}
|
|
8
|
+
}
|
package/package.json
CHANGED
|
@@ -1,27 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hm2mqtt",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Interface between Homematic and MQTT",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Interface between Homematic CCU and MQTT",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "index.js",
|
|
7
|
+
"preferGlobal": true,
|
|
6
8
|
"bin": {
|
|
7
|
-
"hm2mqtt": "
|
|
9
|
+
"hm2mqtt": "index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"config.js",
|
|
14
|
+
"lib/",
|
|
15
|
+
"paramsets.json",
|
|
16
|
+
"example-names.json",
|
|
17
|
+
"names.schema.json",
|
|
18
|
+
"scripts/"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": "^20.19 || ^22.12 || >=24"
|
|
8
22
|
},
|
|
9
|
-
"preferGlobal": true,
|
|
10
23
|
"scripts": {
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
24
|
+
"start": "node index.js",
|
|
25
|
+
"lint": "eslint . && prettier --check .",
|
|
26
|
+
"format": "prettier --write . && eslint --fix .",
|
|
27
|
+
"test": "node --test",
|
|
28
|
+
"test:watch": "node --test --watch",
|
|
29
|
+
"deploy": "bash deploy.sh",
|
|
30
|
+
"test:e2e": "HM2MQTT_E2E=1 node --test --test-force-exit test/e2e.test.js"
|
|
15
31
|
},
|
|
16
32
|
"repository": {
|
|
17
33
|
"type": "git",
|
|
18
|
-
"url": "https://github.com/hobbyquaker/hm2mqtt.js"
|
|
34
|
+
"url": "git+https://github.com/hobbyquaker/hm2mqtt.js.git"
|
|
19
35
|
},
|
|
20
36
|
"keywords": [
|
|
21
37
|
"mqtt",
|
|
22
38
|
"smarthome",
|
|
39
|
+
"mqtt-smarthome",
|
|
40
|
+
"mqtt-interfaces",
|
|
41
|
+
"home-automation",
|
|
42
|
+
"home-assistant",
|
|
23
43
|
"homematic",
|
|
44
|
+
"homematic-ip",
|
|
45
|
+
"ccu",
|
|
24
46
|
"bidcos",
|
|
47
|
+
"hmip",
|
|
25
48
|
"eq-3"
|
|
26
49
|
],
|
|
27
50
|
"author": "Sebastian Raff <hobbyquaker@gmail.com> (https://hobbyquaker.github.io)",
|
|
@@ -34,36 +57,31 @@
|
|
|
34
57
|
"url": "https://github.com/hobbyquaker/hm2mqtt.js/issues"
|
|
35
58
|
},
|
|
36
59
|
"homepage": "https://github.com/hobbyquaker/hm2mqtt.js",
|
|
60
|
+
"mqttInterfaces": {
|
|
61
|
+
"spec": "2.0",
|
|
62
|
+
"envPrefix": "HM2MQTT",
|
|
63
|
+
"needs": [
|
|
64
|
+
"network"
|
|
65
|
+
],
|
|
66
|
+
"serviceExtra": []
|
|
67
|
+
},
|
|
37
68
|
"dependencies": {
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"homematic-xmlrpc": "^
|
|
41
|
-
"mqtt": "^
|
|
42
|
-
"persist-json": "^1.2.0",
|
|
43
|
-
"request": "^2.83.0",
|
|
44
|
-
"yalm": "^4.1.0",
|
|
45
|
-
"yargs": "^11.0.0"
|
|
69
|
+
"binrpc": "^3.3.1",
|
|
70
|
+
"homematic-rega": "^2.0.0",
|
|
71
|
+
"homematic-xmlrpc": "^2.0.0",
|
|
72
|
+
"mqtt-interfaces-core": "^0.7.0"
|
|
46
73
|
},
|
|
47
74
|
"devDependencies": {
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"xo": "latest",
|
|
55
|
-
"should": "latest"
|
|
56
|
-
},
|
|
57
|
-
"engines": {
|
|
58
|
-
"node": ">=6.0.0"
|
|
59
|
-
},
|
|
60
|
-
"yargs": {
|
|
61
|
-
"boolean-negation": false
|
|
75
|
+
"@eslint/js": "^9.0.0",
|
|
76
|
+
"eslint": "^9.0.0",
|
|
77
|
+
"eslint-config-prettier": "^10.0.0",
|
|
78
|
+
"globals": "^16.0.0",
|
|
79
|
+
"hm-simulator": "^0.1.1",
|
|
80
|
+
"prettier": "^3.0.0"
|
|
62
81
|
},
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
]
|
|
82
|
+
"overrides": {
|
|
83
|
+
"hm-simulator": {
|
|
84
|
+
"homematic-xmlrpc": "^2.0.0"
|
|
85
|
+
}
|
|
68
86
|
}
|
|
69
87
|
}
|