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/README.md +191 -96
- package/config.js +112 -58
- package/example-names.json +6 -0
- package/index.js +596 -952
- package/lib/cast.js +105 -0
- package/lib/compare.js +125 -0
- package/lib/install.js +17 -0
- package/lib/interfaces.js +110 -0
- package/lib/metadata.js +360 -0
- package/lib/rega.js +380 -0
- package/lib/rpc.js +497 -0
- package/lib/topics.js +211 -0
- package/lib/values.js +283 -0
- package/names.schema.json +8 -0
- package/package.json +54 -36
- package/paramsets.json +428781 -0
- package/scripts/clean-retained.js +69 -0
- package/scripts/compare-trees.js +46 -0
- package/.npmignore +0 -4
- package/.travis.yml +0 -40
- package/Dockerfile +0 -10
- package/Dockerfile.armhf +0 -11
- package/discover.js +0 -24
- package/firstip.js +0 -14
- package/regascripts/devices.fn +0 -43
- package/regascripts/programs.fn +0 -27
- package/regascripts/variables.fn +0 -78
- package/test.js +0 -201
package/index.js
CHANGED
|
@@ -1,1061 +1,705 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
let nameFile = {};
|
|
76
|
+
if (config.nameFile) {
|
|
77
|
+
nameFile = JSON.parse(fs.readFileSync(path.resolve(config.nameFile), 'utf8'));
|
|
78
|
+
}
|
|
56
79
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
199
|
+
function scheduleIndex() {
|
|
200
|
+
if (indexTimer) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
indexTimer = setTimeout(rebuildIndex, 2000);
|
|
204
|
+
indexTimer.unref();
|
|
205
|
+
}
|
|
88
206
|
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
229
|
+
function publishItem(item, value, {retain = true} = {}) {
|
|
230
|
+
pubStatus(item, value, {retain});
|
|
231
|
+
publishPlain(item, value, retain);
|
|
232
|
+
}
|
|
107
233
|
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
} else if (payload) {
|
|
116
|
-
payload = String(payload);
|
|
117
|
-
} else {
|
|
118
|
-
payload = '';
|
|
239
|
+
function publishCounters() {
|
|
240
|
+
if (!config.publishCounters) {
|
|
241
|
+
return;
|
|
119
242
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
283
|
+
log.debug('rpc', iface, 'listBidcosInterfaces failed:', err.message);
|
|
232
284
|
}
|
|
233
|
-
} else {
|
|
234
|
-
val = payload;
|
|
235
285
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
if (
|
|
283
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
|
380
|
-
const
|
|
381
|
-
const iface =
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
429
|
+
respond({error: err.message});
|
|
430
|
+
throw err;
|
|
410
431
|
}
|
|
432
|
+
}
|
|
411
433
|
|
|
412
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
422
|
-
const
|
|
423
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
|
|
476
|
+
function onEvent(iface, event) {
|
|
477
|
+
if (holdEvent(iface, event)) {
|
|
437
478
|
return;
|
|
438
479
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (
|
|
445
|
-
log.
|
|
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
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
return new Date(...ds);
|
|
532
|
-
}
|
|
558
|
+
/*
|
|
559
|
+
* rega
|
|
560
|
+
*/
|
|
533
561
|
|
|
534
|
-
function
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
697
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
+
});
|