iobroker.waip-web 0.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js ADDED
@@ -0,0 +1,1751 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * ioBroker.WAIP-Web
5
+ *
6
+ * Verbindet sich mit einem WAIP-Wachalarm-Monitor (Socket.IO) und bildet
7
+ * Einsätze, Rückmeldungen, Routen und TTS-Ansagen als ioBroker-States ab.
8
+ *
9
+ * Ported from the original "WAIP Instrumented" ioBroker-JavaScript-Adapter
10
+ * script into a standalone adapter: URL/Monitor-ID kommen jetzt aus der
11
+ * Admin-Konfiguration statt aus einem Laufzeit-State.
12
+ *
13
+ * Objektstruktur (Stand 0.4.0): Rückmeldungen und Routen sind pro Einsatz
14
+ * Listen (1:n) und liegen deshalb als verschachtelte JSON-Arrays innerhalb
15
+ * von einsatz.json bzw. jedem Eintrag von einsatz.history10 - ergänzt um
16
+ * schnell bindbare Zähler (einsatz.routenGesamt, einsatz.rueckmeldungGesamt,
17
+ * einsatz.rueckmeldungAnzahl.*). Der frühere vis.*-Kanal entfällt komplett.
18
+ */
19
+
20
+ const https = require('https');
21
+ const http = require('http');
22
+ const { URL } = require('url');
23
+ const utils = require('@iobroker/adapter-core');
24
+ const { io } = require('socket.io-client');
25
+
26
+ const DEFAULT_URL = 'https://wachalarm.leitstelle-lausitz.de';
27
+ const DEFAULT_SESSION_KEEPALIVE_SEC = 300; // Obergrenze, wie /js/session_keepalive.js der Seite selbst
28
+ // Untergrenze fürs Keepalive-Intervall, analog zur Klammerung in /js/session_keepalive.js
29
+ // (min(max(maxAge*0.8, 55s), Obergrenze)). Die tatsächliche Cookie-Laufzeit ist je
30
+ // WAIP-Web-Instanz per ENV konfigurierbar (Server-Default lt. server/app_cfg.js: 60s!) -
31
+ // deshalb wird das Intervall unten adaptiv aus der vom Server gemeldeten Ablaufzeit
32
+ // berechnet, statt einen festen Wert anzunehmen.
33
+ const SESSION_KEEPALIVE_MIN_MS = 55 * 1000;
34
+ const HISTORY_SIZE = 10;
35
+ const ALLOWED_EINSATZ_FIELDS = [
36
+ 'id',
37
+ 'uuid',
38
+ 'einsatzart',
39
+ 'stichwort',
40
+ 'ort',
41
+ 'ortsteil',
42
+ 'ablaufzeit',
43
+ 'sondersignal',
44
+ // laut client_waip.js (offizielles Frontend) zusätzlich vorhandene Felder:
45
+ 'zeitstempel',
46
+ 'einsatznummer',
47
+ 'objekt',
48
+ 'objektteil',
49
+ 'strasse',
50
+ 'hausnummer',
51
+ 'einsatzdetails',
52
+ 'besonderheiten',
53
+ 'permissions',
54
+ ];
55
+ const RUECKMELDUNG_ANZAHL_KEYS = ['ek', 'gf', 'zf', 'vf', 'agt', 'fzf', 'ma', 'med'];
56
+ const DISCONNECT_DEDUPE_MS = 60000; // suppress identical disconnect logs for 60s
57
+ const WARN_DEDUPE_MS = 5000;
58
+
59
+ /* Für die dynamische Monitor-Auswahl im Admin (siehe fetchMonitorList/onMessage):
60
+ Die /waip/-Übersichtsseite einer WAIP-Web-Instanz gliedert die verfügbaren
61
+ Monitore typischerweise in diese vier Überschriften. Nicht jede Instanz nutzt
62
+ zwingend exakt diese Gliederung - findet fetchMonitorList() keine davon, wird
63
+ die komplette Seite als eine einzige, unkategorisierte Liste geparst. */
64
+ const MONITOR_CATEGORY_HEADINGS = [
65
+ { key: 'leitstelle', re: /Alarmmonitor\s+Leitstelle/i },
66
+ { key: 'kreis', re: /Alarmmonitor\s+Kreis/i },
67
+ { key: 'traeger', re: /Alarmmonitor\s+Tr(?:ä|ä)ger/i },
68
+ { key: 'wache', re: /Alarmmonitor\s+Wache/i },
69
+ ];
70
+ const MONITOR_CATEGORY_LABELS = { leitstelle: 'Leitstelle', kreis: 'Kreis', traeger: 'Träger', wache: 'Wache' };
71
+
72
+ // State-Objekte, die beim Start aus früheren Versionen entfernt werden (Struktur-Migration).
73
+ const OBSOLETE_OBJECT_IDS = [
74
+ 'json.raw',
75
+ 'json.einsatz',
76
+ 'vis.fahrzeugTabelle',
77
+ 'vis.einsatzTabelle',
78
+ 'vis.rueckmeldungenTabelle',
79
+ 'geo.latitude',
80
+ 'geo.longitude',
81
+ 'geo.position',
82
+ 'history.last10',
83
+ 'einsatz.emWeitere',
84
+ 'rueckmeldung.last.json',
85
+ 'rueckmeldung.counts.ek',
86
+ 'rueckmeldung.counts.gf',
87
+ 'rueckmeldung.counts.zf',
88
+ 'rueckmeldung.counts.vf',
89
+ 'rueckmeldung.counts.agt',
90
+ 'rueckmeldung.counts.fzf',
91
+ 'rueckmeldung.counts.ma',
92
+ 'rueckmeldung.counts.med',
93
+ 'rueckmeldung.counts.gesamt',
94
+ 'routen.json',
95
+ 'routen.count',
96
+ ];
97
+
98
+ // Definition aller States, die beim Start sichergestellt werden.
99
+ const STATE_DEFS = [
100
+ {
101
+ id: 'status.connected',
102
+ type: 'boolean',
103
+ role: 'indicator.reachable',
104
+ name: 'Verbunden mit WAIP-Server',
105
+ def: false,
106
+ },
107
+ { id: 'status.alarmAktiv', type: 'boolean', role: 'indicator.alarm', name: 'Alarm aktiv', def: false },
108
+ {
109
+ id: 'status.restzeit',
110
+ type: 'number',
111
+ role: 'value.interval',
112
+ name: 'Restzeit bis Einsatzende',
113
+ unit: 's',
114
+ def: 0,
115
+ },
116
+ { id: 'status.registeredMonitor', type: 'string', role: 'text', name: 'Aktuell registrierte Monitor-ID' },
117
+ {
118
+ id: 'status.registeredMonitorName',
119
+ type: 'string',
120
+ role: 'text',
121
+ name: 'Aktuell registrierter Monitor (Text, ohne ID)',
122
+ },
123
+ {
124
+ id: 'status.registrationAccepted',
125
+ type: 'mixed',
126
+ role: 'indicator',
127
+ name: 'Registrierung bestätigt (true/false/pending)',
128
+ },
129
+ { id: 'debug.lastEvent', type: 'string', role: 'json', name: 'Letztes empfangenes Socket-Event' },
130
+ { id: 'debug.normalizedPosition', type: 'string', role: 'json', name: 'Letzte normalisierte Position' },
131
+ { id: 'debug.rawPayloadShort', type: 'string', role: 'text', name: 'Rohdaten-Vorschau (500 Zeichen)' },
132
+ {
133
+ id: 'debug.ignoredCount',
134
+ type: 'number',
135
+ role: 'value',
136
+ name: 'Anzahl ignorierter Events (explizit falsches Monitor-Feld im Payload)',
137
+ def: 0,
138
+ },
139
+ { id: 'debug.monitorAudit', type: 'string', role: 'json', name: 'Monitor-Audit-Log (letzte 200 Einträge)' },
140
+ { id: 'debug.sessionExpires', type: 'string', role: 'date', name: 'Session-Cookie gültig bis (letzte Erneuerung)' },
141
+ { id: 'debug.lastError', type: 'string', role: 'json', name: 'Letzte Server-Fehlermeldung (io.error)' },
142
+ {
143
+ id: 'debug.serverVersion',
144
+ type: 'string',
145
+ role: 'text',
146
+ name: 'Zuletzt gemeldete Server-Version/Instanz-ID (io.version)',
147
+ },
148
+ // flache Felder des aktuellen Einsatzes
149
+ { id: 'einsatz.id', type: 'number', role: 'value', name: 'Einsatz ID' },
150
+ { id: 'einsatz.uuid', type: 'string', role: 'text', name: 'Einsatz UUID' },
151
+ { id: 'einsatz.einsatzart', type: 'string', role: 'text', name: 'Einsatzart' },
152
+ { id: 'einsatz.stichwort', type: 'string', role: 'text', name: 'Alarmstichwort' },
153
+ { id: 'einsatz.ort', type: 'string', role: 'text', name: 'Ort' },
154
+ { id: 'einsatz.ortsteil', type: 'string', role: 'text', name: 'Ortsteil' },
155
+ { id: 'einsatz.strasse', type: 'string', role: 'text', name: 'Straße' },
156
+ { id: 'einsatz.hausnummer', type: 'string', role: 'text', name: 'Hausnummer' },
157
+ { id: 'einsatz.objekt', type: 'string', role: 'text', name: 'Objekt' },
158
+ { id: 'einsatz.objektteil', type: 'string', role: 'text', name: 'Objektteil' },
159
+ { id: 'einsatz.einsatzdetails', type: 'string', role: 'text', name: 'Einsatzdetails' },
160
+ { id: 'einsatz.besonderheiten', type: 'string', role: 'text', name: 'Besonderheiten' },
161
+ { id: 'einsatz.zeitstempel', type: 'string', role: 'date', name: 'Alarmzeitstempel' },
162
+ { id: 'einsatz.ablaufzeit', type: 'string', role: 'date', name: 'Ablaufzeit' },
163
+ { id: 'einsatz.einsatznummer', type: 'string', role: 'text', name: 'Einsatznummer' },
164
+ { id: 'einsatz.sondersignal', type: 'number', role: 'value', name: 'Sondersignal', def: 0 },
165
+ { id: 'einsatz.permissions', type: 'mixed', role: 'json', name: 'Berechtigungs-Flag der Registrierung' },
166
+ { id: 'einsatz.latitude', type: 'number', role: 'value.gps.latitude', name: 'Breitengrad' },
167
+ { id: 'einsatz.longitude', type: 'number', role: 'value.gps.longitude', name: 'Längengrad' },
168
+ // verschachteltes Gesamtobjekt + Historie
169
+ {
170
+ id: 'einsatz.json',
171
+ type: 'string',
172
+ role: 'json',
173
+ name: 'Vollständiger aktueller Einsatz inkl. Einsatzmittel/Routen/Rückmeldungen (JSON)',
174
+ },
175
+ {
176
+ id: 'einsatz.history10',
177
+ type: 'string',
178
+ role: 'json',
179
+ name: `Letzte ${HISTORY_SIZE} abgeschlossene Einsätze, gleicher Objekt-Shape wie einsatz.json (JSON-Array)`,
180
+ },
181
+ // abgeleitete Zähler
182
+ { id: 'einsatz.routenGesamt', type: 'number', role: 'value', name: 'Anzahl Routen im aktuellen Einsatz', def: 0 },
183
+ {
184
+ id: 'einsatz.rueckmeldungGesamt',
185
+ type: 'number',
186
+ role: 'value',
187
+ name: 'Rückmeldungen gesamt im aktuellen Einsatz',
188
+ def: 0,
189
+ },
190
+ {
191
+ id: 'einsatz.rueckmeldungAnzahl.ek',
192
+ type: 'number',
193
+ role: 'value',
194
+ name: 'Rückmeldungen: Einsatzkräfte',
195
+ def: 0,
196
+ },
197
+ {
198
+ id: 'einsatz.rueckmeldungAnzahl.gf',
199
+ type: 'number',
200
+ role: 'value',
201
+ name: 'Rückmeldungen: Gruppenführer',
202
+ def: 0,
203
+ },
204
+ { id: 'einsatz.rueckmeldungAnzahl.zf', type: 'number', role: 'value', name: 'Rückmeldungen: Zugführer', def: 0 },
205
+ {
206
+ id: 'einsatz.rueckmeldungAnzahl.vf',
207
+ type: 'number',
208
+ role: 'value',
209
+ name: 'Rückmeldungen: Verbandsführer',
210
+ def: 0,
211
+ },
212
+ {
213
+ id: 'einsatz.rueckmeldungAnzahl.agt',
214
+ type: 'number',
215
+ role: 'value',
216
+ name: 'Rückmeldungen: Atemschutzgeräteträger',
217
+ def: 0,
218
+ },
219
+ {
220
+ id: 'einsatz.rueckmeldungAnzahl.fzf',
221
+ type: 'number',
222
+ role: 'value',
223
+ name: 'Rückmeldungen: Fahrzeugführer',
224
+ def: 0,
225
+ },
226
+ { id: 'einsatz.rueckmeldungAnzahl.ma', type: 'number', role: 'value', name: 'Rückmeldungen: Maschinisten', def: 0 },
227
+ {
228
+ id: 'einsatz.rueckmeldungAnzahl.med',
229
+ type: 'number',
230
+ role: 'value',
231
+ name: 'Rückmeldungen: Medizinisch/Sanitäter',
232
+ def: 0,
233
+ },
234
+ // TTS
235
+ { id: 'tts.last', type: 'string', role: 'json', name: 'Letzte TTS-Ansage (JSON)' },
236
+ { id: 'tts.lastTimestamp', type: 'string', role: 'date', name: 'Zeitstempel letzte TTS-Ansage' },
237
+ { id: 'tts.history10', type: 'string', role: 'json', name: `Letzte ${HISTORY_SIZE} TTS-Ansagen (JSON-Array)` },
238
+ ];
239
+
240
+ /* Prüft ob eine monitorID gültig ist (nicht-leer). */
241
+ function isValidMonitor(mon) {
242
+ if (mon === undefined || mon === null) {
243
+ return false;
244
+ }
245
+ return String(mon).trim() !== '';
246
+ }
247
+
248
+ /*
249
+ Robust: akzeptiert Geometry-Objekt oder JSON-String, Feature oder Geometry,
250
+ und handhabt Fälle, in denen geometry.geometry als String kodiert ist.
251
+ Gibt null zurück, wenn keine valide Position gefunden oder 0/0 ermittelt wurde.
252
+ */
253
+ function getCenterFromGeometry(g) {
254
+ try {
255
+ if (!g) {
256
+ return null;
257
+ }
258
+ let parsed = g;
259
+ if (typeof parsed === 'string') {
260
+ try {
261
+ parsed = JSON.parse(parsed);
262
+ } catch {
263
+ /* leave as string */
264
+ }
265
+ }
266
+ const geomCandidate = parsed?.geometry ?? parsed;
267
+ let geom = geomCandidate;
268
+ if (!geom) {
269
+ return null;
270
+ }
271
+ if (typeof geom === 'string') {
272
+ try {
273
+ geom = JSON.parse(geom);
274
+ } catch {
275
+ /* cannot parse */
276
+ }
277
+ }
278
+ if (!geom || !geom.type || !geom.coordinates) {
279
+ return null;
280
+ }
281
+
282
+ const coords = geom.coordinates;
283
+ const collectPoints = (c, type) => {
284
+ const pts = [];
285
+ const pushIfPoint = p => {
286
+ if (!Array.isArray(p) || p.length < 2) {
287
+ return;
288
+ }
289
+ const lon = Number(p[0]);
290
+ const lat = Number(p[1]);
291
+ if (!isNaN(lat) && !isNaN(lon)) {
292
+ pts.push([lon, lat]);
293
+ }
294
+ };
295
+
296
+ if (type === 'Point') {
297
+ pushIfPoint(c);
298
+ } else if (type === 'LineString' || type === 'MultiPoint') {
299
+ for (const p of c) {
300
+ pushIfPoint(p);
301
+ }
302
+ } else if (type === 'Polygon') {
303
+ for (const ring of c) {
304
+ for (const p of ring) {
305
+ pushIfPoint(p);
306
+ }
307
+ }
308
+ } else if (type === 'MultiPolygon') {
309
+ for (const poly of c) {
310
+ for (const ring of poly) {
311
+ for (const p of ring) {
312
+ pushIfPoint(p);
313
+ }
314
+ }
315
+ }
316
+ } else {
317
+ const flat = Array.isArray(c) ? c.flat(Infinity) : [];
318
+ for (let i = 0; i + 1 < flat.length; i += 2) {
319
+ const a = Number(flat[i]);
320
+ const b = Number(flat[i + 1]);
321
+ if (!isNaN(a) && !isNaN(b)) {
322
+ pts.push([a, b]);
323
+ }
324
+ }
325
+ }
326
+ return pts;
327
+ };
328
+
329
+ const points = collectPoints(coords, geom.type);
330
+ if (!points || !points.length) {
331
+ return null;
332
+ }
333
+
334
+ let minLon = points[0][0];
335
+ let maxLon = points[0][0];
336
+ let minLat = points[0][1];
337
+ let maxLat = points[0][1];
338
+ for (const p of points) {
339
+ if (!Array.isArray(p) || p.length < 2) {
340
+ continue;
341
+ }
342
+ minLon = Math.min(minLon, p[0]);
343
+ maxLon = Math.max(maxLon, p[0]);
344
+ minLat = Math.min(minLat, p[1]);
345
+ maxLat = Math.max(maxLat, p[1]);
346
+ }
347
+ const lat = Number(((minLat + maxLat) / 2).toFixed(6));
348
+ const lon = Number(((minLon + maxLon) / 2).toFixed(6));
349
+ if (lat === 0 && lon === 0) {
350
+ return null;
351
+ }
352
+ return { lat, lon };
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
357
+
358
+ /*
359
+ Normalisiert Payload:
360
+ - priorisiert wgs84_x/wgs84_y (wenn nicht 0/0),
361
+ - akzeptiert data.position falls nicht 0/0,
362
+ - fällt auf geometry (auch stringified) zurück.
363
+ - entfernt roh-geo Felder und setzt position nur, wenn valide.
364
+ */
365
+ function normalizeData(obj) {
366
+ try {
367
+ if (!obj || typeof obj !== 'object') {
368
+ return obj;
369
+ }
370
+ const data = JSON.parse(JSON.stringify(obj)); // deep clone
371
+ let center = null;
372
+
373
+ if (data.wgs84_x !== undefined && data.wgs84_y !== undefined) {
374
+ // WAIP-Server-Konvention (bestätigt über client_waip.js des offiziellen
375
+ // Frontends: "const lat = data.wgs84_x; const lng = data.wgs84_y;"):
376
+ // wgs84_x = Breitengrad, wgs84_y = Längengrad - NICHT die übliche
377
+ // GIS-Konvention (x=Länge/y=Breite). Absichtlich so übernommen.
378
+ const lat = Number(data.wgs84_x);
379
+ const lon = Number(data.wgs84_y);
380
+ if (!isNaN(lat) && !isNaN(lon) && !(lat === 0 && lon === 0)) {
381
+ center = { lat, lon };
382
+ }
383
+ }
384
+
385
+ if (!center && data.position && data.position.lat !== undefined && data.position.lon !== undefined) {
386
+ const latP = Number(data.position.lat);
387
+ const lonP = Number(data.position.lon);
388
+ if (!isNaN(latP) && !isNaN(lonP) && !(latP === 0 && lonP === 0)) {
389
+ center = { lat: latP, lon: lonP };
390
+ }
391
+ }
392
+
393
+ if (!center && data.geometry) {
394
+ const c = getCenterFromGeometry(data.geometry);
395
+ if (c) {
396
+ center = c;
397
+ }
398
+ }
399
+
400
+ delete data.geometry;
401
+ delete data.wgs84_x;
402
+ delete data.wgs84_y;
403
+ delete data.geojson;
404
+ delete data.geometry_type;
405
+
406
+ if (center) {
407
+ data.position = { lat: center.lat, lon: center.lon };
408
+ } else {
409
+ delete data.position;
410
+ }
411
+
412
+ return data;
413
+ } catch {
414
+ return obj;
415
+ }
416
+ }
417
+
418
+ /* Dekodiert die auf der /waip/-Übersichtsseite vorkommenden HTML-Entities (teils
419
+ benannt wie &auml;, teils numerisch) und normalisiert Whitespace. Für die dynamische
420
+ Monitor-Auswahl im Admin (siehe fetchMonitorList). */
421
+ function decodeHtmlEntities(str) {
422
+ if (!str) {
423
+ return str;
424
+ }
425
+ const named = {
426
+ amp: '&',
427
+ lt: '<',
428
+ gt: '>',
429
+ quot: '"',
430
+ apos: "'",
431
+ nbsp: ' ',
432
+ auml: 'ä',
433
+ ouml: 'ö',
434
+ uuml: 'ü',
435
+ Auml: 'Ä',
436
+ Ouml: 'Ö',
437
+ Uuml: 'Ü',
438
+ szlig: 'ß',
439
+ };
440
+ return str
441
+ .replace(/&(amp|lt|gt|quot|apos|nbsp|auml|ouml|uuml|Auml|Ouml|Uuml|szlig);/g, m => named[m.slice(1, -1)])
442
+ .replace(/&#(\d+);/g, m => String.fromCodePoint(Number(m.slice(2, -1))))
443
+ .replace(/&#x([0-9a-fA-F]+);/gi, m => String.fromCodePoint(parseInt(m.slice(3, -1), 16)))
444
+ .replace(/\s+/g, ' ')
445
+ .trim();
446
+ }
447
+
448
+ class WaipWeb extends utils.Adapter {
449
+ constructor(options) {
450
+ super({
451
+ ...options,
452
+ name: 'waip-web',
453
+ });
454
+
455
+ this.on('ready', this.onReady.bind(this));
456
+ this.on('unload', this.onUnload.bind(this));
457
+ this.on('message', this.onMessage.bind(this));
458
+
459
+ this.socket = null;
460
+ this.currentMonitor = '';
461
+ this.monitorName = null; // Anzeigename des konfigurierten Monitors, siehe refreshMonitorName()
462
+ this.connecting = false;
463
+ this.registrationPending = false;
464
+ this.registrationTimer = null;
465
+ this.reconnectTimer = null;
466
+ this.restzeitInterval = null;
467
+ this.sessionKeepaliveTimer = null;
468
+ this.nextSessionKeepaliveDelayMs = null;
469
+ this.sessionCookie = null;
470
+ this.currentEinsatzUuid = null;
471
+ this.currentEinsatzSnapshot = null; // verschachteltes Objekt -> einsatz.json
472
+ this.ttsHistory = [];
473
+ this.lastServerVersion = null;
474
+
475
+ this._lastDisconnectMsg = null;
476
+ this._lastDisconnectTs = 0;
477
+ this._warnCache = { lastMsg: null, ts: 0 };
478
+ this._lastRestzeit = null;
479
+ this._lastDebugEvent = { event: null, ts: 0 };
480
+
481
+ this.HISTORY_SIZE = HISTORY_SIZE;
482
+ this.ALLOWED_EINSATZ_FIELDS = ALLOWED_EINSATZ_FIELDS;
483
+ }
484
+
485
+ async onReady() {
486
+ this.REGISTRATION_TIMEOUT_MS = (Number(this.config.registrationTimeoutSec) || 10) * 1000;
487
+ this.RECONNECT_DELAY_MS = (Number(this.config.reconnectDelaySec) || 5) * 1000;
488
+ // Obergrenze für das Session-Keepalive-Intervall - bewusst nicht konfigurierbar,
489
+ // analog zum fest einprogrammierten Wert in /js/session_keepalive.js der Website
490
+ // selbst. Das tatsächliche Intervall wird adaptiv ermittelt (siehe refreshSessionCookie).
491
+ this.SESSION_KEEPALIVE_MS = DEFAULT_SESSION_KEEPALIVE_SEC * 1000;
492
+ this.url = (this.config.url || DEFAULT_URL).trim();
493
+ this.monitorID =
494
+ this.config.monitorID !== undefined && this.config.monitorID !== null
495
+ ? String(this.config.monitorID).trim()
496
+ : '';
497
+
498
+ await this.cleanupObsoleteObjects();
499
+ await this.migrateObjectTypes();
500
+ await this.initObjects();
501
+ // Session-Cookie holen, bevor die erste Socket.IO-Verbindung aufgebaut wird
502
+ await this.refreshSessionCookie();
503
+ this.startSessionKeepalive();
504
+ this.startRestzeitInterval();
505
+ // Nicht awaiten - der Alarm-Empfang soll nicht auf diesen (rein informativen)
506
+ // Namens-Lookup warten müssen.
507
+ this.refreshMonitorName().catch(() => {});
508
+ this.connect();
509
+ }
510
+
511
+ onUnload(callback) {
512
+ try {
513
+ this.cleanupSocket();
514
+ if (this.registrationTimer) {
515
+ this.clearTimeout(this.registrationTimer);
516
+ this.registrationTimer = null;
517
+ }
518
+ if (this.reconnectTimer) {
519
+ this.clearTimeout(this.reconnectTimer);
520
+ this.reconnectTimer = null;
521
+ }
522
+ if (this.restzeitInterval) {
523
+ this.clearInterval(this.restzeitInterval);
524
+ this.restzeitInterval = null;
525
+ }
526
+ if (this.sessionKeepaliveTimer) {
527
+ this.clearTimeout(this.sessionKeepaliveTimer);
528
+ this.sessionKeepaliveTimer = null;
529
+ }
530
+ callback();
531
+ } catch {
532
+ callback();
533
+ }
534
+ }
535
+
536
+ /* Entfernt State-Objekte aus früheren Versionen (vis.*, json.*, geo.*, rueckmeldung.counts.*, ...),
537
+ die durch die Umstrukturierung in 0.4.0 ersetzt wurden. setObjectNotExistsAsync legt neue
538
+ Objekte an, löscht aber nie alte - das übernehmen wir hier einmalig beim Start. */
539
+ async cleanupObsoleteObjects() {
540
+ for (const id of OBSOLETE_OBJECT_IDS) {
541
+ try {
542
+ const obj = await this.getObjectAsync(id);
543
+ if (obj) {
544
+ await this.delObjectAsync(id);
545
+ this.log.info(`Veraltetes State-Objekt aus vorheriger Version entfernt: ${id}`);
546
+ }
547
+ } catch {
548
+ /* ignore - Objekt existierte vermutlich nicht */
549
+ }
550
+ }
551
+ }
552
+
553
+ /* Löscht bestehende State-Objekte, deren common.type nicht mehr zur aktuellen
554
+ STATE_DEFS-Definition passt (z.B. weil sich herausstellt, dass der Server ein Feld
555
+ als Zahl statt als String schickt - siehe einsatz.id/einsatz.sondersignal in 0.4.3).
556
+ setObjectNotExistsAsync legt danach in initObjects() ein frisches Objekt mit dem
557
+ korrekten Typ an. Generisch für alle künftigen Typ-Korrekturen, nicht nur diese. */
558
+ async migrateObjectTypes() {
559
+ for (const def of STATE_DEFS) {
560
+ try {
561
+ const obj = await this.getObjectAsync(def.id);
562
+ if (obj && obj.common && obj.common.type && obj.common.type !== def.type) {
563
+ await this.delObjectAsync(def.id);
564
+ this.log.info(
565
+ `State-Objekt mit geändertem Datentyp neu angelegt: ${def.id} (${obj.common.type} -> ${def.type})`,
566
+ );
567
+ }
568
+ } catch {
569
+ /* ignore - Objekt existierte vermutlich noch nicht */
570
+ }
571
+ }
572
+ }
573
+
574
+ async initObjects() {
575
+ for (const def of STATE_DEFS) {
576
+ await this.setObjectNotExistsAsync(def.id, {
577
+ type: 'state',
578
+ common: {
579
+ name: def.name,
580
+ type: def.type,
581
+ role: def.role,
582
+ read: true,
583
+ write: false,
584
+ unit: def.unit,
585
+ def: def.def !== undefined ? def.def : null,
586
+ },
587
+ native: {},
588
+ });
589
+ }
590
+ }
591
+
592
+ /* Einfacher HTTP(S)-GET ohne zusätzliche Dependency; optional mit Cookie-Header. */
593
+ httpGet(targetUrl, cookie) {
594
+ return new Promise((resolve, reject) => {
595
+ let parsed;
596
+ try {
597
+ parsed = new URL(targetUrl);
598
+ } catch (e) {
599
+ reject(e);
600
+ return;
601
+ }
602
+ const client = parsed.protocol === 'http:' ? http : https;
603
+ const headers = { 'User-Agent': 'ioBroker.waip-web' };
604
+ if (cookie) {
605
+ headers.Cookie = cookie;
606
+ }
607
+ const req = client.get(parsed, { headers, timeout: 15000 }, res => {
608
+ let data = '';
609
+ res.on('data', chunk => {
610
+ data += chunk;
611
+ });
612
+ res.on('end', () => resolve({ statusCode: res.statusCode, headers: res.headers, body: data }));
613
+ });
614
+ req.on('error', reject);
615
+ req.on('timeout', () => req.destroy(new Error('timeout')));
616
+ });
617
+ }
618
+
619
+ /* Holt die öffentliche Monitor-Übersichtsseite (/waip/) der übergebenen WAIP-Web-
620
+ Instanz und parst daraus die verfügbaren Monitor-IDs für die Admin-Dropdown-Auswahl
621
+ (siehe onMessage/'getMonitorList'). Rein lesend, erfordert keine Session/Cookie. */
622
+ async fetchMonitorList(baseUrl) {
623
+ const clean = String(baseUrl || '').replace(/\/+$/, '');
624
+ if (!clean) {
625
+ throw new Error('keine WAIP-Server-URL angegeben');
626
+ }
627
+ const res = await this.httpGet(`${clean}/waip/`);
628
+ if (res.statusCode !== 200 || !res.body) {
629
+ throw new Error(`Monitor-Übersicht nicht abrufbar (status ${res.statusCode})`);
630
+ }
631
+ const html = res.body;
632
+
633
+ const LINK_RE = /href="\/waip\/(\d+)"[^>]*>\s*([^<]+?)\s*(?:<|$)/g;
634
+ const extractLinks = section => {
635
+ const out = [];
636
+ let m;
637
+ LINK_RE.lastIndex = 0;
638
+ while ((m = LINK_RE.exec(section))) {
639
+ const label = decodeHtmlEntities(m[2]);
640
+ if (label) {
641
+ out.push({ value: m[1], label });
642
+ }
643
+ }
644
+ return out;
645
+ };
646
+
647
+ // "alle Wachalarme" ist ein eigener Link außerhalb der kategorisierten Listen -
648
+ // wird unabhängig vom Parsing-Erfolg der übrigen Seite immer als erste Option angeboten.
649
+ // Label beginnt jeweils mit der eigentlichen Monitor-ID (z.B. "4 - Leitstelle: Lausitz").
650
+ const result = [{ value: '0', label: '0 - Alle Wachalarme' }];
651
+ const seen = new Set(['0']);
652
+
653
+ const headingMatches = [];
654
+ for (const cat of MONITOR_CATEGORY_HEADINGS) {
655
+ const m = cat.re.exec(html);
656
+ if (m) {
657
+ headingMatches.push({ key: cat.key, index: m.index });
658
+ }
659
+ }
660
+
661
+ if (headingMatches.length) {
662
+ headingMatches.sort((a, b) => a.index - b.index);
663
+ for (let i = 0; i < headingMatches.length; i++) {
664
+ const start = headingMatches[i].index;
665
+ const end = i + 1 < headingMatches.length ? headingMatches[i + 1].index : html.length;
666
+ const sectionLabel = MONITOR_CATEGORY_LABELS[headingMatches[i].key] || headingMatches[i].key;
667
+ for (const link of extractLinks(html.slice(start, end))) {
668
+ if (!seen.has(link.value)) {
669
+ seen.add(link.value);
670
+ result.push({ value: link.value, label: `${link.value} - ${sectionLabel}: ${link.label}` });
671
+ }
672
+ }
673
+ }
674
+ } else {
675
+ // Keine der bekannten Überschriften gefunden -> gesamte Seite unkategorisiert parsen,
676
+ // damit die Auswahl auch bei abweichend strukturierten WAIP-Web-Instanzen funktioniert.
677
+ for (const link of extractLinks(html)) {
678
+ if (!seen.has(link.value)) {
679
+ seen.add(link.value);
680
+ result.push({ value: link.value, label: `${link.value} - ${link.label}` });
681
+ }
682
+ }
683
+ }
684
+
685
+ // Nach numerischer Monitor-ID sortiert statt in der (je nach Kategorie/Instanz
686
+ // unterschiedlichen) Reihenfolge der Quellseite - '0' (Alle Wachalarme) landet
687
+ // dabei automatisch an erster Stelle, da alle echten Monitor-IDs größer sind.
688
+ result.sort((a, b) => Number(a.value) - Number(b.value));
689
+
690
+ return result;
691
+ }
692
+
693
+ /* Löst this.monitorID einmalig zu einem Anzeigenamen ohne ID auf (z.B. "Leitstelle:
694
+ Lausitz" statt "4 - Leitstelle: Lausitz") und schreibt ihn nach status.registered-
695
+ MonitorName. Wird nur einmal beim Start aufgerufen (nicht bei jedem Reconnect) - das
696
+ Ergebnis wird in this.monitorName gecacht und von onSocketConnect() bei jedem
697
+ (Re-)Connect erneut in den State geschrieben, ohne die Übersichtsseite erneut zu holen. */
698
+ async refreshMonitorName() {
699
+ const monStr = isValidMonitor(this.monitorID) ? this.monitorID : '0';
700
+ try {
701
+ const list = await this.fetchMonitorList(this.url);
702
+ const match = list.find(item => item.value === String(monStr));
703
+ this.monitorName = match ? match.label.replace(/^\d+\s*-\s*/, '') : null;
704
+ } catch (e) {
705
+ this.safeLog('debug', 'refreshMonitorName', e);
706
+ this.monitorName = null;
707
+ }
708
+ await this.setField('status.registeredMonitorName', this.monitorName);
709
+ }
710
+
711
+ /* Reagiert auf sendTo-Nachrichten aus dem Admin (aktuell nur 'getMonitorList' für das
712
+ dynamische Monitor-Dropdown, siehe admin/jsonConfig.json). */
713
+ async onMessage(obj) {
714
+ if (!obj || typeof obj !== 'object') {
715
+ return;
716
+ }
717
+ if (obj.command === 'getMonitorList') {
718
+ if (!obj.callback) {
719
+ return;
720
+ }
721
+ const targetUrl = (obj.message && obj.message.url) || this.config.url;
722
+ let list;
723
+ try {
724
+ list = await this.fetchMonitorList(targetUrl);
725
+ } catch (e) {
726
+ this.safeLog('debug', 'getMonitorList', e);
727
+ list = [{ value: '0', label: '0 - Alle Wachalarme' }];
728
+ }
729
+ this.sendTo(obj.from, obj.command, list, obj.callback);
730
+ }
731
+ }
732
+
733
+ /* Baut aus einem oder mehreren Set-Cookie-Headern einen sendefertigen Cookie-Header (name=value; name2=value2). */
734
+ extractCookieHeader(setCookieHeader) {
735
+ const arr = Array.isArray(setCookieHeader) ? setCookieHeader : setCookieHeader ? [setCookieHeader] : [];
736
+ const pairs = arr.map(c => c.split(';')[0].trim()).filter(Boolean);
737
+ return pairs.length ? pairs.join('; ') : null;
738
+ }
739
+
740
+ /*
741
+ Holt bzw. erneuert den Session-Cookie über GET /session/keepalive (rolling session,
742
+ analog zum /js/session_keepalive.js der WAIP-Seite selbst). Ohne aktiven Cookie
743
+ verbindet sich der Socket sonst anonym und die Session läuft ab, wodurch die
744
+ Alarm-Zustellung stoppt.
745
+
746
+ Solange die bisherige Session noch gültig ist, liefert der Server denselben
747
+ connect.sid zurück (nur die Ablaufzeit wird verlängert). Ist die alte Session
748
+ serverseitig nicht mehr gültig (z.B. weil der letzte Keepalive-Call die konfigurierte
749
+ Cookie-Laufzeit überschritten hat, oder der Server sie aus anderen Gründen invalidiert
750
+ hat), bekommen wir hier einen NEUEN Cookie-Wert - das wird erkannt (isRotation) und
751
+ meldet dem Aufrufer, ob eine bestehende Socket.IO-Verbindung (die noch mit der alten
752
+ Session verknüpft ist) neu aufgebaut werden sollte.
753
+
754
+ Aus der vom Server gemeldeten Ablaufzeit wird außerdem die tatsächliche Cookie-
755
+ Lebensdauer dieser Instanz abgeleitet und in this.nextSessionKeepaliveDelayMs
756
+ abgelegt (siehe scheduleSessionKeepalive) - server/app_cfg.js des WAIP-Web-Projekts
757
+ zeigt, dass die Lebensdauer per ENV konfigurierbar ist (Standard dort: 60s, diese
758
+ Instanz nutzt offenbar 10 Min.), ein fest angenommenes Intervall wäre also für andere
759
+ Instanzen potenziell falsch.
760
+ */
761
+ async refreshSessionCookie() {
762
+ const previousCookie = this.sessionCookie;
763
+ const requestStartedAt = Date.now();
764
+ try {
765
+ const keepaliveUrl = `${this.url}/session/keepalive`;
766
+ const res = await this.httpGet(keepaliveUrl, this.sessionCookie);
767
+ const newCookie = this.extractCookieHeader(res.headers['set-cookie']);
768
+ if (newCookie) {
769
+ const isRotation = !!previousCookie && newCookie !== previousCookie;
770
+ this.sessionCookie = newCookie;
771
+ let expires = null;
772
+ try {
773
+ expires = JSON.parse(res.body).expires || null;
774
+ } catch {
775
+ /* ignore, body war kein JSON */
776
+ }
777
+ if (expires) {
778
+ try {
779
+ await this.setStateAsync('debug.sessionExpires', expires, true);
780
+ } catch {
781
+ /* ignore */
782
+ }
783
+ const expiresMs = new Date(expires).getTime();
784
+ if (!isNaN(expiresMs)) {
785
+ const observedMaxAgeMs = expiresMs - requestStartedAt;
786
+ if (observedMaxAgeMs > 0) {
787
+ // gleiche Klammerung wie /js/session_keepalive.js: 80% der
788
+ // beobachteten Laufzeit, mindestens SESSION_KEEPALIVE_MIN_MS,
789
+ // höchstens die konfigurierte Obergrenze (this.SESSION_KEEPALIVE_MS)
790
+ const ceiling = Math.max(this.SESSION_KEEPALIVE_MS, SESSION_KEEPALIVE_MIN_MS);
791
+ this.nextSessionKeepaliveDelayMs = Math.min(
792
+ Math.max(observedMaxAgeMs * 0.8, SESSION_KEEPALIVE_MIN_MS),
793
+ ceiling,
794
+ );
795
+ }
796
+ }
797
+ }
798
+ if (isRotation) {
799
+ // Teil des normalen, selbstheilenden Session-Zyklus dieser Instanz
800
+ // (siehe refreshSessionCookie-Kommentar oben) -> info statt warn.
801
+ this.log.info(
802
+ 'Session-Cookie wurde vom Server neu ausgestellt (alte Session war ungültig) – erzwinge Reconnect',
803
+ );
804
+ this.appendMonitorAudit({ ts: new Date().toISOString(), event: 'session_cookie_rotated' }).catch(
805
+ () => {},
806
+ );
807
+ } else {
808
+ this.log.debug(
809
+ `session cookie erneuert (status ${res.statusCode}${expires ? `, gültig bis ${expires}` : ''}${this.nextSessionKeepaliveDelayMs ? `, nächstes Keepalive in ${Math.round(this.nextSessionKeepaliveDelayMs / 1000)}s` : ''})`,
810
+ );
811
+ }
812
+ return { ok: true, rotated: isRotation };
813
+ }
814
+ this.safeWarn(
815
+ 'refreshSessionCookie',
816
+ `keepalive lieferte keinen Set-Cookie-Header (status ${res.statusCode})`,
817
+ );
818
+ return { ok: false, rotated: false };
819
+ } catch (e) {
820
+ this.safeWarn('refreshSessionCookie', e);
821
+ return { ok: false, rotated: false };
822
+ }
823
+ }
824
+
825
+ /* Erzwingt einen Socket-Reconnect, falls gerade eine Verbindung aktiv/im Aufbau ist
826
+ (z.B. weil der Session-Cookie rotiert ist oder der Server laut io.version neu
827
+ gestartet wurde). Läuft gerade kein Socket (z.B. während der Wartezeit vor einem
828
+ geplanten Reconnect), ist nichts zu tun - der nächste connect() erledigt das
829
+ ohnehin mit den dann aktuellen Daten (Cookie etc.). */
830
+ forceReconnect(reason) {
831
+ if (this.connecting) {
832
+ this.log.debug(`forceReconnect(${reason}): connect() läuft bereits, überspringe erzwungenen Reconnect`);
833
+ return;
834
+ }
835
+ if (!this.socket) {
836
+ this.log.debug(
837
+ `forceReconnect(${reason}): aktuell keine offene Verbindung, nächster connect() erledigt das automatisch`,
838
+ );
839
+ return;
840
+ }
841
+ this.log.info(`Baue Socket.IO-Verbindung neu auf (${reason})`);
842
+ this.connect(true);
843
+ }
844
+
845
+ /* Startet die adaptive Keepalive-Kette. Nutzt this.nextSessionKeepaliveDelayMs, falls
846
+ aus einer vorherigen refreshSessionCookie()-Antwort bereits eine reale Cookie-Laufzeit
847
+ bekannt ist (z.B. aus dem initialen Aufruf in onReady()), sonst die konfigurierte
848
+ Obergrenze als vorsichtigen Startwert. */
849
+ startSessionKeepalive() {
850
+ this.scheduleSessionKeepalive(this.nextSessionKeepaliveDelayMs || this.SESSION_KEEPALIVE_MS);
851
+ }
852
+
853
+ /* setTimeout statt setInterval, weil sich das Intervall von Aufruf zu Aufruf ändern
854
+ kann (adaptiv aus der vom Server gemeldeten Cookie-Laufzeit abgeleitet). */
855
+ scheduleSessionKeepalive(delayMs) {
856
+ if (this.sessionKeepaliveTimer) {
857
+ this.clearTimeout(this.sessionKeepaliveTimer);
858
+ }
859
+ this.sessionKeepaliveTimer = this.setTimeout(async () => {
860
+ const { rotated } = await this.refreshSessionCookie();
861
+ if (rotated) {
862
+ this.forceReconnect('Session-Cookie rotiert');
863
+ }
864
+ this.scheduleSessionKeepalive(this.nextSessionKeepaliveDelayMs || this.SESSION_KEEPALIVE_MS);
865
+ }, delayMs);
866
+ }
867
+
868
+ /* Sicheres, deduplizierendes Logging. level ist 'error'/'warn'/'info'/'debug' -
869
+ je unerwarteter/handlungsbedürftiger ein Fall ist, desto höher das Level.
870
+ safeWarn() bleibt als Kurzform für den (weitaus häufigsten) warn-Fall erhalten. */
871
+ safeLog(level, context, err) {
872
+ try {
873
+ const now = Date.now();
874
+ const msg = typeof err === 'string' ? err : err && err.message ? err.message : String(err);
875
+ const out = context ? `${context}: ${msg}` : msg;
876
+ if (out === this._warnCache.lastMsg && now - this._warnCache.ts < WARN_DEDUPE_MS) {
877
+ return;
878
+ }
879
+ this._warnCache.lastMsg = out;
880
+ this._warnCache.ts = now;
881
+ this.log[level](out);
882
+ } catch {
883
+ /* silent */
884
+ }
885
+ }
886
+
887
+ safeWarn(context, err) {
888
+ this.safeLog('warn', context, err);
889
+ }
890
+
891
+ /* Dedupliziertes Info-Logging für Disconnects. */
892
+ logDisconnect(msg) {
893
+ try {
894
+ const now = Date.now();
895
+ if (msg === this._lastDisconnectMsg && now - this._lastDisconnectTs < DISCONNECT_DEDUPE_MS) {
896
+ return;
897
+ }
898
+ this._lastDisconnectMsg = msg;
899
+ this._lastDisconnectTs = now;
900
+ this.log.info(msg);
901
+ } catch {
902
+ /* silent */
903
+ }
904
+ }
905
+
906
+ /* Hängt einen Eintrag an das Monitor-Audit-Log an (max. 200 Einträge). */
907
+ async appendMonitorAudit(entry) {
908
+ try {
909
+ const st = await this.getStateAsync('debug.monitorAudit');
910
+ let arr = [];
911
+ try {
912
+ arr = st && st.val ? JSON.parse(st.val) : [];
913
+ } catch {
914
+ arr = [];
915
+ }
916
+ arr.unshift(entry);
917
+ if (arr.length > 200) {
918
+ arr = arr.slice(0, 200);
919
+ }
920
+ await this.setStateAsync('debug.monitorAudit', JSON.stringify(arr), true);
921
+ } catch (e) {
922
+ // betrifft nur das interne Audit-Log, keine echten Einsatzdaten -> debug statt warn
923
+ this.safeLog('debug', 'appendMonitorAudit', e);
924
+ }
925
+ }
926
+
927
+ incrementIgnoredCount() {
928
+ this.getStateAsync('debug.ignoredCount')
929
+ .then(c => this.setStateAsync('debug.ignoredCount', Number((c && c.val) || 0) + 1, true))
930
+ .catch(() => {});
931
+ }
932
+
933
+ /* Setzt einen State; Objekte/Arrays werden JSON-stringifiziert. */
934
+ async setField(path, val) {
935
+ try {
936
+ let toSet;
937
+ if (val === null || typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
938
+ toSet = val;
939
+ } else {
940
+ toSet = JSON.stringify(val);
941
+ }
942
+ await this.setStateAsync(path, toSet, true);
943
+ } catch (e) {
944
+ this.safeWarn(`setField ${path}`, e);
945
+ }
946
+ }
947
+
948
+ /* Prüft ob eine eingehende Payload eindeutig einem Monitor zuordenbar ist und mit currentMonitor übereinstimmt. */
949
+ payloadMonitorMatch(p) {
950
+ if (!p || typeof p !== 'object') {
951
+ return null;
952
+ }
953
+ const keys = [
954
+ 'monitor',
955
+ 'monitorID',
956
+ 'monitor_id',
957
+ 'monitorId',
958
+ 'waip_monitor',
959
+ 'waip_monitor_id',
960
+ 'wache_nr',
961
+ 'wache_id',
962
+ 'wacheId',
963
+ 'room',
964
+ 'tenant',
965
+ 'group',
966
+ ];
967
+ for (const k of keys) {
968
+ if (p[k] !== undefined && p[k] !== null && String(p[k]).trim() !== '') {
969
+ const val = String(p[k]).trim();
970
+ if (val === String(this.currentMonitor)) {
971
+ return true;
972
+ }
973
+ if (
974
+ !isNaN(Number(val)) &&
975
+ !isNaN(Number(this.currentMonitor)) &&
976
+ Number(val) === Number(this.currentMonitor)
977
+ ) {
978
+ return true;
979
+ }
980
+ return false;
981
+ }
982
+ }
983
+ return null; // no monitor-identifying field found
984
+ }
985
+
986
+ /*
987
+ Handler-Wrapper: prüft Monitor-Match bevor der eigentliche Handler ausgeführt wird.
988
+
989
+ WICHTIG (siehe client_waip.js des offiziellen Frontends): Die Monitor-Zuordnung
990
+ passiert vollständig serverseitig über eine Socket.IO-Room-Registrierung
991
+ (ausgelöst durch emit('WAIP', monitorId) in onSocketConnect). Kein einziges
992
+ reales Event (io.new_waip/io.new_rmld/io.routes/io.playtts/io.standby) trägt ein
993
+ eigenes Monitor-Kennungsfeld im Payload - das offizielle Frontend prüft beim
994
+ Empfang auch gar nichts. payloadMonitorMatch() liefert für reale Payloads daher
995
+ praktisch immer null.
996
+
997
+ Frühere Version verwarf Events ohne Monitor-Feld nach Ablauf des Registrierungs-
998
+ Timeouts als "unknownMonitor" - das hat bei jeder nicht-globalen Monitor-ID (≠ '0')
999
+ die komplette Alarm-Zustellung nach dem Timeout stillschweigend gestoppt. Jetzt
1000
+ gilt: jedes empfangene Event bestätigt die Registrierung und wird verarbeitet,
1001
+ außer das Payload nennt EXPLIZIT eine andere Monitor-Kennung (match === false).
1002
+ */
1003
+ wrapHandlerWithMonitorCheck(handler) {
1004
+ return payload => {
1005
+ try {
1006
+ const match = this.payloadMonitorMatch(payload);
1007
+
1008
+ if (match === false) {
1009
+ // Reine, erwartete Filterlogik (kein Fehler) - Häufigkeit ist über
1010
+ // debug.ignoredCount messbar; nur bei dauerhaft hoher Anzahl ein
1011
+ // Hinweis auf eine falsch konfigurierte Monitor-ID.
1012
+ this.safeLog(
1013
+ 'info',
1014
+ 'ignoredEvent.wrongMonitor',
1015
+ `Event für anderen Monitor empfangen (current=${this.currentMonitor})`,
1016
+ );
1017
+ this.incrementIgnoredCount();
1018
+ return;
1019
+ }
1020
+
1021
+ // match === true (Monitor-Feld passt) oder match === null (kein
1022
+ // Monitor-Feld im Payload, der Normalfall) -> Registrierung bestätigt.
1023
+ if (this.registrationPending || match === true) {
1024
+ this.setState('status.registrationAccepted', true, true);
1025
+ this.setState('status.registeredMonitor', this.currentMonitor, true);
1026
+ if (this.registrationTimer) {
1027
+ this.clearTimeout(this.registrationTimer);
1028
+ this.registrationTimer = null;
1029
+ }
1030
+ this.registrationPending = false;
1031
+ }
1032
+
1033
+ try {
1034
+ handler(payload);
1035
+ } catch (e) {
1036
+ // Ein empfangenes Event konnte nicht verarbeitet werden -> echter
1037
+ // Datenverlust, daher error statt warn.
1038
+ this.safeLog('error', 'handler.exec', e);
1039
+ }
1040
+ } catch (e) {
1041
+ this.safeLog('error', 'wrapHandlerWithMonitorCheck', e);
1042
+ }
1043
+ };
1044
+ }
1045
+
1046
+ /* Schreibt this.currentEinsatzSnapshot komplett (inkl. verschachtelter Arrays) nach einsatz.json. */
1047
+ async persistEinsatzSnapshot() {
1048
+ try {
1049
+ await this.setStateAsync('einsatz.json', JSON.stringify(this.currentEinsatzSnapshot), true);
1050
+ } catch (e) {
1051
+ this.safeWarn('persistEinsatzSnapshot', e);
1052
+ }
1053
+ }
1054
+
1055
+ /* Legt den aktuellen Einsatz-Snapshot als abgeschlossenen Eintrag vorne in einsatz.history10 ab
1056
+ (z.B. bei io.standby oder wenn ein neuer Einsatz beginnt, ohne dass zuvor io.standby kam).
1057
+ Dedupliziert über die uuid, damit derselbe Einsatz nicht doppelt eingetragen wird, falls
1058
+ sowohl io.standby als auch der nächste io.new_waip diese Methode auslösen. */
1059
+ async pushEinsatzToHistory() {
1060
+ try {
1061
+ if (!this.currentEinsatzSnapshot || !this.currentEinsatzSnapshot.uuid) {
1062
+ return;
1063
+ }
1064
+ const st = await this.getStateAsync('einsatz.history10');
1065
+ let arr = [];
1066
+ try {
1067
+ arr = st && st.val ? JSON.parse(st.val) : [];
1068
+ } catch {
1069
+ arr = [];
1070
+ }
1071
+ if (arr.length && arr[0] && arr[0].uuid === this.currentEinsatzSnapshot.uuid) {
1072
+ return;
1073
+ }
1074
+ arr.unshift(this.currentEinsatzSnapshot);
1075
+ if (arr.length > this.HISTORY_SIZE) {
1076
+ arr = arr.slice(0, this.HISTORY_SIZE);
1077
+ }
1078
+ await this.setStateAsync('einsatz.history10', JSON.stringify(arr), true);
1079
+ } catch (e) {
1080
+ this.safeWarn('pushEinsatzToHistory', e);
1081
+ }
1082
+ }
1083
+
1084
+ /* Berechnet aus den im Snapshot gesammelten Rückmeldungen die Zähler pro Rolle/Fähigkeit
1085
+ (analog zu den Badges EK/GF/ZF/VF/AGT/FZF/MA/MED/Gesamt der Weboberfläche) und
1086
+ aktualisiert einsatz.rueckmeldungAnzahl.* sowie einsatz.rueckmeldungGesamt. */
1087
+ async updateRueckmeldungCounts() {
1088
+ const list = (this.currentEinsatzSnapshot && this.currentEinsatzSnapshot.rueckmeldungen) || [];
1089
+ const counts = { ek: 0, gf: 0, zf: 0, vf: 0, agt: 0, fzf: 0, ma: 0, med: 0 };
1090
+ for (const r of list) {
1091
+ if (r.rmld_role === 'team_member') {
1092
+ counts.ek++;
1093
+ } else if (r.rmld_role === 'crew_leader') {
1094
+ counts.gf++;
1095
+ } else if (r.rmld_role === 'division_chief') {
1096
+ counts.zf++;
1097
+ } else if (r.rmld_role === 'group_commander') {
1098
+ counts.vf++;
1099
+ }
1100
+ if (Number(r.rmld_capability_agt) > 0) {
1101
+ counts.agt++;
1102
+ }
1103
+ if (Number(r.rmld_capability_fzf) > 0) {
1104
+ counts.fzf++;
1105
+ }
1106
+ if (Number(r.rmld_capability_ma) > 0) {
1107
+ counts.ma++;
1108
+ }
1109
+ if (Number(r.rmld_capability_med) > 0) {
1110
+ counts.med++;
1111
+ }
1112
+ }
1113
+ const tasks = RUECKMELDUNG_ANZAHL_KEYS.map(k =>
1114
+ this.setStateAsync(`einsatz.rueckmeldungAnzahl.${k}`, counts[k], true),
1115
+ );
1116
+ tasks.push(this.setStateAsync('einsatz.rueckmeldungGesamt', list.length, true));
1117
+ const results = await Promise.allSettled(tasks);
1118
+ for (const r of results) {
1119
+ if (r.status === 'rejected') {
1120
+ this.safeWarn('Rückmeldung-Zähler setzen', r.reason);
1121
+ }
1122
+ }
1123
+ }
1124
+
1125
+ /* Handler für eingehende Alarme (io.new_waip). */
1126
+ async handleAlarm(incoming) {
1127
+ try {
1128
+ try {
1129
+ await this.setStateAsync('debug.rawPayloadShort', JSON.stringify(incoming).slice(0, 500), true);
1130
+ } catch {
1131
+ /* ignore */
1132
+ }
1133
+
1134
+ const data = normalizeData(incoming || {});
1135
+ try {
1136
+ await this.setStateAsync(
1137
+ 'debug.normalizedPosition',
1138
+ JSON.stringify({ position: data.position ?? null }),
1139
+ true,
1140
+ );
1141
+ } catch {
1142
+ /* ignore */
1143
+ }
1144
+
1145
+ // Neuer Einsatz (andere uuid als der aktuell verfolgte) -> vorherigen Snapshot (falls
1146
+ // noch nicht per io.standby archiviert) sichern und mit leeren Listen neu beginnen.
1147
+ // Bei einer bloßen Aktualisierung desselben Einsatzes (gleiche uuid, z.B. Korrektur
1148
+ // der Besonderheiten) bleiben bereits erfasste Routen/Rückmeldungen bewusst erhalten -
1149
+ // anders als die Live-Webseite, die bei JEDEM io.new_waip zurücksetzt.
1150
+ const isNewEinsatz = data.uuid && data.uuid !== this.currentEinsatzUuid;
1151
+ if (isNewEinsatz) {
1152
+ await this.pushEinsatzToHistory();
1153
+ this.currentEinsatzUuid = data.uuid;
1154
+ this.currentEinsatzSnapshot = { routen: [], rueckmeldungen: [] };
1155
+ } else if (!this.currentEinsatzSnapshot) {
1156
+ this.currentEinsatzSnapshot = { routen: [], rueckmeldungen: [] };
1157
+ }
1158
+
1159
+ let lat = null;
1160
+ let lon = null;
1161
+ if (data.position && data.position.lat !== undefined && data.position.lon !== undefined) {
1162
+ lat = Number(data.position.lat);
1163
+ lon = Number(data.position.lon);
1164
+ }
1165
+
1166
+ if (lat !== null && lon !== null && !isNaN(lat) && !isNaN(lon)) {
1167
+ try {
1168
+ await this.setStateAsync('einsatz.latitude', lat, true);
1169
+ } catch (e) {
1170
+ this.safeWarn('einsatz.latitude.setState', e);
1171
+ }
1172
+ try {
1173
+ await this.setStateAsync('einsatz.longitude', lon, true);
1174
+ } catch (e) {
1175
+ this.safeWarn('einsatz.longitude.setState', e);
1176
+ }
1177
+ this.currentEinsatzSnapshot.position = { lat, lon };
1178
+ } else {
1179
+ try {
1180
+ await this.setStateAsync('einsatz.latitude', null, true);
1181
+ } catch {
1182
+ /* ignore */
1183
+ }
1184
+ try {
1185
+ await this.setStateAsync('einsatz.longitude', null, true);
1186
+ } catch {
1187
+ /* ignore */
1188
+ }
1189
+ this.currentEinsatzSnapshot.position = null;
1190
+ }
1191
+
1192
+ try {
1193
+ await this.setStateAsync('status.alarmAktiv', true, true);
1194
+ } catch (e) {
1195
+ this.safeWarn('status.alarmAktiv.setState', e);
1196
+ }
1197
+
1198
+ // flache Felder setzen und gleichzeitig im Snapshot mitführen
1199
+ const tasks = [];
1200
+ for (const k of this.ALLOWED_EINSATZ_FIELDS) {
1201
+ if (Object.prototype.hasOwnProperty.call(data, k)) {
1202
+ this.currentEinsatzSnapshot[k] = data[k];
1203
+ tasks.push(this.setField(`einsatz.${k}`, data[k]));
1204
+ }
1205
+ }
1206
+ if (data.em_alarmiert !== undefined) {
1207
+ this.currentEinsatzSnapshot.emAlarmiert = data.em_alarmiert;
1208
+ }
1209
+ if (data.em_weitere !== undefined) {
1210
+ this.currentEinsatzSnapshot.emWeitere = data.em_weitere;
1211
+ }
1212
+
1213
+ const results = await Promise.allSettled(tasks);
1214
+ for (const r of results) {
1215
+ if (r.status === 'rejected') {
1216
+ this.safeWarn('Einsatz-Feld setzen', r.reason);
1217
+ }
1218
+ }
1219
+
1220
+ await this.persistEinsatzSnapshot();
1221
+ } catch (e) {
1222
+ // Ein Alarm-Event konnte nicht verarbeitet werden -> echter Datenverlust.
1223
+ this.safeLog('error', 'handleAlarm', e);
1224
+ }
1225
+ }
1226
+
1227
+ /* Handler für Rückmeldungen (io.new_rmld). Werden im Snapshot des aktuellen Einsatzes
1228
+ gesammelt (dedupliziert über rmld_uuid) statt in einem separaten "letzte Rückmeldung"-State. */
1229
+ async handleRueckmeldung(incoming) {
1230
+ try {
1231
+ const data = normalizeData(incoming || {});
1232
+
1233
+ if (!this.currentEinsatzSnapshot) {
1234
+ this.currentEinsatzSnapshot = { routen: [], rueckmeldungen: [] };
1235
+ }
1236
+ if (!Array.isArray(this.currentEinsatzSnapshot.rueckmeldungen)) {
1237
+ this.currentEinsatzSnapshot.rueckmeldungen = [];
1238
+ }
1239
+
1240
+ // Rückmeldungen für einen anderen (alten) Einsatz nicht mit aufnehmen.
1241
+ if (data.waip_uuid && this.currentEinsatzUuid && data.waip_uuid !== this.currentEinsatzUuid) {
1242
+ this.log.debug(
1243
+ `Rückmeldung für abweichenden Einsatz ${data.waip_uuid} ignoriert (aktuell=${this.currentEinsatzUuid})`,
1244
+ );
1245
+ return;
1246
+ }
1247
+
1248
+ const list = this.currentEinsatzSnapshot.rueckmeldungen;
1249
+ if (data.rmld_uuid) {
1250
+ const idx = list.findIndex(r => r.rmld_uuid === data.rmld_uuid);
1251
+ if (idx >= 0) {
1252
+ list[idx] = data;
1253
+ } else {
1254
+ list.push(data);
1255
+ }
1256
+ } else {
1257
+ list.push(data);
1258
+ }
1259
+
1260
+ await this.persistEinsatzSnapshot();
1261
+ await this.updateRueckmeldungCounts();
1262
+ } catch (e) {
1263
+ // Eine Rückmeldung konnte nicht verarbeitet werden -> echter Datenverlust.
1264
+ this.safeLog('error', 'handleRueckmeldung', e);
1265
+ }
1266
+ }
1267
+
1268
+ /* Handler für Standby (io.standby) - Einsatz beendet / Monitor im Ruhezustand.
1269
+ Analog zum offiziellen Frontend (client_waip.js leert dabei Stichwort, Ortsdaten,
1270
+ Besonderheiten etc. und setzt die Karte zurück): der abgeschlossene Einsatz wird
1271
+ zuerst archiviert (einsatz.history10), danach werden alle auf den aktuellen Einsatz
1272
+ bezogenen States geleert - so bleibt alarmAktiv ein verlässlicher Schalter dafür, ob
1273
+ einsatz.* gerade echte Live-Daten enthält, statt still den letzten (beendeten)
1274
+ Einsatz weiter anzuzeigen. */
1275
+ async handleStandby() {
1276
+ try {
1277
+ this.log.info('Standby empfangen - Einsatz beendet bzw. Monitor im Ruhezustand');
1278
+ this.appendMonitorAudit({ ts: new Date().toISOString(), event: 'standby' }).catch(() => {});
1279
+ try {
1280
+ await this.setStateAsync('status.alarmAktiv', false, true);
1281
+ } catch (e) {
1282
+ this.safeWarn('status.alarmAktiv.setState', e);
1283
+ }
1284
+ await this.pushEinsatzToHistory();
1285
+ await this.clearCurrentEinsatzStates();
1286
+ } catch (e) {
1287
+ // Standby konnte nicht verarbeitet werden -> Historie/States ggf. inkonsistent.
1288
+ this.safeLog('error', 'handleStandby', e);
1289
+ }
1290
+ }
1291
+
1292
+ /* Leert alle States, die sich auf den aktuellen (jetzt beendeten) Einsatz beziehen -
1293
+ flache einsatz.*-Felder, einsatz.json sowie alle abgeleiteten Zähler. Wird nach
1294
+ pushEinsatzToHistory() aufgerufen, der abgeschlossene Einsatz bleibt also weiterhin
1295
+ über einsatz.history10 abrufbar. */
1296
+ async clearCurrentEinsatzStates() {
1297
+ const tasks = this.ALLOWED_EINSATZ_FIELDS.map(k => this.setStateAsync(`einsatz.${k}`, null, true));
1298
+ tasks.push(this.setStateAsync('einsatz.latitude', null, true));
1299
+ tasks.push(this.setStateAsync('einsatz.longitude', null, true));
1300
+ tasks.push(this.setStateAsync('einsatz.json', null, true));
1301
+ tasks.push(this.setStateAsync('einsatz.routenGesamt', 0, true));
1302
+ const results = await Promise.allSettled(tasks);
1303
+ for (const r of results) {
1304
+ if (r.status === 'rejected') {
1305
+ this.safeWarn('clearCurrentEinsatzStates', r.reason);
1306
+ }
1307
+ }
1308
+
1309
+ this.currentEinsatzUuid = null;
1310
+ this.currentEinsatzSnapshot = null;
1311
+ // rueckmeldungGesamt/rueckmeldungAnzahl.* liest aus this.currentEinsatzSnapshot,
1312
+ // ist jetzt also null -> alle Zähler werden konsistent auf 0 zurückgesetzt.
1313
+ await this.updateRueckmeldungCounts();
1314
+ }
1315
+
1316
+ /* Handler für Server-Fehlermeldungen (io.error). Das bekannte "Fehler beim Erneuern
1317
+ der Session"-Muster gehört zum normalen, selbstheilenden ~10-Minuten-Session-Zyklus
1318
+ dieser Instanz (Reconnect erfolgt automatisch über refreshSessionCookie/forceReconnect)
1319
+ und wird deshalb nur als info geloggt. Alle anderen, unbekannten io.error-Inhalte
1320
+ bleiben warn, da dort nicht bekannt ist, ob sie folgenlos sind. */
1321
+ async handleServerError(data) {
1322
+ try {
1323
+ const msg = typeof data === 'string' ? data : JSON.stringify(data);
1324
+ const isKnownSessionRenewalError = /Fehler beim Erneuern der Session/i.test(msg);
1325
+ this.safeLog(isKnownSessionRenewalError ? 'info' : 'warn', 'io.error (Server)', msg);
1326
+ await this.setField('debug.lastError', data);
1327
+ } catch {
1328
+ /* ignore */
1329
+ }
1330
+ }
1331
+
1332
+ /* Handler für io.version - Server-Identität/Version. Ändert sich die vom Server
1333
+ gemeldete ID zur Laufzeit, ist der Server vermutlich neu gestartet (das offizielle
1334
+ Frontend lädt in diesem Fall die Seite komplett neu). Laut server/auth.js des
1335
+ WAIP-Web-Projekts werden Sessions persistent (SQLite) gespeichert, ein Neustart
1336
+ löscht sie also normalerweise NICHT automatisch - trotzdem ist ein Server-Neustart
1337
+ ein guter genereller Anlass, Cookie und Verbindung vorsorglich aufzufrischen (billige
1338
+ Absicherung gegen jede Art von serverseitiger Zustandsänderung, nicht nur Sessions). */
1339
+ async handleServerVersion(serverId) {
1340
+ try {
1341
+ await this.setStateAsync('debug.serverVersion', String(serverId), true);
1342
+ if (this.lastServerVersion && this.lastServerVersion !== serverId) {
1343
+ // Wird bereits automatisch behandelt (Session-Refresh + Reconnect) ->
1344
+ // kein Handlungsbedarf, daher info statt warn.
1345
+ this.log.info(
1346
+ `WAIP-Server meldet neue Version/Instanz-ID (${this.lastServerVersion} -> ${serverId}) - vermutlich Server-Neustart`,
1347
+ );
1348
+ this.appendMonitorAudit({
1349
+ ts: new Date().toISOString(),
1350
+ event: 'server_version_changed',
1351
+ from: this.lastServerVersion,
1352
+ to: serverId,
1353
+ }).catch(() => {});
1354
+ this.lastServerVersion = serverId;
1355
+ await this.refreshSessionCookie();
1356
+ this.forceReconnect('Server-Version geändert');
1357
+ return;
1358
+ }
1359
+ this.lastServerVersion = serverId;
1360
+ } catch (e) {
1361
+ this.safeWarn('handleServerVersion', e);
1362
+ }
1363
+ }
1364
+
1365
+ /* Handler für Routen (io.routes). Routen sind der aktuell gültige Satz für den laufenden
1366
+ Einsatz (wird bei jedem Event ersetzt, nicht akkumuliert) und liegt daher als Array
1367
+ innerhalb von einsatz.json.routen[]. */
1368
+ async handleRoutes(incoming) {
1369
+ try {
1370
+ let data = incoming;
1371
+ if (Array.isArray(incoming)) {
1372
+ data = incoming.map(i => normalizeData(i));
1373
+ } else if (typeof incoming === 'object' && incoming !== null) {
1374
+ data = normalizeData(incoming);
1375
+ }
1376
+
1377
+ if (!this.currentEinsatzSnapshot) {
1378
+ this.currentEinsatzSnapshot = { routen: [], rueckmeldungen: [] };
1379
+ }
1380
+ this.currentEinsatzSnapshot.routen = Array.isArray(data) ? data : data ? [data] : [];
1381
+
1382
+ await this.persistEinsatzSnapshot();
1383
+ try {
1384
+ await this.setStateAsync('einsatz.routenGesamt', this.currentEinsatzSnapshot.routen.length, true);
1385
+ } catch (e) {
1386
+ this.safeWarn('einsatz.routenGesamt.setState', e);
1387
+ }
1388
+ } catch (e) {
1389
+ // Ein Routen-Event konnte nicht verarbeitet werden -> echter Datenverlust.
1390
+ this.safeLog('error', 'handleRoutes', e);
1391
+ }
1392
+ }
1393
+
1394
+ /* Handler für TTS-Events (io.playtts). Payload ist laut client_waip.js nur eine URL
1395
+ (direkt als audio.src verwendet), daher kein Einsatzbezug/keine weiteren Felder. */
1396
+ async handleTTS(incoming) {
1397
+ try {
1398
+ const data = normalizeData(incoming || {});
1399
+ const ts = new Date().toISOString();
1400
+ await this.setField('tts.last', data);
1401
+ await this.setField('tts.lastTimestamp', ts);
1402
+
1403
+ if (!Array.isArray(this.ttsHistory)) {
1404
+ this.ttsHistory = [];
1405
+ }
1406
+ this.ttsHistory.unshift({ zeitstempel: ts, url: data });
1407
+ if (this.ttsHistory.length > this.HISTORY_SIZE) {
1408
+ this.ttsHistory = this.ttsHistory.slice(0, this.HISTORY_SIZE);
1409
+ }
1410
+ try {
1411
+ await this.setStateAsync('tts.history10', JSON.stringify(this.ttsHistory), true);
1412
+ } catch (e) {
1413
+ this.safeWarn('tts.history10.setState', e);
1414
+ }
1415
+ } catch (e) {
1416
+ // Ein TTS-Event konnte nicht verarbeitet werden -> echter Datenverlust.
1417
+ this.safeLog('error', 'handleTTS', e);
1418
+ }
1419
+ }
1420
+
1421
+ /* Cleanup helper: schließt und entfernt eine vorhandene socket-Instanz vollständig. */
1422
+ cleanupSocket() {
1423
+ try {
1424
+ if (!this.socket) {
1425
+ return;
1426
+ }
1427
+ try {
1428
+ this.socket.removeAllListeners();
1429
+ } catch {
1430
+ /* ignore */
1431
+ }
1432
+ try {
1433
+ this.socket.disconnect();
1434
+ } catch {
1435
+ /* ignore */
1436
+ }
1437
+ try {
1438
+ if (typeof this.socket.close === 'function') {
1439
+ this.socket.close();
1440
+ }
1441
+ } catch {
1442
+ /* ignore */
1443
+ }
1444
+ } catch (e) {
1445
+ // Reines Aufräumen der alten Socket-Instanz, kein Datenverlust -> debug statt warn.
1446
+ this.safeLog('debug', 'cleanupSocket', e);
1447
+ } finally {
1448
+ this.socket = null;
1449
+ this.connecting = false;
1450
+ this.registrationPending = false;
1451
+ if (this.registrationTimer) {
1452
+ this.clearTimeout(this.registrationTimer);
1453
+ this.registrationTimer = null;
1454
+ }
1455
+ }
1456
+ }
1457
+
1458
+ onSocketConnect(monStr) {
1459
+ this.connecting = false;
1460
+ this.setState('status.connected', true, true);
1461
+ this.setState('info.connection', true, true);
1462
+
1463
+ try {
1464
+ this.log.info(`socket.emit('WAIP', ${monStr}) [1/3]`);
1465
+ this.appendMonitorAudit({
1466
+ ts: new Date().toISOString(),
1467
+ event: 'emit_WAIP',
1468
+ value: monStr,
1469
+ attempt: 1,
1470
+ }).catch(() => {});
1471
+ this.socket.emit('WAIP', monStr);
1472
+ } catch (e) {
1473
+ this.safeWarn('socket.emit.WAIP', e);
1474
+ }
1475
+
1476
+ this.setTimeout(() => {
1477
+ try {
1478
+ this.log.debug(`socket.emit('WAIP', ${monStr}) [2/3]`);
1479
+ this.appendMonitorAudit({
1480
+ ts: new Date().toISOString(),
1481
+ event: 'emit_WAIP',
1482
+ value: monStr,
1483
+ attempt: 2,
1484
+ }).catch(() => {});
1485
+ if (this.socket) {
1486
+ this.socket.emit('WAIP', monStr);
1487
+ }
1488
+ } catch (e) {
1489
+ this.safeWarn('socket.emit.WAIP.2', e);
1490
+ }
1491
+ }, 1000);
1492
+
1493
+ this.setTimeout(() => {
1494
+ try {
1495
+ this.log.debug(`socket.emit('WAIP', ${monStr}) [3/3]`);
1496
+ this.appendMonitorAudit({
1497
+ ts: new Date().toISOString(),
1498
+ event: 'emit_WAIP',
1499
+ value: monStr,
1500
+ attempt: 3,
1501
+ }).catch(() => {});
1502
+ if (this.socket) {
1503
+ this.socket.emit('WAIP', monStr);
1504
+ }
1505
+ } catch (e) {
1506
+ this.safeWarn('socket.emit.WAIP.3', e);
1507
+ }
1508
+ }, 3000);
1509
+
1510
+ this.registrationPending = true;
1511
+ this.setState('status.registeredMonitor', monStr, true);
1512
+ // Gecachter Anzeigename (siehe refreshMonitorName) - keine erneute HTTP-Abfrage
1513
+ // bei jedem (Re-)Connect, ist ggf. beim allerersten Connect noch null.
1514
+ this.setState('status.registeredMonitorName', this.monitorName, true);
1515
+ this.setState('status.registrationAccepted', 'pending', true);
1516
+ if (this.registrationTimer) {
1517
+ this.clearTimeout(this.registrationTimer);
1518
+ this.registrationTimer = null;
1519
+ }
1520
+ this.registrationTimer = this.setTimeout(async () => {
1521
+ this.registrationPending = false;
1522
+ const accState = await this.getStateAsync('status.registrationAccepted');
1523
+ const acc = accState ? accState.val : null;
1524
+ if (acc !== true) {
1525
+ await this.setStateAsync('status.registrationAccepted', false, true);
1526
+ this.log.warn(
1527
+ `WAIP registration for monitor ${this.currentMonitor} not confirmed within ${this.REGISTRATION_TIMEOUT_MS}ms`,
1528
+ );
1529
+ this.appendMonitorAudit({
1530
+ ts: new Date().toISOString(),
1531
+ event: 'registration_timeout',
1532
+ monitor: this.currentMonitor,
1533
+ }).catch(() => {});
1534
+ }
1535
+ this.registrationTimer = null;
1536
+ }, this.REGISTRATION_TIMEOUT_MS);
1537
+
1538
+ this.log.info(`Verbunden Monitor ${monStr} -> namespace /waip (registered via WAIP emit)`);
1539
+ }
1540
+
1541
+ onSocketDisconnect(reason) {
1542
+ this.connecting = false;
1543
+ this.setState('status.connected', false, true);
1544
+ this.setState('info.connection', false, true);
1545
+ this.logDisconnect(`Socket disconnected: ${reason}`);
1546
+ this.registrationPending = false;
1547
+ this.setState('status.registrationAccepted', false, true);
1548
+ if (this.registrationTimer) {
1549
+ this.clearTimeout(this.registrationTimer);
1550
+ this.registrationTimer = null;
1551
+ }
1552
+
1553
+ this.cleanupSocket();
1554
+ this.reconnectTimer = this.setTimeout(() => {
1555
+ this.log.info(`manual reconnect triggered for monitor '${this.currentMonitor}'`);
1556
+ this.appendMonitorAudit({ ts: new Date().toISOString(), event: 'manual_reconnect_triggered' }).catch(
1557
+ () => {},
1558
+ );
1559
+ this.connect();
1560
+ }, this.RECONNECT_DELAY_MS);
1561
+ }
1562
+
1563
+ onSocketConnectError(err) {
1564
+ this.connecting = false;
1565
+ this.setState('status.connected', false, true);
1566
+ this.setState('info.connection', false, true);
1567
+ // War zuvor doppelt geloggt (safeWarn zusätzlich zu logDisconnect für dasselbe
1568
+ // Event) - logDisconnect() unten reicht aus.
1569
+ this.logDisconnect(`connect_error: ${String(err)}`);
1570
+ this.cleanupSocket();
1571
+ this.reconnectTimer = this.setTimeout(() => {
1572
+ this.log.info(`manual reconnect after connect_error for monitor '${this.currentMonitor}'`);
1573
+ this.appendMonitorAudit({ ts: new Date().toISOString(), event: 'manual_reconnect_after_error' }).catch(
1574
+ () => {},
1575
+ );
1576
+ this.connect();
1577
+ }, this.RECONNECT_DELAY_MS);
1578
+ }
1579
+
1580
+ /*
1581
+ Connect: verbindet zur socket.io-namespace '/waip' (über path '/socket.io'),
1582
+ registriert sich per emit('WAIP', monitor).
1583
+
1584
+ WICHTIG: automatische reconnects deaktiviert (reconnection: false). Nach
1585
+ disconnect/connect_error wird manuell reconnect() nach RECONNECT_DELAY_MS aufgerufen.
1586
+ */
1587
+ async connect(force = false) {
1588
+ try {
1589
+ const monStr = isValidMonitor(this.monitorID) ? this.monitorID : '0';
1590
+
1591
+ if (!force && this.socket && this.currentMonitor === monStr && !this.connecting) {
1592
+ this.log.debug(`connect(): already connected to monitor ${monStr}, skipping`);
1593
+ return;
1594
+ }
1595
+
1596
+ // Falls (z.B. nach einem längeren Disconnect) noch kein/kein frischer
1597
+ // Session-Cookie vorliegt, vor dem (Re-)Connect einen holen.
1598
+ if (!this.sessionCookie) {
1599
+ await this.refreshSessionCookie();
1600
+ }
1601
+
1602
+ this.cleanupSocket();
1603
+ this.connecting = true;
1604
+ this.currentMonitor = monStr;
1605
+
1606
+ this.appendMonitorAudit({ ts: new Date().toISOString(), event: 'connect_called', using: monStr }).catch(
1607
+ () => {},
1608
+ );
1609
+ this.log.info(`connect(): using monitor '${monStr}'`);
1610
+
1611
+ const namespaceUrl = `${this.url}/waip`;
1612
+ this.socket = io(namespaceUrl, {
1613
+ path: '/socket.io',
1614
+ forceNew: true,
1615
+ transports: ['websocket', 'polling'],
1616
+ reconnection: false,
1617
+ timeout: 20000,
1618
+ query: { monitor: monStr },
1619
+ // Session-Cookie mitschicken, damit die Verbindung nicht anonym/ohne
1620
+ // Server-Session läuft (siehe refreshSessionCookie/startSessionKeepalive)
1621
+ extraHeaders: this.sessionCookie ? { Cookie: this.sessionCookie } : undefined,
1622
+ });
1623
+
1624
+ try {
1625
+ if (this.socket && this.socket.io && this.socket.io.engine) {
1626
+ const eng = this.socket.io.engine;
1627
+ this.log.debug(`engine pingInterval=${eng.pingInterval} pingTimeout=${eng.pingTimeout}`);
1628
+ eng.on('packet', pkt => {
1629
+ try {
1630
+ if (pkt && pkt.type) {
1631
+ if (['ping', 'pong', 'open', 'close'].includes(String(pkt.type))) {
1632
+ this.log.debug(`engine.packet: ${JSON.stringify(pkt)}`);
1633
+ } else if (pkt.data && typeof pkt.data === 'string') {
1634
+ const preview = pkt.data.length > 200 ? `${pkt.data.slice(0, 200)}...` : pkt.data;
1635
+ this.log.debug(`engine.packet.message preview: ${preview}`);
1636
+ }
1637
+ }
1638
+ } catch {
1639
+ /* ignore */
1640
+ }
1641
+ });
1642
+ }
1643
+ } catch {
1644
+ /* ignore */
1645
+ }
1646
+
1647
+ this.socket.on('connect', () => this.onSocketConnect(monStr));
1648
+ this.socket.on('disconnect', reason => this.onSocketDisconnect(reason));
1649
+ this.socket.on('connect_error', err => this.onSocketConnectError(err));
1650
+
1651
+ // Diagnostik: erste eingehende Rohdaten als Preview loggen (max. 6 Events)
1652
+ let firstCount = 0;
1653
+ const anyListener = (event, ...args) => {
1654
+ try {
1655
+ firstCount++;
1656
+ const previewArgs =
1657
+ args && args.length
1658
+ ? typeof args[0] === 'string'
1659
+ ? args[0].slice(0, 500)
1660
+ : JSON.stringify(args[0]).slice(0, 500)
1661
+ : '';
1662
+ this.log.debug(`incoming event '${event}' preview: ${previewArgs}`);
1663
+ if (firstCount >= 6 && this.socket && typeof this.socket.offAny === 'function') {
1664
+ try {
1665
+ this.socket.offAny(anyListener);
1666
+ } catch {
1667
+ /* ignore */
1668
+ }
1669
+ }
1670
+ } catch {
1671
+ /* ignore */
1672
+ }
1673
+ };
1674
+ try {
1675
+ if (this.socket && typeof this.socket.onAny === 'function') {
1676
+ this.socket.onAny(anyListener);
1677
+ }
1678
+ } catch {
1679
+ /* ignore */
1680
+ }
1681
+
1682
+ this.socket.on('io.new_waip', this.wrapHandlerWithMonitorCheck(this.handleAlarm.bind(this)));
1683
+ this.socket.on('io.new_rmld', this.wrapHandlerWithMonitorCheck(this.handleRueckmeldung.bind(this)));
1684
+ this.socket.on('io.routes', this.wrapHandlerWithMonitorCheck(this.handleRoutes.bind(this)));
1685
+ this.socket.on('io.playtts', this.wrapHandlerWithMonitorCheck(this.handleTTS.bind(this)));
1686
+ this.socket.on('io.standby', this.wrapHandlerWithMonitorCheck(this.handleStandby.bind(this)));
1687
+ // io.error/io.version sind serverweite, nicht monitor-gebundene Signale ->
1688
+ // bewusst ohne wrapHandlerWithMonitorCheck registriert.
1689
+ this.socket.on('io.error', data => this.handleServerError(data));
1690
+ this.socket.on('io.version', serverId => this.handleServerVersion(serverId));
1691
+
1692
+ this.socket.onAny((event, ...args) => {
1693
+ try {
1694
+ const now = Date.now();
1695
+ if (event !== this._lastDebugEvent.event || now - this._lastDebugEvent.ts > 5000) {
1696
+ this._lastDebugEvent = { event, ts: now };
1697
+ this.setField('debug.lastEvent', {
1698
+ event,
1699
+ ts: new Date().toISOString(),
1700
+ argsCount: args.length,
1701
+ }).catch(() => {});
1702
+ }
1703
+ } catch {
1704
+ /* ignore */
1705
+ }
1706
+ });
1707
+ } catch (e) {
1708
+ this.safeWarn('connect', e);
1709
+ this.connecting = false;
1710
+ }
1711
+ }
1712
+
1713
+ /* Intervall: Restzeit bis Einsatzende. */
1714
+ startRestzeitInterval() {
1715
+ this.restzeitInterval = this.setInterval(async () => {
1716
+ try {
1717
+ const s = await this.getStateAsync('einsatz.ablaufzeit');
1718
+ if (!s || s.val === undefined || s.val === null || s.val === '') {
1719
+ await this.updateRestzeit(0);
1720
+ return;
1721
+ }
1722
+ const end = new Date(s.val);
1723
+ if (isNaN(end.getTime())) {
1724
+ await this.updateRestzeit(0);
1725
+ return;
1726
+ }
1727
+ const rest = Math.max(0, Math.floor((end.getTime() - Date.now()) / 1000));
1728
+ await this.updateRestzeit(rest);
1729
+ } catch {
1730
+ await this.updateRestzeit(0);
1731
+ }
1732
+ }, 1000);
1733
+ }
1734
+
1735
+ async updateRestzeit(rest) {
1736
+ if (this._lastRestzeit !== rest) {
1737
+ this._lastRestzeit = rest;
1738
+ try {
1739
+ await this.setStateAsync('status.restzeit', rest, true);
1740
+ } catch {
1741
+ /* ignore */
1742
+ }
1743
+ }
1744
+ }
1745
+ }
1746
+
1747
+ if (require.main !== module) {
1748
+ module.exports = options => new WaipWeb(options);
1749
+ } else {
1750
+ new WaipWeb();
1751
+ }