iobroker.mqtt-plus 1.6.1

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/build/main.js ADDED
@@ -0,0 +1,1693 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ /*
40
+ * MQTT-Bridge-Manager
41
+ * Feature: Force-Sync Interval (5 min) bypassing cache to heal split-brain/async states
42
+ */
43
+ const utils = __importStar(require("@iobroker/adapter-core"));
44
+ const axios_1 = __importDefault(require("axios"));
45
+ const http = __importStar(require("http"));
46
+ const https = __importStar(require("https"));
47
+ const crypto = __importStar(require("crypto"));
48
+ const os = __importStar(require("os"));
49
+ // Einmalig aus package.json gelesen, statt an mehreren Stellen (User-Agent, info.version)
50
+ // manuell zu pflegen und bei jedem Versionssprung zu vergessen.
51
+ const ADAPTER_VERSION = require("../package.json").version;
52
+ class MqttPlus extends utils.Adapter {
53
+ constructor(options = {}) {
54
+ super({
55
+ ...options,
56
+ name: "mqtt-plus",
57
+ });
58
+ // Map statt Plain Object: verhindert Prototype Pollution über Datenpunkt-/Topic-Namen wie "__proto__"
59
+ this.lastSyncValues = new Map();
60
+ // Quell-ts des zuletzt geschriebenen Werts je Verbindung - Basis für syncMode "refresh".
61
+ this.lastSyncTs = new Map();
62
+ this.updateInterval = undefined;
63
+ this.forceSyncInterval = undefined;
64
+ this.syncInterval = undefined;
65
+ this.httpServer = undefined;
66
+ this.currentWatchdogStatus = "Init";
67
+ this.unloaded = false;
68
+ this.syncRunning = false;
69
+ this.activeSockets = new Set();
70
+ // Performance Lookup Maps
71
+ this.sourceToMappings = new Map();
72
+ this.targetToMappings = new Map();
73
+ // common.type des jeweiligen Zielobjekts (aus ensureAdapterObject) - Basis für Auto-Konvertierung
74
+ this.targetTypeCache = new Map();
75
+ // common.type des jeweiligen Quellobjekts, einmalig beim Setup ermittelt (für /api/json)
76
+ this.sourceTypeCache = new Map();
77
+ // Konsumierbarer Echo-Schutz: merkt sich pro Ziel-ID den zuletzt selbst geschriebenen Wert,
78
+ // bis entweder das passende Echo eintrifft (verbraucht) oder ein abweichender Wert eintrifft
79
+ // (verworfen - echte Änderung). Nur wirksam, wenn syncValue() mit useEchoGuard=true läuft -
80
+ // sonst hätte die Ack-Prüfung in onStateChange ein echtes eigenes Echo bereits ausgeschlossen,
81
+ // und der Schutz würde nur eine spätere, echte Bestätigung fälschlich verschlucken.
82
+ this.pendingWrites = new Map();
83
+ // Quellen, die aktuell als inaktiv (veraltet/schlechte Qualität) gelten - nur für das
84
+ // Logging beim Zustandswechsel, damit nicht jeder Cycle dieselbe Meldung wiederholt.
85
+ this.staleSources = new Set();
86
+ // Brute-Force-Schutz für das Dashboard-Login, pro Client-IP
87
+ this.failedAuthAttempts = new Map();
88
+ this.lastWatchdogStatus = "";
89
+ this.lastWatchdogWriteTs = 0;
90
+ this.on("ready", this.onReady.bind(this));
91
+ this.on("stateChange", this.onStateChange.bind(this));
92
+ this.on("unload", this.onUnload.bind(this));
93
+ this.on("message", this.onMessage.bind(this));
94
+ }
95
+ sleep(ms) {
96
+ return new Promise(resolve => setTimeout(resolve, ms));
97
+ }
98
+ convertMqttPathToIobrokerId(mqttPath) {
99
+ if (!mqttPath)
100
+ return "unknown";
101
+ let cleaned = mqttPath.replace(/^[\/\.]+|[\/\.]+$/g, "");
102
+ cleaned = cleaned.replace(/\//g, ".");
103
+ cleaned = cleaned.replace(/\s+/g, "_");
104
+ // Zusätzlich zu / und . verbietet ioBroker weitere Zeichen in IDs (*?,;'"`<>[])
105
+ cleaned = cleaned.replace(this.FORBIDDEN_CHARS, "_");
106
+ return cleaned || "unknown";
107
+ }
108
+ // Ermittelt das Befehls-Topic eines Mappings.
109
+ // "single" (Standard): Befehl und Status teilen sich ein Topic - wie bisher.
110
+ // "dual": Befehle laufen über <topic>/set, der Status bleibt auf <topic> - dadurch sind
111
+ // Schreib- und Leserichtung physisch getrennt und können sich nicht gegenseitig auslösen.
112
+ resolveCommandPath(entry, statePath) {
113
+ if (entry.topicMode !== "dual")
114
+ return statePath;
115
+ const raw = (entry.commandSuffix || "").trim() || "/set";
116
+ const suffix = this.convertMqttPathToIobrokerId(raw);
117
+ if (!suffix || suffix === "unknown") {
118
+ this.log.warn(`[Setup] Ungültiger Befehls-Suffix "${raw}" für "${entry.id}" - verwende "/set".`);
119
+ return `${statePath}.set`;
120
+ }
121
+ return `${statePath}.${suffix}`;
122
+ }
123
+ // Liest den konfigurierten Ziel-Präfix mit Absicherung gegen leere/fehlerhafte Config.
124
+ getValidatedBasePath() {
125
+ let base = (this.config.targetBasePath || "").trim();
126
+ if (!base) {
127
+ this.log.warn("[Config] Kein MQTT Ziel-Pfad konfiguriert - verwende Standard 'mqtt.0.'");
128
+ base = "mqtt.0.";
129
+ }
130
+ return base.endsWith(".") ? base : base + ".";
131
+ }
132
+ async onReady() {
133
+ var _a, _b;
134
+ await this.initObjects();
135
+ await this.loadAuthLockouts();
136
+ await this.setStateAsync("info.version", ADAPTER_VERSION, true);
137
+ this.log.info(`[Setup] MQTT-Bridge-Manager v${ADAPTER_VERSION} startet...`);
138
+ const staleMin = (_a = this.parseMinutes(this.config.staleAfterMin)) !== null && _a !== void 0 ? _a : MqttPlus.DEFAULT_STALE_AFTER_MIN;
139
+ this.log.info(staleMin > 0
140
+ ? `[Setup] Aktualitätsprüfung: Quellen ohne Update seit ${staleMin} Min werden bei Start/Cycle/Force-Sync nicht gespiegelt.`
141
+ : "[Setup] Aktualitätsprüfung (Alter) global deaktiviert - nur Qualität (q) wird geprüft.");
142
+ await this.setupBridge();
143
+ const port = this.config.serverPort || 8095;
144
+ const hasTls = !!(this.config.dashboardTlsCert && this.config.dashboardTlsKey);
145
+ if (!this.config.dashboardPassword) {
146
+ this.log.warn("[Dashboard] Kein Passwort gesetzt - der Webserver ist ohne Zugangsschutz erreichbar! Bitte in den Adapter-Einstellungen (Tab 'Web Dashboard') ein Passwort vergeben.");
147
+ }
148
+ else if (!hasTls) {
149
+ this.log.warn("[Dashboard] Passwort ist gesetzt, aber kein TLS-Zertifikat konfiguriert - die Zugangsdaten werden unverschlüsselt (HTTP) übertragen. Für Verschlüsselung im Tab 'Web Dashboard' Zertifikat/Key hinterlegen.");
150
+ }
151
+ this.startWebServer(port);
152
+ await this.updateDashboardUrlState(port);
153
+ // 1. Normales Fallback-Intervall (Mit Cache), mindestens 5s gegen Busy-Loop bei Fehlkonfiguration
154
+ const updateIntervalSec = Math.max(5, this.config.updateIntervalSec || 60);
155
+ this.log.info(`Starte Update-Intervall (Fallback): ${updateIntervalSec} Sekunden`);
156
+ this.updateInterval = this.setInterval(() => {
157
+ this.runCycleSync().catch(e => this.log.error(`Cycle-Sync Fehler: ${e.message}`));
158
+ }, updateIntervalSec * 1000);
159
+ // 2. Force-Sync Intervall (ohne Cache zur System-Heilung), konfigurierbar/abschaltbar
160
+ const forceSyncMin = (_b = this.config.forceSyncIntervalMin) !== null && _b !== void 0 ? _b : 5;
161
+ if (forceSyncMin > 0) {
162
+ this.log.info(`Starte Force-Sync-Intervall: Alle ${forceSyncMin} Minuten (Heilung von Asynchronität)`);
163
+ this.forceSyncInterval = this.setInterval(() => {
164
+ this.runForceSync().catch(e => this.log.error(`Force-Sync Fehler: ${e.message}`));
165
+ }, forceSyncMin * 60 * 1000);
166
+ }
167
+ else {
168
+ this.log.info("Force-Sync-Intervall deaktiviert (Konfiguration).");
169
+ }
170
+ // 3. Remote Sync
171
+ if (this.config.syncUrl) {
172
+ const syncIntervalMin = this.config.syncIntervalMin || 60;
173
+ this.log.info(`Starte Remote-Sync: alle ${syncIntervalMin} Minuten`);
174
+ this.runRemoteSync(false).catch(e => this.log.error(`Remote-Sync Fehler: ${e.message}`));
175
+ this.syncInterval = this.setInterval(() => {
176
+ this.runRemoteSync(false).catch(e => this.log.error(`Remote-Sync Fehler: ${e.message}`));
177
+ }, syncIntervalMin * 60 * 1000);
178
+ }
179
+ this.updateWatchdog("Running", true);
180
+ }
181
+ async updateDashboardUrlState(port) {
182
+ let ip = "127.0.0.1";
183
+ try {
184
+ const ifaces = os.networkInterfaces();
185
+ let found = false;
186
+ for (const name in ifaces) {
187
+ const iface = ifaces[name];
188
+ if (!iface)
189
+ continue;
190
+ for (const alias of iface) {
191
+ if (alias.family === "IPv4" && !alias.internal) {
192
+ ip = alias.address;
193
+ found = true;
194
+ break;
195
+ }
196
+ }
197
+ if (found)
198
+ break;
199
+ }
200
+ }
201
+ catch (e) {
202
+ this.log.debug(`[Setup] Netzwerk-Interfaces konnten nicht ermittelt werden: ${e.message}`);
203
+ }
204
+ const url = `http://${ip}:${port}`;
205
+ await this.setStateAsync("info.dashboardUrl", url, true);
206
+ this.log.info(`Dashboard erreichbar unter: ${url}`);
207
+ }
208
+ async onMessage(obj) {
209
+ if (!obj || typeof obj !== "object")
210
+ return;
211
+ if (obj.command === "generateJson") {
212
+ try {
213
+ const tree = await this.generateJsonTree();
214
+ if (obj.callback)
215
+ this.sendTo(obj.from, obj.command, tree, obj.callback);
216
+ }
217
+ catch (e) {
218
+ if (obj.callback)
219
+ this.sendTo(obj.from, obj.command, { error: e.message }, obj.callback);
220
+ }
221
+ }
222
+ else if (obj.command === "checkSync") {
223
+ this.log.info("Manueller Sync-Test angefordert...");
224
+ try {
225
+ const result = await this.runRemoteSync(true);
226
+ if (obj.callback) {
227
+ this.sendTo(obj.from, obj.command, {
228
+ success: result.success,
229
+ result: result.message
230
+ }, obj.callback);
231
+ }
232
+ }
233
+ catch (e) {
234
+ if (obj.callback)
235
+ this.sendTo(obj.from, obj.command, { error: e.message }, obj.callback);
236
+ }
237
+ }
238
+ else if (obj.callback) {
239
+ this.sendTo(obj.from, obj.command, { error: "unknown command" }, obj.callback);
240
+ }
241
+ }
242
+ async initObjects() {
243
+ await this.setObjectNotExistsAsync("watchdog", {
244
+ type: "state",
245
+ common: { name: "MQTT Bridge Watchdog", type: "string", role: "text", read: true, write: false },
246
+ native: {},
247
+ });
248
+ await this.setObjectNotExistsAsync("config.syncTemplate", {
249
+ type: "state",
250
+ common: { name: "Remote Sync JSON Template", type: "string", role: "json", read: true, write: true, def: this.getDefaultSyncTemplate() },
251
+ native: {},
252
+ });
253
+ await this.setObjectNotExistsAsync("info.dashboardUrl", {
254
+ type: "state",
255
+ common: { name: "Dashboard URL", type: "string", role: "url", read: true, write: false },
256
+ native: {},
257
+ });
258
+ await this.setObjectNotExistsAsync("info.lastSyncStatus", {
259
+ type: "state",
260
+ common: { name: "Letzter Sync Status", type: "string", role: "text", read: true, write: false },
261
+ native: {},
262
+ });
263
+ await this.setObjectNotExistsAsync("info.status", {
264
+ type: "state",
265
+ common: { name: "Status", type: "string", role: "text", read: true, write: false },
266
+ native: {},
267
+ });
268
+ await this.setObjectNotExistsAsync("info.lastCycle", {
269
+ type: "state",
270
+ common: { name: "Letzter Sync-Zyklus", type: "number", role: "value.time", read: true, write: false },
271
+ native: {},
272
+ });
273
+ await this.setObjectNotExistsAsync("info.connection", {
274
+ type: "state",
275
+ common: { name: "Connected", type: "boolean", role: "indicator.connected", read: true, write: false, def: false },
276
+ native: {},
277
+ });
278
+ await this.setObjectNotExistsAsync("info.version", {
279
+ type: "state",
280
+ common: { name: "Adapter-Version", type: "string", role: "info.version", read: true, write: false },
281
+ native: {},
282
+ });
283
+ await this.setObjectNotExistsAsync("info.authLockouts", {
284
+ type: "state",
285
+ common: { name: "Login-Sperrliste (intern)", type: "string", role: "json", read: true, write: false, def: "{}" },
286
+ native: {},
287
+ });
288
+ }
289
+ // Lädt aktive Login-Sperren aus der Persistenz, damit ein Adapter-Neustart eine laufende
290
+ // Brute-Force-Sperre nicht zurücksetzt. Bereits abgelaufene Einträge werden verworfen.
291
+ async loadAuthLockouts() {
292
+ try {
293
+ const state = await this.getStateAsync("info.authLockouts");
294
+ if (!state || !state.val)
295
+ return;
296
+ const stored = JSON.parse(state.val);
297
+ const now = Date.now();
298
+ for (const [ip, entry] of Object.entries(stored)) {
299
+ if (entry.lockedUntil > now)
300
+ this.failedAuthAttempts.set(ip, entry);
301
+ }
302
+ }
303
+ catch (e) {
304
+ this.log.debug(`[Dashboard] Sperrliste konnte nicht geladen werden: ${e.message}`);
305
+ }
306
+ }
307
+ // Persistiert nur bei tatsächlicher Sperrung (nicht bei jedem Fehlversuch), um die
308
+ // States-DB nicht unnötig zu belasten - und räumt dabei abgelaufene Einträge auf.
309
+ persistAuthLockouts() {
310
+ const now = Date.now();
311
+ const active = {};
312
+ for (const [ip, entry] of this.failedAuthAttempts) {
313
+ if (entry.lockedUntil > now)
314
+ active[ip] = entry;
315
+ }
316
+ this.setState("info.authLockouts", JSON.stringify(active), true);
317
+ }
318
+ async setupBridge() {
319
+ this.log.info("[MQTT-Bridge] Starte Setup & Indexierung...");
320
+ this.sourceToMappings.clear();
321
+ this.targetToMappings.clear();
322
+ this.targetTypeCache.clear();
323
+ this.sourceTypeCache.clear();
324
+ const mappings = this.config.mappings || [];
325
+ if (!mappings || !Array.isArray(mappings))
326
+ return;
327
+ const basePath = this.getValidatedBasePath();
328
+ const seenTargetPaths = new Map();
329
+ const subscribeIds = [];
330
+ for (const entry of mappings) {
331
+ if (!entry.id || !entry.mqttName)
332
+ continue;
333
+ const cleanSuffix = this.convertMqttPathToIobrokerId(entry.mqttName);
334
+ const fullTargetPath = `${basePath}${cleanSuffix}`;
335
+ const commandPath = this.resolveCommandPath(entry, fullTargetPath);
336
+ entry.fullTargetPath = fullTargetPath;
337
+ entry.commandPath = commandPath;
338
+ try {
339
+ const sObj = await this.getForeignObjectAsync(entry.id);
340
+ const sType = sObj && sObj.common && sObj.common.type;
341
+ this.sourceTypeCache.set(entry.id, sType || "unknown");
342
+ // Auch als Ziel-Typ cachen: entry.id ist bei dir "in"/"both" ein Schreibziel, und
343
+ // convertType() kannte dessen echten Typ bisher nur über fullTargetPath (IOB->MQTT-
344
+ // Richtung) - die Befehlsrichtung (MQTT->IOB) bekam bei "auto" nie eine Koerzion.
345
+ if (sType) {
346
+ this.targetTypeCache.set(entry.id, sType);
347
+ }
348
+ }
349
+ catch (e) {
350
+ this.log.debug(`[Setup] Quellobjekt ${entry.id} nicht lesbar: ${e.message}`);
351
+ this.sourceTypeCache.set(entry.id, "unknown");
352
+ }
353
+ // Source Index (IOB -> MQTT)
354
+ if (entry.dir === "out" || entry.dir === "both") {
355
+ const prevOwner = seenTargetPaths.get(fullTargetPath);
356
+ if (prevOwner && prevOwner !== entry.id) {
357
+ this.log.warn(`[Setup] Doppeltes Ziel-Topic "${fullTargetPath}": wird sowohl von "${prevOwner}" als auch von "${entry.id}" beschrieben - die Werte überschreiben sich gegenseitig!`);
358
+ }
359
+ else {
360
+ seenTargetPaths.set(fullTargetPath, entry.id);
361
+ }
362
+ if (!this.sourceToMappings.has(entry.id)) {
363
+ this.sourceToMappings.set(entry.id, []);
364
+ }
365
+ this.sourceToMappings.get(entry.id).push(entry);
366
+ subscribeIds.push(entry.id);
367
+ }
368
+ // Target Index (MQTT -> IOB): abonniert wird das Befehls-Topic. Bei "dual" ist das
369
+ // <topic>/set, bei "single" das Basis-Topic selbst.
370
+ if (entry.dir === "in" || entry.dir === "both") {
371
+ if (!this.targetToMappings.has(commandPath)) {
372
+ this.targetToMappings.set(commandPath, []);
373
+ }
374
+ this.targetToMappings.get(commandPath).push(entry);
375
+ subscribeIds.push(commandPath);
376
+ }
377
+ // Reihenfolge ist wichtig: erst das Status-Topic als State anlegen, danach das
378
+ // darunterliegende Befehls-Topic. Andernfalls würde die Ordner-Anlage das Basis-Topic
379
+ // als "folder" erzeugen, bevor es als State existiert.
380
+ await this.ensureAdapterObject(entry.id, fullTargetPath, entry.unit);
381
+ if (commandPath !== fullTargetPath) {
382
+ await this.ensureAdapterObject(entry.id, commandPath, entry.unit);
383
+ }
384
+ }
385
+ // Ein gebündelter Subscribe-Aufruf statt einem pro ID
386
+ if (subscribeIds.length > 0) {
387
+ await this.subscribeForeignStatesAsync(subscribeIds);
388
+ }
389
+ // Initial-Sync gebündelt: ein getForeignStatesAsync-Roundtrip statt N Einzelabfragen.
390
+ // Zusätzlich werden die aktuellen Zielwerte gelesen: Ist der Wert dort schon korrekt,
391
+ // wird nicht neu geschrieben - sonst bekäme jeder Spiegel nach jedem Neustart ein
392
+ // frisches ts, obwohl sich nichts geändert hat (und ein totes Gerät sähe aktiv aus).
393
+ const outIds = [...this.sourceToMappings.keys()];
394
+ const outStates = outIds.length ? await this.getForeignStatesAsync(outIds) : {};
395
+ const outTargetStates = await this.getTargetStates(outIds.flatMap(id => this.sourceToMappings.get(id).map(e => e.fullTargetPath)));
396
+ for (const [id, entries] of this.sourceToMappings) {
397
+ const state = outStates[id];
398
+ for (const entry of entries) {
399
+ if (entry.dir === "out" || entry.dir === "both") {
400
+ if (!state || !this.passesAckFilter(entry, state))
401
+ continue;
402
+ const label = entry.dir === "both" ? "IOB -> MQTT (START: both)" : "IOB -> MQTT (START)";
403
+ await this.syncValue(id, entry.fullTargetPath, label, "START-UP", entry.type, state, this.syncOptionsFor(entry, "start", outTargetStates[entry.fullTargetPath]));
404
+ }
405
+ }
406
+ }
407
+ // Achtung: Bei topicMode "dual" ist das Quell-Topic ein reiner BEFEHLS-Kanal, kein
408
+ // Zustandsspeicher. Ein dort liegender (evtl. retained) Befehl darf beim Start nicht
409
+ // erneut ausgeführt werden - sonst würde z.B. nach jedem ioBroker-Neustart das zuletzt
410
+ // gesendete Kommando das Gerät erneut schalten. Deshalb nur "single" initial abgleichen.
411
+ const inOnlyTargets = [];
412
+ for (const [commandPath, entries] of this.targetToMappings) {
413
+ if (entries.some(e => e.dir === "in" && e.topicMode !== "dual"))
414
+ inOnlyTargets.push(commandPath);
415
+ }
416
+ const inStates = inOnlyTargets.length ? await this.getForeignStatesAsync(inOnlyTargets) : {};
417
+ const inTargetStates = await this.getTargetStates(inOnlyTargets.flatMap(p => this.targetToMappings.get(p).map(e => e.id)));
418
+ for (const [commandPath, entries] of this.targetToMappings) {
419
+ const state = inStates[commandPath];
420
+ for (const entry of entries) {
421
+ if (entry.dir === "in" && entry.topicMode !== "dual") {
422
+ // Wie im Event-Pfad (onStateChange) nur echte Broker-Werte (ack=true) übernehmen.
423
+ if (!state || state.ack !== true)
424
+ continue;
425
+ await this.syncValue(commandPath, entry.id, "MQTT -> IOB (START)", "START-UP", entry.type, state, this.syncOptionsFor(entry, "start", inTargetStates[entry.id]));
426
+ }
427
+ }
428
+ }
429
+ this.log.info(`[Setup] Indiziert: ${this.sourceToMappings.size} Quellen, ${this.targetToMappings.size} Ziele.`);
430
+ }
431
+ async onStateChange(id, state) {
432
+ if (!state)
433
+ return;
434
+ // 1. Ist es eine Quelle? (IOB -> MQTT)
435
+ const sourceMatches = this.sourceToMappings.get(id);
436
+ if (sourceMatches) {
437
+ for (const entry of sourceMatches) {
438
+ // Standard: nur bestätigte Werte (ack=true) weiterleiten. Für Datenpunkte ohne
439
+ // echtes Gerät dahinter (0_userdata.0.*, alias.0.*) kann pro Mapping "any" gewählt
440
+ // werden, damit auch ack=false (Admin-UI/Skripte) sofort statt erst beim nächsten
441
+ // Cycle-Sync durchkommt.
442
+ if (this.passesAckFilter(entry, state) && entry.fullTargetPath) {
443
+ const dirLabel = entry.dir === "both" ? "IOB -> MQTT (Config: both)" : "IOB -> MQTT";
444
+ // Echo-Schutz nur bei "any" aktivieren: nur dort können ack=false-Ereignisse
445
+ // (also potenziell unser eigener Schreibvorgang) diesen Zweig überhaupt erreichen.
446
+ // Keine Aktualitätsprüfung: das Ereignis selbst belegt, dass die Quelle lebt.
447
+ await this.syncValue(id, entry.fullTargetPath, dirLabel, "LOCAL-CHANGE", entry.type, state, {
448
+ ...this.syncOptionsFor(entry, "event"),
449
+ useEchoGuard: entry.ackFilter === "any"
450
+ });
451
+ }
452
+ }
453
+ }
454
+ // 2. Ist es ein Ziel? (MQTT -> IOB)
455
+ const targetMatches = this.targetToMappings.get(id);
456
+ if (targetMatches) {
457
+ for (const entry of targetMatches) {
458
+ if (!entry.commandPath)
459
+ continue;
460
+ // Die Ack-Prüfung ist nur im Modus "single" nötig - dort teilen sich Status und
461
+ // Befehl ein Topic, und ein ack=false-Ereignis wäre unser eigener Schreibvorgang
462
+ // (Echo). Bei "dual" schreiben wir auf das Befehls-Topic grundsätzlich nie, ein
463
+ // Echo kann dort also gar nicht entstehen. Die Prüfung würde dort im Gegenteil
464
+ // schaden: Übliche MQTT-Adapter reichen eingehende /set-Nachrichten bewusst als
465
+ // unbestätigten Steuerbefehl (ack=false) weiter - der Schaltbefehl würde dann
466
+ // stillschweigend verworfen.
467
+ const isDual = entry.topicMode === "dual";
468
+ if (!isDual && state.ack !== true)
469
+ continue;
470
+ const modeLabel = isDual ? " [dual]" : "";
471
+ const dirLabel = entry.dir === "both" ? `MQTT -> IOB (Config: both${modeLabel})` : `MQTT -> IOB${modeLabel}`;
472
+ // Bei "both" ist dies die Rückrichtung des konfigurierten Typs (z.B. boolToNum <-> numToBool).
473
+ // Der Wert selbst wird dabei nie invertiert (true bleibt true), nur die Darstellung angepasst.
474
+ const effectiveType = entry.dir === "both" ? this.invertConversionType(entry.type) : entry.type;
475
+ // Bei "dual" ist das ein neuer Befehl - der bekommt bewusst den aktuellen Zeitpunkt,
476
+ // nicht den der MQTT-Nachricht, und wird nie als "Refresh" wiederholt. Bei "single"
477
+ // wird ein Zustand gespiegelt.
478
+ const eventOpts = isDual
479
+ ? { decimals: entry.decimals, preserveTimestamp: false }
480
+ : this.syncOptionsFor(entry, "event");
481
+ await this.syncValue(entry.commandPath, entry.id, dirLabel, "MQTT-EVENT", effectiveType, state, eventOpts);
482
+ }
483
+ }
484
+ }
485
+ /**
486
+ * Sync Funktion. Gibt true zurück, wenn tatsächlich geschrieben wurde.
487
+ */
488
+ async syncValue(sourceId, targetId, dirLabel, triggerSource, convType, stateObj, opts = {}) {
489
+ var _a, _b;
490
+ const force = opts.force === true;
491
+ try {
492
+ let srcState = stateObj;
493
+ if (!srcState) {
494
+ srcState = await this.getForeignStateAsync(sourceId);
495
+ }
496
+ if (!srcState)
497
+ return false;
498
+ const val = srcState.val;
499
+ if (val === null || val === undefined)
500
+ return false;
501
+ // --- 0. AKTUALITÄT DER QUELLE ---
502
+ // Nur bei Start/Cycle/Force-Sync: dort liegt kein frisches Ereignis vor, der Wert kann
503
+ // beliebig alt sein. Ein totes Gerät soll auf der Zielseite nicht "neu" werden.
504
+ if (opts.requireAlive) {
505
+ const staleReason = this.getStaleReason(srcState, (_a = opts.staleLimitMs) !== null && _a !== void 0 ? _a : 0);
506
+ if (staleReason) {
507
+ if (!this.staleSources.has(sourceId)) {
508
+ this.staleSources.add(sourceId);
509
+ this.log.info(`[Aktualität] Quelle ${sourceId} gilt als inaktiv (${staleReason}) - wird bis zum nächsten echten Update nicht mehr nach ${targetId} gespiegelt.`);
510
+ }
511
+ else {
512
+ this.log.debug(`[Aktualität] (${triggerSource}) Überspringe ${sourceId} -> ${targetId}: ${staleReason}`);
513
+ }
514
+ return false;
515
+ }
516
+ }
517
+ if (this.staleSources.delete(sourceId)) {
518
+ this.log.info(`[Aktualität] Quelle ${sourceId} ist wieder aktiv.`);
519
+ }
520
+ // --- 1. ECHO-SCHUTZ (verbrauchbar, mit kurzer Verfallszeit) ---
521
+ // Ein evtl. vorhandener Merker wird immer konsumiert (aufgeräumt), damit er nicht
522
+ // später ein unabhängiges Ereignis mit zufällig demselben Wert blockiert - reagiert
523
+ // wird darauf aber nur, wenn useEchoGuard aktiv ist.
524
+ const pending = this.pendingWrites.get(sourceId);
525
+ if (pending) {
526
+ this.pendingWrites.delete(sourceId);
527
+ const stillFresh = Date.now() - pending.ts < MqttPlus.PENDING_WRITE_TTL_MS;
528
+ if (opts.useEchoGuard && stillFresh && this.sameValue(pending.value, val)) {
529
+ this.log.debug(`[Echo-Schild] (${triggerSource}) Ignoriere Echo von ${sourceId} -> ${targetId} (Wert '${val}' identisch zum eigenen Schreibvorgang)`);
530
+ return false;
531
+ }
532
+ }
533
+ // ----------------------------------------------
534
+ const processedValue = this.convertType(val, convType, targetId, (_b = opts.decimals) !== null && _b !== void 0 ? _b : 2);
535
+ // "::" statt "_to_": vermeidet Key-Kollisionen, falls eine ID selbst "_to_" enthält.
536
+ const cacheKey = `${sourceId}::${targetId}`;
537
+ // syncMode "refresh": eine neue Meldung der Quelle (neueres ts) mit gleichem Wert
538
+ // zählt trotzdem als weiterzureichen - das Ziel bleibt so aktuell, solange das Gerät
539
+ // tatsächlich meldet, und veraltet ehrlich, sobald es verstummt.
540
+ const srcTs = typeof srcState.ts === "number" ? srcState.ts : 0;
541
+ const isRefresh = (knownTs) => opts.passRefresh === true && srcTs > (knownTs !== null && knownTs !== void 0 ? knownTs : 0);
542
+ // --- 2. VALUE CACHE (Ping-Pong Schutz für langsame Echos) ---
543
+ // WICHTIG: Wenn force = true ist, ignorieren wir den Cache komplett!
544
+ if (!force && this.sameValue(this.lastSyncValues.get(cacheKey), processedValue) && !isRefresh(this.lastSyncTs.get(cacheKey))) {
545
+ this.log.debug(`[Cache-Schild] Blockiere redundanten Wert für ${targetId} (Wert '${processedValue}' ist identisch zum letzten Sendevorgang)`);
546
+ return false;
547
+ }
548
+ // --- 2b. ZIELVERGLEICH (Start/Force-Sync) ---
549
+ // Steht auf der Zielseite bereits der richtige Wert, gibt es nichts zu heilen. Ein
550
+ // erneutes Schreiben würde nur ts/lc des Ziels auffrischen und eine MQTT-Nachricht
551
+ // auslösen - und damit ein inaktives Gerät als aktiv erscheinen lassen.
552
+ if (opts.targetState && this.sameValue(opts.targetState.val, processedValue) && !isRefresh(opts.targetState.ts)) {
553
+ this.lastSyncValues.set(cacheKey, processedValue);
554
+ this.lastSyncTs.set(cacheKey, srcTs);
555
+ this.log.debug(`[Ziel-Schild] (${triggerSource}) ${targetId} hat bereits den Wert '${processedValue}' - kein Schreiben nötig`);
556
+ return false;
557
+ }
558
+ this.lastSyncValues.set(cacheKey, processedValue);
559
+ this.lastSyncTs.set(cacheKey, srcTs);
560
+ // -----------------------------------------------------------
561
+ // --- 3. SCHREIBEN & ECHO-MERKER SETZEN ---
562
+ this.pendingWrites.set(targetId, { value: processedValue, ts: Date.now() });
563
+ // Spiegelung eines Zustands: Zeitstempel (ts = letzte Aktualisierung, lc = letzte
564
+ // Änderung) und Qualität der Quelle übernehmen, damit das Ziel den echten
565
+ // Messzeitpunkt zeigt statt des Kopier-Zeitpunkts. Befehle (dual /set) bekommen
566
+ // dagegen den aktuellen Zeitpunkt - ein Befehl ist tatsächlich neu.
567
+ const newState = { val: processedValue, ack: false, c: "mqtt-plus" };
568
+ if (opts.preserveTimestamp) {
569
+ if (typeof srcState.ts === "number")
570
+ newState.ts = srcState.ts;
571
+ if (typeof srcState.lc === "number")
572
+ newState.lc = srcState.lc;
573
+ if (typeof srcState.q === "number")
574
+ newState.q = srcState.q;
575
+ }
576
+ await this.setForeignStateAsync(targetId, newState);
577
+ if (this.config.logTransfers) {
578
+ const forceLabel = force ? "[FORCED] " : "";
579
+ this.log.info(`${forceLabel}[${dirLabel}] (${triggerSource}) ${val} -> ${processedValue} (${targetId})`);
580
+ }
581
+ this.updateWatchdog("Running");
582
+ return true;
583
+ }
584
+ catch (e) {
585
+ this.log.error(`Sync-Fehler ${sourceId}: ${e.message}`);
586
+ return false;
587
+ }
588
+ }
589
+ // Standard: nur bestätigte Werte (ack=true) weiterleiten. Für Datenpunkte ohne echtes Gerät
590
+ // dahinter (0_userdata.0.*, alias.0.*) kann pro Mapping "any" gewählt werden. Gilt für alle
591
+ // Wege IOB -> MQTT (Event, Start, Cycle, Force) - sonst würde z.B. der Cycle einen nie
592
+ // bestätigten Befehl an ein Offline-Gerät nach MQTT als Status melden.
593
+ passesAckFilter(entry, state) {
594
+ return entry.ackFilter === "any" || state.ack === true;
595
+ }
596
+ // Sync-Optionen eines Mappings je Auslöser, abhängig vom Sync-Modus des Eintrags.
597
+ syncOptionsFor(entry, phase, targetState) {
598
+ if (entry.syncMode === "force") {
599
+ // Verhalten vor 1.6.0: keine Aktualitätsprüfung, kein Zielvergleich, ts = jetzt.
600
+ // Start und Force-Sync schreiben dadurch immer - das Ziel wirkt dauerhaft frisch.
601
+ return { force: phase === "force", decimals: entry.decimals, preserveTimestamp: false };
602
+ }
603
+ const opts = {
604
+ decimals: entry.decimals,
605
+ preserveTimestamp: true,
606
+ passRefresh: entry.syncMode === "refresh"
607
+ };
608
+ if (phase === "event")
609
+ return opts;
610
+ opts.requireAlive = true;
611
+ opts.staleLimitMs = this.getStaleLimitMs(entry);
612
+ if (phase === "force")
613
+ opts.force = true;
614
+ if (phase === "start" || phase === "force")
615
+ opts.targetState = targetState !== null && targetState !== void 0 ? targetState : null;
616
+ return opts;
617
+ }
618
+ // Liest eine Minuten-Angabe aus der Config tolerant ein: leer/ungültig -> undefined.
619
+ parseMinutes(raw) {
620
+ if (raw === undefined || raw === null || String(raw).trim() === "")
621
+ return undefined;
622
+ const n = Number(raw);
623
+ return isNaN(n) || n < 0 ? undefined : n;
624
+ }
625
+ // Aktualitätsgrenze in ms für ein Mapping: Eintrag im Mapping vor globalem Wert, 0 = aus.
626
+ getStaleLimitMs(entry) {
627
+ var _a;
628
+ const perMapping = this.parseMinutes(entry.staleAfterMin);
629
+ const global = (_a = this.parseMinutes(this.config.staleAfterMin)) !== null && _a !== void 0 ? _a : MqttPlus.DEFAULT_STALE_AFTER_MIN;
630
+ return (perMapping !== null && perMapping !== void 0 ? perMapping : global) * 60000;
631
+ }
632
+ // Liefert den Grund, warum eine Quelle als inaktiv gilt, oder null wenn sie aktiv ist.
633
+ // - q != 0: der Geräte-Adapter meldet selbst ein Problem (z.B. keine Verbindung).
634
+ // - ts älter als die Grenze: das Gerät hat sich seitdem nicht mehr gemeldet.
635
+ getStaleReason(state, staleLimitMs) {
636
+ if (typeof state.q === "number" && state.q !== 0) {
637
+ return `Qualität q=0x${state.q.toString(16).padStart(2, "0")}`;
638
+ }
639
+ if (staleLimitMs > 0 && typeof state.ts === "number") {
640
+ const age = Date.now() - state.ts;
641
+ if (age > staleLimitMs)
642
+ return `letzte Aktualisierung vor ${this.formatAge(age)}`;
643
+ }
644
+ return null;
645
+ }
646
+ formatAge(ms) {
647
+ const min = Math.round(ms / 60000);
648
+ if (min < 120)
649
+ return `${min} Min`;
650
+ const h = Math.round(min / 60);
651
+ if (h < 48)
652
+ return `${h} Std`;
653
+ return `${Math.round(h / 24)} Tagen`;
654
+ }
655
+ // Liest die aktuellen Werte der Zielobjekte gebündelt (für den Zielvergleich bei Start/Force).
656
+ async getTargetStates(ids) {
657
+ const unique = [...new Set(ids.filter(Boolean))];
658
+ if (!unique.length)
659
+ return {};
660
+ try {
661
+ return await this.getForeignStatesAsync(unique);
662
+ }
663
+ catch (e) {
664
+ this.log.debug(`[Sync] Zielwerte nicht lesbar, schreibe ohne Vergleich: ${e.message}`);
665
+ return {};
666
+ }
667
+ }
668
+ // Kehrt boolToNum/numToBool für die Rückrichtung eines "both"-Mappings um.
669
+ // round/auto sind richtungsunabhängig symmetrisch und bleiben unverändert.
670
+ invertConversionType(type) {
671
+ switch (type) {
672
+ case "boolToNum": return "numToBool";
673
+ case "numToBool": return "boolToNum";
674
+ default: return type;
675
+ }
676
+ }
677
+ // Wandelt einen Wert korrekt in boolean um - Strings wie "0"/"false"/"off" zählen als false,
678
+ // statt (wie !!value) fälschlich als true.
679
+ toBoolean(value) {
680
+ if (typeof value === "string") {
681
+ const normalized = value.trim().toLowerCase();
682
+ if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "")
683
+ return false;
684
+ if (normalized === "1" || normalized === "true" || normalized === "on")
685
+ return true;
686
+ }
687
+ return !!value;
688
+ }
689
+ // Typtoleranter Vergleich für Echo-/Cache-Erkennung: true/"true", 53/"53" gelten als gleich.
690
+ // Ein reiner === schlägt fehl, wenn derselbe Wert einmal als String, einmal als Zahl/Boolean
691
+ // vorliegt, obwohl er inhaltlich identisch ist - mit Folgen in beide Richtungen (verpasste
692
+ // Echo-Erkennung oder verpasste Cache-Treffer).
693
+ sameValue(a, b) {
694
+ if (a === b)
695
+ return true;
696
+ if (a === null || a === undefined || b === null || b === undefined)
697
+ return false;
698
+ const numA = typeof a === "number" ? a : (typeof a === "string" && a.trim() !== "" && !isNaN(Number(a)) ? Number(a) : null);
699
+ const numB = typeof b === "number" ? b : (typeof b === "string" && b.trim() !== "" && !isNaN(Number(b)) ? Number(b) : null);
700
+ if (numA !== null && numB !== null)
701
+ return numA === numB;
702
+ const isBoolLike = (v) => typeof v === "boolean" || (typeof v === "string" && ["true", "false"].includes(v.trim().toLowerCase()));
703
+ if (isBoolLike(a) && isBoolLike(b))
704
+ return this.toBoolean(a) === this.toBoolean(b);
705
+ return String(a) === String(b);
706
+ }
707
+ convertType(value, targetType, targetId, decimals = 2) {
708
+ if (value === null || value === undefined)
709
+ return value;
710
+ switch (targetType) {
711
+ case "boolToNum":
712
+ return value ? 1 : 0;
713
+ case "numToBool":
714
+ return this.toBoolean(value);
715
+ case "round": {
716
+ const factor = Math.pow(10, decimals);
717
+ if (typeof value === "number")
718
+ return Math.round(value * factor) / factor;
719
+ const num = parseFloat(value);
720
+ return isNaN(num) ? value : Math.round(num * factor) / factor;
721
+ }
722
+ default: {
723
+ // Ohne explizit konfigurierten Typ: anhand des tatsächlichen Zielobjekt-Typs
724
+ // (aus ensureAdapterObject bekannt) automatisch konvertieren statt anhand einer
725
+ // fragilen Namensheuristik.
726
+ const knownType = this.targetTypeCache.get(targetId);
727
+ if (knownType === "boolean")
728
+ return this.toBoolean(value);
729
+ if (knownType === "number") {
730
+ const num = typeof value === "number" ? value : parseFloat(value);
731
+ return isNaN(num) ? value : num;
732
+ }
733
+ return value;
734
+ }
735
+ }
736
+ }
737
+ // Wartet aktiv (statt eines pauschalen Sleeps) darauf, dass ein neu angelegtes Objekt
738
+ // in der Objekt-DB sichtbar ist - beendet sich sobald es existiert, spätestens nach timeoutMs.
739
+ async waitForObject(id, timeoutMs = 2000) {
740
+ const start = Date.now();
741
+ while (Date.now() - start < timeoutMs) {
742
+ const obj = await this.getForeignObjectAsync(id);
743
+ if (obj)
744
+ return;
745
+ await this.sleep(100);
746
+ }
747
+ }
748
+ async ensureAdapterObject(sourceId, targetPath, mappingUnit) {
749
+ const parts = targetPath.split(".");
750
+ if (parts.length < 2) {
751
+ this.log.warn(`[Setup] Ungültiger Zielpfad "${targetPath}" (Basispfad zu kurz) - übersprungen.`);
752
+ return;
753
+ }
754
+ let currentPath = parts[0] + "." + parts[1];
755
+ for (let i = 2; i < parts.length - 1; i++) {
756
+ currentPath += "." + parts[i];
757
+ try {
758
+ const exists = await this.getForeignObjectAsync(currentPath);
759
+ if (!exists) {
760
+ await this.setForeignObjectAsync(currentPath, {
761
+ _id: currentPath,
762
+ type: "folder",
763
+ common: { name: parts[i] },
764
+ native: {}
765
+ });
766
+ }
767
+ }
768
+ catch (e) {
769
+ this.log.debug(`[Setup] Ordner-Anlage ${currentPath} übersprungen: ${e.message}`);
770
+ }
771
+ }
772
+ try {
773
+ const finalExists = await this.getForeignObjectAsync(targetPath);
774
+ if (!finalExists) {
775
+ const sObj = await this.getForeignObjectAsync(sourceId);
776
+ let type = "mixed";
777
+ let role = "variable";
778
+ let unit = mappingUnit;
779
+ if (sObj && sObj.common) {
780
+ type = sObj.common.type || "mixed";
781
+ role = sObj.common.role || "variable";
782
+ if (!unit)
783
+ unit = sObj.common.unit;
784
+ }
785
+ if (targetPath.includes("Switch") || targetPath.includes("Schalter")) {
786
+ type = "boolean";
787
+ role = "switch";
788
+ }
789
+ await this.setForeignObjectAsync(targetPath, {
790
+ _id: targetPath,
791
+ type: "state",
792
+ common: {
793
+ name: `Export von ${sourceId}`,
794
+ type: type,
795
+ role: role,
796
+ unit: unit,
797
+ read: true,
798
+ write: true
799
+ },
800
+ native: {}
801
+ });
802
+ this.targetTypeCache.set(targetPath, type);
803
+ await this.waitForObject(targetPath);
804
+ }
805
+ else if (finalExists.common) {
806
+ this.targetTypeCache.set(targetPath, finalExists.common.type || "mixed");
807
+ }
808
+ }
809
+ catch (e) {
810
+ this.log.debug(`[Setup] Zielobjekt ${targetPath} übersprungen: ${e.message}`);
811
+ }
812
+ }
813
+ /**
814
+ * Regulärer Cycle Sync: Prüft mit Cache (produziert keinen massiven Funkverkehr)
815
+ */
816
+ async runCycleSync() {
817
+ if (this.syncRunning || this.unloaded)
818
+ return;
819
+ this.syncRunning = true;
820
+ try {
821
+ const ids = [...this.sourceToMappings.keys()];
822
+ const states = ids.length ? await this.getForeignStatesAsync(ids) : {};
823
+ for (const [id, entries] of this.sourceToMappings) {
824
+ const state = states[id];
825
+ if (!state)
826
+ continue;
827
+ for (const entry of entries) {
828
+ if (entry.fullTargetPath && (entry.dir === "out" || entry.dir === "both")) {
829
+ if (!this.passesAckFilter(entry, state))
830
+ continue;
831
+ const label = entry.dir === "both" ? "IOB -> MQTT (CYCLE: both)" : "IOB -> MQTT (CYCLE)";
832
+ await this.syncValue(id, entry.fullTargetPath, label, "CYCLE", entry.type, state, this.syncOptionsFor(entry, "cycle"));
833
+ }
834
+ }
835
+ }
836
+ this.updateWatchdog("Cycle OK", true);
837
+ }
838
+ catch (e) {
839
+ this.log.error(`Cycle-Sync-Fehler: ${e.message}`);
840
+ }
841
+ finally {
842
+ this.syncRunning = false;
843
+ }
844
+ }
845
+ /**
846
+ * Force Sync (Heilung): Ignoriert den Cache und vergleicht direkt mit dem tatsächlichen
847
+ * Zielwert - geschrieben wird nur, wo Quelle und Ziel wirklich auseinanderlaufen, und nur
848
+ * von aktiven Quellen. Läuft nie parallel zum Cycle-Sync (gemeinsame Sperre) und staffelt
849
+ * die Schreibvorgänge, um Funkbudget (Zigbee/433MHz) nicht als Burst zu belasten.
850
+ */
851
+ async runForceSync() {
852
+ if (this.syncRunning || this.unloaded)
853
+ return;
854
+ this.syncRunning = true;
855
+ try {
856
+ this.log.info("[Force-Sync] Starte zyklische Zwangssynchronisation (Heilung von Asynchronität)...");
857
+ let healed = 0;
858
+ let forced = 0;
859
+ // 1. IOB -> MQTT (für 'out' und 'both' - IOB ist die Quelle der Wahrheit für Aktoren)
860
+ const outIds = [...this.sourceToMappings.keys()];
861
+ const outStates = outIds.length ? await this.getForeignStatesAsync(outIds) : {};
862
+ const outTargetStates = await this.getTargetStates(outIds.flatMap(id => this.sourceToMappings.get(id).map(e => e.fullTargetPath)));
863
+ for (const [id, entries] of this.sourceToMappings) {
864
+ const state = outStates[id];
865
+ if (!state)
866
+ continue;
867
+ for (const entry of entries) {
868
+ if (entry.fullTargetPath && (entry.dir === "out" || entry.dir === "both")) {
869
+ if (!this.passesAckFilter(entry, state))
870
+ continue;
871
+ const label = entry.dir === "both" ? "IOB -> MQTT (FORCE: both)" : "IOB -> MQTT (FORCE)";
872
+ const written = await this.syncValue(id, entry.fullTargetPath, label, "FORCE-SYNC", entry.type, state, this.syncOptionsFor(entry, "force", outTargetStates[entry.fullTargetPath]));
873
+ if (written) {
874
+ if (entry.syncMode === "force")
875
+ forced++;
876
+ else
877
+ healed++;
878
+ await this.sleep(75);
879
+ }
880
+ }
881
+ }
882
+ if (this.unloaded)
883
+ return;
884
+ }
885
+ // 2. MQTT -> IOB (für 'in' - MQTT ist die Quelle der Wahrheit für externe Sensoren)
886
+ // Nur "single": bei "dual" ist die Quelle ein Befehls-Topic - ein Force-Sync würde
887
+ // dort den zuletzt gesendeten Befehl periodisch wiederholen und damit z.B. ein am
888
+ // Wandschalter ausgeschaltetes Licht von selbst wieder einschalten.
889
+ const inIds = [];
890
+ for (const [commandPath, entries] of this.targetToMappings) {
891
+ if (entries.some(e => e.dir === "in" && e.topicMode !== "dual"))
892
+ inIds.push(commandPath);
893
+ }
894
+ const inStates = inIds.length ? await this.getForeignStatesAsync(inIds) : {};
895
+ const inTargetStates = await this.getTargetStates(inIds.flatMap(p => this.targetToMappings.get(p).map(e => e.id)));
896
+ for (const [commandPath, entries] of this.targetToMappings) {
897
+ const state = inStates[commandPath];
898
+ // Wie im Event-Pfad nur echte Broker-Werte (ack=true) übernehmen.
899
+ if (!state || state.ack !== true)
900
+ continue;
901
+ for (const entry of entries) {
902
+ if (entry.dir === "in" && entry.topicMode !== "dual") {
903
+ const written = await this.syncValue(commandPath, entry.id, "MQTT -> IOB (FORCE: in)", "FORCE-SYNC", entry.type, state, this.syncOptionsFor(entry, "force", inTargetStates[entry.id]));
904
+ if (written) {
905
+ if (entry.syncMode === "force")
906
+ forced++;
907
+ else
908
+ healed++;
909
+ await this.sleep(75);
910
+ }
911
+ }
912
+ }
913
+ if (this.unloaded)
914
+ return;
915
+ }
916
+ const forcedInfo = forced ? `, ${forced} im Modus "Force" neu geschrieben` : "";
917
+ this.log.info(`[Force-Sync] Abgeschlossen: ${healed} abweichende Werte geheilt${forcedInfo}.`);
918
+ this.updateWatchdog("Force-Sync OK", true);
919
+ }
920
+ catch (e) {
921
+ this.log.error(`Force-Sync-Fehler: ${e.message}`);
922
+ }
923
+ finally {
924
+ this.syncRunning = false;
925
+ }
926
+ }
927
+ async generateJsonTree() {
928
+ const mappings = this.config.mappings || [];
929
+ const tree = {};
930
+ const basePath = this.getValidatedBasePath();
931
+ for (const entry of mappings) {
932
+ if (!entry.mqttName)
933
+ continue;
934
+ const cleanSuffix = this.convertMqttPathToIobrokerId(entry.mqttName);
935
+ const pathParts = cleanSuffix.split(".");
936
+ let current = tree;
937
+ for (let i = 0; i < pathParts.length; i++) {
938
+ const part = pathParts[i];
939
+ const isLast = i === pathParts.length - 1;
940
+ if (isLast) {
941
+ if (current[part] && typeof current[part] === "object" && !current[part].full_topic) {
942
+ this.log.warn(`[JSON-Tree] Topic-Präfix-Kollision: "${cleanSuffix}" überschreibt eine bestehende Unterstruktur.`);
943
+ }
944
+ const statePath = `${basePath}${cleanSuffix}`;
945
+ const commandPath = this.resolveCommandPath(entry, statePath);
946
+ current[part] = {
947
+ full_topic: statePath,
948
+ // Bei "dual" laufen Befehle über ein eigenes Topic - im Export sichtbar,
949
+ // damit Backup und Vorschau die tatsächliche Topic-Struktur abbilden.
950
+ command_topic: commandPath,
951
+ topic_mode: entry.topicMode === "dual" ? "dual" : "single",
952
+ iobroker_id: entry.id,
953
+ type: this.sourceTypeCache.get(entry.id) || "unknown",
954
+ unit: entry.unit || ""
955
+ };
956
+ }
957
+ else {
958
+ if (current[part] && current[part].full_topic) {
959
+ this.log.warn(`[JSON-Tree] Topic-Präfix-Kollision: "${cleanSuffix}" kollidiert mit dem bestehenden Topic "${current[part].full_topic}".`);
960
+ current[part] = {};
961
+ }
962
+ else if (!current[part]) {
963
+ current[part] = {};
964
+ }
965
+ current = current[part];
966
+ }
967
+ }
968
+ }
969
+ return tree;
970
+ }
971
+ chunkArray(arr, size) {
972
+ const out = [];
973
+ for (let i = 0; i < arr.length; i += size)
974
+ out.push(arr.slice(i, i + size));
975
+ return out;
976
+ }
977
+ // Fehler, bei denen ein Retry mit identischer Payload nie zu einem anderen Ergebnis führt:
978
+ // 4xx-Antworten (Client-/Konfigurationsfehler) und TLS-Zertifikatsfehler. Nur bei Netzwerk-
979
+ // problemen (keine Antwort) oder 5xx-Serverfehlern lohnt sich ein erneuter Versuch.
980
+ isRetryableError(e) {
981
+ if (e.response) {
982
+ return e.response.status >= 500;
983
+ }
984
+ const nonRetryableCodes = [
985
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
986
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
987
+ "SELF_SIGNED_CERT_IN_CHAIN",
988
+ "ERR_TLS_CERT_ALTNAME_INVALID",
989
+ "CERT_HAS_EXPIRED"
990
+ ];
991
+ if (e.code && nonRetryableCodes.includes(e.code))
992
+ return false;
993
+ return true;
994
+ }
995
+ async postWithRetry(url, data, httpsAgent, retries = 2) {
996
+ for (let attempt = 0; attempt <= retries; attempt++) {
997
+ try {
998
+ await axios_1.default.post(url, data, {
999
+ timeout: 10000,
1000
+ httpsAgent,
1001
+ // Verhindert, dass eine gesetzte HTTPS_PROXY-Umgebungsvariable den eigenen
1002
+ // httpsAgent (und damit die konfigurierte CA) stillschweigend umgeht.
1003
+ proxy: false,
1004
+ headers: {
1005
+ "Content-Type": "application/json",
1006
+ "User-Agent": `ioBroker.mqtt-plus/${ADAPTER_VERSION}`
1007
+ }
1008
+ });
1009
+ return;
1010
+ }
1011
+ catch (e) {
1012
+ if (attempt === retries || !this.isRetryableError(e))
1013
+ throw e;
1014
+ await this.sleep(500 * Math.pow(2, attempt));
1015
+ }
1016
+ }
1017
+ }
1018
+ // Repariert häufige Copy-Paste-Beschädigungen in eingefügten PEM-Zertifikaten:
1019
+ // 1) Editoren/Autokorrektur ersetzen normale Bindestriche in "-----BEGIN...-----" durch
1020
+ // optisch identische Unicode-Striche (En-/Em-Dash, Minuszeichen, U+2010-U+2015, U+2212)
1021
+ // oder normale Leerzeichen durch geschützte Leerzeichen (U+00A0).
1022
+ // 2) Manche einzeiligen Formularfelder wandeln echte Zeilenumbrüche in Leerzeichen um -
1023
+ // dann fehlt der Zeilenumbruch nach BEGIN/vor END, den OpenSSLs PEM-Parser zwingend
1024
+ // braucht ("no start line"), obwohl Zeichenzahl und Kopfzeile unauffällig aussehen.
1025
+ normalizePem(pemInput) {
1026
+ let pem = pemInput
1027
+ .replace(/[\u2010-\u2015\u2212]/g, "-")
1028
+ .replace(/\u00a0/g, " ")
1029
+ .replace(/\r\n/g, "\n");
1030
+ pem = pem.replace(/-----BEGIN ([A-Z ]+)-----\s*/g, "-----BEGIN $1-----\n");
1031
+ pem = pem.replace(/\s*-----END ([A-Z ]+)-----/g, "\n-----END $1-----");
1032
+ return pem;
1033
+ }
1034
+ async runRemoteSync(verbose = false) {
1035
+ if (!this.config.syncUrl) {
1036
+ const msg = "Keine Sync-URL konfiguriert";
1037
+ await this.setStateAsync("info.lastSyncStatus", msg, true);
1038
+ return { success: false, message: msg };
1039
+ }
1040
+ let parsedUrl;
1041
+ try {
1042
+ parsedUrl = new URL(this.config.syncUrl);
1043
+ }
1044
+ catch (_a) {
1045
+ const msg = "Ungültige Sync-URL";
1046
+ this.log.error(`Remote Sync Error: ${msg} (${this.config.syncUrl})`);
1047
+ await this.setStateAsync("info.lastSyncStatus", msg, true);
1048
+ return { success: false, message: msg };
1049
+ }
1050
+ const encodedUrl = parsedUrl.toString();
1051
+ let templateStr = this.getDefaultSyncTemplate();
1052
+ try {
1053
+ const tplState = await this.getStateAsync("config.syncTemplate");
1054
+ if (tplState && tplState.val)
1055
+ templateStr = tplState.val;
1056
+ }
1057
+ catch (e) {
1058
+ this.log.debug(`[Remote Sync] Template-State nicht lesbar, verwende Standard: ${e.message}`);
1059
+ }
1060
+ // Platzhalter, die roh in einen JSON-String eingesetzt werden, müssen JSON-escaped werden -
1061
+ // sonst bricht ein Anführungszeichen/Backslash in einer ID das gesamte JSON.
1062
+ const esc = (s) => JSON.stringify(String(s)).slice(1, -1);
1063
+ const mappings = this.config.mappings || [];
1064
+ const payload = [];
1065
+ let skippedStale = 0;
1066
+ for (const entry of mappings) {
1067
+ try {
1068
+ const state = await this.getForeignStateAsync(entry.id);
1069
+ if (state) {
1070
+ if (this.config.remoteSyncSkipStale && this.getStaleReason(state, this.getStaleLimitMs(entry))) {
1071
+ skippedStale++;
1072
+ continue;
1073
+ }
1074
+ // Funktions-Ersetzer statt String: ein "$" im Wert würde sonst als
1075
+ // Ersetzungsmuster ($&, $1 ...) interpretiert.
1076
+ let itemStr = templateStr
1077
+ .replace(/%ID%/g, () => esc(entry.id))
1078
+ .replace(/%MQTT%/g, () => esc(entry.mqttName))
1079
+ .replace(/%PREFIX%/g, () => esc(this.config.targetBasePath))
1080
+ .replace(/%DIR%/g, () => esc(entry.dir))
1081
+ .replace(/%VAL%/g, () => JSON.stringify(state.val))
1082
+ .replace(/%TS%/g, () => String(state.ts))
1083
+ .replace(/%LC%/g, () => { var _a; return String((_a = state.lc) !== null && _a !== void 0 ? _a : state.ts); })
1084
+ .replace(/%ACK%/g, () => String(state.ack === true))
1085
+ .replace(/%Q%/g, () => { var _a; return String((_a = state.q) !== null && _a !== void 0 ? _a : 0); })
1086
+ .replace(/%UNIT%/g, () => esc(entry.unit || ""));
1087
+ try {
1088
+ payload.push(JSON.parse(itemStr));
1089
+ }
1090
+ catch (_b) {
1091
+ this.log.warn(`Remote-Sync Template Fehler bei ${entry.id}`);
1092
+ }
1093
+ }
1094
+ }
1095
+ catch (e) {
1096
+ this.log.debug(`[Remote Sync] Zustand für ${entry.id} nicht lesbar: ${e.message}`);
1097
+ }
1098
+ }
1099
+ this.log.debug(`[Remote Sync] Payload to send: ${JSON.stringify(payload)}`);
1100
+ // Für interne/selbstsignierte Ziele: das eigene Zertifikat/CA gezielt vertrauen,
1101
+ // statt die Prüfung komplett abzuschalten. Ohne Angabe gilt der normale
1102
+ // System-Vertrauensstore (öffentliche CAs).
1103
+ let httpsAgent;
1104
+ if (this.config.syncCaCert && this.config.syncCaCert.trim()) {
1105
+ const normalizedCaCert = this.normalizePem(this.config.syncCaCert);
1106
+ try {
1107
+ httpsAgent = new https.Agent({ ca: normalizedCaCert });
1108
+ // Nur beim manuellen Verbindungstest (info-Level), um den Log bei regulären
1109
+ // Intervall-Läufen nicht zuzumüllen - beweist, dass die konfigurierte CA den
1110
+ // Adapter-Prozess tatsächlich erreicht hat (Diagnose für Config-Save/Restart-Probleme).
1111
+ if (verbose) {
1112
+ let fingerprintInfo = "Fingerabdruck nicht ermittelbar";
1113
+ try {
1114
+ const cert = new crypto.X509Certificate(normalizedCaCert);
1115
+ fingerprintInfo = `Subject="${cert.subject.replace(/\n/g, ", ")}" Fingerprint(SHA256)=${cert.fingerprint256}`;
1116
+ }
1117
+ catch (certErr) {
1118
+ const trimmed = normalizedCaCert.trim();
1119
+ const headCodes = [...trimmed.slice(0, 12)].map(c => c.charCodeAt(0)).join(",");
1120
+ fingerprintInfo = `Zertifikat konnte auch nach Normalisierung nicht geparst werden: ${certErr.message} | erste 12 Zeichencodes: ${headCodes} (erwartet für "-----BEGIN": 45,45,45,45,45,66,69,71,73,78,32,67)`;
1121
+ }
1122
+ this.log.info(`[Remote Sync] Benutzerdefinierte CA geladen (${normalizedCaCert.trim().length} Zeichen). ${fingerprintInfo}`);
1123
+ }
1124
+ }
1125
+ catch (e) {
1126
+ const msg = `Ungültiges CA-Zertifikat in den Einstellungen: ${e.message}`;
1127
+ this.log.error(`Remote Sync Error: ${msg}`);
1128
+ await this.setStateAsync("info.lastSyncStatus", msg, true);
1129
+ return { success: false, message: msg };
1130
+ }
1131
+ }
1132
+ else if (verbose) {
1133
+ this.log.info("[Remote Sync] Kein CA-Zertifikat konfiguriert - Standard-Systemvertrauensstore wird verwendet.");
1134
+ }
1135
+ let sentCount = 0;
1136
+ try {
1137
+ // Sehr große Konfigurationen in Häppchen senden statt als einen Riesen-POST,
1138
+ // dessen einzelner Timeout sonst die komplette Payload verwirft.
1139
+ const chunks = this.chunkArray(payload, MqttPlus.REMOTE_SYNC_CHUNK_SIZE);
1140
+ for (const chunk of chunks) {
1141
+ await this.postWithRetry(encodedUrl, chunk, httpsAgent);
1142
+ sentCount += chunk.length;
1143
+ }
1144
+ const staleInfo = skippedStale ? `, ${skippedStale} inaktive übersprungen` : "";
1145
+ const msg = `OK: ${payload.length} Werte gesendet${staleInfo} (${new Date().toLocaleTimeString()})`;
1146
+ await this.setStateAsync("info.lastSyncStatus", msg, true);
1147
+ this.log.debug(`Remote Sync (${payload.length}) OK.`);
1148
+ return { success: true, message: msg };
1149
+ }
1150
+ catch (e) {
1151
+ let errorMsg = e.message;
1152
+ if (e.response) {
1153
+ errorMsg = `HTTP ${e.response.status}: ${e.response.statusText}`;
1154
+ }
1155
+ if (e.code) {
1156
+ errorMsg += ` (${e.code})`;
1157
+ }
1158
+ // Bei Chunking zeigt der Teilerfolg, ob nur ein Bruchteil oder praktisch nichts
1159
+ // angekommen ist - relevant, weil ein einzelner gescheiterter Chunk sonst wie ein
1160
+ // Totalausfall aussieht, obwohl der Großteil der Werte bereits übertragen wurde.
1161
+ const progress = payload.length > MqttPlus.REMOTE_SYNC_CHUNK_SIZE ? ` (${sentCount}/${payload.length} Werte übertragen)` : "";
1162
+ const fullMsg = `Fehler${progress}: ${errorMsg} (${new Date().toLocaleTimeString()})`;
1163
+ this.log.error(`Remote Sync Error: ${fullMsg} | URL: ${encodedUrl}`);
1164
+ await this.setStateAsync("info.lastSyncStatus", fullMsg, true);
1165
+ return { success: false, message: fullMsg };
1166
+ }
1167
+ }
1168
+ // Konstante-Zeit-Vergleich gegen Timing-Angriffe auf den Passwortvergleich.
1169
+ timingSafeStringEqual(a, b) {
1170
+ const bufA = Buffer.from(a);
1171
+ const bufB = Buffer.from(b);
1172
+ if (bufA.length !== bufB.length) {
1173
+ // Trotzdem konstante Zeit vergleichen (gegen Längen-Rückschlüsse per Timing);
1174
+ // Ergebnis ist ohnehin false.
1175
+ crypto.timingSafeEqual(bufA, bufA);
1176
+ return false;
1177
+ }
1178
+ return crypto.timingSafeEqual(bufA, bufB);
1179
+ }
1180
+ // Vergleicht die "Authorization: Basic ..."-Kopfzeile gegen die konfigurierten Zugangsdaten.
1181
+ // Ist kein Passwort konfiguriert, bleibt der Server bewusst offen (Warnung erfolgt in onReady).
1182
+ // Zusätzlich: Brute-Force-Sperre nach zu vielen Fehlversuchen pro Client-IP.
1183
+ checkAuth(req) {
1184
+ const password = this.config.dashboardPassword;
1185
+ if (!password)
1186
+ return true;
1187
+ const ip = req.socket.remoteAddress || "unknown";
1188
+ const entry = this.failedAuthAttempts.get(ip);
1189
+ if (entry && entry.lockedUntil > Date.now())
1190
+ return false;
1191
+ const user = this.config.dashboardUser || "admin";
1192
+ const header = req.headers["authorization"];
1193
+ let ok = false;
1194
+ if (header && header.startsWith("Basic ")) {
1195
+ try {
1196
+ const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
1197
+ const sep = decoded.indexOf(":");
1198
+ if (sep !== -1) {
1199
+ const reqUser = decoded.slice(0, sep);
1200
+ const reqPass = decoded.slice(sep + 1);
1201
+ ok = this.timingSafeStringEqual(reqUser, user) && this.timingSafeStringEqual(reqPass, password);
1202
+ }
1203
+ }
1204
+ catch (_a) {
1205
+ ok = false;
1206
+ }
1207
+ }
1208
+ if (ok) {
1209
+ this.failedAuthAttempts.delete(ip);
1210
+ return true;
1211
+ }
1212
+ const attempts = ((entry === null || entry === void 0 ? void 0 : entry.count) || 0) + 1;
1213
+ if (attempts >= MqttPlus.MAX_AUTH_ATTEMPTS) {
1214
+ this.failedAuthAttempts.set(ip, { count: 0, lockedUntil: Date.now() + MqttPlus.AUTH_LOCKOUT_MS });
1215
+ this.log.warn(`[Dashboard] Zu viele fehlgeschlagene Login-Versuche von ${ip} - für 5 Minuten gesperrt.`);
1216
+ this.persistAuthLockouts();
1217
+ }
1218
+ else {
1219
+ this.failedAuthAttempts.set(ip, { count: attempts, lockedUntil: 0 });
1220
+ }
1221
+ return false;
1222
+ }
1223
+ // Vergleicht Origin/Referer-Header gegen den eigenen Host - Basis für CORS-Beschränkung
1224
+ // und CSRF-Schutz bei zustandsändernden Requests. Ohne Origin/Referer (z.B. curl/Skript,
1225
+ // kein Browser) wird nicht blockiert - dort besteht kein CSRF-Risiko über den Browser.
1226
+ isSameOrigin(req) {
1227
+ const host = req.headers.host;
1228
+ if (!host)
1229
+ return false;
1230
+ const check = (req.headers.origin || req.headers.referer);
1231
+ if (!check)
1232
+ return true;
1233
+ try {
1234
+ return new URL(check).host === host;
1235
+ }
1236
+ catch (_a) {
1237
+ return false;
1238
+ }
1239
+ }
1240
+ // Liest den Request-Body byte-genau ein (Buffer statt String-Konkatenation, damit ein über
1241
+ // zwei Chunks gesplittetes Multibyte-Zeichen nicht zu korruptem JSON führt), bricht mit 413
1242
+ // ab, sobald maxBytes überschritten wird, und hängt bei Verbindungsabbruch/Timeout nicht.
1243
+ readBodyLimited(req, res, maxBytes) {
1244
+ return new Promise(resolve => {
1245
+ const chunks = [];
1246
+ let size = 0;
1247
+ let settled = false;
1248
+ const finish = (result) => {
1249
+ if (settled)
1250
+ return;
1251
+ settled = true;
1252
+ resolve(result);
1253
+ };
1254
+ req.setTimeout(30000, () => {
1255
+ if (!settled) {
1256
+ res.writeHead(408, { "Content-Type": "application/json" });
1257
+ res.end(JSON.stringify({ success: false, error: "Zeitüberschreitung beim Empfang" }));
1258
+ req.destroy();
1259
+ finish(null);
1260
+ }
1261
+ });
1262
+ req.on("data", (chunk) => {
1263
+ if (settled)
1264
+ return;
1265
+ size += chunk.length;
1266
+ if (size > maxBytes) {
1267
+ res.writeHead(413, { "Content-Type": "application/json" });
1268
+ res.end(JSON.stringify({ success: false, error: "Anfrage zu groß" }));
1269
+ req.destroy();
1270
+ finish(null);
1271
+ return;
1272
+ }
1273
+ chunks.push(chunk);
1274
+ });
1275
+ req.on("error", () => finish(null));
1276
+ req.on("end", () => {
1277
+ if (!settled)
1278
+ finish(Buffer.concat(chunks).toString("utf8"));
1279
+ });
1280
+ });
1281
+ }
1282
+ startWebServer(port) {
1283
+ try {
1284
+ const requestListener = async (req, res) => {
1285
+ try {
1286
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST");
1287
+ // CORS gezielt statt "*": nur die eigene Origin darf die Antwort lesen.
1288
+ const sameOrigin = this.isSameOrigin(req);
1289
+ if (sameOrigin && req.headers.origin) {
1290
+ res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
1291
+ }
1292
+ if (!this.checkAuth(req)) {
1293
+ res.setHeader("WWW-Authenticate", 'Basic realm="mqtt-plus"');
1294
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
1295
+ res.end("Unauthorized");
1296
+ return;
1297
+ }
1298
+ // CSRF-Schutz: zustandsändernde Requests nur, wenn Origin/Referer zum eigenen
1299
+ // Host passt. Basic-Auth wird vom Browser automatisch mitgeschickt - ohne
1300
+ // diese Prüfung könnte jede fremde Webseite die Mapping-Konfiguration
1301
+ // überschreiben.
1302
+ if (req.method === "POST" && !sameOrigin) {
1303
+ res.writeHead(403, { "Content-Type": "application/json" });
1304
+ res.end(JSON.stringify({ success: false, error: "Cross-Origin-Request abgelehnt" }));
1305
+ return;
1306
+ }
1307
+ // Pfad statt rohem req.url vergleichen - sonst bricht jeder Query-String (?t=123)
1308
+ const pathname = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`).pathname;
1309
+ if (pathname === "/" || pathname === "/index.html") {
1310
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
1311
+ res.end(this.getDashboardHtml());
1312
+ }
1313
+ else if (pathname === "/api/json") {
1314
+ const tree = await this.generateJsonTree();
1315
+ const exportData = {
1316
+ prefix: this.config.targetBasePath,
1317
+ mappings: this.config.mappings || [],
1318
+ structure: tree
1319
+ };
1320
+ res.writeHead(200, { "Content-Type": "application/json" });
1321
+ res.end(JSON.stringify(exportData, null, 2));
1322
+ }
1323
+ else if (pathname === "/api/status") {
1324
+ const syncTemplateState = await this.getStateAsync("config.syncTemplate");
1325
+ const status = {
1326
+ watchdog: this.currentWatchdogStatus,
1327
+ uptime: process.uptime(),
1328
+ mappings: this.config.mappings ? this.config.mappings.length : 0,
1329
+ syncTemplate: syncTemplateState ? syncTemplateState.val : this.getDefaultSyncTemplate()
1330
+ };
1331
+ res.writeHead(200, { "Content-Type": "application/json" });
1332
+ res.end(JSON.stringify(status));
1333
+ }
1334
+ else if (pathname === "/api/save-template" && req.method === "POST") {
1335
+ const body = await this.readBodyLimited(req, res, MqttPlus.MAX_BODY_BYTES);
1336
+ if (body === null)
1337
+ return; // Fehlerantwort wurde bereits gesendet
1338
+ try {
1339
+ const data = JSON.parse(body);
1340
+ if (data.template) {
1341
+ await this.setStateAsync("config.syncTemplate", data.template, true);
1342
+ this.log.info("Neues Remote-Sync Template gespeichert.");
1343
+ res.writeHead(200, { "Content-Type": "application/json" });
1344
+ res.end(JSON.stringify({ success: true }));
1345
+ }
1346
+ else {
1347
+ throw new Error("Kein Template empfangen");
1348
+ }
1349
+ }
1350
+ catch (e) {
1351
+ res.writeHead(500, { "Content-Type": "application/json" });
1352
+ res.end(JSON.stringify({ success: false, error: e.message }));
1353
+ }
1354
+ }
1355
+ else if (pathname === "/api/upload-backup" && req.method === "POST") {
1356
+ const body = await this.readBodyLimited(req, res, MqttPlus.MAX_BODY_BYTES);
1357
+ if (body === null)
1358
+ return; // Fehlerantwort wurde bereits gesendet
1359
+ try {
1360
+ const uploaded = JSON.parse(body);
1361
+ if (uploaded && Array.isArray(uploaded.mappings)) {
1362
+ this.log.info(`Restore gestartet! ${uploaded.mappings.length} Mappings gefunden.`);
1363
+ const adapterObj = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`);
1364
+ if (adapterObj) {
1365
+ adapterObj.native.mappings = uploaded.mappings;
1366
+ if (uploaded.prefix) {
1367
+ adapterObj.native.targetBasePath = uploaded.prefix;
1368
+ this.log.info(`Prefix auf ${uploaded.prefix} gesetzt.`);
1369
+ }
1370
+ await this.setForeignObjectAsync(`system.adapter.${this.namespace}`, adapterObj);
1371
+ res.writeHead(200, { "Content-Type": "application/json" });
1372
+ res.end(JSON.stringify({ success: true, message: "Konfiguration wiederhergestellt. Adapter startet neu..." }));
1373
+ }
1374
+ else {
1375
+ throw new Error("Adapter-Objekt nicht gefunden!");
1376
+ }
1377
+ }
1378
+ else {
1379
+ throw new Error("Ungültiges Dateiformat. 'mappings' Array fehlt.");
1380
+ }
1381
+ }
1382
+ catch (e) {
1383
+ this.log.error(`Restore Fehler: ${e.message}`);
1384
+ res.writeHead(500, { "Content-Type": "application/json" });
1385
+ res.end(JSON.stringify({ success: false, error: e.message }));
1386
+ }
1387
+ }
1388
+ else {
1389
+ res.writeHead(404);
1390
+ res.end("Not found");
1391
+ }
1392
+ }
1393
+ catch (e) {
1394
+ // Ohne dieses catch würde ein Fehler in generateJsonTree() o.ä. nie eine
1395
+ // Antwort senden - der Client hinge bis zum eigenen Timeout.
1396
+ this.log.error(`[Dashboard] Unerwarteter Fehler: ${e.message}`);
1397
+ if (!res.headersSent) {
1398
+ res.writeHead(500, { "Content-Type": "application/json" });
1399
+ res.end(JSON.stringify({ success: false, error: "Interner Fehler" }));
1400
+ }
1401
+ }
1402
+ };
1403
+ // Optionales HTTPS: ohne Zertifikat/Key läuft der Server wie bisher über HTTP -
1404
+ // dann werden Basic-Auth-Zugangsdaten aber unverschlüsselt übertragen (siehe Warnung
1405
+ // in onReady). Mit beiden Feldern gesetzt wird verschlüsselt.
1406
+ let usesTls = false;
1407
+ if (this.config.dashboardTlsCert && this.config.dashboardTlsKey) {
1408
+ try {
1409
+ const cert = this.normalizePem(this.config.dashboardTlsCert);
1410
+ const key = this.normalizePem(this.config.dashboardTlsKey);
1411
+ this.httpServer = https.createServer({ cert, key }, requestListener);
1412
+ usesTls = true;
1413
+ }
1414
+ catch (e) {
1415
+ this.log.error(`[Dashboard] TLS-Zertifikat/Key ungültig, falle auf HTTP zurück: ${e.message}`);
1416
+ }
1417
+ }
1418
+ if (!this.httpServer) {
1419
+ this.httpServer = http.createServer(requestListener);
1420
+ }
1421
+ this.httpServer.on("connection", (socket) => {
1422
+ this.activeSockets.add(socket);
1423
+ socket.on("close", () => this.activeSockets.delete(socket));
1424
+ });
1425
+ const bindHost = this.config.bindHost || "0.0.0.0";
1426
+ this.httpServer.listen(port, bindHost, () => {
1427
+ this.log.info(`Dashboard Webserver läuft auf ${usesTls ? "https" : "http"}://${bindHost}:${port}`);
1428
+ this.setState("info.connection", true, true);
1429
+ });
1430
+ this.httpServer.on("error", (e) => {
1431
+ this.log.error(`Webserver Fehler: ${e.message}`);
1432
+ this.setState("info.connection", false, true);
1433
+ if (e.code === "EADDRINUSE") {
1434
+ this.log.error(`Port ${port} ist bereits belegt - Adapter wird beendet, damit er nicht "grün" ohne Dashboard weiterläuft.`);
1435
+ if (typeof this.terminate === "function") {
1436
+ this.terminate("EADDRINUSE", 11);
1437
+ }
1438
+ else {
1439
+ process.exit(1);
1440
+ }
1441
+ }
1442
+ });
1443
+ }
1444
+ catch (e) {
1445
+ this.log.error(`Konnte Webserver nicht starten: ${e.message}`);
1446
+ this.setState("info.connection", false, true);
1447
+ }
1448
+ }
1449
+ getDashboardHtml() {
1450
+ return `
1451
+ <!DOCTYPE html>
1452
+ <html lang="de">
1453
+ <head>
1454
+ <meta charset="UTF-8">
1455
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1456
+ <title>MQTT Plus Dashboard</title>
1457
+ <style>
1458
+ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; background: #f0f2f5; color: #333; }
1459
+ .container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
1460
+ h1 { color: #0078d4; border-bottom: 2px solid #0078d4; padding-bottom: 10px; }
1461
+ h2 { margin-top: 30px; color: #444; }
1462
+ .status-bar { display: flex; gap: 20px; margin-bottom: 20px; background: #e6f0ff; padding: 15px; border-radius: 4px; }
1463
+ .status-item { font-weight: bold; }
1464
+ .status-value { color: #0078d4; }
1465
+ textarea { width: 100%; height: 150px; font-family: monospace; padding: 10px; border: 1px solid #ccc; border-radius: 4px; }
1466
+ pre { background: #282c34; color: #abb2bf; padding: 15px; border-radius: 4px; overflow: auto; max-height: 500px; }
1467
+ button { background: #0078d4; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 14px; margin-right: 10px; }
1468
+ button:hover { background: #005a9e; }
1469
+ button.secondary { background: #6c757d; }
1470
+ button.secondary:hover { background: #5a6268; }
1471
+ .help { font-size: 0.9em; color: #666; margin-top: 5px; }
1472
+ .upload-area { border: 2px dashed #ccc; padding: 20px; text-align: center; margin-bottom: 20px; border-radius: 4px; }
1473
+ </style>
1474
+ </head>
1475
+ <body>
1476
+ <div class="container">
1477
+ <h1>MQTT Plus Dashboard</h1>
1478
+
1479
+ <div class="status-bar">
1480
+ <div class="status-item">Watchdog: <span id="wd" class="status-value">Lade...</span></div>
1481
+ <div class="status-item">Mappings: <span id="map" class="status-value">0</span></div>
1482
+ <div class="status-item">Uptime: <span id="up" class="status-value">0s</span></div>
1483
+ </div>
1484
+
1485
+ <h2>Backup & Restore</h2>
1486
+ <div class="upload-area">
1487
+ <button onclick="downloadJson()">Download Backup (.json)</button>
1488
+ <span style="margin: 0 15px;">|</span>
1489
+ <input type="file" id="restoreFile" accept=".json" />
1490
+ <button class="secondary" onclick="uploadBackup()">Backup Wiederherstellen</button>
1491
+ </div>
1492
+
1493
+ <h2>JSON Struktur Vorschau</h2>
1494
+ <div style="margin-bottom: 10px;">
1495
+ <button class="secondary" onclick="loadJson()">Vorschau Aktualisieren</button>
1496
+ </div>
1497
+ <pre id="jsonViewer">Lade Daten...</pre>
1498
+
1499
+ <h2>Remote Sync Konfiguration</h2>
1500
+ <p>Hier können Sie definieren, wie das JSON für den Remote-Sync (POST Request) aussieht.</p>
1501
+ <div class="help">Platzhalter: %ID%, %MQTT%, %VAL%, %TS% (letzte Aktualisierung), %LC% (letzte Änderung), %ACK%, %Q% (Qualität), %UNIT%, %PREFIX%, %DIR%</div>
1502
+ <textarea id="templateEditor"></textarea>
1503
+ <div style="margin-top: 10px;">
1504
+ <button onclick="saveTemplate()">Template Speichern</button>
1505
+ <button class="secondary" onclick="resetTemplate()">Standard wiederherstellen</button>
1506
+ </div>
1507
+ </div>
1508
+
1509
+ <script>
1510
+ const defaultTemplate = '{"id": "%ID%", "topic": "%MQTT%", "value": %VAL%, "ts": %TS%, "unit": "%UNIT%", "prefix": "%PREFIX%", "dir": "%DIR%"}';
1511
+
1512
+ async function loadStatus() {
1513
+ try {
1514
+ const res = await fetch('/api/status');
1515
+ const data = await res.json();
1516
+ document.getElementById('wd').innerText = data.watchdog;
1517
+ document.getElementById('map').innerText = data.mappings;
1518
+ document.getElementById('up').innerText = Math.round(data.uptime) + 's';
1519
+ if (!document.getElementById('templateEditor').value) {
1520
+ document.getElementById('templateEditor').value = data.syncTemplate || defaultTemplate;
1521
+ }
1522
+ } catch(e) { console.error(e); }
1523
+ }
1524
+
1525
+ async function loadJson() {
1526
+ try {
1527
+ document.getElementById('jsonViewer').innerText = "Lade...";
1528
+ const res = await fetch('/api/json');
1529
+ const data = await res.json();
1530
+ document.getElementById('jsonViewer').innerText = JSON.stringify(data.structure, null, 4);
1531
+ window.lastJson = data;
1532
+ } catch(e) {
1533
+ document.getElementById('jsonViewer').innerText = "Fehler beim Laden: " + e;
1534
+ }
1535
+ }
1536
+
1537
+ function downloadJson() {
1538
+ if(!window.lastJson) {
1539
+ fetch('/api/json').then(r => r.json()).then(data => {
1540
+ window.lastJson = data;
1541
+ executeDownload();
1542
+ });
1543
+ } else {
1544
+ executeDownload();
1545
+ }
1546
+ }
1547
+
1548
+ function executeDownload() {
1549
+ const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(window.lastJson, null, 4));
1550
+ const downloadAnchorNode = document.createElement('a');
1551
+ downloadAnchorNode.setAttribute("href", dataStr);
1552
+ downloadAnchorNode.setAttribute("download", "mqtt_plus_backup.json");
1553
+ document.body.appendChild(downloadAnchorNode);
1554
+ downloadAnchorNode.click();
1555
+ downloadAnchorNode.remove();
1556
+ }
1557
+
1558
+ async function uploadBackup() {
1559
+ const fileInput = document.getElementById('restoreFile');
1560
+ if(fileInput.files.length === 0) return alert("Bitte erst eine Datei auswählen!");
1561
+
1562
+ const file = fileInput.files[0];
1563
+ const reader = new FileReader();
1564
+
1565
+ reader.onload = async function(e) {
1566
+ try {
1567
+ const jsonContent = e.target.result;
1568
+ const parsed = JSON.parse(jsonContent);
1569
+ if(!parsed.mappings) throw new Error("Keine Mappings in Datei gefunden!");
1570
+
1571
+ if(!confirm("ACHTUNG: Dies überschreibt die aktuelle Konfiguration und startet den Adapter neu. Fortfahren?")) return;
1572
+
1573
+ const res = await fetch('/api/upload-backup', {
1574
+ method: 'POST',
1575
+ body: jsonContent
1576
+ });
1577
+ const ret = await res.json();
1578
+
1579
+ if(ret.success) {
1580
+ alert(ret.message);
1581
+ location.reload();
1582
+ } else {
1583
+ alert("Fehler beim Restore: " + ret.error);
1584
+ }
1585
+ } catch(err) {
1586
+ alert("Dateifehler: " + err);
1587
+ }
1588
+ };
1589
+ reader.readAsText(file);
1590
+ }
1591
+
1592
+ async function saveTemplate() {
1593
+ const tpl = document.getElementById('templateEditor').value;
1594
+ try {
1595
+ const res = await fetch('/api/save-template', {
1596
+ method: 'POST',
1597
+ headers: {'Content-Type': 'application/json'},
1598
+ body: JSON.stringify({template: tpl})
1599
+ });
1600
+ const ret = await res.json();
1601
+ if(ret.success) alert("Gespeichert!");
1602
+ else alert("Fehler: " + ret.error);
1603
+ } catch(e) { alert("Sende-Fehler: " + e); }
1604
+ }
1605
+
1606
+ function resetTemplate() {
1607
+ document.getElementById('templateEditor').value = defaultTemplate;
1608
+ }
1609
+
1610
+ loadStatus();
1611
+ loadJson();
1612
+ setInterval(loadStatus, 5000);
1613
+ </script>
1614
+ </body>
1615
+ </html>
1616
+ `;
1617
+ }
1618
+ getDefaultSyncTemplate() {
1619
+ return '{"id": "%ID%", "topic": "%MQTT%", "value": %VAL%, "ts": %TS%, "unit": "%UNIT%", "prefix": "%PREFIX%", "dir": "%DIR%"}';
1620
+ }
1621
+ // Schreibt den Watchdog-Status nur bei Änderung oder maximal alle 30s - verhindert
1622
+ // hunderte States-DB-Schreibvorgänge pro Minute bei aktivem Datenverkehr (z.B. auf SD-Karte).
1623
+ // Ausnahme: info.lastCycle bei isCycleEnd=true (echter Zyklusabschluss) wird immer sofort
1624
+ // geschrieben, sonst könnte der Zeitstempel bei kurzen Intervallen ohne Statuswechsel
1625
+ // zwischen zwei Zyklen bis zu 30s hinter dem tatsächlichen letzten Zyklus zurückbleiben.
1626
+ updateWatchdog(status, isCycleEnd = false) {
1627
+ const now = Date.now();
1628
+ if (isCycleEnd) {
1629
+ this.setState("info.lastCycle", now, true);
1630
+ }
1631
+ const changed = status !== this.lastWatchdogStatus;
1632
+ if (!changed && now - this.lastWatchdogWriteTs < 30000)
1633
+ return;
1634
+ this.lastWatchdogStatus = status;
1635
+ this.lastWatchdogWriteTs = now;
1636
+ this.currentWatchdogStatus = `${status} (${new Date().toLocaleTimeString()})`;
1637
+ this.setState("watchdog", this.currentWatchdogStatus, true);
1638
+ // Maschinenlesbare Variante zusätzlich zum lokalisierten Text-State: reiner Status-String
1639
+ // plus Unix-Timestamp statt serverlokalem toLocaleTimeString().
1640
+ this.setState("info.status", status, true);
1641
+ if (!isCycleEnd) {
1642
+ this.setState("info.lastCycle", now, true);
1643
+ }
1644
+ }
1645
+ async onUnload(callback) {
1646
+ this.unloaded = true;
1647
+ try {
1648
+ [this.updateInterval, this.forceSyncInterval, this.syncInterval].forEach(i => {
1649
+ if (i)
1650
+ this.clearInterval(i);
1651
+ });
1652
+ if (this.httpServer) {
1653
+ for (const socket of this.activeSockets)
1654
+ socket.destroy();
1655
+ this.activeSockets.clear();
1656
+ await new Promise(resolve => this.httpServer.close(() => resolve()));
1657
+ }
1658
+ try {
1659
+ const allIds = new Set([...this.sourceToMappings.keys(), ...this.targetToMappings.keys()]);
1660
+ if (allIds.size > 0) {
1661
+ await this.unsubscribeForeignStatesAsync([...allIds]);
1662
+ }
1663
+ }
1664
+ catch (e) {
1665
+ this.log.debug(`Unsubscribe-Fehler: ${e.message}`);
1666
+ }
1667
+ await this.setStateAsync("info.connection", false, true);
1668
+ this.log.info("cleaned everything up...");
1669
+ callback();
1670
+ }
1671
+ catch (e) {
1672
+ this.log.error(`Fehler beim Herunterfahren: ${e.message}`);
1673
+ callback();
1674
+ }
1675
+ }
1676
+ }
1677
+ // Obergrenze für POST-Bodies (Backup-Upload / Sync-Template) gegen Memory-Exhaustion
1678
+ MqttPlus.MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
1679
+ MqttPlus.MAX_AUTH_ATTEMPTS = 10;
1680
+ MqttPlus.AUTH_LOCKOUT_MS = 5 * 60 * 1000; // 5 Minuten
1681
+ MqttPlus.REMOTE_SYNC_CHUNK_SIZE = 200;
1682
+ // Sicherheitsnetz für pendingWrites: falls auf einen eigenen Schreibvorgang nie ein
1683
+ // (echtes oder Echo-)Ereignis folgt, verfällt der Merker statt für immer liegenzubleiben.
1684
+ MqttPlus.PENDING_WRITE_TTL_MS = 10000;
1685
+ // Standard-Aktualitätsgrenze, falls in der Instanz (z.B. nach Update von <1.6.0) nichts gesetzt ist.
1686
+ MqttPlus.DEFAULT_STALE_AFTER_MIN = 1440; // 24 h
1687
+ if (require.main !== module) {
1688
+ module.exports = (options) => new MqttPlus(options);
1689
+ }
1690
+ else {
1691
+ (() => new MqttPlus())();
1692
+ }
1693
+ //# sourceMappingURL=main.js.map