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/LICENSE +21 -0
- package/README.md +124 -0
- package/admin/jsonConfig.json +358 -0
- package/admin/mqtt-plus.png +0 -0
- package/admin/readme.md +359 -0
- package/build/main.d.ts +38 -0
- package/build/main.js +1693 -0
- package/build/main.js.map +1 -0
- package/io-package.json +207 -0
- package/package.json +48 -0
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
|