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