hm2mqtt 2.5.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/index.js CHANGED
@@ -1,1084 +1,705 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- /* eslint-disable prefer-destructuring */
4
-
5
- const fs = require('fs');
6
- const path = require('path');
7
- const pjson = require('persist-json')('hm2mqtt');
8
- const async = require('async');
9
- const log = require('yalm');
10
- const request = require('request');
11
- const Mqtt = require('mqtt');
12
- const xmlrpc = require('homematic-xmlrpc');
13
- const binrpc = require('binrpc');
14
- const discover = require('./discover.js');
15
- const pkg = require('./package.json');
16
- const config = require('./config.js');
17
-
18
- log.setLevel(config.verbosity);
19
- log.info(pkg.name + ' ' + pkg.version + ' starting');
20
-
21
- const rpcClient = {};
22
- const rpcServer = {};
23
- const ifaceConnected = {};
24
- let ifaceAllConnected = false;
25
- const values = {};
26
- const changes = {};
27
- const working = {};
28
- const workingTimeout = {};
29
- let names = {};
30
- const addresses = {};
31
- function reverseNames() {
32
- Object.keys(names).forEach(address => {
33
- addresses[names[address]] = address;
34
- });
3
+ /**
4
+ * hm2mqtt — Homematic CCU to MQTT. A drop-in replacement for the node-red-contrib-ccu
5
+ * `ccu-mqtt` flow on mqtt-interfaces-core; see ROADMAP.md for the decisions (H-n).
6
+ */
7
+
8
+ import dns from 'node:dns/promises';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import {fileURLToPath} from 'node:url';
13
+ import {createAdapter} from 'mqtt-interfaces-core';
14
+ import {Rega} from 'homematic-rega';
15
+ import config from './config.js';
16
+ import pkg from './package.json' with {type: 'json'};
17
+ import {handle as handleInstall} from './lib/install.js';
18
+ import {parseInterfaces, probeInterfaces, interfaceConfig} from './lib/interfaces.js';
19
+ import {RpcServers, RpcConnection} from './lib/rpc.js';
20
+ import {Metadata} from './lib/metadata.js';
21
+ import {ValueStore, hmBlock, isEvent} from './lib/values.js';
22
+ import {RegaSync} from './lib/rega.js';
23
+ import {castValue, isWriteable} from './lib/cast.js';
24
+ import {sanitizeName, resolveSet, resolveParamset, plainValue, compileTemplate, ItemIndex} from './lib/topics.js';
25
+
26
+ handleInstall(config);
27
+
28
+ const COUNTER_INTERVAL_MS = 30000;
29
+ const VALUES_SAVE_MS = 300000;
30
+ const SET_THROTTLE_MS = 500;
31
+ const DEVICES_WAIT_MS = 10000;
32
+ const STOP_TIMEOUT_MS = 1500;
33
+ const RESOLVE_RETRY_MS = 10000;
34
+
35
+ const here = path.dirname(fileURLToPath(import.meta.url));
36
+ /** the configured CCU address (the `ccu` field of every payload); connections use the resolved ip */
37
+ const host = config.ccuAddress;
38
+ let ccuIp = host;
39
+
40
+ /** Resolves the CCU once (this host's resolver proved flaky under parallel lookups); retries forever. */
41
+ async function resolveCcu() {
42
+ for (;;) {
43
+ try {
44
+ const {address} = await dns.lookup(host, {family: 4});
45
+ if (address !== host) {
46
+ log.info('ccu', host, 'resolves to', address);
47
+ }
48
+ return address;
49
+ } catch (err) {
50
+ log.warn('cannot resolve', host, '(' + err.message + ') - retrying in', RESOLVE_RETRY_MS / 1000, 's');
51
+ await new Promise((resolve) => setTimeout(resolve, RESOLVE_RETRY_MS));
52
+ }
53
+ }
35
54
  }
36
55
 
37
- const devices = {};
38
- log.debug('loading', 'paramsetDescriptions_' + fileName());
39
- const paramsetDescriptions = pjson.load('paramsetDescriptions_' + fileName()) || {};
40
- const getParamsetTimeout = {};
41
- const paramsetQueue = {};
42
- const lastEvent = {};
56
+ function firstIp() {
57
+ for (const list of Object.values(os.networkInterfaces())) {
58
+ for (const addr of list) {
59
+ if (addr.family === 'IPv4' && !addr.internal) {
60
+ return addr.address;
61
+ }
62
+ }
63
+ }
64
+ return '0.0.0.0';
65
+ }
43
66
 
44
- const programs = {};
45
- const programNames = {};
46
- const variables = {};
47
- const variableNames = {};
48
- const variableType = {
49
- 2: 'BOOL',
50
- 4: 'FLOAT',
51
- 16: 'INTEGER',
52
- 20: 'STRING'
53
- };
67
+ /*
68
+ * state
69
+ */
54
70
 
55
- let mqttConnected;
71
+ // the option's default is $STATE_DIRECTORY only, so --install does not freeze this fallback into the env file
72
+ config.stateDir = config.stateDir || path.join(os.homedir(), '.hm2mqtt');
73
+ fs.mkdirSync(config.stateDir, {recursive: true});
56
74
 
57
- log.info('mqtt trying to connect', config.mqttUrl);
75
+ let nameFile = {};
76
+ if (config.nameFile) {
77
+ nameFile = JSON.parse(fs.readFileSync(path.resolve(config.nameFile), 'utf8'));
78
+ }
58
79
 
59
- const mqtt = Mqtt.connect(config.mqttUrl, {
60
- clientId: config.name + '_' + Math.random().toString(16).substr(2, 8),
61
- will: {topic: config.name + '/connected', payload: '0', retain: (config.mqttRetain)},
62
- rejectUnauthorized: !config.insecure
80
+ const adapter = createAdapter({
81
+ pkg,
82
+ config,
83
+ deviceLabel: 'ccu',
84
+ info: () => ({ccu: host, interfaces: enabled, devices: metadata.count(), rega: Boolean(regaSync)}),
85
+ onSet: handleSet,
86
+ subscriptions: {
87
+ 'paramset/#': handleParamset,
88
+ ...(config.rpcTopics ? {'rpc/+/+/+': handleRpc} : {}),
89
+ },
90
+ onShutdown: shutdown,
63
91
  });
92
+ const {log, pubStatus} = adapter;
93
+
94
+ const metadata = new Metadata({stateDir: config.stateDir, seedFile: path.join(here, 'paramsets.json'), log});
95
+ metadata.load();
96
+
97
+ let regaSync = null;
98
+ if (config.rega) {
99
+ const rega = new Rega({
100
+ host: ccuIp,
101
+ tls: config.ccuTls,
102
+ insecure: config.ccuInsecure,
103
+ username: config.ccuUsername,
104
+ password: config.ccuPassword,
105
+ timeZone: config.ccuTimezone,
106
+ });
107
+ regaSync = new RegaSync({rega, host, metadata, stateDir: config.stateDir, nameFile, log});
108
+ regaSync.load();
109
+ }
64
110
 
65
- mqtt.on('connect', () => {
66
- mqttConnected = true;
67
-
68
- log.info('mqtt connected', config.mqttUrl);
69
- mqtt.publish(config.name + '/connected', ifaceAllConnected ? '2' : '1', {retain: (config.mqttRetain)});
70
-
71
- log.info('mqtt subscribe', config.name + '/set/#');
72
- mqtt.subscribe(config.name + '/set/#');
73
-
74
- log.info('mqtt subscribe', config.name + '/param/#');
75
- mqtt.subscribe(config.name + '/param/#');
111
+ const channelName = (address) => (regaSync ? regaSync.channelName(address) : nameFile[address]);
76
112
 
77
- log.info('mqtt subscribe', config.name + '/paramset/#');
78
- mqtt.subscribe(config.name + '/paramset/#');
113
+ const values = new ValueStore({
114
+ host,
115
+ context: {
116
+ device: (iface, address) => metadata.device(iface, address),
117
+ valueDescription: (iface, address, datapoint) => metadata.valueDescription(iface, address, datapoint),
118
+ channelName,
119
+ rooms: (address) => (regaSync ? regaSync.rooms(address) : undefined),
120
+ functions: (address) => (regaSync ? regaSync.functions(address) : undefined),
121
+ },
122
+ stateDir: config.stateDir,
123
+ log,
124
+ });
125
+ values.load();
126
+
127
+ const renderItem = compileTemplate(config.itemTemplate);
128
+ const renderSysvarItem = compileTemplate(config.sysvarItemTemplate);
129
+ const renderProgramItem = compileTemplate(config.programItemTemplate);
130
+ const itemIndex = new ItemIndex();
131
+ let indexTimer = null;
132
+
133
+ let enabled = [];
134
+ const connections = {};
135
+ let servers = null;
136
+ let regaOk = false;
137
+ const timers = [];
138
+ const warnedNames = new Set();
139
+ const publishedCounters = {};
140
+ let fetchChain = Promise.resolve();
141
+
142
+ /*
143
+ * publishing
144
+ */
145
+
146
+ function itemOf(name) {
147
+ const {name: item, changed} = sanitizeName(name);
148
+ if (changed && !warnedNames.has(name)) {
149
+ warnedNames.add(name);
150
+ log.warn('name', JSON.stringify(name), 'is not usable as topic, publishing as', JSON.stringify(item));
151
+ }
152
+ return item;
153
+ }
79
154
 
80
- log.info('mqtt subscribe', config.name + '/rega/#');
81
- mqtt.subscribe(config.name + '/rega/#');
155
+ function rendered(render, fields, label) {
156
+ const {name: item, changed} = render(fields);
157
+ if (changed && !warnedNames.has(label)) {
158
+ warnedNames.add(label);
159
+ log.warn('item of', label, 'rendered with replacements as', JSON.stringify(item));
160
+ }
161
+ return item;
162
+ }
82
163
 
83
- log.info('mqtt subscribe', config.name + '/rpc/#');
84
- mqtt.subscribe(config.name + '/rpc/#');
164
+ /**
165
+ * Rebuilds the reverse map item → target for set topics: every VALUES parameter of every known
166
+ * channel rendered with the item template (plus its address form), variables and programs.
167
+ */
168
+ function rebuildIndex() {
169
+ indexTimer = null;
170
+ itemIndex.clear('datapoint');
171
+ itemIndex.collisions.clear();
172
+ for (const iface of Object.keys(metadata.devices)) {
173
+ for (const [address, device] of Object.entries(metadata.devices[iface])) {
174
+ if (!device.PARENT) {
175
+ continue;
176
+ }
177
+ const description = metadata.description(iface, address, 'VALUES');
178
+ if (!description) {
179
+ continue;
180
+ }
181
+ for (const datapoint of Object.keys(description)) {
182
+ const target = {kind: 'datapoint', address, datapoint};
183
+ itemIndex.add(renderItem(values.fields(iface, address, datapoint)).name, target);
184
+ itemIndex.add(`${address}/${datapoint}`, target);
185
+ }
186
+ }
187
+ }
188
+ if (itemIndex.collisions.size > 0) {
189
+ const list = [...itemIndex.collisions.keys()];
190
+ log.warn(
191
+ 'items shared by several channels (the first one wins on set):',
192
+ list.slice(0, 10).join(', '),
193
+ list.length > 10 ? `… ${list.length - 10} more` : '',
194
+ );
195
+ }
196
+ log.debug('item index rebuilt:', itemIndex.size, 'items');
197
+ }
85
198
 
86
- log.info('mqtt subscribe', config.name + '/command/#');
87
- mqtt.subscribe(config.name + '/command/#');
88
- });
199
+ function scheduleIndex() {
200
+ if (indexTimer) {
201
+ return;
202
+ }
203
+ indexTimer = setTimeout(rebuildIndex, 2000);
204
+ indexTimer.unref();
205
+ }
89
206
 
90
- mqtt.on('close', () => {
91
- if (mqttConnected) {
92
- mqttConnected = false;
93
- log.error('mqtt closed ' + config.mqttUrl);
207
+ function publishPlain(item, value, retain) {
208
+ if (config.plainTree) {
209
+ adapter.publish(adapter.topic(config.plainTree, item), plainValue(value), {retain});
94
210
  }
95
- });
211
+ }
96
212
 
97
- mqtt.on('error', err => {
98
- log.error('mqtt', err);
99
- });
213
+ function publishMessage(message, {retain = true} = {}) {
214
+ const item = rendered(renderItem, message, message.datapointName);
215
+ const extra = config.hmPayload ? {hm: hmBlock(message)} : undefined;
216
+ pubStatus(item, message.value, {retain, extra, ts: message.ts, lc: message.lc});
217
+ publishPlain(item, message.value, retain);
218
+ }
100
219
 
101
- mqtt.on('close', () => {
102
- log.warn('mqtt close');
103
- });
220
+ function publishRega(message) {
221
+ const render = message.type === 'PROGRAM' ? renderProgramItem : renderSysvarItem;
222
+ const item = rendered(render, message, `${message.type} ${message.name}`);
223
+ itemIndex.add(item, {kind: message.type === 'PROGRAM' ? 'program' : 'sysvar', name: message.name});
224
+ const extra = config.hmPayload ? {hm: hmBlock(message)} : undefined;
225
+ pubStatus(item, message.value, {retain: true, extra, ts: message.ts, lc: message.lc});
226
+ publishPlain(item, message.value, true);
227
+ }
104
228
 
105
- mqtt.on('offline', () => {
106
- log.warn('mqtt offline');
107
- });
229
+ function publishItem(item, value, {retain = true} = {}) {
230
+ pubStatus(item, value, {retain});
231
+ publishPlain(item, value, retain);
232
+ }
108
233
 
109
- mqtt.on('reconnect', () => {
110
- log.info('mqtt reconnect');
111
- });
234
+ function updateConnected() {
235
+ const all = enabled.every((iface) => connections[iface] && connections[iface].connected) && (!regaSync || regaOk);
236
+ adapter.setDeviceConnected(all);
237
+ }
112
238
 
113
- function mqttPublish(topic, payload, options) {
114
- if (typeof payload === 'object') {
115
- payload = JSON.stringify(payload);
116
- } else if (payload) {
117
- payload = String(payload);
118
- } else {
119
- payload = '';
239
+ function publishCounters() {
240
+ if (!config.publishCounters) {
241
+ return;
120
242
  }
121
- mqtt.publish(topic, payload, options, err => {
122
- if (err) {
123
- log.error('mqtt publish', err);
124
- } else {
125
- log.debug('mqtt >', topic, payload);
243
+ for (const iface of enabled) {
244
+ const conn = connections[iface];
245
+ for (const dir of ['rx', 'tx']) {
246
+ const key = `${iface}/${dir}`;
247
+ const value = conn ? conn.counters[dir] : 0;
248
+ if (publishedCounters[key] !== value) {
249
+ publishedCounters[key] = value;
250
+ publishItem(`counter/${key}`, value);
251
+ }
126
252
  }
127
- });
253
+ }
128
254
  }
129
255
 
130
- mqtt.on('message', (topic, payload) => {
131
- payload = payload.toString();
132
- log.debug('mqtt <', topic, payload);
133
- const parts = topic.split('/');
134
- if (parts.length >= 4 && parts[1] === 'set') {
135
- // Topic <name>/set/<channel>/<datapoint>
136
- const channel = parts.slice(2, parts.length - 1).join('/');
137
- const datapoint = parts[parts.length - 1];
138
- rpcSet(channel, 'VALUES', datapoint, payload);
139
- } else if (parts.length >= 5 && parts[1] === 'param') {
140
- // Topic <name>/param/<channel>/<paramset>/<datapoint>
141
- const channel = parts.slice(2, parts.length - 2).join('/');
142
- const paramset = parts[parts.length - 2];
143
- const datapoint = parts[parts.length - 1];
144
- rpcPutParam(channel, paramset, datapoint, payload);
145
- } else if (parts.length >= 4 && parts[1] === 'paramset') {
146
- // Topic <name>/paramset/<channel>/<paramset>
147
- const channel = parts.slice(2, parts.length - 1).join('/');
148
- const paramset = parts[parts.length - 1];
149
- rpcPutParamset(channel, paramset, payload);
150
- } else if (parts.length === 5 && parts[1] === 'rpc') {
151
- // Topic <name>/rpc/<interface>/<command>/<call_id> - Answer: <name>/response/<call_id>
152
- const [, , iface, command, callid] = parts;
153
- rpc(iface, command, callid, payload);
154
- } else if (parts.length >= 3 && parts[1] === 'rega') {
155
- // Topic <name>/rega/<variableOrProgramName>
156
- const name = parts.slice(2, parts.length);
157
- if (variables[name]) {
158
- setVar(variables[name], payload);
159
- } else if (programs[name]) {
160
- setProgram(programs[name], payload);
161
- } else {
162
- log.error('unknown variable/program', name);
163
- }
164
- } else if (parts[1] === 'command') {
165
- switch (parts[2]) {
166
- case 'regasync':
167
- getRegaDeviceNames();
168
- break;
169
- default:
170
- log.error('mqtt < unknown command', parts[2]);
256
+ async function pollDutyCycle() {
257
+ for (const iface of enabled) {
258
+ const conn = connections[iface];
259
+ if (!conn || !conn.connected || !interfaceConfig(iface, config).dutyCycle) {
260
+ continue;
171
261
  }
172
- } else {
173
- log.error('mqtt <', topic, payload);
174
- }
175
- });
176
-
177
- function setVar(variable, payload) {
178
- let val;
179
- if (payload.indexOf('{') === 0) {
180
262
  try {
181
- val = JSON.parse(payload).val;
182
- } catch (err) {
183
- val = payload;
184
- }
185
- } else {
186
- val = payload;
187
- }
188
-
189
- switch (variable.type) {
190
- case 'BOOL':
191
- // OMG this is so ugly...
192
- if (val === 'false') {
193
- val = false;
194
- } else if (!isNaN(val)) { // Make sure that the string "0" gets casted to boolean false
195
- val = Number(val);
263
+ const list = await conn.methodCall('listBidcosInterfaces', []);
264
+ if (!Array.isArray(list)) {
265
+ continue;
196
266
  }
197
- val = Boolean(val);
198
- break;
199
- case 'FLOAT':
200
- val = parseFloat(val) || 0;
201
- break;
202
- case 'INTEGER':
203
- if (typeof val === 'string') {
204
- if (variable.enum && (variable.enum.indexOf(val) !== -1)) {
205
- val = variable.enum.indexOf(val);
267
+ for (const entry of list) {
268
+ if (!entry || typeof entry.ADDRESS !== 'string') {
269
+ continue;
270
+ }
271
+ const item = itemOf(entry.ADDRESS);
272
+ if (typeof entry.DUTY_CYCLE === 'number') {
273
+ publishItem(`${item}/DUTY_CYCLE`, entry.DUTY_CYCLE);
274
+ }
275
+ if (typeof entry.CARRIER_SENSE_LEVEL === 'number') {
276
+ publishItem(`${item}/CARRIER_SENSE_LEVEL`, entry.CARRIER_SENSE_LEVEL);
277
+ }
278
+ if (typeof entry.CONNECTED === 'boolean') {
279
+ publishItem(`${item}/CONNECTED`, entry.CONNECTED);
206
280
  }
207
281
  }
208
- val = parseInt(val, 10) || 0;
209
- break;
210
- case 'STRING':
211
- val = '"' + String(val) + '"';
212
- break;
213
- default:
214
- }
215
- const script = 'dom.GetObject(' + variable.id + ').State(' + val + ');';
216
- log.debug('rega >', script);
217
- rega(script, err => {
218
- if (err) {
219
- log.error(err);
220
- }
221
- });
222
- }
223
-
224
- function setProgram(program, payload) {
225
- log.debug(program, payload);
226
- let val;
227
- let script;
228
- if (payload.indexOf('{') === 0) {
229
- try {
230
- val = JSON.parse(payload).val;
231
282
  } catch (err) {
232
- val = payload;
283
+ log.debug('rpc', iface, 'listBidcosInterfaces failed:', err.message);
233
284
  }
234
- } else {
235
- val = payload;
236
285
  }
237
- if (val === 'start') {
238
- script = 'dom.GetObject(' + program.id + ').ProgramExecute();';
239
- } else {
240
- if (val === 'false') {
241
- val = false;
286
+ }
287
+
288
+ /*
289
+ * incoming: set / paramset / rpc
290
+ */
291
+
292
+ const lookup = {
293
+ channelAddress: (name, devices = false) => {
294
+ if (regaSync) {
295
+ return regaSync.channelAddress(name, devices);
242
296
  }
243
- val = Boolean(val);
244
- script = 'dom.GetObject(' + program.id + ').Active(' + val + ');';
245
- }
246
- log.debug('rega >', script);
247
- rega(script, err => {
248
- if (err) {
249
- log.error(err);
297
+ const address = Object.entries(nameFile).find(([, n]) => n === name)?.[0] || name;
298
+ if (!metadata.findIface(address)) {
299
+ return undefined;
250
300
  }
251
- });
252
- }
301
+ return devices || address.includes(':') ? address : undefined;
302
+ },
303
+ isSysvar: (name) => Boolean(regaSync && regaSync.hasVariable(name)),
304
+ isProgram: (name) => Boolean(regaSync && regaSync.hasProgram(name)),
305
+ };
253
306
 
254
- function rpc(iface, command, callid, payload) {
255
- if (rpcClient[iface]) {
256
- let params;
257
- if (payload.indexOf('[') === 0) {
258
- try {
259
- params = JSON.parse(payload);
260
- } catch (err) {
261
- log.error(err);
262
- }
263
- } else if (params) {
264
- params = [params];
265
- } else {
266
- params = [];
307
+ const setTimers = new Map();
308
+
309
+ /** node-red-contrib-ccu's setValue throttle: one call per datapoint per 500 ms, the last value wins. */
310
+ function throttled(id, fn) {
311
+ const pending = setTimers.get(id);
312
+ if (pending) {
313
+ if (pending.deferred) {
314
+ pending.deferred.resolve();
267
315
  }
268
- log.debug('rpc', iface, '>', command, params);
269
- rpcClient[iface].methodCall(command, params, (err, res) => {
270
- if (err) {
271
- log.error(err);
272
- } else {
273
- const topic = config.name + '/response/' + callid;
274
- const payload = JSON.stringify(res);
275
- mqttPublish(topic, payload);
276
- }
316
+ return new Promise((resolve, reject) => {
317
+ pending.deferred = {fn, resolve, reject};
277
318
  });
278
319
  }
320
+ const entry = {deferred: null};
321
+ entry.timer = setTimeout(() => {
322
+ setTimers.delete(id);
323
+ if (entry.deferred) {
324
+ const {fn: deferredFn, resolve, reject} = entry.deferred;
325
+ log.debug('deferred set', id);
326
+ deferredFn().then(resolve, reject);
327
+ }
328
+ }, SET_THROTTLE_MS);
329
+ setTimers.set(id, entry);
330
+ return fn();
279
331
  }
280
332
 
281
- function rpcType(payload, paramset) {
282
- let val;
283
- if (payload.indexOf('{') === 0) {
284
- try {
285
- val = JSON.parse(payload).val;
286
- } catch (err) {
287
- val = payload;
288
- }
289
- } else {
290
- val = payload;
333
+ function connectionOf(address) {
334
+ const iface = metadata.findIface(address);
335
+ if (!iface) {
336
+ throw new Error(`unknown device ${address}`);
291
337
  }
292
-
293
- switch (paramset && paramset.TYPE) {
294
- case 'BOOL':
295
- // eslint-disable-line no-fallthrough
296
- case 'ACTION':
297
- // OMG this is so ugly...
298
- if (val === 'false') {
299
- val = false;
300
- } else if (!isNaN(val)) { // Make sure that the string "0" gets casted to boolean false
301
- val = Number(val);
302
- }
303
- val = Boolean(val);
304
- break;
305
- case 'FLOAT':
306
- val = parseFloat(val);
307
- if (val < paramset.MIN) {
308
- val = paramset.MIN;
309
- } else if (val > paramset.MAX) {
310
- val = paramset.MAX;
311
- }
312
- val = {explicitDouble: val};
313
- break;
314
- case 'ENUM':
315
- if (typeof val === 'string') {
316
- if (paramset.ENUM && (paramset.ENUM.indexOf(val) !== -1)) {
317
- val = paramset.ENUM.indexOf(val);
318
- }
319
- }
320
- // eslint-disable-line no-fallthrough
321
- case 'INTEGER':
322
- val = parseInt(val, 10);
323
- if (val < paramset.MIN) {
324
- val = paramset.MIN;
325
- } else if (val > paramset.MAX) {
326
- val = paramset.MAX;
327
- }
328
- break;
329
- case 'STRING':
330
- val = String(val);
331
- break;
332
- default:
338
+ const conn = connections[iface];
339
+ if (!conn) {
340
+ throw new Error(`interface ${iface} of ${address} is not enabled`);
333
341
  }
334
-
335
- return val;
342
+ return {iface, conn};
336
343
  }
337
344
 
338
- function findIface(address) {
339
- let iface = null;
340
- Object.keys(devices).forEach(i => {
341
- if (devices[i] && devices[i][address]) {
342
- iface = i;
343
- }
344
- });
345
- return iface;
345
+ async function setValue(address, datapoint, value) {
346
+ const {iface, conn} = connectionOf(address);
347
+ const description = metadata.valueDescription(iface, address, datapoint);
348
+ if (!isWriteable(description)) {
349
+ throw new Error(`${address} ${datapoint} is not writeable`);
350
+ }
351
+ if (!description) {
352
+ log.debug('no paramset description for', iface, address, datapoint);
353
+ }
354
+ const cast = castValue(value, description);
355
+ return throttled(`${iface}.${address}.${datapoint}`, () => conn.methodCall('setValue', [address, datapoint, cast]));
346
356
  }
347
357
 
348
- function rpcPutParam(name, paramsetKey, datapoint, payload) {
349
- const address = addresses[name] || name;
350
- const iface = findIface(address);
351
- if (!iface) {
352
- log.error('unknown device', address);
358
+ async function handleSet(parts, value, topic) {
359
+ if (value === undefined) {
360
+ log.warn('mqtt ignoring empty payload on', topic);
353
361
  return;
354
362
  }
355
- const psName = paramsetName(devices[iface][address]);
356
- let ps = paramsetDescriptions[psName];
357
- ps = ps && ps[paramsetKey] && ps[paramsetKey][datapoint];
358
- if (!ps) {
359
- log.warn('unknown paramset', paramsetName(devices[iface][address]) + '.' + paramsetKey + '.' + datapoint);
363
+ // exact item first (rendered template, address form, variables, programs), then the positional form
364
+ const target = itemIndex.get(parts.join('/')) || resolveSet(parts, lookup);
365
+ if (!target) {
366
+ throw new Error('unknown channel, variable or program');
360
367
  }
361
-
362
- if (ps && !(ps.OPERATIONS & 2)) {
363
- log.error(iface, address, paramsetKey, datapoint, 'not writeable');
364
- return;
368
+ switch (target.kind) {
369
+ case 'datapoint':
370
+ return setValue(target.address, target.datapoint, value);
371
+ case 'sysvar':
372
+ return regaSync.setVariable(target.name, value);
373
+ case 'program':
374
+ return typeof value === 'boolean'
375
+ ? regaSync.programActive(target.name, value)
376
+ : regaSync.programExecute(target.name);
377
+ case 'command':
378
+ if (target.command === 'sync' && regaSync) {
379
+ return regaSync.syncNames();
380
+ }
381
+ throw new Error('unknown command ' + target.command);
382
+ default:
383
+ throw new Error('unhandled target ' + target.kind);
365
384
  }
385
+ }
366
386
 
367
- const val = rpcType(payload, ps);
368
-
387
+ async function handleParamset(parts, value) {
388
+ const whole = value && typeof value === 'object' && !Array.isArray(value);
389
+ const target = resolveParamset(parts, lookup, {single: !whole});
390
+ if (!target) {
391
+ throw new Error(
392
+ whole
393
+ ? 'unknown channel/device or paramset'
394
+ : 'unknown channel/device, paramset or parameter (object payloads set a whole paramset)',
395
+ );
396
+ }
397
+ const {iface, conn} = connectionOf(target.address);
398
+ const description =
399
+ (await metadata.fetchDescription(iface, target.address, target.paramset, (m, p) => conn.methodCall(m, p))) ||
400
+ {};
369
401
  const paramset = {};
370
- paramset[datapoint] = val;
371
- log.debug('rpc', iface, '> putParamset', [address, paramsetKey, paramset]);
372
-
373
- rpcClient[iface].methodCall('putParamset', [address, paramsetKey, paramset], err => {
374
- if (err) {
375
- log.error(err);
402
+ const entries = whole ? Object.entries(value) : [[target.param, value]];
403
+ for (const [param, v] of entries) {
404
+ const d = description[param];
405
+ if (!isWriteable(d)) {
406
+ throw new Error(`${target.address} ${target.paramset} ${param} is not writeable`);
376
407
  }
377
- });
408
+ if (!d) {
409
+ log.debug('no paramset description for', iface, target.address, target.paramset, param);
410
+ }
411
+ paramset[param] = castValue(v, d);
412
+ }
413
+ await conn.methodCall('putParamset', [target.address, target.paramset, paramset]);
378
414
  }
379
415
 
380
- function rpcPutParamset(name, paramsetKey, payload) {
381
- const address = addresses[name] || name;
382
- const iface = findIface(address);
383
- if (!iface) {
384
- log.error('unknown device', address);
385
- return;
386
- }
387
- const psName = paramsetName(devices[iface][address]);
388
- let ps = paramsetDescriptions[psName];
389
- ps = ps && ps[paramsetKey];
390
- if (!ps) {
391
- log.warn('unknown paramset', paramsetName(devices[iface][address]) + '.' + paramsetKey);
416
+ async function handleRpc(parts, value) {
417
+ const [ifaceName, method, callId] = parts;
418
+ const iface = enabled.find((n) => n.toLowerCase() === String(ifaceName).toLowerCase());
419
+ const conn = iface && connections[iface];
420
+ const respond = (payload) => adapter.publish(adapter.topic('response', callId), payload, {retain: false});
421
+ if (!conn) {
422
+ respond({error: `unknown interface ${ifaceName}`});
423
+ throw new Error(`unknown interface ${ifaceName}`);
392
424
  }
393
-
394
- const paramset = {};
395
-
425
+ const params = Array.isArray(value) ? value : value === undefined ? [] : [value];
396
426
  try {
397
- payload = JSON.parse(payload);
398
- if (typeof payload !== 'object') {
399
- throw new TypeError('invalid payload type', typeof payload);
400
- }
401
- Object.keys(payload).forEach(datapoint => {
402
- if (ps[datapoint] && !(ps[datapoint].OPERATIONS & 2)) {
403
- log.error(iface, address, paramsetKey, datapoint, 'not writeable');
404
- return;
405
- }
406
- paramset[datapoint] = rpcType(String(payload[datapoint]), ps[datapoint]);
407
- });
427
+ respond(await conn.methodCall(method, params));
408
428
  } catch (err) {
409
- log.error(err);
410
- return;
429
+ respond({error: err.message});
430
+ throw err;
411
431
  }
432
+ }
412
433
 
413
- log.debug('rpc', iface, '> putParamset', [address, paramsetKey, paramset]);
434
+ /*
435
+ * interfaces
436
+ */
437
+
438
+ function fetchDescriptions(iface, addresses) {
439
+ const conn = connections[iface];
440
+ fetchChain = fetchChain
441
+ .then(() => metadata.fetchDescriptions(iface, (method, params) => conn.methodCall(method, params), {addresses}))
442
+ .catch((err) => log.warn(iface, 'fetching paramset descriptions failed:', err.message))
443
+ .then(scheduleIndex);
444
+ return fetchChain;
445
+ }
414
446
 
415
- rpcClient[iface].methodCall('putParamset', [address, paramsetKey, paramset], err => {
416
- if (err) {
417
- log.error(err);
418
- }
419
- });
447
+ /**
448
+ * Right after init() the CCU pushes events before it announces its devices (newDevices). On a
449
+ * first start (empty state) those events would lack device/channel/datapoint fields, so they
450
+ * are held per interface until the devices are known (or 10 s passed).
451
+ */
452
+ const heldEvents = {};
453
+
454
+ function holdEvent(iface, event) {
455
+ if (!heldEvents[iface]) {
456
+ return false;
457
+ }
458
+ heldEvents[iface].push(event);
459
+ return true;
420
460
  }
421
461
 
422
- function rpcSet(name, paramset, datapoint, payload) {
423
- const address = addresses[name] || name;
424
- const iface = findIface(address);
425
- if (!iface) {
426
- log.error('unknown device', address);
462
+ function releaseEvents(iface) {
463
+ const held = heldEvents[iface];
464
+ if (!held) {
427
465
  return;
428
466
  }
429
- const psName = paramsetName(devices[iface][address]);
430
- let ps = paramsetDescriptions[psName];
431
- ps = ps && ps[paramset] && ps[paramset][datapoint];
432
- if (!ps) {
433
- log.warn('unknown paramset', paramsetName(devices[iface][address]) + '.' + paramset + '.' + datapoint);
467
+ delete heldEvents[iface];
468
+ if (held.length > 0) {
469
+ log.debug(iface, 'releasing', held.length, 'events held until the devices were known');
434
470
  }
471
+ for (const event of held) {
472
+ onEvent(iface, event);
473
+ }
474
+ }
435
475
 
436
- if (ps && !(ps.OPERATIONS & 2)) {
437
- log.error(iface, address, paramset, datapoint, 'not writeable');
476
+ function onEvent(iface, event) {
477
+ if (holdEvent(iface, event)) {
438
478
  return;
439
479
  }
440
-
441
- const val = rpcType(payload, ps);
442
-
443
- log.debug('rpc', iface, '> setValue', [address, datapoint, val]);
444
- rpcClient[iface].methodCall('setValue', [address, datapoint, val], err => {
445
- if (err) {
446
- log.error(err);
480
+ if (config.regaPollTrigger && regaSync && `${event.channel}.${event.datapoint}` === config.regaPollTrigger) {
481
+ regaSync.poll().catch((err) => log.warn('rega poll failed:', err.message));
482
+ }
483
+ values.event({iface, ...event}, (message) => {
484
+ const description = metadata.valueDescription(iface, event.channel, event.datapoint);
485
+ if (!metadata.device(iface, event.channel)) {
486
+ log.debug(iface, 'event of unknown channel', event.channel);
487
+ } else if (!description) {
488
+ log.debug(iface, 'no description for', event.channel, event.datapoint);
489
+ }
490
+ publishMessage(message, {retain: !isEvent(event.datapoint, description)});
491
+ const notWorking = values.notWorking(message);
492
+ if (notWorking) {
493
+ publishMessage(notWorking);
447
494
  }
448
495
  });
449
496
  }
450
497
 
451
- function rega(script, callback) {
452
- const url = 'http://' + config.ccuAddress + ':8181/rega.exe';
453
- log.debug('sending script to', url);
454
- request({
455
- method: 'POST',
456
- url,
457
- body: script,
458
- headers: {
459
- 'Content-Type': 'application/x-www-form-urlencoded',
460
- 'Content-Length': script.length
461
- }
462
- }, (err, res, body) => {
463
- if (!err && body) {
464
- const end = body.indexOf('<xml>');
465
- const data = body.substr(0, end);
466
- callback(null, data);
467
- } else {
468
- callback(err);
469
- }
498
+ function createConnection(iface) {
499
+ const ic = interfaceConfig(iface, {tls: config.ccuTls, bidcosBinrpc: config.bidcosBinrpc});
500
+ const conn = new RpcConnection({
501
+ name: iface,
502
+ host: ccuIp,
503
+ protocol: ic.protocol,
504
+ port: ic.port,
505
+ path: ic.path,
506
+ tls: config.ccuTls,
507
+ insecure: config.ccuInsecure,
508
+ username: config.ccuUsername,
509
+ password: config.ccuPassword,
510
+ init: ic.init,
511
+ ping: ic.ping,
512
+ pingTimeout: ic.pingTimeout || config.pingTimeout,
513
+ servers,
514
+ initId: `hm2mqtt_${config.name}_${iface}`,
515
+ listDevices: () => metadata.listDevicesAnswer(iface),
516
+ log,
470
517
  });
471
- }
472
-
473
- function regaJson(file, callback) {
474
- const filepath = path.join(__dirname, 'regascripts', file);
475
- const script = fs.readFileSync(filepath).toString();
476
- rega(script, (err, res) => {
477
- if (err) {
478
- log.error(err);
479
- } else {
480
- try {
481
- callback(null, JSON.parse(unescape(res)));
482
- } catch (err) {
483
- callback(err);
518
+ conn.on('connected', (connected) => {
519
+ publishItem(`interface/${iface}/connected`, connected);
520
+ updateConnected();
521
+ if (connected) {
522
+ if (metadata.count(iface) === 0 && !heldEvents[iface]) {
523
+ heldEvents[iface] = [];
524
+ setTimeout(() => releaseEvents(iface), DEVICES_WAIT_MS).unref();
484
525
  }
526
+ fetchDescriptions(iface);
485
527
  }
486
528
  });
487
- }
488
-
489
- function getRegaDeviceNames(cb) {
490
- regaJson('devices.fn', (err, res) => {
491
- if (err) {
492
- log.error(err);
529
+ conn.on('event', (event) => onEvent(iface, event));
530
+ conn.on('newDevices', (devices) => {
531
+ const added = metadata.addDevices(iface, devices);
532
+ log.info(
533
+ iface,
534
+ 'newDevices:',
535
+ devices.length,
536
+ 'announced,',
537
+ added.length,
538
+ 'new (',
539
+ metadata.count(iface),
540
+ 'known )',
541
+ );
542
+ if (added.length > 0) {
543
+ fetchDescriptions(iface, added).then(() => releaseEvents(iface));
544
+ adapter.publishInfo();
493
545
  } else {
494
- names = res;
495
- reverseNames();
496
- log.info('got', Object.keys(names).length, 'names from rega. saving', 'names_' + fileName());
497
- pjson.save('names_' + fileName(), names);
498
- if (typeof cb === 'function') {
499
- cb();
500
- }
546
+ releaseEvents(iface);
501
547
  }
502
548
  });
503
- }
504
-
505
- if (config.jsonNameTable) {
506
- log.info('loading name table', config.jsonNameTable);
507
- names = require(config.jsonNameTable);
508
- reverseNames();
509
- } else if (!config.disableRega) {
510
- log.info('loading', 'names_' + fileName());
511
- names = pjson.load('names_' + fileName()) || {};
512
- getRegaDeviceNames(() => {
513
- if (config.regaPollInterval || config.regaPollTrigger) {
514
- getPrograms(() => {
515
- getVariables();
516
- });
517
- }
518
- if (config.regaPollInterval > 0) {
519
- log.debug('rega poll interval', config.regaPollInterval);
520
- setInterval(() => {
521
- getPrograms(() => {
522
- getVariables();
523
- });
524
- }, config.regaPollInterval * 1000);
525
- }
549
+ conn.on('deleteDevices', (addresses) => {
550
+ const deleted = metadata.deleteDevices(iface, addresses);
551
+ log.info(iface, 'deleteDevices:', deleted.length, 'removed');
552
+ adapter.publishInfo();
553
+ scheduleIndex();
526
554
  });
555
+ return conn;
527
556
  }
528
557
 
529
- function parseDateISOString(s) {
530
- const ds = s.split(/\D/).map(s => parseInt(s, 10));
531
- ds[1] -= 1;
532
- return new Date(...ds);
533
- }
558
+ /*
559
+ * rega
560
+ */
534
561
 
535
- function getVariables() {
536
- regaJson('variables.fn', (err, res) => {
537
- if (err) {
538
- log.error(err);
539
- } else {
540
- Object.keys(res).forEach(id => {
541
- const varName = res[id].name;
542
- let change = false;
543
- if (!variables[varName] || (res[id].val !== variables[varName].val) || (res[id].ts !== variables[varName].ts)) {
544
- change = true;
545
- }
546
- variables[varName] = {
547
- id: Number(id),
548
- val: res[id].val,
549
- min: res[id].min,
550
- max: res[id].max,
551
- unit: res[id].unit,
552
- ts: res[id].ts,
553
- type: variableType[res[id].type],
554
- enum: res[id].enum ? res[id].enum.split(';') : undefined
555
- };
556
- variableNames[Number(id)] = varName;
557
- if (change) {
558
- const ts = res[id].ts ? parseDateISOString(res[id].ts).getTime() : 0;
559
- const topic = config.name + '/status/' + varName;
560
- let enumIndex = res[id].val;
561
- if (enumIndex === false) {
562
- enumIndex = 0;
563
- } else if (enumIndex === true) {
564
- enumIndex = 1;
565
- }
566
- let payload = {
567
- val: res[id].val,
568
- ts,
569
- hm: {
570
- id: Number(id),
571
- UNIT: res[id].unit,
572
- MIN: res[id].min,
573
- MAX: res[id].max,
574
- ENUM: res[id].enum ? res[id].enum.split(';')[enumIndex] : undefined
575
- }
576
- };
577
- payload = JSON.stringify(payload);
578
- mqttPublish(topic, payload, {retain: (config.mqttRetain)});
579
- }
580
- });
581
- log.debug('rega got', Object.keys(variables).length, 'variables');
562
+ async function startRega() {
563
+ try {
564
+ await regaSync.syncNames();
565
+ } catch (err) {
566
+ log.warn(
567
+ 'rega: names not available (' + err.message + '), using',
568
+ Object.keys(regaSync.channelNames).length,
569
+ 'cached names',
570
+ );
571
+ }
572
+ const duplicates = Object.entries(
573
+ Object.values(regaSync.channelNames).reduce((acc, name) => ((acc[name] = (acc[name] || 0) + 1), acc), {}),
574
+ )
575
+ .filter(([, n]) => n > 1)
576
+ .map(([name]) => name);
577
+ if (duplicates.length > 0) {
578
+ log.warn(
579
+ 'duplicate channel names share a topic:',
580
+ duplicates.slice(0, 20).join(', '),
581
+ duplicates.length > 20 ? '…' : '',
582
+ );
583
+ }
584
+ regaSync.on('names', scheduleIndex);
585
+ regaSync.on('sysvar', publishRega);
586
+ regaSync.on('program', publishRega);
587
+ regaSync.on('polled', () => {
588
+ if (!regaOk) {
589
+ regaOk = true;
590
+ log.info('rega connected');
591
+ updateConnected();
582
592
  }
583
593
  });
584
- }
585
-
586
- function getPrograms(cb) {
587
- regaJson('programs.fn', (err, res) => {
588
- if (err) {
589
- log.error(err);
590
- cb(err);
591
- } else {
592
- Object.keys(res).forEach(id => {
593
- const programName = res[id].name;
594
- let change = false;
595
- if (!programs[programName] || (res[id].val !== programs[programName].val) || (res[id].ts !== programs[programName].ts)) {
596
- change = true;
597
- }
598
- programs[programName] = {
599
- id: Number(id),
600
- active: res[id].active,
601
- ts: res[id].ts
602
- };
603
- programNames[Number(id)] = programName;
604
- if (change) {
605
- const ts = res[id].ts ? parseDateISOString(res[id].ts).getTime() : 0;
606
- const topic = config.name + '/status/' + programName;
607
- let payload = {
608
- val: res[id].active,
609
- ts,
610
- hm: {
611
- id: Number(id)
612
- }
613
- };
614
- payload = JSON.stringify(payload);
615
- mqttPublish(topic, payload, {retain: (config.mqttRetain)});
616
- }
617
- });
618
- log.debug('rega got', Object.keys(programs).length, 'programs');
619
- cb(null);
594
+ regaSync.on('error', () => {
595
+ if (regaOk) {
596
+ regaOk = false;
597
+ log.warn('rega disconnected');
598
+ updateConnected();
620
599
  }
621
600
  });
601
+ regaSync.startPolling(config.regaPollInterval);
602
+ loadCache().catch((err) => log.warn('rega getValues failed:', err.message));
603
+ if (config.regaNamesInterval > 0) {
604
+ timers.push(
605
+ setInterval(
606
+ () => regaSync.syncNames().catch((err) => log.warn('rega names re-sync failed:', err.message)),
607
+ config.regaNamesInterval * 1000,
608
+ ),
609
+ );
610
+ }
622
611
  }
623
612
 
624
- log.debug('discover interfaces');
625
- discover(config.ccuAddress, {
626
- // Todo... cuxd: {port: 8701, protocol: 'binrpc'},
627
- rfd: {port: 2001, protocol: 'binrpc'},
628
- hs485d: {port: 2000, protocol: 'binrpc'},
629
- hmip: {port: 2010, protocol: 'xmlrpc'}
630
- }, interfaces => {
631
- Object.keys(interfaces).forEach(iface => {
632
- createIface(iface, interfaces[iface].protocol, interfaces[iface].port);
633
- if (iface === 'hmip' && config.hmipReconnectInterval) {
634
- setInterval(() => {
635
- checkInit(iface, interfaces[iface].protocol);
636
- }, config.hmipReconnectInterval * 1000);
637
- } else if (iface === 'cuxd') {
638
- // TODO
639
- } else if (config.pingInterval) {
640
- setInterval(() => {
641
- checkInit(iface, interfaces[iface].protocol);
642
- }, config.pingInterval * 1000);
613
+ /** The ReGa's copy of every datapoint value: seeds the value store, published with --publish-cache. */
614
+ async function loadCache() {
615
+ const list = await regaSync.rega.getValues();
616
+ let published = 0;
617
+ for (const dp of list) {
618
+ const iface = String(dp.name).split('.')[0];
619
+ if (!enabled.includes(iface)) {
620
+ continue;
643
621
  }
644
- });
645
- });
646
-
647
- function fileName(name) {
648
- return config.ccuAddress + (name ? '_' + name : '');
649
- }
650
-
651
- function checkInit(iface, protocol) {
652
- const now = (new Date()).getTime();
653
- const elapsed = Math.ceil((now - (lastEvent[iface] || 0)) / 1000);
654
- log.debug(iface, 'elapsed since lastevent:', elapsed + 's');
655
- if (iface === 'hmip' && config.hmipReconnectInterval) {
656
- if (elapsed >= config.hmipReconnectInterval) {
657
- ifaceConnected[iface] = false;
658
- initIface(iface, protocol);
622
+ const message = values.cached(dp);
623
+ if (!message) {
624
+ continue;
659
625
  }
660
- } else if (iface === 'cuxd') {
661
- // TODO cuxd reconnect? ping possible?
662
- } else if (elapsed >= (config.pingInterval * 2)) {
663
- ifaceConnected[iface] = false;
664
- initIface(iface, protocol);
665
- } else if (elapsed >= config.pingInterval) {
666
- log.debug('rpc', iface, '> ping');
667
- rpcClient[iface].methodCall('ping', ['hm2mqtt'], err => {
668
- if (err) {
669
- log.error(err);
670
- }
671
- });
672
- }
673
- checkIfaceAll();
674
- }
675
-
676
- function checkIfaceAll() {
677
- const current = ifaceAllConnected;
678
- ifaceAllConnected = true;
679
- Object.keys(ifaceConnected).forEach(i => {
680
- if (!ifaceConnected[i]) {
681
- ifaceAllConnected = false;
626
+ if (config.publishCache && !message.datapoint.startsWith('PRESS_')) {
627
+ const description = metadata.valueDescription(iface, message.channel, message.datapoint);
628
+ publishMessage(message, {retain: !isEvent(message.datapoint, description)});
629
+ published += 1;
682
630
  }
683
- });
684
- log.debug('ifaceAllConnected', ifaceAllConnected);
685
- if (current !== ifaceAllConnected) {
686
- mqtt.publish(config.name + '/connected', ifaceAllConnected ? '2' : '1', {retain: (config.mqttRetain)});
687
631
  }
632
+ log.info('rega getValues:', list.length, 'values cached', config.publishCache ? `, ${published} published` : '');
688
633
  }
689
634
 
690
- function createIface(name, protocol, port) {
691
- log.debug('loading', 'devices_' + fileName(name));
692
- devices[name] = pjson.load('devices_' + fileName(name));
693
- log.debug('createIface', name, protocol, port);
694
- if (!rpcServer[protocol]) {
695
- rpcServer[protocol] = createServer(protocol);
635
+ /*
636
+ * lifecycle
637
+ */
638
+
639
+ async function start() {
640
+ adapter.start();
641
+ ccuIp = await resolveCcu();
642
+ if (regaSync) {
643
+ regaSync.rega.host = ccuIp;
644
+ regaSync.rega.url = regaSync.rega.url.replace(host, ccuIp);
645
+ regaSync.rega.webUrl = regaSync.rega.webUrl.replace(host, ccuIp);
696
646
  }
697
- rpcClient[name] = createClient(protocol, port);
698
- initIface(name, protocol, port);
699
- if (config.dutyCyclePollInterval && (name === 'hmip' || name === 'rfd')) {
700
- setInterval(() => {
701
- pollDutyCylce(name);
702
- }, config.dutyCyclePollInterval * 1000);
703
- }
704
- }
705
-
706
- const stopIface = {};
707
- function stop() {
708
- const cmdQueue = [];
709
- Object.keys(stopIface).forEach(iface => {
710
- cmdQueue.push(stopIface[iface]);
711
- });
712
- async.parallel(cmdQueue, () => {
713
- process.exit(0);
714
- });
715
- setTimeout(() => {
716
- process.exit(1);
717
- }, 2500);
718
- }
719
-
720
- process.on('SIGINT', stop);
721
- process.on('SIGTERM', stop);
722
-
723
- function initIface(name, protocol) {
724
- let url;
725
- if (protocol === 'binrpc') {
726
- url = 'xmlrpc_bin://' + (config.initAddress || config.listenAddress) + ':' + config.binrpcListenPort;
727
- } else {
728
- url = 'http://' + (config.initAddress || config.listenAddress) + ':' + config.listenPort;
647
+ enabled = parseInterfaces(config.interfaces) || (await probeInterfaces(ccuIp, {tls: config.ccuTls}));
648
+ if (enabled.length === 0) {
649
+ log.error('no interface found on', host, '- check --ccu-address / --interfaces');
729
650
  }
730
- const params = [url, 'hm2mqtt_' + name];
731
- log.info('rpc', name, '> init', params);
732
- lastEvent[name] = (new Date()).getTime();
733
- rpcClient[name].methodCall('init', params, (err, res) => {
734
- if (err) {
735
- log.error(err);
736
- } else {
737
- log.debug('rpc', name, '< init', JSON.stringify(res));
738
- ifaceConnected[name] = true;
739
- checkIfaceAll();
740
- }
741
- stopIface[name] = cb => {
742
- const stopParams = [url, ''];
743
- log.info('rpc', name, '> init', stopParams);
744
- rpcClient[name].methodCall('init', stopParams, (err, res) => {
745
- log.debug('rpc', name, '< init', err, JSON.stringify(res));
746
- ifaceConnected[name] = false;
747
- checkIfaceAll();
748
- cb();
749
- });
750
- };
651
+ log.info('interfaces:', enabled.join(', ') || '(none)');
652
+ const listenAddress = config.listenAddress || firstIp();
653
+ servers = new RpcServers({
654
+ listenAddress,
655
+ initAddress: config.initAddress,
656
+ xmlrpcPort: config.xmlrpcPort,
657
+ binrpcPort: config.binrpcPort,
658
+ log,
751
659
  });
752
- }
753
-
754
- function createClient(protocol, port) {
755
- let client;
756
- const options = {
757
- host: config.ccuAddress,
758
- port,
759
- path: '/'
760
- };
761
- if (protocol === 'binrpc') {
762
- client = binrpc.createClient(options);
763
- } else {
764
- client = xmlrpc.createClient(options);
660
+ for (const iface of enabled) {
661
+ connections[iface] = createConnection(iface);
662
+ publishItem(`interface/${iface}/connected`, false);
765
663
  }
766
- return client;
767
- }
768
-
769
- function paramsetName(dev) {
770
- return dev.PARENT_TYPE + '/' + dev.VERSION + '/' + dev.TYPE;
771
- }
772
-
773
- function publishMeta(name) {
774
- log.info('publish meta data', name);
775
- Object.keys(devices[name]).forEach(address => {
776
- const dev = devices[name][address];
777
- const psDesc = paramsetDescriptions[paramsetName(dev)];
778
- const obj = {
779
- name: names[address] || address,
780
- type: dev.PARENT_TYPE ? 'channel' : 'device',
781
- interface: 'homematic',
782
- native: dev
783
- };
784
- obj.native.PARAMSET_DESCRIPTIONS = psDesc;
785
- mqttPublish('db/extend/' + config.name + '/' + address, obj);
786
- });
787
- }
788
-
789
- function createParamsetQueue(name) {
790
- name = ifaceName(name);
791
- if (!devices[name]) {
792
- log.error('createParamsetQueue called for unknown devices', name);
793
- return;
664
+ publishCounters();
665
+ if (regaSync) {
666
+ await startRega();
794
667
  }
795
- log.info(name, 'got', Object.keys(devices[name]).length, 'devices and channels');
796
-
797
- log.debug('createParamsetQueue', name);
798
- if (!paramsetQueue[name]) {
799
- paramsetQueue[name] = [];
668
+ rebuildIndex();
669
+ await Promise.all(enabled.map((iface) => connections[iface].start()));
670
+ updateConnected();
671
+ adapter.publishInfo();
672
+
673
+ timers.push(setInterval(publishCounters, COUNTER_INTERVAL_MS));
674
+ if (config.dutyCycleInterval > 0) {
675
+ timers.push(setInterval(() => pollDutyCycle(), config.dutyCycleInterval * 1000));
676
+ setTimeout(() => pollDutyCycle(), 5000);
800
677
  }
801
-
802
- Object.keys(devices[name]).forEach(address => {
803
- const dev = devices[name][address];
804
- if (!dev.PARENT_TYPE) {
805
- return;
806
- }
807
- if (!paramsetDescriptions[paramsetName(dev)]) {
808
- log.debug('unknown', paramsetName(dev), dev.PARAMSETS);
809
- paramsetDescriptions[paramsetName(dev)] = {};
810
- dev.PARAMSETS.forEach(ps => {
811
- paramsetQueue[name].push({ADDRESS: dev.ADDRESS, PARAMSET: ps, name: paramsetName(dev)});
812
- });
813
- }
814
- });
815
- getParamset(name);
678
+ timers.push(setInterval(() => values.save(), VALUES_SAVE_MS));
816
679
  }
817
680
 
818
- function getParamset(name) {
819
- if (paramsetQueue[name].length > 0) {
820
- const obj = paramsetQueue[name].shift();
821
- log.debug('rpc', name, '> getParamsetDescription', [obj.ADDRESS, obj.PARAMSET], obj.name);
822
- rpcClient[name].methodCall('getParamsetDescription', [obj.ADDRESS, obj.PARAMSET], (err, res) => {
823
- if (!err) {
824
- paramsetDescriptions[obj.name][obj.PARAMSET] = res;
825
- }
826
- setTimeout(() => {
827
- getParamset(name);
828
- }, 500);
829
- });
830
- } else {
831
- log.debug('getParamsetDescriptions', name, 'done');
832
- log.info('got', Object.keys(paramsetDescriptions).length, 'paramsetDescriptions');
833
- log.debug('saving', 'paramsetDescriptions_' + fileName());
834
- pjson.save('paramsetDescriptions_' + fileName(), paramsetDescriptions);
835
- if (config.publishMetadata) {
836
- publishMeta(name);
837
- }
681
+ async function shutdown() {
682
+ for (const timer of timers) {
683
+ clearInterval(timer);
838
684
  }
839
- }
840
-
841
- function ifaceName(id) {
842
- return id.replace(/^hm2mqtt_/, '');
843
- }
844
-
845
- const rpcMethods = {
846
- notFound: method => {
847
- log.debug('rpc < Method ' + method + ' does not exist');
848
- },
849
- 'system.multicall': (err, params, callback) => {
850
- if (err) {
851
- log.error(err);
852
- return;
853
- }
854
- const res = [];
855
- params[0].forEach(c => {
856
- if (rpcMethods[c.methodName]) {
857
- rpcMethods[c.methodName](err, c.params);
858
- } else {
859
- rpcMethods.notFound(c.methodName, c.params);
860
- }
861
- res.push('');
862
- });
863
- callback(null, res);
864
- },
865
- 'system.listMethods': (err, params, callback) => {
866
- if (err) {
867
- log.error(err);
868
- return;
869
- }
870
- log.debug('rpc < system.listMethods', params);
871
- callback(null, Object.keys(rpcMethods));
872
- },
873
- event: (err, params, callback) => {
874
- if (err) {
875
- log.error(err);
876
- return;
877
- }
878
- log.debug('rpc < event', JSON.stringify(params));
879
-
880
- const ts = (new Date()).getTime();
881
- lastEvent[ifaceName(params[0])] = ts;
882
-
883
- if (params[1] === 'CENTRAL' && params[2] === 'PONG') {
884
- if (typeof callback === 'function') {
885
- callback(null, '');
886
- }
887
- return;
888
- }
889
-
890
- if (params[2] === 'WORKING' || params[2] === 'DIRECTION') {
891
- working[params[1]] = Boolean(params[3]);
892
- }
893
-
894
- if (config.regaPollTrigger) {
895
- const [regaPollTriggerChannel, regaPollTriggerDatapoint] = config.regaPollTrigger.split('.');
896
- if (params[1] === regaPollTriggerChannel && params[2] === regaPollTriggerDatapoint) {
897
- getPrograms(() => {
898
- getVariables();
899
- });
900
- }
901
- }
902
-
903
- const key = params[1] + '/' + params[2];
904
-
905
- if (values[key] !== params[3]) {
906
- changes[key] = ts;
907
- values[key] = params[3];
908
- }
909
-
910
- const dev = devices[ifaceName(params[0])] && devices[ifaceName(params[0])][params[1]];
911
- if (!dev) {
912
- log.error('unknown device', params[0], params[1]);
913
- return;
914
- }
915
- let ps = paramsetDescriptions[paramsetName(dev)];
916
- if (!ps) {
917
- log.error('unknown paramsetDescription', paramsetName(dev));
918
- } else if (!ps.VALUES) {
919
- log.error('missing VALUES in paramsetDescription', paramsetName(dev));
920
- } else if (!ps.VALUES[params[2]]) {
921
- log.error('missing VALUE', params[2], 'in paramsetDescription', paramsetName(dev));
922
- }
923
- ps = (ps && ps.VALUES && ps.VALUES[params[2]]) || {};
924
-
925
- const topic = config.name + '/status/' + (names[params[1]] || params[1]) + '/' + params[2];
926
-
927
- let payload = {val: params[3], ts, lc: changes[key], hm: {ADDRESS: params[1]}};
928
- if (ps.UNIT && ps.UNIT !== '""') {
929
- if (ps.UNIT === '�C') {
930
- payload.hm.UNIT = '°C';
931
- } else {
932
- payload.hm.UNIT = ps.UNIT;
933
- }
934
- }
935
- if (ps.TYPE === 'ENUM') {
936
- payload.hm.ENUM = ps.VALUE_LIST[params[3]];
937
- }
938
- payload = JSON.stringify(payload);
939
-
940
- const retain = (config.mqttRetain) && (ps.TYPE !== 'ACTION');
941
-
942
- mqttPublish(topic, payload, {retain});
943
-
944
- if (typeof working[params[1]] !== 'undefined' && (params[2] === 'LEVEL' || params[2] === 'STATE')) {
945
- clearTimeout(workingTimeout[params[1]]);
946
- workingTimeout[params[1]] = setTimeout(() => {
947
- if (!working[params[1]]) {
948
- mqttPublish(topic + '_NOTWORKING', payload, {retain});
949
- }
950
- }, 500);
951
- }
952
-
953
- if (typeof callback === 'function') {
954
- callback(null, '');
955
- }
956
- },
957
- listDevices: (err, params, callback) => {
958
- if (err) {
959
- log.error(err);
960
- return;
961
- }
962
- log.debug('rpc < listDevices', params);
963
- const name = ifaceName(params[0]);
964
- const ret = [];
965
- const test = [];
966
- if (devices[name]) {
967
- Object.keys(devices[name]).forEach(address => {
968
- test.push(devices[name][address]);
969
- /* Todo This does not work: https://github.com/eq-3/occu/issues/45
970
- if (name === 'hmip') {
971
- ret.push({
972
- ADDRESS: address,
973
- VERSION: devices[name][address].VERSION,
974
- AES_ACTIVE: devices[name][address].AES_ACTIVE,
975
- CHILDREN: devices[name][address].CHILDREN,
976
- DIRECTION: devices[name][address].DIRECTION,
977
- FIRMWARE: devices[name][address].FIRMWARE,
978
- FLAGS: devices[name][address].FLAGS,
979
- GROUP: devices[name][address].GROUP,
980
- INDEX: devices[name][address].INDEX,
981
- INTERFACE: devices[name][address].INTERFACE,
982
- LINK_SOURCE_ROLES: devices[name][address].LINK_SOURCE_ROLES,
983
- LINK_TARGET_ROLES: devices[name][address].LINK_TARGET_ROLES,
984
- PARAMSETS: devices[name][address].PARAMSETS,
985
- PARENT: devices[name][address].PARENT,
986
- PARENT_TYPE: devices[name][address].PARENT_TYPE,
987
- RF_ADDRESS: devices[name][address].RF_ADDRESS,
988
- ROAMING: devices[name][address].ROAMING,
989
- RX_MODE: devices[name][address].RX_MODE,
990
- TEAM: devices[name][address].TEAM,
991
- TEAM_CHANNELS: devices[name][address].TEAM_CHANNELS,
992
- TEAM_TAG: devices[name][address].TEAM_TAG,
993
- TYPE: devices[name][address].TYPE
994
- });
995
- } else {
996
- ret.push({
997
- ADDRESS: address,
998
- VERSION: devices[name][address].VERSION
999
- });
1000
- }
1001
- */
1002
- ret.push({
1003
- ADDRESS: address,
1004
- VERSION: devices[name][address].VERSION
1005
- });
1006
- });
1007
- }
1008
- log.debug('>', ret.length);
1009
- callback(null, ret);
1010
- getParamsetTimeout[params[0]] = setTimeout(() => {
1011
- createParamsetQueue(params[0]);
1012
- }, 5000);
1013
- },
1014
- deleteDevices: (err, params, callback) => {
1015
- if (err) {
1016
- log.error(err);
1017
- return;
1018
- }
1019
- clearTimeout(getParamsetTimeout[params[0]]);
1020
- log.debug('rpc < deleteDevices', params[1].length);
1021
- const name = ifaceName(params[0]);
1022
- params[1].forEach(dev => {
1023
- delete devices[name][dev];
1024
- });
1025
- log.debug('saving', 'devices_' + fileName(name), '(' + Object.keys(devices[name]).length + ')');
1026
- pjson.save('devices_' + fileName(name), devices[name]);
1027
- callback(null, '');
1028
- getParamsetTimeout[params[0]] = setTimeout(() => {
1029
- createParamsetQueue(params[0]);
1030
- }, 5000);
1031
- },
1032
- newDevices: (err, params, callback) => {
1033
- if (err) {
1034
- log.error(err);
1035
- return;
1036
- }
1037
- clearTimeout(getParamsetTimeout[params[0]]);
1038
- log.debug('rpc < newDevices', params[1].length);
1039
- const name = ifaceName(params[0]);
1040
- const devs = devices[name] || {};
1041
- params[1].forEach(dev => {
1042
- devs[dev.ADDRESS] = dev;
1043
- });
1044
- devices[name] = devs;
1045
- log.debug('saving', 'devices_' + fileName(name), '(' + Object.keys(devices[name]).length + ')');
1046
- pjson.save('devices_' + fileName(name), devs);
1047
- callback(null, '');
1048
- getParamsetTimeout[params[0]] = setTimeout(() => {
1049
- createParamsetQueue(params[0]);
1050
- }, 5000);
685
+ for (const entry of setTimers.values()) {
686
+ clearTimeout(entry.timer);
1051
687
  }
1052
- };
1053
-
1054
- rpcMethods.NotFound = rpcMethods.notFound;
1055
-
1056
- function createServer(protocol) {
1057
- let server;
1058
- if (protocol === 'binrpc') {
1059
- server = binrpc.createServer({host: config.listenAddress, port: config.binrpcListenPort});
1060
- } else {
1061
- server = xmlrpc.createServer({host: config.listenAddress, port: config.listenPort});
688
+ values.stop();
689
+ if (regaSync) {
690
+ regaSync.stopPolling();
691
+ }
692
+ // state first: the core gives onShutdown two seconds, the de-init may not answer
693
+ values.save();
694
+ metadata.save();
695
+ const stopAll = Promise.all(Object.values(connections).map((conn) => conn.stop()));
696
+ await Promise.race([stopAll, new Promise((resolve) => setTimeout(resolve, STOP_TIMEOUT_MS))]);
697
+ if (servers) {
698
+ await Promise.race([servers.close(), new Promise((resolve) => setTimeout(resolve, 300))]);
1062
699
  }
1063
- Object.keys(rpcMethods).forEach(method => {
1064
- server.on(method, rpcMethods[method]);
1065
- });
1066
- return server;
1067
700
  }
1068
701
 
1069
- function pollDutyCylce(iface) {
1070
- rpcClient[iface].methodCall('listBidcosInterfaces', [], (err, res) => {
1071
- if (err) {
1072
- log.error(err);
1073
- } else if (res && res.forEach) {
1074
- res.forEach(data => {
1075
- const topic = config.name + '/status/' + data.ADDRESS + '/DUTY_CYCLE';
1076
- const payload = {
1077
- val: data.DUTY_CYCLE,
1078
- ts: (new Date()).getTime()
1079
- };
1080
- mqttPublish(topic, payload, {retain: (config.mqttRetain)});
1081
- });
1082
- }
1083
- });
1084
- }
702
+ start().catch((err) => {
703
+ log.error('start failed:', err.message);
704
+ adapter.shutdown('start failed', 1);
705
+ });