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/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
+ }