iobroker.metermaster 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js ADDED
@@ -0,0 +1,2479 @@
1
+ 'use strict';
2
+
3
+ const utils = require('@iobroker/adapter-core');
4
+ const http = require('node:http');
5
+ const crypto = require('node:crypto');
6
+ const https = require('node:https');
7
+ const { exec } = require('node:child_process');
8
+
9
+ const CURRENT_VERSION = '0.9.1';
10
+ const GITHUB_REPO = 'MPunktBPunkt/ioBroker.metermaster';
11
+ const GITHUB_URL = 'https://github.com/MPunktBPunkt/ioBroker.metermaster';
12
+
13
+ // Node gilt als online wenn lastSeen < NODE_ONLINE_SEC Sekunden
14
+ const NODE_ONLINE_SEC = 120;
15
+
16
+ const adapter = new utils.Adapter('metermaster');
17
+
18
+ let server = null;
19
+ let readingsReceived = 0;
20
+
21
+ // ─── In-Memory Datencache ─────────────────────────────────────────────────────
22
+ // Struktur: receivedData[house][apartment][meter] = { latest, latestDate, unit, typeName, history[] }
23
+ const receivedData = {};
24
+
25
+ // ─── ESP32 Node-Cache ─────────────────────────────────────────────────────────
26
+ // Struktur: nodesCache[mac] = { mac, ip, name, version, lastSeen, config, configAck }
27
+ const nodesCache = {};
28
+
29
+ function cacheReading(house, apt, meter, value, unit, typeName, readingDate, ts) {
30
+ if (!receivedData[house]) receivedData[house] = {};
31
+ if (!receivedData[house][apt]) receivedData[house][apt] = {};
32
+ if (!receivedData[house][apt][meter]) receivedData[house][apt][meter] = { unit, typeName, history: [] };
33
+
34
+ const entry = receivedData[house][apt][meter];
35
+ entry.latest = value;
36
+ entry.latestDate = readingDate;
37
+ entry.unit = unit;
38
+ entry.typeName = typeName;
39
+
40
+ // Nur in Cache-History hinzufügen wenn noch nicht vorhanden
41
+ if (!entry.history.some(h => h.ts === ts)) {
42
+ entry.history.push({ value, readingDate, ts });
43
+ entry.history.sort((a, b) => a.ts - b.ts);
44
+ }
45
+ }
46
+
47
+ // ─── Log-System ───────────────────────────────────────────────────────────────
48
+ const logBuffer = [];
49
+ let logBufferMaxSize = 500;
50
+
51
+ const LVL = { DEBUG: 'debug', INFO: 'info', WARN: 'warn', ERROR: 'error' };
52
+ const CAT = { SYSTEM: 'SYSTEM', AUTH: 'AUTH', CONNECT: 'CONNECT', DATAPOINT: 'DATAPOINT', SYNC: 'SYNC', HISTORY: 'HISTORY', IMPORT: 'IMPORT', NODE: 'NODE' };
53
+
54
+ function log(level, category, message, detail) {
55
+ const fullMsg = detail ? `[${category}] ${message} — ${detail}` : `[${category}] ${message}`;
56
+ switch (level) {
57
+ case LVL.DEBUG: adapter.log.debug(fullMsg); break;
58
+ case LVL.WARN: adapter.log.warn(fullMsg); break;
59
+ case LVL.ERROR: adapter.log.error(fullMsg); break;
60
+ default: adapter.log.info(fullMsg); break;
61
+ }
62
+ if (!adapter.config.verboseLogging && level === LVL.DEBUG) return;
63
+ logBuffer.push({ ts: Date.now(), level, category, message, detail: detail || null });
64
+ while (logBuffer.length > logBufferMaxSize) logBuffer.shift();
65
+ }
66
+
67
+ // ─── ready ────────────────────────────────────────────────────────────────────
68
+ adapter.on('ready', async () => {
69
+ logBufferMaxSize = parseInt(adapter.config?.logBufferSize) || 500;
70
+ log(LVL.INFO, CAT.SYSTEM, `MeterMaster Adapter v${CURRENT_VERSION} gestartet`,
71
+ `Port: ${adapter.config.port || 8089} | Logging: ${adapter.config.verboseLogging ? 'ausführlich' : 'standard'} | Puffer: ${logBufferMaxSize}`);
72
+
73
+ await adapter.setStateAsync('info.connection', { val: false, ack: true });
74
+
75
+ // readingsReceived aus persistentem State wiederherstellen
76
+ const savedRx = await adapter.getStateAsync('info.readingsReceived');
77
+ if (savedRx && typeof savedRx.val === 'number') {
78
+ readingsReceived = savedRx.val;
79
+ }
80
+
81
+ // In-Memory-Cache aus gespeicherten ioBroker-States wiederherstellen
82
+ await restoreCacheFromStates();
83
+ await restoreNodesFromStates();
84
+
85
+ // ESP32-Node-States beobachten (Heartbeat-Erkennung via simple-api)
86
+ adapter.subscribeStates('nodes.*');
87
+
88
+ startHttpServer();
89
+ });
90
+
91
+ adapter.on('unload', (callback) => {
92
+ log(LVL.INFO, CAT.SYSTEM, 'Adapter wird gestoppt');
93
+ try { if (server) { server.close(() => callback()); } else { callback(); } }
94
+ catch (e) { callback(); }
95
+ });
96
+
97
+ // ─── State-Change-Handler (ESP32 Heartbeats via simple-api) ──────────────────
98
+ adapter.on('stateChange', async (id, state) => {
99
+ // Eigene Writes (ack: true) ignorieren – nur externe Änderungen verarbeiten
100
+ if (!state || state.ack) return;
101
+ if (!state || state.val === null) return;
102
+ const ns = `${adapter.namespace}.`;
103
+ const relative = id.startsWith(ns) ? id.slice(ns.length) : id;
104
+ const parts = relative.split('.');
105
+ if (parts.length < 3 || parts[0] !== 'nodes') return;
106
+
107
+ const mac = parts[1];
108
+ const field = parts.slice(2).join('.');
109
+
110
+ if (!nodesCache[mac]) nodesCache[mac] = { mac };
111
+
112
+ if (field === 'ip') nodesCache[mac].ip = String(state.val);
113
+ if (field === 'name') nodesCache[mac].name = String(state.val);
114
+ if (field === 'version') nodesCache[mac].version = String(state.val);
115
+ if (field === 'lastSeen') nodesCache[mac].lastSeen = Number(state.val);
116
+ if (field === 'configAck') nodesCache[mac].configAck = String(state.val);
117
+ if (field === 'config') nodesCache[mac].config = String(state.val);
118
+ if (field === 'cmd' && state.val) nodesCache[mac].cmd = String(state.val);
119
+
120
+ if (field === 'lastSeen') {
121
+ const n = nodesCache[mac];
122
+ log(LVL.INFO, CAT.NODE, `Heartbeat`, `${mac} | IP: ${n.ip || '?'} | v${n.version || '?'} | ${n.name || 'unbenannt'}`);
123
+ await ensureNodeStates(mac);
124
+ }
125
+ });
126
+
127
+ // ─── Cache-Wiederherstellung beim Start ───────────────────────────────────────
128
+ async function restoreCacheFromStates() {
129
+ try {
130
+ // getStatesAsync('*') gibt Keys MIT vollständigem Namespace zurück:
131
+ // "metermaster.0.MeinHaus.Westerheim.Wasseruhr.readings.latest"
132
+ const allStates = await adapter.getStatesAsync('*');
133
+ if (!allStates) return;
134
+
135
+ const ns = `${adapter.namespace}.`; // "metermaster.0."
136
+
137
+ const latestKeys = Object.keys(allStates).filter(k =>
138
+ k.endsWith('.readings.latest') && allStates[k] && allStates[k].val !== null
139
+ );
140
+
141
+ if (latestKeys.length === 0) {
142
+ log(LVL.DEBUG, CAT.SYSTEM, 'Cache-Wiederherstellung', 'Keine gespeicherten Ablesungen gefunden');
143
+ return;
144
+ }
145
+
146
+ let restored = 0;
147
+ for (const key of latestKeys) {
148
+ // Key ohne Namespace: "MeinHaus.Westerheim.Wasseruhr.readings.latest"
149
+ const relative = key.startsWith(ns) ? key.slice(ns.length) : key;
150
+ const parts = relative.split('.');
151
+ // Letzten 2 Teile ("readings", "latest") entfernen → [house, apt, meter, ...]
152
+ const segments = parts.slice(0, parts.length - 2);
153
+ if (segments.length < 3) continue;
154
+ const [house, apt, ...meterParts] = segments;
155
+ const meter = meterParts.join('.');
156
+
157
+ const base = `${ns}${segments.join('.')}`; // vollständiger Pfad mit Namespace
158
+
159
+ const latest = allStates[key]?.val;
160
+ const latestDate = allStates[`${base}.readings.latestDate`]?.val || '';
161
+ const unit = allStates[`${base}.unit`]?.val || '';
162
+ const typeName = allStates[`${base}.typeName`]?.val || '';
163
+ const histRaw = allStates[`${base}.readings.history`]?.val || '[]';
164
+
165
+ let history = [];
166
+ try { history = JSON.parse(histRaw); if (!Array.isArray(history)) history = []; } catch {}
167
+
168
+ if (!receivedData[house]) receivedData[house] = {};
169
+ if (!receivedData[house][apt]) receivedData[house][apt] = {};
170
+ receivedData[house][apt][meter] = { latest, latestDate, unit, typeName, history };
171
+ restored++;
172
+ }
173
+
174
+ log(LVL.INFO, CAT.SYSTEM, `Cache wiederhergestellt`, `${restored} Zähler aus ioBroker-States geladen`);
175
+ } catch (e) {
176
+ log(LVL.WARN, CAT.SYSTEM, 'Cache-Wiederherstellung fehlgeschlagen', e.message);
177
+ }
178
+ }
179
+
180
+ // ─── ESP32 Node-Wiederherstellung beim Start ──────────────────────────────────
181
+ async function restoreNodesFromStates() {
182
+ try {
183
+ const allStates = await adapter.getStatesAsync('nodes.*');
184
+ if (!allStates) return;
185
+ const ns = `${adapter.namespace}.`;
186
+ let count = 0;
187
+
188
+ for (const [key, state] of Object.entries(allStates)) {
189
+ if (!state || state.val === null) continue;
190
+ const relative = key.startsWith(ns) ? key.slice(ns.length) : key;
191
+ const parts = relative.split('.');
192
+ if (parts.length < 3 || parts[0] !== 'nodes') continue;
193
+ const mac = parts[1];
194
+ const field = parts.slice(2).join('.');
195
+ if (!nodesCache[mac]) { nodesCache[mac] = { mac }; count++; }
196
+ if (field === 'ip') nodesCache[mac].ip = String(state.val);
197
+ if (field === 'name') nodesCache[mac].name = String(state.val);
198
+ if (field === 'version') nodesCache[mac].version = String(state.val);
199
+ if (field === 'lastSeen') nodesCache[mac].lastSeen = Number(state.val);
200
+ if (field === 'configAck') nodesCache[mac].configAck = String(state.val);
201
+ if (field === 'config') nodesCache[mac].config = String(state.val);
202
+ if (field === 'cmd' && state.val) nodesCache[mac].cmd = String(state.val);
203
+ }
204
+ if (count > 0) log(LVL.INFO, CAT.NODE, `Nodes wiederhergestellt`, `${count} ESP32-Node(s) aus States geladen`);
205
+ else log(LVL.DEBUG, CAT.NODE, 'Keine registrierten Nodes gefunden');
206
+ } catch (e) {
207
+ log(LVL.WARN, CAT.NODE, 'Node-Wiederherstellung fehlgeschlagen', e.message);
208
+ }
209
+ }
210
+
211
+ // ─── ESP32 Node States anlegen ────────────────────────────────────────────────
212
+ async function ensureNodeStates(mac) {
213
+ const base = `nodes.${mac}`;
214
+ await ensureChannel('nodes', 'ESP32 Nodes');
215
+ await ensureChannel(base, `ESP32 Node ${mac}`);
216
+ await ensureState(`${base}.ip`, { name: 'IP-Adresse', type: 'string', role: 'info.ip', read: true, write: false });
217
+ await ensureState(`${base}.name`, { name: 'Gerätename', type: 'string', role: 'info.name', read: true, write: false });
218
+ await ensureState(`${base}.version`, { name: 'Firmware-Version', type: 'string', role: 'info.version', read: true, write: false });
219
+ await ensureState(`${base}.lastSeen`, { name: 'Zuletzt gesehen (ms)', type: 'number', role: 'value.time', read: true, write: false });
220
+ await ensureState(`${base}.config`, { name: 'Konfiguration (JSON)', type: 'string', role: 'value', read: true, write: true });
221
+ await ensureState(`${base}.configAck`, { name: 'Config-Quittierung', type: 'string', role: 'value', read: true, write: false });
222
+ await ensureState(`${base}.cmd`, { name: 'Befehl (JSON)', type: 'string', role: 'value', read: true, write: true });
223
+ }
224
+
225
+ // ─── HTTP-Server ──────────────────────────────────────────────────────────────
226
+ function startHttpServer() {
227
+ const port = parseInt(adapter.config.port) || 8089;
228
+ const user = (adapter.config.user || '').trim();
229
+ const password = (adapter.config.password || '').trim();
230
+
231
+ server = http.createServer((req, res) => {
232
+ res.setHeader('Access-Control-Allow-Origin', '*');
233
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
234
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
235
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
236
+
237
+ const url = req.url.split('?')[0];
238
+ const clientIp = req.socket.remoteAddress || '?';
239
+
240
+ // Web-UI und read-only API ohne Auth
241
+ if (req.method === 'GET' && (url === '/' || url === '/logs' || url === '/data' || url === '/import' || url === '/nodes')) {
242
+ serveWebApp(res, port); return;
243
+ }
244
+ if (req.method === 'GET' && url === '/api/logs') { serveLogsJson(req, res); return; }
245
+ if (req.method === 'GET' && url === '/api/stats') { serveStats(res); return; }
246
+ if (req.method === 'GET' && url === '/api/data') { serveDataJson(res); return; }
247
+ if (req.method === 'GET' && url === '/api/version') { serveVersion(res); return; }
248
+ if (req.method === 'GET' && url === '/api/nodes') { serveNodesJson(res); return; }
249
+ if (req.method === 'GET' && url === '/api/discover'){ serveDiscoverJson(res); return; }
250
+ if (req.method === 'POST' && url === '/api/update') { handleUpdate(req, res); return; }
251
+
252
+ // ESP32 Node-Registrierung (kein Auth – ESP32 kennt keine Zugangsdaten)
253
+ if (req.method === 'POST' && url === '/api/register') {
254
+ readBody(req, b => handleNodeRegister(b, res, clientIp)); return;
255
+ }
256
+ // ESP32 Config-Poll (kein Auth)
257
+ const configPollMatch = url.match(/^\/api\/nodes\/([A-Fa-f0-9]+)\/config$/);
258
+ if (req.method === 'GET' && configPollMatch) {
259
+ handleNodeConfigPoll(configPollMatch[1].toUpperCase(), res); return;
260
+ }
261
+ // ESP32 ConfigAck (kein Auth)
262
+ const ackMatch = url.match(/^\/api\/nodes\/([A-Fa-f0-9]+)\/configAck$/);
263
+ if (req.method === 'POST' && ackMatch) {
264
+ readBody(req, b => handleNodeAck(ackMatch[1].toUpperCase(), b, res)); return;
265
+ }
266
+
267
+ // Favicon ohne Auth durchlassen (Browser ruft das automatisch ab)
268
+ if (url === '/favicon.ico') { res.writeHead(204); res.end(); return; }
269
+
270
+ // Basic Auth für schreibende Endpunkte
271
+ if (user && password) {
272
+ const authHeader = req.headers['authorization'] || '';
273
+ if (!authHeader.startsWith('Basic ')) {
274
+ log(LVL.WARN, CAT.AUTH, `Kein Auth-Header`, `IP: ${clientIp} | ${url}`);
275
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="MeterMaster"' });
276
+ res.end(JSON.stringify({ error: 'Authentifizierung erforderlich' })); return;
277
+ }
278
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
279
+ const colonIdx = decoded.indexOf(':');
280
+ const reqUser = colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded;
281
+ const reqPass = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : '';
282
+ let authOk = false;
283
+ try {
284
+ const maxU = Math.max(reqUser.length, user.length, 1);
285
+ const maxP = Math.max(reqPass.length, password.length, 1);
286
+ const uBuf = Buffer.alloc(maxU); Buffer.from(reqUser, 'utf-8').copy(uBuf);
287
+ const pBuf = Buffer.alloc(maxP); Buffer.from(reqPass, 'utf-8').copy(pBuf);
288
+ const uRef = Buffer.alloc(maxU); Buffer.from(user, 'utf-8').copy(uRef);
289
+ const pRef = Buffer.alloc(maxP); Buffer.from(password, 'utf-8').copy(pRef);
290
+ authOk = crypto.timingSafeEqual(uBuf, uRef) && crypto.timingSafeEqual(pBuf, pRef);
291
+ } catch { authOk = false; }
292
+ if (!authOk) {
293
+ log(LVL.WARN, CAT.AUTH, `Ungültige Zugangsdaten`, `IP: ${clientIp} | User: "${reqUser}"`);
294
+ res.writeHead(403); res.end(JSON.stringify({ error: 'Ungültige Zugangsdaten' })); return;
295
+ }
296
+ log(LVL.DEBUG, CAT.AUTH, `Auth OK`, `IP: ${clientIp} | User: "${reqUser}"`);
297
+ }
298
+
299
+ if (req.method === 'GET' && url === '/api/ping') { handlePing(res, clientIp); }
300
+ else if (req.method === 'POST' && url === '/api/reading') { readBody(req, b => handleReading(b, res, clientIp)); }
301
+ else if (req.method === 'POST' && url === '/api/readings') { readBody(req, b => handleReadings(b, res, clientIp)); }
302
+ else if (req.method === 'POST' && url === '/api/import') { readBody(req, b => handleImport(b, res, clientIp)); }
303
+ else {
304
+ // Node-Config: POST /api/nodes/{MAC}/config
305
+ const nodeMatch = url.match(/^\/api\/nodes\/([A-Fa-f0-9]+)\/config$/);
306
+ if (req.method === 'POST' && nodeMatch) {
307
+ readBody(req, b => handleNodeConfig(nodeMatch[1].toUpperCase(), b, res, clientIp));
308
+ } else {
309
+ // Node-Cmd: POST /api/nodes/{MAC}/cmd
310
+ const cmdMatch = url.match(/^\/api\/nodes\/([A-Fa-f0-9]+)\/cmd$/);
311
+ if (req.method === 'POST' && cmdMatch) {
312
+ readBody(req, b => handleNodeCmd(cmdMatch[1].toUpperCase(), b, res));
313
+ } else {
314
+ log(LVL.WARN, CAT.CONNECT, `Unbekannte URL`, `${req.method} ${url} von ${clientIp}`);
315
+ res.writeHead(404); res.end(JSON.stringify({ error: 'Nicht gefunden' }));
316
+ }
317
+ }
318
+ }
319
+ });
320
+
321
+ server.on('error', err => {
322
+ log(LVL.ERROR, CAT.SYSTEM, `HTTP-Fehler: ${err.message}`,
323
+ err.code === 'EADDRINUSE' ? `Port ${port} belegt!` : undefined);
324
+ });
325
+ server.listen(port, '0.0.0.0', () => {
326
+ log(LVL.INFO, CAT.SYSTEM, `Lauscht auf Port ${port}`, `Web-UI: http://IP:${port}/`);
327
+ adapter.setState('info.connection', { val: true, ack: true });
328
+ });
329
+ }
330
+
331
+ // ─── Ping ─────────────────────────────────────────────────────────────────────
332
+ function handlePing(res, clientIp) {
333
+ log(LVL.DEBUG, CAT.CONNECT, `Ping`, `IP: ${clientIp}`);
334
+ sendJson(res, 200, { ok: true, adapter: 'metermaster', version: CURRENT_VERSION, received: readingsReceived });
335
+ }
336
+
337
+ // ─── Validierung ─────────────────────────────────────────────────────────────
338
+ function validateReading(data) {
339
+ if (!data || typeof data !== 'object') return 'Kein Objekt';
340
+ if (!data.house || typeof data.house !== 'string') return 'Pflichtfeld fehlt: house';
341
+ if (!data.apartment || typeof data.apartment !== 'string') return 'Pflichtfeld fehlt: apartment';
342
+ if (!data.meter || typeof data.meter !== 'string') return 'Pflichtfeld fehlt: meter';
343
+ if (data.value === undefined || data.value === null) return 'Pflichtfeld fehlt: value';
344
+ if (isNaN(parseFloat(data.value))) return 'value muss eine Zahl sein';
345
+ if (!data.readingDate) return 'Pflichtfeld fehlt: readingDate';
346
+ if (isNaN(new Date(data.readingDate).getTime())) return 'readingDate: kein gültiges Datum';
347
+ return null;
348
+ }
349
+
350
+ // ─── Einzelne Ablesung ────────────────────────────────────────────────────────
351
+ async function handleReading(body, res, clientIp) {
352
+ let data;
353
+ try { data = JSON.parse(body); } catch {
354
+ log(LVL.WARN, CAT.SYNC, `Ungültiges JSON`, `IP: ${clientIp}`);
355
+ sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
356
+ }
357
+ const err = validateReading(data);
358
+ if (err) { log(LVL.WARN, CAT.SYNC, `Validierungsfehler`, err); sendJson(res, 422, { error: err }); return; }
359
+ log(LVL.INFO, CAT.SYNC, `Ablesung empfangen`,
360
+ `${data.house}/${data.apartment}/${data.meter} = ${data.value} ${data.unit||''} (${data.readingDate})`);
361
+ try {
362
+ const path = await storeReading(data);
363
+ sendJson(res, 200, { ok: true, path });
364
+ } catch (e) {
365
+ log(LVL.ERROR, CAT.SYNC, `Speicherfehler`, e.message);
366
+ sendJson(res, 500, { error: e.message });
367
+ }
368
+ }
369
+
370
+ // ─── Batch ────────────────────────────────────────────────────────────────────
371
+ async function handleReadings(body, res, clientIp) {
372
+ let items;
373
+ try { items = JSON.parse(body); if (!Array.isArray(items)) items = [items]; } catch {
374
+ log(LVL.WARN, CAT.SYNC, `Ungültiges JSON im Batch`, `IP: ${clientIp}`);
375
+ sendJson(res, 400, { error: 'Array erwartet' }); return;
376
+ }
377
+ log(LVL.INFO, CAT.SYNC, `Batch empfangen`, `IP: ${clientIp} | ${items.length} Ablesungen`);
378
+ let stored = 0, failed = 0;
379
+ const errors = [];
380
+ for (const data of items) {
381
+ const err = validateReading(data);
382
+ if (err) { failed++; errors.push(`${data.meter||'?'}: ${err}`); continue; }
383
+ try { await storeReading(data); stored++; }
384
+ catch (e) { failed++; errors.push(`${data.meter||'?'}: ${e.message}`); log(LVL.ERROR, CAT.SYNC, `Batch-Fehler`, `${data.meter}: ${e.message}`); }
385
+ }
386
+ const summary = `${stored} gespeichert, ${failed} fehlgeschlagen`;
387
+ if (failed === 0) log(LVL.INFO, CAT.SYNC, `Batch ✓`, summary);
388
+ else log(LVL.WARN, CAT.SYNC, `Batch mit Fehlern`, summary);
389
+ sendJson(res, 200, { ok: failed === 0, stored, failed, errors });
390
+ }
391
+
392
+ // ─── Import (App-Export Schema 2.0) ──────────────────────────────────────────
393
+ async function handleImport(body, res, clientIp) {
394
+ let data;
395
+ try { data = JSON.parse(body); } catch {
396
+ log(LVL.WARN, CAT.IMPORT, `Ungültiges JSON`, `IP: ${clientIp}`);
397
+ sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
398
+ }
399
+
400
+ // Schema-Prüfung
401
+ if (!data.Apartments || !data.Meters || !data.Readings) {
402
+ sendJson(res, 422, { error: 'Ungültiges Format: Apartments, Meters und Readings erforderlich' }); return;
403
+ }
404
+
405
+ const schema = data.SchemaVersion || '1.0';
406
+ const house = data.HouseName || adapter.config.houseName || 'MeinHaus';
407
+ log(LVL.INFO, CAT.IMPORT, `Import gestartet`,
408
+ `Schema: ${schema} | ${data.Apartments.length} Wohnungen | ${data.Meters.length} Zähler | ${data.Readings.length} Ablesungen | IP: ${clientIp}`);
409
+
410
+ // ID-Maps aufbauen: JSON-Id → Objekt
411
+ const aptMap = {}; // aptId → Apartment
412
+ const meterMap = {}; // meterId → Meter
413
+
414
+ for (const apt of data.Apartments) aptMap[apt.Id] = apt;
415
+ for (const m of data.Meters) meterMap[m.Id] = m;
416
+
417
+ let stored = 0, skipped = 0, failed = 0;
418
+ const errors = [];
419
+
420
+ for (const reading of data.Readings) {
421
+ const meter = meterMap[reading.MeterId];
422
+ if (!meter) {
423
+ skipped++;
424
+ log(LVL.WARN, CAT.IMPORT, `Zähler nicht gefunden`, `MeterId: ${reading.MeterId}`);
425
+ continue;
426
+ }
427
+
428
+ const apt = meter.ApartmentId ? aptMap[meter.ApartmentId] : null;
429
+
430
+ const payload = {
431
+ house: house,
432
+ apartment: apt ? apt.Name : 'shared',
433
+ meter: meter.Name,
434
+ value: reading.Value,
435
+ unit: meter.Unit || '',
436
+ typeName: meter.TypeName || (meter.Type !== undefined ? String(meter.Type) : ''),
437
+ readingDate: reading.ReadingDate,
438
+ };
439
+
440
+ const err = validateReading(payload);
441
+ if (err) {
442
+ skipped++;
443
+ log(LVL.WARN, CAT.IMPORT, `Ungültige Ablesung übersprungen`, `${meter.Name}: ${err}`);
444
+ continue;
445
+ }
446
+
447
+ try {
448
+ await storeReading(payload);
449
+ stored++;
450
+ } catch (e) {
451
+ failed++;
452
+ errors.push(`${meter.Name}: ${e.message}`);
453
+ log(LVL.ERROR, CAT.IMPORT, `Speicherfehler`, `${meter.Name}: ${e.message}`);
454
+ }
455
+ }
456
+
457
+ const summary = `${stored} importiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`;
458
+ if (failed === 0) log(LVL.INFO, CAT.IMPORT, `Import abgeschlossen ✓`, summary);
459
+ else log(LVL.WARN, CAT.IMPORT, `Import mit Fehlern`, summary);
460
+
461
+ sendJson(res, 200, { ok: failed === 0, stored, skipped, failed, errors, summary });
462
+ }
463
+
464
+ // ─── Kernspeicherlogik ────────────────────────────────────────────────────────
465
+ async function storeReading(data) {
466
+ const house = sanitize(data.house);
467
+ const apt = sanitize(data.apartment);
468
+ const meter = sanitize(data.meter);
469
+ const base = `${house}.${apt}.${meter}`;
470
+ const ts = new Date(data.readingDate).getTime();
471
+ const value = parseFloat(data.value);
472
+
473
+ const isNew = await ensureChannel(`${house}`, data.house);
474
+ await ensureChannel(`${house}.${apt}`, data.apartment);
475
+ await ensureChannel(`${base}`, data.meter);
476
+ await ensureChannel(`${base}.readings`, 'Ablesungen');
477
+
478
+ if (isNew) log(LVL.INFO, CAT.DATAPOINT, `Neuer Zähler`, `metermaster.0.${base}`);
479
+
480
+ const dpNew = await ensureState(`${base}.readings.latest`, { name: `${data.meter} – Letzter Wert`, type: 'number', role: 'value', unit: data.unit||'', read: true, write: false });
481
+ await ensureState(`${base}.readings.latestDate`, { name: 'Ablesedatum', type: 'string', role: 'value.datetime', read: true, write: false });
482
+ await ensureState(`${base}.name`, { name: 'Zählername', type: 'string', role: 'info.name', read: true, write: false });
483
+ await ensureState(`${base}.unit`, { name: 'Einheit', type: 'string', role: 'value.unit', read: true, write: false });
484
+ await ensureState(`${base}.typeName`, { name: 'Zählertyp', type: 'string', role: 'info.type', read: true, write: false });
485
+ await ensureState(`${base}.readings.history`, { name: 'Historische Ablesungen', type: 'array', role: 'list', read: true, write: false });
486
+
487
+ if (dpNew) log(LVL.INFO, CAT.DATAPOINT, `Datenpunkte angelegt`, `${base}.readings.{latest,latestDate,history} + name/unit/typeName`);
488
+
489
+ await adapter.setStateAsync(`${base}.readings.latest`, { val: value, ts, ack: true });
490
+ await adapter.setStateAsync(`${base}.readings.latestDate`, { val: data.readingDate, ts, ack: true });
491
+ await adapter.setStateAsync(`${base}.name`, { val: data.meter, ts, ack: true });
492
+ await adapter.setStateAsync(`${base}.unit`, { val: data.unit||'', ts, ack: true });
493
+ await adapter.setStateAsync(`${base}.typeName`, { val: data.typeName||'', ts, ack: true });
494
+
495
+ log(LVL.DEBUG, CAT.SYNC, `State geschrieben`, `metermaster.0.${base} = ${value} ${data.unit||''} | ${data.readingDate}`);
496
+
497
+ const histResult = await updateHistory(base, { value, unit: data.unit||'', readingDate: data.readingDate, ts });
498
+ if (histResult === 'added') log(LVL.DEBUG, CAT.HISTORY, `Historie +1`, `${base} @ ${data.readingDate}`);
499
+ if (histResult === 'duplicate') log(LVL.DEBUG, CAT.HISTORY, `Duplikat`, `${base} @ ${data.readingDate}`);
500
+
501
+ // In-Memory-Cache aktualisieren
502
+ cacheReading(house, apt, meter, value, data.unit||'', data.typeName||'', data.readingDate, ts);
503
+
504
+ readingsReceived++;
505
+ await adapter.setStateAsync('info.lastSync', { val: Date.now(), ack: true });
506
+ await adapter.setStateAsync('info.readingsReceived', { val: readingsReceived, ack: true });
507
+ return base;
508
+ }
509
+
510
+ // ─── Historie ─────────────────────────────────────────────────────────────────
511
+ async function updateHistory(base, entry) {
512
+ const stateId = `${base}.readings.history`;
513
+ const keep = parseInt(adapter.config.keepHistory) || 0;
514
+ let history = [];
515
+ try {
516
+ const ex = await adapter.getStateAsync(stateId);
517
+ if (ex && ex.val) { history = JSON.parse(ex.val); if (!Array.isArray(history)) history = []; }
518
+ } catch { history = []; }
519
+ if (history.some(h => h.ts === entry.ts)) return 'duplicate';
520
+ history.push(entry);
521
+ history.sort((a, b) => a.ts - b.ts);
522
+ if (keep > 0 && history.length > keep) history = history.slice(history.length - keep);
523
+ await adapter.setStateAsync(stateId, { val: JSON.stringify(history), ts: entry.ts, ack: true });
524
+ return 'added';
525
+ }
526
+
527
+ // ─── Objekt-Helfer ────────────────────────────────────────────────────────────
528
+ async function ensureChannel(id, name) {
529
+ const ex = await adapter.getObjectAsync(id).catch(() => null);
530
+ if (ex) return false;
531
+ await adapter.setObjectNotExistsAsync(id, { type: 'channel', common: { name: name||id }, native: {} });
532
+ return true;
533
+ }
534
+ async function ensureState(id, common) {
535
+ const ex = await adapter.getObjectAsync(id).catch(() => null);
536
+ if (ex) return false;
537
+ await adapter.setObjectNotExistsAsync(id, {
538
+ type: 'state',
539
+ common: { ...common,
540
+ read: common.read !== undefined ? common.read : true,
541
+ write: common.write !== undefined ? common.write : false,
542
+ def: common.type === 'number' ? 0 : (common.type === 'array' ? '[]' : '')
543
+ },
544
+ native: {}
545
+ });
546
+ return true;
547
+ }
548
+
549
+ // ─── API Endpunkte ────────────────────────────────────────────────────────────
550
+ function serveDataJson(res) {
551
+ sendJson(res, 200, { data: receivedData, receivedTotal: readingsReceived });
552
+ }
553
+ function serveLogsJson(req, res) {
554
+ const u = new URL(req.url, 'http://localhost');
555
+ const since = parseInt(u.searchParams.get('since') || '0');
556
+ const level = u.searchParams.get('level') || '';
557
+ const category = u.searchParams.get('category') || '';
558
+ const limit = parseInt(u.searchParams.get('limit') || '200');
559
+ let entries = logBuffer.filter(e => e.ts > since);
560
+ if (level) entries = entries.filter(e => e.level === level);
561
+ if (category) entries = entries.filter(e => e.category === category);
562
+ sendJson(res, 200, {
563
+ entries: entries.slice(-limit),
564
+ total: logBuffer.length,
565
+ maxSize: logBufferMaxSize,
566
+ newest: logBuffer.length ? logBuffer[logBuffer.length-1].ts : 0
567
+ });
568
+ }
569
+ function serveStats(res) {
570
+ const nodeCount = Object.keys(nodesCache).length;
571
+ const onlineCount = Object.values(nodesCache).filter(n =>
572
+ n.lastSeen && (Date.now() - n.lastSeen) < NODE_ONLINE_SEC * 1000
573
+ ).length;
574
+ sendJson(res, 200, {
575
+ adapter: 'metermaster', version: CURRENT_VERSION,
576
+ readingsReceived, logEntries: logBuffer.length, uptime: process.uptime(),
577
+ nodeCount, onlineCount
578
+ });
579
+ }
580
+
581
+ // ─── Nodes API ────────────────────────────────────────────────────────────────
582
+ function serveNodesJson(res) {
583
+ const now = Date.now();
584
+ const nodes = Object.values(nodesCache).map(n => ({
585
+ mac: n.mac,
586
+ name: n.name || '',
587
+ ip: n.ip || '',
588
+ version: n.version || '',
589
+ lastSeen: n.lastSeen || 0,
590
+ online: n.lastSeen ? (now - n.lastSeen) < NODE_ONLINE_SEC * 1000 : false,
591
+ config: n.config || '',
592
+ configAck: n.configAck || '',
593
+ }));
594
+ nodes.sort((a, b) => b.lastSeen - a.lastSeen);
595
+ sendJson(res, 200, nodes);
596
+ }
597
+
598
+ // ─── Discover: alle bekannten Zähler-State-IDs ───────────────────────────────
599
+ function serveDiscoverJson(res) {
600
+ const result = [];
601
+ const ns = adapter.namespace;
602
+ for (const [house, apts] of Object.entries(receivedData)) {
603
+ for (const [apt, meters] of Object.entries(apts)) {
604
+ for (const [meter, data] of Object.entries(meters)) {
605
+ result.push({
606
+ stateId: `${ns}.${house}.${apt}.${meter}.readings.latest`,
607
+ label: meter,
608
+ unit: data.unit || '',
609
+ typeName: data.typeName || '',
610
+ house, apartment: apt, meter,
611
+ latest: data.latest,
612
+ });
613
+ }
614
+ }
615
+ }
616
+ result.sort((a, b) =>
617
+ `${a.house}/${a.apartment}/${a.meter}`.localeCompare(`${b.house}/${b.apartment}/${b.meter}`)
618
+ );
619
+ sendJson(res, 200, result);
620
+ }
621
+
622
+ // ─── Node Config schreiben ────────────────────────────────────────────────────
623
+ async function handleNodeConfig(mac, body, res, clientIp) {
624
+ let data;
625
+ try { data = JSON.parse(body); } catch {
626
+ sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
627
+ }
628
+ const sid = (data.sid || '').trim();
629
+ const label = (data.label || '').trim();
630
+ const unit = (data.unit || '').trim();
631
+
632
+ const config = {
633
+ sid,
634
+ label,
635
+ unit,
636
+ carouselActive: data.carouselActive || false,
637
+ carouselSec: data.carouselSec || 10,
638
+ carousel: data.carousel || [],
639
+ };
640
+ const configStr = JSON.stringify(config);
641
+
642
+ try {
643
+ await ensureNodeStates(mac);
644
+ await adapter.setStateAsync(`nodes.${mac}.config`, { val: configStr, ack: true });
645
+ if (!nodesCache[mac]) nodesCache[mac] = { mac };
646
+ nodesCache[mac].config = configStr;
647
+ log(LVL.INFO, CAT.NODE, `Config gesetzt`, `${mac} \u2192 ${sid || '(leer)'} | IP: ${nodesCache[mac]?.ip || '?'}`);
648
+ sendJson(res, 200, { ok: true, mac, config });
649
+ } catch (e) {
650
+ log(LVL.ERROR, CAT.NODE, `Config-Fehler`, `${mac}: ${e.message}`);
651
+ sendJson(res, 500, { error: e.message });
652
+ }
653
+ }
654
+
655
+ // ─── Node-Befehl (POST /api/nodes/{MAC}/cmd) ──────────────────────────────────
656
+ // Unterstützte Befehle: { "ledOn": true/false }
657
+ // { "sid": "...", "label": "...", "unit": "..." } (Zähler wechseln)
658
+ async function handleNodeCmd(mac, body, res) {
659
+ let data;
660
+ try { data = JSON.parse(body); } catch {
661
+ sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
662
+ }
663
+ const cmdStr = JSON.stringify(data);
664
+ try {
665
+ await ensureNodeStates(mac);
666
+ await adapter.setStateAsync(`nodes.${mac}.cmd`, { val: cmdStr, ack: true });
667
+ if (!nodesCache[mac]) nodesCache[mac] = { mac };
668
+ nodesCache[mac].cmd = cmdStr;
669
+ const keys = Object.keys(data).join(', ');
670
+ log(LVL.INFO, CAT.NODE, `Befehl gesetzt`, `${mac} → ${keys}`);
671
+ sendJson(res, 200, { ok: true, mac, cmd: data });
672
+ } catch (e) {
673
+ log(LVL.ERROR, CAT.NODE, `Cmd-Fehler`, `${mac}: ${e.message}`);
674
+ sendJson(res, 500, { error: e.message });
675
+ }
676
+ }
677
+
678
+ // ─── Node-Registrierung via HTTP (POST /api/register) ────────────────────────
679
+ // ESP32 sendet: { mac, ip, name, version, configAck? }
680
+ // Adapter legt States an, aktualisiert Cache, antwortet mit aktuellem Config-JSON
681
+ async function handleNodeRegister(body, res, clientIp) {
682
+ let data;
683
+ try { data = JSON.parse(body); } catch {
684
+ sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
685
+ }
686
+ const mac = (data.mac || '').replace(/[^A-Fa-f0-9]/g, '').toUpperCase();
687
+ const ip = (data.ip || clientIp || '').trim();
688
+ const name = (data.name || 'ESP32 Node').trim();
689
+ const version = (data.version || '').trim();
690
+ const ack = data.configAck ? String(data.configAck) : null;
691
+
692
+ if (!mac) { sendJson(res, 400, { error: 'Feld mac fehlt' }); return; }
693
+
694
+ const ts = Date.now();
695
+ if (!nodesCache[mac]) nodesCache[mac] = { mac };
696
+ nodesCache[mac].ip = ip;
697
+ nodesCache[mac].name = name;
698
+ nodesCache[mac].version = version;
699
+ nodesCache[mac].lastSeen = ts;
700
+ if (ack) nodesCache[mac].configAck = ack;
701
+
702
+ try {
703
+ await ensureNodeStates(mac);
704
+ await adapter.setStateAsync(`nodes.${mac}.ip`, { val: ip, ack: true });
705
+ await adapter.setStateAsync(`nodes.${mac}.name`, { val: name, ack: true });
706
+ await adapter.setStateAsync(`nodes.${mac}.version`, { val: version, ack: true });
707
+ await adapter.setStateAsync(`nodes.${mac}.lastSeen`, { val: ts, ack: true });
708
+ if (ack) await adapter.setStateAsync(`nodes.${mac}.configAck`, { val: ack, ack: true });
709
+
710
+ log(LVL.INFO, CAT.NODE, `Heartbeat`, `${mac} | IP: ${ip} | v${version} | ${name}`);
711
+
712
+ // Aktuelle Config zurückgeben (null wenn noch keine gesetzt)
713
+ const config = nodesCache[mac].config || null;
714
+ sendJson(res, 200, { ok: true, mac, config });
715
+ } catch (e) {
716
+ log(LVL.ERROR, CAT.NODE, `Register-Fehler`, `${mac}: ${e.message}`);
717
+ sendJson(res, 500, { error: e.message });
718
+ }
719
+ }
720
+
721
+ // ─── Node ConfigAck (POST /api/nodes/{MAC}/configAck) ────────────────────────
722
+ // ESP32 meldet zurück dass Config übernommen wurde
723
+ async function handleNodeAck(mac, body, res) {
724
+ let data = {};
725
+ try { data = JSON.parse(body); } catch { /* ack-String ist optional */ }
726
+ const ack = data.ack || String(Date.now());
727
+
728
+ if (!nodesCache[mac]) nodesCache[mac] = { mac };
729
+ nodesCache[mac].configAck = ack;
730
+
731
+ try {
732
+ await ensureNodeStates(mac);
733
+ await adapter.setStateAsync(`nodes.${mac}.configAck`, { val: ack, ack: true });
734
+ log(LVL.INFO, CAT.NODE, `Config quittiert`, `${mac} | ack: ${ack}`);
735
+ sendJson(res, 200, { ok: true });
736
+ } catch (e) {
737
+ sendJson(res, 500, { error: e.message });
738
+ }
739
+ }
740
+
741
+ // ─── Node Config-Poll (GET /api/nodes/{MAC}/config) ───────────────────────────
742
+ // ESP32 fragt alle 15s aktuelle Config ab
743
+ async function handleNodeConfigPoll(mac, res) {
744
+ const node = nodesCache[mac];
745
+ if (!node) {
746
+ sendJson(res, 200, { ok: true, config: null, cmd: null });
747
+ return;
748
+ }
749
+ // cmd einmalig ausliefern und danach löschen
750
+ const cmd = node.cmd || null;
751
+ if (cmd) {
752
+ node.cmd = null;
753
+ try { await adapter.setStateAsync(`nodes.${mac}.cmd`, { val: '', ack: true }); } catch(_) {}
754
+ }
755
+ sendJson(res, 200, { ok: true, mac, config: node.config || null, cmd });
756
+ }
757
+
758
+ // ─── Web-Oberfläche ───────────────────────────────────────────────────────────
759
+ // ─── Versions-Check (GitHub) ──────────────────────────────────────────────────
760
+ function githubGet(path) {
761
+ return new Promise((resolve, reject) => {
762
+ const req = https.get({
763
+ hostname: 'api.github.com',
764
+ path,
765
+ headers: { 'User-Agent': 'iobroker.metermaster' }
766
+ }, res => {
767
+ let data = '';
768
+ res.on('data', c => data += c);
769
+ res.on('end', () => { try { resolve({ status: res.statusCode, body: JSON.parse(data) }); } catch(e) { reject(e); } });
770
+ });
771
+ req.on('error', reject);
772
+ req.setTimeout(8000, () => { req.destroy(); reject(new Error('Timeout')); });
773
+ });
774
+ }
775
+
776
+ async function fetchGitHubVersion() {
777
+ // 1. Versuch: Releases-API
778
+ try {
779
+ const r = await githubGet(`/repos/${GITHUB_REPO}/releases/latest`);
780
+ if (r.status === 200 && r.body.tag_name) {
781
+ return r.body.tag_name.replace(/^v/, '');
782
+ }
783
+ } catch(_) { /* weiter zum Fallback */ }
784
+
785
+ // 2. Fallback: Tags-API (wenn noch keine Releases existieren)
786
+ try {
787
+ const r = await githubGet(`/repos/${GITHUB_REPO}/tags`);
788
+ if (r.status === 200 && Array.isArray(r.body) && r.body.length > 0) {
789
+ return r.body[0].name.replace(/^v/, '');
790
+ }
791
+ } catch(_) { /* weiter */ }
792
+
793
+ return null; // kein Release und kein Tag → null statt '0.0.0'
794
+ }
795
+
796
+ function compareVersions(a, b) {
797
+ const pa = a.split('.').map(Number);
798
+ const pb = b.split('.').map(Number);
799
+ for (let i = 0; i < 3; i++) {
800
+ if ((pa[i]||0) > (pb[i]||0)) return 1;
801
+ if ((pa[i]||0) < (pb[i]||0)) return -1;
802
+ }
803
+ return 0;
804
+ }
805
+
806
+
807
+ async function serveVersion(res) {
808
+ try {
809
+ const latest = await fetchGitHubVersion();
810
+ const updateAvail = latest ? compareVersions(latest, CURRENT_VERSION) > 0 : false;
811
+ sendJson(res, 200, { current: CURRENT_VERSION, latest, updateAvailable: updateAvail });
812
+ } catch(e) {
813
+ sendJson(res, 200, { current: CURRENT_VERSION, latest: null, updateAvailable: false, error: e.message });
814
+ }
815
+ }
816
+
817
+ function handleUpdate(req, res) {
818
+ log(LVL.INFO, CAT.SYSTEM, 'Update gestartet', `von ${CURRENT_VERSION} → GitHub`);
819
+ const cmd = `iobroker upgrade metermaster && iobroker restart metermaster.0`;
820
+ exec(cmd, { timeout: 180000 }, (err, stdout, stderr) => {
821
+ const out = (stdout + '\n' + stderr).trim();
822
+ if (err) {
823
+ log(LVL.ERROR, CAT.SYSTEM, 'Update fehlgeschlagen', err.message);
824
+ sendJson(res, 500, { ok: false, error: err.message, output: out });
825
+ } else {
826
+ log(LVL.INFO, CAT.SYSTEM, 'Update erfolgreich', out.slice(0, 200));
827
+ sendJson(res, 200, { ok: true, output: out });
828
+ }
829
+ });
830
+ }
831
+
832
+
833
+
834
+ function serveWebApp(res, port) {
835
+
836
+ // SVG-Logo identisch mit appicon.svg (inline, ohne width/height-Attribute)
837
+ const LOGO_SVG = `<svg viewBox="0 0 108 108" xmlns="http://www.w3.org/2000/svg">
838
+ <defs>
839
+ <linearGradient id="lbg" x1="0" y1="0" x2="1" y2="1">
840
+ <stop offset="0%" stop-color="#3b1fa8"/>
841
+ <stop offset="100%" stop-color="#1a0e5a"/>
842
+ </linearGradient>
843
+ <linearGradient id="larc" x1="0" y1="1" x2="1" y2="0">
844
+ <stop offset="0%" stop-color="#7c3aed"/>
845
+ <stop offset="100%" stop-color="#a78bfa"/>
846
+ </linearGradient>
847
+ <filter id="lglow">
848
+ <feGaussianBlur stdDeviation="1.2" result="blur"/>
849
+ <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
850
+ </filter>
851
+ </defs>
852
+ <rect width="108" height="108" rx="24" fill="url(#lbg)"/>
853
+ <circle cx="54" cy="56" r="37" fill="none" stroke="#512BD4" stroke-width="2.5" opacity="0.6"/>
854
+ <circle cx="54" cy="56" r="33" fill="#1e1252"/>
855
+ <path d="M 37.5 84.6 A 33 33 0 1 1 70.5 84.6" fill="none" stroke="#2d1b80" stroke-width="5.5" stroke-linecap="round"/>
856
+ <path d="M 37.5 84.6 A 33 33 0 0 1 54 23" fill="none" stroke="url(#larc)" stroke-width="5.5" stroke-linecap="round" filter="url(#lglow)"/>
857
+ <g stroke="#6d4fc4" stroke-linecap="round">
858
+ <line x1="54" y1="25" x2="54" y2="30" stroke-width="2" transform="rotate(-120 54 56)"/>
859
+ <line x1="54" y1="25" x2="54" y2="28" stroke-width="1.2" transform="rotate(-96 54 56)"/>
860
+ <line x1="54" y1="25" x2="54" y2="28" stroke-width="1.2" transform="rotate(-72 54 56)"/>
861
+ <line x1="54" y1="25" x2="54" y2="30" stroke-width="2" transform="rotate(-48 54 56)"/>
862
+ <line x1="54" y1="25" x2="54" y2="28" stroke-width="1.2" transform="rotate(-24 54 56)"/>
863
+ <line x1="54" y1="25" x2="54" y2="30" stroke-width="2" transform="rotate(0 54 56)"/>
864
+ <line x1="54" y1="25" x2="54" y2="28" stroke-width="1.2" transform="rotate(24 54 56)"/>
865
+ <line x1="54" y1="25" x2="54" y2="30" stroke-width="2" transform="rotate(48 54 56)"/>
866
+ <line x1="54" y1="25" x2="54" y2="28" stroke-width="1.2" transform="rotate(72 54 56)"/>
867
+ <line x1="54" y1="25" x2="54" y2="28" stroke-width="1.2" transform="rotate(96 54 56)"/>
868
+ <line x1="54" y1="25" x2="54" y2="30" stroke-width="2" transform="rotate(120 54 56)"/>
869
+ </g>
870
+ <g transform="rotate(0 54 56)">
871
+ <line x1="54" y1="56" x2="54" y2="29" stroke="white" stroke-width="2.2" stroke-linecap="round" filter="url(#lglow)"/>
872
+ <line x1="54" y1="56" x2="54" y2="63" stroke="#512BD4" stroke-width="2.2" stroke-linecap="round"/>
873
+ </g>
874
+ <circle cx="54" cy="56" r="5" fill="#2d1b80" stroke="#7c3aed" stroke-width="1.5"/>
875
+ <circle cx="54" cy="56" r="2" fill="#c4b5fd"/>
876
+ <text x="54" y="93" font-family="Arial,sans-serif" font-weight="700" font-size="10" fill="#a78bfa" text-anchor="middle" letter-spacing="1.5">MM</text>
877
+ </svg>`;
878
+
879
+ // Typ-Icons nach typeName (spiegelt App-Icons wider)
880
+ const TYPE_ICONS = {
881
+ Electricity:'\u26A1', Gas:'\uD83D\uDD25', Water:'\uD83D\uDCA7', HotWater:'\uD83C\uDF21',
882
+ TotalWater:'\uD83D\uDCA7', ColdWater:'\u2744', Heat:'\uD83C\uDFE0', HeatMeter:'\uD83C\uDFE0',
883
+ Cooling:'\uD83E\uDDCA', Oil:'\uD83D\uDEE2', Other:'\uD83D\uDCDF'
884
+ };
885
+
886
+ const html = `<!DOCTYPE html>
887
+ <html lang="de">
888
+ <head>
889
+ <meta charset="UTF-8">
890
+ <meta name="viewport" content="width=device-width,initial-scale=1">
891
+ <title>MeterMaster</title>
892
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
893
+ <style>
894
+ /* ── Exakte App-Farben aus Colors.xaml ───────────────────────────────────── */
895
+ :root {
896
+ /* Hintergründe */
897
+ --bg-deep: #0F0B1A; /* BgDeep – Haupt-Seitenhintergrund */
898
+ --bg-surface: #1A1430; /* BgSurface – Karten/Panels */
899
+ --bg-surface2:#241C40; /* BgSurface2 – Eingabefelder */
900
+ --bg-surface3:#2E2456; /* BgSurface3 – Hover/aktiv */
901
+
902
+ /* Akzentfarben */
903
+ --primary: #7B54C4; /* Primary – Haupt-Lila */
904
+ --primary-dark:#5C35A0; /* PrimaryDark */
905
+ --primary-deep:#3D2070; /* PrimaryDeep */
906
+ --secondary: #C8B8FF; /* Secondary – helles Lila, Text-Akzent */
907
+
908
+ /* Status */
909
+ --accent: #4CAF50; /* Grün */
910
+ --danger: #F44336; /* Rot */
911
+ --warning: #FF9800; /* Orange */
912
+ --info: #2196F3; /* Blau */
913
+
914
+ /* Text */
915
+ --text: #E8E0FF; /* TextPrimary */
916
+ --text-dim: #9585BB; /* TextSecondary */
917
+ --text-muted: #5E4D8A; /* TextMuted */
918
+
919
+ /* Rahmen */
920
+ --border: #2A2050; /* BorderColor */
921
+ --border-light: #3D2E6A; /* BorderLight */
922
+
923
+ /* Log-Level-Farben */
924
+ --log-debug: #9585BB;
925
+ --log-info: #4CAF50;
926
+ --log-warn: #FF9800;
927
+ --log-error: #F44336;
928
+ }
929
+
930
+ * { box-sizing:border-box; margin:0; padding:0; }
931
+ body { background:var(--bg-deep); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; min-height:100vh; display:flex; flex-direction:column; }
932
+
933
+ /* ── Header ─────────────────────────────────────────────────────────────── */
934
+ header {
935
+ background: linear-gradient(135deg, #3D2070 0%, #1A1430 100%);
936
+ border-bottom: 1px solid var(--border-light);
937
+ padding: 10px 20px;
938
+ display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
939
+ flex-shrink: 0;
940
+ }
941
+ .logo-icon { width: 38px; height: 38px; flex-shrink: 0; }
942
+ .logo-text { display: flex; flex-direction: column; line-height: 1.15; }
943
+ .logo-title { font-size: 1.15em; font-weight: 700; color: var(--secondary); letter-spacing: .5px; }
944
+ .logo-sub { font-size: .72em; color: var(--text-dim); letter-spacing: 1.5px; text-transform: uppercase; }
945
+ .hstats { display: flex; gap: 18px; margin-left: auto; flex-wrap: wrap; align-items: center; }
946
+ .hstat { font-size: .78em; color: var(--text-dim); }
947
+ .hstat b { color: var(--secondary); }
948
+ .live-dot { font-size: .78em; color: var(--accent); }
949
+
950
+ /* ── Navigation ─────────────────────────────────────────────────────────── */
951
+ nav {
952
+ background: var(--bg-surface);
953
+ border-bottom: 1px solid var(--border);
954
+ padding: 0 20px;
955
+ display: flex; flex-shrink: 0;
956
+ }
957
+ .tab {
958
+ padding: 11px 22px; cursor: pointer !important; font-size: .88em;
959
+ color: var(--text-dim); border-bottom: 3px solid transparent;
960
+ transition: color .2s, border-color .2s; user-select: none;
961
+ pointer-events: all !important;
962
+ /* button-Reset */
963
+ background: none; border-top: none; border-left: none; border-right: none;
964
+ outline: none; font-family: inherit; -webkit-appearance: none; appearance: none;
965
+ }
966
+ .tab:hover { color: var(--text); }
967
+ .tab.active { color: var(--secondary); border-bottom-color: var(--primary); }
968
+
969
+ /* ── Seiten ──────────────────────────────────────────────────────────────── */
970
+ .page { flex:1; overflow-y:auto; padding:20px; display:none; }
971
+ .page.active { display:block; }
972
+
973
+ /* ── Daten-Tab ───────────────────────────────────────────────────────────── */
974
+ .house-block { margin-bottom: 28px; }
975
+ .house-title {
976
+ font-size: 1em; font-weight: 700; color: var(--secondary);
977
+ margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
978
+ padding-bottom: 8px; border-bottom: 1px solid var(--border);
979
+ }
980
+ .apt-block { margin-bottom: 14px; margin-left: 10px; }
981
+ .apt-title {
982
+ font-size: .88em; font-weight: 600; color: var(--text-dim);
983
+ margin-bottom: 8px; padding: 4px 10px;
984
+ border-left: 3px solid var(--primary-deep); background: var(--bg-surface);
985
+ border-radius: 0 6px 6px 0;
986
+ }
987
+ .meters-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); gap: 12px; margin-left: 20px; }
988
+
989
+ /* Zählerkarte – wie App-Karte */
990
+ .meter-card {
991
+ background: var(--bg-surface); border: 1px solid var(--border);
992
+ border-radius: 12px; padding: 14px 16px;
993
+ transition: border-color .2s, background .2s;
994
+ }
995
+ .meter-card:hover { border-color: var(--border-light); background: var(--bg-surface2); }
996
+ .mc-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
997
+ .mc-name { font-weight: 600; font-size: .95em; color: var(--text); }
998
+ .mc-badge {
999
+ font-size: .7em; font-weight: 700; padding: 2px 8px;
1000
+ border-radius: 20px; background: var(--primary-deep);
1001
+ color: var(--secondary); letter-spacing: .4px;
1002
+ }
1003
+ .mc-value-row { display: flex; align-items: baseline; gap: 4px; }
1004
+ .mc-value { font-size: 2em; font-weight: 700; color: var(--secondary); line-height: 1; }
1005
+ .mc-unit { font-size: .88em; color: var(--text-dim); }
1006
+ .mc-date { font-size: .76em; color: var(--text-muted); margin-top: 5px; }
1007
+ .mc-hist-toggle {
1008
+ display: inline-block; margin-top: 10px; font-size: .76em;
1009
+ color: var(--primary); cursor: pointer; border: none; background: none;
1010
+ padding: 0; transition: color .15s;
1011
+ }
1012
+ .mc-hist-toggle:hover { color: var(--secondary); }
1013
+ .mc-history {
1014
+ display: none; margin-top: 8px;
1015
+ border-top: 1px solid var(--border); padding-top: 8px;
1016
+ max-height: 180px; overflow-y: auto;
1017
+ }
1018
+ .mc-history.open { display: block; }
1019
+ .hist-row {
1020
+ display: flex; justify-content: space-between;
1021
+ font-size: .76em; padding: 3px 0; color: var(--text-dim);
1022
+ border-bottom: 1px solid var(--bg-surface3);
1023
+ }
1024
+ .hist-row:last-child { border: none; }
1025
+ .hist-val { color: var(--text); font-weight: 600; }
1026
+ .mc-consume {
1027
+ font-size: .76em; color: var(--accent); margin-top: 6px;
1028
+ display: flex; align-items: center; gap: 4px;
1029
+ }
1030
+ .mc-actions {
1031
+ display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap;
1032
+ }
1033
+ .mc-chart-btn, .mc-csv-btn {
1034
+ font-size: .76em; padding: 4px 10px; border-radius: 7px; cursor: pointer;
1035
+ border: 1px solid var(--border-light); background: var(--bg-surface3);
1036
+ color: var(--secondary); transition: border-color .15s, background .15s;
1037
+ }
1038
+ .mc-chart-btn:hover, .mc-csv-btn:hover {
1039
+ border-color: var(--primary); background: var(--primary-deep);
1040
+ }
1041
+ .lang-select {
1042
+ background: var(--bg-surface2); border: 1px solid var(--border-light);
1043
+ color: var(--text-dim); padding: 3px 8px; border-radius: 6px;
1044
+ font-size: .75em; font-weight: 600; cursor: pointer; outline: none;
1045
+ }
1046
+ .lang-select:hover { border-color: var(--primary); color: var(--text); }
1047
+
1048
+ .modal-overlay {
1049
+ position: fixed; inset: 0; background: rgba(15,11,26,.88);
1050
+ z-index: 9998; display: none; align-items: center; justify-content: center;
1051
+ padding: 16px;
1052
+ }
1053
+ .chart-modal {
1054
+ background: var(--bg-surface); border: 1px solid var(--border-light);
1055
+ border-radius: 16px; padding: 22px 24px; width: 100%; max-width: 820px;
1056
+ max-height: 92vh; overflow-y: auto;
1057
+ }
1058
+ .chart-modal-head {
1059
+ display: flex; justify-content: space-between; align-items: flex-start;
1060
+ gap: 12px; margin-bottom: 14px;
1061
+ }
1062
+ .chart-modal-title { font-size: 1.05em; font-weight: 700; color: var(--secondary); }
1063
+ .chart-modal-sub { font-size: .78em; color: var(--text-dim); margin-top: 3px; }
1064
+ .chart-close {
1065
+ background: none; border: none; color: var(--text-dim); font-size: 1.6em;
1066
+ cursor: pointer; line-height: 1; padding: 0 4px;
1067
+ }
1068
+ .chart-close:hover { color: var(--text); }
1069
+ .chart-range-btns { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
1070
+ .chart-range {
1071
+ background: var(--bg-surface2); border: 1px solid var(--border);
1072
+ color: var(--text-dim); padding: 4px 12px; border-radius: 7px;
1073
+ cursor: pointer; font-size: .78em; font-weight: 600;
1074
+ }
1075
+ .chart-range.active, .chart-range:hover {
1076
+ border-color: var(--primary); color: var(--secondary); background: var(--primary-deep);
1077
+ }
1078
+ .chart-yearly { margin-left: auto; }
1079
+ .chart-kpi {
1080
+ font-size: .82em; color: var(--accent); margin-bottom: 12px;
1081
+ padding: 8px 12px; background: rgba(76,175,80,.08);
1082
+ border-radius: 8px; border: 1px solid rgba(76,175,80,.2);
1083
+ }
1084
+ .chart-kpi-yearly {
1085
+ margin-top: 6px; color: var(--secondary);
1086
+ border-color: rgba(123,84,196,.25); background: rgba(123,84,196,.08);
1087
+ padding: 6px 12px; border-radius: 8px; border: 1px solid rgba(123,84,196,.25);
1088
+ }
1089
+ .chart-wrap-box {
1090
+ background: var(--bg-deep); border: 1px solid var(--border);
1091
+ border-radius: 10px; padding: 12px; margin-bottom: 12px; position: relative; height: 260px;
1092
+ }
1093
+ .chart-wrap-box.chart-wrap-sm { height: 200px; }
1094
+ .chart-modal-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
1095
+ .chart-no-data {
1096
+ text-align: center; padding: 40px 16px; color: var(--text-dim); font-size: .88em;
1097
+ }
1098
+
1099
+ .empty-state { text-align: center; padding: 70px 20px; color: var(--text-dim); }
1100
+ .empty-state .ico { font-size: 3em; margin-bottom: 14px; }
1101
+ .empty-state p { line-height: 1.7; }
1102
+
1103
+ /* ── Import-Tab ──────────────────────────────────────────────────────────── */
1104
+ .import-card {
1105
+ background: var(--bg-surface); border: 1px solid var(--border);
1106
+ border-radius: 14px; padding: 26px; max-width: 660px;
1107
+ }
1108
+ .import-card h3 { font-size: 1.05em; color: var(--secondary); margin-bottom: 8px; }
1109
+ .import-card > p { font-size: .86em; color: var(--text-dim); line-height: 1.65; margin-bottom: 18px; }
1110
+ .schema-box {
1111
+ background: var(--bg-deep); border: 1px solid var(--border);
1112
+ border-radius: 8px; padding: 12px 14px; font-family: Consolas,monospace;
1113
+ font-size: .78em; color: #90CAF9; margin-bottom: 18px;
1114
+ white-space: pre; overflow-x: auto;
1115
+ }
1116
+ .house-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
1117
+ .house-row label { font-size: .86em; color: var(--text-dim); }
1118
+ input[type=text] {
1119
+ background: var(--bg-surface2); border: 1px solid var(--border-light);
1120
+ color: var(--text); padding: 7px 12px; border-radius: 8px;
1121
+ font-size: .86em; width: 200px; outline: none;
1122
+ transition: border-color .2s;
1123
+ }
1124
+ input[type=text]:focus { border-color: var(--primary); }
1125
+ .drop-zone {
1126
+ border: 2px dashed var(--border-light); border-radius: 12px;
1127
+ padding: 34px; text-align: center; cursor: pointer;
1128
+ transition: border-color .2s, background .2s; margin-bottom: 16px;
1129
+ }
1130
+ .drop-zone:hover, .drop-zone.drag {
1131
+ border-color: var(--primary); background: rgba(123,84,196,.08);
1132
+ }
1133
+ .drop-zone .dz-ico { font-size: 2.4em; margin-bottom: 8px; }
1134
+ .drop-zone p { color: var(--text-dim); font-size: .86em; }
1135
+ input[type=file] { display: none; }
1136
+
1137
+ .preview-box {
1138
+ background: var(--bg-deep); border: 1px solid var(--border);
1139
+ border-radius: 8px; padding: 14px 16px; margin-bottom: 16px; display: none;
1140
+ }
1141
+ .preview-box h4 { font-size: .88em; color: var(--secondary); margin-bottom: 10px; }
1142
+ .preview-row {
1143
+ display: flex; justify-content: space-between;
1144
+ font-size: .84em; padding: 4px 0; border-bottom: 1px solid var(--border);
1145
+ }
1146
+ .preview-row:last-child { border: none; }
1147
+ .preview-row span { color: var(--text-dim); }
1148
+ .preview-row b { color: var(--text); }
1149
+
1150
+ .btn-row { display: flex; gap: 10px; align-items: center; }
1151
+ button.primary {
1152
+ background: var(--primary); color: #fff; border: none;
1153
+ padding: 9px 22px; border-radius: 9px; cursor: pointer;
1154
+ font-size: .88em; font-weight: 600;
1155
+ transition: background .2s, opacity .2s;
1156
+ }
1157
+ button.primary:hover { background: var(--primary-dark); }
1158
+ button.primary:disabled { opacity: .4; cursor: default; }
1159
+ button.ghost {
1160
+ background: transparent; border: 1px solid var(--border-light);
1161
+ color: var(--text-dim); padding: 8px 16px; border-radius: 9px;
1162
+ cursor: pointer; font-size: .86em; transition: border-color .2s, color .2s;
1163
+ }
1164
+ button.ghost:hover { border-color: var(--primary); color: var(--text); }
1165
+
1166
+ .result-box {
1167
+ border-radius: 8px; padding: 12px 14px; font-size: .85em;
1168
+ margin-top: 14px; display: none; line-height: 1.6;
1169
+ }
1170
+ .result-box.ok { background: rgba(76,175,80,.12); border:1px solid rgba(76,175,80,.3); color: #A5D6A7; }
1171
+ .result-box.warn { background: rgba(255,152,0,.1); border:1px solid rgba(255,152,0,.3); color: #FFCC80; }
1172
+ .result-box.err { background: rgba(244,67,54,.1); border:1px solid rgba(244,67,54,.3); color: #EF9A9A; }
1173
+
1174
+ /* ── Log-Tab ─────────────────────────────────────────────────────────────── */
1175
+ .log-toolbar {
1176
+ background: var(--bg-surface); border: 1px solid var(--border);
1177
+ border-radius: 10px; padding: 10px 14px;
1178
+ display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
1179
+ margin-bottom: 14px;
1180
+ }
1181
+ select {
1182
+ background: var(--bg-surface2); border: 1px solid var(--border-light);
1183
+ color: var(--text); padding: 6px 10px; border-radius: 7px; font-size: .83em; outline: none;
1184
+ }
1185
+ input.search {
1186
+ background: var(--bg-surface2); border: 1px solid var(--border-light);
1187
+ color: var(--text); padding: 6px 10px; border-radius: 7px;
1188
+ font-size: .83em; width: 170px; outline: none;
1189
+ }
1190
+ .lbl { font-size: .8em; color: var(--text-dim); display: flex; align-items: center; gap: 5px; }
1191
+ #lc { font-family: 'Cascadia Code','Fira Code',Consolas,monospace; font-size: .8em; }
1192
+ .le {
1193
+ display: grid; grid-template-columns: 88px 50px 88px 1fr;
1194
+ gap: 0 10px; padding: 4px 2px;
1195
+ border-bottom: 1px solid var(--bg-surface3); align-items: start;
1196
+ }
1197
+ .le:hover { background: rgba(123,84,196,.07); }
1198
+ .le.new { animation: fadeIn .4s; }
1199
+ @keyframes fadeIn { from { background: rgba(123,84,196,.22); } to { background: transparent; } }
1200
+ .ts { color: var(--text-muted); font-size: .88em; white-space: nowrap; }
1201
+ .bdg { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: .7em; font-weight: 700; }
1202
+ /* Level-Farben */
1203
+ .ld { color: var(--log-debug); }
1204
+ .li { color: var(--log-info); }
1205
+ .lw { color: var(--log-warn); }
1206
+ .le2{ color: var(--log-error); font-weight: 700; }
1207
+ /* Kategorie-Badges – subtile Hintergrundtönung */
1208
+ .cSYSTEM { color: var(--text-dim); background: rgba(94,77,138,.25); }
1209
+ .cAUTH { color: #CE93D8; background: rgba(156,39,176,.2); }
1210
+ .cCONNECT { color: #81D4FA; background: rgba(33,150,243,.2); }
1211
+ .cDATAPOINT { color: #A5D6A7; background: rgba(76,175,80,.2); }
1212
+ .cSYNC { color: #FFCC80; background: rgba(255,152,0,.2); }
1213
+ .cHISTORY { color: #CE93D8; background: rgba(123,84,196,.2); }
1214
+ .cIMPORT { color: var(--secondary); background: rgba(123,84,196,.25); }
1215
+ .msg { color: var(--text); }
1216
+ .det { color: var(--text-dim); font-size: .9em; }
1217
+ .log-empty { text-align: center; padding: 50px; color: var(--text-dim); }
1218
+
1219
+ /* ── System-Tab ──────────────────────────────────────────────────────────────── */
1220
+ .sys-card {
1221
+ background: var(--bg-surface); border: 1px solid var(--border);
1222
+ border-radius: 14px; padding: 24px 28px; max-width: 560px; margin-bottom: 18px;
1223
+ }
1224
+ .sys-card h3 { font-size: 1em; color: var(--secondary); margin-bottom: 16px; }
1225
+ .ver-row {
1226
+ display: flex; justify-content: space-between; align-items: center;
1227
+ padding: 9px 0; border-bottom: 1px solid var(--border);
1228
+ font-size: .88em;
1229
+ }
1230
+ .ver-row:last-of-type { border: none; }
1231
+ .ver-label { color: var(--text-dim); }
1232
+ .ver-val { color: var(--text); font-weight: 600; font-family: Consolas,monospace; }
1233
+ .badge-ok { background:rgba(76,175,80,.15); color:#A5D6A7; border:1px solid rgba(76,175,80,.3); padding:2px 10px; border-radius:20px; font-size:.8em; }
1234
+ .badge-new { background:rgba(33,150,243,.15); color:#90CAF9; border:1px solid rgba(33,150,243,.3); padding:2px 10px; border-radius:20px; font-size:.8em; }
1235
+ .badge-warn { background:rgba(255,152,0,.12); color:#FFCC80; border:1px solid rgba(255,152,0,.3); padding:2px 10px; border-radius:20px; font-size:.8em; }
1236
+ .badge-err { background:rgba(244,67,54,.12); color:#EF9A9A; border:1px solid rgba(244,67,54,.3); padding:2px 10px; border-radius:20px; font-size:.8em; }
1237
+ .sys-btn-row { display:flex; gap:10px; margin-top:18px; flex-wrap:wrap; align-items:center; }
1238
+ .sys-out {
1239
+ margin-top:14px; background:var(--bg-deep); border:1px solid var(--border);
1240
+ border-radius:8px; padding:12px 14px; font-family:Consolas,monospace;
1241
+ font-size:.78em; color:var(--text-dim); max-height:180px; overflow-y:auto;
1242
+ white-space:pre-wrap; display:none;
1243
+ }
1244
+ .cmd-row {
1245
+ display:flex; align-items:center; gap:8px;
1246
+ background:var(--bg-deep); border:1px solid var(--border);
1247
+ border-radius:8px; padding:8px 10px;
1248
+ }
1249
+ .cmd-code {
1250
+ flex:1; font-family:Consolas,monospace; font-size:.8em;
1251
+ color:var(--secondary); background:none; border:none; outline:none;
1252
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
1253
+ }
1254
+ .cmd-copy {
1255
+ flex-shrink:0; background:var(--bg-card); border:1px solid var(--border);
1256
+ border-radius:6px; padding:4px 8px; cursor:pointer; font-size:.88em;
1257
+ color:var(--text-dim); transition:all .15s;
1258
+ }
1259
+ .cmd-copy:hover { background:var(--primary); color:#fff; border-color:var(--primary); }
1260
+ .cmd-copy.copied { background:var(--accent); color:#fff; border-color:var(--accent); }
1261
+
1262
+ #ni {
1263
+ position: fixed; bottom: 22px; right: 22px;
1264
+ background: var(--primary); color: #fff;
1265
+ padding: 8px 18px; border-radius: 20px;
1266
+ font-size: .83em; cursor: pointer; display: none;
1267
+ box-shadow: 0 4px 20px rgba(123,84,196,.45);
1268
+ transition: background .2s;
1269
+ }
1270
+ #ni:hover { background: var(--primary-dark); }
1271
+ </style>
1272
+ </head>
1273
+ <body>
1274
+
1275
+ <!-- ══ HEADER ════════════════════════════════════════════════════════════════ -->
1276
+ <header>
1277
+ <div class="logo-icon">${LOGO_SVG}</div>
1278
+ <div class="logo-text">
1279
+ <span class="logo-title">MeterMaster</span>
1280
+ <span class="logo-sub">ioBroker Adapter &nbsp;<span style="color:var(--primary);font-size:.95em;letter-spacing:.5px">v${CURRENT_VERSION}</span></span>
1281
+ </div>
1282
+ <div class="hstats">
1283
+ <div class="hstat"><span id="lbl-rx">Ablesungen</span>: <b id="st-rx">–</b></div>
1284
+ <div class="hstat"><span id="lbl-nodes">Nodes</span>: <b id="st-nodes">–</b></div>
1285
+ <div class="hstat"><span id="lbl-up">Uptime</span>: <b id="st-up">–</b></div>
1286
+ <div class="live-dot" id="st-live">● Live</div>
1287
+ <select class="lang-select" id="lang-select" title="Language">
1288
+ <option value="de">DE</option>
1289
+ <option value="en">EN</option>
1290
+ </select>
1291
+ </div>
1292
+ </header>
1293
+
1294
+ <!-- ══ NAV ═══════════════════════════════════════════════════════════════════ -->
1295
+ <nav>
1296
+ <button class="tab active" id="tab-data" data-tab="data" onclick="showTab('data')" >📊 Daten</button>
1297
+ <button class="tab" id="tab-nodes" data-tab="nodes" onclick="showTab('nodes')" >📡 Nodes</button>
1298
+ <button class="tab" id="tab-import" data-tab="import" onclick="showTab('import')">📥 Import</button>
1299
+ <button class="tab" id="tab-logs" data-tab="logs" onclick="showTab('logs')" >📋 Logs</button>
1300
+ <button class="tab" id="tab-system" data-tab="system" onclick="showTab('system')">⚙️ System</button>
1301
+ </nav>
1302
+
1303
+ <!-- ══ DATEN ══════════════════════════════════════════════════════════════════ -->
1304
+ <div class="page active" id="page-data">
1305
+ <div id="data-container">
1306
+ <div class="empty-state">
1307
+ <div class="ico">📡</div>
1308
+ <p>Noch keine Ablesungen empfangen.<br>Starte einen Sync in der MeterMaster App oder lade ein Backup hoch.</p>
1309
+ </div>
1310
+ </div>
1311
+ </div>
1312
+
1313
+ <!-- ══ NODES ═════════════════════════════════════════════════════════════════ -->
1314
+ <div class="page" id="page-nodes">
1315
+ <div id="nodes-page-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px;">
1316
+ <div>
1317
+ <h2 style="font-size:1em;color:var(--secondary);margin-bottom:2px;">📡 Registrierte ESP32 Nodes</h2>
1318
+ <div style="font-size:.82em;color:var(--text-dim);">Gesamt: <b id="nd-total" style="color:var(--text);">0</b> &nbsp;|&nbsp; Online: <b id="nd-online" style="color:var(--accent);">0</b></div>
1319
+ </div>
1320
+ <button class="ghost" onclick="fetchNodes()">↻ Aktualisieren</button>
1321
+ </div>
1322
+ <div id="nodes-container">
1323
+ <div class="empty-state">
1324
+ <div class="ico">📡</div>
1325
+ <p>Noch keine ESP32 Nodes registriert.<br>Wenn ein MeterMaster Node startet und seinen Heartbeat sendet, erscheint er hier automatisch.</p>
1326
+ </div>
1327
+ </div>
1328
+ </div>
1329
+
1330
+ <!-- ══ IMPORT ════════════════════════════════════════════════════════════════ -->
1331
+ <div class="page" id="page-import">
1332
+ <div class="import-card">
1333
+ <h3>📥 App-Backup importieren</h3>
1334
+ <p>Importiere einen Backup-Export aus der MeterMaster App direkt in den Adapter. Alle Ablesungen werden mit ihren originalen Zeitstempeln gespeichert — ideal für die erstmalige Befüllung oder das Nachführen historischer Daten.</p>
1335
+
1336
+ <div class="schema-box">{ "SchemaVersion": "2.0", "Source": "MeterMaster",
1337
+ "Apartments": [ { "Id": 1, "Name": "Westerheim" } ],
1338
+ "Meters": [ { "Id": 1, "Name": "Warmwasser", "ApartmentId": 1, "Unit": "m³" } ],
1339
+ "Readings": [ { "MeterId": 1, "Value": 128.75, "ReadingDate": "2024-02-12T09:30:00" } ]
1340
+ }</div>
1341
+
1342
+ <div class="house-row">
1343
+ <label>Hausname (ioBroker-Pfad):</label>
1344
+ <input type="text" id="imp-house" value="MeinHaus" placeholder="z.B. MeinHaus">
1345
+ </div>
1346
+
1347
+ <div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-in').click()">
1348
+ <div class="dz-ico">📂</div>
1349
+ <p>JSON-Datei hier ablegen oder klicken zum Auswählen</p>
1350
+ </div>
1351
+ <input type="file" id="file-in" accept=".json">
1352
+
1353
+ <div class="preview-box" id="preview-box">
1354
+ <h4>📋 Vorschau</h4>
1355
+ <div id="preview-content"></div>
1356
+ </div>
1357
+
1358
+ <div class="btn-row">
1359
+ <button class="primary" id="imp-btn" disabled onclick="doImport()">⬆ Importieren</button>
1360
+ <button class="ghost" onclick="clearImport()">✕ Zurücksetzen</button>
1361
+ </div>
1362
+ <div class="result-box" id="imp-result"></div>
1363
+ </div>
1364
+ </div>
1365
+
1366
+ <!-- ══ LOGS ═══════════════════════════════════════════════════════════════════ -->
1367
+ <div class="page" id="page-logs">
1368
+ <div class="log-toolbar">
1369
+ <select id="fl">
1370
+ <option value="">Alle Level</option>
1371
+ <option value="debug">DEBUG</option>
1372
+ <option value="info">INFO</option>
1373
+ <option value="warn">WARN</option>
1374
+ <option value="error">ERROR</option>
1375
+ </select>
1376
+ <select id="fc">
1377
+ <option value="">Alle Kategorien</option>
1378
+ <option value="SYSTEM">SYSTEM</option>
1379
+ <option value="AUTH">AUTH</option>
1380
+ <option value="CONNECT">CONNECT</option>
1381
+ <option value="DATAPOINT">DATAPOINT</option>
1382
+ <option value="SYNC">SYNC</option>
1383
+ <option value="HISTORY">HISTORY</option>
1384
+ <option value="IMPORT">IMPORT</option>
1385
+ <option value="NODE">NODE</option>
1386
+ </select>
1387
+ <input class="search" type="text" id="ft" placeholder="Suche…">
1388
+ <button class="ghost" onclick="clearLogs()">🗑 Leeren</button>
1389
+ <button class="ghost" onclick="exportLogs()">⬇ Export</button>
1390
+ <label class="lbl"><input type="checkbox" id="as" checked> Auto-Scroll</label>
1391
+ <label class="lbl"><input type="checkbox" id="ar" checked> Live</label>
1392
+ </div>
1393
+ <div id="lc"><div class="log-empty" id="log-empty">Keine Log-Einträge vorhanden.</div></div>
1394
+ </div>
1395
+
1396
+
1397
+ <!-- ══ SYSTEM ════════════════════════════════════════════════════════════════ -->
1398
+ <div class="page" id="page-system">
1399
+
1400
+ <div class="sys-card">
1401
+ <h3>📊 Statistiken</h3>
1402
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
1403
+ <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1404
+ <div style="font-size:1.6em;font-weight:700;color:var(--secondary);" id="sys-rx">–</div>
1405
+ <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Ablesungen gesamt</div>
1406
+ </div>
1407
+ <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1408
+ <div style="font-size:1.6em;font-weight:700;color:var(--secondary);" id="sys-up">–</div>
1409
+ <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Uptime</div>
1410
+ </div>
1411
+ <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1412
+ <div style="font-size:1.6em;font-weight:700;color:var(--accent);" id="sys-online">–</div>
1413
+ <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Nodes online</div>
1414
+ </div>
1415
+ <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1416
+ <div style="font-size:1.6em;font-weight:700;color:var(--secondary);" id="sys-total">–</div>
1417
+ <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Nodes gesamt</div>
1418
+ </div>
1419
+ </div>
1420
+ </div>
1421
+
1422
+ <div class="sys-card">
1423
+ <h3>🔄 Adapter-Version</h3>
1424
+ <div class="ver-row"><span class="ver-label">Installiert</span> <span class="ver-val" id="sv-cur">–</span></div>
1425
+ <div class="ver-row"><span class="ver-label">Aktuell (GitHub)</span><span class="ver-val" id="sv-lat">–</span></div>
1426
+ <div class="ver-row"><span class="ver-label">Status</span> <span id="sv-status"><span class="badge-warn">Noch nicht geprüft</span></span></div>
1427
+ <div class="sys-btn-row">
1428
+ <button class="ghost" id="sv-check-btn">🔍 Auf Updates prüfen</button>
1429
+ <button class="primary" id="sv-upd-btn" style="display:none">⬆ Update installieren</button>
1430
+ <span id="sv-spin" style="display:none;font-size:.84em;color:var(--text-dim)">⏳ Bitte warten…</span>
1431
+ </div>
1432
+ <div class="sys-out" id="sv-out"></div>
1433
+ </div>
1434
+
1435
+ <div class="sys-card">
1436
+ <h3>ℹ️ Adapter-Info</h3>
1437
+ <div class="ver-row"><span class="ver-label">Adapter</span> <span class="ver-val">iobroker.metermaster</span></div>
1438
+ <div class="ver-row"><span class="ver-label">Port</span> <span class="ver-val">${port}</span></div>
1439
+ <div class="ver-row"><span class="ver-label">Repository</span>
1440
+ <a href="https://github.com/MPunktBPunkt/ioBroker.metermaster" target="_blank"
1441
+ style="color:var(--primary);font-size:.84em">GitHub ↗</a>
1442
+ </div>
1443
+ </div>
1444
+
1445
+ <div class="sys-card">
1446
+ <h3>🔄 Update-Befehle</h3>
1447
+ <p style="font-size:.82em;color:var(--text-dim);margin:0 0 12px">
1448
+ Adapter aktualisieren — Befehle in der ioBroker-Konsole ausführen:
1449
+ </p>
1450
+ <div class="cmd-row">
1451
+ <code class="cmd-code">iobroker upgrade metermaster</code>
1452
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker upgrade metermaster" title="Kopieren">📋</button>
1453
+ </div>
1454
+ <div class="cmd-row" style="margin-top:8px">
1455
+ <code class="cmd-code">iobroker restart metermaster.0</code>
1456
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker restart metermaster.0" title="Kopieren">📋</button>
1457
+ </div>
1458
+ <div class="cmd-row" style="margin-top:8px">
1459
+ <code class="cmd-code">sleep 5 &amp;&amp; iobroker status metermaster.0</code>
1460
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="sleep 5 && iobroker status metermaster.0" title="Kopieren">📋</button>
1461
+ </div>
1462
+ <p style="font-size:.78em;color:var(--text-dim);margin:10px 0 0">
1463
+ 💡 Tipp: Alle drei Befehle nacheinander ausführen — warten bis jeder abgeschlossen ist.
1464
+ </p>
1465
+ </div>
1466
+
1467
+ </div>
1468
+
1469
+ <div id="ni" onclick="scrollLogBottom()">↓ Neue Einträge</div>
1470
+
1471
+ <!-- ══ CHART-MODAL (vor Script — init() bindet Listener) ═════════════════════ -->
1472
+ <div id="chart-overlay" class="modal-overlay">
1473
+ <div class="chart-modal">
1474
+ <div class="chart-modal-head">
1475
+ <div>
1476
+ <div class="chart-modal-title" id="chart-title">–</div>
1477
+ <div class="chart-modal-sub" id="chart-sub"></div>
1478
+ </div>
1479
+ <button class="chart-close" id="chart-close" title="Close">&times;</button>
1480
+ </div>
1481
+ <div class="chart-range-btns">
1482
+ <button class="chart-range" data-months="3">3M</button>
1483
+ <button class="chart-range" data-months="6">6M</button>
1484
+ <button class="chart-range" data-months="12">12M</button>
1485
+ <button class="chart-range active" data-months="0">All</button>
1486
+ <button class="chart-range chart-yearly" id="chart-yearly-toggle" type="button">↗ Pro Jahr</button>
1487
+ </div>
1488
+ <div class="chart-kpi" id="chart-kpi" style="display:none"></div>
1489
+ <div class="chart-kpi-yearly" id="chart-kpi-yearly" style="display:none"></div>
1490
+ <div class="chart-wrap-box"><canvas id="chart-line"></canvas></div>
1491
+ <div class="chart-wrap-box chart-wrap-sm"><canvas id="chart-bar"></canvas></div>
1492
+ <div class="chart-modal-foot">
1493
+ <button class="ghost" id="chart-csv-btn">⬇ CSV</button>
1494
+ </div>
1495
+ </div>
1496
+ </div>
1497
+ <!-- ══ LOGIN-MODAL ═══════════════════════════════════════════════════════════ -->
1498
+ <div id="login-overlay" style="display:none;position:fixed;inset:0;background:rgba(15,11,26,.85);z-index:9999;align-items:center;justify-content:center;">
1499
+ <div style="background:var(--bg-surface);border:1px solid var(--border-light);border-radius:16px;padding:32px 36px;min-width:320px;max-width:420px;width:90%;">
1500
+ <div style="font-size:1.1em;font-weight:700;color:var(--secondary);margin-bottom:6px;">🔑 Anmelden</div>
1501
+ <div style="font-size:.82em;color:var(--text-dim);margin-bottom:20px;">Zugangsdaten für schreibende Aktionen (Zähler zuweisen, Import).</div>
1502
+ <div style="margin-bottom:12px;">
1503
+ <label style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Benutzername</label>
1504
+ <input id="login-user" type="text" value="metermaster" style="width:100%;background:var(--bg-surface2);border:1px solid var(--border-light);color:var(--text);padding:8px 12px;border-radius:8px;font-size:.9em;outline:none;" onkeydown="if(event.key===\'Enter\')document.getElementById(\'login-pass\').focus()">
1505
+ </div>
1506
+ <div style="margin-bottom:18px;">
1507
+ <label style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Passwort</label>
1508
+ <div style="position:relative;">
1509
+ <input id="login-pass" type="password" style="width:100%;background:var(--bg-surface2);border:1px solid var(--border-light);color:var(--text);padding:8px 36px 8px 12px;border-radius:8px;font-size:.9em;outline:none;" onkeydown="if(event.key===\'Enter\')doLogin()">
1510
+ <button onclick="const i=document.getElementById(\'login-pass\');i.type=i.type===\'password\'?\'text\':\'password\';" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;color:var(--text-dim);">👁</button>
1511
+ </div>
1512
+ </div>
1513
+ <div id="login-msg" style="font-size:.82em;color:var(--danger);min-height:18px;margin-bottom:12px;"></div>
1514
+ <div style="display:flex;gap:10px;">
1515
+ <button onclick="doLogin()" style="flex:1;background:var(--primary);color:#fff;border:none;padding:9px;border-radius:9px;cursor:pointer;font-size:.9em;font-weight:600;">✓ Anmelden</button>
1516
+ <button onclick="hideLoginModal()" style="background:transparent;border:1px solid var(--border-light);color:var(--text-dim);padding:9px 16px;border-radius:9px;cursor:pointer;font-size:.9em;">✕</button>
1517
+ </div>
1518
+ </div>
1519
+ </div>
1520
+
1521
+ <script>
1522
+
1523
+ // ── Auth-System ─────────────────────────────────────────────────────────────
1524
+ let _authHeader = null;
1525
+ function getAuthHeader() { return _authHeader; }
1526
+ function setAuth(user, pass) {
1527
+ _authHeader = 'Basic ' + btoa(unescape(encodeURIComponent(user + ':' + pass)));
1528
+ }
1529
+ function showLoginModal(msg) {
1530
+ document.getElementById('login-msg').textContent = msg || '';
1531
+ document.getElementById('login-overlay').style.display = 'flex';
1532
+ window.setTimeout(() => document.getElementById('login-user').focus(), 50);
1533
+ }
1534
+ function hideLoginModal() {
1535
+ document.getElementById('login-overlay').style.display = 'none';
1536
+ }
1537
+ async function doLogin() {
1538
+ const user = document.getElementById('login-user').value.trim();
1539
+ const pass = document.getElementById('login-pass').value;
1540
+ if (!user) { document.getElementById('login-msg').textContent = 'Benutzername fehlt'; return; }
1541
+ setAuth(user, pass);
1542
+ try {
1543
+ const r = await fetch('/api/ping', { headers: { 'Authorization': _authHeader } });
1544
+ if (r.ok) {
1545
+ hideLoginModal();
1546
+ } else {
1547
+ _authHeader = null;
1548
+ document.getElementById('login-msg').textContent = '❌ Falsche Zugangsdaten';
1549
+ }
1550
+ } catch(e) {
1551
+ document.getElementById('login-msg').textContent = '❌ Verbindungsfehler';
1552
+ }
1553
+ }
1554
+ async function authFetch(url, opts) {
1555
+ if (!_authHeader) { showLoginModal(); return null; }
1556
+ const headers = Object.assign({ 'Content-Type': 'application/json', 'Authorization': _authHeader }, opts.headers || {});
1557
+ const r = await fetch(url, Object.assign({}, opts, { headers }));
1558
+ if (r.status === 401) { _authHeader = null; showLoginModal('❌ Sitzung abgelaufen – bitte neu anmelden'); return null; }
1559
+ return r;
1560
+ }
1561
+
1562
+ const TYPE_ICONS = {
1563
+ Electricity:'\u26A1', Gas:'\uD83D\uDD25', Water:'\uD83D\uDCA7', HotWater:'\uD83C\uDF21',
1564
+ TotalWater:'\uD83D\uDCA7', ColdWater:'\u2744', Heat:'\uD83C\uDFE0', HeatMeter:'\uD83C\uDFE0',
1565
+ Cooling:'\uD83E\uDDCA', Oil:'\uD83D\uDEE2', Other:'\uD83D\uDCDF'
1566
+ };
1567
+
1568
+ const I18N = {
1569
+ de: {
1570
+ readings:'Ablesungen', nodes:'Nodes', uptime:'Uptime', live:'Live', disconnected:'Getrennt',
1571
+ tab_data:'Daten', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1572
+ no_data:'Noch keine Ablesungen empfangen.<br>Starte einen Sync in der MeterMaster App oder lade ein Backup hoch.',
1573
+ history:'Verlauf', since_last:'seit letzter Ablesung', days:'Tage', chart_btn:'Chart', csv_btn:'CSV',
1574
+ chart_readings:'Z\u00E4hlerstand', chart_monthly:'Monatsverbrauch', chart_period_all:'Alles',
1575
+ chart_yearly:'Pro Jahr', chart_yearly_proj:'Hochrechnung', chart_per_year:'/Jahr',
1576
+ chart_yearly_hint:'basierend auf {days} {daysLabel} ({delta} {unit})',
1577
+ chart_no_data:'Zu wenig Daten f\u00FCr eine Grafik (mind. 2 Ablesungen n\u00F6tig).',
1578
+ chart_close:'Schlie\u00DFen', error:'Fehler', consume:'Verbrauch',
1579
+ ago_just:'gerade eben', ago_sec:'vor {s}s', ago_min:'vor {m}min'
1580
+ },
1581
+ en: {
1582
+ readings:'Readings', nodes:'Nodes', uptime:'Uptime', live:'Live', disconnected:'Disconnected',
1583
+ tab_data:'Data', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1584
+ no_data:'No readings received yet.<br>Start a sync in the MeterMaster app or upload a backup.',
1585
+ history:'History', since_last:'since last reading', days:'days', chart_btn:'Chart', csv_btn:'CSV',
1586
+ chart_readings:'Meter reading', chart_monthly:'Monthly consumption', chart_period_all:'All',
1587
+ chart_yearly:'Per year', chart_yearly_proj:'Projected', chart_per_year:'/yr',
1588
+ chart_yearly_hint:'based on {days} {daysLabel} ({delta} {unit})',
1589
+ chart_no_data:'Not enough data for a chart (at least 2 readings required).',
1590
+ chart_close:'Close', error:'Error', consume:'Consumption',
1591
+ ago_just:'just now', ago_sec:'{s}s ago', ago_min:'{m}min ago'
1592
+ }
1593
+ };
1594
+
1595
+ let currentLang = 'de';
1596
+ try {
1597
+ const saved = localStorage.getItem('mm-lang');
1598
+ currentLang = saved || (navigator.language && navigator.language.startsWith('de') ? 'de' : 'en');
1599
+ } catch { currentLang = 'de'; }
1600
+
1601
+ function t(key, vars) {
1602
+ let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.de[key]) || key;
1603
+ if (vars) Object.keys(vars).forEach(k => { s = s.replace('{'+k+'}', vars[k]); });
1604
+ return s;
1605
+ }
1606
+ function localeTag() { return currentLang === 'de' ? 'de-DE' : 'en-GB'; }
1607
+
1608
+ function applyI18n() {
1609
+ document.documentElement.lang = currentLang;
1610
+ const ls = document.getElementById('lang-select');
1611
+ if (ls) ls.value = currentLang;
1612
+ const set = (id, key) => { const el = document.getElementById(id); if (el) el.textContent = t(key); };
1613
+ set('lbl-rx', 'readings');
1614
+ set('lbl-nodes', 'nodes');
1615
+ set('lbl-up', 'uptime');
1616
+ const tabs = { 'tab-data':'tab_data', 'tab-nodes':'tab_nodes', 'tab-import':'tab_import', 'tab-logs':'tab_logs', 'tab-system':'tab_system' };
1617
+ Object.keys(tabs).forEach(id => {
1618
+ const el = document.getElementById(id);
1619
+ if (!el) return;
1620
+ const icons = { 'tab-data':'\uD83D\uDCCA ', 'tab-nodes':'\uD83D\uDCE1 ', 'tab-import':'\uD83D\uDCE5 ', 'tab-logs':'\uD83D\uDCCB ', 'tab-system':'\u2699\uFE0F ' };
1621
+ el.textContent = (icons[id] || '') + t(tabs[id]);
1622
+ });
1623
+ const crAll = document.querySelector('.chart-range[data-months="0"]');
1624
+ if (crAll) crAll.textContent = t('chart_period_all');
1625
+ const csvBtn = document.getElementById('chart-csv-btn');
1626
+ if (csvBtn) csvBtn.textContent = '\u2B07 ' + t('csv_btn');
1627
+ const cc = document.getElementById('chart-close');
1628
+ if (cc) cc.title = t('chart_close');
1629
+ const yearlyBtn = document.getElementById('chart-yearly-toggle');
1630
+ if (yearlyBtn) yearlyBtn.textContent = '\u2197 ' + t('chart_yearly');
1631
+ }
1632
+
1633
+ function setLang(lang) {
1634
+ if (!I18N[lang]) return;
1635
+ currentLang = lang;
1636
+ try { localStorage.setItem('mm-lang', lang); } catch {}
1637
+ applyI18n();
1638
+ fetchData();
1639
+ fetchStats();
1640
+ }
1641
+
1642
+ let dataCacheList = [];
1643
+ let chartCtxIdx = -1;
1644
+ let chartRangeMonths = 0;
1645
+ let chartShowYearly = false;
1646
+ try {
1647
+ chartShowYearly = localStorage.getItem('mm-chart-yearly') === '1';
1648
+ } catch {}
1649
+ let chartInstLine = null;
1650
+ let chartInstBar = null;
1651
+
1652
+ function calcDelta(history) {
1653
+ if (!history || history.length < 2) return null;
1654
+ const sorted = history.slice().sort((a, b) => a.ts - b.ts);
1655
+ const prev = sorted[sorted.length - 2];
1656
+ const last = sorted[sorted.length - 1];
1657
+ const delta = last.value - prev.value;
1658
+ if (delta < 0) return null;
1659
+ const days = Math.max(1, Math.round((last.ts - prev.ts) / 86400000));
1660
+ return { delta, days, prev, last };
1661
+ }
1662
+
1663
+ function calcYearlyProjection(history) {
1664
+ if (!history || history.length < 2) return null;
1665
+ const sorted = history.slice().sort((a, b) => a.ts - b.ts);
1666
+ const first = sorted[0];
1667
+ const last = sorted[sorted.length - 1];
1668
+ const delta = last.value - first.value;
1669
+ if (delta < 0) return null;
1670
+ const days = Math.max(1, (last.ts - first.ts) / 86400000);
1671
+ return { delta, days: Math.max(1, Math.round(days)), yearly: delta * (365 / days) };
1672
+ }
1673
+
1674
+ function calcMonthlyConsumption(history) {
1675
+ const sorted = history.slice().sort((a, b) => a.ts - b.ts);
1676
+ const months = {};
1677
+ for (let i = 1; i < sorted.length; i++) {
1678
+ const delta = sorted[i].value - sorted[i - 1].value;
1679
+ if (delta < 0) continue;
1680
+ const d = new Date(sorted[i].ts);
1681
+ const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0');
1682
+ months[key] = (months[key] || 0) + delta;
1683
+ }
1684
+ return months;
1685
+ }
1686
+
1687
+ function filterHistoryByMonths(history, months) {
1688
+ if (!months || months <= 0) return history.slice();
1689
+ const cutoff = Date.now() - months * 30 * 86400000;
1690
+ return history.filter(h => h.ts >= cutoff);
1691
+ }
1692
+
1693
+ function fmtNum(n) {
1694
+ if (n === undefined || n === null || isNaN(n)) return '\u2013';
1695
+ return Number(n).toLocaleString(localeTag(), { maximumFractionDigits: 3 });
1696
+ }
1697
+
1698
+ function exportMeterCsv(idx) {
1699
+ const m = dataCacheList[idx];
1700
+ if (!m) return;
1701
+ const hist = (m.history || []).slice().sort((a, b) => a.ts - b.ts);
1702
+ const sep = currentLang === 'de' ? ';' : ',';
1703
+ const hdr = currentLang === 'de'
1704
+ ? ['Datum', 'Wert', 'Einheit', 'Verbrauch'].join(sep)
1705
+ : ['Date', 'Value', 'Unit', 'Consumption'].join(sep);
1706
+ let rows = [hdr];
1707
+ for (let i = 0; i < hist.length; i++) {
1708
+ const cons = (i > 0 && hist[i].value >= hist[i - 1].value) ? (hist[i].value - hist[i - 1].value) : '';
1709
+ rows.push([
1710
+ new Date(hist[i].ts).toISOString(),
1711
+ hist[i].value,
1712
+ m.unit || '',
1713
+ cons
1714
+ ].join(sep));
1715
+ }
1716
+ const blob = new Blob(['\\uFEFF' + rows.join(String.fromCharCode(10))], { type: 'text/csv;charset=utf-8' });
1717
+ const a = document.createElement('a');
1718
+ a.href = URL.createObjectURL(blob);
1719
+ a.download = (m.house + '_' + m.apartment + '_' + m.meter + '.csv').replace(/[^a-zA-Z0-9_.\-]/g, '_');
1720
+ a.click();
1721
+ URL.revokeObjectURL(a.href);
1722
+ }
1723
+
1724
+ function destroyCharts() {
1725
+ if (chartInstLine) { chartInstLine.destroy(); chartInstLine = null; }
1726
+ if (chartInstBar) { chartInstBar.destroy(); chartInstBar = null; }
1727
+ }
1728
+
1729
+ function initChartTheme() {
1730
+ if (typeof Chart === 'undefined') return;
1731
+ Chart.defaults.color = '#9585BB';
1732
+ Chart.defaults.borderColor = 'rgba(42,32,80,.8)';
1733
+ Chart.defaults.font.family = "'Segoe UI',system-ui,sans-serif";
1734
+ Chart.defaults.font.size = 11;
1735
+ Chart.defaults.plugins.legend.display = true;
1736
+ Chart.defaults.plugins.legend.labels.boxWidth = 12;
1737
+ Chart.defaults.elements.point.radius = 3;
1738
+ Chart.defaults.elements.point.hoverRadius = 5;
1739
+ Chart.defaults.elements.line.borderWidth = 2;
1740
+ Chart.defaults.elements.line.tension = 0.2;
1741
+ }
1742
+
1743
+ function renderMeterCharts() {
1744
+ destroyCharts();
1745
+ const m = dataCacheList[chartCtxIdx];
1746
+ const overlay = document.getElementById('chart-overlay');
1747
+ const kpiEl = document.getElementById('chart-kpi');
1748
+ if (!m || !overlay) return;
1749
+
1750
+ const allHist = (m.history || []).slice().sort((a, b) => a.ts - b.ts);
1751
+ const hist = filterHistoryByMonths(allHist, chartRangeMonths);
1752
+
1753
+ if (typeof Chart === 'undefined') {
1754
+ kpiEl.style.display = 'block';
1755
+ kpiEl.textContent = 'Chart.js not loaded';
1756
+ return;
1757
+ }
1758
+ initChartTheme();
1759
+
1760
+ const delta = calcDelta(allHist);
1761
+ const yearlyEl = document.getElementById('chart-kpi-yearly');
1762
+ const yearlyBtn = document.getElementById('chart-yearly-toggle');
1763
+ if (yearlyBtn) yearlyBtn.classList.toggle('active', chartShowYearly);
1764
+
1765
+ if (delta) {
1766
+ kpiEl.style.display = 'block';
1767
+ kpiEl.textContent = '\uD83D\uDCCA +' + fmtNum(delta.delta) + ' ' + (m.unit || '') +
1768
+ ' ' + t('since_last') + ' (' + delta.days + ' ' + t('days') + ')';
1769
+ } else {
1770
+ kpiEl.style.display = 'none';
1771
+ }
1772
+
1773
+ const projection = chartShowYearly ? calcYearlyProjection(hist) : null;
1774
+ if (projection && yearlyEl) {
1775
+ yearlyEl.style.display = 'block';
1776
+ yearlyEl.textContent = '\u2197 \u2248 ' + fmtNum(projection.yearly) + ' ' + (m.unit || '') +
1777
+ t('chart_per_year') + ' (' + t('chart_yearly_proj') + ': ' +
1778
+ t('chart_yearly_hint', {
1779
+ days: projection.days,
1780
+ daysLabel: t('days'),
1781
+ delta: fmtNum(projection.delta),
1782
+ unit: m.unit || ''
1783
+ }) + ')';
1784
+ } else if (yearlyEl) {
1785
+ yearlyEl.style.display = 'none';
1786
+ }
1787
+
1788
+ if (hist.length < 2) {
1789
+ destroyCharts();
1790
+ return;
1791
+ }
1792
+
1793
+ const lineData = hist.map(h => ({ x: h.ts, y: h.value }));
1794
+ const unit = m.unit || '';
1795
+ const readingLabel = t('chart_readings') + (unit ? ' (' + unit + ')' : '');
1796
+
1797
+ chartInstLine = new Chart(document.getElementById('chart-line'), {
1798
+ type: 'line',
1799
+ data: {
1800
+ datasets: [{
1801
+ label: readingLabel,
1802
+ data: lineData,
1803
+ borderColor: '#7B54C4',
1804
+ backgroundColor: 'rgba(123,84,196,.15)',
1805
+ fill: true
1806
+ }]
1807
+ },
1808
+ options: {
1809
+ responsive: true, maintainAspectRatio: false,
1810
+ plugins: {
1811
+ legend: { labels: { color: '#C8B8FF' } },
1812
+ tooltip: {
1813
+ callbacks: {
1814
+ title: items => (items.length ? fmtDt(items[0].parsed.x) : ''),
1815
+ label: item => readingLabel + ': ' + fmtNum(item.parsed.y)
1816
+ }
1817
+ }
1818
+ },
1819
+ scales: {
1820
+ x: {
1821
+ type: 'linear',
1822
+ ticks: {
1823
+ maxTicksLimit: 8,
1824
+ maxRotation: 45,
1825
+ color: '#9585BB',
1826
+ callback: val => fmtDt(val)
1827
+ },
1828
+ grid: { color: 'rgba(42,32,80,.5)' }
1829
+ },
1830
+ y: { ticks: { color: '#9585BB' }, grid: { color: 'rgba(42,32,80,.5)' } }
1831
+ }
1832
+ }
1833
+ });
1834
+
1835
+ const monthly = calcMonthlyConsumption(hist);
1836
+ const monthKeys = Object.keys(monthly).sort();
1837
+ const barDatasets = [{
1838
+ label: t('chart_monthly') + (unit ? ' (' + unit + ')' : ''),
1839
+ data: monthKeys.map(k => monthly[k]),
1840
+ backgroundColor: 'rgba(76,175,80,.55)',
1841
+ borderColor: '#4CAF50',
1842
+ borderWidth: 1
1843
+ }];
1844
+ if (projection) {
1845
+ const monthlyAvg = projection.yearly / 12;
1846
+ barDatasets.push({
1847
+ label: '\u00D8 ' + fmtNum(monthlyAvg) + ' ' + (unit || '') + ' (' + t('chart_yearly_proj') + ')',
1848
+ type: 'line',
1849
+ data: monthKeys.map(() => monthlyAvg),
1850
+ borderColor: '#C8B8FF',
1851
+ backgroundColor: 'transparent',
1852
+ borderWidth: 2,
1853
+ borderDash: [6, 4],
1854
+ pointRadius: 0,
1855
+ fill: false,
1856
+ order: 0
1857
+ });
1858
+ }
1859
+ chartInstBar = new Chart(document.getElementById('chart-bar'), {
1860
+ type: 'bar',
1861
+ data: {
1862
+ labels: monthKeys,
1863
+ datasets: barDatasets
1864
+ },
1865
+ options: {
1866
+ responsive: true, maintainAspectRatio: false,
1867
+ plugins: { legend: { labels: { color: '#C8B8FF' } } },
1868
+ scales: {
1869
+ x: { ticks: { color: '#9585BB' }, grid: { display: false } },
1870
+ y: { ticks: { color: '#9585BB' }, grid: { color: 'rgba(42,32,80,.5)' } }
1871
+ }
1872
+ }
1873
+ });
1874
+ }
1875
+
1876
+ function openChartModal(idx) {
1877
+ const m = dataCacheList[idx];
1878
+ if (!m) return;
1879
+ chartCtxIdx = idx;
1880
+ chartRangeMonths = 0;
1881
+ document.querySelectorAll('.chart-range[data-months]').forEach(b => {
1882
+ b.classList.toggle('active', b.dataset.months === '0');
1883
+ });
1884
+ document.getElementById('chart-title').textContent = m.meter;
1885
+ document.getElementById('chart-sub').textContent = m.house + ' \u203A ' + m.apartment +
1886
+ (m.typeName ? ' \u00B7 ' + m.typeName : '');
1887
+ document.getElementById('chart-overlay').style.display = 'flex';
1888
+ renderMeterCharts();
1889
+ }
1890
+
1891
+ function closeChartModal() {
1892
+ document.getElementById('chart-overlay').style.display = 'none';
1893
+ destroyCharts();
1894
+ chartCtxIdx = -1;
1895
+ }
1896
+
1897
+ // \u2500\u2500 Tab-Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1898
+ window.showTab = function showTab(name) {
1899
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
1900
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
1901
+ document.getElementById('tab-'+name).classList.add('active');
1902
+ document.getElementById('page-'+name).classList.add('active');
1903
+ if (name === 'data') fetchData();
1904
+ if (name === 'nodes') fetchNodes();
1905
+ if (name === 'logs') fetchLogs();
1906
+ if (name === 'system') { checkVersion(); fetchSysStats(); }
1907
+ }
1908
+
1909
+ const esc = s => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
1910
+ const fmtDt = ts => new Date(ts).toLocaleString(localeTag(),{hour12:false});
1911
+ const fmtUp = s => Math.floor(s/3600)+'h '+Math.floor(s%3600/60)+'m '+Math.floor(s%60)+'s';
1912
+ const fmtLog = ts => {
1913
+ const d = new Date(ts);
1914
+ return d.toLocaleTimeString(localeTag(),{hour12:false})+'.'+String(d.getMilliseconds()).padStart(3,'0');
1915
+ };
1916
+ const fmtAgo = ts => {
1917
+ if (!ts) return '\u2013';
1918
+ const s = Math.floor((Date.now()-ts)/1000);
1919
+ if (s < 5) return t('ago_just');
1920
+ if (s < 60) return t('ago_sec', { s: String(s) });
1921
+ if (s < 3600) return t('ago_min', { m: String(Math.floor(s/60)) });
1922
+ return new Date(ts).toLocaleString(localeTag(),{hour12:false});
1923
+ };
1924
+
1925
+ // \u2500\u2500 Stats \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1926
+ async function fetchStats() {
1927
+ try {
1928
+ const d = await fetch('/api/stats').then(r => r.json());
1929
+ document.getElementById('st-rx').textContent = d.readingsReceived;
1930
+ document.getElementById('st-nodes').textContent = d.onlineCount+'/'+d.nodeCount;
1931
+ document.getElementById('st-up').textContent = fmtUp(d.uptime);
1932
+ document.getElementById('st-live').textContent = '\u25CF ' + t('live');
1933
+ document.getElementById('st-live').style.color = 'var(--accent)';
1934
+ } catch {
1935
+ document.getElementById('st-live').textContent = '\u2717 ' + t('disconnected');
1936
+ document.getElementById('st-live').style.color = 'var(--danger)';
1937
+ }
1938
+ }
1939
+
1940
+ async function fetchSysStats() {
1941
+ try {
1942
+ const d = await fetch('/api/stats').then(r => r.json());
1943
+ const sRx = document.getElementById('sys-rx'); if (sRx) sRx.textContent = d.readingsReceived;
1944
+ const sUp = document.getElementById('sys-up'); if (sUp) sUp.textContent = fmtUp(d.uptime);
1945
+ const sOn = document.getElementById('sys-online'); if (sOn) sOn.textContent = d.onlineCount;
1946
+ const sTt = document.getElementById('sys-total'); if (sTt) sTt.textContent = d.nodeCount;
1947
+ } catch {}
1948
+ }
1949
+
1950
+ // \u2500\u2500 DATEN-TAB \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1951
+ async function fetchData() {
1952
+ try {
1953
+ const d = await fetch('/api/data').then(r => r.json());
1954
+ const con = document.getElementById('data-container');
1955
+ dataCacheList = [];
1956
+ if (!d.data || !Object.keys(d.data).length) {
1957
+ con.innerHTML = '<div class="empty-state"><div class="ico">\uD83D\uDCE1</div><p>' + t('no_data') + '</p></div>';
1958
+ return;
1959
+ }
1960
+ let html = '';
1961
+ let idx = 0;
1962
+ for (const [house, apts] of Object.entries(d.data)) {
1963
+ html += '<div class="house-block"><div class="house-title">\uD83C\uDFE0 '+esc(house)+'</div>';
1964
+ for (const [apt, meters] of Object.entries(apts)) {
1965
+ html += '<div class="apt-block"><div class="apt-title">\uD83C\uDFD8 '+esc(apt)+'</div><div class="meters-grid">';
1966
+ for (const [key, m] of Object.entries(meters)) {
1967
+ const cacheIdx = idx++;
1968
+ dataCacheList.push({ house, apartment: apt, meter: key, unit: m.unit, typeName: m.typeName, latest: m.latest, latestDate: m.latestDate, history: m.history || [] });
1969
+ const icon = TYPE_ICONS[m.typeName] || '\uD83D\uDCDF';
1970
+ const histId = 'h-'+CSS.escape(house+apt+key);
1971
+ const hist = m.history || [];
1972
+ const delta = calcDelta(hist);
1973
+ const rows = hist.slice().reverse().map(h =>
1974
+ '<div class="hist-row"><span>'+esc(fmtDt(h.ts))+'</span><span class="hist-val">'+h.value+' '+esc(m.unit||'')+'</span></div>'
1975
+ ).join('');
1976
+ const consumeHtml = delta
1977
+ ? '<div class="mc-consume">\uD83D\uDCCA +'+fmtNum(delta.delta)+' '+esc(m.unit||'')+' '+t('since_last')+' ('+delta.days+' '+t('days')+')</div>'
1978
+ : '';
1979
+ const actionsHtml = hist.length >= 1
1980
+ ? '<div class="mc-actions">'+
1981
+ (hist.length >= 2 ? '<button class="mc-chart-btn" data-idx="'+cacheIdx+'" title="'+esc(t('chart_btn'))+'">\uD83D\uDCC8 '+esc(t('chart_btn'))+'</button>' : '')+
1982
+ '<button class="mc-csv-btn" data-idx="'+cacheIdx+'" title="'+esc(t('csv_btn'))+'">\u2B07 '+esc(t('csv_btn'))+'</button>'+
1983
+ '</div>'
1984
+ : '';
1985
+ html +=
1986
+ '<div class="meter-card">'+
1987
+ '<div class="mc-head">'+
1988
+ '<div class="mc-name">'+icon+' '+esc(key)+'</div>'+
1989
+ '<div class="mc-badge">'+esc(m.typeName||'?')+'</div>'+
1990
+ '</div>'+
1991
+ '<div class="mc-value-row">'+
1992
+ '<span class="mc-value">'+(m.latest !== undefined ? m.latest : '\u2013')+'</span>'+
1993
+ '<span class="mc-unit">'+esc(m.unit||'')+'</span>'+
1994
+ '</div>'+
1995
+ '<div class="mc-date">\uD83D\uDCC5 '+esc(m.latestDate ? fmtDt(new Date(m.latestDate).getTime()) : '\u2013')+'</div>'+
1996
+ consumeHtml+
1997
+ (rows
1998
+ ? '<button class="mc-hist-toggle" data-hist="'+histId+'">\uD83D\uDCC8 '+t('history')+' ('+hist.length+')</button>'+
1999
+ '<div class="mc-history" id="'+histId+'">'+rows+'</div>'
2000
+ : '')+
2001
+ actionsHtml+
2002
+ '</div>';
2003
+ }
2004
+ html += '</div></div>';
2005
+ }
2006
+ html += '</div>';
2007
+ }
2008
+ con.innerHTML = html;
2009
+ } catch(e) {
2010
+ document.getElementById('data-container').innerHTML =
2011
+ '<div class="empty-state"><div class="ico">\u26A0</div><p>'+t('error')+': '+esc(e.message)+'</p></div>';
2012
+ }
2013
+ }
2014
+
2015
+ function toggleHist(id) {
2016
+ const el = document.getElementById(id);
2017
+ if (el) el.classList.toggle('open');
2018
+ }
2019
+ // \u2500\u2500 NODES-TAB \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2020
+ let discoverCache = [];
2021
+
2022
+ window.fetchNodes = async function fetchNodes() {
2023
+ const con = document.getElementById('nodes-container');
2024
+ try {
2025
+ const [nodes, discover] = await Promise.all([
2026
+ fetch('/api/nodes').then(r => r.json()),
2027
+ fetch('/api/discover').then(r => r.json())
2028
+ ]);
2029
+ discoverCache = discover;
2030
+ const total = nodes.length;
2031
+ const online = nodes.filter(n => n.online).length;
2032
+ const elTot = document.getElementById('nd-total'); if (elTot) elTot.textContent = total;
2033
+ const elOn = document.getElementById('nd-online'); if (elOn) elOn.textContent = online;
2034
+ if (!total) {
2035
+ con.innerHTML = '<div class="empty-state"><div class="ico">\uD83D\uDCE1</div><p>Noch keine ESP32 Nodes registriert.<br>Wenn ein MeterMaster Node startet, erscheint er automatisch hier.</p></div>';
2036
+ return;
2037
+ }
2038
+ const buildOptions = (currentSid) => {
2039
+ let opts = '<option value="">\u2014 Kein Z\u00E4hler zugewiesen \u2014</option>';
2040
+ for (const m of discover) {
2041
+ const lbl = m.house + ' \u203A ' + m.apartment + ' \u203A ' + m.meter + (m.latest !== undefined ? ' (' + m.latest + ' ' + esc(m.unit) + ')' : '');
2042
+ opts += '<option value="'+esc(m.stateId)+'"'+(m.stateId===currentSid?' selected':'')+'>'+esc(lbl)+'</option>';
2043
+ }
2044
+ return opts;
2045
+ };
2046
+ let html = '<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;">';
2047
+ html += '<table style="width:100%;border-collapse:collapse;font-size:.85em;">';
2048
+ html += '<thead><tr style="text-align:left;">';
2049
+ const th = (t) => '<th style="padding:10px 12px;color:var(--text-dim);font-size:.8em;text-transform:uppercase;letter-spacing:.6px;border-bottom:2px solid var(--border);background:var(--bg-surface);">'+t+'</th>';
2050
+ html += th('Status')+th('Name')+th('IP-Adresse')+th('FW-Version')+th('Zuletzt gesehen')+th('Z\u00E4hler zuweisen')+th('LED');
2051
+ html += '</tr></thead><tbody>';
2052
+ for (const n of nodes) {
2053
+ let currentSid = '';
2054
+ try { currentSid = JSON.parse(n.config||'{}').sid || ''; } catch {}
2055
+ const onBadge = '<span style="display:inline-flex;align-items:center;gap:5px;font-size:.8em;font-weight:700;padding:2px 9px;border-radius:20px;background:rgba(76,175,80,.15);color:#A5D6A7;border:1px solid rgba(76,175,80,.3);"><span style="width:7px;height:7px;border-radius:50%;background:var(--accent);display:inline-block;box-shadow:0 0 4px var(--accent);"></span>Online</span>';
2056
+ const offBadge = '<span style="display:inline-flex;align-items:center;gap:5px;font-size:.8em;font-weight:700;padding:2px 9px;border-radius:20px;background:rgba(244,67,54,.12);color:#EF9A9A;border:1px solid rgba(244,67,54,.3);"><span style="width:7px;height:7px;border-radius:50%;background:var(--danger);display:inline-block;"></span>Offline</span>';
2057
+ const badge = n.online ? onBadge : offBadge;
2058
+ const ackHint = n.configAck ? '<div style="font-size:.75em;color:var(--text-muted);margin-top:3px;">\u2713 Ack</div>' : '';
2059
+ const ipCell = n.ip ? '<a href="http://'+esc(n.ip)+'" target="_blank" style="color:var(--primary);text-decoration:none;font-family:Consolas,monospace;font-size:.9em;">'+esc(n.ip)+'</a>' : '\u2013';
2060
+ const td = (c, extra) => '<td style="padding:10px 12px;vertical-align:middle;'+(extra||'')+'">'+c+'</td>';
2061
+ html += '<tr style="border-bottom:1px solid var(--border);">';
2062
+ html += td(badge+'<br><span style="font-family:Consolas,monospace;font-size:.78em;color:var(--text-muted);">'+esc(n.mac)+'</span>');
2063
+ html += td('<b>'+esc(n.name||'\u2013')+'</b>');
2064
+ html += td(ipCell);
2065
+ html += td('<span style="background:var(--bg-surface3);color:var(--secondary);font-family:Consolas,monospace;font-size:.82em;padding:2px 8px;border-radius:6px;">'+esc(n.version||'\u2013')+'</span>');
2066
+ html += td(esc(fmtAgo(n.lastSeen)), 'color:var(--text-dim);font-size:.82em;');
2067
+ html += td('<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;"><select id="sel-'+esc(n.mac)+'" style="background:var(--bg-surface2);border:1px solid var(--border-light);color:var(--text);padding:6px 10px;border-radius:7px;font-size:.82em;max-width:300px;min-width:180px;outline:none;">'+buildOptions(currentSid)+'</select><button id="sbtn-'+esc(n.mac)+'" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:7px;cursor:pointer;font-size:.82em;font-weight:600;white-space:nowrap;">\uD83D\uDCBE Speichern</button><span id="smsg-'+esc(n.mac)+'"></span></div>'+ackHint);
2068
+ // LED-Spalte
2069
+ html += td('<div style="display:flex;flex-direction:column;gap:5px;align-items:center;">'
2070
+ +'<button class="ledBtn" data-mac="'+esc(n.mac)+'" data-led="1" title="LED ein" style="background:rgba(239,68,68,.2);color:#F87171;border:1px solid rgba(239,68,68,.4);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:.8em;white-space:nowrap;">\uD83D\uDD34 Ein</button>'
2071
+ +'<button class="ledBtn" data-mac="'+esc(n.mac)+'" data-led="0" title="LED aus" style="background:var(--bg-surface2);color:var(--text-dim);border:1px solid var(--border);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:.8em;white-space:nowrap;">\u26AB Aus</button>'
2072
+ +'<span id="ledmsg-'+esc(n.mac)+'" style="font-size:.75em;min-height:14px;"></span>'
2073
+ +'</div>');
2074
+ html += '</tr>';
2075
+ }
2076
+ html += '</tbody></table></div>';
2077
+ con.innerHTML = html;
2078
+ } catch(e) {
2079
+ con.innerHTML = '<div class="empty-state"><div class="ico">\u26A0</div><p>Fehler: '+esc(e.message)+'</p></div>';
2080
+ }
2081
+ }
2082
+
2083
+ window.saveNodeConfig = async function saveNodeConfig(mac) {
2084
+ const sel = document.getElementById('sel-'+mac);
2085
+ const btn = document.getElementById('sbtn-'+mac);
2086
+ const msg = document.getElementById('smsg-'+mac);
2087
+ if (!sel || !btn || !msg) return;
2088
+ const stateId = sel.value;
2089
+ const meter = discoverCache.find(m => m.stateId === stateId);
2090
+ btn.disabled = true; msg.textContent = '';
2091
+ try {
2092
+ const r = await authFetch('/api/nodes/'+encodeURIComponent(mac)+'/config', {
2093
+ method: 'POST',
2094
+ body: JSON.stringify({ sid: stateId, label: meter ? meter.label : '', unit: meter ? meter.unit : '' })
2095
+ });
2096
+ if (!r) { btn.disabled = false; return; }
2097
+ const d = await r.json();
2098
+ if (d.ok) {
2099
+ msg.innerHTML = '<span style="color:var(--accent);font-size:.82em;">\u2713 Gespeichert</span>';
2100
+ window.setTimeout(() => { msg.textContent = ''; }, 3000);
2101
+ } else {
2102
+ msg.innerHTML = '<span style="color:var(--danger);font-size:.82em;">\u2717 '+esc(d.error||'Fehler')+'</span>';
2103
+ }
2104
+ } catch(e) {
2105
+ msg.innerHTML = '<span style="color:var(--danger);font-size:.82em;">\u2717 '+esc(e.message)+'</span>';
2106
+ } finally {
2107
+ btn.disabled = false;
2108
+ }
2109
+ }
2110
+
2111
+ window.sendNodeCmd = async function sendNodeCmd(mac, cmd) {
2112
+ const msgEl = document.getElementById('ledmsg-'+mac);
2113
+ if (msgEl) msgEl.textContent = '…';
2114
+ try {
2115
+ const r = await fetch('/api/nodes/'+encodeURIComponent(mac)+'/cmd', {
2116
+ method: 'POST', headers: {'Content-Type':'application/json'},
2117
+ body: JSON.stringify(cmd)
2118
+ });
2119
+ const d = await r.json();
2120
+ if (msgEl) {
2121
+ msgEl.innerHTML = d.ok
2122
+ ? '<span style="color:var(--accent);">✓</span>'
2123
+ : '<span style="color:var(--danger);">✗</span>';
2124
+ window.setTimeout(() => { if (msgEl) msgEl.textContent = ''; }, 3000);
2125
+ }
2126
+ } catch(e) {
2127
+ if (msgEl) msgEl.innerHTML = '<span style="color:var(--danger);">✗</span>';
2128
+ }
2129
+ }
2130
+
2131
+
2132
+ // \u2500\u2500 IMPORT-TAB \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2133
+ let importData = null;
2134
+ let dz = null; // wird in initDropzone() gesetzt
2135
+
2136
+ function loadFile(file) {
2137
+ if (!file) return;
2138
+ const r = new FileReader();
2139
+ r.onload = e => {
2140
+ try { importData = JSON.parse(e.target.result); showPreview(importData, file.name); }
2141
+ catch { showResult('err', '\u274C Ung\u00FCltige JSON-Datei'); }
2142
+ };
2143
+ r.readAsText(file);
2144
+ }
2145
+
2146
+ function showPreview(d, fname) {
2147
+ const valid = !!(d.Apartments && d.Meters && d.Readings);
2148
+ document.getElementById('preview-content').innerHTML =
2149
+ prow('Datei', esc(fname)) +
2150
+ prow('Schema', d.SchemaVersion||'?') +
2151
+ prow('Wohnungen', (d.Apartments||[]).length) +
2152
+ prow('Z\u00E4hler', (d.Meters||[]).length) +
2153
+ prow('Ablesungen', (d.Readings||[]).length) +
2154
+ prow('Kompatibel', valid ? '\u2705 Ja' : '\u274C Nein \u2013 Pflichtfelder fehlen');
2155
+ document.getElementById('preview-box').style.display = 'block';
2156
+ document.getElementById('imp-btn').disabled = !valid;
2157
+ }
2158
+ const prow = (l,v) => '<div class="preview-row"><span>'+l+'</span><b>'+v+'</b></div>';
2159
+
2160
+ window.doImport = async function doImport() {
2161
+ if (!importData) return;
2162
+ const house = document.getElementById('imp-house').value.trim() || 'MeinHaus';
2163
+ const btn = document.getElementById('imp-btn');
2164
+ btn.disabled = true; btn.textContent = '\u23F3 Importiere\u2026';
2165
+ try {
2166
+ const r = await authFetch('/api/import', {
2167
+ method: 'POST',
2168
+ body: JSON.stringify({...importData, HouseName: house})
2169
+ });
2170
+ if (!r) { btn.disabled = false; btn.textContent = '⬆ Importieren'; return; }
2171
+ const d = await r.json();
2172
+ if (d.ok) { showResult('ok', '\u2705 '+d.summary); fetchData(); fetchStats(); }
2173
+ else { showResult('warn','\u26A0 '+d.summary+(d.errors.length ? '<br>'+d.errors.slice(0,5).map(esc).join('<br>') : '')); }
2174
+ } catch(e) { showResult('err', '\u274C Netzwerkfehler: '+esc(e.message)); }
2175
+ finally { btn.disabled = false; btn.textContent = '\u2B06 Importieren'; }
2176
+ }
2177
+ function showResult(type, msg) {
2178
+ const rb = document.getElementById('imp-result');
2179
+ rb.className = 'result-box '+type; rb.innerHTML = msg; rb.style.display = 'block';
2180
+ }
2181
+ window.clearImport = function clearImport() {
2182
+ importData = null;
2183
+ document.getElementById('file-in').value = '';
2184
+ document.getElementById('preview-box').style.display = 'none';
2185
+ document.getElementById('imp-result').style.display = 'none';
2186
+ document.getElementById('imp-btn').disabled = true;
2187
+ }
2188
+
2189
+ // \u2500\u2500 LOG-TAB \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2190
+ let newestTs = 0, displayed = [], logTimer;
2191
+ const lc = () => document.getElementById('lc');
2192
+ const gf = () => ({
2193
+ level: document.getElementById('fl').value,
2194
+ cat: document.getElementById('fc').value,
2195
+ txt: document.getElementById('ft').value.toLowerCase()
2196
+ });
2197
+ const matchLog = (e,f) => {
2198
+ if (f.level && e.level !== f.level) return false;
2199
+ if (f.cat && e.category !== f.cat) return false;
2200
+ if (f.txt && !(e.message+' '+(e.detail||'')).toLowerCase().includes(f.txt)) return false;
2201
+ return true;
2202
+ };
2203
+ const lvlCls = l => ({debug:'ld',info:'li',warn:'lw',error:'le2'}[l]||'li');
2204
+
2205
+ function renderLog(e, isNew) {
2206
+ const f = gf(); if (!matchLog(e,f)) return null;
2207
+ const d = document.createElement('div');
2208
+ d.className = 'le'+(isNew?' new':''); d.dataset.ts = e.ts;
2209
+ d.innerHTML =
2210
+ '<span class="ts">'+fmtLog(e.ts)+'</span>'+
2211
+ '<span class="bdg '+lvlCls(e.level)+'">'+e.level.toUpperCase()+'</span>'+
2212
+ '<span class="bdg c'+e.category+'">'+e.category+'</span>'+
2213
+ '<span class="msg">'+esc(e.message)+(e.detail?'<br><span class="det">'+esc(e.detail)+'</span>':'')+'</span>';
2214
+ return d;
2215
+ }
2216
+
2217
+ async function fetchLogs() {
2218
+ try {
2219
+ const f = gf();
2220
+ const url = '/api/logs?since='+newestTs+'&limit=100'+(f.level?'&level='+f.level:'')+(f.cat?'&category='+f.cat:'');
2221
+ const d = await fetch(url).then(r => r.json());
2222
+ const c = lc();
2223
+ const atB = c.scrollHeight - c.scrollTop - c.clientHeight < 80;
2224
+ if (d.entries.length > 0) {
2225
+ document.getElementById('log-empty').style.display = 'none';
2226
+ d.entries.forEach(e => { const el = renderLog(e,true); if(el) c.insertBefore(el, c.firstChild); displayed.unshift(e); });
2227
+ newestTs = d.newest;
2228
+ const rows = c.querySelectorAll('.le');
2229
+ if (rows.length > 1000) for (let i=0;i<rows.length-1000;i++) rows[i].remove();
2230
+ if (document.getElementById('as').checked && atB) scrollLogBottom();
2231
+ else if (!atB) document.getElementById('ni').style.display = 'block';
2232
+ }
2233
+ document.getElementById('st-lg').textContent = d.total;
2234
+ } catch {}
2235
+ }
2236
+
2237
+ window.scrollLogBottom = function scrollLogBottom() {
2238
+ const c = lc(); c.scrollTop = c.scrollHeight;
2239
+ document.getElementById('ni').style.display = 'none';
2240
+ }
2241
+ window.clearLogs = function clearLogs() {
2242
+ lc().querySelectorAll('.le').forEach(e => e.remove());
2243
+ document.getElementById('log-empty').style.display = '';
2244
+ newestTs = Date.now(); displayed = [];
2245
+ }
2246
+ function applyLogFilter() {
2247
+ const f = gf();
2248
+ lc().querySelectorAll('.le').forEach(el => {
2249
+ const e = displayed.find(d => d.ts == el.dataset.ts);
2250
+ if (e) el.style.display = matchLog(e,f) ? '' : 'none';
2251
+ });
2252
+ }
2253
+ window.exportLogs = function exportLogs() {
2254
+ const f = gf();
2255
+ const txt = displayed.filter(e=>matchLog(e,f))
2256
+ .map(e=>'['+new Date(e.ts).toISOString()+'] ['+e.level.toUpperCase()+'] ['+e.category+'] '+e.message+(e.detail?' \\u2014 '+e.detail:'')).join(String.fromCharCode(10));
2257
+ const a = document.createElement('a');
2258
+ a.href = URL.createObjectURL(new Blob([txt],{type:'text/plain'}));
2259
+ a.download = 'metermaster-log-'+new Date().toISOString().slice(0,19)+'.txt';
2260
+ a.click();
2261
+ }
2262
+ // Log-Filter EventListener \u2192 werden in initLogFilters() gesetzt
2263
+
2264
+ function startLive() {
2265
+ window.clearInterval(logTimer);
2266
+ logTimer = window.setInterval(async () => { await fetchLogs(); await fetchStats(); }, 3000);
2267
+ }
2268
+
2269
+
2270
+ // \u2500\u2500 SYSTEM-TAB \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2271
+ async function checkVersion() {
2272
+ const btn = document.getElementById('sv-check-btn');
2273
+ const spin = document.getElementById('sv-spin');
2274
+ const updBtn = document.getElementById('sv-upd-btn');
2275
+ btn.disabled = true; spin.style.display = 'inline';
2276
+ try {
2277
+ const d = await fetch('/api/version').then(r => r.json());
2278
+ document.getElementById('sv-cur').textContent = d.current || '\u2013';
2279
+ document.getElementById('sv-lat').textContent = d.latest || (d.error ? '(Fehler)' : 'Noch kein Release');
2280
+ const st = document.getElementById('sv-status');
2281
+ if (d.error) {
2282
+ st.innerHTML = '<span class="badge-err">\u26A0 GitHub nicht erreichbar</span>';
2283
+ updBtn.style.display = 'none';
2284
+ } else if (!d.latest) {
2285
+ st.innerHTML = '<span class="badge-warn">\u2139 Kein GitHub-Release vorhanden</span>';
2286
+ updBtn.style.display = 'none';
2287
+ } else if (d.updateAvailable) {
2288
+ st.innerHTML = '<span class="badge-new">\uD83C\uDD95 Update verf\u00FCgbar</span>';
2289
+ updBtn.style.display = '';
2290
+ } else {
2291
+ st.innerHTML = '<span class="badge-ok">\u2713 Aktuell</span>';
2292
+ updBtn.style.display = 'none';
2293
+ }
2294
+ } catch(e) {
2295
+ document.getElementById('sv-status').innerHTML = '<span class="badge-err">\u26A0 Netzwerkfehler</span>';
2296
+ }
2297
+ btn.disabled = false; spin.style.display = 'none';
2298
+ }
2299
+
2300
+ async function doUpdate() {
2301
+ const btn = document.getElementById('sv-upd-btn');
2302
+ const spin = document.getElementById('sv-spin');
2303
+ const outBox = document.getElementById('sv-out');
2304
+ if (!confirm('Update installieren und Adapter neu starten?')) return;
2305
+ btn.style.display = 'none'; spin.style.display = 'inline';
2306
+ outBox.style.display = 'block'; outBox.textContent = '\u23F3 Update l\u00E4uft\u2026';
2307
+ try {
2308
+ const r = await fetch('/api/update', { method: 'POST' });
2309
+ const d = await r.json();
2310
+ if (d.ok) {
2311
+ outBox.textContent = '\u2705 Update erfolgreich.' + String.fromCharCode(10) + 'Adapter wird neu gestartet\u2026' + String.fromCharCode(10,10) + (d.output||'');
2312
+ document.getElementById('sv-status').innerHTML = '<span class="badge-ok">\u2713 Neu gestartet</span>';
2313
+ window.setTimeout(() => { outBox.textContent += String.fromCharCode(10) + '\u27F3 Seite wird neu geladen\u2026'; location.reload(); }, 8000);
2314
+ } else {
2315
+ outBox.textContent = '\u274C Fehler:' + String.fromCharCode(10) + (d.error||'') + String.fromCharCode(10,10) + (d.output||'');
2316
+ btn.style.display = ''; spin.style.display = 'none';
2317
+ }
2318
+ } catch(e) {
2319
+ outBox.textContent = '\u274C Netzwerkfehler: ' + e.message;
2320
+ btn.style.display = ''; spin.style.display = 'none';
2321
+ }
2322
+ }
2323
+
2324
+ // \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2325
+ // ── Copy-to-Clipboard ─────────────────────────────────────────────────────────
2326
+ window.copyCmd = function copyCmd(btn) {
2327
+ const cmd = btn.dataset.cmd;
2328
+ if (!cmd) return;
2329
+ navigator.clipboard.writeText(cmd).then(() => {
2330
+ btn.textContent = '\u2713';
2331
+ btn.classList.add('copied');
2332
+ window.setTimeout(() => { btn.textContent = '\uD83D\uDCCB'; btn.classList.remove('copied'); }, 1800);
2333
+ }).catch(() => {
2334
+ // Fallback für ältere Browser / HTTP-Kontext
2335
+ const ta = document.createElement('textarea');
2336
+ ta.value = cmd; ta.style.position = 'fixed'; ta.style.opacity = '0';
2337
+ document.body.appendChild(ta); ta.select();
2338
+ document.execCommand('copy');
2339
+ document.body.removeChild(ta);
2340
+ btn.textContent = '\u2713'; btn.classList.add('copied');
2341
+ window.setTimeout(() => { btn.textContent = '\uD83D\uDCCB'; btn.classList.remove('copied'); }, 1800);
2342
+ });
2343
+ }
2344
+
2345
+ function initTabs() {
2346
+ // Tab-Buttons
2347
+ ['data','nodes','import','logs','system'].forEach(name => {
2348
+ const el = document.getElementById('tab-' + name);
2349
+ if (el) {
2350
+ el.addEventListener('click', () => showTab(name));
2351
+ el.style.cursor = 'pointer';
2352
+ el.style.pointerEvents = 'all';
2353
+ }
2354
+ });
2355
+ // Event Delegation auf nav als Fallback
2356
+ const nav = document.querySelector('nav');
2357
+ if (nav) {
2358
+ nav.addEventListener('click', e => {
2359
+ const tab = e.target.closest('[data-tab]');
2360
+ if (tab) { e.stopPropagation(); showTab(tab.dataset.tab); }
2361
+ });
2362
+ }
2363
+ // System-Tab Buttons
2364
+ const chkBtn = document.getElementById('sv-check-btn');
2365
+ if (chkBtn) chkBtn.addEventListener('click', checkVersion);
2366
+ const updBtn = document.getElementById('sv-upd-btn');
2367
+ if (updBtn) updBtn.addEventListener('click', doUpdate);
2368
+
2369
+ // Verlauf-Toggle via Event Delegation (kein onclick-Attribut nötig)
2370
+ document.addEventListener('click', e => {
2371
+ const btn = e.target.closest('.mc-hist-toggle');
2372
+ if (btn && btn.dataset.hist) toggleHist(btn.dataset.hist);
2373
+ });
2374
+
2375
+ // Chart + CSV + Chart-Modal via Event Delegation
2376
+ document.addEventListener('click', e => {
2377
+ const chartBtn = e.target.closest('.mc-chart-btn');
2378
+ if (chartBtn) { openChartModal(parseInt(chartBtn.dataset.idx, 10)); return; }
2379
+ const csvBtn = e.target.closest('.mc-csv-btn');
2380
+ if (csvBtn) { exportMeterCsv(parseInt(csvBtn.dataset.idx, 10)); return; }
2381
+ if (e.target.closest('.chart-close')) { closeChartModal(); return; }
2382
+ if (e.target.id === 'chart-overlay') { closeChartModal(); return; }
2383
+ if (e.target.closest('#chart-csv-btn')) { if (chartCtxIdx >= 0) exportMeterCsv(chartCtxIdx); return; }
2384
+ const rangeBtn = e.target.closest('.chart-range[data-months]');
2385
+ if (rangeBtn) {
2386
+ chartRangeMonths = parseInt(rangeBtn.dataset.months, 10) || 0;
2387
+ document.querySelectorAll('.chart-range[data-months]').forEach(b => b.classList.toggle('active', b === rangeBtn));
2388
+ renderMeterCharts();
2389
+ return;
2390
+ }
2391
+ const yearlyBtn = e.target.closest('#chart-yearly-toggle');
2392
+ if (yearlyBtn) {
2393
+ chartShowYearly = !chartShowYearly;
2394
+ try { localStorage.setItem('mm-chart-yearly', chartShowYearly ? '1' : '0'); } catch {}
2395
+ renderMeterCharts();
2396
+ }
2397
+ });
2398
+
2399
+ // Sprache
2400
+ const langSel = document.getElementById('lang-select');
2401
+ if (langSel) langSel.addEventListener('change', e => setLang(e.target.value));
2402
+
2403
+ document.addEventListener('keydown', e => {
2404
+ if (e.key === 'Escape') closeChartModal();
2405
+ });
2406
+
2407
+ // Nodes-Speichern via Event Delegation (kein onclick-Attribut mit Escaping-Problemen)
2408
+ document.addEventListener('click', e => {
2409
+ const btn2 = e.target.closest('[id^="sbtn-"]');
2410
+ if (btn2) saveNodeConfig(btn2.id.slice(5));
2411
+ });
2412
+
2413
+ // LED-Buttons via Event Delegation (kein onclick-Attribut)
2414
+ document.addEventListener('click', e => {
2415
+ const btn = e.target.closest('.ledBtn');
2416
+ if (!btn) return;
2417
+ window.sendNodeCmd(btn.dataset.mac, { ledOn: btn.dataset.led === '1' });
2418
+ });
2419
+
2420
+ // Dropzone
2421
+ dz = document.getElementById('drop-zone');
2422
+ if (dz) {
2423
+ dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); });
2424
+ dz.addEventListener('dragleave', ()=> dz.classList.remove('drag'));
2425
+ dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('drag'); loadFile(e.dataTransfer.files[0]); });
2426
+ }
2427
+ const fi = document.getElementById('file-in');
2428
+ if (fi) fi.addEventListener('change', e => loadFile(e.target.files[0]));
2429
+
2430
+ // Log-Filter
2431
+ ['fl','fc'].forEach(id => {
2432
+ const el = document.getElementById(id);
2433
+ if (el) el.addEventListener('change', applyLogFilter);
2434
+ });
2435
+ const ft = document.getElementById('ft');
2436
+ if (ft) ft.addEventListener('input', applyLogFilter);
2437
+ const ar = document.getElementById('ar');
2438
+ if (ar) ar.addEventListener('change', e => { if(e.target.checked) startLive(); else window.clearInterval(logTimer); });
2439
+ }
2440
+
2441
+ async function init() {
2442
+ initTabs();
2443
+ applyI18n();
2444
+ try {
2445
+ const d = await fetch('/api/logs?limit=500').then(r => r.json());
2446
+ if (d.entries.length > 0) {
2447
+ document.getElementById('log-empty').style.display = 'none';
2448
+ d.entries.slice().reverse().forEach(e => { const el = renderLog(e,false); if(el) lc().appendChild(el); });
2449
+ displayed = d.entries; newestTs = d.newest;
2450
+ }
2451
+ } catch {}
2452
+ await fetchData();
2453
+ await fetchStats();
2454
+ startLive();
2455
+ }
2456
+ init();
2457
+ </script>
2458
+
2459
+ </body>
2460
+ </html>`;
2461
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2462
+ res.end(html);
2463
+ }
2464
+
2465
+ // ─── Hilfsfunktionen ──────────────────────────────────────────────────────────
2466
+ function sanitize(input) {
2467
+ if (!input) return 'unknown';
2468
+ return input
2469
+ .replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue')
2470
+ .replace(/Ä/g,'Ae').replace(/Ö/g,'Oe').replace(/Ü/g,'Ue')
2471
+ .replace(/ß/g,'ss').replace(/\s+/g,'_').replace(/[^a-zA-Z0-9_\-]/g,'')
2472
+ || 'unknown';
2473
+ }
2474
+ function readBody(req, cb) { let b=''; req.on('data',c=>{b+=c.toString();}); req.on('end',()=>cb(b)); }
2475
+ function sendJson(res, status, data) { res.writeHead(status,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(data)); }
2476
+
2477
+ // adapter-core v3.x startet automatisch bei new utils.Adapter() — kein .start() nötig
2478
+ if (require.main === module) { }
2479
+ module.exports = adapter;