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