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/LICENSE +21 -0
- package/README.md +719 -0
- package/admin/jsonConfig.json +72 -0
- package/admin/waip-web.png +0 -0
- package/io-package.json +196 -0
- package/main.js +1751 -0
- package/package.json +51 -0
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 ä, 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
|
+
}
|