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/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": "2.5.0",
4
- "description": "Interface between Homematic and MQTT",
3
+ "version": "3.1.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": "./index.js"
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
- "test": "camo-purge ; xo && nyc mocha --exit test.js && nyc report --reporter=text-lcov | coveralls --force",
12
- "testonly": "mocha --exit test.js",
13
- "lint": "xo",
14
- "lintfix": "xo --fix"
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
- "async": "^2.6.0",
39
- "binrpc": "^3.1.2",
40
- "homematic-xmlrpc": "^1.0.2",
41
- "mqtt": "^2.16.0",
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
- "camo-purge": "latest",
49
- "coveralls": "latest",
50
- "hm-simulator": "latest",
51
- "mocha": "latest",
52
- "nyc": "latest",
53
- "stream-splitter": "latest",
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
- "xo": {
64
- "space": 4,
65
- "ignore": [
66
- "test.js"
67
- ]
82
+ "overrides": {
83
+ "hm-simulator": {
84
+ "homematic-xmlrpc": "^2.0.0"
85
+ }
68
86
  }
69
87
  }