iobroker.metermaster 0.9.3 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +151 -117
  2. package/io-package.json +41 -2
  3. package/main.js +655 -264
  4. package/package.json +5 -4
package/main.js CHANGED
@@ -4,9 +4,7 @@ const utils = require('@iobroker/adapter-core');
4
4
  const http = require('node:http');
5
5
  const crypto = require('node:crypto');
6
6
  const https = require('node:https');
7
- const { exec } = require('node:child_process');
8
-
9
- const CURRENT_VERSION = '0.9.3';
7
+ const CURRENT_VERSION = '0.9.6';
10
8
  const GITHUB_REPO = 'MPunktBPunkt/ioBroker.metermaster';
11
9
  const GITHUB_URL = 'https://github.com/MPunktBPunkt/ioBroker.metermaster';
12
10
 
@@ -32,16 +30,21 @@ function cacheReading(house, apt, meter, value, unit, typeName, readingDate, ts)
32
30
  if (!receivedData[house][apt][meter]) receivedData[house][apt][meter] = { unit, typeName, history: [] };
33
31
 
34
32
  const entry = receivedData[house][apt][meter];
35
- entry.latest = value;
36
- entry.latestDate = readingDate;
37
33
  entry.unit = unit;
38
34
  entry.typeName = typeName;
39
35
 
40
- // Nur in Cache-History hinzufügen wenn noch nicht vorhanden
41
- if (!entry.history.some(h => h.ts === ts)) {
36
+ const histIdx = entry.history.findIndex(h => h.ts === ts);
37
+ if (histIdx >= 0) {
38
+ entry.history[histIdx] = { value, readingDate, ts };
39
+ } else {
42
40
  entry.history.push({ value, readingDate, ts });
43
41
  entry.history.sort((a, b) => a.ts - b.ts);
44
42
  }
43
+
44
+ // latest = chronologisch neuester Eintrag (Korrektur alter Werte ändert latest nicht)
45
+ const newest = entry.history[entry.history.length - 1];
46
+ entry.latest = newest.value;
47
+ entry.latestDate = newest.readingDate;
45
48
  }
46
49
 
47
50
  // ─── Log-System ───────────────────────────────────────────────────────────────
@@ -64,11 +67,30 @@ function log(level, category, message, detail) {
64
67
  while (logBuffer.length > logBufferMaxSize) logBuffer.shift();
65
68
  }
66
69
 
70
+ // ─── Config validation ─────────────────────────────────────────────────────────
71
+ function clampInt(val, min, max, fallback) {
72
+ const n = parseInt(val, 10);
73
+ if (isNaN(n)) return fallback;
74
+ return Math.min(max, Math.max(min, n));
75
+ }
76
+
77
+ function getValidatedConfig() {
78
+ return {
79
+ port: clampInt(adapter.config?.port, 1024, 65535, 8089),
80
+ logBufferSize: clampInt(adapter.config?.logBufferSize, 50, 5000, 500),
81
+ keepHistory: clampInt(adapter.config?.keepHistory, 0, 100000, 0),
82
+ verboseLogging: !!adapter.config?.verboseLogging,
83
+ user: (adapter.config?.user || '').trim(),
84
+ password: (adapter.config?.password || '').trim(),
85
+ };
86
+ }
87
+
67
88
  // ─── ready ────────────────────────────────────────────────────────────────────
68
89
  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}`);
90
+ const cfg = getValidatedConfig();
91
+ logBufferMaxSize = cfg.logBufferSize;
92
+ log(LVL.INFO, CAT.SYSTEM, `MeterMaster Adapter v${CURRENT_VERSION} started`,
93
+ `Port: ${cfg.port} | Logging: ${cfg.verboseLogging ? 'verbose' : 'standard'} | Buffer: ${logBufferMaxSize}`);
72
94
 
73
95
  await adapter.setStateAsync('info.connection', { val: false, ack: true });
74
96
 
@@ -90,7 +112,7 @@ adapter.on('ready', async () => {
90
112
  });
91
113
 
92
114
  adapter.on('unload', (callback) => {
93
- log(LVL.INFO, CAT.SYSTEM, 'Adapter wird gestoppt');
115
+ log(LVL.INFO, CAT.SYSTEM, 'Adapter stopping');
94
116
  try { if (server) { server.close(() => callback()); } else { callback(); } }
95
117
  catch (e) { callback(); }
96
118
  });
@@ -99,7 +121,7 @@ adapter.on('unload', (callback) => {
99
121
  adapter.on('stateChange', async (id, state) => {
100
122
  // Eigene Writes (ack: true) ignorieren – nur externe Änderungen verarbeiten
101
123
  if (!state || state.ack) return;
102
- if (!state || state.val === null) return;
124
+ if (state.val === null) return;
103
125
  const ns = `${adapter.namespace}.`;
104
126
  const relative = id.startsWith(ns) ? id.slice(ns.length) : id;
105
127
  const parts = relative.split('.');
@@ -120,7 +142,7 @@ adapter.on('stateChange', async (id, state) => {
120
142
 
121
143
  if (field === 'lastSeen') {
122
144
  const n = nodesCache[mac];
123
- log(LVL.INFO, CAT.NODE, `Heartbeat`, `${mac} | IP: ${n.ip || '?'} | v${n.version || '?'} | ${n.name || 'unbenannt'}`);
145
+ log(LVL.INFO, CAT.NODE, `Heartbeat`, `${mac} | IP: ${n.ip || '?'} | v${n.version || '?'} | ${n.name || 'unnamed'}`);
124
146
  await ensureNodeStates(mac);
125
147
  }
126
148
  });
@@ -140,7 +162,7 @@ async function restoreCacheFromStates() {
140
162
  );
141
163
 
142
164
  if (latestKeys.length === 0) {
143
- log(LVL.DEBUG, CAT.SYSTEM, 'Cache-Wiederherstellung', 'Keine gespeicherten Ablesungen gefunden');
165
+ log(LVL.DEBUG, CAT.SYSTEM, 'Cache restore', 'No stored readings found');
144
166
  return;
145
167
  }
146
168
 
@@ -172,9 +194,9 @@ async function restoreCacheFromStates() {
172
194
  restored++;
173
195
  }
174
196
 
175
- log(LVL.INFO, CAT.SYSTEM, `Cache wiederhergestellt`, `${restored} Zähler aus ioBroker-States geladen`);
197
+ log(LVL.INFO, CAT.SYSTEM, `Cache restored`, `${restored} meters loaded from ioBroker states`);
176
198
  } catch (e) {
177
- log(LVL.WARN, CAT.SYSTEM, 'Cache-Wiederherstellung fehlgeschlagen', e.message);
199
+ log(LVL.WARN, CAT.SYSTEM, 'Cache restore failed', e.message);
178
200
  }
179
201
  }
180
202
 
@@ -202,10 +224,10 @@ async function restoreNodesFromStates() {
202
224
  if (field === 'config') nodesCache[mac].config = String(state.val);
203
225
  if (field === 'cmd' && state.val) nodesCache[mac].cmd = String(state.val);
204
226
  }
205
- if (count > 0) log(LVL.INFO, CAT.NODE, `Nodes wiederhergestellt`, `${count} ESP32-Node(s) aus States geladen`);
206
- else log(LVL.DEBUG, CAT.NODE, 'Keine registrierten Nodes gefunden');
227
+ if (count > 0) log(LVL.INFO, CAT.NODE, `Nodes restored`, `${count} ESP32 node(s) loaded from states`);
228
+ else log(LVL.DEBUG, CAT.NODE, 'No registered nodes found');
207
229
  } catch (e) {
208
- log(LVL.WARN, CAT.NODE, 'Node-Wiederherstellung fehlgeschlagen', e.message);
230
+ log(LVL.WARN, CAT.NODE, 'Node restore failed', e.message);
209
231
  }
210
232
  }
211
233
 
@@ -214,24 +236,25 @@ async function ensureNodeStates(mac) {
214
236
  const base = `nodes.${mac}`;
215
237
  await ensureChannel('nodes', 'ESP32 Nodes');
216
238
  await ensureChannel(base, `ESP32 Node ${mac}`);
217
- await ensureState(`${base}.ip`, { name: 'IP-Adresse', type: 'string', role: 'info.ip', read: true, write: false });
218
- await ensureState(`${base}.name`, { name: 'Gerätename', type: 'string', role: 'info.name', read: true, write: false });
219
- await ensureState(`${base}.version`, { name: 'Firmware-Version', type: 'string', role: 'info.firmware', read: true, write: false });
220
- await ensureState(`${base}.lastSeen`, { name: 'Zuletzt gesehen (ms)', type: 'number', role: 'value.time', read: true, write: false });
221
- await ensureState(`${base}.config`, { name: 'Konfiguration (JSON)', type: 'string', role: 'json', read: true, write: true });
222
- await ensureState(`${base}.configAck`, { name: 'Config-Quittierung', type: 'string', role: 'json', read: true, write: false });
223
- await ensureState(`${base}.cmd`, { name: 'Befehl (JSON)', type: 'string', role: 'json', read: true, write: true });
239
+ await ensureState(`${base}.ip`, { name: 'IP address', type: 'string', role: 'info.ip', read: true, write: false });
240
+ await ensureState(`${base}.name`, { name: 'Device name', type: 'string', role: 'info.name', read: true, write: false });
241
+ await ensureState(`${base}.version`, { name: 'Firmware version', type: 'string', role: 'info.firmware', read: true, write: false });
242
+ await ensureState(`${base}.lastSeen`, { name: 'Last seen (ms)', type: 'number', role: 'value.time', read: true, write: false });
243
+ await ensureState(`${base}.config`, { name: 'Configuration (JSON)', type: 'string', role: 'json', read: true, write: true });
244
+ await ensureState(`${base}.configAck`, { name: 'Config acknowledgement', type: 'string', role: 'json', read: true, write: false });
245
+ await ensureState(`${base}.cmd`, { name: 'Command (JSON)', type: 'string', role: 'json', read: true, write: true });
224
246
  }
225
247
 
226
248
  // ─── HTTP-Server ──────────────────────────────────────────────────────────────
227
249
  function startHttpServer() {
228
- const port = parseInt(adapter.config.port) || 8089;
229
- const user = (adapter.config.user || '').trim();
230
- const password = (adapter.config.password || '').trim();
250
+ const cfg = getValidatedConfig();
251
+ const port = cfg.port;
252
+ const user = cfg.user;
253
+ const password = cfg.password;
231
254
 
232
255
  server = http.createServer((req, res) => {
233
256
  res.setHeader('Access-Control-Allow-Origin', '*');
234
- res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
257
+ res.setHeader('Access-Control-Allow-Methods', 'POST, PUT, GET, OPTIONS');
235
258
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
236
259
  if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
237
260
 
@@ -248,9 +271,8 @@ function startHttpServer() {
248
271
  if (req.method === 'GET' && url === '/api/version') { serveVersion(res); return; }
249
272
  if (req.method === 'GET' && url === '/api/nodes') { serveNodesJson(res); return; }
250
273
  if (req.method === 'GET' && url === '/api/discover'){ serveDiscoverJson(res); return; }
251
- if (req.method === 'POST' && url === '/api/update') { handleUpdate(req, res); return; }
252
274
 
253
- // ESP32 Node-Registrierung (kein Auth – ESP32 kennt keine Zugangsdaten)
275
+ // ESP32 node registration (no auth – ESP32 has no credentials)
254
276
  if (req.method === 'POST' && url === '/api/register') {
255
277
  readBody(req, b => handleNodeRegister(b, res, clientIp)); return;
256
278
  }
@@ -272,9 +294,9 @@ function startHttpServer() {
272
294
  if (user && password) {
273
295
  const authHeader = req.headers['authorization'] || '';
274
296
  if (!authHeader.startsWith('Basic ')) {
275
- log(LVL.WARN, CAT.AUTH, `Kein Auth-Header`, `IP: ${clientIp} | ${url}`);
297
+ log(LVL.WARN, CAT.AUTH, `No auth header`, `IP: ${clientIp} | ${url}`);
276
298
  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="MeterMaster"' });
277
- res.end(JSON.stringify({ error: 'Authentifizierung erforderlich' })); return;
299
+ res.end(JSON.stringify({ error: 'Authentication required' })); return;
278
300
  }
279
301
  const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
280
302
  const colonIdx = decoded.indexOf(':');
@@ -291,14 +313,16 @@ function startHttpServer() {
291
313
  authOk = crypto.timingSafeEqual(uBuf, uRef) && crypto.timingSafeEqual(pBuf, pRef);
292
314
  } catch { authOk = false; }
293
315
  if (!authOk) {
294
- log(LVL.WARN, CAT.AUTH, `Ungültige Zugangsdaten`, `IP: ${clientIp} | User: "${reqUser}"`);
295
- res.writeHead(403); res.end(JSON.stringify({ error: 'Ungültige Zugangsdaten' })); return;
316
+ log(LVL.WARN, CAT.AUTH, `Invalid credentials`, `IP: ${clientIp} | User: "${reqUser}"`);
317
+ res.writeHead(403); res.end(JSON.stringify({ error: 'Invalid credentials' })); return;
296
318
  }
297
319
  log(LVL.DEBUG, CAT.AUTH, `Auth OK`, `IP: ${clientIp} | User: "${reqUser}"`);
298
320
  }
299
321
 
300
322
  if (req.method === 'GET' && url === '/api/ping') { handlePing(res, clientIp); }
301
- else if (req.method === 'POST' && url === '/api/reading') { readBody(req, b => handleReading(b, res, clientIp)); }
323
+ else if ((req.method === 'POST' || req.method === 'PUT') && url === '/api/reading') {
324
+ readBody(req, b => handleReading(b, res, clientIp));
325
+ }
302
326
  else if (req.method === 'POST' && url === '/api/readings') { readBody(req, b => handleReadings(b, res, clientIp)); }
303
327
  else if (req.method === 'POST' && url === '/api/import') { readBody(req, b => handleImport(b, res, clientIp)); }
304
328
  else {
@@ -312,19 +336,19 @@ function startHttpServer() {
312
336
  if (req.method === 'POST' && cmdMatch) {
313
337
  readBody(req, b => handleNodeCmd(cmdMatch[1].toUpperCase(), b, res));
314
338
  } else {
315
- log(LVL.WARN, CAT.CONNECT, `Unbekannte URL`, `${req.method} ${url} von ${clientIp}`);
316
- res.writeHead(404); res.end(JSON.stringify({ error: 'Nicht gefunden' }));
339
+ log(LVL.WARN, CAT.CONNECT, `Unknown URL`, `${req.method} ${url} from ${clientIp}`);
340
+ res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' }));
317
341
  }
318
342
  }
319
343
  }
320
344
  });
321
345
 
322
346
  server.on('error', err => {
323
- log(LVL.ERROR, CAT.SYSTEM, `HTTP-Fehler: ${err.message}`,
324
- err.code === 'EADDRINUSE' ? `Port ${port} belegt!` : undefined);
347
+ log(LVL.ERROR, CAT.SYSTEM, `HTTP error: ${err.message}`,
348
+ err.code === 'EADDRINUSE' ? `Port ${port} in use!` : undefined);
325
349
  });
326
350
  server.listen(port, '0.0.0.0', () => {
327
- log(LVL.INFO, CAT.SYSTEM, `Lauscht auf Port ${port}`, `Web-UI: http://IP:${port}/`);
351
+ log(LVL.INFO, CAT.SYSTEM, `Listening on port ${port}`, `Web UI: http://IP:${port}/`);
328
352
  adapter.setState('info.connection', { val: true, ack: true });
329
353
  });
330
354
  }
@@ -337,14 +361,14 @@ function handlePing(res, clientIp) {
337
361
 
338
362
  // ─── Validierung ─────────────────────────────────────────────────────────────
339
363
  function validateReading(data) {
340
- if (!data || typeof data !== 'object') return 'Kein Objekt';
341
- if (!data.house || typeof data.house !== 'string') return 'Pflichtfeld fehlt: house';
342
- if (!data.apartment || typeof data.apartment !== 'string') return 'Pflichtfeld fehlt: apartment';
343
- if (!data.meter || typeof data.meter !== 'string') return 'Pflichtfeld fehlt: meter';
344
- if (data.value === undefined || data.value === null) return 'Pflichtfeld fehlt: value';
345
- if (isNaN(parseFloat(data.value))) return 'value muss eine Zahl sein';
346
- if (!data.readingDate) return 'Pflichtfeld fehlt: readingDate';
347
- if (isNaN(new Date(data.readingDate).getTime())) return 'readingDate: kein gültiges Datum';
364
+ if (!data || typeof data !== 'object') return 'Not an object';
365
+ if (!data.house || typeof data.house !== 'string') return 'Required field missing: house';
366
+ if (!data.apartment || typeof data.apartment !== 'string') return 'Required field missing: apartment';
367
+ if (!data.meter || typeof data.meter !== 'string') return 'Required field missing: meter';
368
+ if (data.value === undefined || data.value === null) return 'Required field missing: value';
369
+ if (isNaN(parseFloat(data.value))) return 'value must be a number';
370
+ if (!data.readingDate) return 'Required field missing: readingDate';
371
+ if (isNaN(new Date(data.readingDate).getTime())) return 'readingDate: invalid date';
348
372
  return null;
349
373
  }
350
374
 
@@ -352,18 +376,18 @@ function validateReading(data) {
352
376
  async function handleReading(body, res, clientIp) {
353
377
  let data;
354
378
  try { data = JSON.parse(body); } catch {
355
- log(LVL.WARN, CAT.SYNC, `Ungültiges JSON`, `IP: ${clientIp}`);
356
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
379
+ log(LVL.WARN, CAT.SYNC, `Invalid JSON`, `IP: ${clientIp}`);
380
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
357
381
  }
358
382
  const err = validateReading(data);
359
- if (err) { log(LVL.WARN, CAT.SYNC, `Validierungsfehler`, err); sendJson(res, 422, { error: err }); return; }
360
- log(LVL.INFO, CAT.SYNC, `Ablesung empfangen`,
383
+ if (err) { log(LVL.WARN, CAT.SYNC, `Validation error`, err); sendJson(res, 422, { error: err }); return; }
384
+ log(LVL.INFO, CAT.SYNC, `Reading received`,
361
385
  `${data.house}/${data.apartment}/${data.meter} = ${data.value} ${data.unit||''} (${data.readingDate})`);
362
386
  try {
363
- const path = await storeReading(data);
364
- sendJson(res, 200, { ok: true, path });
387
+ const result = await storeReading(data);
388
+ sendJson(res, 200, { ok: true, path: result.path, history: result.history });
365
389
  } catch (e) {
366
- log(LVL.ERROR, CAT.SYNC, `Speicherfehler`, e.message);
390
+ log(LVL.ERROR, CAT.SYNC, `Storage error`, e.message);
367
391
  sendJson(res, 500, { error: e.message });
368
392
  }
369
393
  }
@@ -372,21 +396,21 @@ async function handleReading(body, res, clientIp) {
372
396
  async function handleReadings(body, res, clientIp) {
373
397
  let items;
374
398
  try { items = JSON.parse(body); if (!Array.isArray(items)) items = [items]; } catch {
375
- log(LVL.WARN, CAT.SYNC, `Ungültiges JSON im Batch`, `IP: ${clientIp}`);
376
- sendJson(res, 400, { error: 'Array erwartet' }); return;
399
+ log(LVL.WARN, CAT.SYNC, `Invalid JSON in batch`, `IP: ${clientIp}`);
400
+ sendJson(res, 400, { error: 'Array expected' }); return;
377
401
  }
378
- log(LVL.INFO, CAT.SYNC, `Batch empfangen`, `IP: ${clientIp} | ${items.length} Ablesungen`);
402
+ log(LVL.INFO, CAT.SYNC, `Batch received`, `IP: ${clientIp} | ${items.length} readings`);
379
403
  let stored = 0, failed = 0;
380
404
  const errors = [];
381
405
  for (const data of items) {
382
406
  const err = validateReading(data);
383
407
  if (err) { failed++; errors.push(`${data.meter||'?'}: ${err}`); continue; }
384
408
  try { await storeReading(data); stored++; }
385
- catch (e) { failed++; errors.push(`${data.meter||'?'}: ${e.message}`); log(LVL.ERROR, CAT.SYNC, `Batch-Fehler`, `${data.meter}: ${e.message}`); }
409
+ catch (e) { failed++; errors.push(`${data.meter||'?'}: ${e.message}`); log(LVL.ERROR, CAT.SYNC, `Batch error`, `${data.meter}: ${e.message}`); }
386
410
  }
387
- const summary = `${stored} gespeichert, ${failed} fehlgeschlagen`;
388
- if (failed === 0) log(LVL.INFO, CAT.SYNC, `Batch ✓`, summary);
389
- else log(LVL.WARN, CAT.SYNC, `Batch mit Fehlern`, summary);
411
+ const summary = `${stored} stored, ${failed} failed`;
412
+ if (failed === 0) log(LVL.INFO, CAT.SYNC, `Batch OK`, summary);
413
+ else log(LVL.WARN, CAT.SYNC, `Batch with errors`, summary);
390
414
  sendJson(res, 200, { ok: failed === 0, stored, failed, errors });
391
415
  }
392
416
 
@@ -394,19 +418,19 @@ async function handleReadings(body, res, clientIp) {
394
418
  async function handleImport(body, res, clientIp) {
395
419
  let data;
396
420
  try { data = JSON.parse(body); } catch {
397
- log(LVL.WARN, CAT.IMPORT, `Ungültiges JSON`, `IP: ${clientIp}`);
398
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
421
+ log(LVL.WARN, CAT.IMPORT, `Invalid JSON`, `IP: ${clientIp}`);
422
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
399
423
  }
400
424
 
401
- // Schema-Prüfung
425
+ // Schema validation
402
426
  if (!data.Apartments || !data.Meters || !data.Readings) {
403
- sendJson(res, 422, { error: 'Ungültiges Format: Apartments, Meters und Readings erforderlich' }); return;
427
+ sendJson(res, 422, { error: 'Invalid format: Apartments, Meters and Readings required' }); return;
404
428
  }
405
429
 
406
430
  const schema = data.SchemaVersion || '1.0';
407
- const house = data.HouseName || adapter.config.houseName || 'MeinHaus';
408
- log(LVL.INFO, CAT.IMPORT, `Import gestartet`,
409
- `Schema: ${schema} | ${data.Apartments.length} Wohnungen | ${data.Meters.length} Zähler | ${data.Readings.length} Ablesungen | IP: ${clientIp}`);
431
+ const house = data.HouseName || 'MyHouse';
432
+ log(LVL.INFO, CAT.IMPORT, `Import started`,
433
+ `Schema: ${schema} | ${data.Apartments.length} apartments | ${data.Meters.length} meters | ${data.Readings.length} readings | IP: ${clientIp}`);
410
434
 
411
435
  // ID-Maps aufbauen: JSON-Id → Objekt
412
436
  const aptMap = {}; // aptId → Apartment
@@ -422,7 +446,7 @@ async function handleImport(body, res, clientIp) {
422
446
  const meter = meterMap[reading.MeterId];
423
447
  if (!meter) {
424
448
  skipped++;
425
- log(LVL.WARN, CAT.IMPORT, `Zähler nicht gefunden`, `MeterId: ${reading.MeterId}`);
449
+ log(LVL.WARN, CAT.IMPORT, `Meter not found`, `MeterId: ${reading.MeterId}`);
426
450
  continue;
427
451
  }
428
452
 
@@ -441,7 +465,7 @@ async function handleImport(body, res, clientIp) {
441
465
  const err = validateReading(payload);
442
466
  if (err) {
443
467
  skipped++;
444
- log(LVL.WARN, CAT.IMPORT, `Ungültige Ablesung übersprungen`, `${meter.Name}: ${err}`);
468
+ log(LVL.WARN, CAT.IMPORT, `Invalid reading skipped`, `${meter.Name}: ${err}`);
445
469
  continue;
446
470
  }
447
471
 
@@ -451,13 +475,13 @@ async function handleImport(body, res, clientIp) {
451
475
  } catch (e) {
452
476
  failed++;
453
477
  errors.push(`${meter.Name}: ${e.message}`);
454
- log(LVL.ERROR, CAT.IMPORT, `Speicherfehler`, `${meter.Name}: ${e.message}`);
478
+ log(LVL.ERROR, CAT.IMPORT, `Storage error`, `${meter.Name}: ${e.message}`);
455
479
  }
456
480
  }
457
481
 
458
- const summary = `${stored} importiert, ${skipped} übersprungen, ${failed} fehlgeschlagen`;
459
- if (failed === 0) log(LVL.INFO, CAT.IMPORT, `Import abgeschlossen ✓`, summary);
460
- else log(LVL.WARN, CAT.IMPORT, `Import mit Fehlern`, summary);
482
+ const summary = `${stored} imported, ${skipped} skipped, ${failed} failed`;
483
+ if (failed === 0) log(LVL.INFO, CAT.IMPORT, `Import completed`, summary);
484
+ else log(LVL.WARN, CAT.IMPORT, `Import with errors`, summary);
461
485
 
462
486
  sendJson(res, 200, { ok: failed === 0, stored, skipped, failed, errors, summary });
463
487
  }
@@ -474,50 +498,62 @@ async function storeReading(data) {
474
498
  const isNew = await ensureChannel(`${house}`, data.house);
475
499
  await ensureChannel(`${house}.${apt}`, data.apartment);
476
500
  await ensureChannel(`${base}`, data.meter);
477
- await ensureChannel(`${base}.readings`, 'Ablesungen');
501
+ await ensureChannel(`${base}.readings`, 'Readings');
478
502
 
479
- if (isNew) log(LVL.INFO, CAT.DATAPOINT, `Neuer Zähler`, `metermaster.0.${base}`);
503
+ if (isNew) log(LVL.INFO, CAT.DATAPOINT, `New meter`, `metermaster.0.${base}`);
480
504
 
481
- const dpNew = await ensureState(`${base}.readings.latest`, { name: `${data.meter} – Letzter Wert`, type: 'number', role: 'value', unit: data.unit||'', read: true, write: false });
482
- await ensureState(`${base}.readings.latestDate`, { name: 'Ablesedatum', type: 'string', role: 'date', read: true, write: false });
483
- await ensureState(`${base}.name`, { name: 'Zählername', type: 'string', role: 'info.name', read: true, write: false });
484
- await ensureState(`${base}.unit`, { name: 'Einheit', type: 'string', role: 'text', read: true, write: false });
485
- await ensureState(`${base}.typeName`, { name: 'Zählertyp', type: 'string', role: 'text', read: true, write: false });
486
- await ensureState(`${base}.readings.history`, { name: 'Historische Ablesungen', type: 'array', role: 'list', read: true, write: false });
505
+ const dpNew = await ensureState(`${base}.readings.latest`, { name: `${data.meter} – latest value`, type: 'number', role: 'value', unit: data.unit||'', read: true, write: false });
506
+ await ensureState(`${base}.readings.latestDate`, { name: 'Reading date', type: 'string', role: 'date', read: true, write: false });
507
+ await ensureState(`${base}.name`, { name: 'Meter name', type: 'string', role: 'text', read: true, write: false });
508
+ await ensureState(`${base}.unit`, { name: 'Unit', type: 'string', role: 'text', read: true, write: false });
509
+ await ensureState(`${base}.typeName`, { name: 'Meter type', type: 'string', role: 'text', read: true, write: false });
510
+ await ensureState(`${base}.readings.history`, { name: 'Reading history', type: 'string', role: 'json', read: true, write: false });
487
511
 
488
- if (dpNew) log(LVL.INFO, CAT.DATAPOINT, `Datenpunkte angelegt`, `${base}.readings.{latest,latestDate,history} + name/unit/typeName`);
512
+ if (dpNew) log(LVL.INFO, CAT.DATAPOINT, `Data points created`, `${base}.readings.{latest,latestDate,history} + name/unit/typeName`);
489
513
 
490
- await adapter.setStateAsync(`${base}.readings.latest`, { val: value, ts, ack: true });
491
- await adapter.setStateAsync(`${base}.readings.latestDate`, { val: data.readingDate, ts, ack: true });
492
- await adapter.setStateAsync(`${base}.name`, { val: data.meter, ts, ack: true });
493
- await adapter.setStateAsync(`${base}.unit`, { val: data.unit||'', ts, ack: true });
494
- await adapter.setStateAsync(`${base}.typeName`, { val: data.typeName||'', ts, ack: true });
495
-
496
- log(LVL.DEBUG, CAT.SYNC, `State geschrieben`, `metermaster.0.${base} = ${value} ${data.unit||''} | ${data.readingDate}`);
514
+ await adapter.setStateAsync(`${base}.name`, { val: data.meter, ts, ack: true });
515
+ await adapter.setStateAsync(`${base}.unit`, { val: data.unit||'', ts, ack: true });
516
+ await adapter.setStateAsync(`${base}.typeName`, { val: data.typeName||'', ts, ack: true });
497
517
 
498
518
  const histResult = await updateHistory(base, { value, unit: data.unit||'', readingDate: data.readingDate, ts });
499
- if (histResult === 'added') log(LVL.DEBUG, CAT.HISTORY, `Historie +1`, `${base} @ ${data.readingDate}`);
500
- if (histResult === 'duplicate') log(LVL.DEBUG, CAT.HISTORY, `Duplikat`, `${base} @ ${data.readingDate}`);
519
+ if (histResult === 'added') log(LVL.DEBUG, CAT.HISTORY, `History +1`, `${base} @ ${data.readingDate}`);
520
+ if (histResult === 'updated') log(LVL.INFO, CAT.HISTORY, `History updated`, `${base} @ ${data.readingDate} → ${value}`);
521
+ if (histResult === 'duplicate') log(LVL.DEBUG, CAT.HISTORY, `Duplicate`, `${base} @ ${data.readingDate}`);
501
522
 
502
- // In-Memory-Cache aktualisieren
523
+ // Cache + latest anhand chronologisch neuestem History-Eintrag
503
524
  cacheReading(house, apt, meter, value, data.unit||'', data.typeName||'', data.readingDate, ts);
525
+ const cached = receivedData[house][apt][meter];
526
+ const latestTs = new Date(cached.latestDate).getTime();
527
+ await adapter.setStateAsync(`${base}.readings.latest`, { val: cached.latest, ts: latestTs, ack: true });
528
+ await adapter.setStateAsync(`${base}.readings.latestDate`, { val: cached.latestDate, ts: latestTs, ack: true });
529
+
530
+ log(LVL.DEBUG, CAT.SYNC, `State written`, `metermaster.0.${base} = ${value} ${data.unit||''} | ${data.readingDate} (${histResult})`);
504
531
 
505
532
  readingsReceived++;
506
533
  await adapter.setStateAsync('info.lastSync', { val: Date.now(), ack: true });
507
534
  await adapter.setStateAsync('info.readingsReceived', { val: readingsReceived, ack: true });
508
- return base;
535
+ return { path: base, history: histResult };
509
536
  }
510
537
 
511
538
  // ─── Historie ─────────────────────────────────────────────────────────────────
512
539
  async function updateHistory(base, entry) {
513
540
  const stateId = `${base}.readings.history`;
514
- const keep = parseInt(adapter.config.keepHistory) || 0;
541
+ const keep = getValidatedConfig().keepHistory;
515
542
  let history = [];
516
543
  try {
517
544
  const ex = await adapter.getStateAsync(stateId);
518
545
  if (ex && ex.val) { history = JSON.parse(ex.val); if (!Array.isArray(history)) history = []; }
519
546
  } catch { history = []; }
520
- if (history.some(h => h.ts === entry.ts)) return 'duplicate';
547
+
548
+ const idx = history.findIndex(h => h.ts === entry.ts);
549
+ if (idx >= 0) {
550
+ if (history[idx].value === entry.value) return 'duplicate';
551
+ history[idx] = entry;
552
+ history.sort((a, b) => a.ts - b.ts);
553
+ await adapter.setStateAsync(stateId, { val: JSON.stringify(history), ts: entry.ts, ack: true });
554
+ return 'updated';
555
+ }
556
+
521
557
  history.push(entry);
522
558
  history.sort((a, b) => a.ts - b.ts);
523
559
  if (keep > 0 && history.length > keep) history = history.slice(history.length - keep);
@@ -583,25 +619,30 @@ function resolveMigratedRole(id, common) {
583
619
 
584
620
  async function migrateStateRoles() {
585
621
  try {
586
- const objects = await adapter.getForeignObjectsAsync('*', { type: 'state' });
587
- if (!objects) return;
622
+ const allObjects = await adapter.getAdapterObjectsAsync();
623
+ if (!allObjects) return;
588
624
  let fixed = 0;
589
- for (const [id, obj] of Object.entries(objects)) {
625
+ for (const [id, obj] of Object.entries(allObjects)) {
626
+ if (obj.type !== 'state') continue;
590
627
  const newRole = resolveMigratedRole(id, obj.common || {});
591
628
  if (newRole && obj.common.role !== newRole) {
592
629
  await adapter.extendObjectAsync(id, { common: { role: newRole } });
593
630
  fixed++;
594
631
  }
595
632
  }
596
- if (fixed) log(LVL.INFO, CAT.SYSTEM, 'State-Rollen migriert', `${fixed} Objekte`);
633
+ if (fixed) log(LVL.INFO, CAT.SYSTEM, 'State roles migrated', `${fixed} objects`);
597
634
  } catch (e) {
598
- log(LVL.WARN, CAT.SYSTEM, 'State-Rollen-Migration fehlgeschlagen', e.message);
635
+ log(LVL.WARN, CAT.SYSTEM, 'State role migration failed', e.message);
599
636
  }
600
637
  }
601
638
 
602
639
  // ─── API Endpunkte ────────────────────────────────────────────────────────────
603
640
  function serveDataJson(res) {
604
- sendJson(res, 200, { data: receivedData, receivedTotal: readingsReceived });
641
+ sendJson(res, 200, {
642
+ data: receivedData,
643
+ receivedTotal: readingsReceived,
644
+ namespace: adapter.namespace,
645
+ });
605
646
  }
606
647
  function serveLogsJson(req, res) {
607
648
  const u = new URL(req.url, 'http://localhost');
@@ -676,7 +717,7 @@ function serveDiscoverJson(res) {
676
717
  async function handleNodeConfig(mac, body, res, clientIp) {
677
718
  let data;
678
719
  try { data = JSON.parse(body); } catch {
679
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
720
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
680
721
  }
681
722
  const sid = (data.sid || '').trim();
682
723
  const label = (data.label || '').trim();
@@ -697,10 +738,10 @@ async function handleNodeConfig(mac, body, res, clientIp) {
697
738
  await adapter.setStateAsync(`nodes.${mac}.config`, { val: configStr, ack: true });
698
739
  if (!nodesCache[mac]) nodesCache[mac] = { mac };
699
740
  nodesCache[mac].config = configStr;
700
- log(LVL.INFO, CAT.NODE, `Config gesetzt`, `${mac} \u2192 ${sid || '(leer)'} | IP: ${nodesCache[mac]?.ip || '?'}`);
741
+ log(LVL.INFO, CAT.NODE, `Config set`, `${mac} \u2192 ${sid || '(empty)'} | IP: ${nodesCache[mac]?.ip || '?'}`);
701
742
  sendJson(res, 200, { ok: true, mac, config });
702
743
  } catch (e) {
703
- log(LVL.ERROR, CAT.NODE, `Config-Fehler`, `${mac}: ${e.message}`);
744
+ log(LVL.ERROR, CAT.NODE, `Config error`, `${mac}: ${e.message}`);
704
745
  sendJson(res, 500, { error: e.message });
705
746
  }
706
747
  }
@@ -711,7 +752,7 @@ async function handleNodeConfig(mac, body, res, clientIp) {
711
752
  async function handleNodeCmd(mac, body, res) {
712
753
  let data;
713
754
  try { data = JSON.parse(body); } catch {
714
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
755
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
715
756
  }
716
757
  const cmdStr = JSON.stringify(data);
717
758
  try {
@@ -720,10 +761,10 @@ async function handleNodeCmd(mac, body, res) {
720
761
  if (!nodesCache[mac]) nodesCache[mac] = { mac };
721
762
  nodesCache[mac].cmd = cmdStr;
722
763
  const keys = Object.keys(data).join(', ');
723
- log(LVL.INFO, CAT.NODE, `Befehl gesetzt`, `${mac} → ${keys}`);
764
+ log(LVL.INFO, CAT.NODE, `Command set`, `${mac} → ${keys}`);
724
765
  sendJson(res, 200, { ok: true, mac, cmd: data });
725
766
  } catch (e) {
726
- log(LVL.ERROR, CAT.NODE, `Cmd-Fehler`, `${mac}: ${e.message}`);
767
+ log(LVL.ERROR, CAT.NODE, `Cmd error`, `${mac}: ${e.message}`);
727
768
  sendJson(res, 500, { error: e.message });
728
769
  }
729
770
  }
@@ -734,7 +775,7 @@ async function handleNodeCmd(mac, body, res) {
734
775
  async function handleNodeRegister(body, res, clientIp) {
735
776
  let data;
736
777
  try { data = JSON.parse(body); } catch {
737
- sendJson(res, 400, { error: 'Ungültiges JSON' }); return;
778
+ sendJson(res, 400, { error: 'Invalid JSON' }); return;
738
779
  }
739
780
  const mac = (data.mac || '').replace(/[^A-Fa-f0-9]/g, '').toUpperCase();
740
781
  const ip = (data.ip || clientIp || '').trim();
@@ -742,7 +783,7 @@ async function handleNodeRegister(body, res, clientIp) {
742
783
  const version = (data.version || '').trim();
743
784
  const ack = data.configAck ? String(data.configAck) : null;
744
785
 
745
- if (!mac) { sendJson(res, 400, { error: 'Feld mac fehlt' }); return; }
786
+ if (!mac) { sendJson(res, 400, { error: 'Field mac missing' }); return; }
746
787
 
747
788
  const ts = Date.now();
748
789
  if (!nodesCache[mac]) nodesCache[mac] = { mac };
@@ -766,7 +807,7 @@ async function handleNodeRegister(body, res, clientIp) {
766
807
  const config = nodesCache[mac].config || null;
767
808
  sendJson(res, 200, { ok: true, mac, config });
768
809
  } catch (e) {
769
- log(LVL.ERROR, CAT.NODE, `Register-Fehler`, `${mac}: ${e.message}`);
810
+ log(LVL.ERROR, CAT.NODE, `Register error`, `${mac}: ${e.message}`);
770
811
  sendJson(res, 500, { error: e.message });
771
812
  }
772
813
  }
@@ -784,7 +825,7 @@ async function handleNodeAck(mac, body, res) {
784
825
  try {
785
826
  await ensureNodeStates(mac);
786
827
  await adapter.setStateAsync(`nodes.${mac}.configAck`, { val: ack, ack: true });
787
- log(LVL.INFO, CAT.NODE, `Config quittiert`, `${mac} | ack: ${ack}`);
828
+ log(LVL.INFO, CAT.NODE, `Config acknowledged`, `${mac} | ack: ${ack}`);
788
829
  sendJson(res, 200, { ok: true });
789
830
  } catch (e) {
790
831
  sendJson(res, 500, { error: e.message });
@@ -867,22 +908,6 @@ async function serveVersion(res) {
867
908
  }
868
909
  }
869
910
 
870
- function handleUpdate(req, res) {
871
- log(LVL.INFO, CAT.SYSTEM, 'Update gestartet', `von ${CURRENT_VERSION} → GitHub`);
872
- const cmd = `iobroker upgrade metermaster && iobroker restart metermaster.0`;
873
- exec(cmd, { timeout: 180000 }, (err, stdout, stderr) => {
874
- const out = (stdout + '\n' + stderr).trim();
875
- if (err) {
876
- log(LVL.ERROR, CAT.SYSTEM, 'Update fehlgeschlagen', err.message);
877
- sendJson(res, 500, { ok: false, error: err.message, output: out });
878
- } else {
879
- log(LVL.INFO, CAT.SYSTEM, 'Update erfolgreich', out.slice(0, 200));
880
- sendJson(res, 200, { ok: true, output: out });
881
- }
882
- });
883
- }
884
-
885
-
886
911
 
887
912
  function serveWebApp(res, port) {
888
913
 
@@ -937,7 +962,7 @@ const TYPE_ICONS = {
937
962
  };
938
963
 
939
964
  const html = `<!DOCTYPE html>
940
- <html lang="de">
965
+ <html lang="en">
941
966
  <head>
942
967
  <meta charset="UTF-8">
943
968
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -1036,6 +1061,38 @@ nav {
1036
1061
  margin-bottom: 8px; padding: 4px 10px;
1037
1062
  border-left: 3px solid var(--primary-deep); background: var(--bg-surface);
1038
1063
  border-radius: 0 6px 6px 0;
1064
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
1065
+ }
1066
+ .print-scope-btn {
1067
+ background: transparent; border: 1px solid var(--border); color: var(--text-dim);
1068
+ border-radius: 6px; padding: 2px 8px; cursor: pointer; font-size: .78em;
1069
+ white-space: nowrap;
1070
+ }
1071
+ .print-scope-btn:hover { color: var(--secondary); border-color: var(--secondary); }
1072
+ .mc-nodes {
1073
+ margin-top: 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
1074
+ }
1075
+ .mc-nodes-label {
1076
+ font-size: .72em; color: var(--text-muted); text-transform: uppercase; letter-spacing: .4px;
1077
+ margin-right: 2px;
1078
+ }
1079
+ .node-chip {
1080
+ border: 1px solid var(--border); background: var(--bg-surface2); color: var(--text-dim);
1081
+ border-radius: 999px; padding: 3px 10px; font-size: .75em; font-weight: 600;
1082
+ cursor: pointer; line-height: 1.3; display: inline-flex; align-items: center; gap: 5px;
1083
+ }
1084
+ .node-chip:hover { border-color: var(--secondary); color: var(--secondary); }
1085
+ .node-chip.active {
1086
+ background: rgba(76,175,80,.18); color: #A5D6A7; border-color: rgba(76,175,80,.45);
1087
+ }
1088
+ .node-chip.offline { opacity: .7; }
1089
+ .node-chip .dot {
1090
+ width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); display: inline-block;
1091
+ }
1092
+ .node-chip.active .dot { background: var(--accent); box-shadow: 0 0 4px var(--accent); }
1093
+ .meter-card.has-node {
1094
+ border-color: rgba(76,175,80,.35);
1095
+ box-shadow: inset 0 0 0 1px rgba(76,175,80,.12);
1039
1096
  }
1040
1097
  .meters-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); gap: 12px; margin-left: 20px; }
1041
1098
 
@@ -1076,6 +1133,11 @@ nav {
1076
1133
  }
1077
1134
  .hist-row:last-child { border: none; }
1078
1135
  .hist-val { color: var(--text); font-weight: 600; }
1136
+ .hist-edit-btn {
1137
+ background: transparent; border: 1px solid var(--border); color: var(--text-dim);
1138
+ border-radius: 6px; padding: 2px 8px; cursor: pointer; font-size: .78em;
1139
+ }
1140
+ .hist-edit-btn:hover { color: var(--secondary); border-color: var(--secondary); }
1079
1141
  .mc-consume {
1080
1142
  font-size: .76em; color: var(--accent); margin-top: 6px;
1081
1143
  display: flex; align-items: center; gap: 4px;
@@ -1333,7 +1395,7 @@ input.search {
1333
1395
  <span class="logo-sub">ioBroker Adapter &nbsp;<span style="color:var(--primary);font-size:.95em;letter-spacing:.5px">v${CURRENT_VERSION}</span></span>
1334
1396
  </div>
1335
1397
  <div class="hstats">
1336
- <div class="hstat"><span id="lbl-rx">Ablesungen</span>: <b id="st-rx">–</b></div>
1398
+ <div class="hstat"><span id="lbl-rx">Readings</span>: <b id="st-rx">–</b></div>
1337
1399
  <div class="hstat"><span id="lbl-nodes">Nodes</span>: <b id="st-nodes">–</b></div>
1338
1400
  <div class="hstat"><span id="lbl-up">Uptime</span>: <b id="st-up">–</b></div>
1339
1401
  <div class="live-dot" id="st-live">● Live</div>
@@ -1346,7 +1408,7 @@ input.search {
1346
1408
 
1347
1409
  <!-- ══ NAV ═══════════════════════════════════════════════════════════════════ -->
1348
1410
  <nav>
1349
- <button class="tab active" id="tab-data" data-tab="data" onclick="showTab('data')" >📊 Daten</button>
1411
+ <button class="tab active" id="tab-data" data-tab="data" onclick="showTab('data')" >📊 Data</button>
1350
1412
  <button class="tab" id="tab-nodes" data-tab="nodes" onclick="showTab('nodes')" >📡 Nodes</button>
1351
1413
  <button class="tab" id="tab-import" data-tab="import" onclick="showTab('import')">📥 Import</button>
1352
1414
  <button class="tab" id="tab-logs" data-tab="logs" onclick="showTab('logs')" >📋 Logs</button>
@@ -1358,7 +1420,7 @@ input.search {
1358
1420
  <div id="data-container">
1359
1421
  <div class="empty-state">
1360
1422
  <div class="ico">📡</div>
1361
- <p>Noch keine Ablesungen empfangen.<br>Starte einen Sync in der MeterMaster App oder lade ein Backup hoch.</p>
1423
+ <p id="no-data-msg">No readings received yet.<br>Start a sync in the MeterMaster app or upload a backup.</p>
1362
1424
  </div>
1363
1425
  </div>
1364
1426
  </div>
@@ -1367,15 +1429,15 @@ input.search {
1367
1429
  <div class="page" id="page-nodes">
1368
1430
  <div id="nodes-page-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px;">
1369
1431
  <div>
1370
- <h2 style="font-size:1em;color:var(--secondary);margin-bottom:2px;">📡 Registrierte ESP32 Nodes</h2>
1371
- <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>
1432
+ <h2 id="nodes-title" style="font-size:1em;color:var(--secondary);margin-bottom:2px;">📡 Registered ESP32 Nodes</h2>
1433
+ <div style="font-size:.82em;color:var(--text-dim);"><span id="lbl-nd-total">Total:</span> <b id="nd-total" style="color:var(--text);">0</b> &nbsp;|&nbsp; <span id="lbl-nd-online">Online:</span> <b id="nd-online" style="color:var(--accent);">0</b></div>
1372
1434
  </div>
1373
- <button class="ghost" onclick="fetchNodes()">↻ Aktualisieren</button>
1435
+ <button class="ghost" id="nodes-refresh-btn" onclick="fetchNodes()">↻ Refresh</button>
1374
1436
  </div>
1375
1437
  <div id="nodes-container">
1376
1438
  <div class="empty-state">
1377
1439
  <div class="ico">📡</div>
1378
- <p>Noch keine ESP32 Nodes registriert.<br>Wenn ein MeterMaster Node startet und seinen Heartbeat sendet, erscheint er hier automatisch.</p>
1440
+ <p id="no-nodes-msg">No ESP32 nodes registered yet.<br>When a MeterMaster node starts and sends its heartbeat, it will appear here automatically.</p>
1379
1441
  </div>
1380
1442
  </div>
1381
1443
  </div>
@@ -1383,8 +1445,8 @@ input.search {
1383
1445
  <!-- ══ IMPORT ════════════════════════════════════════════════════════════════ -->
1384
1446
  <div class="page" id="page-import">
1385
1447
  <div class="import-card">
1386
- <h3>📥 App-Backup importieren</h3>
1387
- <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>
1448
+ <h3 id="import-title">📥 Import app backup</h3>
1449
+ <p id="import-desc">Import a backup export from the MeterMaster app directly into the adapter. All readings are stored with their original timestamps — ideal for initial setup or importing historical data.</p>
1388
1450
 
1389
1451
  <div class="schema-box">{ "SchemaVersion": "2.0", "Source": "MeterMaster",
1390
1452
  "Apartments": [ { "Id": 1, "Name": "Westerheim" } ],
@@ -1393,24 +1455,24 @@ input.search {
1393
1455
  }</div>
1394
1456
 
1395
1457
  <div class="house-row">
1396
- <label>Hausname (ioBroker-Pfad):</label>
1397
- <input type="text" id="imp-house" value="MeinHaus" placeholder="z.B. MeinHaus">
1458
+ <label id="import-house-label">House name (ioBroker path):</label>
1459
+ <input type="text" id="imp-house" value="MyHouse" placeholder="e.g. MyHouse">
1398
1460
  </div>
1399
1461
 
1400
1462
  <div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-in').click()">
1401
1463
  <div class="dz-ico">📂</div>
1402
- <p>JSON-Datei hier ablegen oder klicken zum Auswählen</p>
1464
+ <p id="import-drop-text">Drop JSON file here or click to select</p>
1403
1465
  </div>
1404
1466
  <input type="file" id="file-in" accept=".json">
1405
1467
 
1406
1468
  <div class="preview-box" id="preview-box">
1407
- <h4>📋 Vorschau</h4>
1469
+ <h4 id="import-preview-title">📋 Preview</h4>
1408
1470
  <div id="preview-content"></div>
1409
1471
  </div>
1410
1472
 
1411
1473
  <div class="btn-row">
1412
- <button class="primary" id="imp-btn" disabled onclick="doImport()">⬆ Importieren</button>
1413
- <button class="ghost" onclick="clearImport()">✕ Zurücksetzen</button>
1474
+ <button class="primary" id="imp-btn" disabled onclick="doImport()">⬆ Import</button>
1475
+ <button class="ghost" id="import-reset-btn" onclick="clearImport()">✕ Reset</button>
1414
1476
  </div>
1415
1477
  <div class="result-box" id="imp-result"></div>
1416
1478
  </div>
@@ -1420,14 +1482,14 @@ input.search {
1420
1482
  <div class="page" id="page-logs">
1421
1483
  <div class="log-toolbar">
1422
1484
  <select id="fl">
1423
- <option value="">Alle Level</option>
1485
+ <option value="" id="fl-all">All levels</option>
1424
1486
  <option value="debug">DEBUG</option>
1425
1487
  <option value="info">INFO</option>
1426
1488
  <option value="warn">WARN</option>
1427
1489
  <option value="error">ERROR</option>
1428
1490
  </select>
1429
1491
  <select id="fc">
1430
- <option value="">Alle Kategorien</option>
1492
+ <option value="" id="fc-all">All categories</option>
1431
1493
  <option value="SYSTEM">SYSTEM</option>
1432
1494
  <option value="AUTH">AUTH</option>
1433
1495
  <option value="CONNECT">CONNECT</option>
@@ -1437,13 +1499,13 @@ input.search {
1437
1499
  <option value="IMPORT">IMPORT</option>
1438
1500
  <option value="NODE">NODE</option>
1439
1501
  </select>
1440
- <input class="search" type="text" id="ft" placeholder="Suche…">
1441
- <button class="ghost" onclick="clearLogs()">🗑 Leeren</button>
1442
- <button class="ghost" onclick="exportLogs()">⬇ Export</button>
1443
- <label class="lbl"><input type="checkbox" id="as" checked> Auto-Scroll</label>
1444
- <label class="lbl"><input type="checkbox" id="ar" checked> Live</label>
1502
+ <input class="search" type="text" id="ft" placeholder="Search…">
1503
+ <button class="ghost" id="log-clear-btn" onclick="clearLogs()">🗑 Clear</button>
1504
+ <button class="ghost" id="log-export-btn" onclick="exportLogs()">⬇ Export</button>
1505
+ <label class="lbl"><input type="checkbox" id="as" checked> <span id="lbl-auto-scroll">Auto-scroll</span></label>
1506
+ <label class="lbl"><input type="checkbox" id="ar" checked> <span id="lbl-live">Live</span></label>
1445
1507
  </div>
1446
- <div id="lc"><div class="log-empty" id="log-empty">Keine Log-Einträge vorhanden.</div></div>
1508
+ <div id="lc"><div class="log-empty" id="log-empty">No log entries yet.</div></div>
1447
1509
  </div>
1448
1510
 
1449
1511
 
@@ -1451,75 +1513,74 @@ input.search {
1451
1513
  <div class="page" id="page-system">
1452
1514
 
1453
1515
  <div class="sys-card">
1454
- <h3>📊 Statistiken</h3>
1516
+ <h3 id="sys-stats-title">📊 Statistics</h3>
1455
1517
  <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
1456
1518
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1457
1519
  <div style="font-size:1.6em;font-weight:700;color:var(--secondary);" id="sys-rx">–</div>
1458
- <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Ablesungen gesamt</div>
1520
+ <div id="sys-rx-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Total readings</div>
1459
1521
  </div>
1460
1522
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1461
1523
  <div style="font-size:1.6em;font-weight:700;color:var(--secondary);" id="sys-up">–</div>
1462
- <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Uptime</div>
1524
+ <div id="sys-up-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Uptime</div>
1463
1525
  </div>
1464
1526
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1465
1527
  <div style="font-size:1.6em;font-weight:700;color:var(--accent);" id="sys-online">–</div>
1466
- <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Nodes online</div>
1528
+ <div id="sys-online-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Nodes online</div>
1467
1529
  </div>
1468
1530
  <div style="background:var(--bg-deep);border:1px solid var(--border);border-radius:9px;padding:12px 14px;">
1469
1531
  <div style="font-size:1.6em;font-weight:700;color:var(--secondary);" id="sys-total">–</div>
1470
- <div style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Nodes gesamt</div>
1532
+ <div id="sys-total-label" style="font-size:.78em;color:var(--text-dim);margin-top:3px;">Total nodes</div>
1471
1533
  </div>
1472
1534
  </div>
1473
1535
  </div>
1474
1536
 
1475
1537
  <div class="sys-card">
1476
- <h3>🔄 Adapter-Version</h3>
1477
- <div class="ver-row"><span class="ver-label">Installiert</span> <span class="ver-val" id="sv-cur">–</span></div>
1478
- <div class="ver-row"><span class="ver-label">Aktuell (GitHub)</span><span class="ver-val" id="sv-lat">–</span></div>
1479
- <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>
1538
+ <h3 id="sys-version-title">🔄 Adapter version</h3>
1539
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-installed">Installed</span> <span class="ver-val" id="sv-cur">–</span></div>
1540
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-latest">Latest (GitHub)</span><span class="ver-val" id="sv-lat">–</span></div>
1541
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-status">Status</span> <span id="sv-status"><span class="badge-warn" id="sv-status-text">Not checked yet</span></span></div>
1480
1542
  <div class="sys-btn-row">
1481
- <button class="ghost" id="sv-check-btn">🔍 Auf Updates prüfen</button>
1482
- <button class="primary" id="sv-upd-btn" style="display:none">⬆ Update installieren</button>
1483
- <span id="sv-spin" style="display:none;font-size:.84em;color:var(--text-dim)">⏳ Bitte warten…</span>
1543
+ <button class="ghost" id="sv-check-btn">🔍 Check for updates</button>
1544
+ <span id="sv-spin" style="display:none;font-size:.84em;color:var(--text-dim)">⏳ Please wait…</span>
1484
1545
  </div>
1485
1546
  <div class="sys-out" id="sv-out"></div>
1486
1547
  </div>
1487
1548
 
1488
1549
  <div class="sys-card">
1489
- <h3>ℹ️ Adapter-Info</h3>
1490
- <div class="ver-row"><span class="ver-label">Adapter</span> <span class="ver-val">iobroker.metermaster</span></div>
1491
- <div class="ver-row"><span class="ver-label">Port</span> <span class="ver-val">${port}</span></div>
1492
- <div class="ver-row"><span class="ver-label">Repository</span>
1550
+ <h3 id="sys-info-title">ℹ️ Adapter info</h3>
1551
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-adapter">Adapter</span> <span class="ver-val">iobroker.metermaster</span></div>
1552
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-port">Port</span> <span class="ver-val">${port}</span></div>
1553
+ <div class="ver-row"><span class="ver-label" id="sys-lbl-repo">Repository</span>
1493
1554
  <a href="https://github.com/MPunktBPunkt/ioBroker.metermaster" target="_blank"
1494
1555
  style="color:var(--primary);font-size:.84em">GitHub ↗</a>
1495
1556
  </div>
1496
1557
  </div>
1497
1558
 
1498
1559
  <div class="sys-card">
1499
- <h3>🔄 Update-Befehle</h3>
1500
- <p style="font-size:.82em;color:var(--text-dim);margin:0 0 12px">
1501
- Adapter aktualisieren — Befehle in der ioBroker-Konsole ausführen:
1560
+ <h3 id="sys-cmds-title">🔄 Update commands</h3>
1561
+ <p id="sys-cmds-desc" style="font-size:.82em;color:var(--text-dim);margin:0 0 12px">
1562
+ Update the adapter — run these commands in the ioBroker console:
1502
1563
  </p>
1503
1564
  <div class="cmd-row">
1504
1565
  <code class="cmd-code">iobroker upgrade metermaster</code>
1505
- <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker upgrade metermaster" title="Kopieren">📋</button>
1566
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker upgrade metermaster" title="Copy">📋</button>
1506
1567
  </div>
1507
1568
  <div class="cmd-row" style="margin-top:8px">
1508
1569
  <code class="cmd-code">iobroker restart metermaster.0</code>
1509
- <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker restart metermaster.0" title="Kopieren">📋</button>
1570
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="iobroker restart metermaster.0" title="Copy">📋</button>
1510
1571
  </div>
1511
1572
  <div class="cmd-row" style="margin-top:8px">
1512
1573
  <code class="cmd-code">sleep 5 &amp;&amp; iobroker status metermaster.0</code>
1513
- <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="sleep 5 && iobroker status metermaster.0" title="Kopieren">📋</button>
1574
+ <button class="cmd-copy" onclick="copyCmd(this)" data-cmd="sleep 5 && iobroker status metermaster.0" title="Copy">📋</button>
1514
1575
  </div>
1515
- <p style="font-size:.78em;color:var(--text-dim);margin:10px 0 0">
1516
- 💡 Tipp: Alle drei Befehle nacheinander ausführen — warten bis jeder abgeschlossen ist.
1576
+ <p id="sys-cmds-tip" style="font-size:.78em;color:var(--text-dim);margin:10px 0 0">
1577
+ 💡 Tip: Run all three commands in sequence — wait for each to finish.
1517
1578
  </p>
1518
1579
  </div>
1519
1580
 
1520
1581
  </div>
1521
1582
 
1522
- <div id="ni" onclick="scrollLogBottom()">↓ Neue Einträge</div>
1583
+ <div id="ni" onclick="scrollLogBottom()">↓ New entries</div>
1523
1584
 
1524
1585
  <!-- ══ CHART-MODAL (vor Script — init() bindet Listener) ═════════════════════ -->
1525
1586
  <div id="chart-overlay" class="modal-overlay">
@@ -1543,6 +1604,7 @@ input.search {
1543
1604
  <div class="chart-wrap-box"><canvas id="chart-line"></canvas></div>
1544
1605
  <div class="chart-wrap-box chart-wrap-sm"><canvas id="chart-bar"></canvas></div>
1545
1606
  <div class="chart-modal-foot">
1607
+ <button class="ghost" id="chart-print-btn" title="Print">🖨 Print</button>
1546
1608
  <button class="ghost" id="chart-csv-btn">⬇ CSV</button>
1547
1609
  </div>
1548
1610
  </div>
@@ -1550,14 +1612,14 @@ input.search {
1550
1612
  <!-- ══ LOGIN-MODAL ═══════════════════════════════════════════════════════════ -->
1551
1613
  <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;">
1552
1614
  <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%;">
1553
- <div style="font-size:1.1em;font-weight:700;color:var(--secondary);margin-bottom:6px;">🔑 Anmelden</div>
1554
- <div style="font-size:.82em;color:var(--text-dim);margin-bottom:20px;">Zugangsdaten für schreibende Aktionen (Zähler zuweisen, Import).</div>
1615
+ <div id="login-title" style="font-size:1.1em;font-weight:700;color:var(--secondary);margin-bottom:6px;">🔑 Sign in</div>
1616
+ <div id="login-desc" style="font-size:.82em;color:var(--text-dim);margin-bottom:20px;">Credentials for write actions (assign meter, import).</div>
1555
1617
  <div style="margin-bottom:12px;">
1556
- <label style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Benutzername</label>
1618
+ <label id="login-user-label" style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Username</label>
1557
1619
  <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()">
1558
1620
  </div>
1559
1621
  <div style="margin-bottom:18px;">
1560
- <label style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Passwort</label>
1622
+ <label id="login-pass-label" style="font-size:.82em;color:var(--text-dim);display:block;margin-bottom:4px;">Password</label>
1561
1623
  <div style="position:relative;">
1562
1624
  <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()">
1563
1625
  <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>
@@ -1565,8 +1627,8 @@ input.search {
1565
1627
  </div>
1566
1628
  <div id="login-msg" style="font-size:.82em;color:var(--danger);min-height:18px;margin-bottom:12px;"></div>
1567
1629
  <div style="display:flex;gap:10px;">
1568
- <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>
1569
- <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>
1630
+ <button id="login-submit-btn" 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;">✓ Sign in</button>
1631
+ <button id="login-cancel-btn" 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>
1570
1632
  </div>
1571
1633
  </div>
1572
1634
  </div>
@@ -1590,7 +1652,7 @@ function hideLoginModal() {
1590
1652
  async function doLogin() {
1591
1653
  const user = document.getElementById('login-user').value.trim();
1592
1654
  const pass = document.getElementById('login-pass').value;
1593
- if (!user) { document.getElementById('login-msg').textContent = 'Benutzername fehlt'; return; }
1655
+ if (!user) { document.getElementById('login-msg').textContent = t('login_missing_user'); return; }
1594
1656
  setAuth(user, pass);
1595
1657
  try {
1596
1658
  const r = await fetch('/api/ping', { headers: { 'Authorization': _authHeader } });
@@ -1598,17 +1660,17 @@ async function doLogin() {
1598
1660
  hideLoginModal();
1599
1661
  } else {
1600
1662
  _authHeader = null;
1601
- document.getElementById('login-msg').textContent = '❌ Falsche Zugangsdaten';
1663
+ document.getElementById('login-msg').textContent = '\u274C ' + t('login_wrong');
1602
1664
  }
1603
1665
  } catch(e) {
1604
- document.getElementById('login-msg').textContent = '❌ Verbindungsfehler';
1666
+ document.getElementById('login-msg').textContent = '\u274C ' + t('login_conn_err');
1605
1667
  }
1606
1668
  }
1607
1669
  async function authFetch(url, opts) {
1608
1670
  if (!_authHeader) { showLoginModal(); return null; }
1609
1671
  const headers = Object.assign({ 'Content-Type': 'application/json', 'Authorization': _authHeader }, opts.headers || {});
1610
1672
  const r = await fetch(url, Object.assign({}, opts, { headers }));
1611
- if (r.status === 401) { _authHeader = null; showLoginModal('❌ Sitzung abgelaufen – bitte neu anmelden'); return null; }
1673
+ if (r.status === 401) { _authHeader = null; showLoginModal('\u274C ' + t('login_expired')); return null; }
1612
1674
  return r;
1613
1675
  }
1614
1676
 
@@ -1623,36 +1685,118 @@ const I18N = {
1623
1685
  readings:'Ablesungen', nodes:'Nodes', uptime:'Uptime', live:'Live', disconnected:'Getrennt',
1624
1686
  tab_data:'Daten', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1625
1687
  no_data:'Noch keine Ablesungen empfangen.<br>Starte einen Sync in der MeterMaster App oder lade ein Backup hoch.',
1626
- history:'Verlauf', since_last:'seit letzter Ablesung', days:'Tage', chart_btn:'Chart', csv_btn:'CSV',
1688
+ no_nodes:'Noch keine ESP32 Nodes registriert.<br>Wenn ein MeterMaster Node startet, erscheint er automatisch hier.',
1689
+ history:'Verlauf', history_edit:'Wert bearbeiten', history_edit_prompt:'Neuer Zählerstand:', history_saved:'Gespeichert',
1690
+ since_last:'seit letzter Ablesung', days:'Tage', chart_btn:'Chart', csv_btn:'CSV', print_btn:'Drucken',
1691
+ print_chart:'Chart drucken', print_apartment:'Wohnung drucken', print_house:'Haus drucken',
1692
+ print_latest_title:'Aktuelle Zählerstände', print_meter:'Zähler', print_value:'Stand',
1693
+ print_unit:'Einheit', print_date:'Datum', print_generated:'Erstellt',
1694
+ node_label:'Node', node_assign:'Diesen Zähler auf dem Node anzeigen',
1695
+ node_unassign:'Anzeige auf diesem Node beenden', node_none:'Keine Nodes registriert',
1627
1696
  chart_readings:'Z\u00E4hlerstand', chart_monthly:'Monatsverbrauch', chart_period_all:'Alles',
1628
1697
  chart_yearly:'Pro Jahr', chart_yearly_proj:'Hochrechnung', chart_per_year:'/Jahr',
1629
1698
  chart_yearly_hint:'basierend auf {days} {daysLabel} ({delta} {unit})',
1630
1699
  chart_no_data:'Zu wenig Daten f\u00FCr eine Grafik (mind. 2 Ablesungen n\u00F6tig).',
1631
1700
  chart_close:'Schlie\u00DFen', error:'Fehler', consume:'Verbrauch',
1632
- ago_just:'gerade eben', ago_sec:'vor {s}s', ago_min:'vor {m}min'
1701
+ ago_just:'gerade eben', ago_sec:'vor {s}s', ago_min:'vor {m}min',
1702
+ nodes_title:'Registrierte ESP32 Nodes', nodes_total:'Gesamt:', nodes_online:'Online:', nodes_refresh:'Aktualisieren',
1703
+ th_status:'Status', th_name:'Name', th_ip:'IP-Adresse', th_fw:'FW-Version', th_last_seen:'Zuletzt gesehen',
1704
+ th_assign:'Z\u00E4hler zuweisen', th_led:'LED', node_online:'Online', node_offline:'Offline',
1705
+ no_meter:'\u2014 Kein Z\u00E4hler zugewiesen \u2014', save:'Speichern', saved:'Gespeichert',
1706
+ led_on:'Ein', led_off:'Aus', led_on_title:'LED ein', led_off_title:'LED aus',
1707
+ import_title:'App-Backup importieren',
1708
+ import_desc:'Importiere einen Backup-Export aus der MeterMaster App direkt in den Adapter. Alle Ablesungen werden mit ihren originalen Zeitstempeln gespeichert \u2014 ideal f\u00FCr die erstmalige Bef\u00FCllung oder das Nachf\u00FChren historischer Daten.',
1709
+ import_house_label:'Hausname (ioBroker-Pfad):', import_house_ph:'z.B. MeinHaus',
1710
+ import_drop:'JSON-Datei hier ablegen oder klicken zum Ausw\u00E4hlen',
1711
+ import_preview:'Vorschau', import_btn:'Importieren', import_reset:'Zur\u00FCcksetzen', import_importing:'Importiere\u2026',
1712
+ prev_file:'Datei', prev_schema:'Schema', prev_apts:'Wohnungen', prev_meters:'Z\u00E4hler',
1713
+ prev_readings:'Ablesungen', prev_compat:'Kompatibel', prev_yes:'Ja', prev_no:'Nein \u2013 Pflichtfelder fehlen',
1714
+ log_all_levels:'Alle Level', log_all_cats:'Alle Kategorien', log_search_ph:'Suche\u2026',
1715
+ log_clear:'Leeren', log_export:'Export', log_auto_scroll:'Auto-Scroll', log_live:'Live',
1716
+ log_empty:'Keine Log-Eintr\u00E4ge vorhanden.', new_entries:'Neue Eintr\u00E4ge',
1717
+ sys_stats:'Statistiken', sys_readings_total:'Ablesungen gesamt', sys_uptime:'Uptime',
1718
+ sys_nodes_online:'Nodes online', sys_nodes_total:'Nodes gesamt',
1719
+ sys_version:'Adapter-Version', sys_installed:'Installiert', sys_latest:'Aktuell (GitHub)',
1720
+ sys_status:'Status', sys_check:'Auf Updates pr\u00FCfen', sys_waiting:'Bitte warten\u2026',
1721
+ sys_not_checked:'Noch nicht gepr\u00FCft', sys_no_release:'Noch kein Release',
1722
+ sys_update_avail:'Update verf\u00FCgbar', sys_up_to_date:'Aktuell',
1723
+ sys_github_err:'GitHub nicht erreichbar', sys_no_github_release:'Kein GitHub-Release vorhanden',
1724
+ sys_net_err:'Netzwerkfehler', sys_info:'Adapter-Info', sys_adapter:'Adapter',
1725
+ sys_port:'Port', sys_repo:'Repository',
1726
+ sys_cmds:'Update-Befehle',
1727
+ sys_cmds_desc:'Adapter aktualisieren \u2014 Befehle in der ioBroker-Konsole ausf\u00FChren:',
1728
+ sys_tip:'Tipp: Alle drei Befehle nacheinander ausf\u00FChren \u2014 warten bis jeder abgeschlossen ist.',
1729
+ copy:'Kopieren',
1730
+ login_title:'Anmelden',
1731
+ login_desc:'Zugangsdaten f\u00FCr schreibende Aktionen (Z\u00E4hler zuweisen, Import).',
1732
+ login_user:'Benutzername', login_pass:'Passwort', login_submit:'Anmelden',
1733
+ login_missing_user:'Benutzername fehlt', login_wrong:'Falsche Zugangsdaten',
1734
+ login_conn_err:'Verbindungsfehler', login_expired:'Sitzung abgelaufen \u2013 bitte neu anmelden',
1735
+ invalid_json:'Ung\u00FCltige JSON-Datei', network_err:'Netzwerkfehler'
1633
1736
  },
1634
1737
  en: {
1635
1738
  readings:'Readings', nodes:'Nodes', uptime:'Uptime', live:'Live', disconnected:'Disconnected',
1636
1739
  tab_data:'Data', tab_nodes:'Nodes', tab_import:'Import', tab_logs:'Logs', tab_system:'System',
1637
1740
  no_data:'No readings received yet.<br>Start a sync in the MeterMaster app or upload a backup.',
1638
- history:'History', since_last:'since last reading', days:'days', chart_btn:'Chart', csv_btn:'CSV',
1741
+ no_nodes:'No ESP32 nodes registered yet.<br>When a MeterMaster node starts and sends its heartbeat, it will appear here automatically.',
1742
+ history:'History', history_edit:'Edit value', history_edit_prompt:'New meter reading:', history_saved:'Saved',
1743
+ since_last:'since last reading', days:'days', chart_btn:'Chart', csv_btn:'CSV', print_btn:'Print',
1744
+ print_chart:'Print chart', print_apartment:'Print apartment', print_house:'Print house',
1745
+ print_latest_title:'Latest meter readings', print_meter:'Meter', print_value:'Reading',
1746
+ print_unit:'Unit', print_date:'Date', print_generated:'Generated',
1747
+ node_label:'Node', node_assign:'Show this meter on the node',
1748
+ node_unassign:'Stop showing on this node', node_none:'No nodes registered',
1639
1749
  chart_readings:'Meter reading', chart_monthly:'Monthly consumption', chart_period_all:'All',
1640
1750
  chart_yearly:'Per year', chart_yearly_proj:'Projected', chart_per_year:'/yr',
1641
1751
  chart_yearly_hint:'based on {days} {daysLabel} ({delta} {unit})',
1642
1752
  chart_no_data:'Not enough data for a chart (at least 2 readings required).',
1643
1753
  chart_close:'Close', error:'Error', consume:'Consumption',
1644
- ago_just:'just now', ago_sec:'{s}s ago', ago_min:'{m}min ago'
1754
+ ago_just:'just now', ago_sec:'{s}s ago', ago_min:'{m}min ago',
1755
+ nodes_title:'Registered ESP32 Nodes', nodes_total:'Total:', nodes_online:'Online:', nodes_refresh:'Refresh',
1756
+ th_status:'Status', th_name:'Name', th_ip:'IP address', th_fw:'FW version', th_last_seen:'Last seen',
1757
+ th_assign:'Assign meter', th_led:'LED', node_online:'Online', node_offline:'Offline',
1758
+ no_meter:'\u2014 No meter assigned \u2014', save:'Save', saved:'Saved',
1759
+ led_on:'On', led_off:'Off', led_on_title:'LED on', led_off_title:'LED off',
1760
+ import_title:'Import app backup',
1761
+ import_desc:'Import a backup export from the MeterMaster app directly into the adapter. All readings are stored with their original timestamps \u2014 ideal for initial setup or importing historical data.',
1762
+ import_house_label:'House name (ioBroker path):', import_house_ph:'e.g. MyHouse',
1763
+ import_drop:'Drop JSON file here or click to select',
1764
+ import_preview:'Preview', import_btn:'Import', import_reset:'Reset', import_importing:'Importing\u2026',
1765
+ prev_file:'File', prev_schema:'Schema', prev_apts:'Apartments', prev_meters:'Meters',
1766
+ prev_readings:'Readings', prev_compat:'Compatible', prev_yes:'Yes', prev_no:'No \u2013 required fields missing',
1767
+ log_all_levels:'All levels', log_all_cats:'All categories', log_search_ph:'Search\u2026',
1768
+ log_clear:'Clear', log_export:'Export', log_auto_scroll:'Auto-scroll', log_live:'Live',
1769
+ log_empty:'No log entries yet.', new_entries:'New entries',
1770
+ sys_stats:'Statistics', sys_readings_total:'Total readings', sys_uptime:'Uptime',
1771
+ sys_nodes_online:'Nodes online', sys_nodes_total:'Total nodes',
1772
+ sys_version:'Adapter version', sys_installed:'Installed', sys_latest:'Latest (GitHub)',
1773
+ sys_status:'Status', sys_check:'Check for updates', sys_waiting:'Please wait\u2026',
1774
+ sys_not_checked:'Not checked yet', sys_no_release:'No release yet',
1775
+ sys_update_avail:'Update available', sys_up_to_date:'Up to date',
1776
+ sys_github_err:'GitHub unreachable', sys_no_github_release:'No GitHub release available',
1777
+ sys_net_err:'Network error', sys_info:'Adapter info', sys_adapter:'Adapter',
1778
+ sys_port:'Port', sys_repo:'Repository',
1779
+ sys_cmds:'Update commands',
1780
+ sys_cmds_desc:'Update the adapter \u2014 run these commands in the ioBroker console:',
1781
+ sys_tip:'Tip: Run all three commands in sequence \u2014 wait for each to finish.',
1782
+ copy:'Copy',
1783
+ login_title:'Sign in',
1784
+ login_desc:'Credentials for write actions (assign meter, import).',
1785
+ login_user:'Username', login_pass:'Password', login_submit:'Sign in',
1786
+ login_missing_user:'Username required', login_wrong:'Invalid credentials',
1787
+ login_conn_err:'Connection error', login_expired:'Session expired \u2013 please sign in again',
1788
+ invalid_json:'Invalid JSON file', network_err:'Network error'
1645
1789
  }
1646
1790
  };
1647
1791
 
1648
- let currentLang = 'de';
1792
+ let currentLang = 'en';
1649
1793
  try {
1650
1794
  const saved = localStorage.getItem('mm-lang');
1651
1795
  currentLang = saved || (navigator.language && navigator.language.startsWith('de') ? 'de' : 'en');
1652
- } catch { currentLang = 'de'; }
1796
+ } catch { currentLang = 'en'; }
1653
1797
 
1654
1798
  function t(key, vars) {
1655
- let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.de[key]) || key;
1799
+ let s = (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || key;
1656
1800
  if (vars) Object.keys(vars).forEach(k => { s = s.replace('{'+k+'}', vars[k]); });
1657
1801
  return s;
1658
1802
  }
@@ -1663,9 +1807,59 @@ function applyI18n() {
1663
1807
  const ls = document.getElementById('lang-select');
1664
1808
  if (ls) ls.value = currentLang;
1665
1809
  const set = (id, key) => { const el = document.getElementById(id); if (el) el.textContent = t(key); };
1810
+ const setHtml = (id, key) => { const el = document.getElementById(id); if (el) el.innerHTML = t(key); };
1811
+ const setPh = (id, key) => { const el = document.getElementById(id); if (el) el.placeholder = t(key); };
1666
1812
  set('lbl-rx', 'readings');
1667
1813
  set('lbl-nodes', 'nodes');
1668
1814
  set('lbl-up', 'uptime');
1815
+ set('lbl-nd-total', 'nodes_total');
1816
+ set('lbl-nd-online', 'nodes_online');
1817
+ setHtml('no-data-msg', 'no_data');
1818
+ setHtml('no-nodes-msg', 'no_nodes');
1819
+ set('nodes-title', 'nodes_title');
1820
+ set('nodes-refresh-btn', 'nodes_refresh');
1821
+ set('import-title', 'import_title');
1822
+ set('import-desc', 'import_desc');
1823
+ set('import-house-label', 'import_house_label');
1824
+ setPh('imp-house', 'import_house_ph');
1825
+ set('import-drop-text', 'import_drop');
1826
+ set('import-preview-title', 'import_preview');
1827
+ set('imp-btn', 'import_btn');
1828
+ set('import-reset-btn', 'import_reset');
1829
+ set('fl-all', 'log_all_levels');
1830
+ set('fc-all', 'log_all_cats');
1831
+ setPh('ft', 'log_search_ph');
1832
+ set('log-clear-btn', 'log_clear');
1833
+ set('log-export-btn', 'log_export');
1834
+ set('lbl-auto-scroll', 'log_auto_scroll');
1835
+ set('lbl-live', 'log_live');
1836
+ set('log-empty', 'log_empty');
1837
+ set('ni', 'new_entries');
1838
+ set('sys-stats-title', 'sys_stats');
1839
+ set('sys-rx-label', 'sys_readings_total');
1840
+ set('sys-up-label', 'sys_uptime');
1841
+ set('sys-online-label', 'sys_nodes_online');
1842
+ set('sys-total-label', 'sys_nodes_total');
1843
+ set('sys-version-title', 'sys_version');
1844
+ set('sys-lbl-installed', 'sys_installed');
1845
+ set('sys-lbl-latest', 'sys_latest');
1846
+ set('sys-lbl-status', 'sys_status');
1847
+ set('sv-status-text', 'sys_not_checked');
1848
+ set('sv-check-btn', 'sys_check');
1849
+ set('sv-spin', 'sys_waiting');
1850
+ set('sys-info-title', 'sys_info');
1851
+ set('sys-lbl-adapter', 'sys_adapter');
1852
+ set('sys-lbl-port', 'sys_port');
1853
+ set('sys-lbl-repo', 'sys_repo');
1854
+ set('sys-cmds-title', 'sys_cmds');
1855
+ set('sys-cmds-desc', 'sys_cmds_desc');
1856
+ set('sys-cmds-tip', 'sys_tip');
1857
+ document.querySelectorAll('.cmd-copy').forEach(btn => { btn.title = t('copy'); });
1858
+ set('login-title', 'login_title');
1859
+ set('login-desc', 'login_desc');
1860
+ set('login-user-label', 'login_user');
1861
+ set('login-pass-label', 'login_pass');
1862
+ set('login-submit-btn', 'login_submit');
1669
1863
  const tabs = { 'tab-data':'tab_data', 'tab-nodes':'tab_nodes', 'tab-import':'tab_import', 'tab-logs':'tab_logs', 'tab-system':'tab_system' };
1670
1864
  Object.keys(tabs).forEach(id => {
1671
1865
  const el = document.getElementById(id);
@@ -1677,6 +1871,11 @@ function applyI18n() {
1677
1871
  if (crAll) crAll.textContent = t('chart_period_all');
1678
1872
  const csvBtn = document.getElementById('chart-csv-btn');
1679
1873
  if (csvBtn) csvBtn.textContent = '\u2B07 ' + t('csv_btn');
1874
+ const printBtn = document.getElementById('chart-print-btn');
1875
+ if (printBtn) {
1876
+ printBtn.textContent = '\uD83D\uDDA8 ' + t('print_btn');
1877
+ printBtn.title = t('print_chart');
1878
+ }
1680
1879
  const cc = document.getElementById('chart-close');
1681
1880
  if (cc) cc.title = t('chart_close');
1682
1881
  const yearlyBtn = document.getElementById('chart-yearly-toggle');
@@ -1690,6 +1889,8 @@ function setLang(lang) {
1690
1889
  applyI18n();
1691
1890
  fetchData();
1692
1891
  fetchStats();
1892
+ const nodesPage = document.getElementById('page-nodes');
1893
+ if (nodesPage && nodesPage.classList.contains('active')) fetchNodes();
1693
1894
  }
1694
1895
 
1695
1896
  let dataCacheList = [];
@@ -1947,6 +2148,88 @@ function closeChartModal() {
1947
2148
  chartCtxIdx = -1;
1948
2149
  }
1949
2150
 
2151
+ function openPrintWindow(title, bodyHtml) {
2152
+ const w = window.open('', '_blank', 'noopener,noreferrer');
2153
+ if (!w) {
2154
+ window.alert(t('error'));
2155
+ return;
2156
+ }
2157
+ const generated = new Date().toLocaleString(localeTag(), { hour12: false });
2158
+ w.document.write(
2159
+ '<!DOCTYPE html><html><head><meta charset="utf-8"><title>'+esc(title)+'</title>'+
2160
+ '<style>'+
2161
+ 'body{font-family:Segoe UI,Helvetica,Arial,sans-serif;color:#111;margin:24px;}'+
2162
+ 'h1{font-size:1.25rem;margin:0 0 4px}h2{font-size:1rem;color:#444;font-weight:600;margin:0 0 16px}'+
2163
+ '.meta{font-size:.85rem;color:#666;margin-bottom:18px}'+
2164
+ 'table{width:100%;border-collapse:collapse;font-size:.95rem}'+
2165
+ 'th,td{border-bottom:1px solid #ddd;padding:8px 6px;text-align:left}'+
2166
+ 'th{font-size:.8rem;text-transform:uppercase;letter-spacing:.04em;color:#555}'+
2167
+ 'td.num{text-align:right;font-variant-numeric:tabular-nums}'+
2168
+ 'img{max-width:100%;height:auto;margin:12px 0;border:1px solid #eee}'+
2169
+ '@media print{body{margin:12mm}button{display:none}}'+
2170
+ '</style></head><body>'+
2171
+ '<h1>'+esc(title)+'</h1>'+
2172
+ '<div class="meta">'+esc(t('print_generated'))+': '+esc(generated)+'</div>'+
2173
+ bodyHtml+
2174
+ '<script>window.onload=function(){window.focus();window.print();}<\\/script>'+
2175
+ '</body></html>'
2176
+ );
2177
+ w.document.close();
2178
+ }
2179
+
2180
+ window.printChartView = function printChartView() {
2181
+ if (chartCtxIdx < 0) return;
2182
+ const m = dataCacheList[chartCtxIdx];
2183
+ if (!m) return;
2184
+ const line = document.getElementById('chart-line');
2185
+ const bar = document.getElementById('chart-bar');
2186
+ const kpi = document.getElementById('chart-kpi');
2187
+ const kpiY = document.getElementById('chart-kpi-yearly');
2188
+ let html = '<h2>'+esc(m.house + ' \u203A ' + m.apartment)+
2189
+ (m.typeName ? ' \u00B7 ' + esc(m.typeName) : '')+'</h2>';
2190
+ if (kpi && kpi.style.display !== 'none' && kpi.textContent.trim()) {
2191
+ html += '<p>'+esc(kpi.textContent.trim())+'</p>';
2192
+ }
2193
+ if (kpiY && kpiY.style.display !== 'none' && kpiY.textContent.trim()) {
2194
+ html += '<p>'+esc(kpiY.textContent.trim())+'</p>';
2195
+ }
2196
+ if (line) html += '<img alt="'+esc(t('chart_readings'))+'" src="'+line.toDataURL('image/png')+'">';
2197
+ if (bar && bar.offsetParent !== null) {
2198
+ html += '<img alt="'+esc(t('chart_monthly'))+'" src="'+bar.toDataURL('image/png')+'">';
2199
+ }
2200
+ openPrintWindow(m.meter + ' \u2013 ' + t('print_chart'), html);
2201
+ };
2202
+
2203
+ window.printScopeReadings = function printScopeReadings(house, apartment) {
2204
+ const rows = dataCacheList.filter(m =>
2205
+ m.house === house && (!apartment || m.apartment === apartment)
2206
+ );
2207
+ if (!rows.length) return;
2208
+ const title = apartment
2209
+ ? (house + ' \u203A ' + apartment)
2210
+ : house;
2211
+ let html = '<h2>'+esc(t('print_latest_title'))+'</h2>';
2212
+ html += '<table><thead><tr>'+
2213
+ (apartment ? '' : '<th>'+esc(t('prev_apts'))+'</th>')+
2214
+ '<th>'+esc(t('print_meter'))+'</th>'+
2215
+ '<th>'+esc(t('print_value'))+'</th>'+
2216
+ '<th>'+esc(t('print_unit'))+'</th>'+
2217
+ '<th>'+esc(t('print_date'))+'</th>'+
2218
+ '</tr></thead><tbody>';
2219
+ for (const m of rows) {
2220
+ const dt = m.latestDate ? fmtDt(new Date(m.latestDate).getTime()) : '\u2013';
2221
+ html += '<tr>'+
2222
+ (apartment ? '' : '<td>'+esc(m.apartment)+'</td>')+
2223
+ '<td>'+esc(m.meter)+(m.typeName ? ' <span style="color:#777">('+esc(m.typeName)+')</span>' : '')+'</td>'+
2224
+ '<td class="num">'+(m.latest !== undefined && m.latest !== null ? esc(String(m.latest)) : '\u2013')+'</td>'+
2225
+ '<td>'+esc(m.unit || '')+'</td>'+
2226
+ '<td>'+esc(dt)+'</td>'+
2227
+ '</tr>';
2228
+ }
2229
+ html += '</tbody></table>';
2230
+ openPrintWindow(title, html);
2231
+ };
2232
+
1950
2233
  // \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
1951
2234
  window.showTab = function showTab(name) {
1952
2235
  document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@@ -2001,9 +2284,38 @@ async function fetchSysStats() {
2001
2284
  }
2002
2285
 
2003
2286
  // \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
2287
+ let nodesCacheForData = [];
2288
+ let dataNamespace = '';
2289
+
2290
+ function meterStateId(house, apt, meter) {
2291
+ return dataNamespace ? (dataNamespace + '.' + house + '.' + apt + '.' + meter + '.readings.latest') : '';
2292
+ }
2293
+
2294
+ function nodeSid(n) {
2295
+ try { return JSON.parse(n.config || '{}').sid || ''; } catch { return ''; }
2296
+ }
2297
+
2298
+ function buildNodeChipsHtml(stateId, unit, label) {
2299
+ if (!nodesCacheForData.length) return '';
2300
+ const chips = nodesCacheForData.map(n => {
2301
+ const sid = nodeSid(n);
2302
+ const active = sid && stateId && sid === stateId;
2303
+ const name = n.name || n.mac;
2304
+ const cls = 'node-chip' + (active ? ' active' : '') + (n.online ? '' : ' offline');
2305
+ const title = active ? t('node_unassign') : t('node_assign');
2306
+ return '<button type="button" class="'+cls+'" data-mac="'+esc(n.mac)+'" data-sid="'+esc(stateId)+'" data-unit="'+esc(unit||'')+'" data-label="'+esc(label||'')+'" data-active="'+(active?'1':'0')+'" title="'+esc(title)+'"><span class="dot"></span>'+esc(name)+'</button>';
2307
+ }).join('');
2308
+ return '<div class="mc-nodes"><span class="mc-nodes-label">'+esc(t('node_label'))+'</span>'+chips+'</div>';
2309
+ }
2310
+
2004
2311
  async function fetchData() {
2005
2312
  try {
2006
- const d = await fetch('/api/data').then(r => r.json());
2313
+ const [d, nodes] = await Promise.all([
2314
+ fetch('/api/data').then(r => r.json()),
2315
+ fetch('/api/nodes').then(r => r.json()).catch(() => [])
2316
+ ]);
2317
+ nodesCacheForData = Array.isArray(nodes) ? nodes : [];
2318
+ dataNamespace = d.namespace || '';
2007
2319
  const con = document.getElementById('data-container');
2008
2320
  dataCacheList = [];
2009
2321
  if (!d.data || !Object.keys(d.data).length) {
@@ -2013,9 +2325,11 @@ async function fetchData() {
2013
2325
  let html = '';
2014
2326
  let idx = 0;
2015
2327
  for (const [house, apts] of Object.entries(d.data)) {
2016
- html += '<div class="house-block"><div class="house-title">\uD83C\uDFE0 '+esc(house)+'</div>';
2328
+ html += '<div class="house-block"><div class="house-title"><span>\uD83C\uDFE0 '+esc(house)+'</span>'+
2329
+ '<button type="button" class="print-scope-btn" data-print-house="'+esc(house)+'" title="'+esc(t('print_house'))+'">\uD83D\uDDA8</button></div>';
2017
2330
  for (const [apt, meters] of Object.entries(apts)) {
2018
- html += '<div class="apt-block"><div class="apt-title">\uD83C\uDFD8 '+esc(apt)+'</div><div class="meters-grid">';
2331
+ html += '<div class="apt-block"><div class="apt-title"><span>\uD83C\uDFD8 '+esc(apt)+'</span>'+
2332
+ '<button type="button" class="print-scope-btn" data-print-house="'+esc(house)+'" data-print-apt="'+esc(apt)+'" title="'+esc(t('print_apartment'))+'">\uD83D\uDDA8</button></div><div class="meters-grid">';
2019
2333
  for (const [key, m] of Object.entries(meters)) {
2020
2334
  const cacheIdx = idx++;
2021
2335
  dataCacheList.push({ house, apartment: apt, meter: key, unit: m.unit, typeName: m.typeName, latest: m.latest, latestDate: m.latestDate, history: m.history || [] });
@@ -2023,8 +2337,16 @@ async function fetchData() {
2023
2337
  const histId = 'h-'+CSS.escape(house+apt+key);
2024
2338
  const hist = m.history || [];
2025
2339
  const delta = calcDelta(hist);
2340
+ const sid = meterStateId(house, apt, key);
2341
+ const assignedHere = nodesCacheForData.some(n => nodeSid(n) === sid);
2026
2342
  const rows = hist.slice().reverse().map(h =>
2027
- '<div class="hist-row"><span>'+esc(fmtDt(h.ts))+'</span><span class="hist-val">'+h.value+' '+esc(m.unit||'')+'</span></div>'
2343
+ '<div class="hist-row">'+
2344
+ '<span>'+esc(fmtDt(h.ts))+'</span>'+
2345
+ '<span style="display:flex;align-items:center;gap:8px;">'+
2346
+ '<span class="hist-val">'+h.value+' '+esc(m.unit||'')+'</span>'+
2347
+ '<button type="button" class="hist-edit-btn" data-idx="'+cacheIdx+'" data-ts="'+h.ts+'" title="'+esc(t('history_edit'))+'">\u270E</button>'+
2348
+ '</span>'+
2349
+ '</div>'
2028
2350
  ).join('');
2029
2351
  const consumeHtml = delta
2030
2352
  ? '<div class="mc-consume">\uD83D\uDCCA +'+fmtNum(delta.delta)+' '+esc(m.unit||'')+' '+t('since_last')+' ('+delta.days+' '+t('days')+')</div>'
@@ -2035,8 +2357,9 @@ async function fetchData() {
2035
2357
  '<button class="mc-csv-btn" data-idx="'+cacheIdx+'" title="'+esc(t('csv_btn'))+'">\u2B07 '+esc(t('csv_btn'))+'</button>'+
2036
2358
  '</div>'
2037
2359
  : '';
2360
+ const nodesHtml = buildNodeChipsHtml(sid, m.unit, key);
2038
2361
  html +=
2039
- '<div class="meter-card">'+
2362
+ '<div class="meter-card'+(assignedHere ? ' has-node' : '')+'">'+
2040
2363
  '<div class="mc-head">'+
2041
2364
  '<div class="mc-name">'+icon+' '+esc(key)+'</div>'+
2042
2365
  '<div class="mc-badge">'+esc(m.typeName||'?')+'</div>'+
@@ -2052,6 +2375,7 @@ async function fetchData() {
2052
2375
  '<div class="mc-history" id="'+histId+'">'+rows+'</div>'
2053
2376
  : '')+
2054
2377
  actionsHtml+
2378
+ nodesHtml+
2055
2379
  '</div>';
2056
2380
  }
2057
2381
  html += '</div></div>';
@@ -2065,10 +2389,80 @@ async function fetchData() {
2065
2389
  }
2066
2390
  }
2067
2391
 
2392
+ window.toggleNodeAssign = async function toggleNodeAssign(mac, sid, label, unit, currentlyActive) {
2393
+ const nextSid = currentlyActive ? '' : sid;
2394
+ try {
2395
+ const r = await authFetch('/api/nodes/'+encodeURIComponent(mac)+'/config', {
2396
+ method: 'POST',
2397
+ body: JSON.stringify({
2398
+ sid: nextSid,
2399
+ label: nextSid ? (label || '') : '',
2400
+ unit: nextSid ? (unit || '') : ''
2401
+ })
2402
+ });
2403
+ if (!r) return;
2404
+ const d = await r.json();
2405
+ if (!d.ok) {
2406
+ window.alert(t('error') + ': ' + (d.error || r.status));
2407
+ return;
2408
+ }
2409
+ await fetchData();
2410
+ if (typeof fetchNodes === 'function' && document.querySelector('.nav-item.active[data-tab="nodes"]')) {
2411
+ fetchNodes();
2412
+ }
2413
+ } catch (e) {
2414
+ window.alert(t('error') + ': ' + e.message);
2415
+ }
2416
+ }
2417
+
2068
2418
  function toggleHist(id) {
2069
2419
  const el = document.getElementById(id);
2070
2420
  if (el) el.classList.toggle('open');
2071
2421
  }
2422
+
2423
+ window.editHistoryValue = async function editHistoryValue(cacheIdx, ts) {
2424
+ const m = dataCacheList[cacheIdx];
2425
+ if (!m) return;
2426
+ const entry = (m.history || []).find(h => h.ts === ts);
2427
+ if (!entry) return;
2428
+ const raw = window.prompt(t('history_edit_prompt'), String(entry.value));
2429
+ if (raw === null) return;
2430
+ const normalized = String(raw).trim().replace(',', '.');
2431
+ const value = parseFloat(normalized);
2432
+ if (!Number.isFinite(value)) {
2433
+ window.alert(t('error') + ': value');
2434
+ return;
2435
+ }
2436
+ try {
2437
+ const r = await authFetch('/api/reading', {
2438
+ method: 'POST',
2439
+ body: JSON.stringify({
2440
+ house: m.house,
2441
+ apartment: m.apartment,
2442
+ meter: m.meter,
2443
+ value,
2444
+ unit: m.unit || '',
2445
+ typeName: m.typeName || '',
2446
+ readingDate: entry.readingDate
2447
+ })
2448
+ });
2449
+ if (!r) return;
2450
+ const d = await r.json();
2451
+ if (!d.ok) {
2452
+ window.alert(t('error') + ': ' + (d.error || r.status));
2453
+ return;
2454
+ }
2455
+ entry.value = value;
2456
+ if (m.history && m.history.length) {
2457
+ const newest = m.history.slice().sort((a, b) => a.ts - b.ts).pop();
2458
+ m.latest = newest.value;
2459
+ m.latestDate = newest.readingDate;
2460
+ }
2461
+ await fetchData();
2462
+ } catch (e) {
2463
+ window.alert(t('error') + ': ' + e.message);
2464
+ }
2465
+ }
2072
2466
  // \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
2073
2467
  let discoverCache = [];
2074
2468
 
@@ -2085,11 +2479,11 @@ window.fetchNodes = async function fetchNodes() {
2085
2479
  const elTot = document.getElementById('nd-total'); if (elTot) elTot.textContent = total;
2086
2480
  const elOn = document.getElementById('nd-online'); if (elOn) elOn.textContent = online;
2087
2481
  if (!total) {
2088
- 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>';
2482
+ con.innerHTML = '<div class="empty-state"><div class="ico">\uD83D\uDCE1</div><p>' + t('no_nodes') + '</p></div>';
2089
2483
  return;
2090
2484
  }
2091
2485
  const buildOptions = (currentSid) => {
2092
- let opts = '<option value="">\u2014 Kein Z\u00E4hler zugewiesen \u2014</option>';
2486
+ let opts = '<option value="">' + esc(t('no_meter')) + '</option>';
2093
2487
  for (const m of discover) {
2094
2488
  const lbl = m.house + ' \u203A ' + m.apartment + ' \u203A ' + m.meter + (m.latest !== undefined ? ' (' + m.latest + ' ' + esc(m.unit) + ')' : '');
2095
2489
  opts += '<option value="'+esc(m.stateId)+'"'+(m.stateId===currentSid?' selected':'')+'>'+esc(lbl)+'</option>';
@@ -2100,13 +2494,13 @@ window.fetchNodes = async function fetchNodes() {
2100
2494
  html += '<table style="width:100%;border-collapse:collapse;font-size:.85em;">';
2101
2495
  html += '<thead><tr style="text-align:left;">';
2102
2496
  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>';
2103
- html += th('Status')+th('Name')+th('IP-Adresse')+th('FW-Version')+th('Zuletzt gesehen')+th('Z\u00E4hler zuweisen')+th('LED');
2497
+ html += th(t('th_status'))+th(t('th_name'))+th(t('th_ip'))+th(t('th_fw'))+th(t('th_last_seen'))+th(t('th_assign'))+th(t('th_led'));
2104
2498
  html += '</tr></thead><tbody>';
2105
2499
  for (const n of nodes) {
2106
2500
  let currentSid = '';
2107
2501
  try { currentSid = JSON.parse(n.config||'{}').sid || ''; } catch {}
2108
- 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>';
2109
- 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>';
2502
+ 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>'+esc(t('node_online'))+'</span>';
2503
+ 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>'+esc(t('node_offline'))+'</span>';
2110
2504
  const badge = n.online ? onBadge : offBadge;
2111
2505
  const ackHint = n.configAck ? '<div style="font-size:.75em;color:var(--text-muted);margin-top:3px;">\u2713 Ack</div>' : '';
2112
2506
  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';
@@ -2117,11 +2511,11 @@ window.fetchNodes = async function fetchNodes() {
2117
2511
  html += td(ipCell);
2118
2512
  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>');
2119
2513
  html += td(esc(fmtAgo(n.lastSeen)), 'color:var(--text-dim);font-size:.82em;');
2120
- 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);
2121
- // LED-Spalte
2514
+ 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 '+esc(t('save'))+'</button><span id="smsg-'+esc(n.mac)+'"></span></div>'+ackHint);
2515
+ // LED column
2122
2516
  html += td('<div style="display:flex;flex-direction:column;gap:5px;align-items:center;">'
2123
- +'<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>'
2124
- +'<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>'
2517
+ +'<button class="ledBtn" data-mac="'+esc(n.mac)+'" data-led="1" title="'+esc(t('led_on_title'))+'" 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 '+esc(t('led_on'))+'</button>'
2518
+ +'<button class="ledBtn" data-mac="'+esc(n.mac)+'" data-led="0" title="'+esc(t('led_off_title'))+'" 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 '+esc(t('led_off'))+'</button>'
2125
2519
  +'<span id="ledmsg-'+esc(n.mac)+'" style="font-size:.75em;min-height:14px;"></span>'
2126
2520
  +'</div>');
2127
2521
  html += '</tr>';
@@ -2129,7 +2523,7 @@ window.fetchNodes = async function fetchNodes() {
2129
2523
  html += '</tbody></table></div>';
2130
2524
  con.innerHTML = html;
2131
2525
  } catch(e) {
2132
- con.innerHTML = '<div class="empty-state"><div class="ico">\u26A0</div><p>Fehler: '+esc(e.message)+'</p></div>';
2526
+ con.innerHTML = '<div class="empty-state"><div class="ico">\u26A0</div><p>'+t('error')+': '+esc(e.message)+'</p></div>';
2133
2527
  }
2134
2528
  }
2135
2529
 
@@ -2149,10 +2543,10 @@ window.saveNodeConfig = async function saveNodeConfig(mac) {
2149
2543
  if (!r) { btn.disabled = false; return; }
2150
2544
  const d = await r.json();
2151
2545
  if (d.ok) {
2152
- msg.innerHTML = '<span style="color:var(--accent);font-size:.82em;">\u2713 Gespeichert</span>';
2546
+ msg.innerHTML = '<span style="color:var(--accent);font-size:.82em;">\u2713 '+esc(t('saved'))+'</span>';
2153
2547
  window.setTimeout(() => { msg.textContent = ''; }, 3000);
2154
2548
  } else {
2155
- msg.innerHTML = '<span style="color:var(--danger);font-size:.82em;">\u2717 '+esc(d.error||'Fehler')+'</span>';
2549
+ msg.innerHTML = '<span style="color:var(--danger);font-size:.82em;">\u2717 '+esc(d.error||t('error'))+'</span>';
2156
2550
  }
2157
2551
  } catch(e) {
2158
2552
  msg.innerHTML = '<span style="color:var(--danger);font-size:.82em;">\u2717 '+esc(e.message)+'</span>';
@@ -2191,7 +2585,7 @@ function loadFile(file) {
2191
2585
  const r = new FileReader();
2192
2586
  r.onload = e => {
2193
2587
  try { importData = JSON.parse(e.target.result); showPreview(importData, file.name); }
2194
- catch { showResult('err', '\u274C Ung\u00FCltige JSON-Datei'); }
2588
+ catch { showResult('err', '\u274C ' + t('invalid_json')); }
2195
2589
  };
2196
2590
  r.readAsText(file);
2197
2591
  }
@@ -2199,12 +2593,12 @@ function loadFile(file) {
2199
2593
  function showPreview(d, fname) {
2200
2594
  const valid = !!(d.Apartments && d.Meters && d.Readings);
2201
2595
  document.getElementById('preview-content').innerHTML =
2202
- prow('Datei', esc(fname)) +
2203
- prow('Schema', d.SchemaVersion||'?') +
2204
- prow('Wohnungen', (d.Apartments||[]).length) +
2205
- prow('Z\u00E4hler', (d.Meters||[]).length) +
2206
- prow('Ablesungen', (d.Readings||[]).length) +
2207
- prow('Kompatibel', valid ? '\u2705 Ja' : '\u274C Nein \u2013 Pflichtfelder fehlen');
2596
+ prow(t('prev_file'), esc(fname)) +
2597
+ prow(t('prev_schema'), d.SchemaVersion||'?') +
2598
+ prow(t('prev_apts'), (d.Apartments||[]).length) +
2599
+ prow(t('prev_meters'), (d.Meters||[]).length) +
2600
+ prow(t('prev_readings'), (d.Readings||[]).length) +
2601
+ prow(t('prev_compat'), valid ? '\u2705 ' + t('prev_yes') : '\u274C ' + t('prev_no'));
2208
2602
  document.getElementById('preview-box').style.display = 'block';
2209
2603
  document.getElementById('imp-btn').disabled = !valid;
2210
2604
  }
@@ -2212,20 +2606,20 @@ const prow = (l,v) => '<div class="preview-row"><span>'+l+'</span><b>'+v+'</b></
2212
2606
 
2213
2607
  window.doImport = async function doImport() {
2214
2608
  if (!importData) return;
2215
- const house = document.getElementById('imp-house').value.trim() || 'MeinHaus';
2609
+ const house = document.getElementById('imp-house').value.trim() || 'MyHouse';
2216
2610
  const btn = document.getElementById('imp-btn');
2217
- btn.disabled = true; btn.textContent = '\u23F3 Importiere\u2026';
2611
+ btn.disabled = true; btn.textContent = '\u23F3 ' + t('import_importing');
2218
2612
  try {
2219
2613
  const r = await authFetch('/api/import', {
2220
2614
  method: 'POST',
2221
2615
  body: JSON.stringify({...importData, HouseName: house})
2222
2616
  });
2223
- if (!r) { btn.disabled = false; btn.textContent = '⬆ Importieren'; return; }
2617
+ if (!r) { btn.disabled = false; btn.textContent = '\u2B06 ' + t('import_btn'); return; }
2224
2618
  const d = await r.json();
2225
2619
  if (d.ok) { showResult('ok', '\u2705 '+d.summary); fetchData(); fetchStats(); }
2226
2620
  else { showResult('warn','\u26A0 '+d.summary+(d.errors.length ? '<br>'+d.errors.slice(0,5).map(esc).join('<br>') : '')); }
2227
- } catch(e) { showResult('err', '\u274C Netzwerkfehler: '+esc(e.message)); }
2228
- finally { btn.disabled = false; btn.textContent = '\u2B06 Importieren'; }
2621
+ } catch(e) { showResult('err', '\u274C ' + t('network_err') + ': '+esc(e.message)); }
2622
+ finally { btn.disabled = false; btn.textContent = '\u2B06 ' + t('import_btn'); }
2229
2623
  }
2230
2624
  function showResult(type, msg) {
2231
2625
  const rb = document.getElementById('imp-result');
@@ -2324,56 +2718,27 @@ function startLive() {
2324
2718
  async function checkVersion() {
2325
2719
  const btn = document.getElementById('sv-check-btn');
2326
2720
  const spin = document.getElementById('sv-spin');
2327
- const updBtn = document.getElementById('sv-upd-btn');
2328
2721
  btn.disabled = true; spin.style.display = 'inline';
2329
2722
  try {
2330
2723
  const d = await fetch('/api/version').then(r => r.json());
2331
2724
  document.getElementById('sv-cur').textContent = d.current || '\u2013';
2332
- document.getElementById('sv-lat').textContent = d.latest || (d.error ? '(Fehler)' : 'Noch kein Release');
2725
+ document.getElementById('sv-lat').textContent = d.latest || (d.error ? '(' + t('error') + ')' : t('sys_no_release'));
2333
2726
  const st = document.getElementById('sv-status');
2334
2727
  if (d.error) {
2335
- st.innerHTML = '<span class="badge-err">\u26A0 GitHub nicht erreichbar</span>';
2336
- updBtn.style.display = 'none';
2728
+ st.innerHTML = '<span class="badge-err">\u26A0 ' + t('sys_github_err') + '</span>';
2337
2729
  } else if (!d.latest) {
2338
- st.innerHTML = '<span class="badge-warn">\u2139 Kein GitHub-Release vorhanden</span>';
2339
- updBtn.style.display = 'none';
2730
+ st.innerHTML = '<span class="badge-warn">\u2139 ' + t('sys_no_github_release') + '</span>';
2340
2731
  } else if (d.updateAvailable) {
2341
- st.innerHTML = '<span class="badge-new">\uD83C\uDD95 Update verf\u00FCgbar</span>';
2342
- updBtn.style.display = '';
2732
+ st.innerHTML = '<span class="badge-new">\uD83C\uDD95 ' + t('sys_update_avail') + '</span>';
2343
2733
  } else {
2344
- st.innerHTML = '<span class="badge-ok">\u2713 Aktuell</span>';
2345
- updBtn.style.display = 'none';
2734
+ st.innerHTML = '<span class="badge-ok">\u2713 ' + t('sys_up_to_date') + '</span>';
2346
2735
  }
2347
2736
  } catch(e) {
2348
- document.getElementById('sv-status').innerHTML = '<span class="badge-err">\u26A0 Netzwerkfehler</span>';
2737
+ document.getElementById('sv-status').innerHTML = '<span class="badge-err">\u26A0 ' + t('sys_net_err') + '</span>';
2349
2738
  }
2350
2739
  btn.disabled = false; spin.style.display = 'none';
2351
2740
  }
2352
2741
 
2353
- async function doUpdate() {
2354
- const btn = document.getElementById('sv-upd-btn');
2355
- const spin = document.getElementById('sv-spin');
2356
- const outBox = document.getElementById('sv-out');
2357
- if (!confirm('Update installieren und Adapter neu starten?')) return;
2358
- btn.style.display = 'none'; spin.style.display = 'inline';
2359
- outBox.style.display = 'block'; outBox.textContent = '\u23F3 Update l\u00E4uft\u2026';
2360
- try {
2361
- const r = await fetch('/api/update', { method: 'POST' });
2362
- const d = await r.json();
2363
- if (d.ok) {
2364
- outBox.textContent = '\u2705 Update erfolgreich.' + String.fromCharCode(10) + 'Adapter wird neu gestartet\u2026' + String.fromCharCode(10,10) + (d.output||'');
2365
- document.getElementById('sv-status').innerHTML = '<span class="badge-ok">\u2713 Neu gestartet</span>';
2366
- window.setTimeout(() => { outBox.textContent += String.fromCharCode(10) + '\u27F3 Seite wird neu geladen\u2026'; location.reload(); }, 8000);
2367
- } else {
2368
- outBox.textContent = '\u274C Fehler:' + String.fromCharCode(10) + (d.error||'') + String.fromCharCode(10,10) + (d.output||'');
2369
- btn.style.display = ''; spin.style.display = 'none';
2370
- }
2371
- } catch(e) {
2372
- outBox.textContent = '\u274C Netzwerkfehler: ' + e.message;
2373
- btn.style.display = ''; spin.style.display = 'none';
2374
- }
2375
- }
2376
-
2377
2742
  // \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
2378
2743
  // ── Copy-to-Clipboard ─────────────────────────────────────────────────────────
2379
2744
  window.copyCmd = function copyCmd(btn) {
@@ -2416,15 +2781,35 @@ function initTabs() {
2416
2781
  // System-Tab Buttons
2417
2782
  const chkBtn = document.getElementById('sv-check-btn');
2418
2783
  if (chkBtn) chkBtn.addEventListener('click', checkVersion);
2419
- const updBtn = document.getElementById('sv-upd-btn');
2420
- if (updBtn) updBtn.addEventListener('click', doUpdate);
2421
2784
 
2422
- // Verlauf-Toggle via Event Delegation (kein onclick-Attribut nötig)
2785
+ // History toggle via event delegation
2423
2786
  document.addEventListener('click', e => {
2424
2787
  const btn = e.target.closest('.mc-hist-toggle');
2425
2788
  if (btn && btn.dataset.hist) toggleHist(btn.dataset.hist);
2426
2789
  });
2427
2790
 
2791
+ // History value edit
2792
+ document.addEventListener('click', e => {
2793
+ const editBtn = e.target.closest('.hist-edit-btn');
2794
+ if (!editBtn) return;
2795
+ const idx = parseInt(editBtn.dataset.idx, 10);
2796
+ const ts = parseInt(editBtn.dataset.ts, 10);
2797
+ editHistoryValue(idx, ts);
2798
+ });
2799
+
2800
+ // Node chips on meter cards: assign / unassign
2801
+ document.addEventListener('click', e => {
2802
+ const chip = e.target.closest('.node-chip');
2803
+ if (!chip) return;
2804
+ toggleNodeAssign(
2805
+ chip.dataset.mac,
2806
+ chip.dataset.sid,
2807
+ chip.dataset.label || '',
2808
+ chip.dataset.unit || '',
2809
+ chip.dataset.active === '1'
2810
+ );
2811
+ });
2812
+
2428
2813
  // Chart + CSV + Chart-Modal via Event Delegation
2429
2814
  document.addEventListener('click', e => {
2430
2815
  const chartBtn = e.target.closest('.mc-chart-btn');
@@ -2434,6 +2819,12 @@ function initTabs() {
2434
2819
  if (e.target.closest('.chart-close')) { closeChartModal(); return; }
2435
2820
  if (e.target.id === 'chart-overlay') { closeChartModal(); return; }
2436
2821
  if (e.target.closest('#chart-csv-btn')) { if (chartCtxIdx >= 0) exportMeterCsv(chartCtxIdx); return; }
2822
+ if (e.target.closest('#chart-print-btn')) { printChartView(); return; }
2823
+ const scopeBtn = e.target.closest('.print-scope-btn');
2824
+ if (scopeBtn) {
2825
+ printScopeReadings(scopeBtn.dataset.printHouse, scopeBtn.dataset.printApt || null);
2826
+ return;
2827
+ }
2437
2828
  const rangeBtn = e.target.closest('.chart-range[data-months]');
2438
2829
  if (rangeBtn) {
2439
2830
  chartRangeMonths = parseInt(rangeBtn.dataset.months, 10) || 0;