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/lib/rega.js ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The ReGaHSS side: device/channel names, rooms and functions (persisted as rega.json),
3
+ * system variables and programs with change detection and polling, and their set operations.
4
+ * Messages follow node-red-contrib-ccu's sysvar/program objects (the `hm` block of the payload).
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import {EventEmitter} from 'node:events';
10
+ import {castVariable} from './cast.js';
11
+
12
+ export class RegaSync extends EventEmitter {
13
+ /**
14
+ * @param {object} o
15
+ * @param {object} o.rega homematic-rega instance
16
+ * @param {string} o.host CCU address (`ccu` field)
17
+ * @param {object} [o.metadata] Metadata (channel fields of variables bound to a channel)
18
+ * @param {string} [o.stateDir]
19
+ * @param {Object<string, string>} [o.nameFile] {address: name} overriding ReGa names
20
+ * @param {object} o.log
21
+ * @param {() => number} [o.now]
22
+ */
23
+ constructor({rega, host, metadata, stateDir, nameFile, log, now}) {
24
+ super();
25
+ this.rega = rega;
26
+ this.host = host;
27
+ this.metadata = metadata;
28
+ this.stateDir = stateDir;
29
+ this.nameFile = nameFile || {};
30
+ this.log = log;
31
+ this.now = now || Date.now;
32
+ this.channelNames = {};
33
+ this.addresses = {};
34
+ this.regaIdChannel = {};
35
+ this.channelRooms = {};
36
+ this.channelFunctions = {};
37
+ this.sysvars = {};
38
+ this.programs = {};
39
+ this.pollPending = false;
40
+ this.pollTimer = null;
41
+ this.pollInterval = 0;
42
+ this.ready = false;
43
+ }
44
+
45
+ file() {
46
+ return this.stateDir ? path.join(this.stateDir, 'rega.json') : null;
47
+ }
48
+
49
+ load() {
50
+ const file = this.file();
51
+ if (!file) {
52
+ return;
53
+ }
54
+ try {
55
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
56
+ this.channelNames = data.channelNames || {};
57
+ this.regaIdChannel = data.regaIdChannel || {};
58
+ this.channelRooms = data.channelRooms || {};
59
+ this.channelFunctions = data.channelFunctions || {};
60
+ this.applyNameFile();
61
+ this.log.info('loaded', Object.keys(this.channelNames).length, 'names from', file);
62
+ } catch (err) {
63
+ if (err.code !== 'ENOENT') {
64
+ this.log.warn('cannot read', file, '-', err.message);
65
+ }
66
+ }
67
+ }
68
+
69
+ save() {
70
+ const file = this.file();
71
+ if (!file) {
72
+ return;
73
+ }
74
+ try {
75
+ fs.mkdirSync(this.stateDir, {recursive: true});
76
+ fs.writeFileSync(
77
+ file,
78
+ JSON.stringify({
79
+ channelNames: this.channelNames,
80
+ regaIdChannel: this.regaIdChannel,
81
+ channelRooms: this.channelRooms,
82
+ channelFunctions: this.channelFunctions,
83
+ }),
84
+ );
85
+ } catch (err) {
86
+ this.log.warn('cannot save', file, '-', err.message);
87
+ }
88
+ }
89
+
90
+ applyNameFile() {
91
+ for (const [address, name] of Object.entries(this.nameFile)) {
92
+ if (typeof name === 'string' && name !== '') {
93
+ this.channelNames[address] = name;
94
+ }
95
+ }
96
+ this.addresses = {};
97
+ for (const [address, name] of Object.entries(this.channelNames)) {
98
+ // the first address of a duplicate name wins; channels win over devices
99
+ if (!this.addresses[name] || (address.includes(':') && !this.addresses[name].includes(':'))) {
100
+ this.addresses[name] = address;
101
+ }
102
+ }
103
+ }
104
+
105
+ /*
106
+ * lookups
107
+ */
108
+
109
+ channelName(address) {
110
+ return this.channelNames[address];
111
+ }
112
+
113
+ /** Address of a channel (or device, with `devices`) by ReGa name or address. */
114
+ channelAddress(nameOrAddress, devices = false) {
115
+ let address;
116
+ if (this.metadata && this.metadata.findIface(nameOrAddress)) {
117
+ address = nameOrAddress;
118
+ } else if (this.addresses[nameOrAddress]) {
119
+ address = this.addresses[nameOrAddress];
120
+ } else if (!this.metadata && /^[\w-]+(:\d+)?$/.test(nameOrAddress)) {
121
+ address = nameOrAddress;
122
+ }
123
+ if (!address) {
124
+ return undefined;
125
+ }
126
+ return devices || address.includes(':') ? address : undefined;
127
+ }
128
+
129
+ rooms(address) {
130
+ return this.channelRooms[address];
131
+ }
132
+
133
+ functions(address) {
134
+ return this.channelFunctions[address];
135
+ }
136
+
137
+ hasVariable(name) {
138
+ return Boolean(this.sysvars[name]);
139
+ }
140
+
141
+ hasProgram(name) {
142
+ return Boolean(this.programs[name]);
143
+ }
144
+
145
+ /*
146
+ * names, rooms, functions
147
+ */
148
+
149
+ /** Reads channels, rooms and functions. Emits 'names' when done. */
150
+ async syncNames() {
151
+ const channels = await this.rega.getChannels();
152
+ if (channels.length > 0) {
153
+ this.channelNames = {};
154
+ this.regaIdChannel = {};
155
+ }
156
+ for (const ch of channels) {
157
+ this.channelNames[ch.address] = ch.name;
158
+ this.regaIdChannel[ch.id] = ch.address;
159
+ }
160
+ const rooms = await this.rega.getRooms();
161
+ this.channelRooms = {};
162
+ for (const room of rooms) {
163
+ for (const id of room.channels) {
164
+ const address = this.regaIdChannel[id];
165
+ if (address) {
166
+ (this.channelRooms[address] = this.channelRooms[address] || []).push(room.name);
167
+ }
168
+ }
169
+ }
170
+ const functions = await this.rega.getFunctions();
171
+ this.channelFunctions = {};
172
+ for (const func of functions) {
173
+ for (const id of func.channels) {
174
+ const address = this.regaIdChannel[id];
175
+ if (address) {
176
+ (this.channelFunctions[address] = this.channelFunctions[address] || []).push(func.name);
177
+ }
178
+ }
179
+ }
180
+ this.applyNameFile();
181
+ this.log.info('rega:', channels.length, 'channels,', rooms.length, 'rooms,', functions.length, 'functions');
182
+ this.save();
183
+ this.emit('names');
184
+ }
185
+
186
+ /*
187
+ * variables and programs
188
+ */
189
+
190
+ channelFields(channelId) {
191
+ const channel = this.regaIdChannel[channelId];
192
+ if (!channel) {
193
+ return {};
194
+ }
195
+ const iface = this.metadata && this.metadata.findIface(channel);
196
+ const ch = iface && this.metadata.device(iface, channel);
197
+ const deviceAddress = ch && ch.PARENT;
198
+ const device = deviceAddress && this.metadata.device(iface, deviceAddress);
199
+ const rooms = this.channelRooms[channel];
200
+ const functions = this.channelFunctions[channel];
201
+ return {
202
+ device: deviceAddress,
203
+ deviceName: deviceAddress ? this.channelNames[deviceAddress] : undefined,
204
+ deviceType: device && device.TYPE,
205
+ channel,
206
+ channelName: this.channelNames[channel],
207
+ channelType: ch && ch.TYPE,
208
+ channelIndex: channel.includes(':') ? Number.parseInt(channel.split(':')[1], 10) : undefined,
209
+ rooms,
210
+ room: rooms && rooms.length === 1 ? rooms[0] : undefined,
211
+ functions,
212
+ function: functions && functions.length === 1 ? functions[0] : undefined,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Merges a variable from getVariables(); returns the message when it is new or changed
218
+ * (node-red-contrib-ccu's updateRegaVariable), else null.
219
+ */
220
+ updateVariable(sysvar) {
221
+ const ts = sysvar.ts || this.now();
222
+ let current = this.sysvars[sysvar.name];
223
+ const isNew = !current;
224
+ if (isNew) {
225
+ current = {
226
+ topic: '',
227
+ payload: sysvar.val,
228
+ ccu: this.host,
229
+ iface: 'ReGaHSS',
230
+ type: 'SYSVAR',
231
+ name: sysvar.name,
232
+ info: sysvar.info,
233
+ value: sysvar.val,
234
+ valueType: sysvar.type,
235
+ valueEnum: sysvar.enum[Number(sysvar.val)],
236
+ unit: sysvar.unit,
237
+ enum: sysvar.enum,
238
+ id: sysvar.id,
239
+ cache: true,
240
+ ...(sysvar.channel ? this.channelFields(sysvar.channel) : {}),
241
+ };
242
+ this.sysvars[sysvar.name] = current;
243
+ }
244
+ if (!isNew && current.ts === ts) {
245
+ return null;
246
+ }
247
+ const changed = !isNew && current.value !== sysvar.val;
248
+ Object.assign(current, {
249
+ payload: sysvar.val,
250
+ info: sysvar.info,
251
+ value: sysvar.val,
252
+ valueEnum: current.enum[Number(sysvar.val)],
253
+ valuePrevious: isNew ? undefined : current.value,
254
+ valueEnumPrevious: isNew ? undefined : current.valueEnum,
255
+ ts,
256
+ tsPrevious: isNew ? undefined : current.ts,
257
+ lc: isNew ? ts : changed ? ts : current.lc,
258
+ lcPrevious: isNew ? undefined : current.lc,
259
+ change: changed,
260
+ cache: isNew,
261
+ });
262
+ return {...current};
263
+ }
264
+
265
+ updateProgram(prg) {
266
+ const ts = prg.ts || 0;
267
+ const current = this.programs[prg.name];
268
+ if (current && current.active === prg.active && current.ts === ts) {
269
+ return null;
270
+ }
271
+ const message = {
272
+ id: prg.id,
273
+ ccu: this.host,
274
+ iface: 'ReGaHSS',
275
+ type: 'PROGRAM',
276
+ name: prg.name,
277
+ payload: prg.active,
278
+ value: prg.active,
279
+ active: prg.active,
280
+ activePrevious: current ? current.active : undefined,
281
+ ts,
282
+ tsPrevious: current ? current.ts : undefined,
283
+ };
284
+ this.programs[prg.name] = message;
285
+ return {...message};
286
+ }
287
+
288
+ /** Polls variables and programs; emits 'sysvar' / 'program' for new or changed ones. */
289
+ async poll() {
290
+ if (this.pollPending) {
291
+ this.log.debug('rega poll already pending');
292
+ return;
293
+ }
294
+ this.pollPending = true;
295
+ try {
296
+ const variables = await this.rega.getVariables();
297
+ for (const sysvar of variables) {
298
+ const message = this.updateVariable(sysvar);
299
+ if (message) {
300
+ this.emit('sysvar', message);
301
+ }
302
+ }
303
+ const programs = await this.rega.getPrograms();
304
+ for (const prg of programs) {
305
+ const message = this.updateProgram(prg);
306
+ if (message) {
307
+ this.emit('program', message);
308
+ }
309
+ }
310
+ if (!this.ready) {
311
+ this.ready = true;
312
+ this.log.info('rega:', variables.length, 'variables,', programs.length, 'programs');
313
+ }
314
+ this.emit('polled');
315
+ } finally {
316
+ this.pollPending = false;
317
+ }
318
+ }
319
+
320
+ startPolling(intervalSeconds, {setTimer = setTimeout, clearTimer = clearTimeout} = {}) {
321
+ this.pollInterval = intervalSeconds;
322
+ this._setTimer = setTimer;
323
+ this._clearTimer = clearTimer;
324
+ const loop = async () => {
325
+ try {
326
+ await this.poll();
327
+ } catch (err) {
328
+ this.log.warn('rega poll failed:', err.message);
329
+ this.emit('error', err);
330
+ }
331
+ if (this.pollInterval > 0) {
332
+ this.pollTimer = setTimer(loop, this.pollInterval * 1000);
333
+ }
334
+ };
335
+ return loop();
336
+ }
337
+
338
+ stopPolling() {
339
+ if (this.pollTimer && this._clearTimer) {
340
+ this._clearTimer(this.pollTimer);
341
+ }
342
+ this.pollTimer = null;
343
+ this.pollInterval = 0;
344
+ }
345
+
346
+ /*
347
+ * set
348
+ */
349
+
350
+ async setVariable(name, value) {
351
+ const sysvar = this.sysvars[name];
352
+ if (!sysvar) {
353
+ throw new Error(`variable ${name} unknown`);
354
+ }
355
+ const cast = castVariable(value, {type: sysvar.valueType, enum: sysvar.enum});
356
+ this.log.debug('rega > setVariable', name, cast);
357
+ await this.rega.setVariable(sysvar.id, cast);
358
+ await this.poll().catch((err) => this.log.warn('rega poll failed:', err.message));
359
+ }
360
+
361
+ async programActive(name, active) {
362
+ const program = this.programs[name];
363
+ if (!program) {
364
+ throw new Error(`program ${name} unknown`);
365
+ }
366
+ this.log.debug('rega > programActive', name, active);
367
+ await this.rega.setProgram(program.id, active);
368
+ await this.poll().catch((err) => this.log.warn('rega poll failed:', err.message));
369
+ }
370
+
371
+ async programExecute(name) {
372
+ const program = this.programs[name];
373
+ if (!program) {
374
+ throw new Error(`program ${name} unknown`);
375
+ }
376
+ this.log.debug('rega > programExecute', name);
377
+ await this.rega.startProgram(program.id);
378
+ await this.poll().catch((err) => this.log.warn('rega poll failed:', err.message));
379
+ }
380
+ }