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.
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Devices/channels per interface and the paramset descriptions, persisted in the state
3
+ * directory (devices.json, paramsets.json). The description table is seeded from the
4
+ * paramsets.json node-red-contrib-ccu collected, so most devices need no fetch.
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const SAVE_DELAY_MS = 10000;
11
+ /** paramsets fetched eagerly for every channel; MASTER on demand, LINK/SERVICE never (battery devices do not answer) */
12
+ const EAGER_PARAMSETS = ['VALUES'];
13
+ const NEVER_PARAMSETS = new Set(['SERVICE']);
14
+ const FETCH_PAUSE_MS = 50;
15
+
16
+ export class Metadata {
17
+ /**
18
+ * @param {object} o
19
+ * @param {string} o.stateDir
20
+ * @param {string} [o.seedFile] paramsets.json shipped with the package
21
+ * @param {object} o.log
22
+ */
23
+ constructor({stateDir, seedFile, log}) {
24
+ this.stateDir = stateDir;
25
+ this.seedFile = seedFile;
26
+ this.log = log;
27
+ this.devices = {};
28
+ this.descriptions = {};
29
+ this.dirty = {devices: false, descriptions: false};
30
+ this.saveTimer = null;
31
+ }
32
+
33
+ file(name) {
34
+ return path.join(this.stateDir, name);
35
+ }
36
+
37
+ readJson(file) {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
40
+ } catch (err) {
41
+ if (err.code !== 'ENOENT') {
42
+ this.log.warn('cannot read', file, '-', err.message);
43
+ }
44
+ return null;
45
+ }
46
+ }
47
+
48
+ load() {
49
+ const devices = this.readJson(this.file('devices.json'));
50
+ if (devices && typeof devices === 'object') {
51
+ this.devices = devices;
52
+ const n = Object.values(devices).reduce((sum, d) => sum + Object.keys(d).length, 0);
53
+ this.log.info('loaded', n, 'devices/channels from', this.file('devices.json'));
54
+ }
55
+ const descriptions = this.readJson(this.file('paramsets.json'));
56
+ if (descriptions && typeof descriptions === 'object') {
57
+ this.descriptions = descriptions;
58
+ this.log.info(
59
+ 'loaded',
60
+ Object.keys(descriptions).length,
61
+ 'paramset descriptions from',
62
+ this.file('paramsets.json'),
63
+ );
64
+ } else if (this.seedFile) {
65
+ const seed = this.readJson(this.seedFile);
66
+ if (seed) {
67
+ this.descriptions = seed;
68
+ this.dirty.descriptions = true;
69
+ this.log.info('loaded', Object.keys(seed).length, 'paramset descriptions from the seed', this.seedFile);
70
+ this.save();
71
+ }
72
+ }
73
+ }
74
+
75
+ scheduleSave() {
76
+ if (this.saveTimer) {
77
+ return;
78
+ }
79
+ this.saveTimer = setTimeout(() => {
80
+ this.saveTimer = null;
81
+ this.save();
82
+ }, SAVE_DELAY_MS);
83
+ if (typeof this.saveTimer.unref === 'function') {
84
+ this.saveTimer.unref();
85
+ }
86
+ }
87
+
88
+ save() {
89
+ clearTimeout(this.saveTimer);
90
+ this.saveTimer = null;
91
+ try {
92
+ fs.mkdirSync(this.stateDir, {recursive: true});
93
+ if (this.dirty.devices) {
94
+ fs.writeFileSync(this.file('devices.json'), JSON.stringify(this.devices));
95
+ this.dirty.devices = false;
96
+ this.log.debug('saved', this.file('devices.json'));
97
+ }
98
+ if (this.dirty.descriptions) {
99
+ fs.writeFileSync(this.file('paramsets.json'), JSON.stringify(this.descriptions));
100
+ this.dirty.descriptions = false;
101
+ this.log.debug('saved', this.file('paramsets.json'));
102
+ }
103
+ } catch (err) {
104
+ this.log.warn('cannot save state to', this.stateDir, '-', err.message);
105
+ }
106
+ }
107
+
108
+ /*
109
+ * devices
110
+ */
111
+
112
+ device(iface, address) {
113
+ return this.devices[iface] && this.devices[iface][address];
114
+ }
115
+
116
+ /** The interface a device/channel address belongs to. */
117
+ findIface(address) {
118
+ for (const iface of Object.keys(this.devices)) {
119
+ if (this.devices[iface][address]) {
120
+ return iface;
121
+ }
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ count(iface) {
127
+ if (iface) {
128
+ return this.devices[iface] ? Object.keys(this.devices[iface]).length : 0;
129
+ }
130
+ return Object.values(this.devices).reduce((sum, d) => sum + Object.keys(d).length, 0);
131
+ }
132
+
133
+ /**
134
+ * Records devices the CCU announced (newDevices). Returns the addresses that were unknown.
135
+ * @param {string} iface
136
+ * @param {object[]} devices
137
+ * @returns {string[]}
138
+ */
139
+ addDevices(iface, devices) {
140
+ if (!this.devices[iface]) {
141
+ this.devices[iface] = {};
142
+ }
143
+ const added = [];
144
+ for (const device of devices) {
145
+ if (!device || typeof device.ADDRESS !== 'string' || !device.TYPE) {
146
+ continue;
147
+ }
148
+ if (!this.devices[iface][device.ADDRESS]) {
149
+ added.push(device.ADDRESS);
150
+ }
151
+ this.devices[iface][device.ADDRESS] = device;
152
+ this.dirty.devices = true;
153
+ }
154
+ if (added.length > 0) {
155
+ this.scheduleSave();
156
+ }
157
+ return added;
158
+ }
159
+
160
+ deleteDevices(iface, addresses) {
161
+ if (!this.devices[iface]) {
162
+ return [];
163
+ }
164
+ const deleted = [];
165
+ for (const address of addresses) {
166
+ if (this.devices[iface][address]) {
167
+ delete this.devices[iface][address];
168
+ deleted.push(address);
169
+ this.dirty.devices = true;
170
+ }
171
+ }
172
+ if (deleted.length > 0) {
173
+ this.scheduleSave();
174
+ }
175
+ return deleted;
176
+ }
177
+
178
+ /**
179
+ * What we tell the CCU on listDevices(): HmIP-RF and VirtualDevices want the whole device,
180
+ * the others only ADDRESS and VERSION (node-red-contrib-ccu's listDevicesAnswer).
181
+ */
182
+ listDevicesAnswer(iface) {
183
+ const devices = this.devices[iface] || {};
184
+ return Object.values(devices).map((device) => {
185
+ if (iface !== 'HmIP-RF' && iface !== 'VirtualDevices') {
186
+ return {ADDRESS: device.ADDRESS, VERSION: device.VERSION};
187
+ }
188
+ const answer = {};
189
+ for (const key of [
190
+ 'ADDRESS',
191
+ 'VERSION',
192
+ 'AES_ACTIVE',
193
+ 'CHILDREN',
194
+ 'DIRECTION',
195
+ 'FIRMWARE',
196
+ 'FLAGS',
197
+ 'GROUP',
198
+ 'INDEX',
199
+ 'INTERFACE',
200
+ 'LINK_SOURCE_ROLES',
201
+ 'LINK_TARGET_ROLES',
202
+ 'PARAMSETS',
203
+ 'PARENT',
204
+ 'PARENT_TYPE',
205
+ 'RF_ADDRESS',
206
+ 'ROAMING',
207
+ 'RX_MODE',
208
+ 'TEAM',
209
+ 'TEAM_CHANNELS',
210
+ 'TEAM_TAG',
211
+ 'TYPE',
212
+ ]) {
213
+ // https://github.com/eq-3/occu/issues/83 — empty strings make the CCU choke
214
+ if (device[key] !== undefined && device[key] !== '') {
215
+ answer[key] = device[key];
216
+ }
217
+ }
218
+ return answer;
219
+ });
220
+ }
221
+
222
+ /*
223
+ * paramset descriptions
224
+ */
225
+
226
+ /**
227
+ * Key of a paramset description: interface, device type, firmware, version, channel type,
228
+ * paramset. Link paramsets (peer address as name) share the LINK key.
229
+ */
230
+ paramsetKey(iface, device, paramset) {
231
+ if (!device) {
232
+ return undefined;
233
+ }
234
+ let channelType = '';
235
+ let parent = device;
236
+ if (device.PARENT) {
237
+ channelType = device.TYPE;
238
+ parent = this.device(iface, device.PARENT);
239
+ if (!parent) {
240
+ return undefined;
241
+ }
242
+ }
243
+ if (/^[\da-f]+:\d+$/i.test(paramset)) {
244
+ paramset = 'LINK';
245
+ }
246
+ return [iface, parent.TYPE, parent.FIRMWARE, parent.VERSION, channelType, paramset].join('/');
247
+ }
248
+
249
+ /** Description of a whole paramset of a device/channel, undefined when not known yet. */
250
+ description(iface, address, paramset) {
251
+ const key = this.paramsetKey(iface, this.device(iface, address), paramset);
252
+ return key ? this.descriptions[key] : undefined;
253
+ }
254
+
255
+ /** Description of one VALUES parameter (datapoint). */
256
+ valueDescription(iface, address, datapoint) {
257
+ const description = this.description(iface, address, 'VALUES');
258
+ return description ? description[datapoint] : undefined;
259
+ }
260
+
261
+ setDescription(key, description) {
262
+ this.descriptions[key] = description;
263
+ this.dirty.descriptions = true;
264
+ this.scheduleSave();
265
+ }
266
+
267
+ /**
268
+ * Paramsets of the given devices (or all of an interface) whose description is unknown,
269
+ * one entry per key. By default only the eager ones (VALUES); MASTER is fetched on demand
270
+ * (fetchDescription), LINK and SERVICE never.
271
+ * @param {string} iface
272
+ * @param {string[]} [addresses]
273
+ * @param {{paramsets?: string[]}} [o]
274
+ * @returns {Array<{key: string, address: string, paramset: string}>}
275
+ */
276
+ missingDescriptions(iface, addresses, {paramsets = EAGER_PARAMSETS} = {}) {
277
+ const devices = this.devices[iface] || {};
278
+ const list = addresses || Object.keys(devices);
279
+ const seen = new Set();
280
+ const missing = [];
281
+ for (const address of list) {
282
+ const device = devices[address];
283
+ if (!device || !Array.isArray(device.PARAMSETS)) {
284
+ continue;
285
+ }
286
+ for (const paramset of device.PARAMSETS) {
287
+ if (!paramsets.includes(paramset)) {
288
+ continue;
289
+ }
290
+ const key = this.paramsetKey(iface, device, paramset);
291
+ if (!key || this.descriptions[key] || seen.has(key)) {
292
+ continue;
293
+ }
294
+ seen.add(key);
295
+ missing.push({key, address, paramset});
296
+ }
297
+ }
298
+ return missing;
299
+ }
300
+
301
+ /**
302
+ * Fetches the missing descriptions sequentially (the CCU does not like bursts).
303
+ * @param {string} iface
304
+ * @param {(method: string, params: Array) => Promise<*>} methodCall
305
+ * @param {{addresses?: string[], pause?: number, sleep?: Function}} [o]
306
+ * @returns {Promise<number>} number fetched
307
+ */
308
+ async fetchDescriptions(iface, methodCall, {addresses, pause = FETCH_PAUSE_MS, sleep} = {}) {
309
+ const wait = sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
310
+ const missing = this.missingDescriptions(iface, addresses);
311
+ if (missing.length === 0) {
312
+ return 0;
313
+ }
314
+ this.log.info(iface, 'fetching', missing.length, 'paramset descriptions');
315
+ let fetched = 0;
316
+ for (const {key, address, paramset} of missing) {
317
+ if (this.descriptions[key]) {
318
+ continue;
319
+ }
320
+ try {
321
+ const description = await methodCall('getParamsetDescription', [address, paramset]);
322
+ if (description && typeof description === 'object') {
323
+ this.setDescription(key, description);
324
+ fetched += 1;
325
+ }
326
+ } catch (err) {
327
+ this.log.warn(iface, 'getParamsetDescription', address, paramset, 'failed:', err.message);
328
+ }
329
+ await wait(pause);
330
+ }
331
+ this.save();
332
+ return fetched;
333
+ }
334
+
335
+ /**
336
+ * Description of one paramset, fetched on demand when unknown (MASTER, or a LINK partner
337
+ * paramset). SERVICE is never fetched.
338
+ * @returns {Promise<object | undefined>}
339
+ */
340
+ async fetchDescription(iface, address, paramset, methodCall) {
341
+ const known = this.description(iface, address, paramset);
342
+ if (known || NEVER_PARAMSETS.has(paramset)) {
343
+ return known;
344
+ }
345
+ const key = this.paramsetKey(iface, this.device(iface, address), paramset);
346
+ if (!key) {
347
+ return undefined;
348
+ }
349
+ try {
350
+ const description = await methodCall('getParamsetDescription', [address, paramset]);
351
+ if (description && typeof description === 'object') {
352
+ this.setDescription(key, description);
353
+ return description;
354
+ }
355
+ } catch (err) {
356
+ this.log.warn(iface, 'getParamsetDescription', address, paramset, 'failed:', err.message);
357
+ }
358
+ return undefined;
359
+ }
360
+ }