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