iobroker.zeptrion 0.8.2
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/LICENSE +21 -0
- package/README.md +302 -0
- package/admin/jsonConfig.json +245 -0
- package/admin/zeptrion.png +0 -0
- package/io-package.json +196 -0
- package/main.js +1638 -0
- package/package.json +61 -0
- package/zeptrion.png +0 -0
package/main.js
ADDED
|
@@ -0,0 +1,1638 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* ioBroker Adapter für Feller zeptrion / zApp WLAN-Aktoren
|
|
5
|
+
* (WLAN-Nebenstelle 4K = zApp-Gateway, WLAN-Zwischenmodul 2K = zApp-Booster)
|
|
6
|
+
*
|
|
7
|
+
* Basiert auf der zrap Webservice API, Dokument 10.ZEPAPI-E.1612 / Version 1.0
|
|
8
|
+
*
|
|
9
|
+
* Enthält:
|
|
10
|
+
* - Polling von Kanalzuständen (zrap/chscan) und Signalstärke (zrap/rssi)
|
|
11
|
+
* - Statische Geräteinfos (zrap/id), Netzwerkinfos (zrap/net), Kanalbeschreibungen (zrap/chdes)
|
|
12
|
+
* - Vollständige Kanalsteuerung (zrap/chctrl): on/off/stop/toggle/open/close/move_open/
|
|
13
|
+
* move_close/dim_up/dim_down inkl. timed-Varianten, sowie Szenen recall/store/delete
|
|
14
|
+
* - Systembefehle (zrap/sys): reboot / factory-default / network-default
|
|
15
|
+
* - Sammelbefehle für Hagelalarm: control.closeAllShutters / openAllShutters / stopAllShutters
|
|
16
|
+
* - mDNS-Discovery (Kapitel 4 der API-Doku) zum automatischen Auffinden von Geräten im Netz,
|
|
17
|
+
* Ergebnisse werden als deaktivierte Zeilen in die Konfigurationstabelle übernommen
|
|
18
|
+
* (kombiniert Auto-Erkennung mit manueller Kontrolle/Aktivierung durch den Anwender)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const utils = require('@iobroker/adapter-core');
|
|
22
|
+
const axios = require('axios');
|
|
23
|
+
const { XMLParser } = require('fast-xml-parser');
|
|
24
|
+
|
|
25
|
+
let Bonjour;
|
|
26
|
+
try {
|
|
27
|
+
// optionale Abhängigkeit - Discovery wird ohne dieses Modul einfach übersprungen
|
|
28
|
+
Bonjour = require('bonjour-service').Bonjour;
|
|
29
|
+
} catch (e) {
|
|
30
|
+
Bonjour = null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const xmlParser = new XMLParser({
|
|
34
|
+
ignoreAttributes: true,
|
|
35
|
+
trimValues: true,
|
|
36
|
+
// WICHTIG: ohne diese beiden Optionen landet die XML-Deklaration (<?xml ...?>)
|
|
37
|
+
// als eigener Key '?xml' im Ergebnis und Object.keys(parsed)[0] träfe die
|
|
38
|
+
// Deklaration statt der Nutzdaten (Bug in 0.5.0: alle Werte blieben null).
|
|
39
|
+
ignoreDeclaration: true,
|
|
40
|
+
ignorePiTags: true,
|
|
41
|
+
// Werte NICHT automatisch in Zahlen wandeln: chdes type/cat sind Codes wie
|
|
42
|
+
// "0815", die als Zahl ihre führende Null verlieren würden. Numerische Felder
|
|
43
|
+
// (val, dbm) werden gezielt per parseInt konvertiert.
|
|
44
|
+
parseTagValue: false
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// gültige einfache chctrl-Kommandos (Kapitel 3.6.3)
|
|
48
|
+
const SIMPLE_CMDS = [
|
|
49
|
+
'stop', 'on', 'off', 'toggle',
|
|
50
|
+
'dim_up', 'dim_down',
|
|
51
|
+
'close', 'open',
|
|
52
|
+
'move_close', 'move_open'
|
|
53
|
+
];
|
|
54
|
+
// Szenenbefehle 1-4 (Kapitel 3.6.3)
|
|
55
|
+
const SCENE_CMDS = [1, 2, 3, 4].flatMap(n => [`recall_s${n}`, `store_s${n}`, `delete_s${n}`]);
|
|
56
|
+
// zeitgesteuerte Varianten, t = 100-32000 ms (Kapitel 3.6.3)
|
|
57
|
+
const TIMED_CMD_RE = /^(dim_up|dim_down|move_open|move_close|dim)_(\d{3,5})$/;
|
|
58
|
+
|
|
59
|
+
function isValidChCmd(cmd) {
|
|
60
|
+
if (SIMPLE_CMDS.includes(cmd) || SCENE_CMDS.includes(cmd)) return true;
|
|
61
|
+
const m = String(cmd).match(TIMED_CMD_RE);
|
|
62
|
+
if (m) {
|
|
63
|
+
const t = parseInt(m[2], 10);
|
|
64
|
+
return t >= 100 && t <= 32000;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SYS_CMDS = {
|
|
70
|
+
reboot: 'reboot',
|
|
71
|
+
factoryDefault: 'factory-default',
|
|
72
|
+
networkDefault: 'network-default'
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Zeitfenster nach einem gesendeten Bewegungsbefehl, in dem ein zeitgleicher
|
|
76
|
+
// chscan-Resync den Kanalwert nicht überschreibt (siehe sendChannelCommand).
|
|
77
|
+
const COMMAND_SETTLE_MS = 5000;
|
|
78
|
+
// Debounce-Fenster: mehrere Kanalbefehle desselben Geräts, die innerhalb dieser
|
|
79
|
+
// Zeit eintreffen (z.B. "alle Storen schliessen"), werden zu einem einzigen
|
|
80
|
+
// Multicast-POST an /zrap/chctrl gebündelt statt N sequentiellen Einzelrequests
|
|
81
|
+
// an den (schwachbrüstigen) Embedded-Webserver des Aktors.
|
|
82
|
+
const COMMAND_BATCH_MS = 50;
|
|
83
|
+
// Long-Poll-Timeout für zrap/chnotify: laut Doku antwortet das Gerät spätestens
|
|
84
|
+
// nach 30s auch ohne Änderung - Timeout grosszügig darüber ansetzen.
|
|
85
|
+
const NOTIFY_TIMEOUT_MS = 35000;
|
|
86
|
+
// Pause vor einem erneuten chnotify-Aufruf nach einem Fehler, um das Gerät/Netz
|
|
87
|
+
// bei anhaltenden Problemen nicht zuzuspammen.
|
|
88
|
+
const NOTIFY_ERROR_RETRY_MS = 10000;
|
|
89
|
+
// Grenzen der zeitgesteuerten chctrl-Befehle laut API (Kapitel 3.6.3).
|
|
90
|
+
const MIN_TIMED_MS = 100;
|
|
91
|
+
const MAX_TIMED_MS = 32000;
|
|
92
|
+
// Pause zwischen zwei gestückelten Fahr-Impulsen bei setPosition (Fahrten länger
|
|
93
|
+
// als MAX_TIMED_MS müssen in mehrere move_*_(t)-Impulse zerlegt werden).
|
|
94
|
+
const DRIVE_GAP_MS = 400;
|
|
95
|
+
|
|
96
|
+
const CH_BUTTONS = {
|
|
97
|
+
stop: 'Stopp',
|
|
98
|
+
on: 'Ein (100%)',
|
|
99
|
+
off: 'Aus (0%)',
|
|
100
|
+
toggle: 'Umschalten',
|
|
101
|
+
open: 'Öffnen',
|
|
102
|
+
close: 'Schliessen',
|
|
103
|
+
move_open: 'Öffnen (Taste halten)',
|
|
104
|
+
move_close: 'Schliessen (Taste halten)',
|
|
105
|
+
dim_up: 'Dimmen hoch (Taste halten)',
|
|
106
|
+
dim_down: 'Dimmen runter (Taste halten)'
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
class Zeptrion extends utils.Adapter {
|
|
110
|
+
constructor(options) {
|
|
111
|
+
super({ ...options, name: 'zeptrion' });
|
|
112
|
+
|
|
113
|
+
this.devices = {}; // id -> { cfg, client, timer, fails, connected }
|
|
114
|
+
|
|
115
|
+
this.on('ready', this.onReady.bind(this));
|
|
116
|
+
this.on('stateChange', this.onStateChange.bind(this));
|
|
117
|
+
this.on('message', this.onMessage.bind(this));
|
|
118
|
+
this.on('unload', this.onUnload.bind(this));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------- ready
|
|
122
|
+
|
|
123
|
+
async onReady() {
|
|
124
|
+
await this.setStateAsync('info.connection', { val: false, ack: true });
|
|
125
|
+
|
|
126
|
+
const timeout = parseInt(this.config.requestTimeout, 10) || 4000;
|
|
127
|
+
this.requestTimeout = Math.min(Math.max(timeout, 500), 30000);
|
|
128
|
+
|
|
129
|
+
const devicesCfg = Array.isArray(this.config.devices) ? this.config.devices : [];
|
|
130
|
+
|
|
131
|
+
// Auto-ID: Zeilen mit Host aber ohne ID bekommen eine aus dem Host abgeleitete
|
|
132
|
+
// ID (z.B. "10.195.36.116" -> "zapp_10_195_36_116", "zapp-14150003.local" ->
|
|
133
|
+
// "zapp_14150003"). Änderung wird einmalig in die Konfiguration zurück-
|
|
134
|
+
// geschrieben (Adapter startet dadurch neu - passiert nur bei Änderungen).
|
|
135
|
+
let cfgChanged = false;
|
|
136
|
+
const usedIds = new Set(devicesCfg.map(d => d && d.id).filter(Boolean));
|
|
137
|
+
for (const d of devicesCfg) {
|
|
138
|
+
if (d && d.host && !d.id) {
|
|
139
|
+
let base = String(d.host).trim().replace(/\.local\.?$/i, '').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
140
|
+
if (/^\d/.test(base)) base = 'zapp_' + base;
|
|
141
|
+
let candidate = base || 'device';
|
|
142
|
+
let i = 2;
|
|
143
|
+
while (usedIds.has(candidate)) candidate = `${base}_${i++}`;
|
|
144
|
+
d.id = candidate;
|
|
145
|
+
usedIds.add(candidate);
|
|
146
|
+
if (!d.name) d.name = d.host;
|
|
147
|
+
cfgChanged = true;
|
|
148
|
+
this.log.info(`Geräte-ID automatisch vergeben: "${candidate}" für Host ${d.host}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (cfgChanged) {
|
|
152
|
+
const instObj = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`);
|
|
153
|
+
if (instObj) {
|
|
154
|
+
instObj.native.devices = devicesCfg;
|
|
155
|
+
await this.setForeignObjectAsync(`system.adapter.${this.namespace}`, instObj);
|
|
156
|
+
return; // Adapter startet durch die Konfig-Änderung neu
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const active = devicesCfg.filter(d => d && d.enabled !== false && d.id && d.host);
|
|
161
|
+
|
|
162
|
+
// --- Startup-Validierung aller manuell/per Import erfassten Zeilen ---
|
|
163
|
+
// Ungültige Zeilen werden übersprungen (mit klarer Log-Meldung), nicht nur
|
|
164
|
+
// stillschweigend falsch verarbeitet. Duplikate (ID oder Host doppelt, auch
|
|
165
|
+
// nach Sanitisierung) würden sich sonst gegenseitig im Geräte-Registry
|
|
166
|
+
// überschreiben und Geisterzustände hinterlassen.
|
|
167
|
+
const seenIds = new Set();
|
|
168
|
+
const seenHosts = new Set();
|
|
169
|
+
const validated = [];
|
|
170
|
+
for (const d of active) {
|
|
171
|
+
const errs = this.validateDeviceRow(d);
|
|
172
|
+
const sanId = this.sanitize(d.id);
|
|
173
|
+
if (seenIds.has(sanId)) errs.push(`ID "${d.id}" (sanitisiert "${sanId}") ist doppelt vergeben`);
|
|
174
|
+
const hostKey = String(d.host).trim().toLowerCase();
|
|
175
|
+
if (seenHosts.has(hostKey)) errs.push(`Host "${d.host}" ist doppelt konfiguriert`);
|
|
176
|
+
if (errs.length) {
|
|
177
|
+
this.log.error(`Gerät "${d.name || d.id || d.host}" übersprungen: ${errs.join('; ')}`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
seenIds.add(sanId);
|
|
181
|
+
seenHosts.add(hostKey);
|
|
182
|
+
validated.push(d);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!validated.length && active.length) {
|
|
186
|
+
this.log.error('Alle konfigurierten Geräte sind ungültig - bitte Konfiguration prüfen (Test-Button verwenden).');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- Paralleles Setup ---
|
|
190
|
+
// Sequentielles await würde bei vielen (teils offline) Geräten den Start
|
|
191
|
+
// minutenlang blockieren (jedes Gerät macht mehrere HTTP-Calls mit Timeout).
|
|
192
|
+
await Promise.allSettled(validated.map(async (dev) => {
|
|
193
|
+
try {
|
|
194
|
+
await this.setupDevice(dev);
|
|
195
|
+
} catch (err) {
|
|
196
|
+
this.log.error(`Gerät ${dev.id} konnte nicht initialisiert werden: ${err.message || err}`);
|
|
197
|
+
}
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
// Verwaiste Geräte-Objekte entfernen: alles unter zeptrion.N.<deviceId>, dessen
|
|
201
|
+
// <deviceId> nicht (mehr) in der aktiven Konfiguration steht, wird gelöscht.
|
|
202
|
+
// Verhindert, dass States gelöschter/umbenannter Geräte im Objektbaum liegen
|
|
203
|
+
// bleiben (Bug: alte Zustände blieben nach Entfernen/Ersetzen eines Geräts).
|
|
204
|
+
await this.cleanupOrphanedDevices(validated);
|
|
205
|
+
|
|
206
|
+
if (!active.length) {
|
|
207
|
+
this.log.warn('Keine aktiven zeptrion Geräte konfiguriert. Bitte in der Instanz-Konfiguration Geräte anlegen oder Discovery-Button verwenden.');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
await this.createGlobalControlObjects();
|
|
211
|
+
|
|
212
|
+
this.subscribeStates('*');
|
|
213
|
+
this.updateGlobalConnection();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Löscht Objektbäume von Geräten, die nicht mehr in der aktiven Konfiguration
|
|
218
|
+
* stehen. getAdapterObjects liefert nur die Objekte dieser Instanz; daraus die
|
|
219
|
+
* Top-Level-Geräte-IDs ableiten und gegen die konfigurierten IDs abgleichen.
|
|
220
|
+
* Reservierte Top-Level-Knoten (info, control) werden nie angetastet.
|
|
221
|
+
*/
|
|
222
|
+
async cleanupOrphanedDevices(validated) {
|
|
223
|
+
const keepIds = new Set(validated.map(d => this.sanitize(d.id)));
|
|
224
|
+
const reserved = new Set(['info', 'control']);
|
|
225
|
+
try {
|
|
226
|
+
const all = await this.getAdapterObjectsAsync();
|
|
227
|
+
const prefix = `${this.namespace}.`;
|
|
228
|
+
const deviceIds = new Set();
|
|
229
|
+
for (const fullId of Object.keys(all)) {
|
|
230
|
+
if (!fullId.startsWith(prefix)) continue;
|
|
231
|
+
const top = fullId.substring(prefix.length).split('.')[0];
|
|
232
|
+
if (top && !reserved.has(top)) deviceIds.add(top);
|
|
233
|
+
}
|
|
234
|
+
for (const devId of deviceIds) {
|
|
235
|
+
if (!keepIds.has(devId)) {
|
|
236
|
+
this.log.info(`Entferne verwaistes Gerät "${devId}" (nicht mehr in der Konfiguration).`);
|
|
237
|
+
await this.delObjectAsync(devId, { recursive: true });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
this.log.warn(`Aufräumen verwaister Objekte fehlgeschlagen: ${err.message || err}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Validiert eine Geräte-Zeile aus der Konfiguration/dem CSV-Import. Gibt eine
|
|
246
|
+
* Liste menschenlesbarer Fehler zurück (leer = gültig). */
|
|
247
|
+
validateDeviceRow(d) {
|
|
248
|
+
const errs = [];
|
|
249
|
+
const host = String(d.host || '').trim();
|
|
250
|
+
if (!host) {
|
|
251
|
+
errs.push('Host fehlt');
|
|
252
|
+
} else if (!/^[a-zA-Z0-9.-]+$/.test(host)) {
|
|
253
|
+
errs.push(`Host "${host}" enthält ungültige Zeichen (kein http://, keine Leerzeichen, kein Port)`);
|
|
254
|
+
} else if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
255
|
+
const octets = host.split('.').map(Number);
|
|
256
|
+
if (octets.length !== 4 || octets.some(o => o < 0 || o > 255)) {
|
|
257
|
+
errs.push(`"${host}" ist keine gültige IPv4-Adresse`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (d.id && !/^[a-zA-Z0-9_-]+$/.test(String(d.id))) {
|
|
261
|
+
errs.push(`ID "${d.id}" enthält ungültige Zeichen (erlaubt: a-z, 0-9, _, -)`);
|
|
262
|
+
}
|
|
263
|
+
const ch = parseInt(d.channels, 10);
|
|
264
|
+
if (d.channels !== undefined && d.channels !== '' && (isNaN(ch) || ch < 1 || ch > 4)) {
|
|
265
|
+
errs.push(`Kanäle "${d.channels}" ungültig (1-4)`);
|
|
266
|
+
}
|
|
267
|
+
if (d.kind !== undefined && d.kind !== '' && !['unknown', 'blind', 'light'].includes(String(d.kind))) {
|
|
268
|
+
errs.push(`Art "${d.kind}" ungültig (unknown/blind/light)`);
|
|
269
|
+
}
|
|
270
|
+
const tt = parseInt(d.travelTimeSec, 10);
|
|
271
|
+
if (d.travelTimeSec !== undefined && d.travelTimeSec !== '' && (isNaN(tt) || tt < 0 || tt > 300)) {
|
|
272
|
+
errs.push(`Laufzeit "${d.travelTimeSec}" ungültig (0-300s)`);
|
|
273
|
+
}
|
|
274
|
+
if (d.travelTimeSecCh !== undefined && String(d.travelTimeSecCh).trim() !== '') {
|
|
275
|
+
const parts = String(d.travelTimeSecCh).split(',').map(s => s.trim());
|
|
276
|
+
if (parts.length > 4) {
|
|
277
|
+
errs.push(`Laufzeit/Kanal "${d.travelTimeSecCh}": maximal 4 Werte`);
|
|
278
|
+
}
|
|
279
|
+
for (const p of parts) {
|
|
280
|
+
if (p === '') continue; // leerer Eintrag = Fallback auf travelTimeSec
|
|
281
|
+
const v = parseInt(p, 10);
|
|
282
|
+
if (isNaN(v) || v < 0 || v > 300 || String(v) !== p) {
|
|
283
|
+
errs.push(`Laufzeit/Kanal "${d.travelTimeSecCh}": Wert "${p}" ungültig (0-300, ganzzahlig)`);
|
|
284
|
+
break;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const tp = parseInt(d.tiltTimeMs, 10);
|
|
289
|
+
if (d.tiltTimeMs !== undefined && d.tiltTimeMs !== '' && (isNaN(tp) || tp < 0 || tp > 5000)) {
|
|
290
|
+
errs.push(`Kipp-Impuls "${d.tiltTimeMs}" ungültig (0-5000ms)`);
|
|
291
|
+
}
|
|
292
|
+
const pi = parseInt(d.pollInterval, 10);
|
|
293
|
+
if (d.pollInterval !== undefined && d.pollInterval !== '' && (isNaN(pi) || pi < 5 || pi > 3600)) {
|
|
294
|
+
errs.push(`Poll-Intervall "${d.pollInterval}" ungültig (5-3600s)`);
|
|
295
|
+
}
|
|
296
|
+
return errs;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
sanitize(str) {
|
|
300
|
+
return String(str || '').trim().replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40) || 'device';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async ensureState(idPath, common) {
|
|
304
|
+
await this.setObjectNotExistsAsync(idPath, {
|
|
305
|
+
type: 'state',
|
|
306
|
+
common,
|
|
307
|
+
native: {}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async createGlobalControlObjects() {
|
|
312
|
+
await this.setObjectNotExistsAsync('control', {
|
|
313
|
+
type: 'channel',
|
|
314
|
+
common: { name: 'Sammelbefehle' },
|
|
315
|
+
native: {}
|
|
316
|
+
});
|
|
317
|
+
await this.ensureState('control.closeAllShutters', {
|
|
318
|
+
name: 'ALLE Storen schliessen (z.B. Hagelalarm)',
|
|
319
|
+
type: 'boolean', role: 'button', read: false, write: true, def: false
|
|
320
|
+
});
|
|
321
|
+
await this.ensureState('control.openAllShutters', {
|
|
322
|
+
name: 'Alle Storen öffnen',
|
|
323
|
+
type: 'boolean', role: 'button', read: false, write: true, def: false
|
|
324
|
+
});
|
|
325
|
+
await this.ensureState('control.stopAllShutters', {
|
|
326
|
+
name: 'Alle Storen stoppen',
|
|
327
|
+
type: 'boolean', role: 'button', read: false, write: true, def: false
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ------------------------------------------------------- Geräte-Setup
|
|
332
|
+
|
|
333
|
+
async setupDevice(devCfg) {
|
|
334
|
+
const id = this.sanitize(devCfg.id);
|
|
335
|
+
const channels = Math.min(Math.max(parseInt(devCfg.channels, 10) || 1, 1), 4);
|
|
336
|
+
const pollInterval = Math.max(parseInt(devCfg.pollInterval, 10) || 30, 5) * 1000;
|
|
337
|
+
const host = String(devCfg.host).trim();
|
|
338
|
+
const travelTimeMs = Math.max(parseInt(devCfg.travelTimeSec, 10) || 0, 0) * 1000;
|
|
339
|
+
const travelOverrides = String(devCfg.travelTimeSecCh || '').split(',').map(s => s.trim());
|
|
340
|
+
const travelTimeMsByCh = {};
|
|
341
|
+
for (let n = 1; n <= channels; n++) {
|
|
342
|
+
const raw = travelOverrides[n - 1];
|
|
343
|
+
const sec = (raw !== undefined && raw !== '') ? parseInt(raw, 10) : NaN;
|
|
344
|
+
travelTimeMsByCh[n] = (!isNaN(sec) && sec >= 0) ? sec * 1000 : travelTimeMs;
|
|
345
|
+
}
|
|
346
|
+
const tiltTimeMs = Math.max(parseInt(devCfg.tiltTimeMs, 10) || 0, 0);
|
|
347
|
+
const smartfront = devCfg.smartfront === true;
|
|
348
|
+
|
|
349
|
+
if (!host) {
|
|
350
|
+
this.log.warn(`Gerät ${id}: kein Host angegeben, wird übersprungen.`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const client = axios.create({
|
|
355
|
+
baseURL: `http://${host}`,
|
|
356
|
+
timeout: this.requestTimeout,
|
|
357
|
+
maxRedirects: 0,
|
|
358
|
+
validateStatus: status => status < 400
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
this.devices[id] = {
|
|
362
|
+
cfg: { id, name: devCfg.name || id, host, channels, pollInterval, kind: devCfg.kind || 'unknown', travelTimeMs, travelTimeMsByCh, tiltTimeMs, smartfront },
|
|
363
|
+
client,
|
|
364
|
+
timer: null,
|
|
365
|
+
notifyActive: false,
|
|
366
|
+
channelBusyUntil: {},
|
|
367
|
+
posEstimate: {}, // chNum -> 0-100 (Software-Schätzung, siehe updatePositionEstimate)
|
|
368
|
+
moveState: {}, // chNum -> {dir, startTs, startPos} während einer laufenden move_open/move_close-Fahrt
|
|
369
|
+
driveToken: {}, // chNum -> Symbol der aktuell laufenden setPosition-Sequenz (Abbruch-Mechanismus)
|
|
370
|
+
pendingCmds: {}, // chNum -> cmd, wird gebündelt und nach COMMAND_BATCH_MS als Multicast-POST gesendet
|
|
371
|
+
pendingCallbacks: [],
|
|
372
|
+
pendingTimer: null,
|
|
373
|
+
fails: 0,
|
|
374
|
+
connected: false
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
await this.createDeviceObjects(id, channels);
|
|
378
|
+
await this.refreshStaticInfo(id);
|
|
379
|
+
this.startPolling(id);
|
|
380
|
+
if (this.config.useNotify !== false) {
|
|
381
|
+
this.startNotifyLoop(id);
|
|
382
|
+
} else {
|
|
383
|
+
this.log.info(`[${id}] chnotify-Long-Poll per Konfiguration deaktiviert, nur Intervall-Polling aktiv.`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async createDeviceObjects(id, channelCount) {
|
|
388
|
+
const dev = this.devices[id];
|
|
389
|
+
|
|
390
|
+
await this.setObjectNotExistsAsync(id, {
|
|
391
|
+
type: 'device',
|
|
392
|
+
common: { name: dev.cfg.name, icon: '/adapter/zeptrion/zeptrion.png' },
|
|
393
|
+
native: { host: dev.cfg.host, channels: dev.cfg.channels, kind: dev.cfg.kind, pollInterval: dev.cfg.pollInterval }
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// --- info ---
|
|
397
|
+
await this.setObjectNotExistsAsync(`${id}.info`, { type: 'channel', common: { name: 'Geräteinformationen' }, native: {} });
|
|
398
|
+
await this.ensureState(`${id}.info.connection`, { name: 'Verbindung OK', type: 'boolean', role: 'indicator.reachable', read: true, write: false, def: false });
|
|
399
|
+
await this.ensureState(`${id}.info.lastError`, { name: 'Letzter Fehler', type: 'string', role: 'text', read: true, write: false, def: '' });
|
|
400
|
+
await this.ensureState(`${id}.info.hw`, { name: 'Hardware-Version', type: 'string', role: 'info.hardware', read: true, write: false });
|
|
401
|
+
await this.ensureState(`${id}.info.sw`, { name: 'Software-Version', type: 'string', role: 'info.firmware', read: true, write: false });
|
|
402
|
+
await this.ensureState(`${id}.info.boot`, { name: 'Bootloader-Version', type: 'string', role: 'text', read: true, write: false });
|
|
403
|
+
await this.ensureState(`${id}.info.sn`, { name: 'Seriennummer', type: 'string', role: 'info.serial', read: true, write: false });
|
|
404
|
+
await this.ensureState(`${id}.info.sys`, { name: 'System-Name', type: 'string', role: 'text', read: true, write: false });
|
|
405
|
+
await this.ensureState(`${id}.info.type`, { name: 'Gerätetyp (Device ID)', type: 'string', role: 'text', read: true, write: false });
|
|
406
|
+
await this.ensureState(`${id}.info.oen`, { name: 'Owner Environment', type: 'string', role: 'text', read: true, write: false });
|
|
407
|
+
await this.ensureState(`${id}.info.rssi`, { name: 'Signalstärke', type: 'number', role: 'value', unit: 'dBm', read: true, write: false });
|
|
408
|
+
await this.ensureState(`${id}.info.refresh`, { name: 'Statische Infos neu laden (id/net/chdes)', type: 'boolean', role: 'button', read: false, write: true, def: false });
|
|
409
|
+
|
|
410
|
+
// --- network (read-only Anzeige, siehe README für Gründe) ---
|
|
411
|
+
await this.setObjectNotExistsAsync(`${id}.network`, { type: 'channel', common: { name: 'Netzwerk' }, native: {} });
|
|
412
|
+
const netFields = {
|
|
413
|
+
ssid: 'SSID', ip: 'IP-Adresse', mac: 'MAC-Adresse',
|
|
414
|
+
mode: 'Netzwerkmodus (0=AccessPoint, 1=Associate)', enc: 'Verschlüsselung',
|
|
415
|
+
mask: 'Subnetzmaske', gw: 'Gateway', bssid: 'MAC-Adresse Access Point'
|
|
416
|
+
};
|
|
417
|
+
for (const [key, name] of Object.entries(netFields)) {
|
|
418
|
+
await this.ensureState(`${id}.network.${key}`, { name, type: 'string', role: 'text', read: true, write: false });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// --- system ---
|
|
422
|
+
await this.setObjectNotExistsAsync(`${id}.system`, { type: 'channel', common: { name: 'Systembefehle' }, native: {} });
|
|
423
|
+
await this.ensureState(`${id}.system.reboot`, { name: 'Neustart', type: 'boolean', role: 'button', read: false, write: true, def: false });
|
|
424
|
+
await this.ensureState(`${id}.system.unlock`, {
|
|
425
|
+
name: 'Entriegelung für Werksreset (Sicherheitsverriegelung: muss max. 30s VOR factoryDefault auf true gesetzt werden)',
|
|
426
|
+
type: 'boolean', role: 'button', read: false, write: true, def: false
|
|
427
|
+
});
|
|
428
|
+
await this.ensureState(`${id}.system.factoryDefault`, { name: 'ACHTUNG: Werksreset - löscht ALLE Einstellungen inkl. WLAN, Gerät fällt vom Netz! Erfordert vorheriges system.unlock (30s-Fenster)', type: 'boolean', role: 'button', read: false, write: true, def: false });
|
|
429
|
+
await this.ensureState(`${id}.system.networkDefault`, { name: 'Zurück in Access-Point-Modus (Konfiguration bleibt erhalten)', type: 'boolean', role: 'button', read: false, write: true, def: false });
|
|
430
|
+
|
|
431
|
+
// --- location (zrap/loc) ---
|
|
432
|
+
await this.setObjectNotExistsAsync(`${id}.location`, { type: 'channel', common: { name: 'Standort' }, native: {} });
|
|
433
|
+
await this.ensureState(`${id}.location.name`, { name: 'Standortbezeichnung (frei wählbar, z.B. "Fideris Valzigg")', type: 'string', role: 'text', read: true, write: true });
|
|
434
|
+
|
|
435
|
+
// --- ntp (zrap/ntp) ---
|
|
436
|
+
await this.setObjectNotExistsAsync(`${id}.ntp`, { type: 'channel', common: { name: 'NTP' }, native: {} });
|
|
437
|
+
await this.ensureState(`${id}.ntp.url`, { name: 'NTP-Server (URL/IP, max. 32 Zeichen)', type: 'string', role: 'text', read: true, write: true });
|
|
438
|
+
await this.ensureState(`${id}.ntp.per`, { name: 'Abfrageintervall in Stunden (0=deaktiviert)', type: 'number', role: 'value', read: true, write: true, min: 0, max: 255 });
|
|
439
|
+
|
|
440
|
+
// --- date (zrap/date) ---
|
|
441
|
+
await this.setObjectNotExistsAsync(`${id}.date`, { type: 'channel', common: { name: 'Datum/Zeit' }, native: {} });
|
|
442
|
+
await this.ensureState(`${id}.date.rfc1123`, { name: 'RFC1123 Zeitstempel (muss GMT sein)', type: 'string', role: 'text', read: true, write: true });
|
|
443
|
+
await this.ensureState(`${id}.date.tz`, { name: 'Zeitzonen-Offset HHMM (z.B. +0200)', type: 'string', role: 'text', read: true, write: true });
|
|
444
|
+
await this.ensureState(`${id}.date.dst`, { name: 'Sommerzeit-Offset HHMM', type: 'string', role: 'text', read: true, write: true });
|
|
445
|
+
await this.ensureState(`${id}.date.syncNow`, { name: 'Button: Geräte-Uhrzeit mit ioBroker-Host synchronisieren', type: 'boolean', role: 'button', read: false, write: true, def: false });
|
|
446
|
+
|
|
447
|
+
// --- smartfront (zapi, optional - nur bei angeschlossenem Smartfront-Taster) ---
|
|
448
|
+
if (dev.cfg.smartfront) {
|
|
449
|
+
await this.setObjectNotExistsAsync(`${id}.smartfront`, { type: 'channel', common: { name: 'Smartfront' }, native: {} });
|
|
450
|
+
await this.ensureState(`${id}.smartfront.temp`, { name: 'Temperatur', type: 'number', role: 'value.temperature', unit: '°C', read: true, write: false });
|
|
451
|
+
await this.ensureState(`${id}.smartfront.lux`, { name: 'Helligkeit', type: 'number', role: 'value.brightness', unit: 'lx', read: true, write: false });
|
|
452
|
+
await this.ensureState(`${id}.smartfront.hum`, { name: 'Luftfeuchtigkeit', type: 'number', role: 'value.humidity', unit: '%', read: true, write: false });
|
|
453
|
+
await this.ensureState(`${id}.smartfront.ledState`, { name: 'Aktueller LED-Status (JSON, read-only)', type: 'string', role: 'json', read: true, write: false });
|
|
454
|
+
await this.ensureState(`${id}.smartfront.ledSet`, {
|
|
455
|
+
name: 'LED(s) setzen - JSON-Array wie in API-Doku 5.1.3.4, z.B. [{"id":2,"bg":"#220000"}]. Laut Doku nur "bg" (Hintergrundfarbe) unbedenklich extern setzbar.',
|
|
456
|
+
type: 'string', role: 'json', read: false, write: true, def: ''
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// --- channels ---
|
|
461
|
+
// Rollen richten sich nach dem optionalen "kind"-Feld pro Gerät (Storen/Licht/
|
|
462
|
+
// unbekannt). Bei "unbekannt" bleibt es bei den bisherigen generischen Rollen,
|
|
463
|
+
// da die zrap-API selbst nicht zwischen Licht- und Storenkanal unterscheidet
|
|
464
|
+
// (chscan liefert für einen Storenkanal laut Doku i.d.R. ohnehin -1 = unbekannt -
|
|
465
|
+
// "level.blind" ist damit ein Angebot für VIS-Widget-Kompatibilität, liefert aber
|
|
466
|
+
// ohne echte Positionsrückmeldung der Hardware keinen laufend aktuellen Wert).
|
|
467
|
+
const kind = dev.cfg.kind;
|
|
468
|
+
// .val bleibt bewusst neutral ("value") - das ist der ROHE Hardwarewert und
|
|
469
|
+
// bei Storen laut Doku praktisch immer -1. Die Rolle "level.blind" (für VIS-
|
|
470
|
+
// Widgets) sitzt stattdessen auf der Software-Positionsschätzung unten.
|
|
471
|
+
const valRole = kind === 'light' ? 'level.dimmer' : 'value';
|
|
472
|
+
const btnRoles = kind === 'blind'
|
|
473
|
+
? { stop: 'button.stop', open: 'button.open.blind', close: 'button.close.blind' }
|
|
474
|
+
: {};
|
|
475
|
+
|
|
476
|
+
await this.setObjectNotExistsAsync(`${id}.channels`, { type: 'channel', common: { name: 'Kanäle' }, native: {} });
|
|
477
|
+
for (let n = 1; n <= channelCount; n++) {
|
|
478
|
+
const ch = `${id}.channels.ch${n}`;
|
|
479
|
+
await this.setObjectNotExistsAsync(ch, {
|
|
480
|
+
type: 'channel',
|
|
481
|
+
common: { name: `Kanal ${n}` },
|
|
482
|
+
native: { channelNumber: n, host: dev.cfg.host, kind }
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
await this.ensureState(`${ch}.val`, {
|
|
486
|
+
name: 'Zustand (0-100, bei Storen meist -1=unbekannt)',
|
|
487
|
+
type: 'number', role: valRole, min: -1, max: 100, read: true, write: false
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
if (kind === 'blind') {
|
|
491
|
+
const hasTravel = !!dev.cfg.travelTimeMsByCh[n];
|
|
492
|
+
// extendObject statt setObjectNotExists: Beschreibung und Semantik
|
|
493
|
+
// hängen von der Konfiguration ab und sollen sich mit aktualisieren.
|
|
494
|
+
await this.extendObjectAsync(`${ch}.posEstimate`, {
|
|
495
|
+
type: 'state',
|
|
496
|
+
common: {
|
|
497
|
+
name: hasTravel
|
|
498
|
+
? 'Geschätzte Ist-Position 0=zu/100=offen (reine Anzeige der Software-Schätzung, KEINE Hardware-Rückmeldung; zum Kalibrieren "calibrate" verwenden, zum Anfahren "setPosition")'
|
|
499
|
+
: 'Geschätzte Position (deaktiviert - "Laufzeit Storenmotor" auf >0s setzen)',
|
|
500
|
+
type: 'number', role: 'value.blind', min: 0, max: 100, read: true, write: false
|
|
501
|
+
},
|
|
502
|
+
native: {}
|
|
503
|
+
});
|
|
504
|
+
await this.extendObjectAsync(`${ch}.setPosition`, {
|
|
505
|
+
type: 'state',
|
|
506
|
+
common: {
|
|
507
|
+
name: hasTravel
|
|
508
|
+
? 'Position anfahren 0=zu/100=offen (zeitbasiert über move-Impulse; 0/100 fahren als echte Endlagenfahrt und rekalibrieren die Schätzung)'
|
|
509
|
+
: 'Position anfahren (deaktiviert - "Laufzeit Storenmotor" auf >0s setzen)',
|
|
510
|
+
type: 'number', role: 'level.blind', min: 0, max: 100, read: true, write: true
|
|
511
|
+
},
|
|
512
|
+
native: {}
|
|
513
|
+
});
|
|
514
|
+
await this.ensureState(`${ch}.calibrate`, {
|
|
515
|
+
name: 'Schätzung setzen OHNE Fahrt (z.B. nach manueller Bedienung am Wandtaster): aktuellen Ist-Zustand in % eintragen',
|
|
516
|
+
type: 'number', role: 'value', min: 0, max: 100, read: false, write: true
|
|
517
|
+
});
|
|
518
|
+
await this.extendObjectAsync(`${ch}.tiltOpen`, {
|
|
519
|
+
type: 'state',
|
|
520
|
+
common: {
|
|
521
|
+
name: dev.cfg.tiltTimeMs
|
|
522
|
+
? `Lamellen kippen Richtung offen (Impuls ${dev.cfg.tiltTimeMs}ms)`
|
|
523
|
+
: 'Lamellen kippen (deaktiviert - "Kipp-Impuls (ms)" in der Konfiguration setzen)',
|
|
524
|
+
type: 'boolean', role: 'button', read: false, write: true, def: false
|
|
525
|
+
},
|
|
526
|
+
native: {}
|
|
527
|
+
});
|
|
528
|
+
await this.extendObjectAsync(`${ch}.tiltClose`, {
|
|
529
|
+
type: 'state',
|
|
530
|
+
common: {
|
|
531
|
+
name: dev.cfg.tiltTimeMs
|
|
532
|
+
? `Lamellen kippen Richtung zu (Impuls ${dev.cfg.tiltTimeMs}ms)`
|
|
533
|
+
: 'Lamellen kippen (deaktiviert - "Kipp-Impuls (ms)" in der Konfiguration setzen)',
|
|
534
|
+
type: 'boolean', role: 'button', read: false, write: true, def: false
|
|
535
|
+
},
|
|
536
|
+
native: {}
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
await this.ensureState(`${ch}.name`, { name: 'Kanalname (chdes)', type: 'string', role: 'text', read: true, write: true });
|
|
541
|
+
await this.ensureState(`${ch}.group`, { name: 'Gruppe (chdes)', type: 'string', role: 'text', read: true, write: true });
|
|
542
|
+
await this.ensureState(`${ch}.icon`, { name: 'Icon (chdes)', type: 'string', role: 'text', read: true, write: true });
|
|
543
|
+
await this.ensureState(`${ch}.type`, { name: 'Typ-Code (chdes)', type: 'string', role: 'text', read: true, write: true });
|
|
544
|
+
await this.ensureState(`${ch}.cat`, { name: 'Kategorie-Code (chdes)', type: 'string', role: 'text', read: true, write: true });
|
|
545
|
+
|
|
546
|
+
await this.ensureState(`${ch}.command`, {
|
|
547
|
+
name: 'Freier Befehl (z.B. dim_2000, move_close_5000, recall_s1 …)',
|
|
548
|
+
type: 'string', role: 'text', read: false, write: true, def: ''
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
for (const [cmd, name] of Object.entries(CH_BUTTONS)) {
|
|
552
|
+
await this.ensureState(`${ch}.${cmd}`, {
|
|
553
|
+
name, type: 'boolean', role: btnRoles[cmd] || 'button', read: false, write: true, def: false
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
for (let s = 1; s <= 4; s++) {
|
|
557
|
+
await this.ensureState(`${ch}.recall_s${s}`, { name: `Szene ${s} abrufen`, type: 'boolean', role: 'button', read: false, write: true, def: false });
|
|
558
|
+
await this.ensureState(`${ch}.store_s${s}`, { name: `Szene ${s} speichern`, type: 'boolean', role: 'button', read: false, write: true, def: false });
|
|
559
|
+
await this.ensureState(`${ch}.delete_s${s}`, { name: `Szene ${s} löschen`, type: 'boolean', role: 'button', read: false, write: true, def: false });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ------------------------------------------------------------- HTTP-IO
|
|
565
|
+
|
|
566
|
+
async zrapGet(id, path, axiosOpts = {}) {
|
|
567
|
+
const dev = this.devices[id];
|
|
568
|
+
if (!dev) throw new Error(`Unbekanntes Gerät ${id}`);
|
|
569
|
+
const res = await dev.client.get(path, { responseType: 'text', transformResponse: [d => d], ...axiosOpts });
|
|
570
|
+
if (res.status >= 400) throw new Error(`HTTP ${res.status}`);
|
|
571
|
+
if (!res.data) return {};
|
|
572
|
+
const parsed = xmlParser.parse(res.data);
|
|
573
|
+
// Root-Element robust wählen: Keys, die mit '?' beginnen (XML-Deklaration,
|
|
574
|
+
// Processing Instructions), überspringen - zweite Verteidigungslinie zur
|
|
575
|
+
// Parser-Option ignoreDeclaration.
|
|
576
|
+
const rootKey = Object.keys(parsed).find(k => !k.startsWith('?'));
|
|
577
|
+
return (rootKey && parsed[rootKey]) || {};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async zrapPost(id, path, bodyObj) {
|
|
581
|
+
const dev = this.devices[id];
|
|
582
|
+
if (!dev) throw new Error(`Unbekanntes Gerät ${id}`);
|
|
583
|
+
const data = Object.entries(bodyObj)
|
|
584
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
585
|
+
.join('&');
|
|
586
|
+
const res = await dev.client.post(path, data, {
|
|
587
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
588
|
+
});
|
|
589
|
+
if (res.status >= 400) throw new Error(`HTTP ${res.status}`);
|
|
590
|
+
return res;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// zapi (Kapitel 5) ist JSON-basiert, im Gegensatz zu zrap (XML/urlencoded).
|
|
594
|
+
// Nur relevant für Geräte mit angeschlossenem Smartfront (WLAN-Zwischenmodul-2k
|
|
595
|
+
// 3340-2-B + Front 920-330x), daher separat und optional (Konfig-Checkbox).
|
|
596
|
+
async zapiGet(id, path) {
|
|
597
|
+
const dev = this.devices[id];
|
|
598
|
+
if (!dev) throw new Error(`Unbekanntes Gerät ${id}`);
|
|
599
|
+
const res = await dev.client.get(path);
|
|
600
|
+
if (res.status >= 400) throw new Error(`HTTP ${res.status}`);
|
|
601
|
+
return res.data;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async zapiPost(id, path, jsonBody) {
|
|
605
|
+
const dev = this.devices[id];
|
|
606
|
+
if (!dev) throw new Error(`Unbekanntes Gerät ${id}`);
|
|
607
|
+
const res = await dev.client.post(path, jsonBody, {
|
|
608
|
+
headers: { 'Content-Type': 'application/json' }
|
|
609
|
+
});
|
|
610
|
+
if (res.status >= 400) throw new Error(`HTTP ${res.status}`);
|
|
611
|
+
return res;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Extrahiert die erste Fliesskommazahl aus einem zapi-Sensorwert wie "24.50C" oder "none". */
|
|
615
|
+
parseSensorNumber(str) {
|
|
616
|
+
if (typeof str !== 'string') return null;
|
|
617
|
+
const m = str.match(/-?\d+(\.\d+)?/);
|
|
618
|
+
return m ? parseFloat(m[0]) : null;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Prüft String-Werte gegen die Byte-Limits der zrap-API (UTF-8-Bytes, nicht
|
|
623
|
+
* Zeichen! Ein Umlaut = 2 Bytes, siehe Fussnoten in Kapitel 3.7/3.12 der Doku).
|
|
624
|
+
* Wirft eine klare Fehlermeldung statt eines nichtssagenden HTTP-400 vom Gerät.
|
|
625
|
+
*/
|
|
626
|
+
validateApiString(value, maxBytes, fieldName) {
|
|
627
|
+
const str = String(value ?? '');
|
|
628
|
+
const bytes = Buffer.byteLength(str, 'utf8');
|
|
629
|
+
if (bytes > maxBytes) {
|
|
630
|
+
throw new Error(`${fieldName}: ${bytes} Bytes überschreiten das API-Limit von ${maxBytes} Bytes (Achtung: Umlaute zählen als 2 Bytes)`);
|
|
631
|
+
}
|
|
632
|
+
return str;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Reiht einen Kanalbefehl in die Sende-Queue des Geräts ein. Mehrere Befehle
|
|
637
|
+
* desselben Geräts, die innerhalb von COMMAND_BATCH_MS eintreffen, werden zu
|
|
638
|
+
* einem einzigen Multicast-POST an /zrap/chctrl gebündelt (Kapitel 3.6.5).
|
|
639
|
+
* Das Promise löst erst auf, wenn der gebündelte Request tatsächlich raus ist.
|
|
640
|
+
*/
|
|
641
|
+
sendChannelCommand(id, chNum, cmd) {
|
|
642
|
+
if (!isValidChCmd(cmd)) {
|
|
643
|
+
return Promise.reject(new Error(`Ungültiger Kanalbefehl "${cmd}"`));
|
|
644
|
+
}
|
|
645
|
+
const dev = this.devices[id];
|
|
646
|
+
if (!dev) return Promise.reject(new Error(`Unbekanntes Gerät ${id}`));
|
|
647
|
+
|
|
648
|
+
return new Promise((resolve, reject) => {
|
|
649
|
+
dev.pendingCmds[chNum] = cmd;
|
|
650
|
+
dev.pendingCallbacks.push({ resolve, reject });
|
|
651
|
+
if (!dev.pendingTimer) {
|
|
652
|
+
dev.pendingTimer = this.setTimeout(() => this.flushPendingCmds(id), COMMAND_BATCH_MS);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async flushPendingCmds(id) {
|
|
658
|
+
const dev = this.devices[id];
|
|
659
|
+
if (!dev) return;
|
|
660
|
+
const cmds = dev.pendingCmds;
|
|
661
|
+
const callbacks = dev.pendingCallbacks;
|
|
662
|
+
dev.pendingCmds = {};
|
|
663
|
+
dev.pendingCallbacks = [];
|
|
664
|
+
dev.pendingTimer = null;
|
|
665
|
+
|
|
666
|
+
const chNums = Object.keys(cmds);
|
|
667
|
+
if (!chNums.length) return;
|
|
668
|
+
|
|
669
|
+
try {
|
|
670
|
+
if (chNums.length === 1) {
|
|
671
|
+
const chNum = chNums[0];
|
|
672
|
+
await this.zrapPost(id, `/zrap/chctrl/ch${chNum}`, { cmd: cmds[chNum] });
|
|
673
|
+
this.log.info(`[${id}] Kanalbefehl gesendet: ch${chNum} -> ${cmds[chNum]}`);
|
|
674
|
+
} else {
|
|
675
|
+
const body = {};
|
|
676
|
+
for (const chNum of chNums) body[`cmd${chNum}`] = cmds[chNum];
|
|
677
|
+
await this.zrapPost(id, '/zrap/chctrl', body);
|
|
678
|
+
const summary = chNums.map(n => `ch${n}->${cmds[n]}`).join(', ');
|
|
679
|
+
this.log.info(`[${id}] Multicast-Befehl gesendet: ${summary}`);
|
|
680
|
+
this.log.debug(`[${id}] Multicast-Befehl gebündelt: ${JSON.stringify(body)}`);
|
|
681
|
+
}
|
|
682
|
+
for (const chNum of chNums) {
|
|
683
|
+
this.markChannelBusy(dev, chNum, cmds[chNum]);
|
|
684
|
+
this.updatePositionEstimate(id, parseInt(chNum, 10), cmds[chNum]).catch(() => {});
|
|
685
|
+
}
|
|
686
|
+
callbacks.forEach(cb => cb.resolve());
|
|
687
|
+
} catch (err) {
|
|
688
|
+
const summary = chNums.map(n => `ch${n}->${cmds[n]}`).join(', ');
|
|
689
|
+
this.log.warn(`[${id}] Kanalbefehl fehlgeschlagen (${summary}): ${err.message || err}`);
|
|
690
|
+
callbacks.forEach(cb => cb.reject(err));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Sicherheitsnetz gegen Race Conditions mit dem periodischen chscan-Resync
|
|
695
|
+
* (reine Lese-/Verwaltungsbefehle wie store/delete Szene bewegen nichts). */
|
|
696
|
+
markChannelBusy(dev, chNum, cmd) {
|
|
697
|
+
if (!/^(store_s|delete_s)/.test(cmd)) {
|
|
698
|
+
dev.channelBusyUntil[chNum] = Date.now() + COMMAND_SETTLE_MS;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Best-Effort-Positionsschätzung für Storenkanäle (siehe README "Positions-
|
|
704
|
+
* schätzung"). Die Hardware selbst liefert laut Feller-Doku für Storen nahezu
|
|
705
|
+
* immer -1 (unbekannt) - diese Schätzung basiert rein auf Bewegungsrichtung und
|
|
706
|
+
* verstrichener Zeit relativ zur konfigurierten Gesamtlaufzeit. Nur aktiv, wenn
|
|
707
|
+
* kind === 'blind' und eine Laufzeit (travelTimeMs) konfiguriert ist.
|
|
708
|
+
*/
|
|
709
|
+
async updatePositionEstimate(id, chNum, cmd) {
|
|
710
|
+
const dev = this.devices[id];
|
|
711
|
+
if (!dev || dev.cfg.kind !== 'blind' || !dev.cfg.travelTimeMsByCh[chNum]) return;
|
|
712
|
+
const travel = dev.cfg.travelTimeMsByCh[chNum];
|
|
713
|
+
const now = Date.now();
|
|
714
|
+
const cur = dev.posEstimate[chNum];
|
|
715
|
+
|
|
716
|
+
const setEstimate = async (val) => {
|
|
717
|
+
dev.posEstimate[chNum] = Math.max(0, Math.min(100, Math.round(val)));
|
|
718
|
+
await this.setStateAsync(`${id}.channels.ch${chNum}.posEstimate`, { val: dev.posEstimate[chNum], ack: true });
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
if (cmd === 'open' || cmd === 'close') {
|
|
722
|
+
// "open"/"close" fahren selbstständig bis zur Endlage. Die Fahrt wird
|
|
723
|
+
// trotzdem als moveState getrackt: ein "stop" mittendrin kann so die
|
|
724
|
+
// Zwischenposition berechnen, und der Endlagen-Timer feuert dann NICHT
|
|
725
|
+
// mehr fälschlich (moveState wurde durch stop genullt).
|
|
726
|
+
const dir = cmd === 'open' ? 'open' : 'close';
|
|
727
|
+
const target = cmd === 'open' ? 100 : 0;
|
|
728
|
+
const startTs = now;
|
|
729
|
+
dev.moveState[chNum] = { dir, startTs, startPos: cur ?? (dir === 'open' ? 0 : 100) };
|
|
730
|
+
this.setTimeout(() => {
|
|
731
|
+
if (!this.devices[id]) return;
|
|
732
|
+
const mv = dev.moveState[chNum];
|
|
733
|
+
if (!mv || mv.startTs !== startTs) return; // gestoppt oder neuer Befehl
|
|
734
|
+
dev.moveState[chNum] = null;
|
|
735
|
+
setEstimate(target).catch(() => {});
|
|
736
|
+
}, travel);
|
|
737
|
+
} else if (cmd === 'move_open' || cmd === 'move_close') {
|
|
738
|
+
dev.moveState[chNum] = {
|
|
739
|
+
dir: cmd === 'move_open' ? 'open' : 'close',
|
|
740
|
+
startTs: now,
|
|
741
|
+
startPos: cur ?? (cmd === 'move_open' ? 0 : 100)
|
|
742
|
+
};
|
|
743
|
+
} else if (cmd === 'stop') {
|
|
744
|
+
const mv = dev.moveState[chNum];
|
|
745
|
+
if (mv) {
|
|
746
|
+
const fraction = Math.min((now - mv.startTs) / travel, 1);
|
|
747
|
+
const val = mv.dir === 'open'
|
|
748
|
+
? mv.startPos + fraction * (100 - mv.startPos)
|
|
749
|
+
: mv.startPos - fraction * mv.startPos;
|
|
750
|
+
dev.moveState[chNum] = null;
|
|
751
|
+
await setEstimate(val);
|
|
752
|
+
}
|
|
753
|
+
} else {
|
|
754
|
+
const m = cmd.match(/^(move_open|move_close)_(\d{3,5})$/);
|
|
755
|
+
if (m) {
|
|
756
|
+
const dir = m[1] === 'move_open' ? 'open' : 'close';
|
|
757
|
+
const t = parseInt(m[2], 10);
|
|
758
|
+
const startPos = cur ?? (dir === 'open' ? 0 : 100);
|
|
759
|
+
const startTs = now;
|
|
760
|
+
dev.moveState[chNum] = { dir, startTs, startPos };
|
|
761
|
+
this.setTimeout(() => {
|
|
762
|
+
if (!this.devices[id]) return;
|
|
763
|
+
const mv = dev.moveState[chNum];
|
|
764
|
+
if (!mv || mv.startTs !== startTs) return; // durch neueren Befehl überschrieben
|
|
765
|
+
const fraction = Math.min(t / travel, 1);
|
|
766
|
+
const val = dir === 'open' ? startPos + fraction * (100 - startPos) : startPos - fraction * startPos;
|
|
767
|
+
dev.moveState[chNum] = null;
|
|
768
|
+
setEstimate(val).catch(() => {});
|
|
769
|
+
}, t);
|
|
770
|
+
}
|
|
771
|
+
// recall_sN, on/off/toggle, dim_*, store/delete: keine Schätzung möglich,
|
|
772
|
+
// Position bleibt unverändert.
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** Bricht eine laufende setPosition-Sequenz für diesen Kanal ab (z.B. weil ein
|
|
777
|
+
* manueller Befehl oder ein neues setPosition eingetroffen ist). */
|
|
778
|
+
cancelDrive(id, chNum) {
|
|
779
|
+
const dev = this.devices[id];
|
|
780
|
+
if (dev && dev.driveToken[chNum]) {
|
|
781
|
+
dev.driveToken[chNum] = null;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Fährt einen Storenkanal zeitbasiert auf eine Zielposition (0=zu, 100=offen).
|
|
787
|
+
*
|
|
788
|
+
* WICHTIG - Grenzen dieses Verfahrens (siehe README):
|
|
789
|
+
* Die Hardware meldet KEINE Position zurück (chscan liefert für Storen immer -1).
|
|
790
|
+
* Die Anfahrt basiert vollständig auf der Software-Schätzung + konfigurierter
|
|
791
|
+
* Motor-Laufzeit und driftet über die Zeit (Anlaufverzögerung, Temperatur, Last).
|
|
792
|
+
* Selbstkorrektur: Ziel 0/100 wird als echte Endlagenfahrt (cmd close/open)
|
|
793
|
+
* ausgeführt und rekalibriert die Schätzung; ohne bekannte Ausgangsposition wird
|
|
794
|
+
* zuerst eine Referenzfahrt zur näheren Endlage gemacht.
|
|
795
|
+
*
|
|
796
|
+
* Das API-Limit von 32s pro move_*_(t)-Impuls wird durch Stückelung in mehrere
|
|
797
|
+
* sequentielle Impulse umgangen (relevant bei Laufzeiten > ~64s).
|
|
798
|
+
*/
|
|
799
|
+
async driveToPosition(id, chNum, target) {
|
|
800
|
+
const dev = this.devices[id];
|
|
801
|
+
if (!dev) return;
|
|
802
|
+
if (dev.cfg.kind !== 'blind' || !dev.cfg.travelTimeMsByCh[chNum]) {
|
|
803
|
+
throw new Error('setPosition erfordert Art=Storen und eine konfigurierte Motor-Laufzeit (>0s) für diesen Kanal');
|
|
804
|
+
}
|
|
805
|
+
target = Math.max(0, Math.min(100, Math.round(Number(target))));
|
|
806
|
+
const travel = dev.cfg.travelTimeMsByCh[chNum];
|
|
807
|
+
|
|
808
|
+
// laufende Sequenz dieses Kanals abbrechen, eigenes Token registrieren
|
|
809
|
+
const token = Symbol('drive');
|
|
810
|
+
dev.driveToken[chNum] = token;
|
|
811
|
+
const aborted = () => !this.devices[id] || dev.driveToken[chNum] !== token;
|
|
812
|
+
|
|
813
|
+
// Endlagen als echte open/close-Fahrt: robust, rekalibriert die Schätzung
|
|
814
|
+
if (target === 0 || target === 100) {
|
|
815
|
+
await this.sendChannelCommand(id, chNum, target === 0 ? 'close' : 'open');
|
|
816
|
+
await this.setStateAsync(`${id}.channels.ch${chNum}.setPosition`, { val: target, ack: true });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// unbekannte Ausgangsposition: Referenzfahrt zur näheren Endlage
|
|
821
|
+
if (dev.posEstimate[chNum] === undefined) {
|
|
822
|
+
const refCmd = target < 50 ? 'close' : 'open';
|
|
823
|
+
const refPos = target < 50 ? 0 : 100;
|
|
824
|
+
this.log.info(`[${id}] ch${chNum}: Position unbekannt - Referenzfahrt (${refCmd}, ${Math.round(travel / 1000)}s) vor Anfahrt auf ${target}%`);
|
|
825
|
+
await this.sendChannelCommand(id, chNum, refCmd);
|
|
826
|
+
await this.delay(travel + 1000);
|
|
827
|
+
if (aborted()) return;
|
|
828
|
+
dev.posEstimate[chNum] = refPos;
|
|
829
|
+
dev.moveState[chNum] = null;
|
|
830
|
+
await this.setStateAsync(`${id}.channels.ch${chNum}.posEstimate`, { val: refPos, ack: true });
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Differenz in Fahrzeit umrechnen und in API-konforme Impulse stückeln
|
|
834
|
+
const current = dev.posEstimate[chNum];
|
|
835
|
+
const deltaPct = target - current;
|
|
836
|
+
if (Math.abs(deltaPct) < 1) {
|
|
837
|
+
await this.setStateAsync(`${id}.channels.ch${chNum}.setPosition`, { val: target, ack: true });
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const dirCmd = deltaPct > 0 ? 'move_open' : 'move_close';
|
|
841
|
+
let remainingMs = Math.round(Math.abs(deltaPct) / 100 * travel);
|
|
842
|
+
if (remainingMs < MIN_TIMED_MS) {
|
|
843
|
+
this.log.debug(`[${id}] ch${chNum}: Differenz ${deltaPct}% ergäbe ${remainingMs}ms < API-Minimum ${MIN_TIMED_MS}ms - keine Fahrt`);
|
|
844
|
+
await this.setStateAsync(`${id}.channels.ch${chNum}.setPosition`, { val: current, ack: true });
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
while (remainingMs > 0) {
|
|
849
|
+
if (aborted()) {
|
|
850
|
+
this.log.debug(`[${id}] ch${chNum}: setPosition-Sequenz abgebrochen`);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const pulse = Math.max(MIN_TIMED_MS, Math.min(remainingMs, MAX_TIMED_MS));
|
|
854
|
+
await this.sendChannelCommand(id, chNum, `${dirCmd}_${pulse}`);
|
|
855
|
+
// warten bis der Impuls abgefahren ist (+Puffer), Schätzung aktualisiert
|
|
856
|
+
// updatePositionEstimate automatisch über den bestehenden Timer
|
|
857
|
+
await this.delay(pulse + DRIVE_GAP_MS);
|
|
858
|
+
remainingMs -= pulse;
|
|
859
|
+
}
|
|
860
|
+
if (aborted()) return;
|
|
861
|
+
await this.setStateAsync(`${id}.channels.ch${chNum}.setPosition`, { val: target, ack: true });
|
|
862
|
+
this.log.debug(`[${id}] ch${chNum}: Zielposition ${target}% angefahren (Schätzung)`);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ------------------------------------------------------- statische Infos
|
|
866
|
+
|
|
867
|
+
async refreshStaticInfo(id) {
|
|
868
|
+
const dev = this.devices[id];
|
|
869
|
+
try {
|
|
870
|
+
const idData = await this.zrapGet(id, '/zrap/id');
|
|
871
|
+
|
|
872
|
+
// Verifikation: antwortet hier wirklich ein zeptrion-Gerät?
|
|
873
|
+
if (idData.sys !== undefined && String(idData.sys).toUpperCase() !== 'ZEPTRION') {
|
|
874
|
+
this.log.warn(`[${id}] Host ${dev.cfg.host} antwortet, meldet aber sys="${idData.sys}" statt "ZEPTRION" - vermutlich falsche IP oder kein zeptrion-Gerät!`);
|
|
875
|
+
}
|
|
876
|
+
// Plausibilisierung: Kanalzahl aus dem Gerätetyp ableiten (3340-4-x = 4, 3340-2-x = 2)
|
|
877
|
+
const typeStr = String(idData.type ?? '');
|
|
878
|
+
const m = typeStr.match(/^3340-(\d)-/);
|
|
879
|
+
if (m) {
|
|
880
|
+
const hwChannels = parseInt(m[1], 10);
|
|
881
|
+
if (hwChannels !== dev.cfg.channels) {
|
|
882
|
+
this.log.warn(`[${id}] Gerätetyp ${typeStr} hat ${hwChannels} Kanäle, konfiguriert sind ${dev.cfg.channels} - bitte in der Instanz-Konfiguration korrigieren.`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
await this.setStateAsync(`${id}.info.hw`, { val: String(idData.hw ?? ''), ack: true });
|
|
887
|
+
await this.setStateAsync(`${id}.info.sw`, { val: String(idData.sw ?? ''), ack: true });
|
|
888
|
+
await this.setStateAsync(`${id}.info.boot`, { val: String(idData.boot ?? ''), ack: true });
|
|
889
|
+
await this.setStateAsync(`${id}.info.sn`, { val: String(idData.sn ?? ''), ack: true });
|
|
890
|
+
await this.setStateAsync(`${id}.info.sys`, { val: String(idData.sys ?? ''), ack: true });
|
|
891
|
+
await this.setStateAsync(`${id}.info.type`, { val: String(idData.type ?? ''), ack: true });
|
|
892
|
+
await this.setStateAsync(`${id}.info.oen`, { val: String(idData.oen ?? ''), ack: true });
|
|
893
|
+
|
|
894
|
+
const netData = await this.zrapGet(id, '/zrap/net');
|
|
895
|
+
for (const key of ['ssid', 'ip', 'mac', 'mode', 'enc', 'mask', 'gw', 'bssid']) {
|
|
896
|
+
if (netData[key] !== undefined) {
|
|
897
|
+
await this.setStateAsync(`${id}.network.${key}`, { val: String(netData[key]), ack: true });
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const chdesData = await this.zrapGet(id, '/zrap/chdes');
|
|
902
|
+
for (let n = 1; n <= dev.cfg.channels; n++) {
|
|
903
|
+
const chData = chdesData[`ch${n}`];
|
|
904
|
+
if (chData) {
|
|
905
|
+
await this.setStateAsync(`${id}.channels.ch${n}.name`, { val: String(chData.name ?? ''), ack: true });
|
|
906
|
+
await this.setStateAsync(`${id}.channels.ch${n}.group`, { val: String(chData.group ?? ''), ack: true });
|
|
907
|
+
await this.setStateAsync(`${id}.channels.ch${n}.icon`, { val: String(chData.icon ?? ''), ack: true });
|
|
908
|
+
await this.setStateAsync(`${id}.channels.ch${n}.type`, { val: String(chData.type ?? ''), ack: true });
|
|
909
|
+
await this.setStateAsync(`${id}.channels.ch${n}.cat`, { val: String(chData.cat ?? ''), ack: true });
|
|
910
|
+
// im Gerät hinterlegter Kanalname als Objektname übernehmen -
|
|
911
|
+
// macht Objektbaum und VIS-Auswahl deutlich lesbarer
|
|
912
|
+
const chName = String(chData.name ?? '').trim();
|
|
913
|
+
if (chName) {
|
|
914
|
+
await this.extendObjectAsync(`${id}.channels.ch${n}`, {
|
|
915
|
+
common: { name: `Kanal ${n} - ${chName}` }
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
this.markConnected(id, true);
|
|
921
|
+
} catch (err) {
|
|
922
|
+
this.handleDeviceError(id, err, 'refreshStaticInfo');
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Optionale Zusatzservices: Fehler hier gelten NICHT als Verbindungsabbruch
|
|
927
|
+
// (z.B. ältere Firmware ohne diese Services), sondern werden nur protokolliert.
|
|
928
|
+
await this.safeRefresh(id, '/zrap/loc', async (data) => {
|
|
929
|
+
if (data.name !== undefined) {
|
|
930
|
+
await this.setStateAsync(`${id}.location.name`, { val: String(data.name), ack: true });
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
await this.safeRefresh(id, '/zrap/ntp', async (data) => {
|
|
934
|
+
if (data.url !== undefined) await this.setStateAsync(`${id}.ntp.url`, { val: String(data.url), ack: true });
|
|
935
|
+
if (data.per !== undefined) await this.setStateAsync(`${id}.ntp.per`, { val: parseInt(data.per, 10) || 0, ack: true });
|
|
936
|
+
});
|
|
937
|
+
await this.safeRefresh(id, '/zrap/date', async (data) => {
|
|
938
|
+
if (data.rfc1123 !== undefined) await this.setStateAsync(`${id}.date.rfc1123`, { val: String(data.rfc1123), ack: true });
|
|
939
|
+
if (data.tz !== undefined) await this.setStateAsync(`${id}.date.tz`, { val: String(data.tz), ack: true });
|
|
940
|
+
if (data.dst !== undefined) await this.setStateAsync(`${id}.date.dst`, { val: String(data.dst), ack: true });
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async safeRefresh(id, path, apply) {
|
|
945
|
+
try {
|
|
946
|
+
const data = await this.zrapGet(id, path);
|
|
947
|
+
await apply(data);
|
|
948
|
+
} catch (err) {
|
|
949
|
+
this.log.debug(`[${id}] optionaler Service ${path} nicht verfügbar/fehlgeschlagen: ${err.message || err}`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
formatOffset(minutes) {
|
|
954
|
+
const sign = minutes >= 0 ? '+' : '-';
|
|
955
|
+
const abs = Math.abs(minutes);
|
|
956
|
+
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
|
|
957
|
+
const mm = String(abs % 60).padStart(2, '0');
|
|
958
|
+
return `${sign}${hh}${mm}`;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async syncDeviceTime(id) {
|
|
962
|
+
const now = new Date();
|
|
963
|
+
const rfc1123 = now.toUTCString(); // z.B. "Tue, 07 Jul 2026 08:00:00 GMT" - erfüllt "muss GMT sein"
|
|
964
|
+
const tz = this.formatOffset(-now.getTimezoneOffset()); // DST ist in getTimezoneOffset() bereits enthalten
|
|
965
|
+
await this.zrapPost(id, '/zrap/date', { rfc1123, tz, dst: '0000' });
|
|
966
|
+
await this.setStateAsync(`${id}.date.rfc1123`, { val: rfc1123, ack: true });
|
|
967
|
+
await this.setStateAsync(`${id}.date.tz`, { val: tz, ack: true });
|
|
968
|
+
await this.setStateAsync(`${id}.date.dst`, { val: '0000', ack: true });
|
|
969
|
+
this.log.info(`[${id}] Geräte-Zeit synchronisiert: ${rfc1123} (tz=${tz})`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ------------------------------------------------------------- Polling
|
|
973
|
+
|
|
974
|
+
startPolling(id) {
|
|
975
|
+
const dev = this.devices[id];
|
|
976
|
+
const loop = async () => {
|
|
977
|
+
if (!this.devices[id]) return; // Adapter wird beendet / Gerät entfernt
|
|
978
|
+
await this.pollDevice(id);
|
|
979
|
+
if (!this.devices[id]) return;
|
|
980
|
+
const backoff = Math.min(dev.fails, 5) || 1;
|
|
981
|
+
dev.timer = this.setTimeout(loop, dev.cfg.pollInterval * backoff);
|
|
982
|
+
};
|
|
983
|
+
// Startversatz (0..3s zufällig): desynchronisiert die Poll-Zyklen vieler
|
|
984
|
+
// Geräte, damit nicht alle 30s ein Request-Burst durchs Netz geht
|
|
985
|
+
// ("Thundering Herd" bei 20+ Geräten).
|
|
986
|
+
dev.timer = this.setTimeout(loop, Math.floor(Math.random() * 3000));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Schreibt einen aus chscan/chnotify gelesenen Kanalwert in den State.
|
|
991
|
+
* @param {boolean} authoritative true=chnotify (Push, immer aktuell/verbindlich),
|
|
992
|
+
* false=chscan-Resync (Pull, kann bei einem gerade laufenden Bewegungsbefehl
|
|
993
|
+
* kurzzeitig veraltet sein, siehe COMMAND_SETTLE_MS).
|
|
994
|
+
*/
|
|
995
|
+
async applyChannelVal(id, chNum, rawVal, authoritative) {
|
|
996
|
+
const dev = this.devices[id];
|
|
997
|
+
if (!authoritative) {
|
|
998
|
+
const busyUntil = dev.channelBusyUntil[chNum] || 0;
|
|
999
|
+
if (Date.now() < busyUntil) return; // veralteten Resync-Wert verwerfen
|
|
1000
|
+
} else {
|
|
1001
|
+
delete dev.channelBusyUntil[chNum]; // Push bestätigt neuen Zustand -> Sperre aufheben
|
|
1002
|
+
}
|
|
1003
|
+
await this.setStateAsync(`${id}.channels.ch${chNum}.val`, { val: parseInt(rawVal, 10), ack: true });
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async pollDevice(id) {
|
|
1007
|
+
const dev = this.devices[id];
|
|
1008
|
+
dev.pollCount = (dev.pollCount || 0) + 1;
|
|
1009
|
+
try {
|
|
1010
|
+
// Verbindungs-Ökonomie: die Embedded-Webserver der Aktoren verkraften nur
|
|
1011
|
+
// wenige parallele Verbindungen, und chnotify hält bereits dauerhaft eine
|
|
1012
|
+
// offen. Solange der Notify-Kanal gesund läuft (connected + notifyHealthy),
|
|
1013
|
+
// ist chscan redundant und wird nur jeden 5. Poll als Resync ausgeführt.
|
|
1014
|
+
const needChscan = !dev.notifyHealthy || dev.pollCount % 5 === 0;
|
|
1015
|
+
if (needChscan) {
|
|
1016
|
+
const chscan = await this.zrapGet(id, '/zrap/chscan');
|
|
1017
|
+
for (let n = 1; n <= dev.cfg.channels; n++) {
|
|
1018
|
+
const chVal = chscan[`ch${n}`];
|
|
1019
|
+
if (chVal && chVal.val !== undefined) {
|
|
1020
|
+
await this.applyChannelVal(id, n, chVal.val, false);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
const rssi = await this.zrapGet(id, '/zrap/rssi');
|
|
1025
|
+
if (rssi.dbm !== undefined) {
|
|
1026
|
+
await this.setStateAsync(`${id}.info.rssi`, { val: parseInt(rssi.dbm, 10), ack: true });
|
|
1027
|
+
}
|
|
1028
|
+
this.markConnected(id, true);
|
|
1029
|
+
} catch (err) {
|
|
1030
|
+
this.handleDeviceError(id, err, 'pollDevice');
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (dev.cfg.smartfront) {
|
|
1034
|
+
try {
|
|
1035
|
+
const sensor = await this.zapiGet(id, '/zapi/smartfront/sensor');
|
|
1036
|
+
if (sensor) {
|
|
1037
|
+
const temp = this.parseSensorNumber(sensor.temp);
|
|
1038
|
+
const lux = this.parseSensorNumber(sensor.lux);
|
|
1039
|
+
const hum = this.parseSensorNumber(sensor.hum);
|
|
1040
|
+
if (temp !== null) await this.setStateAsync(`${id}.smartfront.temp`, { val: temp, ack: true });
|
|
1041
|
+
if (lux !== null) await this.setStateAsync(`${id}.smartfront.lux`, { val: lux, ack: true });
|
|
1042
|
+
if (hum !== null) await this.setStateAsync(`${id}.smartfront.hum`, { val: hum, ack: true });
|
|
1043
|
+
}
|
|
1044
|
+
const led = await this.zapiGet(id, '/zapi/smartfront/led');
|
|
1045
|
+
if (led !== undefined) {
|
|
1046
|
+
await this.setStateAsync(`${id}.smartfront.ledState`, { val: JSON.stringify(led), ack: true });
|
|
1047
|
+
}
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
this.log.debug(`[${id}] Smartfront (zapi) nicht verfügbar: ${err.message || err}`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// --------------------------------------------------------- Notify-Loop
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* Nutzt zrap/chnotify (Kapitel 3.5 der API-Doku) als Push-ähnlichen Mechanismus:
|
|
1058
|
+
* Der Request blockiert am Gerät, bis sich ein Kanal ändert, spätestens aber
|
|
1059
|
+
* nach 30s (dann leerer/gleicher Response). So kommen Statusänderungen ohne
|
|
1060
|
+
* Warten auf das nächste Poll-Intervall an, und das Race-Condition-Risiko aus
|
|
1061
|
+
* dem Audit (Befund 5) entfällt für den Regelfall, weil chnotify-Daten per
|
|
1062
|
+
* Definition den soeben eingetretenen, verbindlichen Zustand liefern.
|
|
1063
|
+
*/
|
|
1064
|
+
startNotifyLoop(id) {
|
|
1065
|
+
const dev = this.devices[id];
|
|
1066
|
+
dev.notifyActive = true;
|
|
1067
|
+
|
|
1068
|
+
const loop = async () => {
|
|
1069
|
+
if (!this.devices[id] || !this.devices[id].notifyActive) return;
|
|
1070
|
+
let delay = 0;
|
|
1071
|
+
try {
|
|
1072
|
+
const data = await this.zrapGet(id, '/zrap/chnotify', { timeout: NOTIFY_TIMEOUT_MS });
|
|
1073
|
+
for (let n = 1; n <= dev.cfg.channels; n++) {
|
|
1074
|
+
const chVal = data[`ch${n}`];
|
|
1075
|
+
if (chVal && chVal.val !== undefined) {
|
|
1076
|
+
await this.applyChannelVal(id, n, chVal.val, true);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
this.markConnected(id, true);
|
|
1080
|
+
dev.notifyHealthy = true;
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
// Laut Doku antwortet das Gerät IMMER binnen 30s (auch ohne Änderung),
|
|
1083
|
+
// ein Fehler hier ist also immer ein echter Verbindungsproblem-Fall,
|
|
1084
|
+
// keine Sonderbehandlung nötig wie bei einem normalen Request-Timeout.
|
|
1085
|
+
dev.notifyHealthy = false;
|
|
1086
|
+
this.handleDeviceError(id, err, 'chnotify');
|
|
1087
|
+
delay = NOTIFY_ERROR_RETRY_MS;
|
|
1088
|
+
}
|
|
1089
|
+
if (!this.devices[id] || !this.devices[id].notifyActive) return;
|
|
1090
|
+
this.setTimeout(loop, delay);
|
|
1091
|
+
};
|
|
1092
|
+
loop();
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// --------------------------------------------------- Status / Fehler
|
|
1096
|
+
|
|
1097
|
+
markConnected(id, ok) {
|
|
1098
|
+
const dev = this.devices[id];
|
|
1099
|
+
if (!dev) return;
|
|
1100
|
+
const was = dev.connected;
|
|
1101
|
+
dev.connected = ok;
|
|
1102
|
+
if (ok) {
|
|
1103
|
+
dev.fails = 0;
|
|
1104
|
+
this.setStateChangedAsync(`${id}.info.connection`, { val: true, ack: true });
|
|
1105
|
+
this.setStateChangedAsync(`${id}.info.lastError`, { val: '', ack: true });
|
|
1106
|
+
if (!was) this.log.info(`Gerät ${id} (${dev.cfg.host}) ist erreichbar.`);
|
|
1107
|
+
} else {
|
|
1108
|
+
dev.fails++;
|
|
1109
|
+
this.setStateChangedAsync(`${id}.info.connection`, { val: false, ack: true });
|
|
1110
|
+
if (was) this.log.warn(`Gerät ${id} (${dev.cfg.host}) nicht mehr erreichbar.`);
|
|
1111
|
+
}
|
|
1112
|
+
this.updateGlobalConnection();
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
updateGlobalConnection() {
|
|
1116
|
+
const anyConnected = Object.values(this.devices).some(d => d.connected);
|
|
1117
|
+
this.setStateChangedAsync('info.connection', { val: anyConnected, ack: true });
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
handleDeviceError(id, err, context) {
|
|
1121
|
+
let msg = (err && err.message) || String(err);
|
|
1122
|
+
const code = err && err.code;
|
|
1123
|
+
if (code === 'ECONNREFUSED') msg = 'Verbindung verweigert (Gerät aus oder falsche IP?)';
|
|
1124
|
+
else if (code === 'ECONNABORTED') msg = 'Zeitüberschreitung (Gerät nicht erreichbar)';
|
|
1125
|
+
else if (code === 'EHOSTUNREACH') msg = 'Host nicht erreichbar (Netzwerk/Routing prüfen)';
|
|
1126
|
+
else if (code === 'ENOTFOUND') msg = 'Hostname/mDNS-Name nicht auflösbar';
|
|
1127
|
+
else if (code === 'ETIMEDOUT') msg = 'Zeitüberschreitung beim Verbindungsaufbau';
|
|
1128
|
+
this.log.warn(`[${id}] Fehler bei ${context}: ${msg}`);
|
|
1129
|
+
this.setStateAsync(`${id}.info.lastError`, { val: msg, ack: true }).catch(() => {});
|
|
1130
|
+
this.markConnected(id, false);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// --------------------------------------------------------- stateChange
|
|
1134
|
+
|
|
1135
|
+
async onStateChange(idFull, state) {
|
|
1136
|
+
if (!state || state.ack) return;
|
|
1137
|
+
// KOMPLETTER Handler in try/catch: Fehler in einem Event-Handler würden sonst
|
|
1138
|
+
// als Unhandled Promise Rejection den Adapterprozess gefährden (Audit-Befund).
|
|
1139
|
+
try {
|
|
1140
|
+
await this.routeStateChange(idFull, state);
|
|
1141
|
+
} catch (err) {
|
|
1142
|
+
const rel = idFull.substring(this.namespace.length + 1);
|
|
1143
|
+
const devId = rel.split('.')[0];
|
|
1144
|
+
if (this.devices[devId]) {
|
|
1145
|
+
this.handleDeviceError(devId, err, `onStateChange(${rel})`);
|
|
1146
|
+
} else {
|
|
1147
|
+
this.log.warn(`Fehler bei onStateChange(${rel}): ${err.message || err}`);
|
|
1148
|
+
}
|
|
1149
|
+
if (typeof state.val === 'boolean') {
|
|
1150
|
+
await this.setStateAsync(idFull, { val: false, ack: true }).catch(() => {});
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
async routeStateChange(idFull, state) {
|
|
1156
|
+
const rel = idFull.substring(this.namespace.length + 1);
|
|
1157
|
+
|
|
1158
|
+
if (rel === 'control.closeAllShutters' && state.val) {
|
|
1159
|
+
await this.broadcastCommand('close');
|
|
1160
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (rel === 'control.openAllShutters' && state.val) {
|
|
1164
|
+
await this.broadcastCommand('open');
|
|
1165
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (rel === 'control.stopAllShutters' && state.val) {
|
|
1169
|
+
await this.broadcastCommand('stop');
|
|
1170
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const parts = rel.split('.');
|
|
1175
|
+
const id = parts[0];
|
|
1176
|
+
const dev = this.devices[id];
|
|
1177
|
+
if (!dev) return;
|
|
1178
|
+
|
|
1179
|
+
{
|
|
1180
|
+
if (parts[1] === 'info' && parts[2] === 'refresh' && state.val) {
|
|
1181
|
+
await this.refreshStaticInfo(id);
|
|
1182
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (parts[1] === 'system') {
|
|
1187
|
+
if (parts[2] === 'unlock' && state.val) {
|
|
1188
|
+
dev.unlockUntil = Date.now() + 30000;
|
|
1189
|
+
this.log.warn(`[${id}] Werksreset für 30 Sekunden entriegelt.`);
|
|
1190
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
const cmd = SYS_CMDS[parts[2]];
|
|
1194
|
+
if (cmd && state.val) {
|
|
1195
|
+
if (cmd === 'factory-default') {
|
|
1196
|
+
// Sicherheitsverriegelung: Werksreset löscht ALLE Einstellungen
|
|
1197
|
+
// inkl. WLAN-Zugang - das Gerät fällt danach vom Netz und muss
|
|
1198
|
+
// physisch neu eingerichtet werden. Ein einzelner (versehent-
|
|
1199
|
+
// licher) setState aus Script/VIS darf das nicht auslösen können.
|
|
1200
|
+
if (!dev.unlockUntil || Date.now() > dev.unlockUntil) {
|
|
1201
|
+
this.log.error(`[${id}] Werksreset ABGELEHNT: zuerst ${id}.system.unlock setzen (30s-Fenster). Das Gerät würde sonst inkl. WLAN-Konfiguration gelöscht und vom Netz fallen.`);
|
|
1202
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
dev.unlockUntil = 0;
|
|
1206
|
+
this.log.warn(`[${id}] WERKSRESET wird ausgeführt - Gerät verliert alle Einstellungen inkl. WLAN!`);
|
|
1207
|
+
}
|
|
1208
|
+
await this.zrapPost(id, '/zrap/sys', { cmd });
|
|
1209
|
+
this.log.info(`[${id}] Systembefehl gesendet: ${cmd}`);
|
|
1210
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1211
|
+
}
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (parts[1] === 'location' && parts[2] === 'name') {
|
|
1216
|
+
const val = this.validateApiString(state.val, 32, 'location.name');
|
|
1217
|
+
await this.zrapPost(id, '/zrap/loc', { name: val });
|
|
1218
|
+
await this.setStateAsync(idFull, { val, ack: true });
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
if (parts[1] === 'ntp' && ['url', 'per'].includes(parts[2])) {
|
|
1223
|
+
let val = state.val;
|
|
1224
|
+
if (parts[2] === 'url') {
|
|
1225
|
+
val = this.validateApiString(val, 32, 'ntp.url');
|
|
1226
|
+
} else {
|
|
1227
|
+
val = Math.max(0, Math.min(255, parseInt(val, 10) || 0));
|
|
1228
|
+
}
|
|
1229
|
+
await this.zrapPost(id, '/zrap/ntp', { [parts[2]]: val });
|
|
1230
|
+
await this.setStateAsync(idFull, { val, ack: true });
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (parts[1] === 'date') {
|
|
1235
|
+
if (parts[2] === 'syncNow' && state.val) {
|
|
1236
|
+
await this.syncDeviceTime(id);
|
|
1237
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
if (['rfc1123', 'tz', 'dst'].includes(parts[2])) {
|
|
1241
|
+
await this.zrapPost(id, '/zrap/date', { [parts[2]]: state.val });
|
|
1242
|
+
await this.setStateAsync(idFull, { val: state.val, ack: true });
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (parts[1] === 'smartfront' && parts[2] === 'ledSet') {
|
|
1249
|
+
let body;
|
|
1250
|
+
try {
|
|
1251
|
+
body = JSON.parse(String(state.val));
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
throw new Error(`ledSet: kein gültiges JSON ("${err.message}"). Beispiel: [{"id":2,"bg":"#220000"}]`);
|
|
1254
|
+
}
|
|
1255
|
+
await this.zapiPost(id, '/zapi/smartfront/led', body);
|
|
1256
|
+
await this.setStateAsync(idFull, { val: state.val, ack: true });
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (parts[1] === 'channels') {
|
|
1261
|
+
const chMatch = (parts[2] || '').match(/^ch(\d+)$/);
|
|
1262
|
+
if (!chMatch) return;
|
|
1263
|
+
const chNum = parseInt(chMatch[1], 10);
|
|
1264
|
+
const action = parts[3];
|
|
1265
|
+
|
|
1266
|
+
if (['name', 'group', 'icon', 'type', 'cat'].includes(action)) {
|
|
1267
|
+
const limits = { name: 32, group: 32, icon: 24, type: 4, cat: 4 };
|
|
1268
|
+
const val = this.validateApiString(state.val, limits[action], `chdes.${action}`);
|
|
1269
|
+
await this.zrapPost(id, `/zrap/chdes/ch${chNum}`, { [action]: val });
|
|
1270
|
+
await this.setStateAsync(idFull, { val, ack: true });
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (action === 'posEstimate') {
|
|
1275
|
+
// read-only seit 0.5.0 - Hinweis für alte Scripts
|
|
1276
|
+
this.log.warn(`[${id}] posEstimate ist jetzt read-only. Zum Kalibrieren "calibrate", zum Anfahren "setPosition" verwenden.`);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
if (action === 'calibrate') {
|
|
1281
|
+
// Schätzung setzen OHNE Fahrt (z.B. nach manueller Bedienung am Wandtaster)
|
|
1282
|
+
const v = Math.max(0, Math.min(100, Math.round(Number(state.val))));
|
|
1283
|
+
this.cancelDrive(id, chNum);
|
|
1284
|
+
dev.posEstimate[chNum] = v;
|
|
1285
|
+
dev.moveState[chNum] = null;
|
|
1286
|
+
await this.setStateAsync(`${id}.channels.ch${chNum}.posEstimate`, { val: v, ack: true });
|
|
1287
|
+
await this.setStateAsync(idFull, { val: v, ack: true });
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (action === 'setPosition') {
|
|
1292
|
+
// driveToPosition läuft bewusst OHNE await im Hintergrund weiter -
|
|
1293
|
+
// die Sequenz kann bei langen Laufzeiten Minuten dauern und würde
|
|
1294
|
+
// sonst den stateChange-Handler blockieren. Fehler werden intern
|
|
1295
|
+
// über handleDeviceError gemeldet.
|
|
1296
|
+
this.driveToPosition(id, chNum, state.val).catch(err =>
|
|
1297
|
+
this.handleDeviceError(id, err, `setPosition(ch${chNum})`)
|
|
1298
|
+
);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
if (action === 'tiltOpen' || action === 'tiltClose') {
|
|
1303
|
+
if (state.val !== true) return;
|
|
1304
|
+
if (!dev.cfg.tiltTimeMs) {
|
|
1305
|
+
this.log.warn(`[${id}] Kipp-Impuls nicht konfiguriert ("Kipp-Impuls (ms)" in der Geräte-Tabelle setzen, typisch 300-800ms für Rafflamellen).`);
|
|
1306
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
const pulse = Math.max(MIN_TIMED_MS, Math.min(dev.cfg.tiltTimeMs, MAX_TIMED_MS));
|
|
1310
|
+
this.cancelDrive(id, chNum);
|
|
1311
|
+
await this.sendChannelCommand(id, chNum, `${action === 'tiltOpen' ? 'move_open' : 'move_close'}_${pulse}`);
|
|
1312
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
if (action === 'command') {
|
|
1317
|
+
this.cancelDrive(id, chNum);
|
|
1318
|
+
await this.sendChannelCommand(id, chNum, String(state.val));
|
|
1319
|
+
await this.setStateAsync(idFull, { val: state.val, ack: true });
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (state.val === true) {
|
|
1324
|
+
// manueller Button unterbricht eine laufende setPosition-Sequenz
|
|
1325
|
+
this.cancelDrive(id, chNum);
|
|
1326
|
+
await this.sendChannelCommand(id, chNum, action);
|
|
1327
|
+
await this.setStateAsync(idFull, { val: false, ack: true });
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
async broadcastCommand(cmd) {
|
|
1334
|
+
// WICHTIG: alle Kanalbefehle werden ohne Zwischen-await gestartet, damit sie
|
|
1335
|
+
// innerhalb desselben COMMAND_BATCH_MS-Fensters landen und pro Gerät zu einem
|
|
1336
|
+
// einzigen Multicast-POST gebündelt werden (siehe sendChannelCommand/
|
|
1337
|
+
// flushPendingCmds). Sequentielles awaiten würde das Bündeln verhindern, da
|
|
1338
|
+
// jeder Aufruf erst nach Abschluss des Debounce-Timers des vorigen auflöst.
|
|
1339
|
+
const promises = [];
|
|
1340
|
+
for (const id of Object.keys(this.devices)) {
|
|
1341
|
+
const dev = this.devices[id];
|
|
1342
|
+
for (let n = 1; n <= dev.cfg.channels; n++) {
|
|
1343
|
+
this.cancelDrive(id, n); // Sammelbefehl (z.B. Hagelalarm) hat Vorrang vor laufenden setPosition-Sequenzen
|
|
1344
|
+
promises.push(
|
|
1345
|
+
this.sendChannelCommand(id, n, cmd).catch(err => {
|
|
1346
|
+
this.handleDeviceError(id, err, `broadcastCommand(${cmd})`);
|
|
1347
|
+
})
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
await Promise.allSettled(promises);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ------------------------------------------------------------ Discovery
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* mDNS-Discovery gemäss Kapitel 4 der API-Doku.
|
|
1358
|
+
* Aktuelle Firmware (>= 01.08.xx) meldet sich als _zapp._tcp,
|
|
1359
|
+
* ältere Firmware nur als _http._tcp (dort per Hostname-Muster zapp-YYWWNNNN gefiltert).
|
|
1360
|
+
*/
|
|
1361
|
+
discoverDevices(timeoutMs = 4000) {
|
|
1362
|
+
return new Promise((resolve, reject) => {
|
|
1363
|
+
if (!Bonjour) {
|
|
1364
|
+
reject(new Error('Modul "bonjour-service" ist nicht installiert. "npm install bonjour-service" im Adapterverzeichnis ausführen.'));
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const bonjour = new Bonjour();
|
|
1368
|
+
const found = new Map();
|
|
1369
|
+
|
|
1370
|
+
// WICHTIG: dieser Callback wird asynchron aus dem EventEmitter von
|
|
1371
|
+
// bonjour-service heraus aufgerufen, für JEDES gesehene mDNS-Gerät im
|
|
1372
|
+
// Netz (auch Sonos/Chromecast/Drucker etc. bei "type: 'http'"). Ein
|
|
1373
|
+
// hier ungefangener Fehler (z.B. durch unerwartete/fehlende Felder in
|
|
1374
|
+
// einem fremden TXT-Record) würde NICHT vom äusseren try/catch dieser
|
|
1375
|
+
// Funktion abgedeckt, sondern könnte als unhandled exception den ganzen
|
|
1376
|
+
// Adapterprozess crashen. Daher hart mit try/catch abgesichert.
|
|
1377
|
+
const handle = (service) => {
|
|
1378
|
+
try {
|
|
1379
|
+
const name = (service && (service.name || service.host)) || '';
|
|
1380
|
+
const addresses = Array.isArray(service && service.addresses) ? service.addresses : [];
|
|
1381
|
+
const addr = addresses.find(a => typeof a === 'string' && /^\d+\.\d+\.\d+\.\d+$/.test(a));
|
|
1382
|
+
const host = addr || (service && service.host);
|
|
1383
|
+
if (!host) return;
|
|
1384
|
+
const txt = (service && service.txt) || {};
|
|
1385
|
+
const type = (txt && txt.type) || '';
|
|
1386
|
+
let channels = 1;
|
|
1387
|
+
if (/^3340-4-/.test(type)) channels = 4;
|
|
1388
|
+
else if (/^3340-2-/.test(type)) channels = 2;
|
|
1389
|
+
found.set(host, {
|
|
1390
|
+
name: String(name).replace(/\.local\.?$/i, ''),
|
|
1391
|
+
host,
|
|
1392
|
+
type,
|
|
1393
|
+
sw: txt.sw || '',
|
|
1394
|
+
channels
|
|
1395
|
+
});
|
|
1396
|
+
} catch (err) {
|
|
1397
|
+
this.log.debug(`Discovery: unerwartetes/fremdes mDNS-Paket ignoriert (${err.message || err})`);
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
let browserNew;
|
|
1402
|
+
let browserOld;
|
|
1403
|
+
try {
|
|
1404
|
+
browserNew = bonjour.find({ type: 'zapp' }, handle);
|
|
1405
|
+
browserOld = bonjour.find({ type: 'http' }, (service) => {
|
|
1406
|
+
try {
|
|
1407
|
+
if (service && service.name && /^zapp-\d{8}$/i.test(service.name)) handle(service);
|
|
1408
|
+
} catch (err) {
|
|
1409
|
+
this.log.debug(`Discovery: Fallback-Filter (_http._tcp) Fehler ignoriert (${err.message || err})`);
|
|
1410
|
+
}
|
|
1411
|
+
});
|
|
1412
|
+
// Auch auf explizite Fehler-Events der Browser reagieren, statt sie
|
|
1413
|
+
// als unhandled 'error' durchfallen zu lassen.
|
|
1414
|
+
if (browserNew && typeof browserNew.on === 'function') {
|
|
1415
|
+
browserNew.on('error', err => this.log.debug(`Discovery (_zapp._tcp) Fehler: ${err.message || err}`));
|
|
1416
|
+
}
|
|
1417
|
+
if (browserOld && typeof browserOld.on === 'function') {
|
|
1418
|
+
browserOld.on('error', err => this.log.debug(`Discovery (_http._tcp) Fehler: ${err.message || err}`));
|
|
1419
|
+
}
|
|
1420
|
+
} catch (err) {
|
|
1421
|
+
try { bonjour.destroy(); } catch (e) { /* ignore */ }
|
|
1422
|
+
reject(err);
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
this.setTimeout(() => {
|
|
1427
|
+
try { browserNew && browserNew.stop(); } catch (e) { /* ignore */ }
|
|
1428
|
+
try { browserOld && browserOld.stop(); } catch (e) { /* ignore */ }
|
|
1429
|
+
try { bonjour.destroy(); } catch (e) { /* ignore */ }
|
|
1430
|
+
resolve(Array.from(found.values()));
|
|
1431
|
+
}, timeoutMs);
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/** Übernimmt neu gefundene Geräte deaktiviert in die Instanz-Konfiguration (native.devices). */
|
|
1436
|
+
async mergeDiscoveredDevices(results) {
|
|
1437
|
+
const instObj = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`);
|
|
1438
|
+
if (!instObj) return 0;
|
|
1439
|
+
const devices = Array.isArray(instObj.native.devices) ? instObj.native.devices : [];
|
|
1440
|
+
const existingHosts = new Set(devices.map(d => String(d.host || '').toLowerCase()));
|
|
1441
|
+
let added = 0;
|
|
1442
|
+
|
|
1443
|
+
for (const r of results) {
|
|
1444
|
+
if (existingHosts.has(String(r.host).toLowerCase())) continue;
|
|
1445
|
+
devices.push({
|
|
1446
|
+
enabled: false,
|
|
1447
|
+
id: this.sanitize(r.name || r.host),
|
|
1448
|
+
name: r.name || r.host,
|
|
1449
|
+
host: r.host,
|
|
1450
|
+
channels: r.channels || 1,
|
|
1451
|
+
kind: 'unknown',
|
|
1452
|
+
pollInterval: 30
|
|
1453
|
+
});
|
|
1454
|
+
existingHosts.add(String(r.host).toLowerCase());
|
|
1455
|
+
added++;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
if (added > 0) {
|
|
1459
|
+
instObj.native.devices = devices;
|
|
1460
|
+
await this.setForeignObjectAsync(`system.adapter.${this.namespace}`, instObj);
|
|
1461
|
+
}
|
|
1462
|
+
return added;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
async onMessage(obj) {
|
|
1466
|
+
if (!obj || !obj.command) return;
|
|
1467
|
+
|
|
1468
|
+
if (obj.command === 'importCsv') {
|
|
1469
|
+
try {
|
|
1470
|
+
const csv = String((obj.message && obj.message.csv) || '').trim();
|
|
1471
|
+
if (!csv) {
|
|
1472
|
+
if (obj.callback) this.sendTo(obj.from, obj.command, { result: 'CSV-Feld ist leer. Format: host;name;kanäle;art;laufzeit_s;kipp_ms;smartfront;poll_s;laufzeit_kanal_s (nur host ist Pflicht).' }, obj.callback);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
const delim = csv.includes(';') ? ';' : ',';
|
|
1476
|
+
const lines = csv.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#'));
|
|
1477
|
+
// optionale Kopfzeile erkennen und überspringen
|
|
1478
|
+
if (lines.length && /^host\b/i.test(lines[0])) lines.shift();
|
|
1479
|
+
|
|
1480
|
+
const instObj = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`);
|
|
1481
|
+
const devices = Array.isArray(instObj?.native?.devices) ? instObj.native.devices : [];
|
|
1482
|
+
const existingHosts = new Set(devices.map(d => String(d.host || '').trim().toLowerCase()));
|
|
1483
|
+
const existingIds = new Set(devices.map(d => this.sanitize(d.id || '')));
|
|
1484
|
+
|
|
1485
|
+
const report = [];
|
|
1486
|
+
let added = 0;
|
|
1487
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1488
|
+
const c = lines[i].split(delim).map(x => x.trim());
|
|
1489
|
+
const row = {
|
|
1490
|
+
host: c[0] || '',
|
|
1491
|
+
name: c[1] || '',
|
|
1492
|
+
channels: c[2] || 1,
|
|
1493
|
+
kind: (c[3] || 'unknown').toLowerCase(),
|
|
1494
|
+
travelTimeSec: c[4] || 0,
|
|
1495
|
+
tiltTimeMs: c[5] || 0,
|
|
1496
|
+
smartfront: /^(1|true|ja|yes|x)$/i.test(c[6] || ''),
|
|
1497
|
+
pollInterval: c[7] || 30,
|
|
1498
|
+
travelTimeSecCh: c[8] || ''
|
|
1499
|
+
};
|
|
1500
|
+
// Kurzformen für "Art" erlauben
|
|
1501
|
+
if (['storen', 'rolladen', 'shutter', 'blinds'].includes(row.kind)) row.kind = 'blind';
|
|
1502
|
+
if (['licht', 'lampe'].includes(row.kind)) row.kind = 'light';
|
|
1503
|
+
|
|
1504
|
+
const errs = this.validateDeviceRow(row);
|
|
1505
|
+
if (existingHosts.has(row.host.toLowerCase())) errs.push('Host bereits konfiguriert');
|
|
1506
|
+
if (errs.length) {
|
|
1507
|
+
report.push(`❌ Zeile ${i + 1} (${row.host || '?'}): ${errs.join('; ')}`);
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
// ID aus Host ableiten, Kollisionen auflösen
|
|
1511
|
+
let base = row.host.replace(/\.local\.?$/i, '').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
1512
|
+
if (/^\d/.test(base)) base = 'zapp_' + base;
|
|
1513
|
+
let candidate = base || 'device';
|
|
1514
|
+
let n = 2;
|
|
1515
|
+
while (existingIds.has(candidate)) candidate = `${base}_${n++}`;
|
|
1516
|
+
|
|
1517
|
+
devices.push({
|
|
1518
|
+
enabled: true,
|
|
1519
|
+
id: candidate,
|
|
1520
|
+
name: row.name || row.host,
|
|
1521
|
+
host: row.host,
|
|
1522
|
+
channels: parseInt(row.channels, 10) || 1,
|
|
1523
|
+
kind: row.kind,
|
|
1524
|
+
travelTimeSec: parseInt(row.travelTimeSec, 10) || 0,
|
|
1525
|
+
travelTimeSecCh: String(row.travelTimeSecCh || '').trim(),
|
|
1526
|
+
tiltTimeMs: parseInt(row.tiltTimeMs, 10) || 0,
|
|
1527
|
+
smartfront: row.smartfront,
|
|
1528
|
+
pollInterval: parseInt(row.pollInterval, 10) || 30
|
|
1529
|
+
});
|
|
1530
|
+
existingHosts.add(row.host.toLowerCase());
|
|
1531
|
+
existingIds.add(candidate);
|
|
1532
|
+
report.push(`✅ Zeile ${i + 1}: ${row.name || row.host} (${row.host}) als "${candidate}" übernommen`);
|
|
1533
|
+
added++;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (added > 0 && instObj) {
|
|
1537
|
+
instObj.native.devices = devices;
|
|
1538
|
+
await this.setForeignObjectAsync(`system.adapter.${this.namespace}`, instObj);
|
|
1539
|
+
}
|
|
1540
|
+
const result = `${added} von ${lines.length} Zeile(n) importiert.${added ? ' Adapter startet neu; Dialog schliessen und neu öffnen.' : ''}\n\n${report.join('\n')}`;
|
|
1541
|
+
this.log.info(`CSV-Import: ${added}/${lines.length} übernommen`);
|
|
1542
|
+
if (obj.callback) this.sendTo(obj.from, obj.command, { result }, obj.callback);
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
const msg = `CSV-Import fehlgeschlagen: ${err.message || err}`;
|
|
1545
|
+
this.log.warn(msg);
|
|
1546
|
+
if (obj.callback) this.sendTo(obj.from, obj.command, { error: msg }, obj.callback);
|
|
1547
|
+
}
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
if (obj.command === 'testDevices') {
|
|
1552
|
+
const devicesCfg = Array.isArray(this.config.devices) ? this.config.devices : [];
|
|
1553
|
+
const rows = devicesCfg.filter(d => d && d.host);
|
|
1554
|
+
if (!rows.length) {
|
|
1555
|
+
if (obj.callback) this.sendTo(obj.from, obj.command, { result: 'Keine Geräte mit Host in der Tabelle.' }, obj.callback);
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
const lines = [];
|
|
1559
|
+
for (const d of rows) {
|
|
1560
|
+
const host = String(d.host).trim();
|
|
1561
|
+
const label = d.name || d.id || host;
|
|
1562
|
+
// IP-Format grob prüfen (Hostnamen sind auch erlaubt)
|
|
1563
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
1564
|
+
const octets = host.split('.').map(Number);
|
|
1565
|
+
if (octets.some(o => o > 255)) {
|
|
1566
|
+
lines.push(`❌ ${label} (${host}): ungültige IP-Adresse`);
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
try {
|
|
1571
|
+
const res = await axios.get(`http://${host}/zrap/id`, {
|
|
1572
|
+
timeout: 3000, responseType: 'text', transformResponse: [x => x]
|
|
1573
|
+
});
|
|
1574
|
+
const parsed = xmlParser.parse(res.data || '');
|
|
1575
|
+
const rootKey = Object.keys(parsed).find(k => !k.startsWith('?'));
|
|
1576
|
+
const idData = (rootKey && parsed[rootKey]) || {};
|
|
1577
|
+
if (String(idData.sys ?? '').toUpperCase() === 'ZEPTRION') {
|
|
1578
|
+
const m = String(idData.type ?? '').match(/^3340-(\d)-/);
|
|
1579
|
+
const ch = m ? `, ${m[1]} Kanäle` : '';
|
|
1580
|
+
lines.push(`✅ ${label} (${host}): zeptrion ${idData.type ?? '?'}${ch}, SW ${idData.sw ?? '?'}, SN ${idData.sn ?? '?'}`);
|
|
1581
|
+
} else {
|
|
1582
|
+
lines.push(`⚠️ ${label} (${host}): antwortet, aber KEIN zeptrion-Gerät (sys="${idData.sys ?? 'unbekannt'}")`);
|
|
1583
|
+
}
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
const code = err.code || (err.message || '').substring(0, 40);
|
|
1586
|
+
lines.push(`❌ ${label} (${host}): nicht erreichbar (${code})`);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const result = lines.join('\n');
|
|
1590
|
+
this.log.info(`Gerätetest:\n${result}`);
|
|
1591
|
+
if (obj.callback) this.sendTo(obj.from, obj.command, { result }, obj.callback);
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (obj.command === 'discover') {
|
|
1596
|
+
try {
|
|
1597
|
+
this.log.info('Starte mDNS-Discovery nach zeptrion-Geräten …');
|
|
1598
|
+
const results = await this.discoverDevices(4000);
|
|
1599
|
+
const added = await this.mergeDiscoveredDevices(results);
|
|
1600
|
+
const msg = `Suche abgeschlossen: ${results.length} Gerät(e) im Netz gefunden, ${added} neu (deaktiviert) übernommen. ` +
|
|
1601
|
+
`Instanz-Konfiguration schliessen und neu öffnen, um sie in der Tabelle zu sehen und zu aktivieren.`;
|
|
1602
|
+
this.log.info(msg);
|
|
1603
|
+
if (obj.callback) {
|
|
1604
|
+
this.sendTo(obj.from, obj.command, { result: msg, devices: results }, obj.callback);
|
|
1605
|
+
}
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
const msg = err.message || String(err);
|
|
1608
|
+
this.log.warn(`Discovery fehlgeschlagen: ${msg}`);
|
|
1609
|
+
if (obj.callback) {
|
|
1610
|
+
this.sendTo(obj.from, obj.command, { error: msg }, obj.callback);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// -------------------------------------------------------------- unload
|
|
1617
|
+
|
|
1618
|
+
onUnload(callback) {
|
|
1619
|
+
try {
|
|
1620
|
+
for (const id of Object.keys(this.devices)) {
|
|
1621
|
+
const dev = this.devices[id];
|
|
1622
|
+
if (dev.timer) this.clearTimeout(dev.timer);
|
|
1623
|
+
if (dev.pendingTimer) this.clearTimeout(dev.pendingTimer);
|
|
1624
|
+
dev.notifyActive = false;
|
|
1625
|
+
}
|
|
1626
|
+
this.devices = {};
|
|
1627
|
+
callback();
|
|
1628
|
+
} catch (e) {
|
|
1629
|
+
callback();
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
if (require.main !== module) {
|
|
1635
|
+
module.exports = (options) => new Zeptrion(options);
|
|
1636
|
+
} else {
|
|
1637
|
+
new Zeptrion();
|
|
1638
|
+
}
|